@driftengine/tools 4.0.0 → 4.1.1

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/README.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # `@driftengine/tools`
2
2
 
3
3
  The editor's panels, as something a shipped game can carry: an inspector, a console, a profiler and
4
- a network panel, with the command stack that makes their edits undoable.
4
+ a network panel, with the command stack that makes their edits undoable, and the overlay that puts
5
+ them on screen on a key.
5
6
 
6
- **3,395 bytes gzipped**, measured by `scripts/size-gate.test.mjs` against
7
+ **5,391 bytes gzipped**, measured by `scripts/size-gate.test.mjs` against
7
8
  `scripts/fixtures/size/tools-only.ts`. Optional — nothing in `@driftengine/core` imports it, so a
8
9
  game that never asks for these pays nothing.
9
10
 
@@ -37,6 +38,67 @@ const command = inspectorPanel.route(world, view, event);
37
38
  if (command !== null) undo.push(command);
38
39
  ```
39
40
 
41
+ ## The overlay, because a panel that nothing mounts is a tree nobody sees
42
+
43
+ Every panel above builds a `UiNode` tree and stops there. For 4.0.0 the half that turns a tree into
44
+ something a person can look at — geometry, event routing, painting — lived only in the editor
45
+ application, which is private and ships to nobody, so a game wanting an in-game inspector had to
46
+ write it again. `createToolsOverlay` is that half.
47
+
48
+ A **column rather than a dock**: nobody arranges a debug overlay, they press a key, read a number
49
+ and press it again, so the panels stack down one edge in the order they are given.
50
+
51
+ The **painter is supplied**. Four calls — a rectangle, a line of text, a clip and its close — which
52
+ is every operation the overlay makes. A host with a 2D context writes four lines; a host drawing
53
+ through `@driftengine/ui2d` writes them over a sprite batch; a host with no screen writes them into
54
+ an array, which is what this package's own tests do. Nothing here needs a graphics device, which is
55
+ what makes the whole of it assertable.
56
+
57
+ ```ts
58
+ import { bindPanel, createToolsOverlay, paintOverlay, profilerPanel } from '@driftengine/tools';
59
+
60
+ const overlay = createToolsOverlay({
61
+ panels: [bindPanel(profilerPanel, () => profilerWorld, profilerView)],
62
+ key: 'F3',
63
+ });
64
+
65
+ // once, and whenever the surface changes size
66
+ overlay.resize(canvas.width, canvas.height);
67
+
68
+ // every frame, after the scene
69
+ overlay.invalidate();
70
+ overlay.frame(now);
71
+ paintOverlay(painter, overlay);
72
+
73
+ // wherever input arrives; true means the overlay took it and the game should not
74
+ if (overlay.route(keyEvent(event.key, event.shiftKey, event.ctrlKey))) event.preventDefault();
75
+ ```
76
+
77
+ While it is closed `frame` returns before it builds anything and `route` refuses every event but the
78
+ one that opens it, so a game that never presses the key pays for a boolean.
79
+
80
+ ## Two adapters, because every consumer was about to write the same ones
81
+
82
+ A panel needs its world in the shape the panel reads. Two of those shapes are the same in every
83
+ game that has the thing behind them, so they are here rather than in each of them.
84
+
85
+ `entitiesInspectable(world, types)` turns an entity world into an `InspectableWorld`: the inspector
86
+ addresses a component by name because that is what a row carries, and a world addresses it by the
87
+ type object because that is what indexes its stores. A name with no type behind it does nothing
88
+ rather than guessing, so a row built from a stale schema cannot write a field into a component the
89
+ world does not have and report success.
90
+
91
+ `createSessionRecorder` plus `observeSession` and `sessionReadout` turn a lockstep session into a
92
+ `NetworkReadout`. The watching is the part worth having written once: `session.desync` is **latched
93
+ rather than an event**, so it keeps answering with the same disagreement every frame until the next
94
+ one arrives, and a recorder that appended what it read would turn one divergence into sixty a
95
+ second. `snapshotBytes` is the caller's, because `RewindLoop` is generic over the state it
96
+ snapshots and cannot know the size of one.
97
+
98
+ **Both are typed structurally**, the way `@driftengine/drft` takes DTEX types that
99
+ `@driftengine/texture` satisfies, so this package depends on nothing new and anything shaped right
100
+ can be read — including a consumer whose state is not an entity world at all.
101
+
40
102
  ## Building it in or leaving it out
41
103
 
42
104
  Import it and it is in the bundle; do not and it is not. There is no flag, because a flag would be a
package/dist/index.d.ts CHANGED
@@ -25,10 +25,14 @@ export type { Selection } from './selection.ts';
25
25
  export { addToSelection, clearSelection, createSelection, isSelected, primarySelection, selectOnly, selectRange, selectedEntities, toggleSelection, } from './selection.ts';
26
26
  export { divergenceIsUnaccounted, divergentComponents } from './divergence.ts';
27
27
  export type { InspectableWorld, InspectorRow, InspectorView, InspectorWorld, RowKind, } from './inspector.ts';
28
- export { MIXED, addComponentCommand, createInspectorView, fieldEditable, inspectorPanel, inspectorRows, removeComponentCommand, rowText, setFieldCommand, } from './inspector.ts';
28
+ export type { EntityWorldLike, InspectableComponentType } from './inspector.ts';
29
+ export { MIXED, addComponentCommand, createInspectorView, entitiesInspectable, fieldEditable, inspectorPanel, inspectorRows, removeComponentCommand, rowText, setFieldCommand, } from './inspector.ts';
29
30
  export type { ConsoleView, ConsoleWorld, LogEntry, LogFilter, LogRing, Severity, SourceCursor, } from './console.ts';
30
31
  export { DISPLAY_LIMIT, appendLog, consolePanel, createConsoleView, createLogFilter, createLogRing, displayText, filteredEntries, jumpToSourceCommand, logEntries, } from './console.ts';
31
32
  export type { FrameHistory, ProfilerRow, ProfilerView, ProfilerWorld } from './profiler.ts';
32
- export { createFrameHistory, createProfilerView, formatMs, historyValues, occupancy, profilerPanel, profilerRows, pushFrame, } from './profiler.ts';
33
+ export { createFrameHistory, createGpuPassTimings, createProfilerView, formatMs, historyValues, occupancy, profilerPanel, profilerRows, pushFrame, recordGpuSample, } from './profiler.ts';
33
34
  export type { NetworkReadout, NetworkView, NetworkWorld, RollbackHistory } from './network.ts';
34
- export { componentDivergence, createNetworkView, createRollbackHistory, networkPanel, pushRollback, rollbackValues, } from './network.ts';
35
+ export type { SessionLike, SessionRecorder } from './network.ts';
36
+ export { componentDivergence, createNetworkView, createRollbackHistory, createSessionRecorder, networkPanel, observeSession, pushRollback, rollbackValues, sessionReadout, } from './network.ts';
37
+ export type { OverlayPainter, OverlaySite, PanelBinding, ToolsOverlay, ToolsOverlayOptions, } from './overlay.ts';
38
+ export { bindPanel, createToolsOverlay, paintOverlay } from './overlay.ts';
package/dist/index.js CHANGED
@@ -3,7 +3,8 @@ export { createUndoStack } from './command.js';
3
3
  export { PANEL_PREFIX, createPanelRoot, emptyPanel, keyEvent, panelIdOf, pointerEvent, treeShape, wheelEvent, } from './panel.js';
4
4
  export { addToSelection, clearSelection, createSelection, isSelected, primarySelection, selectOnly, selectRange, selectedEntities, toggleSelection, } from './selection.js';
5
5
  export { divergenceIsUnaccounted, divergentComponents } from './divergence.js';
6
- export { MIXED, addComponentCommand, createInspectorView, fieldEditable, inspectorPanel, inspectorRows, removeComponentCommand, rowText, setFieldCommand, } from './inspector.js';
6
+ export { MIXED, addComponentCommand, createInspectorView, entitiesInspectable, fieldEditable, inspectorPanel, inspectorRows, removeComponentCommand, rowText, setFieldCommand, } from './inspector.js';
7
7
  export { DISPLAY_LIMIT, appendLog, consolePanel, createConsoleView, createLogFilter, createLogRing, displayText, filteredEntries, jumpToSourceCommand, logEntries, } from './console.js';
8
- export { createFrameHistory, createProfilerView, formatMs, historyValues, occupancy, profilerPanel, profilerRows, pushFrame, } from './profiler.js';
9
- export { componentDivergence, createNetworkView, createRollbackHistory, networkPanel, pushRollback, rollbackValues, } from './network.js';
8
+ export { createFrameHistory, createGpuPassTimings, createProfilerView, formatMs, historyValues, occupancy, profilerPanel, profilerRows, pushFrame, recordGpuSample, } from './profiler.js';
9
+ export { componentDivergence, createNetworkView, createRollbackHistory, createSessionRecorder, networkPanel, observeSession, pushRollback, rollbackValues, sessionReadout, } from './network.js';
10
+ export { bindPanel, createToolsOverlay, paintOverlay } from './overlay.js';
@@ -84,3 +84,45 @@ export declare function createInspectorView(options: {
84
84
  /** What a row shows to the right of its label. `—` is the mark for a disagreement. */
85
85
  export declare function rowText(row: InspectorRow): string;
86
86
  export declare const inspectorPanel: Panel<InspectorWorld, InspectorView>;
87
+ /**
88
+ * The shape of a component type, as this package needs it.
89
+ *
90
+ * **Structural rather than imported.** `@driftengine/entities`' `ComponentType` satisfies it, and
91
+ * so does anything else a consumer describes its state with — which is the arrangement
92
+ * `@driftengine/drft` already has with `@driftengine/texture`'s DTEX types across a boundary the
93
+ * one package must not depend across. This package gains no dependency for the adapter below, so
94
+ * the release's place count does not move.
95
+ */
96
+ export interface InspectableComponentType {
97
+ readonly name: string;
98
+ readonly schema: {
99
+ readonly fields: readonly {
100
+ readonly name: string;
101
+ readonly type: string;
102
+ }[];
103
+ };
104
+ }
105
+ /** The five methods the adapter forwards to. `World` has all of them. */
106
+ export interface EntityWorldLike<T> {
107
+ has(entity: number, type: T): boolean;
108
+ add(entity: number, type: T): void;
109
+ remove(entity: number, type: T): boolean;
110
+ read(entity: number, type: T, field: string): unknown;
111
+ write(entity: number, type: T, field: string, value: unknown): void;
112
+ }
113
+ /**
114
+ * An entity world, as something the inspector can read and edit.
115
+ *
116
+ * **Written once here because every consumer with a world was about to write it.** The inspector
117
+ * addresses a component by name, since a name is what a row carries; a world addresses it by the
118
+ * type object, since that is what indexes its stores. Bridging the two is seven forwarding methods
119
+ * and a lookup, identical wherever it is written.
120
+ *
121
+ * **A name with no type behind it does nothing rather than guessing.** A row built against a stale
122
+ * schema would otherwise write a field into a component the world does not have and report
123
+ * success, which is a silent loss of the edit a person just made and watched apply.
124
+ *
125
+ * `componentsOf` answers in the order the types were given, so a panel's rows do not reorder
126
+ * between frames.
127
+ */
128
+ export declare function entitiesInspectable<T extends InspectableComponentType>(world: EntityWorldLike<T>, types: readonly T[]): InspectableWorld;
package/dist/inspector.js CHANGED
@@ -263,3 +263,58 @@ export const inspectorPanel = {
263
263
  return null;
264
264
  },
265
265
  };
266
+ /**
267
+ * An entity world, as something the inspector can read and edit.
268
+ *
269
+ * **Written once here because every consumer with a world was about to write it.** The inspector
270
+ * addresses a component by name, since a name is what a row carries; a world addresses it by the
271
+ * type object, since that is what indexes its stores. Bridging the two is seven forwarding methods
272
+ * and a lookup, identical wherever it is written.
273
+ *
274
+ * **A name with no type behind it does nothing rather than guessing.** A row built against a stale
275
+ * schema would otherwise write a field into a component the world does not have and report
276
+ * success, which is a silent loss of the edit a person just made and watched apply.
277
+ *
278
+ * `componentsOf` answers in the order the types were given, so a panel's rows do not reorder
279
+ * between frames.
280
+ */
281
+ export function entitiesInspectable(world, types) {
282
+ const byName = new Map();
283
+ for (const type of types)
284
+ byName.set(type.name, type);
285
+ return {
286
+ componentsOf(entity) {
287
+ const out = [];
288
+ for (const type of types)
289
+ if (world.has(entity, type))
290
+ out.push(type.name);
291
+ return out;
292
+ },
293
+ schemaOf(component) {
294
+ return byName.get(component)?.schema.fields ?? [];
295
+ },
296
+ read(entity, component, field) {
297
+ const type = byName.get(component);
298
+ return type === undefined ? undefined : world.read(entity, type, field);
299
+ },
300
+ write(entity, component, field, value) {
301
+ const type = byName.get(component);
302
+ if (type !== undefined)
303
+ world.write(entity, type, field, value);
304
+ },
305
+ hasComponent(entity, component) {
306
+ const type = byName.get(component);
307
+ return type !== undefined && world.has(entity, type);
308
+ },
309
+ addComponent(entity, component) {
310
+ const type = byName.get(component);
311
+ if (type !== undefined)
312
+ world.add(entity, type);
313
+ },
314
+ removeComponent(entity, component) {
315
+ const type = byName.get(component);
316
+ if (type !== undefined)
317
+ world.remove(entity, type);
318
+ },
319
+ };
320
+ }
package/dist/network.d.ts CHANGED
@@ -56,3 +56,45 @@ export declare function createNetworkView(options: {
56
56
  historyLength?: number;
57
57
  }): NetworkView;
58
58
  export declare const networkPanel: Panel<NetworkWorld, NetworkView>;
59
+ /**
60
+ * A session, as this package needs it.
61
+ *
62
+ * Structural rather than imported, so nothing here loads `@driftengine/network` at run time and a
63
+ * consumer whose session is its own shape can still be read.
64
+ */
65
+ export interface SessionLike {
66
+ readonly participants: number;
67
+ readonly inputDelay: number;
68
+ /** The disagreement the session is currently holding, or null. Latched, not an event. */
69
+ readonly desync: Desync | null;
70
+ readonly loop: {
71
+ readonly depth: number;
72
+ };
73
+ }
74
+ /** What a recorder keeps: the disagreements it has seen, oldest first. */
75
+ export interface SessionRecorder {
76
+ readonly desyncs: Desync[];
77
+ readonly limit: number;
78
+ }
79
+ export declare function createSessionRecorder(limit?: number): SessionRecorder;
80
+ /**
81
+ * Watch a session for one frame.
82
+ *
83
+ * **`desync` is latched rather than an event**, which is the whole reason this is not one line at
84
+ * the call site. A session keeps answering with the same disagreement on every frame until the
85
+ * next one arrives, so appending what it reads turns one divergence into sixty a second and buries
86
+ * the tick somebody is looking for under copies of itself. A disagreement is new when its tick and
87
+ * peer differ from the last one kept.
88
+ *
89
+ * Bounded, because a session that desyncs every tick would otherwise grow an array forever behind
90
+ * a panel nobody has open.
91
+ */
92
+ export declare function observeSession(recorder: SessionRecorder, session: SessionLike): void;
93
+ /**
94
+ * The session and what has been seen of it, as the panel's readout.
95
+ *
96
+ * **`snapshotBytes` is the caller's, because the engine cannot know it.** `RewindLoop` is generic
97
+ * over the state it snapshots, so its depth is knowable here and the size of one is not. A caller
98
+ * passing zero is saying it has not measured, which the panel renders as such.
99
+ */
100
+ export declare function sessionReadout(session: SessionLike, recorder: SessionRecorder, snapshotBytes: number, componentHashes?: NetworkReadout['componentHashes']): NetworkReadout;
package/dist/network.js CHANGED
@@ -118,3 +118,46 @@ export const networkPanel = {
118
118
  return null;
119
119
  },
120
120
  };
121
+ export function createSessionRecorder(limit = 32) {
122
+ return { desyncs: [], limit: Math.max(1, limit) };
123
+ }
124
+ /**
125
+ * Watch a session for one frame.
126
+ *
127
+ * **`desync` is latched rather than an event**, which is the whole reason this is not one line at
128
+ * the call site. A session keeps answering with the same disagreement on every frame until the
129
+ * next one arrives, so appending what it reads turns one divergence into sixty a second and buries
130
+ * the tick somebody is looking for under copies of itself. A disagreement is new when its tick and
131
+ * peer differ from the last one kept.
132
+ *
133
+ * Bounded, because a session that desyncs every tick would otherwise grow an array forever behind
134
+ * a panel nobody has open.
135
+ */
136
+ export function observeSession(recorder, session) {
137
+ const seen = session.desync;
138
+ if (seen === null)
139
+ return;
140
+ const last = recorder.desyncs[recorder.desyncs.length - 1];
141
+ if (last !== undefined && last.tick === seen.tick && last.peer === seen.peer)
142
+ return;
143
+ recorder.desyncs.push(seen);
144
+ if (recorder.desyncs.length > recorder.limit)
145
+ recorder.desyncs.shift();
146
+ }
147
+ /**
148
+ * The session and what has been seen of it, as the panel's readout.
149
+ *
150
+ * **`snapshotBytes` is the caller's, because the engine cannot know it.** `RewindLoop` is generic
151
+ * over the state it snapshots, so its depth is knowable here and the size of one is not. A caller
152
+ * passing zero is saying it has not measured, which the panel renders as such.
153
+ */
154
+ export function sessionReadout(session, recorder, snapshotBytes, componentHashes = null) {
155
+ return {
156
+ inputDelay: session.inputDelay,
157
+ participants: session.participants,
158
+ snapshotBytes,
159
+ snapshotCount: session.loop.depth,
160
+ desyncs: recorder.desyncs,
161
+ componentHashes,
162
+ };
163
+ }
@@ -0,0 +1,70 @@
1
+ import type { UiNode } from '@driftengine/ui2d';
2
+ import { type Command, type UndoStack } from './command.ts';
3
+ import { type Panel, type UiEvent } from './panel.ts';
4
+ /**
5
+ * A panel with its world and its view already attached.
6
+ *
7
+ * **The generics are closed here rather than carried by the host.** `Panel<W, V>` is two type
8
+ * parameters per panel, and a host holding several at once cannot express them in one list without
9
+ * erasing to `unknown` and casting on the way out. Binding closes them where both types are known
10
+ * and hands back something with no parameters at all, so the host never casts and the panel keeps
11
+ * the signature that makes its own rules checkable.
12
+ */
13
+ export interface PanelBinding {
14
+ readonly id: string;
15
+ readonly title: string;
16
+ build(root: UiNode): void;
17
+ route(event: UiEvent): Command | null;
18
+ }
19
+ export declare function bindPanel<W, V>(panel: Panel<W, V>, world: () => Readonly<W>, view: V): PanelBinding;
20
+ /** Where one panel came out. Read rather than recomputed, so a hit test cannot disagree with a draw. */
21
+ export interface OverlaySite {
22
+ readonly id: string;
23
+ readonly x: number;
24
+ readonly y: number;
25
+ readonly w: number;
26
+ readonly h: number;
27
+ }
28
+ /** What a host draws with. Colours are CSS, `#rrggbb` or `#rrggbbaa`. */
29
+ export interface OverlayPainter {
30
+ rect(x: number, y: number, w: number, h: number, colour: string): void;
31
+ /** Text on a baseline at `y`, twelve pixels tall. */
32
+ text(content: string, x: number, y: number, colour: string): void;
33
+ clip(x: number, y: number, w: number, h: number): void;
34
+ unclip(): void;
35
+ }
36
+ export interface ToolsOverlayOptions {
37
+ readonly panels: readonly PanelBinding[];
38
+ /** `KeyboardEvent.key` of the key that opens and closes it. Defaults to `F3`. */
39
+ readonly key?: string;
40
+ readonly side?: 'left' | 'right';
41
+ readonly width?: number;
42
+ readonly undoLimit?: number;
43
+ readonly visible?: boolean;
44
+ }
45
+ export interface ToolsOverlay {
46
+ /** The tree the whole overlay is in. One absolutely placed child per panel. */
47
+ readonly root: UiNode;
48
+ readonly undo: UndoStack;
49
+ readonly visible: boolean;
50
+ /** How many times the tree has been rebuilt and laid out. Read by tests, and by nothing else. */
51
+ readonly layoutCount: number;
52
+ sites(): readonly OverlaySite[];
53
+ setVisible(visible: boolean): void;
54
+ toggle(): void;
55
+ resize(width: number, height: number): void;
56
+ frame(now: number): void;
57
+ /** Deliver one event. True where the overlay took it, so a host knows not to pass it on. */
58
+ route(event: UiEvent): boolean;
59
+ /** Say the world changed, so the next frame rebuilds. A profiler calls this every frame. */
60
+ invalidate(): void;
61
+ dispose(): void;
62
+ }
63
+ export declare function createToolsOverlay(options: ToolsOverlayOptions): ToolsOverlay;
64
+ /**
65
+ * Draw the overlay, or draw nothing because it is closed.
66
+ *
67
+ * A free function rather than a method, so the overlay itself never holds a painter and a host may
68
+ * draw the same overlay into two surfaces — a screen and a capture — without either knowing.
69
+ */
70
+ export declare function paintOverlay(painter: OverlayPainter, overlay: ToolsOverlay): void;
@@ -0,0 +1,275 @@
1
+ /**
2
+ * The panels, on a key, over a running game.
3
+ *
4
+ * **The panels shipped without anything to mount them, and that was the gap.** `@driftengine/tools`
5
+ * exported an inspector, a console, a profiler and a network panel, each of which builds a `UiNode`
6
+ * tree and nothing more. What turned a tree into something a person could see lived in the editor
7
+ * application — a dock, a layout, a painter and the event routing — and that workspace is private
8
+ * and ships to nobody. So every consumer wanting an in-game inspector had to write that half again,
9
+ * and six of them writing it six times is the drift `AGENTS.md` opens by describing.
10
+ *
11
+ * **A column, not a dock.** The editor's dock splits, drags and persists because somebody arranges
12
+ * an editor and then keeps that arrangement. Nobody arranges a debug overlay: they press a key,
13
+ * read a number and press it again. So the panels stack down one edge in the order they were
14
+ * given, and what a dock would have bought is not bought.
15
+ *
16
+ * **The painter is supplied, never reached for.** Four calls — a rectangle, a line of text, a clip
17
+ * and its close — which is every operation `paintOverlay` makes. A host with a 2D context
18
+ * implements them in four lines, a host drawing through `@driftengine/ui2d`'s sprite pass
19
+ * implements them over a batch, and a host with no screen at all implements them into an array,
20
+ * which is what this module's own tests do. That is `AGENTS.md`'s platform rule applied to
21
+ * drawing: take the capability as a parameter, and let the consumer decide what it is.
22
+ *
23
+ * **Nothing here needs a graphics device**, which is the property that makes the whole of it
24
+ * testable. The geometry is arithmetic over a tree, the routing is arithmetic over rectangles, and
25
+ * the drawing is somebody else's four functions. An overlay that could only run against a live
26
+ * adapter is an overlay whose behaviour nobody can assert.
27
+ *
28
+ * **Hidden costs a frame nothing.** While it is closed, `frame` returns before it builds anything
29
+ * and `route` refuses every event but the one that opens it, so a game that never presses the key
30
+ * pays for a boolean.
31
+ */
32
+ import { createUiNode, layoutUiTree } from '@driftengine/ui2d';
33
+ import { createUndoStack } from './command.js';
34
+ import { createPanelRoot } from './panel.js';
35
+ export function bindPanel(panel, world, view) {
36
+ return {
37
+ id: panel.id,
38
+ title: panel.title,
39
+ build: (root) => panel.build(world(), view, root),
40
+ route: (event) => panel.route(world(), view, event),
41
+ };
42
+ }
43
+ const DEFAULT_WIDTH = 320;
44
+ const DEFAULT_KEY = 'F3';
45
+ export function createToolsOverlay(options) {
46
+ const root = createUiNode({ name: 'tools-overlay', width: 'grow', height: 'grow' });
47
+ const undo = createUndoStack(options.undoLimit ?? 100);
48
+ const key = options.key ?? DEFAULT_KEY;
49
+ const side = options.side ?? 'right';
50
+ const columnWidth = options.width ?? DEFAULT_WIDTH;
51
+ const panels = options.panels;
52
+ const roots = new Map();
53
+ /* Pooled and refilled: this is read every frame the overlay is open and must not allocate. */
54
+ const sites = [];
55
+ let width = 0;
56
+ let height = 0;
57
+ let visible = options.visible ?? false;
58
+ let dirty = true;
59
+ let disposed = false;
60
+ let layoutCount = 0;
61
+ let focused = null;
62
+ const relayout = () => {
63
+ const x = side === 'left' ? 0 : Math.max(0, width - columnWidth);
64
+ const w = Math.min(columnWidth, width);
65
+ const each = panels.length === 0 ? 0 : Math.floor(height / panels.length);
66
+ root.absolute = true;
67
+ root.x = x;
68
+ root.y = 0;
69
+ root.width = w;
70
+ root.height = height;
71
+ root.children.length = 0;
72
+ sites.length = 0;
73
+ for (let at = 0; at < panels.length; at += 1) {
74
+ const panel = panels[at];
75
+ if (panel === undefined)
76
+ continue;
77
+ /* The last panel takes the remainder, so a height that does not divide leaves no gap. */
78
+ const top = each * at;
79
+ const tall = at === panels.length - 1 ? height - top : each;
80
+ let panelRoot = roots.get(panel.id);
81
+ if (panelRoot === undefined) {
82
+ panelRoot = createPanelRoot(panel);
83
+ roots.set(panel.id, panelRoot);
84
+ }
85
+ panelRoot.absolute = true;
86
+ panelRoot.x = 0;
87
+ panelRoot.y = top;
88
+ panelRoot.width = w;
89
+ panelRoot.height = tall;
90
+ panel.build(panelRoot);
91
+ root.children.push(panelRoot);
92
+ sites.push({ id: panel.id, x: 0, y: top, w, h: tall });
93
+ }
94
+ layoutUiTree(root, x, 0, w, height);
95
+ layoutCount += 1;
96
+ dirty = false;
97
+ };
98
+ return {
99
+ root,
100
+ undo,
101
+ get visible() {
102
+ return visible;
103
+ },
104
+ get layoutCount() {
105
+ return layoutCount;
106
+ },
107
+ sites() {
108
+ if (visible && dirty)
109
+ relayout();
110
+ return sites;
111
+ },
112
+ setVisible(next) {
113
+ if (disposed || next === visible)
114
+ return;
115
+ visible = next;
116
+ dirty = true;
117
+ },
118
+ toggle() {
119
+ this.setVisible(!visible);
120
+ },
121
+ resize(next, nextHeight) {
122
+ if (disposed)
123
+ return;
124
+ if (next === width && nextHeight === height)
125
+ return;
126
+ width = next;
127
+ height = nextHeight;
128
+ dirty = true;
129
+ },
130
+ frame(_now) {
131
+ if (disposed || !visible)
132
+ return;
133
+ if (dirty)
134
+ relayout();
135
+ },
136
+ route(event) {
137
+ if (disposed)
138
+ return false;
139
+ /*
140
+ * The key is answered whether the overlay is open or closed, and it is the only thing that
141
+ * is. A host that had to know which state it was in to decide whether to forward a press
142
+ * would be holding the same boolean twice.
143
+ */
144
+ if (event.kind === 'key' && event.key === key && !event.ctrl) {
145
+ visible = !visible;
146
+ dirty = true;
147
+ return true;
148
+ }
149
+ if (!visible)
150
+ return false;
151
+ if (event.kind === 'key') {
152
+ if (event.ctrl && event.key === 'z') {
153
+ const moved = event.shift ? undo.redo() : undo.undo();
154
+ if (moved)
155
+ dirty = true;
156
+ return moved;
157
+ }
158
+ if (event.ctrl && event.key === 'y') {
159
+ const moved = undo.redo();
160
+ if (moved)
161
+ dirty = true;
162
+ return moved;
163
+ }
164
+ const target = panelWith(focused);
165
+ return target === null ? false : take(target.route(event));
166
+ }
167
+ if (dirty)
168
+ relayout();
169
+ const site = siteAt(event.x, event.y);
170
+ if (site === null)
171
+ return false;
172
+ if (event.kind === 'pointer' && event.phase === 'down')
173
+ focused = site.id;
174
+ const panel = panelWith(site.id);
175
+ if (panel === null)
176
+ return false;
177
+ /* In the panel's own space: a panel never learns where on screen it is. */
178
+ return take(panel.route(shift(event, -(root.rect.x + site.x), -site.y)));
179
+ },
180
+ invalidate() {
181
+ dirty = true;
182
+ },
183
+ dispose() {
184
+ if (disposed)
185
+ return;
186
+ disposed = true;
187
+ visible = false;
188
+ undo.clear();
189
+ root.children.length = 0;
190
+ roots.clear();
191
+ sites.length = 0;
192
+ focused = null;
193
+ },
194
+ };
195
+ function panelWith(id) {
196
+ if (id === null)
197
+ return null;
198
+ for (const panel of panels)
199
+ if (panel.id === id)
200
+ return panel;
201
+ return null;
202
+ }
203
+ function siteAt(x, y) {
204
+ const localX = x - root.rect.x;
205
+ if (localX < 0 || localX >= root.rect.w)
206
+ return null;
207
+ for (const site of sites) {
208
+ if (y >= site.y && y < site.y + site.h)
209
+ return site;
210
+ }
211
+ return null;
212
+ }
213
+ /*
214
+ * **`push` applies, so this must not.** `createUndoStack.push` calls `apply` on the way in — and
215
+ * on the merge path it says in so many words that an absorbed command has already been applied
216
+ * and must not be applied again. Applying here as well ran every panel edit twice, which the
217
+ * row panel's test saw as two rows added by one press and a real inspector would have shown as a
218
+ * field moved twice as far as the pointer.
219
+ */
220
+ function take(command) {
221
+ if (command === null)
222
+ return false;
223
+ undo.push(command);
224
+ dirty = true;
225
+ return true;
226
+ }
227
+ }
228
+ /** The same event moved by a vector. Returns the event itself where there is nothing to move. */
229
+ function shift(event, dx, dy) {
230
+ if (event.kind === 'pointer' || event.kind === 'wheel') {
231
+ return { ...event, x: event.x + dx, y: event.y + dy };
232
+ }
233
+ return event;
234
+ }
235
+ /**
236
+ * Draw the overlay, or draw nothing because it is closed.
237
+ *
238
+ * A free function rather than a method, so the overlay itself never holds a painter and a host may
239
+ * draw the same overlay into two surfaces — a screen and a capture — without either knowing.
240
+ */
241
+ export function paintOverlay(painter, overlay) {
242
+ if (!overlay.visible)
243
+ return;
244
+ paintNode(painter, overlay.root);
245
+ }
246
+ /** Where a twelve-pixel line sits inside a row. Rows here are sixteen to eighteen pixels tall. */
247
+ const TEXT_BASELINE = 12;
248
+ function paintNode(painter, node) {
249
+ if (node.hidden)
250
+ return;
251
+ const { x, y, w, h } = node.rect;
252
+ if (node.background !== null)
253
+ painter.rect(x, y, w, h, cssColour(node.background));
254
+ if (node.text.length > 0) {
255
+ painter.text(node.text, x, y + TEXT_BASELINE, cssColour(node.tint));
256
+ }
257
+ if (node.children.length === 0)
258
+ return;
259
+ if (node.clip)
260
+ painter.clip(x, y, w, h);
261
+ for (const child of node.children)
262
+ paintNode(painter, child);
263
+ if (node.clip)
264
+ painter.unclip();
265
+ }
266
+ function cssColour(rgba) {
267
+ if (rgba === null)
268
+ return '#ffffff';
269
+ let out = '#';
270
+ for (let at = 0; at < 3; at += 1) {
271
+ const channel = Math.round(Math.min(1, Math.max(0, rgba[at] ?? 0)) * 255);
272
+ out += channel.toString(16).padStart(2, '0');
273
+ }
274
+ return out;
275
+ }