quilt-core 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.
package/src/model.ts ADDED
@@ -0,0 +1,260 @@
1
+ import { LayoutError } from './types.js';
2
+ import type { Bounds, Group, Issue, Json, Layout, Node, Pane } from './types.js';
3
+ const record = (v: unknown): v is Record<string, unknown> =>
4
+ typeof v === 'object' && v !== null && !Array.isArray(v);
5
+ const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
6
+ function isJson(v: unknown, seen = new Set<unknown>(), depth = 0): v is Json {
7
+ if (depth > 256) return false;
8
+ if (v === null || typeof v === 'string' || typeof v === 'boolean') return true;
9
+ if (typeof v === 'number') return Number.isFinite(v);
10
+ if (typeof v !== 'object' || seen.has(v)) return false;
11
+ if (
12
+ !Array.isArray(v) &&
13
+ Object.getPrototypeOf(v) !== Object.prototype &&
14
+ Object.getPrototypeOf(v) !== null
15
+ )
16
+ return false;
17
+ seen.add(v);
18
+ const result = Object.values(v).every((x) => isJson(x, seen, depth + 1));
19
+ seen.delete(v);
20
+ return result;
21
+ }
22
+ export function validate(input: unknown): Issue[] {
23
+ const errors: Issue[] = [];
24
+ const fail = (path: string, message: string) => {
25
+ errors.push({ path, message });
26
+ };
27
+ if (!record(input) || !isJson(input))
28
+ return [{ path: '$', message: 'Expected a finite, acyclic JSON object' }];
29
+ if (input.version !== 1) fail('version', 'Expected version 1');
30
+ if (!record(input.panes))
31
+ return [...errors, { path: 'panes', message: 'Expected pane dictionary' }];
32
+ const panes = input.panes;
33
+ for (const [id, pane] of Object.entries(panes)) {
34
+ if (
35
+ !record(pane) ||
36
+ pane.id !== id ||
37
+ !id ||
38
+ typeof pane.type !== 'string' ||
39
+ !pane.type ||
40
+ typeof pane.title !== 'string'
41
+ ) {
42
+ fail(`panes.${id}`, 'Expected matching id, type, and title');
43
+ continue;
44
+ }
45
+ if (pane.icon !== undefined && typeof pane.icon !== 'string')
46
+ fail(`panes.${id}.icon`, 'Expected string');
47
+ if (pane.header !== undefined && typeof pane.header !== 'boolean')
48
+ fail(`panes.${id}.header`, 'Expected boolean');
49
+ if (pane.capabilities !== undefined) {
50
+ if (!record(pane.capabilities)) fail(`panes.${id}.capabilities`, 'Expected object');
51
+ else
52
+ for (const [key, value] of Object.entries(pane.capabilities))
53
+ if (
54
+ !['resize', 'move', 'split', 'join', 'close', 'popout'].includes(key) ||
55
+ typeof value !== 'boolean'
56
+ )
57
+ fail(`panes.${id}.capabilities.${key}`, 'Unknown capability or non-boolean flag');
58
+ }
59
+ if (pane.size !== undefined) {
60
+ if (!record(pane.size)) fail(`panes.${id}.size`, 'Expected object');
61
+ else {
62
+ for (const [key, value] of Object.entries(pane.size))
63
+ if (
64
+ !['minWidth', 'maxWidth', 'minHeight', 'maxHeight'].includes(key) ||
65
+ !finite(value) ||
66
+ value < 0
67
+ )
68
+ fail(`panes.${id}.size.${key}`, 'Expected nonnegative finite size');
69
+ for (const axis of ['Width', 'Height'])
70
+ if (
71
+ finite(pane.size['min' + axis]) &&
72
+ finite(pane.size['max' + axis]) &&
73
+ Number(pane.size['min' + axis]) > Number(pane.size['max' + axis])
74
+ )
75
+ fail(`panes.${id}.size`, 'Minimum exceeds maximum');
76
+ }
77
+ }
78
+ }
79
+ const ids = new Set<string>();
80
+ const assigned = new Set<string>();
81
+ const groups = new Set<string>();
82
+ const use = (id: unknown, path: string) => {
83
+ if (typeof id !== 'string' || !Object.hasOwn(panes, id)) fail(path, 'Unknown pane');
84
+ else if (assigned.has(id)) fail(path, 'Pane is placed more than once');
85
+ else assigned.add(id);
86
+ };
87
+ function walk(node: unknown, path: string, depth: number) {
88
+ if (depth > 64) {
89
+ fail(path, 'Maximum split depth is 64');
90
+ return;
91
+ }
92
+ if (!record(node) || typeof node.id !== 'string' || !node.id) {
93
+ fail(path, 'Expected node with id');
94
+ return;
95
+ }
96
+ if (ids.has(node.id)) fail(path, 'Duplicate node id');
97
+ ids.add(node.id);
98
+ if (node.kind === 'group') {
99
+ groups.add(node.id);
100
+ if (
101
+ node.tabDisplay !== undefined &&
102
+ node.tabDisplay !== 'automatic' &&
103
+ node.tabDisplay !== 'compact'
104
+ )
105
+ fail(path + '.tabDisplay', 'Expected automatic or compact');
106
+ if (
107
+ node.tabPlacement !== undefined &&
108
+ node.tabPlacement !== 'top' &&
109
+ node.tabPlacement !== 'left'
110
+ )
111
+ fail(path + '.tabPlacement', 'Expected top or left');
112
+ if (!Array.isArray(node.panes)) {
113
+ fail(path, 'Expected pane array');
114
+ return;
115
+ }
116
+ node.panes.forEach((p, i) => use(p, `${path}.panes[${i}]`));
117
+ if (node.panes.length ? !node.panes.includes(node.active) : node.active !== null)
118
+ fail(path + '.active', 'Active tab must belong to group (or be null for empty group)');
119
+ } else if (node.kind === 'split') {
120
+ if (node.gap !== undefined && (!finite(node.gap) || node.gap < 0))
121
+ fail(path + '.gap', 'Expected nonnegative divider space');
122
+ if (!['horizontal', 'vertical'].includes(String(node.axis)))
123
+ fail(path + '.axis', 'Expected horizontal or vertical');
124
+ if (!finite(node.ratio) || node.ratio <= 0 || node.ratio >= 1)
125
+ fail(path + '.ratio', 'Expected ratio between 0 and 1');
126
+ if (!Array.isArray(node.children) || node.children.length !== 2)
127
+ fail(path + '.children', 'Expected two children');
128
+ else node.children.forEach((n, i) => walk(n, `${path}.children[${i}]`, depth + 1));
129
+ } else fail(path + '.kind', 'Expected group or split');
130
+ }
131
+ walk(input.root, 'root', 0);
132
+ if (!Array.isArray(input.popouts)) fail('popouts', 'Expected array');
133
+ else
134
+ input.popouts.forEach((p, i) => {
135
+ const path = `popouts[${i}]`;
136
+ if (!record(p)) {
137
+ fail(path, 'Expected popout object');
138
+ return;
139
+ }
140
+ use(p.paneId, path + '.paneId');
141
+ if (typeof p.groupId !== 'string' || !Number.isInteger(p.index) || Number(p.index) < 0)
142
+ fail(path, 'Expected return group id and nonnegative index');
143
+ if (p.placement !== undefined) {
144
+ if (!record(p.placement)) fail(path + '.placement', 'Expected placement object');
145
+ else
146
+ for (const [key, value] of Object.entries(p.placement))
147
+ if (
148
+ !['width', 'height', 'left', 'top'].includes(key) ||
149
+ !finite(value) ||
150
+ (['width', 'height'].includes(key) && value <= 0)
151
+ )
152
+ fail(path + '.placement.' + key, 'Invalid window placement');
153
+ }
154
+ });
155
+ for (const id of Object.keys(panes))
156
+ if (!assigned.has(id)) fail(`panes.${id}`, 'Pane has no placement');
157
+ if (
158
+ input.maximized !== null &&
159
+ (typeof input.maximized !== 'string' || !groups.has(input.maximized))
160
+ )
161
+ fail('maximized', 'Expected a group id or null');
162
+ if (!errors.length) {
163
+ const check = (n: Node) => {
164
+ const b = bounds(n, input as unknown as Layout);
165
+ if (b.minWidth > b.maxWidth || b.minHeight > b.maxHeight)
166
+ fail(n.id, 'Pane constraints cannot coexist in this region');
167
+ if (n.kind === 'split') n.children.forEach(check);
168
+ };
169
+ check(input.root as unknown as Node);
170
+ }
171
+ return errors;
172
+ }
173
+ export function parseLayout(input: unknown): Layout {
174
+ const issues = validate(input);
175
+ if (issues.length) throw new LayoutError(issues);
176
+ return structuredClone(input) as Layout;
177
+ }
178
+ export function findNode(root: Node, id: string): Node | undefined {
179
+ return root.id === id
180
+ ? root
181
+ : root.kind === 'split'
182
+ ? (findNode(root.children[0], id) ?? findNode(root.children[1], id))
183
+ : undefined;
184
+ }
185
+ export function findParent(root: Node, id: string): import('./types.js').Split | undefined {
186
+ if (root.kind === 'group') return undefined;
187
+ return root.children.some((n) => n.id === id)
188
+ ? root
189
+ : (findParent(root.children[0], id) ?? findParent(root.children[1], id));
190
+ }
191
+ export function groups(root: Node): Group[] {
192
+ return root.kind === 'group' ? [root] : root.children.flatMap(groups);
193
+ }
194
+ export function paneIds(root: Node): string[] {
195
+ return groups(root).flatMap((g) => g.panes);
196
+ }
197
+ function paneBounds(p: Pane): Bounds {
198
+ return {
199
+ minWidth: p.size?.minWidth ?? 0,
200
+ maxWidth: p.size?.maxWidth ?? Infinity,
201
+ minHeight: p.size?.minHeight ?? 0,
202
+ maxHeight: p.size?.maxHeight ?? Infinity,
203
+ };
204
+ }
205
+ export const DIVIDER = 4;
206
+ export function bounds(node: Node, layout: Layout): Bounds {
207
+ if (node.kind === 'group') {
208
+ const values = node.panes.map((id) => paneBounds(layout.panes[id]!));
209
+ return {
210
+ minWidth: Math.max(0, ...values.map((b) => b.minWidth)),
211
+ maxWidth: Math.min(Infinity, ...values.map((b) => b.maxWidth)),
212
+ minHeight: Math.max(0, ...values.map((b) => b.minHeight)),
213
+ maxHeight: Math.min(Infinity, ...values.map((b) => b.maxHeight)),
214
+ };
215
+ }
216
+ const a = bounds(node.children[0], layout),
217
+ b = bounds(node.children[1], layout);
218
+ return node.axis === 'horizontal'
219
+ ? {
220
+ minWidth: a.minWidth + b.minWidth + (node.gap ?? DIVIDER),
221
+ maxWidth: a.maxWidth + b.maxWidth + (node.gap ?? DIVIDER),
222
+ minHeight: Math.max(a.minHeight, b.minHeight),
223
+ maxHeight: Math.max(a.maxHeight, b.maxHeight),
224
+ }
225
+ : {
226
+ minHeight: a.minHeight + b.minHeight + (node.gap ?? DIVIDER),
227
+ maxHeight: a.maxHeight + b.maxHeight + (node.gap ?? DIVIDER),
228
+ minWidth: Math.max(a.minWidth, b.minWidth),
229
+ maxWidth: Math.max(a.maxWidth, b.maxWidth),
230
+ };
231
+ }
232
+ /** Returns content extents; overflow retains minimums, surplus beyond maximums stays empty. */
233
+ export function allocate(
234
+ total: number,
235
+ ratio: number,
236
+ minA: number,
237
+ maxA: number,
238
+ minB: number,
239
+ maxB: number,
240
+ gap = DIVIDER,
241
+ ): [number, number] {
242
+ const available = Math.max(total - gap, minA + minB);
243
+ const used = Math.min(available, maxA + maxB);
244
+ const low = Math.max(minA, used - maxB),
245
+ high = Math.min(maxA, used - minB);
246
+ const a = Math.max(low, Math.min(high, used * ratio));
247
+ return [a, used - a];
248
+ }
249
+
250
+ /** Create independent, validated JSON for an empty or single-pane workspace. */
251
+ export function createLayout(options: { pane?: Pane; groupId?: string } = {}): Layout {
252
+ const { pane, groupId = 'main' } = options;
253
+ return parseLayout({
254
+ version: 1,
255
+ root: { kind: 'group', id: groupId, panes: pane ? [pane.id] : [], active: pane?.id ?? null },
256
+ panes: pane ? { [pane.id]: pane } : {},
257
+ popouts: [],
258
+ maximized: null,
259
+ });
260
+ }
package/src/types.ts ADDED
@@ -0,0 +1,90 @@
1
+ export type AutoCollapse = 'enabled' | 'protected' | 'disabled';
2
+ export interface LayoutStoreOptions {
3
+ /** Session policy; omitted defaults to disabled. Not serialized in Layout JSON. */
4
+ autoCollapse?: AutoCollapse;
5
+ }
6
+ export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
7
+ export type Capability = 'resize' | 'move' | 'split' | 'join' | 'close' | 'popout';
8
+ export type Axis = 'horizontal' | 'vertical';
9
+ export interface Pane {
10
+ id: string;
11
+ type: string;
12
+ title: string;
13
+ /** Application-defined icon key, resolved by the view adapter. */
14
+ icon?: string;
15
+ params?: Json;
16
+ capabilities?: Partial<Record<Capability, boolean>>;
17
+ size?: { minWidth?: number; maxWidth?: number; minHeight?: number; maxHeight?: number };
18
+ /** Hide chrome only for a one-tab group. Host commands can still operate on it. */
19
+ header?: boolean;
20
+ }
21
+ export interface Group {
22
+ kind: 'group';
23
+ id: string;
24
+ panes: string[];
25
+ active: string | null;
26
+ /** Persisted tab orientation for this region; omitted inherits renderer settings. */
27
+ tabPlacement?: 'top' | 'left';
28
+ /** Omitted inherits the workspace tab display setting. */
29
+ tabDisplay?: 'automatic' | 'compact';
30
+ }
31
+ export interface Split {
32
+ kind: 'split';
33
+ id: string;
34
+ axis: Axis;
35
+ ratio: number;
36
+ /** Divider space in pixels; zero removes fixed-bar gaps. Default 4. */
37
+ gap?: number;
38
+ children: [Node, Node];
39
+ }
40
+ export type Node = Group | Split;
41
+ export interface WindowPlacement {
42
+ width?: number;
43
+ height?: number;
44
+ left?: number;
45
+ top?: number;
46
+ }
47
+ export interface Popout {
48
+ paneId: string;
49
+ groupId: string;
50
+ index: number;
51
+ placement?: WindowPlacement;
52
+ }
53
+ export interface Layout {
54
+ version: 1;
55
+ root: Node;
56
+ panes: Record<string, Pane>;
57
+ popouts: Popout[];
58
+ maximized: string | null;
59
+ }
60
+ export interface Issue {
61
+ path: string;
62
+ message: string;
63
+ }
64
+ export class LayoutError extends Error {
65
+ constructor(public readonly issues: Issue[]) {
66
+ super(issues.map((i) => `${i.path}: ${i.message}`).join('; '));
67
+ this.name = 'LayoutError';
68
+ }
69
+ }
70
+ export interface CommandOptions {
71
+ /** Treat capability flags as user-interaction restrictions. */ source?: 'user' | 'api';
72
+ }
73
+ export interface Change {
74
+ action: string;
75
+ layout: Layout;
76
+ }
77
+ export interface Bounds {
78
+ minWidth: number;
79
+ maxWidth: number;
80
+ minHeight: number;
81
+ maxHeight: number;
82
+ }
83
+
84
+ export interface JoinOptions extends CommandOptions {
85
+ /** Optional rendered extents along the join axis, keyed by node ID, including splits.
86
+ * Supply the complete row to retain actual sizes under constraints and custom dividers.
87
+ * Without measurements, joins retain proportional shares of the row/column.
88
+ */
89
+ extents?: Record<string, number>;
90
+ }