@nativedesktop/panes 0.1.1 → 0.2.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 +18 -16
- package/src/DockView.tsx +312 -0
- package/src/TilesView.tsx +133 -0
- package/src/dock.test.ts +482 -0
- package/src/dock.ts +484 -0
- package/src/index.ts +60 -0
- package/src/model.ts +26 -12
- package/src/tiles.test.ts +262 -0
- package/src/tiles.ts +285 -0
package/src/dock.test.ts
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
activateTab,
|
|
4
|
+
activeDockTab,
|
|
5
|
+
activeDockTabIndex,
|
|
6
|
+
addTab,
|
|
7
|
+
applyDockDrop,
|
|
8
|
+
closePanel,
|
|
9
|
+
closeTab,
|
|
10
|
+
deserializeDock,
|
|
11
|
+
dockDragPayload,
|
|
12
|
+
dockPanel,
|
|
13
|
+
dockPanelOf,
|
|
14
|
+
dockPanelRects,
|
|
15
|
+
dockZoneAt,
|
|
16
|
+
emptyDock,
|
|
17
|
+
findDockPanel,
|
|
18
|
+
findDockTab,
|
|
19
|
+
hitTestDockZone,
|
|
20
|
+
moveTab,
|
|
21
|
+
parseDockDrag,
|
|
22
|
+
seedDock,
|
|
23
|
+
serializeDock,
|
|
24
|
+
undockTab,
|
|
25
|
+
} from "./dock.ts";
|
|
26
|
+
import type { DockModel, DockTab, DockZone } from "./dock.ts";
|
|
27
|
+
import { paneLeaves, setPaneRatio } from "./model.ts";
|
|
28
|
+
import type { PaneSplit } from "./model.ts";
|
|
29
|
+
|
|
30
|
+
type Panel = ReturnType<typeof dockPanelOf<string>>;
|
|
31
|
+
|
|
32
|
+
function tab(id: string): DockTab<string> {
|
|
33
|
+
return { id, title: id.toUpperCase(), data: id };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** s3 horizontal [ panel 1 (a, b), panel 2 (c) ], focus on panel 1. */
|
|
37
|
+
function base(): DockModel<string> {
|
|
38
|
+
return seedDock([[tab("a"), tab("b")], [tab("c")]]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const isString = (d: unknown): d is string => typeof d === "string";
|
|
42
|
+
|
|
43
|
+
function tabIds(m: DockModel<string>, panelId: string): string[] {
|
|
44
|
+
return findDockPanel(m, panelId)!.tabs.map((t) => t.id);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function panelIds(m: DockModel<string>): string[] {
|
|
48
|
+
return paneLeaves(m).map((l) => l.id);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("seedDock", () => {
|
|
52
|
+
test("one panel per tab group, first tab active, first panel focused", () => {
|
|
53
|
+
const m = base();
|
|
54
|
+
expect(panelIds(m)).toEqual(["1", "2"]);
|
|
55
|
+
expect(tabIds(m, "1")).toEqual(["a", "b"]);
|
|
56
|
+
expect(findDockPanel(m, "1")!.activeTabId).toBe("a");
|
|
57
|
+
expect(m.focusedId).toBe("1");
|
|
58
|
+
expect((m.root as PaneSplit<Panel>).orientation).toBe("horizontal");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("empty tab groups are dropped; nothing left is the empty dock", () => {
|
|
62
|
+
expect(panelIds(seedDock([[tab("a")], []]))).toEqual(["1"]);
|
|
63
|
+
expect(seedDock<string>([[], []])).toEqual(emptyDock<string>());
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("dockPanel edges", () => {
|
|
68
|
+
test("left puts the moved panel on side 0 of a horizontal split", () => {
|
|
69
|
+
const m = dockPanel(base(), "1", "2", "left");
|
|
70
|
+
const root = m.root as PaneSplit<Panel>;
|
|
71
|
+
expect(root.orientation).toBe("horizontal");
|
|
72
|
+
expect(root.children.map((c) => c.id)).toEqual(["1", "2"]);
|
|
73
|
+
expect(m.focusedId).toBe("1");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("right puts it on side 1", () => {
|
|
77
|
+
const m = dockPanel(base(), "1", "2", "right");
|
|
78
|
+
const root = m.root as PaneSplit<Panel>;
|
|
79
|
+
expect(root.orientation).toBe("horizontal");
|
|
80
|
+
expect(root.children.map((c) => c.id)).toEqual(["2", "1"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("top splits vertically with the moved panel first", () => {
|
|
84
|
+
const root = dockPanel(base(), "1", "2", "top").root as PaneSplit<Panel>;
|
|
85
|
+
expect(root.orientation).toBe("vertical");
|
|
86
|
+
expect(root.children.map((c) => c.id)).toEqual(["1", "2"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("bottom splits vertically with the moved panel second", () => {
|
|
90
|
+
const root = dockPanel(base(), "1", "2", "bottom").root as PaneSplit<Panel>;
|
|
91
|
+
expect(root.orientation).toBe("vertical");
|
|
92
|
+
expect(root.children.map((c) => c.id)).toEqual(["2", "1"]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("docking out of a nested split collapses the split it left", () => {
|
|
96
|
+
// s3 [1, s4 [2, 4]] -> dock 4 to the left of 1 -> s3 [s5 [4, 1], 2]
|
|
97
|
+
let m = seedDock([[tab("a")], [tab("b")]]);
|
|
98
|
+
m = undockTab(addTab(m, "2", tab("c")), "c");
|
|
99
|
+
expect(panelIds(m)).toEqual(["1", "2", "4"]);
|
|
100
|
+
const moved = dockPanel(m, "4", "1", "left");
|
|
101
|
+
const root = moved.root as PaneSplit<Panel>;
|
|
102
|
+
expect(root.id).toBe("s3");
|
|
103
|
+
expect((root.children[0] as PaneSplit<Panel>).children.map((c) => c.id)).toEqual(["4", "1"]);
|
|
104
|
+
expect(root.children[1].id).toBe("2"); // s4 collapsed when 4 left it
|
|
105
|
+
expect(panelIds(moved)).toEqual(["4", "1", "2"]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("tabs and the active tab ride along", () => {
|
|
109
|
+
const m = dockPanel(activateTab(base(), "b"), "1", "2", "top");
|
|
110
|
+
expect(tabIds(m, "1")).toEqual(["a", "b"]);
|
|
111
|
+
expect(findDockPanel(m, "1")!.activeTabId).toBe("b");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("dockPanel center", () => {
|
|
116
|
+
test("merges the moved panel's tabs into the target stack and collapses", () => {
|
|
117
|
+
const m = dockPanel(base(), "2", "1", "center");
|
|
118
|
+
expect(m.root).toMatchObject({ kind: "leaf", id: "1" });
|
|
119
|
+
expect(tabIds(m, "1")).toEqual(["a", "b", "c"]);
|
|
120
|
+
expect(findDockPanel(m, "1")!.activeTabId).toBe("c"); // the moved panel's active tab
|
|
121
|
+
expect(m.focusedId).toBe("1");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("merge is append, and the target keeps its own tab order", () => {
|
|
125
|
+
const m = dockPanel(base(), "1", "2", "center");
|
|
126
|
+
expect(tabIds(m, "2")).toEqual(["c", "a", "b"]);
|
|
127
|
+
expect(panelIds(m)).toEqual(["2"]);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("dockPanel no-ops", () => {
|
|
132
|
+
test("same panel, unknown panel and unknown target are same-reference", () => {
|
|
133
|
+
const m = base();
|
|
134
|
+
expect(dockPanel(m, "1", "1", "left")).toBe(m);
|
|
135
|
+
expect(dockPanel(m, "9", "1", "center")).toBe(m);
|
|
136
|
+
expect(dockPanel(m, "1", "9", "bottom")).toBe(m);
|
|
137
|
+
expect(dockPanel(m, "s3", "1", "left")).toBe(m); // a split is not a panel
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("docking a panel where it already sits is same-reference", () => {
|
|
141
|
+
const m = base(); // s3 horizontal [1, 2]
|
|
142
|
+
expect(dockPanel(m, "2", "1", "right")).toBe(m);
|
|
143
|
+
expect(dockPanel(m, "1", "2", "left")).toBe(m);
|
|
144
|
+
expect(dockPanel(m, "2", "1", "bottom")).not.toBe(m); // different axis
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("tabs", () => {
|
|
149
|
+
test("addTab appends and activates; a duplicate id is a no-op", () => {
|
|
150
|
+
const m = addTab(base(), "2", tab("d"));
|
|
151
|
+
expect(tabIds(m, "2")).toEqual(["c", "d"]);
|
|
152
|
+
expect(findDockPanel(m, "2")!.activeTabId).toBe("d");
|
|
153
|
+
expect(addTab(m, "2", tab("a"))).toBe(m); // "a" lives in panel 1
|
|
154
|
+
expect(addTab(m, "9", tab("z"))).toBe(m);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("addTab honours an index", () => {
|
|
158
|
+
expect(tabIds(addTab(base(), "1", tab("d"), 0), "1")).toEqual(["d", "a", "b"]);
|
|
159
|
+
expect(tabIds(addTab(base(), "1", tab("d"), 99), "1")).toEqual(["a", "b", "d"]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("activateTab selects inside the panel and leaves the focus alone", () => {
|
|
163
|
+
const m = activateTab(base(), "c");
|
|
164
|
+
expect(findDockPanel(m, "2")!.activeTabId).toBe("c");
|
|
165
|
+
expect(m.focusedId).toBe("1");
|
|
166
|
+
expect(activateTab(m, "c")).toBe(m);
|
|
167
|
+
expect(activateTab(m, "zzz")).toBe(m);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("closeTab hands the active slot to the neighbour", () => {
|
|
171
|
+
const m = closeTab(base(), "a");
|
|
172
|
+
expect(tabIds(m, "1")).toEqual(["b"]);
|
|
173
|
+
expect(findDockPanel(m, "1")!.activeTabId).toBe("b");
|
|
174
|
+
expect(closeTab(m, "zzz")).toBe(m);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("closing the last tab in a panel collapses the split", () => {
|
|
178
|
+
const m = closeTab(base(), "c");
|
|
179
|
+
expect(m.root).toMatchObject({ kind: "leaf", id: "1" });
|
|
180
|
+
expect(panelIds(m)).toEqual(["1"]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("closing the last tab of the last panel empties the dock", () => {
|
|
184
|
+
let m = closeTab(base(), "c");
|
|
185
|
+
m = closeTab(closeTab(m, "a"), "b");
|
|
186
|
+
expect(m.root).toBeUndefined();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("closePanel is closePane: the sibling takes the space", () => {
|
|
190
|
+
const m = closePanel(base(), "1");
|
|
191
|
+
expect(m.root).toMatchObject({ kind: "leaf", id: "2" });
|
|
192
|
+
expect(m.focusedId).toBe("2");
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("moveTab", () => {
|
|
197
|
+
test("reorders inside one panel without touching the active tab", () => {
|
|
198
|
+
const m = moveTab(base(), "a", "1", 1);
|
|
199
|
+
expect(tabIds(m, "1")).toEqual(["b", "a"]);
|
|
200
|
+
expect(findDockPanel(m, "1")!.activeTabId).toBe("a");
|
|
201
|
+
expect(m.focusedId).toBe("1");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("moving to the index it already holds is a same-reference no-op", () => {
|
|
205
|
+
const m = base();
|
|
206
|
+
expect(moveTab(m, "a", "1", 0)).toBe(m);
|
|
207
|
+
expect(moveTab(m, "b", "1")).toBe(m); // default index is the end, where b is
|
|
208
|
+
expect(moveTab(m, "zzz", "1", 0)).toBe(m);
|
|
209
|
+
expect(moveTab(m, "a", "9", 0)).toBe(m);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("moving across panels activates the tab there and takes the focus", () => {
|
|
213
|
+
const m = moveTab(base(), "a", "2", 0);
|
|
214
|
+
expect(tabIds(m, "1")).toEqual(["b"]);
|
|
215
|
+
expect(tabIds(m, "2")).toEqual(["a", "c"]);
|
|
216
|
+
expect(findDockPanel(m, "2")!.activeTabId).toBe("a");
|
|
217
|
+
expect(m.focusedId).toBe("2");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("emptying the source panel collapses it", () => {
|
|
221
|
+
const m = moveTab(base(), "c", "1");
|
|
222
|
+
expect(panelIds(m)).toEqual(["1"]);
|
|
223
|
+
expect(tabIds(m, "1")).toEqual(["a", "b", "c"]);
|
|
224
|
+
expect(m.focusedId).toBe("1");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("undockTab", () => {
|
|
229
|
+
test("pulls the tab into a new panel beside its host and focuses it", () => {
|
|
230
|
+
const m = undockTab(base(), "b");
|
|
231
|
+
const root = m.root as PaneSplit<Panel>;
|
|
232
|
+
expect(panelIds(m)).toEqual(["1", "4", "2"]);
|
|
233
|
+
expect(tabIds(m, "1")).toEqual(["a"]);
|
|
234
|
+
expect(tabIds(m, "4")).toEqual(["b"]);
|
|
235
|
+
expect(m.focusedId).toBe("4");
|
|
236
|
+
expect((root.children[0] as PaneSplit<Panel>).orientation).toBe("horizontal");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("the zone picks the side, default right", () => {
|
|
240
|
+
const left = undockTab(base(), "b", "left").root as PaneSplit<Panel>;
|
|
241
|
+
const host = left.children[0] as PaneSplit<Panel>;
|
|
242
|
+
expect(host.children.map((c) => c.id)).toEqual(["4", "1"]);
|
|
243
|
+
const down = undockTab(base(), "b", "bottom").root as PaneSplit<Panel>;
|
|
244
|
+
expect((down.children[0] as PaneSplit<Panel>).orientation).toBe("vertical");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("a panel holding one tab has nothing to undock", () => {
|
|
248
|
+
const m = base();
|
|
249
|
+
expect(undockTab(m, "c")).toBe(m);
|
|
250
|
+
expect(undockTab(m, "zzz")).toBe(m);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe("lookups", () => {
|
|
255
|
+
test("findDockTab reports the panel and index; activeDockTab reads the stack", () => {
|
|
256
|
+
const m = base();
|
|
257
|
+
expect(findDockTab(m, "b")).toMatchObject({ panelId: "1", index: 1 });
|
|
258
|
+
expect(findDockTab(m, "zzz")).toBeUndefined();
|
|
259
|
+
expect(activeDockTab(findDockPanel(m, "1")!)!.id).toBe("a");
|
|
260
|
+
expect(activeDockTabIndex(findDockPanel(m, "1")!)).toBe(0);
|
|
261
|
+
expect(activeDockTabIndex({ tabs: [tab("a")], activeTabId: "gone" })).toBe(0);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("hitTestDockZone", () => {
|
|
266
|
+
const rect = { x: 0, y: 0, width: 100, height: 100 };
|
|
267
|
+
|
|
268
|
+
test("the outer quarter of each side is that side's zone", () => {
|
|
269
|
+
expect(hitTestDockZone(rect, 5, 50)).toBe("left");
|
|
270
|
+
expect(hitTestDockZone(rect, 95, 50)).toBe("right");
|
|
271
|
+
expect(hitTestDockZone(rect, 50, 5)).toBe("top");
|
|
272
|
+
expect(hitTestDockZone(rect, 50, 95)).toBe("bottom");
|
|
273
|
+
expect(hitTestDockZone(rect, 50, 50)).toBe("center");
|
|
274
|
+
expect(hitTestDockZone(rect, 30, 50)).toBe("center"); // just inside the band
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("the rect origin is honoured and a point past an edge still names it", () => {
|
|
278
|
+
expect(hitTestDockZone({ x: 200, y: 100, width: 100, height: 100 }, 205, 150)).toBe("left");
|
|
279
|
+
expect(hitTestDockZone(rect, -40, 50)).toBe("left");
|
|
280
|
+
expect(hitTestDockZone(rect, 50, 140)).toBe("bottom");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("the band is configurable and clamped to a half", () => {
|
|
284
|
+
expect(hitTestDockZone(rect, 30, 50, 0.4)).toBe("left");
|
|
285
|
+
expect(hitTestDockZone(rect, 49, 50, 9)).toBe("left"); // 0.5 band, nothing is center
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("a degenerate rect or a non-finite pointer is center", () => {
|
|
289
|
+
expect(hitTestDockZone({ x: 0, y: 0, width: 0, height: 100 }, 0, 50)).toBe("center");
|
|
290
|
+
expect(hitTestDockZone({ x: 0, y: 0, width: 100, height: NaN }, 50, 50)).toBe("center");
|
|
291
|
+
expect(hitTestDockZone(rect, NaN, 50)).toBe("center");
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("serializeDock / deserializeDock", () => {
|
|
296
|
+
test("round-trips a real layout through JSON", () => {
|
|
297
|
+
const m = activateTab(dockPanel(addTab(base(), "2", tab("d")), "1", "2", "bottom"), "d");
|
|
298
|
+
const back = deserializeDock<string>(JSON.parse(JSON.stringify(serializeDock(m))), isString);
|
|
299
|
+
expect(back).toEqual(m);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("serializeDock is a deep copy, not a view of the live model", () => {
|
|
303
|
+
const m = base();
|
|
304
|
+
const snapshot = serializeDock(m);
|
|
305
|
+
const grown = addTab(m, "1", tab("d"));
|
|
306
|
+
expect(findDockPanel(grown, "1")!.tabs).toHaveLength(3);
|
|
307
|
+
expect(findDockPanel(snapshot, "1")!.tabs).toHaveLength(2);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("malformed input rejects the whole layout", () => {
|
|
311
|
+
const empty = emptyDock<string>();
|
|
312
|
+
expect(deserializeDock<string>(null, isString)).toEqual(empty);
|
|
313
|
+
expect(deserializeDock<string>({ root: { kind: "leaf", id: "1", data: { tabs: [] } } }, isString)).toEqual(empty);
|
|
314
|
+
expect(
|
|
315
|
+
deserializeDock<string>({ root: { kind: "leaf", id: "1", data: { tabs: [{ title: "A", data: "a" }] } } }, isString),
|
|
316
|
+
).toEqual(empty);
|
|
317
|
+
const badData = { root: { kind: "leaf", id: "1", data: { tabs: [{ id: "a", title: "A", data: 42 }] } } };
|
|
318
|
+
expect(deserializeDock<string>(badData, isString)).toEqual(empty);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("a tab id used in two panels rejects the layout", () => {
|
|
322
|
+
const clash = serializeDock(base()) as unknown as { root: { children: { data: { tabs: DockTab<string>[] } }[] } };
|
|
323
|
+
clash.root.children[1]!.data.tabs[0]!.id = "a";
|
|
324
|
+
expect(deserializeDock<string>(clash, isString)).toEqual(emptyDock<string>());
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("an activeTabId naming a tab that is gone is repaired, not rejected", () => {
|
|
328
|
+
const raw = {
|
|
329
|
+
root: { kind: "leaf", id: "1", data: { tabs: [{ id: "a", title: "A", data: "a" }], activeTabId: "gone" } },
|
|
330
|
+
focusedId: "1",
|
|
331
|
+
nextId: 2,
|
|
332
|
+
};
|
|
333
|
+
expect(findDockPanel(deserializeDock<string>(raw, isString), "1")!.activeTabId).toBe("a");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("unknown tab fields are dropped and the icon survives", () => {
|
|
337
|
+
const raw = {
|
|
338
|
+
root: {
|
|
339
|
+
kind: "leaf",
|
|
340
|
+
id: "1",
|
|
341
|
+
data: { tabs: [{ id: "a", title: "A", icon: "folder-symbolic", data: "a", stale: true }], activeTabId: "a" },
|
|
342
|
+
},
|
|
343
|
+
focusedId: "1",
|
|
344
|
+
nextId: 2,
|
|
345
|
+
};
|
|
346
|
+
expect(findDockPanel(deserializeDock<string>(raw, isString), "1")!.tabs[0]).toEqual({
|
|
347
|
+
id: "a",
|
|
348
|
+
title: "A",
|
|
349
|
+
icon: "folder-symbolic",
|
|
350
|
+
data: "a",
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe("dock drag payloads", () => {
|
|
356
|
+
test("round-trip, and anything foreign is not a dock drag", () => {
|
|
357
|
+
expect(parseDockDrag(dockDragPayload("tab", "a"))).toEqual({ kind: "tab", id: "a" });
|
|
358
|
+
expect(parseDockDrag(dockDragPayload("panel", "1"))).toEqual({ kind: "panel", id: "1" });
|
|
359
|
+
// A tab id may hold the separator itself; only the first one splits.
|
|
360
|
+
expect(parseDockDrag(dockDragPayload("tab", "file:///a.txt"))).toEqual({ kind: "tab", id: "file:///a.txt" });
|
|
361
|
+
for (const foreign of ["", "https://example.com", "nd-dock:", "nd-dock:tab:", "nd-dock:widget:x"]) {
|
|
362
|
+
expect(parseDockDrag(foreign)).toBeUndefined();
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe("dockPanelRects", () => {
|
|
368
|
+
test("panel rects come from the split ratios", () => {
|
|
369
|
+
const rects = dockPanelRects(base(), { width: 800, height: 600 });
|
|
370
|
+
expect(rects.get("1")).toEqual({ x: 0, y: 0, width: 400, height: 600 });
|
|
371
|
+
expect(rects.get("2")).toEqual({ x: 400, y: 0, width: 400, height: 600 });
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("a vertical split divides the height, and a moved divider moves the rects", () => {
|
|
375
|
+
const m = setPaneRatio(dockPanel(base(), "2", "1", "bottom"), "s4", 0.25);
|
|
376
|
+
const rects = dockPanelRects(m, { width: 800, height: 600 });
|
|
377
|
+
expect(rects.get("1")).toEqual({ x: 0, y: 0, width: 800, height: 150 });
|
|
378
|
+
expect(rects.get("2")).toEqual({ x: 0, y: 150, width: 800, height: 450 });
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
describe("dockZoneAt", () => {
|
|
383
|
+
const size = { width: 800, height: 600 };
|
|
384
|
+
|
|
385
|
+
test("the outer quarter of each side is that side's zone, the middle is center", () => {
|
|
386
|
+
const m = base();
|
|
387
|
+
expect(dockZoneAt(m, "1", 10, 300, size)).toBe("left");
|
|
388
|
+
expect(dockZoneAt(m, "1", 390, 300, size)).toBe("right");
|
|
389
|
+
expect(dockZoneAt(m, "1", 200, 10, size)).toBe("top");
|
|
390
|
+
expect(dockZoneAt(m, "1", 200, 590, size)).toBe("bottom");
|
|
391
|
+
expect(dockZoneAt(m, "1", 200, 300, size)).toBe("center");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("coordinates are the target panel's own, so panel 2 hit-tests against its own width", () => {
|
|
395
|
+
const m = base();
|
|
396
|
+
// 200 is the middle of panel 2's own 400px, though it sits inside panel
|
|
397
|
+
// 1's half of the dock; 410 is past panel 2's right edge, which still
|
|
398
|
+
// names that edge.
|
|
399
|
+
expect(dockZoneAt(m, "2", 10, 300, size)).toBe("left");
|
|
400
|
+
expect(dockZoneAt(m, "2", 200, 300, size)).toBe("center");
|
|
401
|
+
expect(dockZoneAt(m, "2", 410, 300, size)).toBe("right");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("the nearest edge wins a corner", () => {
|
|
405
|
+
expect(dockZoneAt(base(), "2", 390, 10, size)).toBe("top");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("no size and an unknown panel are center, the zone that needs no geometry", () => {
|
|
409
|
+
expect(dockZoneAt(base(), "1", 10, 300)).toBe("center");
|
|
410
|
+
expect(dockZoneAt(base(), "nope", 10, 300, size)).toBe("center");
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe("applyDockDrop", () => {
|
|
415
|
+
const drop = (m: DockModel<string>, payload: string, panelId: string, zone: DockZone) =>
|
|
416
|
+
applyDockDrop(m, payload, panelId, zone);
|
|
417
|
+
|
|
418
|
+
test("a tab dropped on another panel's center moves into its stack", () => {
|
|
419
|
+
const m = drop(base(), dockDragPayload("tab", "a"), "2", "center");
|
|
420
|
+
expect(tabIds(m, "1")).toEqual(["b"]);
|
|
421
|
+
expect(tabIds(m, "2")).toEqual(["c", "a"]);
|
|
422
|
+
expect(findDockPanel(m, "2")!.activeTabId).toBe("a");
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("a tab dropped on an edge of another panel lands in a new panel on that side", () => {
|
|
426
|
+
const m = drop(base(), dockDragPayload("tab", "a"), "2", "top");
|
|
427
|
+
expect(tabIds(m, "1")).toEqual(["b"]);
|
|
428
|
+
expect(tabIds(m, "2")).toEqual(["c"]);
|
|
429
|
+
const fresh = panelIds(m).find((id) => id !== "1" && id !== "2")!;
|
|
430
|
+
expect(tabIds(m, fresh)).toEqual(["a"]);
|
|
431
|
+
expect((m.root as PaneSplit<Panel>).children[1]).toMatchObject({ kind: "split", orientation: "vertical" });
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("a tab dropped on an edge of its own panel undocks it there", () => {
|
|
435
|
+
const m = drop(base(), dockDragPayload("tab", "b"), "1", "right");
|
|
436
|
+
expect(tabIds(m, "1")).toEqual(["a"]);
|
|
437
|
+
const fresh = panelIds(m).find((id) => id !== "1" && id !== "2")!;
|
|
438
|
+
expect(tabIds(m, fresh)).toEqual(["b"]);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("the last tab of a panel takes the panel path, so the panel keeps its id", () => {
|
|
442
|
+
const m = drop(base(), dockDragPayload("tab", "c"), "1", "left");
|
|
443
|
+
expect(panelIds(m)).toEqual(["2", "1"]);
|
|
444
|
+
expect(tabIds(m, "2")).toEqual(["c"]);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("a panel payload docks the whole panel", () => {
|
|
448
|
+
const m = drop(base(), dockDragPayload("panel", "2"), "1", "center");
|
|
449
|
+
expect(panelIds(m)).toEqual(["1"]);
|
|
450
|
+
expect(tabIds(m, "1")).toEqual(["a", "b", "c"]);
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
describe("applyDockDrop reference stability", () => {
|
|
455
|
+
test("a tab dropped back on its own panel's center changes nothing", () => {
|
|
456
|
+
const m = base();
|
|
457
|
+
expect(applyDockDrop(m, dockDragPayload("tab", "a"), "1", "center")).toBe(m);
|
|
458
|
+
expect(applyDockDrop(m, dockDragPayload("tab", "b"), "1", "center")).toBe(m);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("a panel dropped on itself, or on the side it already occupies, changes nothing", () => {
|
|
462
|
+
const m = base();
|
|
463
|
+
expect(applyDockDrop(m, dockDragPayload("panel", "2"), "2", "center")).toBe(m);
|
|
464
|
+
expect(applyDockDrop(m, dockDragPayload("panel", "2"), "1", "right")).toBe(m);
|
|
465
|
+
// Same drop through the single-tab tab path, which is the one a real
|
|
466
|
+
// drag takes: panel 2 holds only "c".
|
|
467
|
+
expect(applyDockDrop(m, dockDragPayload("tab", "c"), "1", "right")).toBe(m);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("a lone tab dropped on an edge of its own panel has nothing to undock", () => {
|
|
471
|
+
const m = base();
|
|
472
|
+
expect(applyDockDrop(m, dockDragPayload("tab", "c"), "2", "top")).toBe(m);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("a foreign drag and an unknown id are ignored", () => {
|
|
476
|
+
const m = base();
|
|
477
|
+
expect(applyDockDrop(m, "https://example.com", "1", "left")).toBe(m);
|
|
478
|
+
expect(applyDockDrop(m, dockDragPayload("tab", "gone"), "1", "left")).toBe(m);
|
|
479
|
+
expect(applyDockDrop(m, dockDragPayload("panel", "9"), "1", "left")).toBe(m);
|
|
480
|
+
expect(applyDockDrop(m, dockDragPayload("tab", "a"), "9", "center")).toBe(m);
|
|
481
|
+
});
|
|
482
|
+
});
|