@nativedesktop/panes 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/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@nativedesktop/panes",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/FormalSnake/NativeDesktop#readme",
7
+ "bugs": "https://github.com/FormalSnake/NativeDesktop/issues",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/FormalSnake/NativeDesktop.git",
11
+ "directory": "packages/panes"
12
+ },
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "files": ["src"],
17
+ "main": "./src/index.ts",
18
+ "types": "./src/index.ts",
19
+ "exports": {
20
+ ".": "./src/index.ts"
21
+ },
22
+ "peerDependencies": {
23
+ "@nativedesktop/react": "^0.1.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/react": "19.2.17",
27
+ "react": "19.2.7"
28
+ }
29
+ }
@@ -0,0 +1,136 @@
1
+ /** @jsxImportSource @nativedesktop/react */
2
+ // The pragma is load-bearing: this is a raw-src package, and Bun transpiles
3
+ // node_modules TSX with the ROOT tsconfig's JSX settings, so the per-file
4
+ // pragma is what guarantees the ND jsx-runtime regardless of the consumer.
5
+ //
6
+ // Hooks come from @nativedesktop/react, never react directly (dev-react.ts's
7
+ // pinned-dispatcher contract for `nd dev` hot re-eval).
8
+
9
+ import { useRef, useState } from "@nativedesktop/react";
10
+ import type { ReactNode } from "react";
11
+ import {
12
+ closePane,
13
+ focusNeighbor,
14
+ focusPane,
15
+ focusPaneAt,
16
+ setPaneRatio,
17
+ splitPane,
18
+ updatePane,
19
+ } from "./model.ts";
20
+ import type { PaneModel, PaneNode, SplitOrientation } from "./model.ts";
21
+
22
+ export interface PaneTreeProps<T> {
23
+ model: PaneModel<T>;
24
+ onChange: (next: PaneModel<T>) => void;
25
+ /** Owns all per-pane chrome (focus ring, toolbar); PaneTree supplies only
26
+ * the flags and one expanding <box> wrapper per leaf. */
27
+ renderLeaf: (ctx: { id: string; data: T; focused: boolean; solo: boolean }) => ReactNode;
28
+ testID?: string;
29
+ }
30
+
31
+ export function PaneTree<T>(props: PaneTreeProps<T>): ReactNode {
32
+ // Latest-ref: the native positionChanged echo lands via a handler captured
33
+ // at an earlier render; applying it against that render's model would
34
+ // revert everything committed since (a concurrent split, another divider's
35
+ // drag). Both refs are refreshed every render, so handlers always see the
36
+ // current model and onChange.
37
+ const modelRef = useRef(props.model);
38
+ modelRef.current = props.model;
39
+ const onChangeRef = useRef(props.onChange);
40
+ onChangeRef.current = props.onChange;
41
+
42
+ const { model, renderLeaf, testID } = props;
43
+ if (!model.root) return null;
44
+ const solo = model.root.kind === "leaf";
45
+
46
+ function renderNode(node: PaneNode<T>): ReactNode {
47
+ if (node.kind === "leaf") {
48
+ return (
49
+ <box
50
+ key={node.id}
51
+ style={{ hexpand: true, vexpand: true }}
52
+ testID={testID ? `${testID}-leaf-${node.id}` : undefined}
53
+ >
54
+ {renderLeaf({ id: node.id, data: node.data, focused: node.id === model.focusedId, solo })}
55
+ </box>
56
+ );
57
+ }
58
+ return (
59
+ // Keyed on the split node id: orientation is create-only on both
60
+ // backends, so a structural collapse landing a DIFFERENT split at this
61
+ // position must remount rather than mutate.
62
+ <paned
63
+ key={node.id}
64
+ orientation={node.orientation}
65
+ position={node.ratio}
66
+ testID={testID ? `${testID}-split-${node.id}` : undefined}
67
+ onPositionChanged={(e) => {
68
+ // Exact 0/1 (or non-finite) is dropped: structural commits racing
69
+ // the backend's debounced echo report a zero-size mid-layout
70
+ // artifact at exactly those values, and feeding one to the model
71
+ // would collapse the pane on the next render. Anything inside
72
+ // (0, 1) is a settled drag and flows through — AppKit pins a
73
+ // 120pt minimum pane extent, but GTK only floors at the CHILD's
74
+ // own minimum size, so a min-size-zero child can rest out past
75
+ // the clamp bounds; setPaneRatio's clamp then updates the model
76
+ // and the position prop write snaps the native divider back to
77
+ // the bound instead of desyncing the two.
78
+ if (!Number.isFinite(e.position) || e.position <= 0 || e.position >= 1) return;
79
+ const current = modelRef.current;
80
+ // setPaneRatio returns the same reference when the clamped ratio is
81
+ // unchanged; skipping onChange there is what stops the programmatic
82
+ // write -> echo -> render -> write loop.
83
+ const next = setPaneRatio(current, node.id, e.position);
84
+ if (next !== current) onChangeRef.current(next);
85
+ }}
86
+ >
87
+ {renderNode(node.children[0])}
88
+ {renderNode(node.children[1])}
89
+ </paned>
90
+ );
91
+ }
92
+
93
+ return renderNode(model.root);
94
+ }
95
+
96
+ export interface UsePaneTree<T> {
97
+ model: PaneModel<T>;
98
+ /** The latest-ref invariant, built in: reads the model as of the last op,
99
+ * not the last render. */
100
+ latest: () => PaneModel<T>;
101
+ setModel(m: PaneModel<T>): void;
102
+ split(paneId: string, o: SplitOrientation, data: T): void;
103
+ close(paneId: string): void;
104
+ focus(paneId: string): void;
105
+ focusAt(index: number): void;
106
+ focusNeighbor(dir: "left" | "right" | "up" | "down"): void;
107
+ setRatio(splitId: string, ratio: number): void;
108
+ update(paneId: string, fn: (d: T) => T): void;
109
+ }
110
+
111
+ /** Holds the model in state and applies every op against a ref, never the
112
+ * render-time model, so an await-resuming op can't revert a concurrent
113
+ * divider drag. */
114
+ export function usePaneTree<T>(initial: PaneModel<T> | (() => PaneModel<T>)): UsePaneTree<T> {
115
+ const [model, setState] = useState(initial);
116
+ const ref = useRef(model);
117
+
118
+ const apply = (next: PaneModel<T>): void => {
119
+ if (next === ref.current) return;
120
+ ref.current = next;
121
+ setState(next);
122
+ };
123
+
124
+ return {
125
+ model,
126
+ latest: () => ref.current,
127
+ setModel: apply,
128
+ split: (paneId, o, data) => apply(splitPane(ref.current, paneId, o, data)),
129
+ close: (paneId) => apply(closePane(ref.current, paneId)),
130
+ focus: (paneId) => apply(focusPane(ref.current, paneId)),
131
+ focusAt: (index) => apply(focusPaneAt(ref.current, index)),
132
+ focusNeighbor: (dir) => apply(focusNeighbor(ref.current, dir)),
133
+ setRatio: (splitId, ratio) => apply(setPaneRatio(ref.current, splitId, ratio)),
134
+ update: (paneId, fn) => apply(updatePane(ref.current, paneId, fn)),
135
+ };
136
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ export {
2
+ clampPaneRatio,
3
+ closePane,
4
+ emptyPanes,
5
+ focusNeighbor,
6
+ focusPane,
7
+ focusPaneAt,
8
+ migratePanes,
9
+ paneIdAt,
10
+ paneLeaves,
11
+ samePaneShape,
12
+ seedPanes,
13
+ setPaneRatio,
14
+ splitPane,
15
+ updatePane,
16
+ } from "./model.ts";
17
+ export type { PaneLeaf, PaneModel, PaneNode, PaneSplit, SplitOrientation } from "./model.ts";
18
+ export { PaneTree, usePaneTree } from "./PaneTree.tsx";
19
+ export type { PaneTreeProps, UsePaneTree } from "./PaneTree.tsx";
@@ -0,0 +1,268 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ clampPaneRatio,
4
+ closePane,
5
+ emptyPanes,
6
+ focusNeighbor,
7
+ focusPane,
8
+ focusPaneAt,
9
+ migratePanes,
10
+ paneIdAt,
11
+ paneLeaves,
12
+ samePaneShape,
13
+ seedPanes,
14
+ setPaneRatio,
15
+ splitPane,
16
+ updatePane,
17
+ } from "./model.ts";
18
+ import type { PaneModel, PaneSplit } from "./model.ts";
19
+
20
+ describe("clampPaneRatio", () => {
21
+ test("non-finite becomes 0.5, everything else clamps to [0.05, 0.95]", () => {
22
+ expect(clampPaneRatio(NaN)).toBe(0.5);
23
+ expect(clampPaneRatio(Infinity)).toBe(0.5);
24
+ expect(clampPaneRatio(-Infinity)).toBe(0.5);
25
+ expect(clampPaneRatio(-1)).toBe(0.05);
26
+ expect(clampPaneRatio(0)).toBe(0.05);
27
+ expect(clampPaneRatio(0.3)).toBe(0.3);
28
+ expect(clampPaneRatio(2)).toBe(0.95);
29
+ });
30
+ });
31
+
32
+ describe("seedPanes", () => {
33
+ test("empty input is the empty model", () => {
34
+ expect(seedPanes([])).toEqual(emptyPanes());
35
+ });
36
+
37
+ test("one leaf: root is that leaf, focused, nextId past it", () => {
38
+ const m = seedPanes(["a"]);
39
+ expect(m.root).toEqual({ kind: "leaf", id: "1", data: "a" });
40
+ expect(m.focusedId).toBe("1");
41
+ expect(m.nextId).toBe(2);
42
+ });
43
+
44
+ test("three leaves build a binary tree with all leaves in order", () => {
45
+ const m = seedPanes(["a", "b", "c"]);
46
+ expect(paneLeaves(m).map((l) => l.data)).toEqual(["a", "b", "c"]);
47
+ expect(m.root!.kind).toBe("split");
48
+ });
49
+
50
+ test("custom id function is used", () => {
51
+ const m = seedPanes(["a", "b"], (_d, i) => `pane-${i}`);
52
+ expect(paneLeaves(m).map((l) => l.id)).toEqual(["pane-0", "pane-1"]);
53
+ });
54
+
55
+ test("numeric custom ids advance nextId past them, so splits mint fresh ids", () => {
56
+ const m = seedPanes(["a", "b"], (d) => (d === "a" ? "7" : "8"));
57
+ expect(paneLeaves(m).map((l) => l.id)).toEqual(["7", "8"]);
58
+ expect((m.root as PaneSplit<string>).id).toBe("s9");
59
+ expect(m.nextId).toBe(10);
60
+ const split = splitPane(m, "7", "horizontal", "c");
61
+ expect(paneLeaves(split).map((l) => l.id)).toEqual(["7", "10", "8"]);
62
+ });
63
+ });
64
+
65
+ describe("splitPane", () => {
66
+ test("splits a leaf, focuses the new leaf, mints ids from nextId", () => {
67
+ const m0 = seedPanes(["a"]);
68
+ const m1 = splitPane(m0, "1", "horizontal", "b");
69
+ const root = m1.root as PaneSplit<string>;
70
+ expect(root.kind).toBe("split");
71
+ expect(root.id).toBe("s2");
72
+ expect(root.orientation).toBe("horizontal");
73
+ expect(root.ratio).toBe(0.5);
74
+ expect(paneLeaves(m1).map((l) => l.id)).toEqual(["1", "2"]);
75
+ expect(m1.focusedId).toBe("2");
76
+ expect(m1.nextId).toBe(3);
77
+ });
78
+
79
+ test("unknown pane id is a same-reference no-op", () => {
80
+ const m = seedPanes(["a"]);
81
+ expect(splitPane(m, "nope", "vertical", "b")).toBe(m);
82
+ });
83
+
84
+ test("splitting a split id is a same-reference no-op", () => {
85
+ const m = splitPane(seedPanes(["a"]), "1", "horizontal", "b");
86
+ expect(splitPane(m, "s2", "vertical", "c")).toBe(m);
87
+ });
88
+ });
89
+
90
+ describe("closePane", () => {
91
+ test("closing one of two leaves collapses the split to the sibling", () => {
92
+ const m1 = splitPane(seedPanes(["a"]), "1", "horizontal", "b");
93
+ const m2 = closePane(m1, "2");
94
+ expect(m2.root).toEqual({ kind: "leaf", id: "1", data: "a" });
95
+ expect(m2.focusedId).toBe("1");
96
+ });
97
+
98
+ test("closing an unfocused leaf keeps the focus", () => {
99
+ const m1 = splitPane(seedPanes(["a"]), "1", "horizontal", "b"); // focus 2
100
+ const m2 = closePane(m1, "1");
101
+ expect(m2.focusedId).toBe("2");
102
+ });
103
+
104
+ test("closing the root leaf empties the tree", () => {
105
+ const m = closePane(seedPanes(["a"]), "1");
106
+ expect(m.root).toBeUndefined();
107
+ expect(m.focusedId).toBe("");
108
+ });
109
+
110
+ test("unknown id is a same-reference no-op", () => {
111
+ const m = seedPanes(["a"]);
112
+ expect(closePane(m, "9")).toBe(m);
113
+ });
114
+
115
+ test("nested close replaces the parent split with the sibling subtree", () => {
116
+ let m = splitPane(seedPanes(["a"]), "1", "horizontal", "b"); // s2[1, 2]
117
+ m = splitPane(m, "2", "vertical", "c"); // s2[1, s3[2, 3]]
118
+ const closed = closePane(m, "3");
119
+ const root = closed.root as PaneSplit<string>;
120
+ expect(root.id).toBe("s2");
121
+ expect(paneLeaves(closed).map((l) => l.id)).toEqual(["1", "2"]);
122
+ expect(closed.focusedId).toBe("2"); // 3 was focused; sibling takes over
123
+ });
124
+ });
125
+
126
+ describe("focus", () => {
127
+ test("focusPane moves focus; already-focused is a same-reference no-op", () => {
128
+ const m1 = splitPane(seedPanes(["a"]), "1", "horizontal", "b");
129
+ const m2 = focusPane(m1, "1");
130
+ expect(m2.focusedId).toBe("1");
131
+ expect(focusPane(m2, "1")).toBe(m2);
132
+ });
133
+
134
+ test("focusPane on an unknown or split id is a no-op", () => {
135
+ const m = splitPane(seedPanes(["a"]), "1", "horizontal", "b");
136
+ expect(focusPane(m, "s2")).toBe(m);
137
+ expect(focusPane(m, "zzz")).toBe(m);
138
+ });
139
+
140
+ test("focusPaneAt / paneIdAt index leaves in layout order", () => {
141
+ let m = splitPane(seedPanes(["a"]), "1", "horizontal", "b");
142
+ m = splitPane(m, "2", "vertical", "c");
143
+ expect(paneIdAt(m, 0)).toBe("1");
144
+ expect(paneIdAt(m, 2)).toBe("3");
145
+ expect(paneIdAt(m, 3)).toBeUndefined();
146
+ expect(focusPaneAt(m, 0).focusedId).toBe("1");
147
+ expect(focusPaneAt(m, 9)).toBe(m);
148
+ });
149
+
150
+ test("focusNeighbor walks across the matching-axis split", () => {
151
+ // s2 horizontal [1, s3 vertical [2, 3]]
152
+ let m = splitPane(seedPanes(["a"]), "1", "horizontal", "b");
153
+ m = splitPane(m, "2", "vertical", "c");
154
+ m = focusPane(m, "1");
155
+ const right = focusNeighbor(m, "right");
156
+ expect(right.focusedId).toBe("2"); // nearest leaf along the shared edge
157
+ const down = focusNeighbor(right, "down");
158
+ expect(down.focusedId).toBe("3");
159
+ const up = focusNeighbor(down, "up");
160
+ expect(up.focusedId).toBe("2");
161
+ const left = focusNeighbor(up, "left");
162
+ expect(left.focusedId).toBe("1");
163
+ });
164
+
165
+ test("focusNeighbor with no neighbor on that side is a same-reference no-op", () => {
166
+ const m = splitPane(seedPanes(["a"]), "1", "horizontal", "b"); // focus 2
167
+ expect(focusNeighbor(m, "right")).toBe(m);
168
+ expect(focusNeighbor(m, "up")).toBe(m);
169
+ });
170
+ });
171
+
172
+ describe("setPaneRatio", () => {
173
+ test("sets the clamped ratio on the split", () => {
174
+ const m1 = splitPane(seedPanes(["a"]), "1", "horizontal", "b");
175
+ const m2 = setPaneRatio(m1, "s2", 0.3);
176
+ expect((m2.root as PaneSplit<string>).ratio).toBe(0.3);
177
+ const m3 = setPaneRatio(m2, "s2", 99);
178
+ expect((m3.root as PaneSplit<string>).ratio).toBe(0.95);
179
+ });
180
+
181
+ test("same clamped ratio is a same-reference no-op (echo guard)", () => {
182
+ const m1 = splitPane(seedPanes(["a"]), "1", "horizontal", "b");
183
+ const m2 = setPaneRatio(m1, "s2", 0.3);
184
+ expect(setPaneRatio(m2, "s2", 0.3)).toBe(m2);
185
+ expect(setPaneRatio(m2, "s2", NaN)).not.toBe(m2); // NaN clamps to 0.5
186
+ });
187
+
188
+ test("unknown or leaf id is a same-reference no-op", () => {
189
+ const m = splitPane(seedPanes(["a"]), "1", "horizontal", "b");
190
+ expect(setPaneRatio(m, "1", 0.3)).toBe(m);
191
+ expect(setPaneRatio(m, "zzz", 0.3)).toBe(m);
192
+ });
193
+ });
194
+
195
+ describe("updatePane", () => {
196
+ test("replaces leaf data; identical data is a same-reference no-op", () => {
197
+ const m1 = seedPanes([{ n: 1 }]);
198
+ const m2 = updatePane(m1, "1", (d) => ({ ...d, n: 2 }));
199
+ expect(paneLeaves(m2)[0]!.data).toEqual({ n: 2 });
200
+ expect(updatePane(m2, "1", (d) => d)).toBe(m2);
201
+ expect(updatePane(m2, "zzz", (d) => d)).toBe(m2);
202
+ });
203
+ });
204
+
205
+ describe("samePaneShape", () => {
206
+ test("ratio and data changes keep the shape; structure changes break it", () => {
207
+ const m1 = splitPane(seedPanes(["a"]), "1", "horizontal", "b");
208
+ const ratioChanged = setPaneRatio(m1, "s2", 0.2);
209
+ const dataChanged = updatePane(m1, "1", () => "z");
210
+ expect(samePaneShape(m1.root, ratioChanged.root)).toBe(true);
211
+ expect(samePaneShape(m1.root, dataChanged.root)).toBe(true);
212
+ expect(samePaneShape(m1.root, splitPane(m1, "2", "vertical", "c").root)).toBe(false);
213
+ expect(samePaneShape(m1.root, closePane(m1, "2").root)).toBe(false);
214
+ expect(samePaneShape(undefined, undefined)).toBe(true);
215
+ expect(samePaneShape(m1.root, undefined)).toBe(false);
216
+ });
217
+ });
218
+
219
+ describe("migratePanes", () => {
220
+ test("round-trips a real model through JSON", () => {
221
+ let m = splitPane(seedPanes(["a"]), "1", "horizontal", "b");
222
+ m = splitPane(m, "2", "vertical", "c");
223
+ m = setPaneRatio(m, "s2", 0.3);
224
+ const revived = migratePanes<string>(JSON.parse(JSON.stringify(m)), (d): d is string => typeof d === "string");
225
+ expect(revived).toEqual(m);
226
+ });
227
+
228
+ test("garbage input yields the empty model", () => {
229
+ expect(migratePanes(null)).toEqual(emptyPanes());
230
+ expect(migratePanes("nope")).toEqual(emptyPanes());
231
+ expect(migratePanes({ root: { kind: "what" } })).toEqual(emptyPanes());
232
+ expect(migratePanes({ root: { kind: "split", id: "s1", orientation: "diagonal", children: [] } })).toEqual(
233
+ emptyPanes(),
234
+ );
235
+ });
236
+
237
+ test("leaf data failing the guard rejects the whole tree", () => {
238
+ const bad = { root: { kind: "leaf", id: "1", data: 42 }, focusedId: "1", nextId: 2 };
239
+ expect(migratePanes<string>(bad, (d): d is string => typeof d === "string")).toEqual(emptyPanes());
240
+ });
241
+
242
+ test("ratios are clamped, focus falls back to the first leaf, nextId is recomputed", () => {
243
+ const raw = {
244
+ root: {
245
+ kind: "split",
246
+ id: "s9",
247
+ orientation: "horizontal",
248
+ ratio: 47,
249
+ children: [
250
+ { kind: "leaf", id: "4", data: "a" },
251
+ { kind: "leaf", id: "7", data: "b" },
252
+ ],
253
+ },
254
+ focusedId: "gone",
255
+ nextId: "bogus",
256
+ };
257
+ const m = migratePanes<string>(raw, (d): d is string => typeof d === "string");
258
+ expect((m.root as PaneSplit<string>).ratio).toBe(0.95);
259
+ expect(m.focusedId).toBe("4");
260
+ expect(m.nextId).toBe(10); // past s9
261
+ });
262
+
263
+ test("an explicitly empty root is preserved", () => {
264
+ const m: PaneModel<string> = migratePanes({ root: null, focusedId: "", nextId: 5 });
265
+ expect(m.root).toBeUndefined();
266
+ expect(m.nextId).toBe(5);
267
+ });
268
+ });
package/src/model.ts ADDED
@@ -0,0 +1,298 @@
1
+ // Pure split-pane tree model over the existing <paned> widget: no schema
2
+ // change, no new widget, no ABI change. Every op returns the SAME reference
3
+ // when nothing changed (unknown id, already focused, same clamped ratio),
4
+ // which is what stops a native positionChanged echo from looping through a
5
+ // render+persist cycle.
6
+
7
+ export type SplitOrientation = "horizontal" | "vertical";
8
+
9
+ export interface PaneLeaf<T> {
10
+ kind: "leaf";
11
+ id: string;
12
+ data: T;
13
+ }
14
+
15
+ export interface PaneSplit<T> {
16
+ kind: "split";
17
+ id: string;
18
+ orientation: SplitOrientation;
19
+ ratio: number;
20
+ children: [PaneNode<T>, PaneNode<T>];
21
+ }
22
+
23
+ export type PaneNode<T> = PaneLeaf<T> | PaneSplit<T>;
24
+
25
+ export interface PaneModel<T> {
26
+ root: PaneNode<T> | undefined;
27
+ focusedId: string;
28
+ nextId: number;
29
+ }
30
+
31
+ /** Non-finite ratios panic a Debug/ReleaseSafe host, so this is a safety
32
+ * boundary, not a nicety: non-finite becomes 0.5, everything else clamps to
33
+ * [0.05, 0.95] so neither side collapses to an undraggable sliver. */
34
+ export function clampPaneRatio(ratio: number): number {
35
+ if (!Number.isFinite(ratio)) return 0.5;
36
+ return Math.min(0.95, Math.max(0.05, ratio));
37
+ }
38
+
39
+ export function emptyPanes<T>(): PaneModel<T> {
40
+ return { root: undefined, focusedId: "", nextId: 1 };
41
+ }
42
+
43
+ /** Builds a balanced horizontal-split tree over `leaves` (even column widths
44
+ * via per-split ratios). Leaf ids default to "1".."n". */
45
+ export function seedPanes<T>(leaves: readonly T[], id?: (data: T, i: number) => string): PaneModel<T> {
46
+ if (leaves.length === 0) return emptyPanes<T>();
47
+ const leafNodes: PaneLeaf<T>[] = leaves.map((data, i) => ({
48
+ kind: "leaf",
49
+ id: id ? id(data, i) : String(i + 1),
50
+ data,
51
+ }));
52
+ // Custom leaf ids can collide with ids minted later (`s${n}` below,
53
+ // `String(nextId)` in splitPane), so the counter starts past every numeric
54
+ // id actually issued — the same /^s?(\d+)$/ scan migratePanes uses.
55
+ let maxNumericId = 0;
56
+ for (const leaf of leafNodes) {
57
+ const match = /^s?(\d+)$/.exec(leaf.id);
58
+ if (match) maxNumericId = Math.max(maxNumericId, Number(match[1]));
59
+ }
60
+ let counter = Math.max(leaves.length, maxNumericId) + 1;
61
+ function build(lo: number, hi: number): PaneNode<T> {
62
+ if (hi - lo === 1) return leafNodes[lo]!;
63
+ const mid = lo + Math.ceil((hi - lo) / 2);
64
+ const splitId = `s${counter++}`;
65
+ return {
66
+ kind: "split",
67
+ id: splitId,
68
+ orientation: "horizontal",
69
+ ratio: (mid - lo) / (hi - lo),
70
+ children: [build(lo, mid), build(mid, hi)],
71
+ };
72
+ }
73
+ const root = build(0, leafNodes.length);
74
+ return { root, focusedId: leafNodes[0]!.id, nextId: counter };
75
+ }
76
+
77
+ function findNode<T>(node: PaneNode<T> | undefined, id: string): PaneNode<T> | undefined {
78
+ if (!node) return undefined;
79
+ if (node.id === id) return node;
80
+ if (node.kind === "split") return findNode(node.children[0], id) ?? findNode(node.children[1], id);
81
+ return undefined;
82
+ }
83
+
84
+ function firstLeaf<T>(node: PaneNode<T>): PaneLeaf<T> {
85
+ return node.kind === "leaf" ? node : firstLeaf(node.children[0]);
86
+ }
87
+
88
+ /** Path-copies the subtree containing `id`, applying `fn` to that node.
89
+ * Returns undefined when `id` is not in the tree. */
90
+ function mapNode<T>(node: PaneNode<T>, id: string, fn: (node: PaneNode<T>) => PaneNode<T>): PaneNode<T> | undefined {
91
+ if (node.id === id) return fn(node);
92
+ if (node.kind === "leaf") return undefined;
93
+ const [a, b] = node.children;
94
+ const ma = mapNode(a, id, fn);
95
+ if (ma) return { ...node, children: [ma, b] };
96
+ const mb = mapNode(b, id, fn);
97
+ if (mb) return { ...node, children: [a, mb] };
98
+ return undefined;
99
+ }
100
+
101
+ /** Splits the leaf `paneId` in two: the existing leaf keeps side 0, the new
102
+ * leaf (holding `data`) takes side 1 and the focus. */
103
+ export function splitPane<T>(
104
+ m: PaneModel<T>,
105
+ paneId: string,
106
+ orientation: SplitOrientation,
107
+ data: T,
108
+ id?: string,
109
+ ): PaneModel<T> {
110
+ const target = findNode(m.root, paneId);
111
+ if (!target || target.kind !== "leaf") return m;
112
+ const newLeafId = id ?? String(m.nextId);
113
+ const newLeaf: PaneLeaf<T> = { kind: "leaf", id: newLeafId, data };
114
+ const root = mapNode(m.root!, paneId, (leaf) => ({
115
+ kind: "split",
116
+ id: `s${m.nextId}`,
117
+ orientation,
118
+ ratio: 0.5,
119
+ children: [leaf, newLeaf],
120
+ }));
121
+ return { root, focusedId: newLeafId, nextId: m.nextId + 1 };
122
+ }
123
+
124
+ /** Removes the leaf `paneId`; its sibling subtree replaces the parent split.
125
+ * A focused close moves the focus to the sibling's first leaf. */
126
+ export function closePane<T>(m: PaneModel<T>, paneId: string): PaneModel<T> {
127
+ if (!m.root) return m;
128
+ let sibling: PaneNode<T> | undefined;
129
+ // false: not in this subtree; null: this whole node was the closed leaf.
130
+ function removeFrom(node: PaneNode<T>): PaneNode<T> | null | false {
131
+ if (node.kind === "leaf") return node.id === paneId ? null : false;
132
+ const [a, b] = node.children;
133
+ const ra = removeFrom(a);
134
+ if (ra === null) {
135
+ sibling = b;
136
+ return b;
137
+ }
138
+ if (ra !== false) return { ...node, children: [ra, b] };
139
+ const rb = removeFrom(b);
140
+ if (rb === null) {
141
+ sibling = a;
142
+ return a;
143
+ }
144
+ if (rb !== false) return { ...node, children: [a, rb] };
145
+ return false;
146
+ }
147
+ const removed = removeFrom(m.root);
148
+ if (removed === false) return m;
149
+ if (removed === null) return { root: undefined, focusedId: "", nextId: m.nextId };
150
+ const focusedId = m.focusedId === paneId ? firstLeaf(sibling!).id : m.focusedId;
151
+ return { root: removed, focusedId, nextId: m.nextId };
152
+ }
153
+
154
+ export function focusPane<T>(m: PaneModel<T>, paneId: string): PaneModel<T> {
155
+ if (m.focusedId === paneId) return m;
156
+ const node = findNode(m.root, paneId);
157
+ if (!node || node.kind !== "leaf") return m;
158
+ return { ...m, focusedId: paneId };
159
+ }
160
+
161
+ export function focusPaneAt<T>(m: PaneModel<T>, index: number): PaneModel<T> {
162
+ const id = paneIdAt(m, index);
163
+ return id === undefined ? m : focusPane(m, id);
164
+ }
165
+
166
+ export function paneIdAt<T>(m: PaneModel<T>, index: number): string | undefined {
167
+ return paneLeaves(m)[index]?.id;
168
+ }
169
+
170
+ /** Ancestor-walking directional focus: exact for a binary tree, no layout
171
+ * round-trip. Finds the nearest ancestor split on the move's axis where the
172
+ * focused subtree sits on the departing side, then descends into the other
173
+ * child along the shared edge. */
174
+ export function focusNeighbor<T>(m: PaneModel<T>, dir: "left" | "right" | "up" | "down"): PaneModel<T> {
175
+ if (!m.root || !m.focusedId) return m;
176
+ const path: PaneNode<T>[] = [];
177
+ function collect(node: PaneNode<T>): boolean {
178
+ path.push(node);
179
+ if (node.id === m.focusedId) return true;
180
+ if (node.kind === "split" && (collect(node.children[0]) || collect(node.children[1]))) return true;
181
+ path.pop();
182
+ return false;
183
+ }
184
+ if (!collect(m.root)) return m;
185
+ const axis: SplitOrientation = dir === "left" || dir === "right" ? "horizontal" : "vertical";
186
+ const forward = dir === "right" || dir === "down";
187
+ for (let i = path.length - 2; i >= 0; i--) {
188
+ const split = path[i] as PaneSplit<T>;
189
+ const childIndex = split.children[0] === path[i + 1] ? 0 : 1;
190
+ if (split.orientation !== axis || childIndex !== (forward ? 0 : 1)) continue;
191
+ let target = split.children[forward ? 1 : 0];
192
+ while (target.kind === "split") {
193
+ target = target.orientation === axis ? target.children[forward ? 0 : 1] : target.children[0];
194
+ }
195
+ return focusPane(m, target.id);
196
+ }
197
+ return m;
198
+ }
199
+
200
+ export function setPaneRatio<T>(m: PaneModel<T>, splitId: string, ratio: number): PaneModel<T> {
201
+ const node = findNode(m.root, splitId);
202
+ if (!node || node.kind !== "split") return m;
203
+ const clamped = clampPaneRatio(ratio);
204
+ if (clamped === node.ratio) return m;
205
+ const root = mapNode(m.root!, splitId, (split) => ({ ...(split as PaneSplit<T>), ratio: clamped }));
206
+ return { ...m, root };
207
+ }
208
+
209
+ export function updatePane<T>(m: PaneModel<T>, paneId: string, fn: (d: T) => T): PaneModel<T> {
210
+ const node = findNode(m.root, paneId);
211
+ if (!node || node.kind !== "leaf") return m;
212
+ const next = fn(node.data);
213
+ if (Object.is(next, node.data)) return m;
214
+ const root = mapNode(m.root!, paneId, (leaf) => ({ ...(leaf as PaneLeaf<T>), data: next }));
215
+ return { ...m, root };
216
+ }
217
+
218
+ /** Leaves in layout order (side 0 before side 1, depth-first). */
219
+ export function paneLeaves<T>(m: PaneModel<T>): PaneLeaf<T>[] {
220
+ const out: PaneLeaf<T>[] = [];
221
+ function walk(node: PaneNode<T> | undefined): void {
222
+ if (!node) return;
223
+ if (node.kind === "leaf") out.push(node);
224
+ else {
225
+ walk(node.children[0]);
226
+ walk(node.children[1]);
227
+ }
228
+ }
229
+ walk(m.root);
230
+ return out;
231
+ }
232
+
233
+ /** Structural equality: ids, kinds and orientations, ignoring ratios and leaf
234
+ * data. This is the "structural change" test for a persist-on-close flush. */
235
+ export function samePaneShape<T>(a: PaneNode<T> | undefined, b: PaneNode<T> | undefined): boolean {
236
+ if (a === b) return true;
237
+ if (!a || !b) return false;
238
+ if (a.kind !== b.kind || a.id !== b.id) return false;
239
+ if (a.kind === "leaf" || b.kind === "leaf") return true;
240
+ return (
241
+ a.orientation === b.orientation &&
242
+ samePaneShape(a.children[0], b.children[0]) &&
243
+ samePaneShape(a.children[1], b.children[1])
244
+ );
245
+ }
246
+
247
+ /** Rebuilds a model from persisted unknown data. Anything malformed (wrong
248
+ * kinds, missing ids, non-binary children, data failing `isData`) rejects the
249
+ * whole tree back to emptyPanes(); ratios are clamped, focus falls back to
250
+ * the first leaf, and nextId is recomputed past every numeric id. */
251
+ export function migratePanes<T>(raw: unknown, isData?: (d: unknown) => d is T): PaneModel<T> {
252
+ if (typeof raw !== "object" || raw === null) return emptyPanes<T>();
253
+ const candidate = raw as { root?: unknown; focusedId?: unknown; nextId?: unknown };
254
+ let maxNumericId = 0;
255
+ function noteId(id: string): void {
256
+ const match = /^s?(\d+)$/.exec(id);
257
+ if (match) maxNumericId = Math.max(maxNumericId, Number(match[1]));
258
+ }
259
+ function node(v: unknown): PaneNode<T> | null {
260
+ if (typeof v !== "object" || v === null) return null;
261
+ const n = v as { kind?: unknown; id?: unknown };
262
+ if (typeof n.id !== "string" || n.id.length === 0) return null;
263
+ if (n.kind === "leaf") {
264
+ const data = (v as { data?: unknown }).data;
265
+ if (isData && !isData(data)) return null;
266
+ noteId(n.id);
267
+ return { kind: "leaf", id: n.id, data: data as T };
268
+ }
269
+ if (n.kind === "split") {
270
+ const s = v as { orientation?: unknown; ratio?: unknown; children?: unknown };
271
+ if (s.orientation !== "horizontal" && s.orientation !== "vertical") return null;
272
+ if (!Array.isArray(s.children) || s.children.length !== 2) return null;
273
+ const a = node(s.children[0]);
274
+ const b = node(s.children[1]);
275
+ if (!a || !b) return null;
276
+ noteId(n.id);
277
+ return {
278
+ kind: "split",
279
+ id: n.id,
280
+ orientation: s.orientation,
281
+ ratio: clampPaneRatio(typeof s.ratio === "number" ? s.ratio : 0.5),
282
+ children: [a, b],
283
+ };
284
+ }
285
+ return null;
286
+ }
287
+ const root = candidate.root === undefined || candidate.root === null ? undefined : node(candidate.root);
288
+ if (root === null) return emptyPanes<T>();
289
+ const model: PaneModel<T> = { root, focusedId: "", nextId: 1 };
290
+ const leaves = paneLeaves(model);
291
+ if (leaves.length > 0) {
292
+ const focused = typeof candidate.focusedId === "string" ? candidate.focusedId : "";
293
+ model.focusedId = leaves.some((l) => l.id === focused) ? focused : leaves[0]!.id;
294
+ }
295
+ const rawNextId = typeof candidate.nextId === "number" && Number.isInteger(candidate.nextId) ? candidate.nextId : 1;
296
+ model.nextId = Math.max(rawNextId, maxNumericId + 1, 1);
297
+ return model;
298
+ }