@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nativedesktop/panes",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/FormalSnake/NativeDesktop#readme",
@@ -11,19 +11,21 @@
11
11
  "directory": "packages/panes"
12
12
  },
13
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
- }
14
+ "access": "public"
15
+ },
16
+ "files": [
17
+ "src"
18
+ ],
19
+ "main": "./src/index.ts",
20
+ "types": "./src/index.ts",
21
+ "exports": {
22
+ ".": "./src/index.ts"
23
+ },
24
+ "peerDependencies": {
25
+ "@nativedesktop/react": "^0.2.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/react": "19.2.17",
29
+ "react": "19.2.7"
30
+ }
29
31
  }
@@ -0,0 +1,312 @@
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
+ activateTab,
13
+ activeDockTabIndex,
14
+ addTab,
15
+ applyDockDrop,
16
+ closeTab,
17
+ dockDragPayload,
18
+ dockPanel,
19
+ dockZoneAt,
20
+ moveTab,
21
+ undockTab,
22
+ } from "./dock.ts";
23
+ import type { DockEdgeZone, DockModel, DockPanel, DockSize, DockTab, DockZone } from "./dock.ts";
24
+ import { closePane, focusNeighbor, focusPane, setPaneRatio } from "./model.ts";
25
+ import type { PaneNode } from "./model.ts";
26
+
27
+ /** Spread onto any widget to make it the drag handle for a tab or a panel.
28
+ * `onDragEnded` is part of the bundle because it is what clears the drop
29
+ * indicator when a drag is abandoned outside every panel: there is no
30
+ * drag-leave event on the target side. */
31
+ export interface DockDragProps {
32
+ draggable: true;
33
+ dragPayload: string;
34
+ onDragEnded: () => void;
35
+ }
36
+
37
+ /** Classes the hovered panel wears while a drag is over it. Both are in
38
+ * schema/widgets.json's cssClasses list and each is the container card its own
39
+ * backend draws: Adwaita styles `.card` on any widget, AppKit paints a card
40
+ * backing behind a `boxed-list` stack. Neither renders on the other side, so
41
+ * the pair is one highlight per platform, not two stacked. */
42
+ const HOVER_CLASSES = ["card", "boxed-list"];
43
+
44
+ /** The idle class list has to be an empty ARRAY, not an absent prop: the
45
+ * reconciler diffs prop values and an `undefined` one serializes away, so the
46
+ * class set would never be told to clear and the highlight would outlive the
47
+ * drag. Both are module constants so an unrelated render emits no update. */
48
+ const NO_CLASSES: string[] = [];
49
+
50
+ export interface DockTabContext<T> {
51
+ panelId: string;
52
+ tab: DockTab<T>;
53
+ active: boolean;
54
+ /** True when the tab's panel holds the dock's focus, not the tab itself:
55
+ * there is no widget-level focus event to say otherwise. */
56
+ focused: boolean;
57
+ /** Drag handle for THIS tab. DockView already puts it on the tab's own body
58
+ * box, so spread this only to add a second handle (a title row of your own,
59
+ * say) or after turning `dragTabBodies` off. */
60
+ dragProps: DockDragProps;
61
+ }
62
+
63
+ export interface DockPanelContext<T> {
64
+ panelId: string;
65
+ panel: DockPanel<T>;
66
+ focused: boolean;
67
+ solo: boolean;
68
+ /** The panel's <tabview>. Wrap it to add a panel toolbar or a focus ring,
69
+ * and return it as-is to keep the bare tab stack. */
70
+ content: ReactNode;
71
+ /** Drag handle for the WHOLE panel, tab stack included. Nothing carries it
72
+ * by default: a panel's only always-present surface is its content, and that
73
+ * is where the per-tab handle sits. Put it on chrome you draw. */
74
+ dragProps: DockDragProps;
75
+ /** Zone a drag is currently hovering over this panel, or null when no drag
76
+ * is over it. DockView already draws an edge indicator and the card
77
+ * highlight; this is for chrome that wants to react as well. */
78
+ dropZone: DockZone | null;
79
+ }
80
+
81
+ export interface DockViewProps<T> {
82
+ model: DockModel<T>;
83
+ onChange: (next: DockModel<T>) => void;
84
+ /** Renders one tab's body. DockView supplies the <tabview> and one
85
+ * expanding <box> per tab, which is where the tab's label and icon are
86
+ * attached from. */
87
+ renderTab: (ctx: DockTabContext<T>) => ReactNode;
88
+ /** Owns all per-panel chrome, the way PaneTree's renderLeaf does. */
89
+ renderPanel?: (ctx: DockPanelContext<T>) => ReactNode;
90
+ /** Pixel extent of the dock, which is what makes edge zones reachable: drop
91
+ * points arrive in the target panel's own coordinates and nothing on the
92
+ * wire says how big that panel is. Without it every drop is a `center`
93
+ * drop, which still merges and reorders tabs. */
94
+ size?: DockSize;
95
+ /** Fraction of a panel each edge zone claims. Default 0.25. */
96
+ dropEdge?: number;
97
+ /** Default true: a tab's body box is that tab's drag handle, so a drag from
98
+ * anywhere the content does not claim itself moves the tab. Turn it off for
99
+ * content that owns its own drag gestures (a text view, a canvas) and put
100
+ * `dragProps` on chrome instead. */
101
+ dragTabBodies?: boolean;
102
+ testID?: string;
103
+ }
104
+
105
+ interface DockHover {
106
+ panelId: string;
107
+ zone: DockZone;
108
+ }
109
+
110
+ export function DockView<T>(props: DockViewProps<T>): ReactNode {
111
+ // Latest-ref, same reason as PaneTree: the native positionChanged echo
112
+ // lands via a handler captured at an earlier render, and applying it
113
+ // against that render's model would revert everything committed since.
114
+ const modelRef = useRef(props.model);
115
+ modelRef.current = props.model;
116
+ const onChangeRef = useRef(props.onChange);
117
+ onChangeRef.current = props.onChange;
118
+ const [hover, setHover] = useState<DockHover | null>(null);
119
+
120
+ const { model, renderTab, renderPanel, size, dropEdge, dragTabBodies = true, testID } = props;
121
+ if (!model.root) return null;
122
+ const solo = model.root.kind === "leaf";
123
+
124
+ function commit(next: DockModel<T>): void {
125
+ if (next !== modelRef.current) onChangeRef.current(next);
126
+ }
127
+
128
+ function dragProps(kind: "tab" | "panel", id: string): DockDragProps {
129
+ return { draggable: true, dragPayload: dockDragPayload(kind, id), onDragEnded: () => setHover(null) };
130
+ }
131
+
132
+ // dragOver fires per pointer motion, so the state update has to be a bail
133
+ // when the zone has not changed: otherwise every mouse move during a drag
134
+ // is a React commit, and the panel under the pointer rebuilds its props at
135
+ // pointer rate.
136
+ function onPanelDragOver(panelId: string, x: number, y: number): void {
137
+ const zone = dockZoneAt(modelRef.current, panelId, x, y, size, dropEdge);
138
+ setHover((current) =>
139
+ current && current.panelId === panelId && current.zone === zone ? current : { panelId, zone },
140
+ );
141
+ }
142
+
143
+ function onPanelDrop(panelId: string, payload: string, x: number, y: number): void {
144
+ setHover(null);
145
+ const current = modelRef.current;
146
+ commit(applyDockDrop(current, payload, panelId, dockZoneAt(current, panelId, x, y, size, dropEdge)));
147
+ }
148
+
149
+ function renderPanelNode(id: string, panel: DockPanel<T>): ReactNode {
150
+ const focused = id === model.focusedId;
151
+ const zone = hover && hover.panelId === id ? hover.zone : null;
152
+ const tabs = testID ? `${testID}-tabs-${id}` : undefined;
153
+ const content = (
154
+ // selectedIndex is the model's active tab, and the native tab bar's own
155
+ // selectionChanged comes back through activateTab, so clicking a tab
156
+ // natively and activating one from app chrome land in the same place.
157
+ <tabview
158
+ selectedIndex={activeDockTabIndex(panel)}
159
+ style={{ hexpand: true, vexpand: true }}
160
+ testID={tabs}
161
+ onSelectionChanged={(e) => {
162
+ const tab = panel.tabs[e.index];
163
+ if (tab) commit(activateTab(modelRef.current, tab.id));
164
+ }}
165
+ >
166
+ {panel.tabs.map((tab) => (
167
+ <box
168
+ key={tab.id}
169
+ tabLabel={tab.title}
170
+ tabIcon={tab.icon}
171
+ style={{ hexpand: true, vexpand: true }}
172
+ testID={testID ? `${testID}-tab-${tab.id}` : undefined}
173
+ {...(dragTabBodies ? dragProps("tab", tab.id) : {})}
174
+ >
175
+ {renderTab({
176
+ panelId: id,
177
+ tab,
178
+ active: tab.id === panel.activeTabId,
179
+ focused,
180
+ dragProps: dragProps("tab", tab.id),
181
+ })}
182
+ </box>
183
+ ))}
184
+ </tabview>
185
+ );
186
+ const body = renderPanel
187
+ ? renderPanel({ panelId: id, panel, focused, solo, content, dragProps: dragProps("panel", id), dropZone: zone })
188
+ : content;
189
+ const indicator = (edge: DockEdgeZone): ReactNode =>
190
+ zone === edge ? (
191
+ // A native separator IS the platform's insertion line, so the edge a
192
+ // drop would take is drawn with a real widget rather than a hand-sized
193
+ // strip. Vertical edges expand down the panel, horizontal ones across.
194
+ <separator
195
+ orientation={edge === "left" || edge === "right" ? "vertical" : "horizontal"}
196
+ cssClasses={["accent"]}
197
+ style={edge === "left" || edge === "right" ? { vexpand: true } : { hexpand: true }}
198
+ testID={testID ? `${testID}-drop-${edge}-${id}` : undefined}
199
+ />
200
+ ) : null;
201
+ return (
202
+ // The panel box is the drop target for its whole area: both backends
203
+ // keep a drop zone inert until a drag is actually in flight, so this
204
+ // costs the panel nothing the rest of the time.
205
+ <box
206
+ key={id}
207
+ spacing={0}
208
+ style={{ hexpand: true, vexpand: true }}
209
+ cssClasses={zone ? HOVER_CLASSES : NO_CLASSES}
210
+ dropTarget
211
+ onDragOver={(e) => onPanelDragOver(id, e.data.x, e.data.y)}
212
+ onDropped={(e) => onPanelDrop(id, e.text, e.data.x, e.data.y)}
213
+ testID={testID ? `${testID}-panel-${id}` : undefined}
214
+ >
215
+ {indicator("top")}
216
+ {/* spacing 0 on both indicator hosts: the platform default would open
217
+ a gap the moment a drop line appears, and the content under the
218
+ pointer would jump by it. */}
219
+ <box orientation="horizontal" spacing={0} style={{ hexpand: true, vexpand: true }}>
220
+ {indicator("left")}
221
+ {body}
222
+ {indicator("right")}
223
+ </box>
224
+ {indicator("bottom")}
225
+ </box>
226
+ );
227
+ }
228
+
229
+ function renderNode(node: PaneNode<DockPanel<T>>): ReactNode {
230
+ if (node.kind === "leaf") return renderPanelNode(node.id, node.data);
231
+ return (
232
+ // Keyed on the split node id: orientation is create-only on both
233
+ // backends, so a structural collapse landing a DIFFERENT split at this
234
+ // position must remount rather than mutate.
235
+ <paned
236
+ key={node.id}
237
+ orientation={node.orientation}
238
+ position={node.ratio}
239
+ testID={testID ? `${testID}-split-${node.id}` : undefined}
240
+ onPositionChanged={(e) => {
241
+ // Exact 0/1 (or non-finite) is a zero-size mid-layout artifact from
242
+ // a structural commit racing the backend's debounced echo, not a
243
+ // settled drag. See PaneTree for the full reasoning; the guard has
244
+ // to be identical or the two views desync on the same tree.
245
+ if (!Number.isFinite(e.position) || e.position <= 0 || e.position >= 1) return;
246
+ commit(setPaneRatio(modelRef.current, node.id, e.position));
247
+ }}
248
+ >
249
+ {renderNode(node.children[0])}
250
+ {renderNode(node.children[1])}
251
+ </paned>
252
+ );
253
+ }
254
+
255
+ // The bare testID has to land on a real node, not just seed the derived
256
+ // `-panel-`/`-tabs-` ids: automation resolves the dock by its own testID.
257
+ // TilesView puts it on its <grid>; the dock's root is a split or a panel,
258
+ // both of which already carry a derived id, so it needs its own host.
259
+ return (
260
+ <box orientation="vertical" style={{ vexpand: true, hexpand: true }} testID={testID}>
261
+ {renderNode(model.root)}
262
+ </box>
263
+ );
264
+ }
265
+
266
+ export interface UseDock<T> {
267
+ model: DockModel<T>;
268
+ /** The latest-ref invariant, built in: reads the model as of the last op,
269
+ * not the last render. */
270
+ latest: () => DockModel<T>;
271
+ setModel(m: DockModel<T>): void;
272
+ addTab(panelId: string, tab: DockTab<T>, index?: number): void;
273
+ closeTab(tabId: string): void;
274
+ activateTab(tabId: string): void;
275
+ moveTab(tabId: string, targetPanelId: string, index?: number): void;
276
+ undockTab(tabId: string, zone?: DockEdgeZone): void;
277
+ dock(panelId: string, targetPanelId: string, zone: DockZone): void;
278
+ closePanel(panelId: string): void;
279
+ focusPanel(panelId: string): void;
280
+ focusNeighbor(dir: "left" | "right" | "up" | "down"): void;
281
+ setRatio(splitId: string, ratio: number): void;
282
+ }
283
+
284
+ /** Holds the dock in state and applies every op against a ref, never the
285
+ * render-time model, so an await-resuming op can't revert a concurrent
286
+ * divider drag. */
287
+ export function useDock<T>(initial: DockModel<T> | (() => DockModel<T>)): UseDock<T> {
288
+ const [model, setState] = useState(initial);
289
+ const ref = useRef(model);
290
+
291
+ const apply = (next: DockModel<T>): void => {
292
+ if (next === ref.current) return;
293
+ ref.current = next;
294
+ setState(next);
295
+ };
296
+
297
+ return {
298
+ model,
299
+ latest: () => ref.current,
300
+ setModel: apply,
301
+ addTab: (panelId, tab, index) => apply(addTab(ref.current, panelId, tab, index)),
302
+ closeTab: (tabId) => apply(closeTab(ref.current, tabId)),
303
+ activateTab: (tabId) => apply(activateTab(ref.current, tabId)),
304
+ moveTab: (tabId, targetPanelId, index) => apply(moveTab(ref.current, tabId, targetPanelId, index)),
305
+ undockTab: (tabId, zone) => apply(undockTab(ref.current, tabId, zone)),
306
+ dock: (panelId, targetPanelId, zone) => apply(dockPanel(ref.current, panelId, targetPanelId, zone)),
307
+ closePanel: (panelId) => apply(closePane(ref.current, panelId)),
308
+ focusPanel: (panelId) => apply(focusPane(ref.current, panelId)),
309
+ focusNeighbor: (dir) => apply(focusNeighbor(ref.current, dir)),
310
+ setRatio: (splitId, ratio) => apply(setPaneRatio(ref.current, splitId, ratio)),
311
+ };
312
+ }
@@ -0,0 +1,133 @@
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 { applyTileDrop, moveTile, placeTile, raiseTile, removeTile, resizeTile, tileDragPayload, updateTile } from "./tiles.ts";
12
+ import type { Tile, TileModel, TilePlacement, TileSize } from "./tiles.ts";
13
+
14
+ export interface TileContext<T> {
15
+ id: string;
16
+ tile: Tile<T>;
17
+ /** True for the last tile in layout order, the one drawn on top. */
18
+ raised: boolean;
19
+ /** Drag handle for THIS tile. The tile's own box carries it already once
20
+ * `onChange` and `size` are both set, so spread this only to add a second
21
+ * handle or after turning `dragTiles` off. */
22
+ dragProps: { draggable: true; dragPayload: string };
23
+ }
24
+
25
+ export interface TilesViewProps<T> {
26
+ model: TileModel<T>;
27
+ /** Owns all per-tile chrome, the way PaneTree's renderLeaf does. TilesView
28
+ * supplies the <grid> and one expanding <box> per tile, carrying the
29
+ * gridRow/gridColumn spans the model computed. */
30
+ renderTile: (ctx: TileContext<T>) => ReactNode;
31
+ /** Where a dropped tile lands. Omit it (or `size`) and the grid stays
32
+ * display-only: every layout change then comes from a model op the app
33
+ * calls. */
34
+ onChange?: (next: TileModel<T>) => void;
35
+ /** Pixel extent of the grid, which is what turns a drop point into a cell.
36
+ * The grid reports no geometry of its own, so without this a drop has
37
+ * nothing to resolve against and is ignored. */
38
+ size?: TileSize;
39
+ /** Default true: the tile's box is its own drag handle, once `onChange` and
40
+ * `size` make a drop resolvable. Turn it off for tile content that owns its
41
+ * drag gestures and put `dragProps` on chrome instead. */
42
+ dragTiles?: boolean;
43
+ testID?: string;
44
+ }
45
+
46
+ /** Renders a tile layout into the existing <grid> widget. The grid is the drop
47
+ * target for the whole layout: a tile dropped on it takes the cell under the
48
+ * pointer as its top-left, and the tiles it lands on are pushed clear. */
49
+ export function TilesView<T>(props: TilesViewProps<T>): ReactNode {
50
+ const { model, renderTile, onChange, size, dragTiles = true, testID } = props;
51
+ // Latest-ref, same reason as DockView: the drop handler that fires was
52
+ // captured at an earlier render, and applying it against that render's
53
+ // model would revert everything committed since.
54
+ const modelRef = useRef(model);
55
+ modelRef.current = model;
56
+ const onChangeRef = useRef(onChange);
57
+ onChangeRef.current = onChange;
58
+ const last = model.tiles.length - 1;
59
+ // Dragging a tile that has nowhere to land is worse than a tile that does
60
+ // not drag, so both halves turn on together: a drop needs somewhere to send
61
+ // the new model AND the grid extent to resolve the cell against.
62
+ const canDrop = onChange !== undefined && size !== undefined;
63
+
64
+ function onDrop(payload: string, x: number, y: number): void {
65
+ const handler = onChangeRef.current;
66
+ if (!handler || !size) return;
67
+ const current = modelRef.current;
68
+ const next = applyTileDrop(current, payload, size, x, y);
69
+ if (next !== current) handler(next);
70
+ }
71
+
72
+ return (
73
+ <grid testID={testID} dropTarget={canDrop} onDropped={(e) => onDrop(e.text, e.data.x, e.data.y)}>
74
+ {model.tiles.map((tile, index) => {
75
+ const drag = { draggable: true, dragPayload: tileDragPayload(tile.id) } as const;
76
+ return (
77
+ <box
78
+ key={tile.id}
79
+ gridRow={tile.y}
80
+ gridColumn={tile.x}
81
+ gridRowSpan={tile.h}
82
+ gridColumnSpan={tile.w}
83
+ style={{ hexpand: true, vexpand: true }}
84
+ testID={testID ? `${testID}-tile-${tile.id}` : undefined}
85
+ {...(dragTiles && canDrop ? drag : {})}
86
+ >
87
+ {renderTile({ id: tile.id, tile, raised: index === last, dragProps: drag })}
88
+ </box>
89
+ );
90
+ })}
91
+ </grid>
92
+ );
93
+ }
94
+
95
+ export interface UseTiles<T> {
96
+ model: TileModel<T>;
97
+ /** The latest-ref invariant, built in: reads the model as of the last op,
98
+ * not the last render. */
99
+ latest: () => TileModel<T>;
100
+ setModel(m: TileModel<T>): void;
101
+ place(placement: TilePlacement<T>): void;
102
+ move(id: string, x: number, y: number): void;
103
+ resize(id: string, w: number, h: number): void;
104
+ raise(id: string): void;
105
+ remove(id: string): void;
106
+ update(id: string, fn: (data: T) => T): void;
107
+ }
108
+
109
+ /** Holds the layout in state and applies every op against a ref, never the
110
+ * render-time model, so an await-resuming op can't revert a move committed
111
+ * while it was suspended. */
112
+ export function useTiles<T>(initial: TileModel<T> | (() => TileModel<T>)): UseTiles<T> {
113
+ const [model, setState] = useState(initial);
114
+ const ref = useRef(model);
115
+
116
+ const apply = (next: TileModel<T>): void => {
117
+ if (next === ref.current) return;
118
+ ref.current = next;
119
+ setState(next);
120
+ };
121
+
122
+ return {
123
+ model,
124
+ latest: () => ref.current,
125
+ setModel: apply,
126
+ place: (placement) => apply(placeTile(ref.current, placement)),
127
+ move: (id, x, y) => apply(moveTile(ref.current, id, x, y)),
128
+ resize: (id, w, h) => apply(resizeTile(ref.current, id, w, h)),
129
+ raise: (id) => apply(raiseTile(ref.current, id)),
130
+ remove: (id) => apply(removeTile(ref.current, id)),
131
+ update: (id, fn) => apply(updateTile(ref.current, id, fn)),
132
+ };
133
+ }