From 3c09ad9df427039b5e9080eba0a55f64877bbc80 Mon Sep 17 00:00:00 2001 From: mathuo <6710312+mathuo@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:23:03 +0100 Subject: [PATCH] feat(core): add LayoutPriority.Fill for fixed-sibling / fill layouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `LayoutPriority.Fill` so a view (standalone gridview) or group (dockview) absorbs all surplus/deficit space while its siblings keep their fixed sizes — the common "central content fills, side panels stay put" layout that proportional priorities cannot express. Closes #1378. - splitview: fill views absorb space in layout/relayout/distributeEmptySpace; multiple fills split the surplus equally; hidden fills fall back to normal - gridview: Fill propagates up the branch tree (highest precedence); the runtime priority setter forces a full relayout so propagation takes effect - dockview: group-level priority via addGroup({ priority }), group.api.priority, and toJSON/fromJSON round-trip Backwards compatible: every behaviour change is gated on a fill view being present, so existing layouts and serialized state are unchanged; the serialized `priority` key only appears when a group opts in. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dockview/dockviewComponent.spec.ts | 105 +++++++++- .../gridview/gridviewComponent.spec.ts | 48 ++++- .../src/__tests__/splitview/splitview.spec.ts | 185 ++++++++++++++++++ .../src/api/dockviewGroupPanelApi.ts | 20 ++ .../src/dockview/dockviewComponent.ts | 6 +- .../src/dockview/dockviewGroupPanelModel.ts | 11 ++ .../dockview-core/src/gridview/branchNode.ts | 4 +- .../src/gridview/gridviewPanel.ts | 18 ++ .../dockview-core/src/splitview/splitview.ts | 81 +++++++- 9 files changed, 466 insertions(+), 12 deletions(-) diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts index 27a7f9db88..eced5896ab 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts @@ -5,7 +5,7 @@ import { ITabRenderer, } from '../../dockview/types'; import { PanelUpdateEvent } from '../../panel/types'; -import { Orientation } from '../../splitview/splitview'; +import { LayoutPriority, Orientation } from '../../splitview/splitview'; import { CompositeDisposable } from '../../lifecycle'; import { Emitter } from '../../events'; import { DockviewPanel, IDockviewPanel } from '../../dockview/dockviewPanel'; @@ -128,6 +128,109 @@ describe('dockviewComponent', () => { }); }); + test('group LayoutPriority is settable via addGroup and round-trips through JSON', () => { + const create = (el: HTMLElement) => + new DockviewComponent(el, { + createComponent(options) { + switch (options.name) { + case 'default': + return new PanelContentPartTest( + options.id, + options.name + ); + default: + throw new Error(`unsupported`); + } + }, + }); + + const containerA = document.createElement('div'); + const dv = create(containerA); + dv.layout(1000, 500); + + // priority set at group-creation time reaches the group (grid leaf) + dv.addPanel({ id: 'p1', component: 'default' }); + const fillGroup = dv.addGroup({ + direction: 'right', + priority: LayoutPriority.Fill, + }); + dv.addPanel({ + id: 'p2', + component: 'default', + position: { referenceGroup: fillGroup }, + }); + + expect(fillGroup.priority).toBe(LayoutPriority.Fill); + expect(fillGroup.api.priority).toBe(LayoutPriority.Fill); + + // it is present in the serialized group state + const state = dv.toJSON(); + expect(JSON.stringify(state)).toContain('"priority":"fill"'); + + // and restored when loaded into a fresh component + const containerB = document.createElement('div'); + const dv2 = create(containerB); + dv2.layout(1000, 500); + dv2.fromJSON(state); + + const restored = dv2.groups.find( + (g) => g.priority === LayoutPriority.Fill + ); + expect(restored).toBeDefined(); + expect(restored!.panels.map((p) => p.id)).toContain('p2'); + + dv.dispose(); + dv2.dispose(); + }); + + test('group LayoutPriority is settable at runtime via the group api', () => { + dockview.layout(1000, 500); + + const panel = dockview.addPanel({ id: 'p1', component: 'default' }); + const group = panel.group; + + expect(group.api.priority).toBeUndefined(); + + group.api.priority = LayoutPriority.Fill; + expect(group.api.priority).toBe(LayoutPriority.Fill); + expect(group.priority).toBe(LayoutPriority.Fill); + }); + + test('a group with LayoutPriority.Fill absorbs resize while siblings stay fixed', () => { + dockview.layout(600, 1000); + + const p1 = dockview.addPanel({ id: 'p1', component: 'default' }); + const p2 = dockview.addPanel({ + id: 'p2', + component: 'default', + position: { direction: 'right' }, + }); + const p3 = dockview.addPanel({ + id: 'p3', + component: 'default', + position: { direction: 'right' }, + }); + + expect(p1.api.width).toBe(200); + expect(p2.api.width).toBe(200); + expect(p3.api.width).toBe(200); + + // make the middle group the fill group (runtime — forces a relayout) + p2.api.group.api.priority = LayoutPriority.Fill; + + // grow the container: only the fill group grows; siblings stay fixed + dockview.layout(900, 1000); + expect(p1.api.width).toBe(200); + expect(p3.api.width).toBe(200); + expect(p2.api.width).toBe(500); + + // shrink the container: only the fill group shrinks + dockview.layout(700, 1000); + expect(p1.api.width).toBe(200); + expect(p3.api.width).toBe(200); + expect(p2.api.width).toBe(300); + }); + test('update className', () => { dockview = new DockviewComponent(container, { createComponent(options) { diff --git a/packages/dockview-core/src/__tests__/gridview/gridviewComponent.spec.ts b/packages/dockview-core/src/__tests__/gridview/gridviewComponent.spec.ts index 9a8b85aa40..d7cd33d173 100644 --- a/packages/dockview-core/src/__tests__/gridview/gridviewComponent.spec.ts +++ b/packages/dockview-core/src/__tests__/gridview/gridviewComponent.spec.ts @@ -2,7 +2,7 @@ import { GridviewComponent } from '../../gridview/gridviewComponent'; import { GridviewPanel } from '../../gridview/gridviewPanel'; import { CompositeDisposable } from '../../lifecycle'; import { IFrameworkPart } from '../../panel/types'; -import { Orientation } from '../../splitview/splitview'; +import { LayoutPriority, Orientation } from '../../splitview/splitview'; class TestGridview extends GridviewPanel { constructor(id: string, componentName: string) { @@ -80,6 +80,52 @@ describe('gridview', () => { expect(panel?.api.isVisible).toBeTruthy(); }); + test('LayoutPriority.Fill round-trips through toJSON/fromJSON', () => { + const create = () => + new GridviewComponent(container, { + proportionalLayout: false, + orientation: Orientation.VERTICAL, + createComponent: (options) => { + switch (options.name) { + case 'default': + return new TestGridview(options.id, options.name); + default: + throw new Error('unsupported'); + } + }, + }); + + const gridview = create(); + gridview.layout(800, 400); + + gridview.addPanel({ + id: 'content', + component: 'default', + priority: LayoutPriority.Fill, + }); + + expect(gridview.getPanel('content')!.priority).toBe( + LayoutPriority.Fill + ); + + const state = JSON.parse(JSON.stringify(gridview.toJSON())); + + // the fill priority is present in the serialized output + expect(JSON.stringify(state)).toContain('"priority":"fill"'); + + // and it is restored when loaded into a fresh component + const restored = create(); + restored.layout(800, 400); + restored.fromJSON(state); + + expect(restored.getPanel('content')!.priority).toBe( + LayoutPriority.Fill + ); + + gridview.dispose(); + restored.dispose(); + }); + test('remove panel', () => { const gridview = new GridviewComponent(container, { proportionalLayout: false, diff --git a/packages/dockview-core/src/__tests__/splitview/splitview.spec.ts b/packages/dockview-core/src/__tests__/splitview/splitview.spec.ts index f020ce1278..02c61f2fa6 100644 --- a/packages/dockview-core/src/__tests__/splitview/splitview.spec.ts +++ b/packages/dockview-core/src/__tests__/splitview/splitview.spec.ts @@ -88,6 +88,191 @@ describe('splitview', () => { jest.clearAllMocks(); }); + test('LayoutPriority.Fill absorbs surplus/deficit while siblings stay fixed', () => { + const splitview = new Splitview(container, { + orientation: Orientation.HORIZONTAL, + proportionalLayout: true, + }); + + splitview.layout(1000, 500); + + const side1 = new Testview(50, Number.POSITIVE_INFINITY); + const fill = new Testview( + 50, + Number.POSITIVE_INFINITY, + LayoutPriority.Fill + ); + const side2 = new Testview(50, Number.POSITIVE_INFINITY); + + splitview.addView(side1); // index 0 + splitview.addView(fill); // index 1 + splitview.addView(side2); // index 2 + + // pin the two side panels to fixed sizes; the fill view compensates + splitview.resizeView(0, 200); + splitview.resizeView(2, 150); + + expect(splitview.getViewSize(0)).toBe(200); + expect(splitview.getViewSize(2)).toBe(150); + expect(splitview.getViewSize(1)).toBe(650); // 1000 - 200 - 150 + + // grow the container: only the fill view grows + splitview.layout(1200, 500); + expect(splitview.getViewSize(0)).toBe(200); + expect(splitview.getViewSize(2)).toBe(150); + expect(splitview.getViewSize(1)).toBe(850); + + // shrink the container: only the fill view shrinks + splitview.layout(700, 500); + expect(splitview.getViewSize(0)).toBe(200); + expect(splitview.getViewSize(2)).toBe(150); + expect(splitview.getViewSize(1)).toBe(350); + + splitview.dispose(); + }); + + test('LayoutPriority.Fill absorbs add/remove of siblings', () => { + const splitview = new Splitview(container, { + orientation: Orientation.HORIZONTAL, + proportionalLayout: true, + }); + + splitview.layout(1000, 500); + + const side1 = new Testview(50, Number.POSITIVE_INFINITY); + const fill = new Testview( + 50, + Number.POSITIVE_INFINITY, + LayoutPriority.Fill + ); + + splitview.addView(side1); // index 0 + splitview.addView(fill); // index 1 + splitview.resizeView(0, 200); + + expect(splitview.getViewSize(0)).toBe(200); + expect(splitview.getViewSize(1)).toBe(800); + + // add another fixed sibling — the fill view yields the space, not the + // existing side panel + const side2 = new Testview(50, Number.POSITIVE_INFINITY); + splitview.addView(side2, 250, 2); + + expect(splitview.getViewSize(0)).toBe(200); + expect(splitview.getViewSize(2)).toBe(250); + expect(splitview.getViewSize(1)).toBe(550); // 1000 - 200 - 250 + + // remove the new sibling — the fill view reclaims the space + splitview.removeView(2); + expect(splitview.getViewSize(0)).toBe(200); + expect(splitview.getViewSize(1)).toBe(800); + + splitview.dispose(); + }); + + test('multiple LayoutPriority.Fill views share the surplus equally', () => { + const splitview = new Splitview(container, { + orientation: Orientation.HORIZONTAL, + proportionalLayout: true, + }); + + splitview.layout(1000, 500); + + const fill1 = new Testview( + 50, + Number.POSITIVE_INFINITY, + LayoutPriority.Fill + ); + const fill2 = new Testview( + 50, + Number.POSITIVE_INFINITY, + LayoutPriority.Fill + ); + + splitview.addView(fill1); // index 0 + splitview.addView(fill2); // index 1 + + // both fill views split the container equally + expect(splitview.getViewSize(0)).toBe(500); + expect(splitview.getViewSize(1)).toBe(500); + + // the surplus from growing the container is shared equally + splitview.layout(1200, 500); + expect(splitview.getViewSize(0)).toBe(600); + expect(splitview.getViewSize(1)).toBe(600); + + // and the deficit from shrinking it + splitview.layout(800, 500); + expect(splitview.getViewSize(0)).toBe(400); + expect(splitview.getViewSize(1)).toBe(400); + + splitview.dispose(); + }); + + test('LayoutPriority.Fill takes precedence over High (High stays fixed)', () => { + const splitview = new Splitview(container, { + orientation: Orientation.HORIZONTAL, + proportionalLayout: true, + }); + + splitview.layout(1000, 500); + + const high = new Testview( + 50, + Number.POSITIVE_INFINITY, + LayoutPriority.High + ); + const fill = new Testview( + 50, + Number.POSITIVE_INFINITY, + LayoutPriority.Fill + ); + + splitview.addView(high); // index 0 + splitview.addView(fill); // index 1 + + splitview.resizeView(0, 300); + expect(splitview.getViewSize(0)).toBe(300); + expect(splitview.getViewSize(1)).toBe(700); + + // grow: the fill view absorbs everything even though `High` would + // normally be offered space first + splitview.layout(1200, 500); + expect(splitview.getViewSize(0)).toBe(300); + expect(splitview.getViewSize(1)).toBe(900); + + splitview.dispose(); + }); + + test('LayoutPriority.Fill works in a vertical splitview', () => { + const splitview = new Splitview(container, { + orientation: Orientation.VERTICAL, + proportionalLayout: true, + }); + + splitview.layout(1000, 500); + + const side = new Testview(50, Number.POSITIVE_INFINITY); + const fill = new Testview( + 50, + Number.POSITIVE_INFINITY, + LayoutPriority.Fill + ); + + splitview.addView(side); // index 0 + splitview.addView(fill); // index 1 + + splitview.resizeView(0, 200); + expect(splitview.getViewSize(0)).toBe(200); + expect(splitview.getViewSize(1)).toBe(800); + + splitview.layout(600, 500); + expect(splitview.getViewSize(0)).toBe(200); + expect(splitview.getViewSize(1)).toBe(400); + + splitview.dispose(); + }); + test('vertical splitview', () => { const splitview = new Splitview(container, { orientation: Orientation.HORIZONTAL, diff --git a/packages/dockview-core/src/api/dockviewGroupPanelApi.ts b/packages/dockview-core/src/api/dockviewGroupPanelApi.ts index 36b04eaace..ed487e29e9 100644 --- a/packages/dockview-core/src/api/dockviewGroupPanelApi.ts +++ b/packages/dockview-core/src/api/dockviewGroupPanelApi.ts @@ -8,6 +8,7 @@ import { DockviewGroupPanelLocked, } from '../dockview/dockviewGroupPanelModel'; import { DockviewHeaderPosition } from '../dockview/options'; +import { LayoutPriority } from '../splitview/splitview'; import { Emitter, Event } from '../events'; import { GridviewPanelApi, @@ -147,6 +148,25 @@ export class DockviewGroupPanelApiImpl extends GridviewPanelApiImpl { this._group.locked = value; } + /** + * The group's layout priority within the grid. Setting `LayoutPriority.Fill` + * makes the group absorb surplus/deficit space while its siblings keep a + * fixed size; the change relayouts the containing branch. + */ + get priority(): LayoutPriority | undefined { + if (!this._group) { + throw new Error(NOT_INITIALIZED_MESSAGE); + } + return this._group.priority; + } + + set priority(value: LayoutPriority | undefined) { + if (!this._group) { + throw new Error(NOT_INITIALIZED_MESSAGE); + } + this._group.priority = value; + } + constructor( id: string, private readonly accessor: DockviewComponent diff --git a/packages/dockview-core/src/dockview/dockviewComponent.ts b/packages/dockview-core/src/dockview/dockviewComponent.ts index 8e3b9085c2..9acf535a79 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.ts +++ b/packages/dockview-core/src/dockview/dockviewComponent.ts @@ -2671,6 +2671,7 @@ export class DockviewComponent headerPosition, views, activeView, + priority, } = data; if (typeof id !== 'string') { @@ -2684,6 +2685,7 @@ export class DockviewComponent locked: !!locked, hideHeader: !!hideHeader, headerPosition, + priority, }); this._onDidAddGroup.fire(group); @@ -4560,7 +4562,9 @@ export class DockviewComponent } const view = new DockviewGroupPanel(this, id, options); - view.init({ params: {}, accessor: this }); + // the group is the grid leaf, so its layout priority lives on the view + // (inherited from GridviewPanel); apply it via init like a gridview panel + view.init({ params: {}, accessor: this, priority: options.priority }); if (!this._groups.has(view.id)) { const disposable = new CompositeDisposable( diff --git a/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts b/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts index 9e5618cc75..4e5402ceed 100644 --- a/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts +++ b/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts @@ -16,6 +16,7 @@ import { DockviewWillShowOverlayLocationEventOptions, } from './events'; import { IViewSize } from '../gridview/gridview'; +import { LayoutPriority } from '../splitview/splitview'; import { CompositeDisposable, IDisposable } from '../lifecycle'; import { IPanel, @@ -70,6 +71,12 @@ interface CoreGroupOptions { constraints?: Partial; initialWidth?: number; initialHeight?: number; + /** + * The group's layout priority within the grid. `LayoutPriority.Fill` makes + * the group absorb surplus/deficit space while its siblings keep fixed + * sizes. Applies to the group (the grid leaf), not individual panels. + */ + priority?: LayoutPriority; } export interface GroupOptions extends CoreGroupOptions { @@ -1279,6 +1286,10 @@ export class DockviewGroupPanelModel result.headerPosition = this.headerPosition; } + if (this.groupPanel?.priority !== undefined) { + result.priority = this.groupPanel.priority; + } + if (this._tabGroups.length > 0) { result.tabGroups = this._tabGroups.map((tg) => tg.toJSON()); } diff --git a/packages/dockview-core/src/gridview/branchNode.ts b/packages/dockview-core/src/gridview/branchNode.ts index 084c698307..e1485d8e69 100644 --- a/packages/dockview-core/src/gridview/branchNode.ts +++ b/packages/dockview-core/src/gridview/branchNode.ts @@ -125,7 +125,9 @@ export class BranchNode extends CompositeDisposable implements IView { : c.priority ); - if (priorities.some((p) => p === LayoutPriority.High)) { + if (priorities.some((p) => p === LayoutPriority.Fill)) { + return LayoutPriority.Fill; + } else if (priorities.some((p) => p === LayoutPriority.High)) { return LayoutPriority.High; } else if (priorities.some((p) => p === LayoutPriority.Low)) { return LayoutPriority.Low; diff --git a/packages/dockview-core/src/gridview/gridviewPanel.ts b/packages/dockview-core/src/gridview/gridviewPanel.ts index a82b23a70f..132b35c155 100644 --- a/packages/dockview-core/src/gridview/gridviewPanel.ts +++ b/packages/dockview-core/src/gridview/gridviewPanel.ts @@ -70,6 +70,24 @@ export abstract class GridviewPanel< return this._priority; } + set priority(value: LayoutPriority | undefined) { + if (this._priority === value) { + return; + } + this._priority = value; + // Re-run the whole grid layout so the new priority propagates up the + // branch tree — a nested branch reports its highest child priority to + // its parent, and a purely local splitview relayout would miss that. + // (`_params` is only populated once `init` has run, i.e. for runtime + // changes; the construction-time value is applied through `init`.) + const params = this._params as GridviewInitParameters | undefined; + params?.accessor.layout( + params.accessor.width, + params.accessor.height, + true + ); + } + get snap(): boolean { return this._snap; } diff --git a/packages/dockview-core/src/splitview/splitview.ts b/packages/dockview-core/src/splitview/splitview.ts index e6d0dfc57d..d63436b118 100644 --- a/packages/dockview-core/src/splitview/splitview.ts +++ b/packages/dockview-core/src/splitview/splitview.ts @@ -43,6 +43,7 @@ export enum LayoutPriority { Low = 'low', // view is offered space last High = 'high', // view is offered space first Normal = 'normal', // view is offered space in view order + Fill = 'fill', // view absorbs all remaining space; siblings keep fixed sizes } export interface IBaseView extends IDisposable { @@ -671,12 +672,32 @@ export class Splitview { this.addView(view, sizing, to); } + /** + * A view with `LayoutPriority.Fill` absorbs all surplus/deficit space while + * its siblings keep their current sizes. When one is present the splitview + * switches out of proportional redistribution: siblings are held fixed and + * `distributeEmptySpace` funnels the difference into the fill view(s) first. + */ + private hasFillView(): boolean { + // a hidden fill view can't absorb anything, so it doesn't trigger fill + // mode — the layout falls back to normal (proportional) distribution. + return this.viewItems.some( + (item) => item.priority === LayoutPriority.Fill && item.visible + ); + } + public layout(size: number, orthogonalSize: number): void { const previousSize = Math.max(this.size, this._contentSize); this.size = size; this.orthogonalSize = orthogonalSize; - if (!this.proportions) { + if (this.hasFillView()) { + /** + * Fill mode: the siblings keep their sizes; `distributeEmptySpace` + * below hands the entire `size - contentSize` delta to the fill + * view(s), so there is nothing to rescale here. + */ + } else if (!this.proportions) { const indexes = range(this.viewItems.length); const lowPriorityIndexes = indexes.filter( (i) => this.viewItems[i].priority === LayoutPriority.Low @@ -730,13 +751,20 @@ export class Splitview { ): void { const contentSize = this.viewItems.reduce((r, i) => r + i.size, 0); - this.resize( - this.viewItems.length - 1, - this._size - contentSize, - undefined, - lowPriorityIndexes, - highPriorityIndexes - ); + if (!this.hasFillView()) { + this.resize( + this.viewItems.length - 1, + this._size - contentSize, + undefined, + lowPriorityIndexes, + highPriorityIndexes + ); + } + /** + * In fill mode the sibling sizes are already settled (they are held + * fixed); `distributeEmptySpace` funnels the remaining delta into the + * fill view(s), so the `resize` pass above is skipped. + */ this.distributeEmptySpace(); this.layoutViews(); this.saveProportions(); @@ -753,11 +781,48 @@ export class Splitview { const highPriorityIndexes = indexes.filter( (i) => this.viewItems[i].priority === LayoutPriority.High ); + const fillPriorityIndexes = indexes.filter( + (i) => + this.viewItems[i].priority === LayoutPriority.Fill && + this.viewItems[i].visible + ); + + /** + * Fill views take precedence over every other priority: they share the + * surplus/deficit equally between them and absorb it entirely, leaving + * their siblings at a fixed size. Only once every fill view has hit a + * min/max constraint does any leftover spill to the ordered siblings + * below. + */ + if (fillPriorityIndexes.length > 0) { + let remaining = fillPriorityIndexes.length; + for (const index of fillPriorityIndexes) { + if (emptyDelta === 0) { + break; + } + const item = this.viewItems[index]; + const share = Math.round(emptyDelta / remaining); + const size = clamp( + item.size + share, + item.minimumSize, + item.maximumSize + ); + emptyDelta -= size - item.size; + item.size = size; + remaining--; + } + } for (const index of highPriorityIndexes) { pushToStart(indexes, index); } + // fills stay ahead of high priority in the fallback ordering too, so any + // leftover from a clamped fill lands on another fill before a sibling. + for (const index of fillPriorityIndexes) { + pushToStart(indexes, index); + } + for (const index of lowPriorityIndexes) { pushToEnd(indexes, index); }