@realitycollective/webxr-uiextensions 0.1.0-preview.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +61 -0
  4. package/dist/adapter.d.ts +105 -0
  5. package/dist/adapter.js +2 -0
  6. package/dist/adapter.js.map +1 -0
  7. package/dist/chrome/markup.d.ts +40 -0
  8. package/dist/chrome/markup.js +56 -0
  9. package/dist/chrome/markup.js.map +1 -0
  10. package/dist/controls/element.d.ts +22 -0
  11. package/dist/controls/element.js +40 -0
  12. package/dist/controls/element.js.map +1 -0
  13. package/dist/controls/expandable-label.d.ts +32 -0
  14. package/dist/controls/expandable-label.js +63 -0
  15. package/dist/controls/expandable-label.js.map +1 -0
  16. package/dist/controls/log-view.d.ts +38 -0
  17. package/dist/controls/log-view.js +83 -0
  18. package/dist/controls/log-view.js.map +1 -0
  19. package/dist/controls/stepper.d.ts +32 -0
  20. package/dist/controls/stepper.js +78 -0
  21. package/dist/controls/stepper.js.map +1 -0
  22. package/dist/controls/toggle.d.ts +33 -0
  23. package/dist/controls/toggle.js +63 -0
  24. package/dist/controls/toggle.js.map +1 -0
  25. package/dist/controls/upgrade.d.ts +31 -0
  26. package/dist/controls/upgrade.js +87 -0
  27. package/dist/controls/upgrade.js.map +1 -0
  28. package/dist/core/dock-state.d.ts +58 -0
  29. package/dist/core/dock-state.js +64 -0
  30. package/dist/core/dock-state.js.map +1 -0
  31. package/dist/core/drag-math.d.ts +34 -0
  32. package/dist/core/drag-math.js +44 -0
  33. package/dist/core/drag-math.js.map +1 -0
  34. package/dist/core/events.d.ts +17 -0
  35. package/dist/core/events.js +40 -0
  36. package/dist/core/events.js.map +1 -0
  37. package/dist/core/expandable-model.d.ts +35 -0
  38. package/dist/core/expandable-model.js +57 -0
  39. package/dist/core/expandable-model.js.map +1 -0
  40. package/dist/core/hold-to-drag.d.ts +24 -0
  41. package/dist/core/hold-to-drag.js +34 -0
  42. package/dist/core/hold-to-drag.js.map +1 -0
  43. package/dist/core/log-model.d.ts +42 -0
  44. package/dist/core/log-model.js +69 -0
  45. package/dist/core/log-model.js.map +1 -0
  46. package/dist/core/region-layout.d.ts +41 -0
  47. package/dist/core/region-layout.js +79 -0
  48. package/dist/core/region-layout.js.map +1 -0
  49. package/dist/core/region-registry.d.ts +35 -0
  50. package/dist/core/region-registry.js +97 -0
  51. package/dist/core/region-registry.js.map +1 -0
  52. package/dist/core/stepper-model.d.ts +24 -0
  53. package/dist/core/stepper-model.js +46 -0
  54. package/dist/core/stepper-model.js.map +1 -0
  55. package/dist/core/toggle-model.d.ts +11 -0
  56. package/dist/core/toggle-model.js +25 -0
  57. package/dist/core/toggle-model.js.map +1 -0
  58. package/dist/core/window-manager.d.ts +81 -0
  59. package/dist/core/window-manager.js +172 -0
  60. package/dist/core/window-manager.js.map +1 -0
  61. package/dist/index.d.ts +29 -0
  62. package/dist/index.js +34 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/scene.d.ts +79 -0
  65. package/dist/scene.js +43 -0
  66. package/dist/scene.js.map +1 -0
  67. package/package.json +51 -0
@@ -0,0 +1,40 @@
1
+ export class Emitter {
2
+ listeners = new Map();
3
+ on(event, listener) {
4
+ let set = this.listeners.get(event);
5
+ if (!set) {
6
+ set = new Set();
7
+ this.listeners.set(event, set);
8
+ }
9
+ set.add(listener);
10
+ return () => this.off(event, listener);
11
+ }
12
+ once(event, listener) {
13
+ const dispose = this.on(event, (payload) => {
14
+ dispose();
15
+ listener(payload);
16
+ });
17
+ return dispose;
18
+ }
19
+ off(event, listener) {
20
+ this.listeners.get(event)?.delete(listener);
21
+ }
22
+ emit(event, payload) {
23
+ // Copy before iterating so listeners can unsubscribe (or subscribe) safely
24
+ // from inside a handler without corrupting the iteration.
25
+ const set = this.listeners.get(event);
26
+ if (!set) {
27
+ return;
28
+ }
29
+ for (const listener of [...set]) {
30
+ listener(payload);
31
+ }
32
+ }
33
+ listenerCount(event) {
34
+ return this.listeners.get(event)?.size ?? 0;
35
+ }
36
+ clear() {
37
+ this.listeners.clear();
38
+ }
39
+ }
40
+ //# sourceMappingURL=events.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"events.js","sourceRoot":"","sources":["../../src/core/events.ts"],"names":[],"mappings":"AASA,MAAM,OAAO,OAAO;IACV,SAAS,GAAG,IAAI,GAAG,EAAuC,CAAC;IAEnE,EAAE,CAA0B,KAAQ,EAAE,QAA8B;QAClE,IAAI,GAAG,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QACpC,IAAI,CAAC,GAAG,EAAE,CAAC;YACT,GAAG,GAAG,IAAI,GAAG,EAAE,CAAC;YAChB,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;QACjC,CAAC;QACD,GAAG,CAAC,GAAG,CAAC,QAA2B,CAAC,CAAC;QACrC,OAAO,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;IACzC,CAAC;IAED,IAAI,CAA0B,KAAQ,EAAE,QAA8B;QACpE,MAAM,OAAO,GAAG,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,OAAO,EAAE,EAAE;YACzC,OAAO,EAAE,CAAC;YACV,QAAQ,CAAC,OAAO,CAAC,CAAC;QACpB,CAAC,CAAC,CAAC;QACH,OAAO,OAAO,CAAC;IACjB,CAAC;IAED,GAAG,CAA0B,KAAQ,EAAE,QAA8B;QACnE,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,QAA2B,CAAC,CAAC;IACjE,CAAC;IAED,IAAI,CAA0B,KAAQ,EAAE,OAAmB;QACzD,2EAA2E;QAC3E,0DAA0D;QAC1D,MAAM,GAAG,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QACtC,IAAI,CAAC,GAAG,EAAE,CAAC;YACT,OAAO;QACT,CAAC;QACD,KAAK,MAAM,QAAQ,IAAI,CAAC,GAAG,GAAG,CAAC,EAAE,CAAC;YAC/B,QAAiC,CAAC,OAAO,CAAC,CAAC;QAC9C,CAAC;IACH,CAAC;IAED,aAAa,CAA0B,KAAQ;QAC7C,OAAO,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,IAAI,IAAI,CAAC,CAAC;IAC9C,CAAC;IAED,KAAK;QACH,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC;IACzB,CAAC;CACF","sourcesContent":["/**\n * Minimal typed event emitter.\n *\n * The library keeps its observable surface deliberately tiny - consumers that\n * want richer eventing can bridge these into their own bus (for example the\n * Reality Collective service framework's IEventService).\n */\nexport type Listener<T> = (payload: T) => void;\n\nexport class Emitter<TEvents extends Record<string, unknown>> {\n private listeners = new Map<keyof TEvents, Set<Listener<never>>>();\n\n on<K extends keyof TEvents>(event: K, listener: Listener<TEvents[K]>): () => void {\n let set = this.listeners.get(event);\n if (!set) {\n set = new Set();\n this.listeners.set(event, set);\n }\n set.add(listener as Listener<never>);\n return () => this.off(event, listener);\n }\n\n once<K extends keyof TEvents>(event: K, listener: Listener<TEvents[K]>): () => void {\n const dispose = this.on(event, (payload) => {\n dispose();\n listener(payload);\n });\n return dispose;\n }\n\n off<K extends keyof TEvents>(event: K, listener: Listener<TEvents[K]>): void {\n this.listeners.get(event)?.delete(listener as Listener<never>);\n }\n\n emit<K extends keyof TEvents>(event: K, payload: TEvents[K]): void {\n // Copy before iterating so listeners can unsubscribe (or subscribe) safely\n // from inside a handler without corrupting the iteration.\n const set = this.listeners.get(event);\n if (!set) {\n return;\n }\n for (const listener of [...set]) {\n (listener as Listener<TEvents[K]>)(payload);\n }\n }\n\n listenerCount<K extends keyof TEvents>(event: K): number {\n return this.listeners.get(event)?.size ?? 0;\n }\n\n clear(): void {\n this.listeners.clear();\n }\n}\n"]}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Expandable label model - pure truncation/expansion state for the
3
+ * `data-uix="expandable-label"` control (a multi-line label that collapses to
4
+ * a preview with an ellipsis and a "more/less" affordance).
5
+ *
6
+ * Truncation is character-budget based (lines × charsPerLine) rather than
7
+ * pixel based: uikit lays text out with MSDF glyphs at panel resolution, so
8
+ * an exact pixel measure would need the live renderer. A stable budget keeps
9
+ * the control deterministic and testable; authors tune `charsPerLine` to
10
+ * their font size.
11
+ */
12
+ export interface ExpandableOptions {
13
+ text?: string;
14
+ collapsedLines?: number;
15
+ charsPerLine?: number;
16
+ }
17
+ export declare const ELLIPSIS = "...";
18
+ export declare class ExpandableModel {
19
+ readonly collapsedLines: number;
20
+ readonly charsPerLine: number;
21
+ private fullText;
22
+ private isExpanded;
23
+ constructor(options?: ExpandableOptions);
24
+ get text(): string;
25
+ setText(text: string): void;
26
+ get expanded(): boolean;
27
+ toggle(): boolean;
28
+ /** True when the text overflows the collapsed budget (i.e. a toggle is useful). */
29
+ get overflows(): boolean;
30
+ /** The string the label should currently display. */
31
+ get displayText(): string;
32
+ /** Label for the expand/collapse affordance. */
33
+ get toggleLabel(): string;
34
+ private get budget();
35
+ }
@@ -0,0 +1,57 @@
1
+ // Three ASCII dots, not U+2026: MSDF font atlases routinely lack the
2
+ // ellipsis glyph and render a tofu box instead.
3
+ export const ELLIPSIS = '...';
4
+ export class ExpandableModel {
5
+ collapsedLines;
6
+ charsPerLine;
7
+ fullText;
8
+ isExpanded = false;
9
+ constructor(options = {}) {
10
+ this.collapsedLines = options.collapsedLines ?? 2;
11
+ this.charsPerLine = options.charsPerLine ?? 42;
12
+ if (this.collapsedLines < 1) {
13
+ throw new Error(`[uix] collapsedLines must be >= 1 (got ${this.collapsedLines})`);
14
+ }
15
+ if (this.charsPerLine < 4) {
16
+ throw new Error(`[uix] charsPerLine must be >= 4 (got ${this.charsPerLine})`);
17
+ }
18
+ this.fullText = options.text ?? '';
19
+ }
20
+ get text() {
21
+ return this.fullText;
22
+ }
23
+ setText(text) {
24
+ this.fullText = text;
25
+ }
26
+ get expanded() {
27
+ return this.isExpanded;
28
+ }
29
+ toggle() {
30
+ this.isExpanded = !this.isExpanded;
31
+ return this.isExpanded;
32
+ }
33
+ /** True when the text overflows the collapsed budget (i.e. a toggle is useful). */
34
+ get overflows() {
35
+ return this.fullText.length > this.budget;
36
+ }
37
+ /** The string the label should currently display. */
38
+ get displayText() {
39
+ if (this.isExpanded || !this.overflows) {
40
+ return this.fullText;
41
+ }
42
+ // Cut on the last word boundary inside the budget so the preview never
43
+ // ends mid-word; fall back to a hard cut for a single unbroken token.
44
+ const slice = this.fullText.slice(0, this.budget - ELLIPSIS.length);
45
+ const lastSpace = slice.lastIndexOf(' ');
46
+ const cut = lastSpace > this.budget * 0.5 ? slice.slice(0, lastSpace) : slice;
47
+ return `${cut.trimEnd()}${ELLIPSIS}`;
48
+ }
49
+ /** Label for the expand/collapse affordance. */
50
+ get toggleLabel() {
51
+ return this.isExpanded ? 'less' : 'more';
52
+ }
53
+ get budget() {
54
+ return this.collapsedLines * this.charsPerLine;
55
+ }
56
+ }
57
+ //# sourceMappingURL=expandable-model.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"expandable-model.js","sourceRoot":"","sources":["../../src/core/expandable-model.ts"],"names":[],"mappings":"AAiBA,qEAAqE;AACrE,gDAAgD;AAChD,MAAM,CAAC,MAAM,QAAQ,GAAG,KAAK,CAAC;AAE9B,MAAM,OAAO,eAAe;IACjB,cAAc,CAAS;IACvB,YAAY,CAAS;IACtB,QAAQ,CAAS;IACjB,UAAU,GAAG,KAAK,CAAC;IAE3B,YAAY,UAA6B,EAAE;QACzC,IAAI,CAAC,cAAc,GAAG,OAAO,CAAC,cAAc,IAAI,CAAC,CAAC;QAClD,IAAI,CAAC,YAAY,GAAG,OAAO,CAAC,YAAY,IAAI,EAAE,CAAC;QAC/C,IAAI,IAAI,CAAC,cAAc,GAAG,CAAC,EAAE,CAAC;YAC5B,MAAM,IAAI,KAAK,CAAC,0CAA0C,IAAI,CAAC,cAAc,GAAG,CAAC,CAAC;QACpF,CAAC;QACD,IAAI,IAAI,CAAC,YAAY,GAAG,CAAC,EAAE,CAAC;YAC1B,MAAM,IAAI,KAAK,CAAC,wCAAwC,IAAI,CAAC,YAAY,GAAG,CAAC,CAAC;QAChF,CAAC;QACD,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC,IAAI,IAAI,EAAE,CAAC;IACrC,CAAC;IAED,IAAI,IAAI;QACN,OAAO,IAAI,CAAC,QAAQ,CAAC;IACvB,CAAC;IAED,OAAO,CAAC,IAAY;QAClB,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC;IACvB,CAAC;IAED,IAAI,QAAQ;QACV,OAAO,IAAI,CAAC,UAAU,CAAC;IACzB,CAAC;IAED,MAAM;QACJ,IAAI,CAAC,UAAU,GAAG,CAAC,IAAI,CAAC,UAAU,CAAC;QACnC,OAAO,IAAI,CAAC,UAAU,CAAC;IACzB,CAAC;IAED,mFAAmF;IACnF,IAAI,SAAS;QACX,OAAO,IAAI,CAAC,QAAQ,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC;IAC5C,CAAC;IAED,qDAAqD;IACrD,IAAI,WAAW;QACb,IAAI,IAAI,CAAC,UAAU,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC;YACvC,OAAO,IAAI,CAAC,QAAQ,CAAC;QACvB,CAAC;QACD,uEAAuE;QACvE,sEAAsE;QACtE,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC;QACpE,MAAM,SAAS,GAAG,KAAK,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;QACzC,MAAM,GAAG,GAAG,SAAS,GAAG,IAAI,CAAC,MAAM,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;QAC9E,OAAO,GAAG,GAAG,CAAC,OAAO,EAAE,GAAG,QAAQ,EAAE,CAAC;IACvC,CAAC;IAED,gDAAgD;IAChD,IAAI,WAAW;QACb,OAAO,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC;IAC3C,CAAC;IAED,IAAY,MAAM;QAChB,OAAO,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC,YAAY,CAAC;IACjD,CAAC;CACF","sourcesContent":["/**\n * Expandable label model - pure truncation/expansion state for the\n * `data-uix=\"expandable-label\"` control (a multi-line label that collapses to\n * a preview with an ellipsis and a \"more/less\" affordance).\n *\n * Truncation is character-budget based (lines × charsPerLine) rather than\n * pixel based: uikit lays text out with MSDF glyphs at panel resolution, so\n * an exact pixel measure would need the live renderer. A stable budget keeps\n * the control deterministic and testable; authors tune `charsPerLine` to\n * their font size.\n */\nexport interface ExpandableOptions {\n text?: string;\n collapsedLines?: number;\n charsPerLine?: number;\n}\n\n// Three ASCII dots, not U+2026: MSDF font atlases routinely lack the\n// ellipsis glyph and render a tofu box instead.\nexport const ELLIPSIS = '...';\n\nexport class ExpandableModel {\n readonly collapsedLines: number;\n readonly charsPerLine: number;\n private fullText: string;\n private isExpanded = false;\n\n constructor(options: ExpandableOptions = {}) {\n this.collapsedLines = options.collapsedLines ?? 2;\n this.charsPerLine = options.charsPerLine ?? 42;\n if (this.collapsedLines < 1) {\n throw new Error(`[uix] collapsedLines must be >= 1 (got ${this.collapsedLines})`);\n }\n if (this.charsPerLine < 4) {\n throw new Error(`[uix] charsPerLine must be >= 4 (got ${this.charsPerLine})`);\n }\n this.fullText = options.text ?? '';\n }\n\n get text(): string {\n return this.fullText;\n }\n\n setText(text: string): void {\n this.fullText = text;\n }\n\n get expanded(): boolean {\n return this.isExpanded;\n }\n\n toggle(): boolean {\n this.isExpanded = !this.isExpanded;\n return this.isExpanded;\n }\n\n /** True when the text overflows the collapsed budget (i.e. a toggle is useful). */\n get overflows(): boolean {\n return this.fullText.length > this.budget;\n }\n\n /** The string the label should currently display. */\n get displayText(): string {\n if (this.isExpanded || !this.overflows) {\n return this.fullText;\n }\n // Cut on the last word boundary inside the budget so the preview never\n // ends mid-word; fall back to a hard cut for a single unbroken token.\n const slice = this.fullText.slice(0, this.budget - ELLIPSIS.length);\n const lastSpace = slice.lastIndexOf(' ');\n const cut = lastSpace > this.budget * 0.5 ? slice.slice(0, lastSpace) : slice;\n return `${cut.trimEnd()}${ELLIPSIS}`;\n }\n\n /** Label for the expand/collapse affordance. */\n get toggleLabel(): string {\n return this.isExpanded ? 'less' : 'more';\n }\n\n private get budget(): number {\n return this.collapsedLines * this.charsPerLine;\n }\n}\n"]}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Hold-to-drag threshold - pure timing state for title-bar dragging.
3
+ *
4
+ * A press only becomes a drag after being held for `delaySeconds`. Anything
5
+ * shorter is a click (pin/minimize/close and friends), so buttons on the
6
+ * drag surface never fight the drag gesture.
7
+ */
8
+ export type HoldPhase = 'idle' | 'pending' | 'dragging';
9
+ export interface HoldUpdate {
10
+ phase: HoldPhase;
11
+ /** True on the exact update where the hold crossed into dragging. */
12
+ began: boolean;
13
+ /** True on the exact update where an active drag was released. */
14
+ ended: boolean;
15
+ }
16
+ export declare class HoldToDrag {
17
+ readonly delaySeconds: number;
18
+ private heldFor;
19
+ private phase;
20
+ constructor(delaySeconds?: number);
21
+ get current(): HoldPhase;
22
+ /** Advance with this frame's held state and delta time (seconds). */
23
+ update(held: boolean, delta: number): HoldUpdate;
24
+ }
@@ -0,0 +1,34 @@
1
+ export class HoldToDrag {
2
+ delaySeconds;
3
+ heldFor = 0;
4
+ phase = 'idle';
5
+ constructor(delaySeconds = 0.3) {
6
+ this.delaySeconds = delaySeconds;
7
+ if (delaySeconds < 0) {
8
+ throw new Error(`[uix] drag delay must be >= 0 (got ${delaySeconds})`);
9
+ }
10
+ }
11
+ get current() {
12
+ return this.phase;
13
+ }
14
+ /** Advance with this frame's held state and delta time (seconds). */
15
+ update(held, delta) {
16
+ if (!held) {
17
+ const ended = this.phase === 'dragging';
18
+ this.phase = 'idle';
19
+ this.heldFor = 0;
20
+ return { phase: 'idle', began: false, ended };
21
+ }
22
+ if (this.phase === 'dragging') {
23
+ return { phase: 'dragging', began: false, ended: false };
24
+ }
25
+ this.heldFor += delta;
26
+ if (this.heldFor >= this.delaySeconds) {
27
+ this.phase = 'dragging';
28
+ return { phase: 'dragging', began: true, ended: false };
29
+ }
30
+ this.phase = 'pending';
31
+ return { phase: 'pending', began: false, ended: false };
32
+ }
33
+ }
34
+ //# sourceMappingURL=hold-to-drag.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hold-to-drag.js","sourceRoot":"","sources":["../../src/core/hold-to-drag.ts"],"names":[],"mappings":"AAiBA,MAAM,OAAO,UAAU;IAIA;IAHb,OAAO,GAAG,CAAC,CAAC;IACZ,KAAK,GAAc,MAAM,CAAC;IAElC,YAAqB,eAAe,GAAG;QAAlB,iBAAY,GAAZ,YAAY,CAAM;QACrC,IAAI,YAAY,GAAG,CAAC,EAAE,CAAC;YACrB,MAAM,IAAI,KAAK,CAAC,sCAAsC,YAAY,GAAG,CAAC,CAAC;QACzE,CAAC;IACH,CAAC;IAED,IAAI,OAAO;QACT,OAAO,IAAI,CAAC,KAAK,CAAC;IACpB,CAAC;IAED,qEAAqE;IACrE,MAAM,CAAC,IAAa,EAAE,KAAa;QACjC,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,KAAK,UAAU,CAAC;YACxC,IAAI,CAAC,KAAK,GAAG,MAAM,CAAC;YACpB,IAAI,CAAC,OAAO,GAAG,CAAC,CAAC;YACjB,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;QAChD,CAAC;QACD,IAAI,IAAI,CAAC,KAAK,KAAK,UAAU,EAAE,CAAC;YAC9B,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;QAC3D,CAAC;QACD,IAAI,CAAC,OAAO,IAAI,KAAK,CAAC;QACtB,IAAI,IAAI,CAAC,OAAO,IAAI,IAAI,CAAC,YAAY,EAAE,CAAC;YACtC,IAAI,CAAC,KAAK,GAAG,UAAU,CAAC;YACxB,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;QAC1D,CAAC;QACD,IAAI,CAAC,KAAK,GAAG,SAAS,CAAC;QACvB,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IAC1D,CAAC;CACF","sourcesContent":["/**\n * Hold-to-drag threshold - pure timing state for title-bar dragging.\n *\n * A press only becomes a drag after being held for `delaySeconds`. Anything\n * shorter is a click (pin/minimize/close and friends), so buttons on the\n * drag surface never fight the drag gesture.\n */\nexport type HoldPhase = 'idle' | 'pending' | 'dragging';\n\nexport interface HoldUpdate {\n phase: HoldPhase;\n /** True on the exact update where the hold crossed into dragging. */\n began: boolean;\n /** True on the exact update where an active drag was released. */\n ended: boolean;\n}\n\nexport class HoldToDrag {\n private heldFor = 0;\n private phase: HoldPhase = 'idle';\n\n constructor(readonly delaySeconds = 0.3) {\n if (delaySeconds < 0) {\n throw new Error(`[uix] drag delay must be >= 0 (got ${delaySeconds})`);\n }\n }\n\n get current(): HoldPhase {\n return this.phase;\n }\n\n /** Advance with this frame's held state and delta time (seconds). */\n update(held: boolean, delta: number): HoldUpdate {\n if (!held) {\n const ended = this.phase === 'dragging';\n this.phase = 'idle';\n this.heldFor = 0;\n return { phase: 'idle', began: false, ended };\n }\n if (this.phase === 'dragging') {\n return { phase: 'dragging', began: false, ended: false };\n }\n this.heldFor += delta;\n if (this.heldFor >= this.delaySeconds) {\n this.phase = 'dragging';\n return { phase: 'dragging', began: true, ended: false };\n }\n this.phase = 'pending';\n return { phase: 'pending', began: false, ended: false };\n }\n}\n"]}
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Log model - pure ring buffer + viewport for the `data-uix="log-view"`
3
+ * control (scrollable log/list windows).
4
+ *
5
+ * UIKitML text nodes are a fixed pool (see "the text rule": dynamic text
6
+ * needs a placeholder child), so the view renders into a fixed number of
7
+ * visible rows and the model computes which slice of the buffer those rows
8
+ * show. `follow` mode pins the viewport to the newest entry, like a console.
9
+ */
10
+ export interface LogEntry {
11
+ message: string;
12
+ /** Optional severity/category tag; the view can color rows by it. */
13
+ level: string;
14
+ }
15
+ export interface LogModelOptions {
16
+ /** Maximum retained entries; older entries are dropped. */
17
+ capacity?: number;
18
+ /** Number of visible rows in the view. */
19
+ visibleRows?: number;
20
+ }
21
+ export declare class LogModel {
22
+ readonly capacity: number;
23
+ readonly visibleRows: number;
24
+ private entries;
25
+ /** Index of the first visible entry when not following. */
26
+ private scrollTop;
27
+ private following;
28
+ constructor(options?: LogModelOptions);
29
+ get count(): number;
30
+ get isFollowing(): boolean;
31
+ push(message: string, level?: string): void;
32
+ clear(): void;
33
+ /** Scroll up (towards older entries) by `rows`; disengages follow mode. */
34
+ scrollUp(rows?: number): void;
35
+ /** Scroll down; re-engages follow mode when the newest entry comes into view. */
36
+ scrollDown(rows?: number): void;
37
+ follow(): void;
38
+ /** The entries currently in the viewport, oldest first. */
39
+ visible(): LogEntry[];
40
+ private maxTop;
41
+ private effectiveTop;
42
+ }
@@ -0,0 +1,69 @@
1
+ export class LogModel {
2
+ capacity;
3
+ visibleRows;
4
+ entries = [];
5
+ /** Index of the first visible entry when not following. */
6
+ scrollTop = 0;
7
+ following = true;
8
+ constructor(options = {}) {
9
+ this.capacity = options.capacity ?? 200;
10
+ this.visibleRows = options.visibleRows ?? 8;
11
+ if (this.capacity < 1) {
12
+ throw new Error(`[uix] log capacity must be >= 1 (got ${this.capacity})`);
13
+ }
14
+ if (this.visibleRows < 1) {
15
+ throw new Error(`[uix] log visibleRows must be >= 1 (got ${this.visibleRows})`);
16
+ }
17
+ }
18
+ get count() {
19
+ return this.entries.length;
20
+ }
21
+ get isFollowing() {
22
+ return this.following;
23
+ }
24
+ push(message, level = 'info') {
25
+ this.entries.push({ message, level });
26
+ const overflow = this.entries.length - this.capacity;
27
+ if (overflow > 0) {
28
+ this.entries.splice(0, overflow);
29
+ this.scrollTop = Math.max(0, this.scrollTop - overflow);
30
+ }
31
+ }
32
+ clear() {
33
+ this.entries = [];
34
+ this.scrollTop = 0;
35
+ this.following = true;
36
+ }
37
+ /** Scroll up (towards older entries) by `rows`; disengages follow mode. */
38
+ scrollUp(rows = 1) {
39
+ // Seed from the viewport the user is looking at NOW (which, while
40
+ // following, is the tail) before switching to manual scrolling.
41
+ const top = this.effectiveTop();
42
+ this.following = false;
43
+ this.scrollTop = Math.max(0, top - rows);
44
+ }
45
+ /** Scroll down; re-engages follow mode when the newest entry comes into view. */
46
+ scrollDown(rows = 1) {
47
+ const max = this.maxTop();
48
+ const next = Math.min(max, this.effectiveTop() + rows);
49
+ this.scrollTop = next;
50
+ if (next >= max) {
51
+ this.following = true;
52
+ }
53
+ }
54
+ follow() {
55
+ this.following = true;
56
+ }
57
+ /** The entries currently in the viewport, oldest first. */
58
+ visible() {
59
+ const top = this.effectiveTop();
60
+ return this.entries.slice(top, top + this.visibleRows);
61
+ }
62
+ maxTop() {
63
+ return Math.max(0, this.entries.length - this.visibleRows);
64
+ }
65
+ effectiveTop() {
66
+ return this.following ? this.maxTop() : Math.min(this.scrollTop, this.maxTop());
67
+ }
68
+ }
69
+ //# sourceMappingURL=log-model.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log-model.js","sourceRoot":"","sources":["../../src/core/log-model.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Log model - pure ring buffer + viewport for the `data-uix=\"log-view\"`\n * control (scrollable log/list windows).\n *\n * UIKitML text nodes are a fixed pool (see \"the text rule\": dynamic text\n * needs a placeholder child), so the view renders into a fixed number of\n * visible rows and the model computes which slice of the buffer those rows\n * show. `follow` mode pins the viewport to the newest entry, like a console.\n */\nexport interface LogEntry {\n message: string;\n /** Optional severity/category tag; the view can color rows by it. */\n level: string;\n}\n\nexport interface LogModelOptions {\n /** Maximum retained entries; older entries are dropped. */\n capacity?: number;\n /** Number of visible rows in the view. */\n visibleRows?: number;\n}\n\nexport class LogModel {\n readonly capacity: number;\n readonly visibleRows: number;\n private entries: LogEntry[] = [];\n /** Index of the first visible entry when not following. */\n private scrollTop = 0;\n private following = true;\n\n constructor(options: LogModelOptions = {}) {\n this.capacity = options.capacity ?? 200;\n this.visibleRows = options.visibleRows ?? 8;\n if (this.capacity < 1) {\n throw new Error(`[uix] log capacity must be >= 1 (got ${this.capacity})`);\n }\n if (this.visibleRows < 1) {\n throw new Error(`[uix] log visibleRows must be >= 1 (got ${this.visibleRows})`);\n }\n }\n\n get count(): number {\n return this.entries.length;\n }\n\n get isFollowing(): boolean {\n return this.following;\n }\n\n push(message: string, level = 'info'): void {\n this.entries.push({ message, level });\n const overflow = this.entries.length - this.capacity;\n if (overflow > 0) {\n this.entries.splice(0, overflow);\n this.scrollTop = Math.max(0, this.scrollTop - overflow);\n }\n }\n\n clear(): void {\n this.entries = [];\n this.scrollTop = 0;\n this.following = true;\n }\n\n /** Scroll up (towards older entries) by `rows`; disengages follow mode. */\n scrollUp(rows = 1): void {\n // Seed from the viewport the user is looking at NOW (which, while\n // following, is the tail) before switching to manual scrolling.\n const top = this.effectiveTop();\n this.following = false;\n this.scrollTop = Math.max(0, top - rows);\n }\n\n /** Scroll down; re-engages follow mode when the newest entry comes into view. */\n scrollDown(rows = 1): void {\n const max = this.maxTop();\n const next = Math.min(max, this.effectiveTop() + rows);\n this.scrollTop = next;\n if (next >= max) {\n this.following = true;\n }\n }\n\n follow(): void {\n this.following = true;\n }\n\n /** The entries currently in the viewport, oldest first. */\n visible(): LogEntry[] {\n const top = this.effectiveTop();\n return this.entries.slice(top, top + this.visibleRows);\n }\n\n private maxTop(): number {\n return Math.max(0, this.entries.length - this.visibleRows);\n }\n\n private effectiveTop(): number {\n return this.following ? this.maxTop() : Math.min(this.scrollTop, this.maxTop());\n }\n}\n"]}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Layout regions - pure slot math for docking windows into named regions.
3
+ *
4
+ * A `DockRegion` is an anchor in space (an entity transform) that lays its
5
+ * docked windows out in a row, column or grid of fixed-pitch slots. The math
6
+ * here is deliberately simple and deterministic: given a slot index and a
7
+ * region definition, produce a local-space offset; given a drop point and the
8
+ * regions in the scene, decide which region (if any) captured the drop.
9
+ */
10
+ export type Vec3 = readonly [number, number, number];
11
+ export type RegionFlow = 'row' | 'column' | 'grid';
12
+ export interface RegionDefinition {
13
+ /** Layout direction. `row` = +X, `column` = -Y, `grid` = rows of `columns`. */
14
+ flow: RegionFlow;
15
+ /** Distance between slot origins, in meters. */
16
+ pitch: number;
17
+ /** Number of columns per row; only meaningful for `grid`. */
18
+ columns: number;
19
+ /** Maximum windows the region accepts (0 = unlimited). */
20
+ capacity: number;
21
+ /** Drop-capture radius around the region origin, in meters. */
22
+ snapRadius: number;
23
+ }
24
+ export declare const DEFAULT_REGION: RegionDefinition;
25
+ export declare function normalizeRegion(partial: Partial<RegionDefinition>): RegionDefinition;
26
+ /** Local-space offset of slot `index` within a region. */
27
+ export declare function slotOffset(region: RegionDefinition, index: number): Vec3;
28
+ export declare function hasCapacity(region: RegionDefinition, occupancy: number): boolean;
29
+ export interface RegionCandidate<TId> {
30
+ id: TId;
31
+ origin: Vec3;
32
+ region: RegionDefinition;
33
+ occupancy: number;
34
+ }
35
+ /**
36
+ * Which region captures a window dropped at `point`? The nearest region whose
37
+ * `snapRadius` contains the point and which still has capacity; `undefined`
38
+ * when the drop lands in open space (the window stays world-locked where it
39
+ * was released).
40
+ */
41
+ export declare function captureDrop<TId>(point: Vec3, candidates: readonly RegionCandidate<TId>[]): RegionCandidate<TId> | undefined;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Layout regions - pure slot math for docking windows into named regions.
3
+ *
4
+ * A `DockRegion` is an anchor in space (an entity transform) that lays its
5
+ * docked windows out in a row, column or grid of fixed-pitch slots. The math
6
+ * here is deliberately simple and deterministic: given a slot index and a
7
+ * region definition, produce a local-space offset; given a drop point and the
8
+ * regions in the scene, decide which region (if any) captured the drop.
9
+ */
10
+ export const DEFAULT_REGION = {
11
+ flow: 'column',
12
+ pitch: 0.35,
13
+ columns: 2,
14
+ capacity: 0,
15
+ snapRadius: 0.5,
16
+ };
17
+ export function normalizeRegion(partial) {
18
+ const region = { ...DEFAULT_REGION, ...partial };
19
+ if (!(region.pitch > 0)) {
20
+ throw new Error(`[uix] region pitch must be > 0 (got ${region.pitch})`);
21
+ }
22
+ if (!(region.columns >= 1)) {
23
+ throw new Error(`[uix] region columns must be >= 1 (got ${region.columns})`);
24
+ }
25
+ if (region.capacity < 0) {
26
+ throw new Error(`[uix] region capacity must be >= 0 (got ${region.capacity})`);
27
+ }
28
+ if (!(region.snapRadius > 0)) {
29
+ throw new Error(`[uix] region snapRadius must be > 0 (got ${region.snapRadius})`);
30
+ }
31
+ return region;
32
+ }
33
+ /** Local-space offset of slot `index` within a region. */
34
+ export function slotOffset(region, index) {
35
+ if (index < 0 || !Number.isInteger(index)) {
36
+ throw new Error(`[uix] slot index must be a non-negative integer (got ${index})`);
37
+ }
38
+ // `+ 0` folds the -0 that `-0 * pitch` would produce into plain 0.
39
+ switch (region.flow) {
40
+ case 'row':
41
+ return [index * region.pitch, 0, 0];
42
+ case 'column':
43
+ return [0, -index * region.pitch + 0, 0];
44
+ case 'grid': {
45
+ const col = index % region.columns;
46
+ const row = Math.floor(index / region.columns);
47
+ return [col * region.pitch, -row * region.pitch + 0, 0];
48
+ }
49
+ }
50
+ }
51
+ export function hasCapacity(region, occupancy) {
52
+ return region.capacity === 0 || occupancy < region.capacity;
53
+ }
54
+ /**
55
+ * Which region captures a window dropped at `point`? The nearest region whose
56
+ * `snapRadius` contains the point and which still has capacity; `undefined`
57
+ * when the drop lands in open space (the window stays world-locked where it
58
+ * was released).
59
+ */
60
+ export function captureDrop(point, candidates) {
61
+ let best;
62
+ let bestDistSq = Infinity;
63
+ for (const candidate of candidates) {
64
+ if (!hasCapacity(candidate.region, candidate.occupancy)) {
65
+ continue;
66
+ }
67
+ const dx = point[0] - candidate.origin[0];
68
+ const dy = point[1] - candidate.origin[1];
69
+ const dz = point[2] - candidate.origin[2];
70
+ const distSq = dx * dx + dy * dy + dz * dz;
71
+ const radius = candidate.region.snapRadius;
72
+ if (distSq <= radius * radius && distSq < bestDistSq) {
73
+ best = candidate;
74
+ bestDistSq = distSq;
75
+ }
76
+ }
77
+ return best;
78
+ }
79
+ //# sourceMappingURL=region-layout.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"region-layout.js","sourceRoot":"","sources":["../../src/core/region-layout.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Layout regions - pure slot math for docking windows into named regions.\n *\n * A `DockRegion` is an anchor in space (an entity transform) that lays its\n * docked windows out in a row, column or grid of fixed-pitch slots. The math\n * here is deliberately simple and deterministic: given a slot index and a\n * region definition, produce a local-space offset; given a drop point and the\n * regions in the scene, decide which region (if any) captured the drop.\n */\n\nexport type Vec3 = readonly [number, number, number];\n\nexport type RegionFlow = 'row' | 'column' | 'grid';\n\nexport interface RegionDefinition {\n /** Layout direction. `row` = +X, `column` = -Y, `grid` = rows of `columns`. */\n flow: RegionFlow;\n /** Distance between slot origins, in meters. */\n pitch: number;\n /** Number of columns per row; only meaningful for `grid`. */\n columns: number;\n /** Maximum windows the region accepts (0 = unlimited). */\n capacity: number;\n /** Drop-capture radius around the region origin, in meters. */\n snapRadius: number;\n}\n\nexport const DEFAULT_REGION: RegionDefinition = {\n flow: 'column',\n pitch: 0.35,\n columns: 2,\n capacity: 0,\n snapRadius: 0.5,\n};\n\nexport function normalizeRegion(partial: Partial<RegionDefinition>): RegionDefinition {\n const region = { ...DEFAULT_REGION, ...partial };\n if (!(region.pitch > 0)) {\n throw new Error(`[uix] region pitch must be > 0 (got ${region.pitch})`);\n }\n if (!(region.columns >= 1)) {\n throw new Error(`[uix] region columns must be >= 1 (got ${region.columns})`);\n }\n if (region.capacity < 0) {\n throw new Error(`[uix] region capacity must be >= 0 (got ${region.capacity})`);\n }\n if (!(region.snapRadius > 0)) {\n throw new Error(`[uix] region snapRadius must be > 0 (got ${region.snapRadius})`);\n }\n return region;\n}\n\n/** Local-space offset of slot `index` within a region. */\nexport function slotOffset(region: RegionDefinition, index: number): Vec3 {\n if (index < 0 || !Number.isInteger(index)) {\n throw new Error(`[uix] slot index must be a non-negative integer (got ${index})`);\n }\n // `+ 0` folds the -0 that `-0 * pitch` would produce into plain 0.\n switch (region.flow) {\n case 'row':\n return [index * region.pitch, 0, 0];\n case 'column':\n return [0, -index * region.pitch + 0, 0];\n case 'grid': {\n const col = index % region.columns;\n const row = Math.floor(index / region.columns);\n return [col * region.pitch, -row * region.pitch + 0, 0];\n }\n }\n}\n\nexport function hasCapacity(region: RegionDefinition, occupancy: number): boolean {\n return region.capacity === 0 || occupancy < region.capacity;\n}\n\nexport interface RegionCandidate<TId> {\n id: TId;\n origin: Vec3;\n region: RegionDefinition;\n occupancy: number;\n}\n\n/**\n * Which region captures a window dropped at `point`? The nearest region whose\n * `snapRadius` contains the point and which still has capacity; `undefined`\n * when the drop lands in open space (the window stays world-locked where it\n * was released).\n */\nexport function captureDrop<TId>(\n point: Vec3,\n candidates: readonly RegionCandidate<TId>[],\n): RegionCandidate<TId> | undefined {\n let best: RegionCandidate<TId> | undefined;\n let bestDistSq = Infinity;\n for (const candidate of candidates) {\n if (!hasCapacity(candidate.region, candidate.occupancy)) {\n continue;\n }\n const dx = point[0] - candidate.origin[0];\n const dy = point[1] - candidate.origin[1];\n const dz = point[2] - candidate.origin[2];\n const distSq = dx * dx + dy * dy + dz * dz;\n const radius = candidate.region.snapRadius;\n if (distSq <= radius * radius && distSq < bestDistSq) {\n best = candidate;\n bestDistSq = distSq;\n }\n }\n return best;\n}\n"]}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * RegionRegistry - pure bookkeeping of dock regions and their members.
3
+ *
4
+ * The ECS layer feeds it region definitions (from `UIDockRegion` components)
5
+ * and world-space origins at drop time; it answers slot assignments and drop
6
+ * capture. Window/region ids are opaque strings.
7
+ */
8
+ import { RegionDefinition, Vec3 } from './region-layout.js';
9
+ export interface RegisteredRegion {
10
+ id: string;
11
+ definition: RegionDefinition;
12
+ /** Docked window ids in slot order. */
13
+ members: string[];
14
+ }
15
+ export declare class RegionRegistry {
16
+ private regions;
17
+ private regionByWindow;
18
+ register(id: string, definition?: Partial<RegionDefinition>): RegisteredRegion;
19
+ unregister(id: string): void;
20
+ get(id: string): RegisteredRegion | undefined;
21
+ /** Dock a window; returns its slot index. */
22
+ dock(windowId: string, regionId: string): number;
23
+ /** Remove a window from whichever region holds it. Later members shift up. */
24
+ undock(windowId: string): string | undefined;
25
+ regionOf(windowId: string): string | undefined;
26
+ /** Local-space offset for a docked window, or undefined when not docked. */
27
+ offsetOf(windowId: string): Vec3 | undefined;
28
+ /**
29
+ * Which region captures a drop at `point`, given each region's current
30
+ * world-space origin? Regions missing from `origins` are not considered
31
+ * (e.g. their entity is gone this frame).
32
+ */
33
+ capture(point: Vec3, origins: ReadonlyMap<string, Vec3>): string | undefined;
34
+ list(): RegisteredRegion[];
35
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * RegionRegistry - pure bookkeeping of dock regions and their members.
3
+ *
4
+ * The ECS layer feeds it region definitions (from `UIDockRegion` components)
5
+ * and world-space origins at drop time; it answers slot assignments and drop
6
+ * capture. Window/region ids are opaque strings.
7
+ */
8
+ import { captureDrop, hasCapacity, normalizeRegion, slotOffset, } from './region-layout.js';
9
+ export class RegionRegistry {
10
+ regions = new Map();
11
+ regionByWindow = new Map();
12
+ register(id, definition = {}) {
13
+ if (this.regions.has(id)) {
14
+ throw new Error(`[uix] region "${id}" is already registered`);
15
+ }
16
+ const region = {
17
+ id,
18
+ definition: normalizeRegion(definition),
19
+ members: [],
20
+ };
21
+ this.regions.set(id, region);
22
+ return region;
23
+ }
24
+ unregister(id) {
25
+ const region = this.regions.get(id);
26
+ if (!region) {
27
+ return;
28
+ }
29
+ for (const windowId of region.members) {
30
+ this.regionByWindow.delete(windowId);
31
+ }
32
+ this.regions.delete(id);
33
+ }
34
+ get(id) {
35
+ return this.regions.get(id);
36
+ }
37
+ /** Dock a window; returns its slot index. */
38
+ dock(windowId, regionId) {
39
+ const region = this.regions.get(regionId);
40
+ if (!region) {
41
+ throw new Error(`[uix] unknown region "${regionId}"`);
42
+ }
43
+ if (!hasCapacity(region.definition, region.members.length)) {
44
+ throw new Error(`[uix] region "${regionId}" is full`);
45
+ }
46
+ this.undock(windowId);
47
+ region.members.push(windowId);
48
+ this.regionByWindow.set(windowId, regionId);
49
+ return region.members.length - 1;
50
+ }
51
+ /** Remove a window from whichever region holds it. Later members shift up. */
52
+ undock(windowId) {
53
+ const regionId = this.regionByWindow.get(windowId);
54
+ if (regionId === undefined) {
55
+ return undefined;
56
+ }
57
+ this.regionByWindow.delete(windowId);
58
+ // `unregister` scrubs regionByWindow, so a mapped id always has a region.
59
+ const region = this.regions.get(regionId);
60
+ region.members = region.members.filter((member) => member !== windowId);
61
+ return regionId;
62
+ }
63
+ regionOf(windowId) {
64
+ return this.regionByWindow.get(windowId);
65
+ }
66
+ /** Local-space offset for a docked window, or undefined when not docked. */
67
+ offsetOf(windowId) {
68
+ const regionId = this.regionByWindow.get(windowId);
69
+ if (regionId === undefined) {
70
+ return undefined;
71
+ }
72
+ // A mapped window always has a live region with it as a member (dock/
73
+ // undock/unregister keep both maps in step).
74
+ const region = this.regions.get(regionId);
75
+ return slotOffset(region.definition, region.members.indexOf(windowId));
76
+ }
77
+ /**
78
+ * Which region captures a drop at `point`, given each region's current
79
+ * world-space origin? Regions missing from `origins` are not considered
80
+ * (e.g. their entity is gone this frame).
81
+ */
82
+ capture(point, origins) {
83
+ const candidates = [...this.regions.values()]
84
+ .filter((region) => origins.has(region.id))
85
+ .map((region) => ({
86
+ id: region.id,
87
+ origin: origins.get(region.id),
88
+ region: region.definition,
89
+ occupancy: region.members.length,
90
+ }));
91
+ return captureDrop(point, candidates)?.id;
92
+ }
93
+ list() {
94
+ return [...this.regions.values()];
95
+ }
96
+ }
97
+ //# sourceMappingURL=region-registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"region-registry.js","sourceRoot":"","sources":["../../src/core/region-registry.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * RegionRegistry - pure bookkeeping of dock regions and their members.\n *\n * The ECS layer feeds it region definitions (from `UIDockRegion` components)\n * and world-space origins at drop time; it answers slot assignments and drop\n * capture. Window/region ids are opaque strings.\n */\nimport {\n RegionDefinition,\n Vec3,\n captureDrop,\n hasCapacity,\n normalizeRegion,\n slotOffset,\n} from './region-layout.js';\n\nexport interface RegisteredRegion {\n id: string;\n definition: RegionDefinition;\n /** Docked window ids in slot order. */\n members: string[];\n}\n\nexport class RegionRegistry {\n private regions = new Map<string, RegisteredRegion>();\n private regionByWindow = new Map<string, string>();\n\n register(id: string, definition: Partial<RegionDefinition> = {}): RegisteredRegion {\n if (this.regions.has(id)) {\n throw new Error(`[uix] region \"${id}\" is already registered`);\n }\n const region: RegisteredRegion = {\n id,\n definition: normalizeRegion(definition),\n members: [],\n };\n this.regions.set(id, region);\n return region;\n }\n\n unregister(id: string): void {\n const region = this.regions.get(id);\n if (!region) {\n return;\n }\n for (const windowId of region.members) {\n this.regionByWindow.delete(windowId);\n }\n this.regions.delete(id);\n }\n\n get(id: string): RegisteredRegion | undefined {\n return this.regions.get(id);\n }\n\n /** Dock a window; returns its slot index. */\n dock(windowId: string, regionId: string): number {\n const region = this.regions.get(regionId);\n if (!region) {\n throw new Error(`[uix] unknown region \"${regionId}\"`);\n }\n if (!hasCapacity(region.definition, region.members.length)) {\n throw new Error(`[uix] region \"${regionId}\" is full`);\n }\n this.undock(windowId);\n region.members.push(windowId);\n this.regionByWindow.set(windowId, regionId);\n return region.members.length - 1;\n }\n\n /** Remove a window from whichever region holds it. Later members shift up. */\n undock(windowId: string): string | undefined {\n const regionId = this.regionByWindow.get(windowId);\n if (regionId === undefined) {\n return undefined;\n }\n this.regionByWindow.delete(windowId);\n // `unregister` scrubs regionByWindow, so a mapped id always has a region.\n const region = this.regions.get(regionId) as RegisteredRegion;\n region.members = region.members.filter((member) => member !== windowId);\n return regionId;\n }\n\n regionOf(windowId: string): string | undefined {\n return this.regionByWindow.get(windowId);\n }\n\n /** Local-space offset for a docked window, or undefined when not docked. */\n offsetOf(windowId: string): Vec3 | undefined {\n const regionId = this.regionByWindow.get(windowId);\n if (regionId === undefined) {\n return undefined;\n }\n // A mapped window always has a live region with it as a member (dock/\n // undock/unregister keep both maps in step).\n const region = this.regions.get(regionId) as RegisteredRegion;\n return slotOffset(region.definition, region.members.indexOf(windowId));\n }\n\n /**\n * Which region captures a drop at `point`, given each region's current\n * world-space origin? Regions missing from `origins` are not considered\n * (e.g. their entity is gone this frame).\n */\n capture(point: Vec3, origins: ReadonlyMap<string, Vec3>): string | undefined {\n const candidates = [...this.regions.values()]\n .filter((region) => origins.has(region.id))\n .map((region) => ({\n id: region.id,\n origin: origins.get(region.id) as Vec3,\n region: region.definition,\n occupancy: region.members.length,\n }));\n return captureDrop(point, candidates)?.id;\n }\n\n list(): RegisteredRegion[] {\n return [...this.regions.values()];\n }\n}\n"]}