From 3f0be5cd32c1ff892ed887e60409c1a3b37a9238 Mon Sep 17 00:00:00 2001 From: Tavin Cole Date: Tue, 13 Jun 2023 09:42:31 +0100 Subject: [PATCH] Splits with merge option for dock panels (#582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * dock insert modes allowing reuse of existing splits * splits and merges in dockpanel example * revise description of merge modes * yarn ran api * rudimentary tests for addWidget() * ¯\_(ツ)_/¯ --- examples/example-dockpanel/src/index.ts | 80 +++++++++++++++++++ packages/widgets/src/docklayout.ts | 70 ++++++++++++++-- packages/widgets/tests/src/docklayout.spec.ts | 48 ++++++++++- review/api/widgets.api.md | 20 +++++ 4 files changed, 210 insertions(+), 8 deletions(-) diff --git a/examples/example-dockpanel/src/index.ts b/examples/example-dockpanel/src/index.ts index c97cd2333..7613dd54a 100644 --- a/examples/example-dockpanel/src/index.ts +++ b/examples/example-dockpanel/src/index.ts @@ -64,6 +64,8 @@ function createMenu(): Menu { } class ContentWidget extends Widget { + static menuFocus: ContentWidget | null; + static createNode(): HTMLElement { let node = document.createElement('div'); let content = document.createElement('div'); @@ -82,6 +84,10 @@ class ContentWidget extends Widget { this.title.label = name; this.title.closable = true; this.title.caption = `Long description for: ${name}`; + let widget = this; + this.node.addEventListener('contextmenu', (event: MouseEvent) => { + ContentWidget.menuFocus = widget; + }); } get inputNode(): HTMLInputElement { @@ -93,6 +99,12 @@ class ContentWidget extends Widget { this.inputNode.focus(); } } + + protected onBeforeDetach(msg: Message): void { + if (ContentWidget.menuFocus === this) { + ContentWidget.menuFocus = null; + } + } } function main(): void { @@ -322,6 +334,7 @@ function main(): void { let contextMenu = new ContextMenu({ commands }); document.addEventListener('contextmenu', (event: MouseEvent) => { + if (event.shiftKey) return; if (contextMenu.open(event)) { event.preventDefault(); } @@ -397,6 +410,56 @@ function main(): void { sender.addWidget(w, { ref: arg.titles[0].owner }); }); + let doSplit = (mode: DockPanel.InsertMode) => { + let ref = ContentWidget.menuFocus; + if (ref) { + let name = ref.title.label; + let widget = new ContentWidget(name); + widget.inputNode.value = `${name} ${mode}`; + dock.addWidget(widget, { mode: mode, ref: ref }); + } + }; + + commands.addCommand('example:split-left', { + label: 'Split left', + execute: () => doSplit('split-left') + }); + + commands.addCommand('example:split-right', { + label: 'Split right', + execute: () => doSplit('split-right') + }); + + commands.addCommand('example:split-top', { + label: 'Split top', + execute: () => doSplit('split-top') + }); + + commands.addCommand('example:split-bottom', { + label: 'Split bottom', + execute: () => doSplit('split-bottom') + }); + + commands.addCommand('example:merge-left', { + label: 'Merge left', + execute: () => doSplit('merge-left') + }); + + commands.addCommand('example:merge-right', { + label: 'Merge right', + execute: () => doSplit('merge-right') + }); + + commands.addCommand('example:merge-top', { + label: 'Merge top', + execute: () => doSplit('merge-top') + }); + + commands.addCommand('example:merge-bottom', { + label: 'Merge bottom', + execute: () => doSplit('merge-bottom') + }); + let savedLayouts: DockPanel.ILayoutConfig[] = []; commands.addCommand('example:add-button', { @@ -408,7 +471,24 @@ function main(): void { console.log('Toggle add button'); } }); + contextMenu.addItem({ command: 'example:add-button', selector: '.content' }); + let contextSub1 = new Menu({ commands }); + contextSub1.title.label = 'Splitting'; + contextSub1.addItem({ command: 'example:split-left' }); + contextSub1.addItem({ command: 'example:split-right' }); + contextSub1.addItem({ command: 'example:split-top' }); + contextSub1.addItem({ command: 'example:split-bottom' }); + contextSub1.addItem({ type: 'separator' }); + contextSub1.addItem({ command: 'example:merge-left' }); + contextSub1.addItem({ command: 'example:merge-right' }); + contextSub1.addItem({ command: 'example:merge-top' }); + contextSub1.addItem({ command: 'example:merge-bottom' }); + contextMenu.addItem({ + type: 'submenu', + submenu: contextSub1, + selector: '.content' + }); commands.addCommand('save-dock-layout', { label: 'Save Layout', diff --git a/packages/widgets/src/docklayout.ts b/packages/widgets/src/docklayout.ts index 895ac6bab..9f75c4ea4 100644 --- a/packages/widgets/src/docklayout.ts +++ b/packages/widgets/src/docklayout.ts @@ -409,6 +409,18 @@ export class DockLayout extends Layout { case 'split-bottom': this._insertSplit(widget, ref, refNode, 'vertical', true); break; + case 'merge-top': + this._insertSplit(widget, ref, refNode, 'vertical', false, true); + break; + case 'merge-left': + this._insertSplit(widget, ref, refNode, 'horizontal', false, true); + break; + case 'merge-right': + this._insertSplit(widget, ref, refNode, 'horizontal', true, true); + break; + case 'merge-bottom': + this._insertSplit(widget, ref, refNode, 'vertical', true, true); + break; } // Do nothing else if there is no parent widget. @@ -791,6 +803,16 @@ export class DockLayout extends Layout { parentNode.syncHandles(); } + /** + * Create the tab layout node to hold the widget. + */ + private _createTabNode(widget: Widget): Private.TabLayoutNode { + let tabNode = new Private.TabLayoutNode(this._createTabBar()); + tabNode.tabBar.addTab(widget.title); + Private.addAria(widget, tabNode.tabBar); + return tabNode; + } + /** * Insert a widget next to an existing tab. * @@ -872,7 +894,8 @@ export class DockLayout extends Layout { ref: Widget | null, refNode: Private.TabLayoutNode | null, orientation: Private.Orientation, - after: boolean + after: boolean, + merge: boolean = false ): void { // Do nothing if there is no effective split. if (widget === ref && refNode && refNode.tabBar.titles.length === 1) { @@ -882,14 +905,9 @@ export class DockLayout extends Layout { // Ensure the widget is removed from the current layout. this._removeWidget(widget); - // Create the tab layout node to hold the widget. - let tabNode = new Private.TabLayoutNode(this._createTabBar()); - tabNode.tabBar.addTab(widget.title); - Private.addAria(widget, tabNode.tabBar); - // Set the root if it does not exist. if (!this._root) { - this._root = tabNode; + this._root = this._createTabNode(widget); return; } @@ -908,6 +926,7 @@ export class DockLayout extends Layout { let sizer = Private.createSizer(refNode ? 1 : Private.GOLDEN_RATIO); // Insert the tab node sized to the golden ratio. + let tabNode = this._createTabNode(widget); ArrayExt.insert(root.children, i, tabNode); ArrayExt.insert(root.sizers, i, sizer); ArrayExt.insert(root.handles, i, this._createHandle()); @@ -930,6 +949,17 @@ export class DockLayout extends Layout { // Find the index of the ref node. let i = splitNode.children.indexOf(refNode); + // Conditionally reuse a tab layout found in the wanted position. + if (merge) { + let j = i + (after ? 1 : -1); + let sibling = splitNode.children[j]; + if (sibling instanceof Private.TabLayoutNode) { + this._insertTab(widget, null, sibling, true); + ++sibling.tabBar.currentIndex; + return; + } + } + // Normalize the split node. splitNode.normalizeSizes(); @@ -938,6 +968,7 @@ export class DockLayout extends Layout { // Insert the tab node sized to the other half. let j = i + (after ? 1 : 0); + let tabNode = this._createTabNode(widget); ArrayExt.insert(splitNode.children, j, tabNode); ArrayExt.insert(splitNode.sizers, j, Private.createSizer(s)); ArrayExt.insert(splitNode.handles, j, this._createHandle()); @@ -963,6 +994,7 @@ export class DockLayout extends Layout { // Add the tab node sized to the other half. let j = after ? 1 : 0; + let tabNode = this._createTabNode(widget); ArrayExt.insert(childNode.children, j, tabNode); ArrayExt.insert(childNode.sizers, j, Private.createSizer(0.5)); ArrayExt.insert(childNode.handles, j, this._createHandle()); @@ -1244,6 +1276,30 @@ export namespace DockLayout { */ | 'split-bottom' + /** + * Like `split-top` but if a tab layout exists above the reference widget, + * it behaves like `tab-after` with reference to that instead. + */ + | 'merge-top' + + /** + * Like `split-left` but if a tab layout exists left of the reference widget, + * it behaves like `tab-after` with reference to that instead. + */ + | 'merge-left' + + /** + * Like `split-right` but if a tab layout exists right of the reference widget, + * it behaves like `tab-after` with reference to that instead. + */ + | 'merge-right' + + /** + * Like `split-bottom` but if a tab layout exists below the reference widget, + * it behaves like `tab-after` with reference to that instead. + */ + | 'merge-bottom' + /** * The tab position before the reference widget. * diff --git a/packages/widgets/tests/src/docklayout.spec.ts b/packages/widgets/tests/src/docklayout.spec.ts index 196215a6d..84829a80a 100644 --- a/packages/widgets/tests/src/docklayout.spec.ts +++ b/packages/widgets/tests/src/docklayout.spec.ts @@ -244,7 +244,53 @@ describe('@lumino/widgets', () => { it.skip('should have some tests'); }); describe('#addWidget()', () => { - it.skip('should have some tests'); + type Mode = DockLayout.InsertMode; + it('should add widgets', () => { + const layout = new DockLayout({ renderer }); + const widget = new Widget(); + layout.addWidget(widget); + expect(layout['_root'].tabBar).to.exist; + expect(layout['_root'].tabBar.titles[0].owner).to.equal(widget); + }); + it('should add splits', () => { + for (let i = 0, d = ['right', 'left', 'bottom', 'top']; i < 4; ++i) { + const layout = new DockLayout({ renderer }); + const w1 = new Widget(); + const w2 = new Widget(); + layout.addWidget(w1); + layout.addWidget(w2, { ref: w1, mode: `split-${d[i]}` }); + expect(layout['_root'].children).to.exist; + expect(layout['_root'].children.length).to.equal(2); + expect( + layout['_root'].children[0 + (i % 2)].tabBar.titles[0].owner + ).to.equal(w1); + expect( + layout['_root'].children[1 - (i % 2)].tabBar.titles[0].owner + ).to.equal(w2); + } + }); + it('should merge splits', () => { + for (let i = 0, d = ['right', 'left', 'bottom', 'top']; i < 4; ++i) { + const layout = new DockLayout({ renderer }); + const w1 = new Widget(); + const w2 = new Widget(); + const w3 = new Widget(); + layout.addWidget(w1); + layout.addWidget(w2, { ref: w1, mode: `merge-${d[i]}` }); + layout.addWidget(w3, { ref: w1, mode: `merge-${d[i]}` }); + expect(layout['_root'].children).to.exist; + expect(layout['_root'].children.length).to.equal(2); + expect( + layout['_root'].children[0 + (i % 2)].tabBar.titles[0].owner + ).to.equal(w1); + expect( + layout['_root'].children[1 - (i % 2)].tabBar.titles[0].owner + ).to.equal(w2); + expect( + layout['_root'].children[1 - (i % 2)].tabBar.titles[1].owner + ).to.equal(w3); + } + }); }); describe('#removeWidget()', () => { it.skip('should have some tests'); diff --git a/review/api/widgets.api.md b/review/api/widgets.api.md index 626692a00..4f026094e 100644 --- a/review/api/widgets.api.md +++ b/review/api/widgets.api.md @@ -360,6 +360,26 @@ export namespace DockLayout { */ | 'split-bottom' /** + * Like `split-top` but if a tab layout exists above the reference widget, + * it behaves like `tab-after` with reference to that instead. + */ + | 'merge-top' + /** + * Like `split-left` but if a tab layout exists left of the reference widget, + * it behaves like `tab-after` with reference to that instead. + */ + | 'merge-left' + /** + * Like `split-right` but if a tab layout exists right of the reference widget, + * it behaves like `tab-after` with reference to that instead. + */ + | 'merge-right' + /** + * Like `split-bottom` but if a tab layout exists below the reference widget, + * it behaves like `tab-after` with reference to that instead. + */ + | 'merge-bottom' + /** * The tab position before the reference widget. * * The widget will be added as a tab before the reference widget.