quilt-vanilla 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -0
  3. package/dist/action-icons.d.ts +18 -0
  4. package/dist/action-icons.d.ts.map +1 -0
  5. package/dist/action-icons.js +36 -0
  6. package/dist/action-icons.js.map +1 -0
  7. package/dist/chrome-icons.d.ts +3 -0
  8. package/dist/chrome-icons.d.ts.map +1 -0
  9. package/dist/chrome-icons.js +32 -0
  10. package/dist/chrome-icons.js.map +1 -0
  11. package/dist/corners.d.ts +8 -0
  12. package/dist/corners.d.ts.map +1 -0
  13. package/dist/corners.js +205 -0
  14. package/dist/corners.js.map +1 -0
  15. package/dist/index.d.ts +9 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +5 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/lifetime.d.ts +10 -0
  20. package/dist/lifetime.d.ts.map +1 -0
  21. package/dist/lifetime.js +44 -0
  22. package/dist/lifetime.js.map +1 -0
  23. package/dist/menu.d.ts +10 -0
  24. package/dist/menu.d.ts.map +1 -0
  25. package/dist/menu.js +356 -0
  26. package/dist/menu.js.map +1 -0
  27. package/dist/panes.d.ts +13 -0
  28. package/dist/panes.d.ts.map +1 -0
  29. package/dist/panes.js +58 -0
  30. package/dist/panes.js.map +1 -0
  31. package/dist/picker.d.ts +6 -0
  32. package/dist/picker.d.ts.map +1 -0
  33. package/dist/picker.js +112 -0
  34. package/dist/picker.js.map +1 -0
  35. package/dist/registry.d.ts +25 -0
  36. package/dist/registry.d.ts.map +1 -0
  37. package/dist/registry.js +39 -0
  38. package/dist/registry.js.map +1 -0
  39. package/dist/renderer.d.ts +3 -0
  40. package/dist/renderer.d.ts.map +1 -0
  41. package/dist/renderer.js +570 -0
  42. package/dist/renderer.js.map +1 -0
  43. package/dist/resize.d.ts +7 -0
  44. package/dist/resize.d.ts.map +1 -0
  45. package/dist/resize.js +56 -0
  46. package/dist/resize.js.map +1 -0
  47. package/dist/shortcuts.d.ts +6 -0
  48. package/dist/shortcuts.d.ts.map +1 -0
  49. package/dist/shortcuts.js +64 -0
  50. package/dist/shortcuts.js.map +1 -0
  51. package/dist/styles.css +842 -0
  52. package/dist/tab-bar.d.ts +6 -0
  53. package/dist/tab-bar.d.ts.map +1 -0
  54. package/dist/tab-bar.js +336 -0
  55. package/dist/tab-bar.js.map +1 -0
  56. package/dist/tab-tooltip.d.ts +4 -0
  57. package/dist/tab-tooltip.d.ts.map +1 -0
  58. package/dist/tab-tooltip.js +95 -0
  59. package/dist/tab-tooltip.js.map +1 -0
  60. package/dist/tabs.d.ts +15 -0
  61. package/dist/tabs.d.ts.map +1 -0
  62. package/dist/tabs.js +126 -0
  63. package/dist/tabs.js.map +1 -0
  64. package/dist/theme.d.ts +206 -0
  65. package/dist/theme.d.ts.map +1 -0
  66. package/dist/theme.js +166 -0
  67. package/dist/theme.js.map +1 -0
  68. package/dist/types.d.ts +66 -0
  69. package/dist/types.d.ts.map +1 -0
  70. package/dist/types.js +2 -0
  71. package/dist/types.js.map +1 -0
  72. package/dist/windows.d.ts +23 -0
  73. package/dist/windows.d.ts.map +1 -0
  74. package/dist/windows.js +247 -0
  75. package/dist/windows.js.map +1 -0
  76. package/package.json +50 -0
  77. package/src/action-icons.ts +40 -0
  78. package/src/chrome-icons.ts +30 -0
  79. package/src/corners.ts +259 -0
  80. package/src/index.ts +27 -0
  81. package/src/lifetime.ts +46 -0
  82. package/src/menu.ts +432 -0
  83. package/src/panes.ts +76 -0
  84. package/src/picker.ts +114 -0
  85. package/src/registry.ts +46 -0
  86. package/src/renderer.ts +654 -0
  87. package/src/resize.ts +65 -0
  88. package/src/shortcuts.ts +90 -0
  89. package/src/styles.css +842 -0
  90. package/src/tab-bar.ts +382 -0
  91. package/src/tab-tooltip.ts +96 -0
  92. package/src/tabs.ts +129 -0
  93. package/src/theme.ts +211 -0
  94. package/src/types.ts +67 -0
  95. package/src/windows.ts +244 -0
@@ -0,0 +1,654 @@
1
+ import { isolatedResize } from './resize.js';
2
+ import { chromeIcon } from './chrome-icons.js';
3
+ import { bindTabBar, validateTabBar } from './tab-bar.js';
4
+ import { bindCorners } from './corners.js';
5
+ import { applyTheme } from './theme.js';
6
+ import { fillTabs } from './tabs.js';
7
+ import { bindShortcuts } from './shortcuts.js';
8
+ import { allocate, bounds, DIVIDER, findNode, groups, paneIds } from 'quilt-core';
9
+ import type { Group, Layout, Node, Pane } from 'quilt-core';
10
+ import type { LayoutOptions, MountedLayout } from './types.js';
11
+ import { Scope, el, syncChildren } from './lifetime.js';
12
+ import { mountPane } from './panes.js';
13
+ import type { MountedPane } from './panes.js';
14
+ import { Windows } from './windows.js';
15
+ import { createPaneMenu } from './menu.js';
16
+ interface Region {
17
+ element: HTMLElement;
18
+ kind: Node['kind'];
19
+ scope: Scope;
20
+ header?: HTMLElement;
21
+ body?: HTMLElement;
22
+ divider?: HTMLElement;
23
+ signature?: string;
24
+ updateTabBar?: () => void;
25
+ }
26
+ let nextMount = 0;
27
+ export function mountLayout(host: HTMLElement, options: LayoutOptions): MountedLayout {
28
+ validateTabBar(options.tabBar ?? {});
29
+ let tabBar = structuredClone(options.tabBar ?? {});
30
+ const prefix = `layouts-${++nextMount}`;
31
+ const doc = host.ownerDocument,
32
+ win = doc.defaultView;
33
+ if (!win) throw new Error('Layout host must belong to a live document');
34
+ const scope = new Scope(),
35
+ regions = new Map<string, Region>(),
36
+ panes = new Map<string, MountedPane>();
37
+ const root = el(doc, 'div', 'layouts');
38
+ applyTheme(root, options.theme ?? {});
39
+ root.setAttribute('aria-label', 'Pane workspace');
40
+ const stage = el(doc, 'div', 'layouts-stage');
41
+ const status = el(doc, 'div', 'layouts-status');
42
+ status.setAttribute('role', 'status');
43
+ root.append(stage, status);
44
+ host.append(root);
45
+ let disposed = false,
46
+ rendering = false,
47
+ again = false,
48
+ dragScope: Scope | undefined;
49
+ let dragId: string | undefined;
50
+ const error = (e: unknown) => {
51
+ status.textContent = e instanceof Error ? e.message : String(e);
52
+ try {
53
+ options.onError?.(e);
54
+ } catch {
55
+ /* A consumer reporter must not prevent cleanup. */
56
+ }
57
+ };
58
+ const act = (fn: () => void) => {
59
+ try {
60
+ fn();
61
+ } catch (e) {
62
+ error(e);
63
+ }
64
+ };
65
+ const windows = new Windows(doc, options, error, root);
66
+ const menu = createPaneMenu(root, options, windows, render, error);
67
+ bindShortcuts(root, options, scope, act, (group) => {
68
+ const region = regions.get(group.id);
69
+ if (region) menu.addTab(region.header ?? region.element, group);
70
+ });
71
+ const renderOptions = { ...options, onError: error };
72
+ function button(text: string, title: string, action: () => void) {
73
+ const b = el(doc, 'button', 'layouts-button', text);
74
+ b.type = 'button';
75
+ b.title = title;
76
+ b.setAttribute('aria-label', title);
77
+ b.onclick = () => act(action);
78
+ return b;
79
+ }
80
+ function region(node: Node): Region {
81
+ const existing = regions.get(node.id);
82
+ if (existing?.kind === node.kind) return existing;
83
+ if (existing) {
84
+ existing.scope.dispose();
85
+ existing.element.remove();
86
+ regions.delete(node.id);
87
+ }
88
+ const r: Region = {
89
+ kind: node.kind,
90
+ element: el(doc, 'section', `layouts-node layouts-${node.kind}`),
91
+ scope: new Scope(),
92
+ };
93
+ r.element.dataset.nodeId = node.id;
94
+ if (node.kind === 'group') {
95
+ r.header = el(doc, 'header', 'layouts-header');
96
+ r.body = el(doc, 'div', 'layouts-body');
97
+ r.element.append(r.header, r.body);
98
+ r.updateTabBar = bindTabBar(r.element, r.header, root, r.scope, () => {
99
+ const current = findNode(options.store.getSnapshot().root, node.id);
100
+ return {
101
+ ...tabBar,
102
+ ...(tabBar.regions?.[node.id] ?? {}),
103
+ ...(current?.kind === 'group' && current.tabDisplay
104
+ ? { display: current.tabDisplay }
105
+ : {}),
106
+ ...(current?.kind === 'group' && current.tabPlacement
107
+ ? { placement: current.tabPlacement }
108
+ : {}),
109
+ };
110
+ });
111
+ bindCorners(
112
+ r.element,
113
+ node.id,
114
+ options,
115
+ r.scope,
116
+ (pane, group, direction, ratio) => menu.open(r.header!, pane, group, direction, ratio),
117
+ error,
118
+ );
119
+ r.element.setAttribute('aria-label', 'Pane region');
120
+ r.scope.listen(r.element, 'dragover', (event) => {
121
+ if (!dragId) return;
122
+ const e = event as DragEvent;
123
+ const current = findNode(options.store.getSnapshot().root, node.id);
124
+ if (current?.kind !== 'group') return;
125
+ const position = dropPosition(e, r.element);
126
+ if (
127
+ !options.store.can(dragId, 'move') ||
128
+ !current.panes.every(
129
+ (id) =>
130
+ options.store.can(id, 'move') &&
131
+ (position === 'tab' || options.store.can(id, 'split')),
132
+ )
133
+ )
134
+ return;
135
+ e.preventDefault();
136
+ e.stopPropagation();
137
+ r.element.dataset.drop = position;
138
+ });
139
+ r.scope.listen(r.element, 'dragleave', (event) => {
140
+ if (!r.element.contains((event as DragEvent).relatedTarget as globalThis.Node | null))
141
+ delete r.element.dataset.drop;
142
+ });
143
+ r.scope.listen(r.element, 'drop', (event) => {
144
+ const e = event as DragEvent;
145
+ if (!dragId) return;
146
+ e.preventDefault();
147
+ e.stopPropagation();
148
+ const id = dragId;
149
+ dragId = undefined;
150
+ delete r.element.dataset.drop;
151
+ act(() =>
152
+ options.store.move(id, node.id, dropPosition(e, r.element), undefined, {
153
+ source: 'user',
154
+ }),
155
+ );
156
+ });
157
+ } else {
158
+ r.divider = el(doc, 'div', 'layouts-divider');
159
+ r.divider.tabIndex = 0;
160
+ r.divider.setAttribute('role', 'separator');
161
+ r.divider.setAttribute('aria-label', 'Resize panes');
162
+ const start = (event: Event) => {
163
+ const e = event as PointerEvent;
164
+ if (e.button !== 0) return;
165
+ const n = findNode(options.store.getSnapshot().root, node.id);
166
+ if (n?.kind !== 'split' || !resizable(n, options.store.getSnapshot())) return;
167
+ e.preventDefault();
168
+ dragScope?.dispose();
169
+ const drag = new Scope();
170
+ dragScope = drag;
171
+ const resize = isolatedResize(geometryLayout(), node.id, (child, axis) => {
172
+ const rect = regions.get(child.id)!.element.getBoundingClientRect();
173
+ return axis === 'horizontal' ? rect.width : rect.height;
174
+ });
175
+ r.divider!.setPointerCapture(e.pointerId);
176
+ root.classList.add('layouts-resizing');
177
+ drag.add(() => {
178
+ root.classList.remove('layouts-resizing');
179
+ try {
180
+ r.divider!.releasePointerCapture(e.pointerId);
181
+ } catch {}
182
+ });
183
+ const set = (move: PointerEvent) => {
184
+ const current = findNode(geometryLayout().root, node.id);
185
+ if (current?.kind !== 'split') return;
186
+ const rect = r.element.getBoundingClientRect();
187
+ const available =
188
+ (current.axis === 'horizontal' ? rect.width : rect.height) - (current.gap ?? DIVIDER);
189
+ if (available > 0)
190
+ act(() =>
191
+ options.store.resizeMany(
192
+ resize.ratios(
193
+ Math.max(
194
+ 0.001,
195
+ Math.min(
196
+ 0.999,
197
+ (current.axis === 'horizontal'
198
+ ? move.clientX - rect.left
199
+ : move.clientY - rect.top) / available,
200
+ ),
201
+ ),
202
+ ),
203
+ { source: 'user' },
204
+ ),
205
+ );
206
+ };
207
+ drag.listen(r.divider!, 'pointermove', (move) => set(move as PointerEvent));
208
+ drag.listen(r.divider!, 'pointerup', () => drag.dispose());
209
+ drag.listen(r.divider!, 'pointercancel', () => drag.dispose());
210
+ drag.listen(doc, 'keydown', (key) => {
211
+ if ((key as KeyboardEvent).key === 'Escape') {
212
+ act(() => options.store.resizeMany(resize.original));
213
+ drag.dispose();
214
+ }
215
+ });
216
+ };
217
+ r.scope.listen(r.divider, 'pointerdown', start);
218
+ r.scope.listen(r.divider, 'keydown', (event) => {
219
+ const e = event as KeyboardEvent;
220
+ const n = findNode(options.store.getSnapshot().root, node.id);
221
+ if (n?.kind !== 'split' || !resizable(n, options.store.getSnapshot())) return;
222
+ const keys =
223
+ n.axis === 'horizontal' ? ['ArrowLeft', 'ArrowRight'] : ['ArrowUp', 'ArrowDown'];
224
+ if (keys.includes(e.key)) {
225
+ e.preventDefault();
226
+ const resize = isolatedResize(geometryLayout(), node.id, (child, axis) => {
227
+ const rect = regions.get(child.id)!.element.getBoundingClientRect();
228
+ return axis === 'horizontal' ? rect.width : rect.height;
229
+ });
230
+ act(() =>
231
+ options.store.resizeMany(
232
+ resize.ratios(
233
+ Math.max(
234
+ 0.001,
235
+ Math.min(
236
+ 0.999,
237
+ n.ratio + (e.key === keys[0] ? -1 : 1) * (e.shiftKey ? 0.1 : 0.02),
238
+ ),
239
+ ),
240
+ ),
241
+ { source: 'user' },
242
+ ),
243
+ );
244
+ }
245
+ });
246
+ }
247
+ regions.set(node.id, r);
248
+ return r;
249
+ }
250
+ function dropPosition(
251
+ e: DragEvent,
252
+ element: HTMLElement,
253
+ ): 'tab' | 'left' | 'right' | 'top' | 'bottom' {
254
+ const rect = element.getBoundingClientRect(),
255
+ x = (e.clientX - rect.left) / rect.width,
256
+ y = (e.clientY - rect.top) / rect.height;
257
+ if (x < 0.22) return 'left';
258
+ if (x > 0.78) return 'right';
259
+ if (y < 0.22) return 'top';
260
+ if (y > 0.78) return 'bottom';
261
+ return 'tab';
262
+ }
263
+ function content(pane: Pane): MountedPane {
264
+ const old = panes.get(pane.id),
265
+ signature = JSON.stringify(pane);
266
+ if (
267
+ old &&
268
+ old.renderer !==
269
+ (Object.hasOwn(options.renderers, pane.type) ? options.renderers[pane.type] : undefined)
270
+ ) {
271
+ old.dispose();
272
+ panes.delete(pane.id);
273
+ return content(pane);
274
+ }
275
+ if (old) {
276
+ if (old.signature !== signature) {
277
+ if (
278
+ old.pane.type !== pane.type ||
279
+ JSON.stringify(old.pane.params) !== JSON.stringify(pane.params)
280
+ ) {
281
+ old.dispose();
282
+ panes.delete(pane.id);
283
+ } else {
284
+ old.pane = pane;
285
+ old.signature = signature;
286
+ old.view.update?.(pane);
287
+ return old;
288
+ }
289
+ } else return old;
290
+ }
291
+ try {
292
+ const mounted = mountPane(doc, pane, 'main', renderOptions);
293
+ panes.set(pane.id, mounted);
294
+ return mounted;
295
+ } catch (e) {
296
+ error(e);
297
+ const element = el(
298
+ doc,
299
+ 'div',
300
+ 'layouts-pane layouts-placeholder',
301
+ `Could not mount ${pane.title}. Reload the layout to retry.`,
302
+ );
303
+ element.dataset.paneId = pane.id;
304
+ const mounted: MountedPane = {
305
+ element,
306
+ pane,
307
+ signature,
308
+ renderer: options.renderers[pane.type],
309
+ failed: true,
310
+ view: { dispose() {} },
311
+ dispose() {
312
+ element.remove();
313
+ },
314
+ };
315
+ panes.set(pane.id, mounted);
316
+ return mounted;
317
+ }
318
+ }
319
+ function paintGroup(g: Group, r: Region, layout: Layout) {
320
+ const definitions = g.panes.map((id) => layout.panes[id]!);
321
+ const signature = JSON.stringify([
322
+ definitions,
323
+ g.active,
324
+ layout.maximized,
325
+ layout.root.id === g.id,
326
+ definitions.map((p) => windows.pending.has(p.id)),
327
+ ]);
328
+ if (r.signature !== signature) {
329
+ r.signature = signature;
330
+ r.header!.replaceChildren();
331
+ r.header!.hidden = definitions.length === 1 && definitions[0]!.header === false;
332
+ const tabs = el(doc, 'div', 'layouts-tabs');
333
+ tabs.setAttribute('role', 'tablist');
334
+ tabs.setAttribute('aria-label', `Tabs in ${g.id}`);
335
+ fillTabs(tabs, g, definitions, {
336
+ options,
337
+ prefix,
338
+ act,
339
+ getDrag: () => dragId,
340
+ setDrag: (id) => {
341
+ dragId = id;
342
+ },
343
+ clearDrop: () => {
344
+ for (const region of regions.values()) delete region.element.dataset.drop;
345
+ for (const marker of root.querySelectorAll<HTMLElement>('[data-tab-drop]'))
346
+ delete marker.dataset.tabDrop;
347
+ },
348
+ focusActive: () =>
349
+ regions.get(g.id)?.header?.querySelector<HTMLElement>('[aria-selected="true"]')?.focus(),
350
+ });
351
+ r.header!.append(tabs);
352
+ if (!definitions.length) {
353
+ const close = button('', 'Close empty pane', () => options.store.removeEmptyGroup(g.id));
354
+ close.append(chromeIcon(doc, 'close'));
355
+ close.disabled = layout.root.id === g.id;
356
+ const settings = button('', 'Empty pane actions', () => menu.openEmpty(settings, g));
357
+ settings.append(chromeIcon(doc, 'more'));
358
+ settings.setAttribute('aria-haspopup', 'dialog');
359
+ settings.dataset.focusId = `menu-${g.id}`;
360
+ r.header!.append(close, settings);
361
+ }
362
+ const active = g.active ? layout.panes[g.active] : undefined;
363
+ if (active) {
364
+ const more = button('', `${active.title} actions`, () => menu.open(more, active, g));
365
+ more.append(chromeIcon(doc, 'more'));
366
+ more.dataset.focusId = `menu-${active.id}`;
367
+ more.setAttribute('aria-haspopup', 'dialog');
368
+ r.header!.append(more);
369
+ }
370
+ }
371
+ r.updateTabBar?.();
372
+ const bodies = definitions.map((pane) => {
373
+ const p = content(pane);
374
+ p.element.hidden = pane.id !== g.active;
375
+ p.element.id = `${prefix}-panel-${pane.id}`;
376
+ p.element.setAttribute('role', 'tabpanel');
377
+ if (r.header!.hidden) {
378
+ p.element.setAttribute('aria-label', pane.title);
379
+ p.element.removeAttribute('aria-labelledby');
380
+ } else {
381
+ p.element.setAttribute('aria-labelledby', `${prefix}-tab-${pane.id}`);
382
+ p.element.removeAttribute('aria-label');
383
+ }
384
+ return p.element;
385
+ });
386
+ if (!bodies.length) {
387
+ let placeholder = r.body!.querySelector<HTMLButtonElement>('.layouts-empty');
388
+ if (!placeholder) {
389
+ placeholder = button('', 'Choose a tab', () => {
390
+ const current = options.store.getSnapshot();
391
+ const source = Object.values(current.panes).find((pane) => pane.header !== false);
392
+ const group = findNode(current.root, g.id);
393
+ if (group?.kind === 'group' && !group.panes.length)
394
+ menu.open(placeholder!, source, group);
395
+ });
396
+ placeholder.className = 'layouts-empty';
397
+ placeholder.removeAttribute('title');
398
+ placeholder.disabled = !options.tabs;
399
+ placeholder.setAttribute('aria-haspopup', 'dialog');
400
+ const lines = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
401
+ lines.setAttribute('viewBox', '0 0 100 100');
402
+ lines.setAttribute('preserveAspectRatio', 'none');
403
+ lines.setAttribute('aria-hidden', 'true');
404
+ const path = doc.createElementNS(lines.namespaceURI, 'path');
405
+ path.setAttribute('d', 'M0 0 L100 100 M100 0 L0 100');
406
+ path.setAttribute('vector-effect', 'non-scaling-stroke');
407
+ lines.append(path);
408
+ placeholder.append(lines);
409
+ }
410
+ bodies.push(placeholder);
411
+ }
412
+ syncChildren(r.body!, bodies);
413
+ }
414
+ function tree(node: Node, layout: Layout, used: Set<string>): HTMLElement {
415
+ used.add(node.id);
416
+ const r = region(node);
417
+ if (node.kind === 'group') paintGroup(node, r, layout);
418
+ else {
419
+ const a = tree(node.children[0], layout, used),
420
+ b = tree(node.children[1], layout, used);
421
+ syncChildren(r.element, [a, r.divider!, b]);
422
+ r.divider!.setAttribute(
423
+ 'aria-orientation',
424
+ node.axis === 'horizontal' ? 'vertical' : 'horizontal',
425
+ );
426
+ r.divider!.dataset.axis = node.axis;
427
+ r.divider!.setAttribute('aria-valuenow', String(Math.round(node.ratio * 100)));
428
+ r.divider!.setAttribute('aria-valuemin', '0');
429
+ r.divider!.setAttribute('aria-valuemax', '100');
430
+ r.divider!.setAttribute('aria-disabled', String(!resizable(node, layout)));
431
+ }
432
+ return r.element;
433
+ }
434
+ function geometry(
435
+ node: Node,
436
+ x: number,
437
+ y: number,
438
+ width: number,
439
+ height: number,
440
+ layout: Layout,
441
+ shared: string[] = [],
442
+ ) {
443
+ const r = regions.get(node.id);
444
+ if (!r) return;
445
+ if (r.element.dataset.sharedEdges !== shared.join(' ')) {
446
+ r.element.dataset.sharedEdges = shared.join(' ');
447
+ r.updateTabBar?.();
448
+ }
449
+ const b = bounds(node, layout);
450
+ width = Math.max(b.minWidth, Math.min(width, b.maxWidth));
451
+ height = Math.max(b.minHeight, Math.min(height, b.maxHeight));
452
+ Object.assign(r.element.style, {
453
+ left: `${x}px`,
454
+ top: `${y}px`,
455
+ width: `${width}px`,
456
+ height: `${height}px`,
457
+ });
458
+ if (node.kind === 'split') {
459
+ const a = bounds(node.children[0], layout),
460
+ b = bounds(node.children[1], layout),
461
+ horizontal = node.axis === 'horizontal',
462
+ gap = node.gap ?? DIVIDER;
463
+ const [first, second] = allocate(
464
+ horizontal ? width : height,
465
+ node.ratio,
466
+ horizontal ? a.minWidth : a.minHeight,
467
+ horizontal ? a.maxWidth : a.maxHeight,
468
+ horizontal ? b.minWidth : b.minHeight,
469
+ horizontal ? b.maxWidth : b.maxHeight,
470
+ gap,
471
+ );
472
+ const frozen = gap === 0 && !resizable(node, layout);
473
+ const firstEdge = horizontal ? 'right' : 'bottom';
474
+ const secondEdge = horizontal ? 'left' : 'top';
475
+ geometry(
476
+ node.children[0],
477
+ 0,
478
+ 0,
479
+ horizontal ? first : width,
480
+ horizontal ? height : first,
481
+ layout,
482
+ [...shared.filter((edge) => edge !== firstEdge), ...(frozen ? [firstEdge] : [])],
483
+ );
484
+ geometry(
485
+ node.children[1],
486
+ horizontal ? first + gap : 0,
487
+ horizontal ? 0 : first + gap,
488
+ horizontal ? second : width,
489
+ horizontal ? height : second,
490
+ layout,
491
+ [...shared.filter((edge) => edge !== secondEdge), ...(frozen ? [secondEdge] : [])],
492
+ );
493
+ r.divider!.hidden = gap === 0;
494
+ // Paint a shared edge without reserving space or adding a resize target.
495
+ r.element.dataset.frozenBorder = gap === 0 && !resizable(node, layout) ? node.axis : '';
496
+ r.element.style.setProperty('--layouts-frozen-boundary', `${first}px`);
497
+ Object.assign(
498
+ r.divider!.style,
499
+ horizontal
500
+ ? { left: `${first}px`, top: '0', width: `${gap}px`, height: `${height}px` }
501
+ : { left: '0', top: `${first}px`, width: `${width}px`, height: `${gap}px` },
502
+ );
503
+ }
504
+ }
505
+ function resizable(node: Node, layout: Layout) {
506
+ if (node.kind !== 'split' || !paneIds(node).every((id) => options.store.can(id, 'resize')))
507
+ return false;
508
+ return node.children.every((child) => {
509
+ const b = bounds(child, layout);
510
+ return node.axis === 'horizontal' ? b.minWidth < b.maxWidth : b.minHeight < b.maxHeight;
511
+ });
512
+ }
513
+ // Renderer-only gaps participate in bounds/allocation without modifying Layout JSON.
514
+ function geometryLayout(): Layout {
515
+ const layout = options.store.getSnapshot();
516
+ const css = win!.getComputedStyle(root);
517
+ const pixels = (property: string, fallback: number) => {
518
+ const value = parseFloat(css.getPropertyValue(property));
519
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
520
+ };
521
+ const width = pixels('--layouts-resize-handle-width', DIVIDER);
522
+ const disabledWidth = pixels('--layouts-disabled-resize-handle-width', 0);
523
+ const visit = (node: Node): Node =>
524
+ node.kind === 'group'
525
+ ? node
526
+ : {
527
+ ...node,
528
+ gap: resizable(node, layout) ? (node.gap ?? width) : disabledWidth,
529
+ children: [visit(node.children[0]), visit(node.children[1])],
530
+ };
531
+ return { ...layout, root: visit(layout.root) };
532
+ }
533
+ function measure() {
534
+ const layout = geometryLayout();
535
+ const node = layout.maximized ? findNode(layout.root, layout.maximized) : layout.root;
536
+ if (!node) return;
537
+ // Reparented maximized regions can temporarily overflow their old split.
538
+ // Discard those stale scrollbars before allocating the restored layout.
539
+ const overflow = stage.style.overflow;
540
+ let width: number, height: number;
541
+ try {
542
+ stage.style.overflow = 'hidden';
543
+ width = stage.clientWidth;
544
+ height = stage.clientHeight;
545
+ geometry(node, 0, 0, width, height, layout);
546
+ } finally {
547
+ stage.style.overflow = overflow;
548
+ }
549
+ // Minimum sizes may still require real scrollbars; account for their space.
550
+ if (stage.clientWidth !== width || stage.clientHeight !== height)
551
+ geometry(node, 0, 0, stage.clientWidth, stage.clientHeight, layout);
552
+ }
553
+ function render() {
554
+ if (disposed) return;
555
+ if (rendering) {
556
+ again = true;
557
+ return;
558
+ }
559
+ rendering = true;
560
+ try {
561
+ const focused = (doc.activeElement as HTMLElement | null)?.dataset.focusId;
562
+ const layout = options.store.getSnapshot(),
563
+ used = new Set<string>();
564
+ const element = tree(layout.root, layout, used);
565
+ // Maximizing reparents a region; pane views remain mounted.
566
+ const visible = layout.maximized ? regions.get(layout.maximized)?.element : element;
567
+ if (visible) syncChildren(stage, [visible]);
568
+ for (const [id, r] of regions)
569
+ if (!used.has(id)) {
570
+ r.scope.dispose();
571
+ r.element.remove();
572
+ regions.delete(id);
573
+ }
574
+ const docked = new Set(groups(layout.root).flatMap((g) => g.panes));
575
+ for (const [id, p] of panes)
576
+ if (!docked.has(id)) {
577
+ p.dispose();
578
+ panes.delete(id);
579
+ }
580
+ measure();
581
+ if (focused && doc.activeElement === doc.body)
582
+ Array.from(root.querySelectorAll<HTMLElement>('[data-focus-id]'))
583
+ .find((n) => n.dataset.focusId === focused)
584
+ ?.focus();
585
+ } catch (e) {
586
+ error(e);
587
+ } finally {
588
+ rendering = false;
589
+ if (again) {
590
+ again = false;
591
+ render();
592
+ }
593
+ }
594
+ }
595
+ scope.add(
596
+ options.store.subscribe((event) => {
597
+ if (event.action === 'load') {
598
+ for (const [id, pane] of panes)
599
+ if (pane.failed) {
600
+ pane.dispose();
601
+ panes.delete(id);
602
+ }
603
+ }
604
+ render();
605
+ }),
606
+ );
607
+ const observer = new ResizeObserver(measure);
608
+ observer.observe(stage);
609
+ scope.add(() => observer.disconnect());
610
+ scope.listen(doc, 'dragend', () => {
611
+ dragId = undefined;
612
+ for (const r of regions.values()) delete r.element.dataset.drop;
613
+ });
614
+ render();
615
+ return {
616
+ setTabBar(next) {
617
+ if (disposed) return;
618
+ validateTabBar(next);
619
+ tabBar = structuredClone(next);
620
+ for (const region of regions.values()) region.updateTabBar?.();
621
+ },
622
+ setTheme(theme) {
623
+ applyTheme(root, theme);
624
+ measure();
625
+ },
626
+ popout(id, placement) {
627
+ const opened = windows.open(id, placement);
628
+ render();
629
+ return opened;
630
+ },
631
+ returnPane(id) {
632
+ windows.returnPane(id);
633
+ },
634
+ dispose() {
635
+ if (disposed) return;
636
+ disposed = true;
637
+ menu.dispose();
638
+ dragScope?.dispose();
639
+ scope.dispose();
640
+ windows.dispose();
641
+ for (const p of panes.values()) {
642
+ try {
643
+ p.dispose();
644
+ } catch (e) {
645
+ error(e);
646
+ }
647
+ }
648
+ for (const r of regions.values()) r.scope.dispose();
649
+ panes.clear();
650
+ regions.clear();
651
+ root.remove();
652
+ },
653
+ };
654
+ }