@react-x11/components 0.3.0 → 0.4.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 (107) hide show
  1. package/README.md +6 -0
  2. package/dist/index.d.ts +4 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +5 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/qml/containers.d.ts +3 -0
  7. package/dist/qml/containers.d.ts.map +1 -0
  8. package/dist/qml/containers.js +331 -0
  9. package/dist/qml/containers.js.map +1 -0
  10. package/dist/qml/controls.d.ts +12 -0
  11. package/dist/qml/controls.d.ts.map +1 -0
  12. package/dist/qml/controls.js +21 -0
  13. package/dist/qml/controls.js.map +1 -0
  14. package/dist/qml/globals.d.ts +6 -0
  15. package/dist/qml/globals.d.ts.map +1 -0
  16. package/dist/qml/globals.js +25 -0
  17. package/dist/qml/globals.js.map +1 -0
  18. package/dist/qml/index.d.ts +15 -0
  19. package/dist/qml/index.d.ts.map +1 -0
  20. package/dist/qml/index.js +25 -0
  21. package/dist/qml/index.js.map +1 -0
  22. package/dist/qml/interaction.d.ts +12 -0
  23. package/dist/qml/interaction.d.ts.map +1 -0
  24. package/dist/qml/interaction.js +246 -0
  25. package/dist/qml/interaction.js.map +1 -0
  26. package/dist/qml/ir.d.ts +106 -0
  27. package/dist/qml/ir.d.ts.map +1 -0
  28. package/dist/qml/ir.js +7 -0
  29. package/dist/qml/ir.js.map +1 -0
  30. package/dist/qml/layouts.d.ts +3 -0
  31. package/dist/qml/layouts.d.ts.map +1 -0
  32. package/dist/qml/layouts.js +122 -0
  33. package/dist/qml/layouts.js.map +1 -0
  34. package/dist/qml/models.d.ts +14 -0
  35. package/dist/qml/models.d.ts.map +1 -0
  36. package/dist/qml/models.js +80 -0
  37. package/dist/qml/models.js.map +1 -0
  38. package/dist/qml/objects.d.ts +228 -0
  39. package/dist/qml/objects.d.ts.map +1 -0
  40. package/dist/qml/objects.js +1103 -0
  41. package/dist/qml/objects.js.map +1 -0
  42. package/dist/qml/parse.d.ts +10 -0
  43. package/dist/qml/parse.d.ts.map +1 -0
  44. package/dist/qml/parse.js +607 -0
  45. package/dist/qml/parse.js.map +1 -0
  46. package/dist/qml/qtquick.d.ts +7 -0
  47. package/dist/qml/qtquick.d.ts.map +1 -0
  48. package/dist/qml/qtquick.js +347 -0
  49. package/dist/qml/qtquick.js.map +1 -0
  50. package/dist/qml/react.d.ts +83 -0
  51. package/dist/qml/react.d.ts.map +1 -0
  52. package/dist/qml/react.js +323 -0
  53. package/dist/qml/react.js.map +1 -0
  54. package/dist/qml/resolver.d.ts +38 -0
  55. package/dist/qml/resolver.d.ts.map +1 -0
  56. package/dist/qml/resolver.js +64 -0
  57. package/dist/qml/resolver.js.map +1 -0
  58. package/dist/qml/slots.d.ts +65 -0
  59. package/dist/qml/slots.d.ts.map +1 -0
  60. package/dist/qml/slots.js +230 -0
  61. package/dist/qml/slots.js.map +1 -0
  62. package/dist/qml/states.d.ts +5 -0
  63. package/dist/qml/states.d.ts.map +1 -0
  64. package/dist/qml/states.js +276 -0
  65. package/dist/qml/states.js.map +1 -0
  66. package/dist/qml/view-utils.d.ts +42 -0
  67. package/dist/qml/view-utils.d.ts.map +1 -0
  68. package/dist/qml/view-utils.js +121 -0
  69. package/dist/qml/view-utils.js.map +1 -0
  70. package/dist/tabs/hx.d.ts +18 -0
  71. package/dist/tabs/hx.d.ts.map +1 -0
  72. package/dist/tabs/hx.js +10 -0
  73. package/dist/tabs/hx.js.map +1 -0
  74. package/dist/tabs/index.d.ts +143 -0
  75. package/dist/tabs/index.d.ts.map +1 -0
  76. package/dist/tabs/index.js +602 -0
  77. package/dist/tabs/index.js.map +1 -0
  78. package/dist/terminal/vt/index.d.ts +1 -1
  79. package/dist/terminal/vt/index.d.ts.map +1 -1
  80. package/dist/terminal/vt/index.js +3 -3
  81. package/dist/terminal/vt/index.js.map +1 -1
  82. package/dist/terminal/vt/pty.d.ts +16 -0
  83. package/dist/terminal/vt/pty.d.ts.map +1 -1
  84. package/dist/terminal/vt/pty.js +186 -0
  85. package/dist/terminal/vt/pty.js.map +1 -1
  86. package/package.json +11 -1
  87. package/src/index.ts +44 -0
  88. package/src/qml/containers.tsx +380 -0
  89. package/src/qml/controls.tsx +47 -0
  90. package/src/qml/globals.ts +33 -0
  91. package/src/qml/index.ts +67 -0
  92. package/src/qml/interaction.tsx +317 -0
  93. package/src/qml/ir.ts +80 -0
  94. package/src/qml/layouts.tsx +141 -0
  95. package/src/qml/models.ts +101 -0
  96. package/src/qml/objects.ts +1431 -0
  97. package/src/qml/parse.ts +624 -0
  98. package/src/qml/qtquick.tsx +413 -0
  99. package/src/qml/react.tsx +454 -0
  100. package/src/qml/resolver.ts +99 -0
  101. package/src/qml/slots.ts +249 -0
  102. package/src/qml/states.ts +318 -0
  103. package/src/qml/view-utils.ts +140 -0
  104. package/src/tabs/hx.ts +31 -0
  105. package/src/tabs/index.ts +850 -0
  106. package/src/terminal/vt/index.ts +4 -2
  107. package/src/terminal/vt/pty.ts +243 -0
@@ -0,0 +1,1431 @@
1
+ // The QML object model: documents → live instances wired into the slot
2
+ // graph.
3
+ //
4
+ // Scope rules follow Qt's documented order (ids outrank the scope object's
5
+ // own properties; the component root is consulted before context
6
+ // properties; unresolved names fall through `with` to the JS globals, which
7
+ // is how Math, JSON and console work without us lifting a finger).
8
+ //
9
+ // Expressions compile once per document via `new Function` and are shared
10
+ // by every instance (a Repeater with 1000 delegates compiles each binding
11
+ // once).
12
+ //
13
+ // This module is deliberately renderer-free: no react-x11, and React only
14
+ // as a type (the `view` field a registered type may carry). It is the half
15
+ // that a future compiler shares with the interpreter.
16
+
17
+ import type { ComponentType } from 'react';
18
+ import type {
19
+ AliasTargetIR,
20
+ MemberIR,
21
+ ObjectIR,
22
+ QmlDocument,
23
+ ValueIR,
24
+ } from './ir.js';
25
+ import type { DocLoad, QmlResolver } from './resolver.js';
26
+ import { parseQml } from './parse.js';
27
+ import { Slot, flushBindings, type BindingFn } from './slots.js';
28
+ import { warn } from './globals.js';
29
+
30
+ /**
31
+ * A captured object template (`delegate:`, `sourceComponent:`, a `states:`
32
+ * entry) together with where it was written — its document (for compiling
33
+ * and imports) and its context (what its instances' names resolve
34
+ * through). A delegate handed to a file-backed ListView from the use site
35
+ * must keep resolving use-site names; carrying the pair is what makes
36
+ * that true.
37
+ */
38
+ export interface TemplateRef {
39
+ object: ObjectIR;
40
+ doc: QmlDocument;
41
+ context: Context;
42
+ }
43
+
44
+ /** The use-site half of a file-backed component instance: `MyBackdrop
45
+ * { width: 5; … }` — members evaluated in the *site's* scope, applied on
46
+ * the component document's root instance. */
47
+ interface SiteRef {
48
+ ir: ObjectIR;
49
+ doc: QmlDocument;
50
+ ctx: Context;
51
+ }
52
+
53
+ /** What one member-application pass evaluates against. A plain instance
54
+ * has one env (its own document and context); a file-backed instance has
55
+ * two — the component document's for its internals, the use site's for
56
+ * the site members. */
57
+ interface EvalEnv {
58
+ doc: QmlDocument;
59
+ ctx: Context;
60
+ scope: object;
61
+ facade: QmlFacade;
62
+ }
63
+
64
+ /**
65
+ * How expressions see an object: live property access, callable signals
66
+ * and methods. Deliberately `any`-valued — a facade is QML's dynamic edge,
67
+ * and `view.id('meter').width = 40` reading naturally is the point.
68
+ */
69
+ // eslint does not see TypeScript here (AGENTS.md, "Linting"); the explicit
70
+ // `any` is a deliberate choice for this one boundary type.
71
+ export type QmlFacade = Record<string, any>;
72
+
73
+ export interface QmlPropertyDef {
74
+ default?: unknown;
75
+ }
76
+
77
+ export interface ValueSourceHookup {
78
+ targetSlot: Slot;
79
+ target: QmlInstance;
80
+ }
81
+
82
+ /** What `registerQmlModule` takes, per type. */
83
+ export interface QmlTypeDef {
84
+ extends?: string;
85
+ nonVisual?: boolean;
86
+ /** Object children become templates under this key (Repeater's
87
+ * `delegate`, Loader's `sourceComponent`) instead of instantiating. */
88
+ capture?: string;
89
+ /** Bindings to undeclared names create slots (`PropertyChanges`,
90
+ * `ListElement`) instead of failing. */
91
+ dynamicProperties?: boolean;
92
+ /** `on<Signal>` members are collected, not attached — the type wires
93
+ * them itself (`Connections`). */
94
+ deferHandlers?: boolean;
95
+ /** RowLayout/ColumnLayout: children render as flex items (yoga decides
96
+ * geometry, read back into their slots) instead of absolutely. */
97
+ managesChildLayout?: boolean;
98
+ properties?: Record<string, QmlPropertyDef>;
99
+ signals?: Record<string, string[]>;
100
+ enums?: Record<string, number>;
101
+ init?: (inst: QmlInstance, hookup?: ValueSourceHookup) => void;
102
+ dispose?: (inst: QmlInstance) => void;
103
+ /** The instance's effective children changed (Repeater splice). */
104
+ onStructure?: (inst: QmlInstance) => void;
105
+ view?: ComponentType<{ inst: QmlInstance }>;
106
+ }
107
+
108
+ export interface QmlTypeInfo {
109
+ name: string;
110
+ base: QmlTypeInfo | null;
111
+ nonVisual: boolean;
112
+ capture: string | null;
113
+ dynamicProperties: boolean;
114
+ deferHandlers: boolean;
115
+ managesChildLayout: boolean;
116
+ properties: Record<string, QmlPropertyDef>;
117
+ signals: Record<string, string[]>;
118
+ enums: Record<string, number>;
119
+ init: ((inst: QmlInstance, hookup?: ValueSourceHookup) => void) | null;
120
+ dispose: ((inst: QmlInstance) => void) | null;
121
+ onStructure: ((inst: QmlInstance) => void) | null;
122
+ view: ComponentType<{ inst: QmlInstance }> | null;
123
+ }
124
+
125
+ // --- type registry ---------------------------------------------------------
126
+
127
+ const modules = new Map<string, { types: Map<string, QmlTypeInfo> }>();
128
+
129
+ export function registerQmlModule(
130
+ name: string,
131
+ { types }: { version?: string; types: Record<string, QmlTypeDef> },
132
+ ): void {
133
+ const mod = modules.get(name) ?? { types: new Map<string, QmlTypeInfo>() };
134
+ modules.set(name, mod); // visible before resolution: types extend siblings
135
+ for (const [typeName, def] of Object.entries(types)) {
136
+ mod.types.set(typeName, resolveTypeInfo(typeName, def));
137
+ }
138
+ }
139
+
140
+ function resolveTypeInfo(name: string, def: QmlTypeDef): QmlTypeInfo {
141
+ let base: QmlTypeInfo | null = null;
142
+ if (def.extends) {
143
+ for (const mod of modules.values()) {
144
+ const hit = mod.types.get(def.extends);
145
+ if (hit) base = hit;
146
+ }
147
+ if (!base)
148
+ throw new Error(`QML type ${name} extends unknown ${def.extends}`);
149
+ }
150
+ return {
151
+ name,
152
+ base,
153
+ properties: { ...base?.properties, ...def.properties },
154
+ signals: { ...base?.signals, ...def.signals },
155
+ enums: { ...base?.enums, ...def.enums },
156
+ nonVisual: def.nonVisual ?? base?.nonVisual ?? false,
157
+ capture: def.capture ?? base?.capture ?? null,
158
+ dynamicProperties:
159
+ def.dynamicProperties ?? base?.dynamicProperties ?? false,
160
+ deferHandlers: def.deferHandlers ?? base?.deferHandlers ?? false,
161
+ managesChildLayout:
162
+ def.managesChildLayout ?? base?.managesChildLayout ?? false,
163
+ init: def.init ?? base?.init ?? null,
164
+ dispose: def.dispose ?? base?.dispose ?? null,
165
+ onStructure: def.onStructure ?? base?.onStructure ?? null,
166
+ view: def.view ?? base?.view ?? null,
167
+ };
168
+ }
169
+
170
+ export function lookupType(doc: QmlDocument, typeName: string): QmlTypeInfo {
171
+ // `alias.Type` from `import … as alias`
172
+ let name = typeName;
173
+ let onlyModule: string | null = null;
174
+ const dot = typeName.indexOf('.');
175
+ if (dot !== -1) {
176
+ const alias = typeName.slice(0, dot);
177
+ const imp = doc.imports.find((i) => i.alias === alias);
178
+ if (imp?.module) {
179
+ onlyModule = imp.module;
180
+ name = typeName.slice(dot + 1);
181
+ }
182
+ }
183
+ for (const imp of doc.imports) {
184
+ if (!imp.module) continue;
185
+ if (onlyModule && imp.module !== onlyModule) continue;
186
+ const info = modules.get(imp.module)?.types.get(name);
187
+ if (info) return info;
188
+ }
189
+ const known = [...modules.keys()].join(', ');
190
+ const dirNote = doc.load
191
+ ? ` No <${typeName}>.qml was found beside the document or under its ` +
192
+ `quoted imports either.`
193
+ : ` Pass a resolver (createFileResolver) to also load <Name>.qml files.`;
194
+ throw new Error(
195
+ `Unknown QML type '${typeName}' in ${doc.fileName}. Imported modules ` +
196
+ `were searched; registered modules: ${known}. Register your own with ` +
197
+ `registerQmlModule(module, { types }).` +
198
+ dirNote,
199
+ );
200
+ }
201
+
202
+ // --- file-backed components (the resolver seam) ----------------------------
203
+
204
+ /** A type name that resolved to a `.qml` document rather than a registry
205
+ * entry — instantiated by composition in createTree. */
206
+ interface FileComponent {
207
+ fileDoc: QmlDocument;
208
+ }
209
+
210
+ interface FileCacheEntry {
211
+ doc: QmlDocument | null; // null = negative: no such component here
212
+ source: string | null;
213
+ gen: number;
214
+ }
215
+
216
+ // Cached per (dir, name). Entries revalidate once per root instantiation
217
+ // (the generation bump below): a 1000-delegate ListView reads its component
218
+ // file once, and a hot reload of the root re-reads sources so edited
219
+ // siblings are picked up.
220
+ const fileCache = new Map<string, FileCacheEntry>();
221
+ let loadGeneration = 0;
222
+
223
+ function loadFileComponent(
224
+ load: DocLoad,
225
+ dir: string,
226
+ name: string,
227
+ ): QmlDocument | null {
228
+ const key = `${dir}\u0000${name}`;
229
+ const cached = fileCache.get(key);
230
+ if (cached && cached.gen === loadGeneration) return cached.doc;
231
+ const hit = load.resolver.load(dir, name);
232
+ if (!hit) {
233
+ fileCache.set(key, { doc: null, source: null, gen: loadGeneration });
234
+ return null;
235
+ }
236
+ if (cached && cached.doc && cached.source === hit.source) {
237
+ cached.gen = loadGeneration; // unchanged: keep the parsed document
238
+ return cached.doc;
239
+ }
240
+ const compDoc = parseQml(hit.source, { fileName: hit.fileName });
241
+ compDoc.load = { resolver: load.resolver, dir };
242
+ fileCache.set(key, { doc: compDoc, source: hit.source, gen: loadGeneration });
243
+ return compDoc;
244
+ }
245
+
246
+ /** Qt's precedence: the implicit same-directory import first (a local
247
+ * `Button.qml` shadows a module's `Button`), then quoted-path imports,
248
+ * then registered modules. */
249
+ function resolveTypeRef(
250
+ doc: QmlDocument,
251
+ typeName: string,
252
+ ): QmlTypeInfo | FileComponent {
253
+ const load = doc.load;
254
+ if (load && !typeName.includes('.')) {
255
+ const implicit = loadFileComponent(load, load.dir, typeName);
256
+ if (implicit) return { fileDoc: implicit };
257
+ for (const imp of doc.imports) {
258
+ if (!imp.path) continue;
259
+ const dir = load.resolver.join(load.dir, imp.path);
260
+ const fromPath = loadFileComponent(load, dir, typeName);
261
+ if (fromPath) return { fileDoc: fromPath };
262
+ }
263
+ }
264
+ return lookupType(doc, typeName);
265
+ }
266
+
267
+ /** `Loader { source: "widgets/Meter.qml" }` — a relative path resolved
268
+ * against the document that names it. Null without a resolver or when the
269
+ * file is absent. */
270
+ export function resolveComponentByPath(
271
+ fromDoc: QmlDocument,
272
+ path: string,
273
+ ): QmlDocument | null {
274
+ const load = fromDoc.load;
275
+ if (!load || !/\.qml$/i.test(path)) return null;
276
+ const parts = path.replace(/\.qml$/i, '').split('/');
277
+ const name = parts.pop();
278
+ if (!name) return null;
279
+ const dir = parts.length
280
+ ? load.resolver.join(load.dir, parts.join('/'))
281
+ : load.dir;
282
+ return loadFileComponent(load, dir, name);
283
+ }
284
+
285
+ // Instantiation-time cycle guard: A.qml using B.qml using A.qml would
286
+ // recurse forever in createTree; the parse-level cache cannot see it
287
+ // because each document parses fine on its own.
288
+ const instantiating: QmlDocument[] = [];
289
+
290
+ function topContextOf(ctx: Context): Context {
291
+ let top = ctx;
292
+ while (top.parent) top = top.parent;
293
+ return top;
294
+ }
295
+
296
+ // --- expression compilation ------------------------------------------------
297
+
298
+ type CompiledExpr = (this: QmlFacade, scope: object) => unknown;
299
+ type HandlerFactory = (scope: object) => (...args: unknown[]) => unknown;
300
+
301
+ const compiledCache = new WeakMap<object, unknown>();
302
+
303
+ function compileExpr(
304
+ node: Extract<ValueIR, { kind: 'expr' }>,
305
+ doc: QmlDocument,
306
+ ): CompiledExpr {
307
+ let fn = compiledCache.get(node) as CompiledExpr | undefined;
308
+ if (!fn) {
309
+ try {
310
+ fn = new Function(
311
+ '$scope',
312
+ `with($scope){ return (${node.src}\n) }`,
313
+ ) as CompiledExpr;
314
+ } catch (e) {
315
+ throw new Error(
316
+ `QML: bad expression in ${doc.fileName}: \`${node.src}\` (${message(e)})`,
317
+ );
318
+ }
319
+ compiledCache.set(node, fn);
320
+ }
321
+ return fn;
322
+ }
323
+
324
+ function compileHandler(
325
+ node: { kind: string; src: string },
326
+ params: string[],
327
+ doc: QmlDocument,
328
+ ): HandlerFactory {
329
+ let fn = compiledCache.get(node) as HandlerFactory | undefined;
330
+ if (!fn) {
331
+ const body = node.kind === 'block' ? node.src : `return (${node.src}\n)`;
332
+ try {
333
+ fn = new Function(
334
+ '$scope',
335
+ `with($scope){ return function(${params.join(', ')}){ ${body} \n} }`,
336
+ ) as HandlerFactory;
337
+ } catch (e) {
338
+ throw new Error(
339
+ `QML: bad handler in ${doc.fileName}: \`${node.src}\` (${message(e)})`,
340
+ );
341
+ }
342
+ compiledCache.set(node, fn);
343
+ }
344
+ return fn;
345
+ }
346
+
347
+ const message = (e: unknown): string =>
348
+ e instanceof Error ? e.message : String(e);
349
+
350
+ // --- contexts --------------------------------------------------------------
351
+
352
+ export class Context {
353
+ parent: Context | null;
354
+ doc: QmlDocument;
355
+ ids = new Map<string, QmlInstance>();
356
+ extras = new Map<string, Slot>(); // context properties
357
+ root: QmlInstance | null = null;
358
+
359
+ constructor(parent: Context | null, doc: QmlDocument) {
360
+ this.parent = parent;
361
+ this.doc = doc;
362
+ }
363
+
364
+ lookupId(name: string): QmlInstance | null {
365
+ for (let c: Context | null = this; c; c = c.parent) {
366
+ const inst = c.ids.get(name);
367
+ if (inst) return inst;
368
+ }
369
+ return null;
370
+ }
371
+
372
+ lookupExtra(name: string): Slot | null {
373
+ for (let c: Context | null = this; c; c = c.parent) {
374
+ const slot = c.extras.get(name);
375
+ if (slot) return slot;
376
+ }
377
+ return null;
378
+ }
379
+ }
380
+
381
+ const QtBindingMark = Symbol('Qt.binding');
382
+
383
+ export interface QtNamespace {
384
+ [key: string]: unknown;
385
+ rgba(r: number, g: number, b: number, a?: number): string;
386
+ hsla(h: number, s: number, l: number, a?: number): string;
387
+ point(x: number, y: number): { x: number; y: number };
388
+ size(width: number, height: number): { width: number; height: number };
389
+ rect(
390
+ x: number,
391
+ y: number,
392
+ width: number,
393
+ height: number,
394
+ ): { x: number; y: number; width: number; height: number };
395
+ binding(fn: () => unknown): unknown;
396
+ qsTr(s: string): string;
397
+ }
398
+
399
+ export const Qt: QtNamespace = {
400
+ rgba: (r, g, b, a = 1) =>
401
+ `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${a})`,
402
+ hsla: (h, s, l, a = 1) =>
403
+ `hsla(${Math.round(h * 360)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%, ${a})`,
404
+ point: (x, y) => ({ x, y }),
405
+ size: (width, height) => ({ width, height }),
406
+ rect: (x, y, width, height) => ({ x, y, width, height }),
407
+ binding: (fn) => ({ [QtBindingMark]: fn }),
408
+ qsTr: (s) => s,
409
+ };
410
+
411
+ // Parameterized by (ctx, doc) rather than reading the instance's own: a
412
+ // file-backed instance evaluates its use-site members in the *site's*
413
+ // scope — same object, different name resolution.
414
+ function makeScopeProxy(
415
+ inst: QmlInstance,
416
+ ctx: Context,
417
+ doc: QmlDocument,
418
+ ): object {
419
+ const scopeHas = (name: string): boolean => {
420
+ if (name === 'Qt' || name === 'parent' || name === 'this') return true;
421
+ if (ctx.lookupId(name)) return true;
422
+ if (inst.resolveMember(name) !== undefined) return true;
423
+ const root = ctx.root;
424
+ if (root && root !== inst && root.resolveMember(name) !== undefined)
425
+ return true;
426
+ if (ctx.lookupExtra(name)) return true;
427
+ if (enumHolder(doc, name)) return true;
428
+ return false; // fall through `with` to the real JS globals
429
+ };
430
+ return new Proxy(Object.create(null) as object, {
431
+ has: (_, name) => (typeof name === 'string' ? scopeHas(name) : false),
432
+ get: (_, name) => {
433
+ if (typeof name !== 'string') return undefined;
434
+ if (name === 'Qt') return Qt;
435
+ if (name === 'parent') return inst.parentInst?.facade ?? null;
436
+ const idHit = ctx.lookupId(name);
437
+ if (idHit) return idHit.facade;
438
+ let v = inst.getMember(name);
439
+ if (v !== undefined) return v;
440
+ const root = ctx.root;
441
+ if (root && root !== inst) {
442
+ v = root.getMember(name);
443
+ if (v !== undefined) return v;
444
+ }
445
+ const extra = ctx.lookupExtra(name);
446
+ if (extra) return extra.get();
447
+ const holder = enumHolder(doc, name);
448
+ if (holder) return holder;
449
+ return undefined;
450
+ },
451
+ set: (_, name, value) => {
452
+ if (typeof name !== 'string') return false;
453
+ // Assignment in handlers: prefer the object that owns the property.
454
+ if (inst.slots.has(name)) {
455
+ inst.setMember(name, value);
456
+ return true;
457
+ }
458
+ const root = ctx.root;
459
+ if (root && root !== inst && root.slots.has(name)) {
460
+ root.setMember(name, value);
461
+ return true;
462
+ }
463
+ throw new Error(`QML: cannot assign unknown name '${name}'`);
464
+ },
465
+ });
466
+ }
467
+
468
+ /** `Text.AlignHCenter` — uppercase names resolve to a type's enums.
469
+ * Registry-only on purpose: this runs for every capitalized name any
470
+ * expression mentions, and must never cost a resolver probe. */
471
+ function enumHolder(
472
+ doc: QmlDocument,
473
+ name: string,
474
+ ): Record<string, number> | null {
475
+ if (!/^[A-Z]/.test(name)) return null;
476
+ try {
477
+ const info = lookupType(doc, name);
478
+ return Object.keys(info.enums).length ? info.enums : null;
479
+ } catch {
480
+ return null;
481
+ }
482
+ }
483
+
484
+ // --- instances -------------------------------------------------------------
485
+
486
+ let nextInstanceId = 1;
487
+
488
+ export interface AnchorLine {
489
+ inst: QmlInstance;
490
+ edge: string;
491
+ }
492
+
493
+ export class QmlInstance {
494
+ readonly uid: number;
495
+ readonly typeInfo: QmlTypeInfo;
496
+ readonly doc: QmlDocument;
497
+ readonly context: Context;
498
+ parentInst: QmlInstance | null;
499
+ id: string | null = null;
500
+ ir: ObjectIR | null = null;
501
+ slots = new Map<string, Slot>();
502
+ methods = new Map<string, (...args: unknown[]) => unknown>();
503
+ signalParams = new Map<string, string[]>();
504
+ signalSubs = new Map<string, Set<(...args: unknown[]) => void>>();
505
+ /** Effective visual children — Repeater splices instances in here. */
506
+ children: QmlInstance[] = [];
507
+ /** Captured object templates (`delegate`, `sourceComponent`, …). */
508
+ templates = new Map<string, TemplateRef>();
509
+ /** For a file-backed component instance: the use-site members, applied
510
+ * in the site's own scope during activation. */
511
+ site: SiteRef | null = null;
512
+ valueSources: QmlInstance[] = [];
513
+ /** `Keys.onPressed` and friends: attached handlers by dotted name. */
514
+ attachedHandlers = new Map<string, (...args: unknown[]) => unknown>();
515
+ /** `Connections`: raw `onX` members, wired by the type's init. */
516
+ deferredHandlers: Array<{ name: string; value: ValueIR }> = [];
517
+ onDestruction: Array<() => void> = [];
518
+ anchorLines = new Map<string, AnchorLine>();
519
+ version = 0;
520
+ structVersion = 0;
521
+ listeners = new Set<() => void>();
522
+ destroyed = false;
523
+ /** Per-type scratch: timers, behaviors, repeater items, host node… */
524
+ state: Record<string, unknown> = {};
525
+ scope: object;
526
+ facade: QmlFacade;
527
+ _activated = false;
528
+
529
+ constructor(
530
+ typeInfo: QmlTypeInfo,
531
+ doc: QmlDocument,
532
+ context: Context,
533
+ parentInst: QmlInstance | null,
534
+ ) {
535
+ this.uid = nextInstanceId++;
536
+ this.typeInfo = typeInfo;
537
+ this.doc = doc;
538
+ this.context = context;
539
+ this.parentInst = parentInst;
540
+ this.scope = makeScopeProxy(this, context, doc);
541
+ this.facade = makeFacade(this);
542
+ for (const [name, decl] of Object.entries(typeInfo.properties)) {
543
+ this.addSlot(name, decl.default);
544
+ }
545
+ for (const [name, params] of Object.entries(typeInfo.signals)) {
546
+ this.signalParams.set(name, params);
547
+ }
548
+ }
549
+
550
+ addSlot(name: string, value: unknown): Slot {
551
+ const slot = new Slot(this, name, value);
552
+ this.slots.set(name, slot);
553
+ return slot;
554
+ }
555
+
556
+ slot(name: string): Slot {
557
+ const s = this.slots.get(name);
558
+ if (!s)
559
+ throw new Error(
560
+ `QML: ${this.typeInfo.name} has no property '${name}' (${this.doc.fileName})`,
561
+ );
562
+ return s;
563
+ }
564
+
565
+ /** undefined ⇒ not a member (so scope lookup continues up the chain). */
566
+ resolveMember(name: string): unknown {
567
+ if (this.slots.has(name)) return this.slots.get(name);
568
+ if (this.methods.has(name)) return this.methods.get(name);
569
+ if (this.signalParams.has(name)) return name;
570
+ return undefined;
571
+ }
572
+
573
+ getMember(name: string): unknown {
574
+ const slot = this.slots.get(name);
575
+ if (slot) {
576
+ const v = slot.get();
577
+ return v === undefined ? null : v; // undefined would break the walk
578
+ }
579
+ if (this.methods.has(name)) return this.methods.get(name);
580
+ if (this.signalParams.has(name)) {
581
+ return (...args: unknown[]) => this.emit(name, ...args);
582
+ }
583
+ return undefined;
584
+ }
585
+
586
+ setMember(name: string, value: unknown): void {
587
+ if (value && typeof value === 'object' && QtBindingMark in value) {
588
+ const fn = (value as Record<symbol, () => unknown>)[QtBindingMark];
589
+ this.slot(name).setBinding(() => fn.call(this.facade));
590
+ return;
591
+ }
592
+ this.slot(name).set(value);
593
+ }
594
+
595
+ emit(name: string, ...args: unknown[]): void {
596
+ const subs = this.signalSubs.get(name);
597
+ if (subs) for (const fn of [...subs]) fn(...args);
598
+ flushBindings();
599
+ }
600
+
601
+ onSignal(name: string, fn: (...args: unknown[]) => void): () => void {
602
+ if (!this.signalParams.has(name) && !this.slots.has(stripChanged(name)))
603
+ throw new Error(`QML: ${this.typeInfo.name} has no signal '${name}'`);
604
+ let set = this.signalSubs.get(name);
605
+ if (!set) this.signalSubs.set(name, (set = new Set()));
606
+ set.add(fn);
607
+ return () => set.delete(fn);
608
+ }
609
+
610
+ anchorLine(edge: string): AnchorLine {
611
+ let line = this.anchorLines.get(edge);
612
+ if (!line) this.anchorLines.set(edge, (line = { inst: this, edge }));
613
+ return line;
614
+ }
615
+
616
+ /** React bridge: any slot change bumps the version; one subscription
617
+ * per object. */
618
+ _changed(): void {
619
+ this.version++;
620
+ for (const l of [...this.listeners]) l();
621
+ }
622
+
623
+ _structChanged(): void {
624
+ this.structVersion++;
625
+ this.typeInfo.onStructure?.(this); // positioners re-pin children
626
+ this._changed();
627
+ }
628
+
629
+ subscribe(fn: () => void): () => void {
630
+ this.listeners.add(fn);
631
+ return () => this.listeners.delete(fn);
632
+ }
633
+
634
+ visualChildren(): QmlInstance[] {
635
+ return this.children.filter((c) => !c.typeInfo.nonVisual);
636
+ }
637
+
638
+ destroy(): void {
639
+ if (this.destroyed) return;
640
+ this.destroyed = true;
641
+ for (const fn of this.onDestruction) fn();
642
+ for (const child of this.children) child.destroy();
643
+ for (const src of this.valueSources) src.destroy();
644
+ this.typeInfo.dispose?.(this);
645
+ for (const slot of this.slots.values()) slot.destroy();
646
+ this.listeners.clear();
647
+ this.signalSubs.clear();
648
+ }
649
+ }
650
+
651
+ const stripChanged = (n: string): string =>
652
+ /^on[A-Z].*Changed$/.test(n)
653
+ ? n.slice(2, -7)[0].toLowerCase() + n.slice(3, -7)
654
+ : '';
655
+
656
+ // --- facade ----------------------------------------------------------------
657
+
658
+ const FACADE = Symbol('qml-instance');
659
+
660
+ /** The instance behind a facade, or null for anything else. */
661
+ export const instanceOf = (facade: unknown): QmlInstance | null =>
662
+ facade && typeof facade === 'object'
663
+ ? (((facade as Record<symbol, unknown>)[FACADE] as QmlInstance | null) ??
664
+ null)
665
+ : null;
666
+
667
+ const ANCHOR_EDGES = new Set([
668
+ 'left',
669
+ 'right',
670
+ 'top',
671
+ 'bottom',
672
+ 'horizontalCenter',
673
+ 'verticalCenter',
674
+ ]);
675
+
676
+ function makeFacade(inst: QmlInstance): QmlFacade {
677
+ return new Proxy(Object.create(null) as QmlFacade, {
678
+ has: (_, name) =>
679
+ typeof name === 'string' &&
680
+ (name === 'parent' ||
681
+ ANCHOR_EDGES.has(name) ||
682
+ inst.resolveMember(name) !== undefined),
683
+ get: (_, name) => {
684
+ if (name === FACADE) return inst;
685
+ if (typeof name !== 'string') return undefined;
686
+ if (name === 'parent') return inst.parentInst?.facade ?? null;
687
+ if (ANCHOR_EDGES.has(name) && !inst.slots.has(name))
688
+ return inst.anchorLine(name);
689
+ return inst.getMember(name);
690
+ },
691
+ set: (_, name, value) => {
692
+ if (typeof name !== 'string') return false;
693
+ inst.setMember(name, value);
694
+ return true;
695
+ },
696
+ });
697
+ }
698
+
699
+ // --- instantiation ---------------------------------------------------------
700
+
701
+ let activating = false;
702
+
703
+ export interface InstantiateResult {
704
+ root: QmlInstance;
705
+ context: Context;
706
+ }
707
+
708
+ export function instantiateDocument(
709
+ doc: QmlDocument,
710
+ {
711
+ context = null,
712
+ extras = null,
713
+ resolver = null,
714
+ }: {
715
+ context?: Context | null;
716
+ extras?: Record<string, unknown> | null;
717
+ /** Enables `.qml`-file resolution: the implicit same-directory import
718
+ * and quoted-path imports (resolver.ts). */
719
+ resolver?: QmlResolver | null;
720
+ } = {},
721
+ ): InstantiateResult {
722
+ if (resolver) {
723
+ doc.load = { resolver, dir: resolver.rootDir };
724
+ loadGeneration++; // revalidate cached component sources on each mount
725
+ }
726
+ const ctx = new Context(context, doc);
727
+ if (extras) {
728
+ for (const [k, v] of Object.entries(extras)) {
729
+ ctx.extras.set(k, new Slot(null, k, v));
730
+ }
731
+ }
732
+ const completedQueue: Array<() => void> = [];
733
+ const root = createTree(doc.root, doc, ctx, null);
734
+ ctx.root = root;
735
+ const wasActivating = activating;
736
+ activating = true;
737
+ try {
738
+ activate(root, completedQueue);
739
+ flushBindings();
740
+ } finally {
741
+ activating = wasActivating;
742
+ }
743
+ retryFailedBindings(root);
744
+ for (const fn of completedQueue) fn(); // leaf-first: queued on the way up
745
+ flushBindings();
746
+ return { root, context: ctx };
747
+ }
748
+
749
+ /**
750
+ * A delegate template instantiated into a child context (Repeater, Loader,
751
+ * ListView). `extras` become context properties (index, modelData, roles);
752
+ * ids inside the template scope to the child context while names from the
753
+ * enclosing document keep resolving through the chain.
754
+ */
755
+ export function instantiateTemplate(
756
+ tpl: TemplateRef,
757
+ parentInst: QmlInstance | null,
758
+ extras: Record<string, unknown> | null = null,
759
+ ): { inst: QmlInstance; context: Context } {
760
+ const ctx = new Context(tpl.context, tpl.doc);
761
+ ctx.root = tpl.context.root;
762
+ if (extras) {
763
+ for (const [k, v] of Object.entries(extras)) {
764
+ ctx.extras.set(k, new Slot(null, k, v));
765
+ }
766
+ }
767
+ const completedQueue: Array<() => void> = [];
768
+ const inst = createTree(tpl.object, tpl.doc, ctx, parentInst);
769
+ const wasActivating = activating;
770
+ activating = true;
771
+ try {
772
+ activate(inst, completedQueue);
773
+ flushBindings();
774
+ } finally {
775
+ activating = wasActivating;
776
+ }
777
+ retryFailedBindings(inst);
778
+ for (const fn of completedQueue) fn();
779
+ return { inst, context: ctx };
780
+ }
781
+
782
+ /**
783
+ * Instantiate a whole component document (Loader's `source` path). Its
784
+ * names resolve within its own file; only the top-level context — where
785
+ * `QmlView`'s context properties live — is shared through.
786
+ */
787
+ export function instantiateComponentDoc(
788
+ compDoc: QmlDocument,
789
+ nearContext: Context,
790
+ parentInst: QmlInstance | null,
791
+ ): { inst: QmlInstance; context: Context } {
792
+ const ctx = new Context(topContextOf(nearContext), compDoc);
793
+ const completedQueue: Array<() => void> = [];
794
+ const inst = createTree(compDoc.root, compDoc, ctx, parentInst);
795
+ ctx.root = inst;
796
+ const wasActivating = activating;
797
+ activating = true;
798
+ try {
799
+ activate(inst, completedQueue);
800
+ flushBindings();
801
+ } finally {
802
+ activating = wasActivating;
803
+ }
804
+ retryFailedBindings(inst);
805
+ for (const fn of completedQueue) fn();
806
+ return { inst, context: ctx };
807
+ }
808
+
809
+ /**
810
+ * Hot reload: carry interactive state (slots assigned at runtime —
811
+ * toggles, counters, typed text) from the old tree onto the new one,
812
+ * matched by id + property name. Everything else re-derives from the
813
+ * edited source.
814
+ */
815
+ export function migrateUserState(
816
+ oldContext: Context,
817
+ newContext: Context,
818
+ ): number {
819
+ let carried = 0;
820
+ for (const [id, oldInst] of oldContext.ids) {
821
+ const newInst = newContext.ids.get(id);
822
+ if (!newInst) continue;
823
+ for (const [name, oldSlot] of oldInst.slots) {
824
+ if (!oldSlot.userSet) continue;
825
+ const newSlot = newInst.slots.get(name);
826
+ if (!newSlot) continue;
827
+ newSlot.set(oldSlot.peek());
828
+ carried++;
829
+ }
830
+ }
831
+ flushBindings();
832
+ return carried;
833
+ }
834
+
835
+ /** A binding still failing after the graph settled deserves its warning —
836
+ * and a broken dependency read may have kept it from subscribing to
837
+ * anything that would re-run it, so run it once more by hand. */
838
+ function retryFailedBindings(inst: QmlInstance): void {
839
+ for (const child of inst.children) retryFailedBindings(child);
840
+ for (const slot of inst.slots.values()) {
841
+ if (slot.hasBinding() && slot.lastError) slot._reevaluate();
842
+ }
843
+ }
844
+
845
+ function createTree(
846
+ objIr: ObjectIR,
847
+ doc: QmlDocument,
848
+ ctx: Context,
849
+ parentInst: QmlInstance | null,
850
+ ): QmlInstance {
851
+ const resolved = resolveTypeRef(doc, objIr.type);
852
+ if ('fileDoc' in resolved) {
853
+ // A file-backed component: instantiate its document's root in its own
854
+ // context (ids and root-props private to the file; only the top-level
855
+ // context — QmlView's context properties — is shared), then lay the
856
+ // use-site members over it. Site bindings evaluate in the *site's*
857
+ // scope during activation; instantiator bindings win, as in Qt.
858
+ const compDoc = resolved.fileDoc;
859
+ if (instantiating.includes(compDoc)) {
860
+ const chain = [...instantiating.map((d) => d.fileName), compDoc.fileName];
861
+ throw new Error(
862
+ `QML: circular component reference: ${chain.join(' -> ')}`,
863
+ );
864
+ }
865
+ instantiating.push(compDoc);
866
+ let inst: QmlInstance;
867
+ try {
868
+ const compCtx = new Context(topContextOf(ctx), compDoc);
869
+ inst = createTree(compDoc.root, compDoc, compCtx, parentInst);
870
+ compCtx.root = inst;
871
+ } finally {
872
+ instantiating.pop();
873
+ }
874
+ if (objIr.id) {
875
+ inst.id = objIr.id;
876
+ ctx.ids.set(objIr.id, inst);
877
+ }
878
+ inst.site = { ir: objIr, doc, ctx };
879
+ collectStructural(inst, objIr, doc, ctx);
880
+ return inst;
881
+ }
882
+ const typeInfo = resolved;
883
+ const inst = new QmlInstance(typeInfo, doc, ctx, parentInst);
884
+ inst.ir = objIr;
885
+ // Qt's default: an unset width tracks implicitWidth (`widthValid`). Any
886
+ // later binding or assignment replaces this — binding-breaking gives the
887
+ // exact semantics with no bookkeeping.
888
+ if (inst.slots.has('width') && inst.slots.has('implicitWidth')) {
889
+ inst
890
+ .slot('width')
891
+ .setBinding(() => inst.slot('implicitWidth').get(), { isDefault: true });
892
+ inst
893
+ .slot('height')
894
+ .setBinding(() => inst.slot('implicitHeight').get(), { isDefault: true });
895
+ }
896
+ if (objIr.id) {
897
+ inst.id = objIr.id;
898
+ ctx.ids.set(objIr.id, inst);
899
+ }
900
+ collectStructural(inst, objIr, doc, ctx);
901
+ return inst;
902
+ }
903
+
904
+ /** The structural half of a member list: declarations, children, captured
905
+ * templates. Runs once for a plain instance; a file-backed instance runs
906
+ * it twice — the component document's pass happened inside its own
907
+ * createTree, and the use site's runs here with the site's doc and
908
+ * context, so site children and site delegates resolve site names. */
909
+ function collectStructural(
910
+ inst: QmlInstance,
911
+ objIr: ObjectIR,
912
+ doc: QmlDocument,
913
+ ctx: Context,
914
+ ): void {
915
+ const capture = inst.typeInfo.capture;
916
+ for (const m of objIr.members) {
917
+ if (m.kind === 'property') {
918
+ if (m.propType !== 'alias' && !inst.slots.has(m.name)) {
919
+ inst.addSlot(m.name, defaultForPropType(m.propType));
920
+ }
921
+ } else if (m.kind === 'signal') {
922
+ inst.signalParams.set(
923
+ m.name,
924
+ m.params.map((p) => p.name),
925
+ );
926
+ } else if (m.kind === 'object') {
927
+ if (capture) {
928
+ if (!inst.templates.has(capture))
929
+ inst.templates.set(capture, {
930
+ object: m.object,
931
+ doc,
932
+ context: ctx,
933
+ });
934
+ } else {
935
+ inst.children.push(createTree(m.object, doc, ctx, inst));
936
+ }
937
+ } else if (m.kind === 'binding' && m.value.kind === 'object') {
938
+ const name = m.path.join('.');
939
+ if (name === capture || m.value.object.type === 'Component') {
940
+ // `delegate: Text { … }`, `sourceComponent: Component { … }` —
941
+ // component-typed properties stay templates, instantiated on demand.
942
+ inst.templates.set(name, {
943
+ object: unwrapComponent(m.value.object),
944
+ doc,
945
+ context: ctx,
946
+ });
947
+ } else {
948
+ // `model: ListModel { … }` — an object rvalue instantiates, and the
949
+ // property is assigned the reference (QML's rule).
950
+ const child = createTree(m.value.object, doc, ctx, inst);
951
+ inst.children.push(child);
952
+ (
953
+ (inst.state.objectAssigns ??= []) as Array<[string, QmlInstance]>
954
+ ).push([name, child]);
955
+ }
956
+ } else if (m.kind === 'binding' && m.value.kind === 'array') {
957
+ // `states: [State { … }]` — templates too, under the property name.
958
+ const objects = m.value.items
959
+ .filter(
960
+ (it): it is Extract<ValueIR, { kind: 'object' }> =>
961
+ it.kind === 'object',
962
+ )
963
+ .map((it) => it.object);
964
+ if (objects.length === m.value.items.length && objects.length > 0) {
965
+ inst.state[`templates:${m.path.join('.')}`] = objects.map(
966
+ (object): TemplateRef => ({ object, doc, context: ctx }),
967
+ );
968
+ }
969
+ } else if (m.kind === 'inline-component') {
970
+ throw new Error(
971
+ `QML: inline components (component ${m.name}: …) are not there ` +
972
+ `yet; put the component in its own <Name>.qml next to this ` +
973
+ `document (resolved via the resolver seam), or define it in ` +
974
+ `JavaScript with registerQmlModule.`,
975
+ );
976
+ }
977
+ }
978
+ }
979
+
980
+ function defaultForPropType(propType: string): unknown {
981
+ if (propType === 'int' || propType === 'real' || propType === 'double')
982
+ return 0;
983
+ if (propType === 'bool') return false;
984
+ if (propType === 'string') return '';
985
+ if (propType === 'list') return [];
986
+ return undefined;
987
+ }
988
+
989
+ const unwrapComponent = (obj: ObjectIR): ObjectIR => {
990
+ if (obj.type !== 'Component') return obj;
991
+ const child = obj.members.find(
992
+ (m): m is Extract<MemberIR, { kind: 'object' }> => m.kind === 'object',
993
+ );
994
+ if (!child) throw new Error('QML: Component {} needs exactly one child');
995
+ return child.object;
996
+ };
997
+
998
+ const ownEnv = (inst: QmlInstance): EvalEnv => ({
999
+ doc: inst.doc,
1000
+ ctx: inst.context,
1001
+ scope: inst.scope,
1002
+ facade: inst.facade,
1003
+ });
1004
+
1005
+ function activate(inst: QmlInstance, completedQueue: Array<() => void>): void {
1006
+ if (inst._activated) return; // Repeater splices siblings mid-walk
1007
+ inst._activated = true;
1008
+ const objIr = inst.ir;
1009
+ if (!objIr) return;
1010
+ for (const child of [...inst.children]) activate(child, completedQueue);
1011
+
1012
+ applyMembers(inst, objIr.members, ownEnv(inst), completedQueue);
1013
+ if (inst.site) {
1014
+ const site = inst.site;
1015
+ applyMembers(
1016
+ inst,
1017
+ site.ir.members,
1018
+ {
1019
+ doc: site.doc,
1020
+ ctx: site.ctx,
1021
+ scope: makeScopeProxy(inst, site.ctx, site.doc),
1022
+ facade: inst.facade,
1023
+ },
1024
+ completedQueue,
1025
+ );
1026
+ }
1027
+
1028
+ // Object rvalues (`model: ListModel { … }`): the instance exists since
1029
+ // createTree; hand the property its reference before the type's init
1030
+ // reads it.
1031
+ const objectAssigns = inst.state.objectAssigns as
1032
+ Array<[string, QmlInstance]> | undefined;
1033
+ if (objectAssigns) {
1034
+ for (const [name, child] of objectAssigns) {
1035
+ const slot =
1036
+ inst.slots.get(name) ??
1037
+ (inst.typeInfo.dynamicProperties
1038
+ ? inst.addSlot(name, undefined)
1039
+ : inst.slot(name));
1040
+ slot.assign(child.facade);
1041
+ }
1042
+ }
1043
+
1044
+ installAnchors(inst);
1045
+ inst.typeInfo.init?.(inst);
1046
+ }
1047
+
1048
+ function applyMembers(
1049
+ inst: QmlInstance,
1050
+ members: MemberIR[],
1051
+ env: EvalEnv,
1052
+ completedQueue: Array<() => void>,
1053
+ ): void {
1054
+ const applyBinding = (path: string[], value: ValueIR): void => {
1055
+ if (path.length === 1 && /^on[A-Z]/.test(path[0])) {
1056
+ if (inst.typeInfo.deferHandlers) {
1057
+ inst.deferredHandlers.push({ name: path[0], value });
1058
+ return;
1059
+ }
1060
+ attachHandler(inst, path[0], value, env);
1061
+ return;
1062
+ }
1063
+ if (/^[A-Z]/.test(path[0]) && path.length > 1) {
1064
+ attachAttached(inst, path, value, env, completedQueue);
1065
+ return;
1066
+ }
1067
+ const name = path.join('.'); // grouped props live as dotted slot names
1068
+ if (value.kind === 'object') return; // captured as a template already
1069
+ if (value.kind === 'array') return; // captured as templates already
1070
+ let slot = inst.slots.get(name);
1071
+ if (!slot) {
1072
+ if (inst.typeInfo.dynamicProperties) slot = inst.addSlot(name, undefined);
1073
+ else slot = inst.slot(name); // throws with the readable message
1074
+ }
1075
+ if (value.kind === 'block') {
1076
+ // `width: { if (…) return a; return b }` — a script binding; QML
1077
+ // re-evaluates these like any binding when dependencies change.
1078
+ const h = compileHandler(value, [], env.doc)(env.scope);
1079
+ installGuardedBinding(inst, slot, value, env.doc, name, () =>
1080
+ h.call(env.facade),
1081
+ );
1082
+ return;
1083
+ }
1084
+ if (isLiteral(value.src)) {
1085
+ slot.assign(evalLiteral(value.src));
1086
+ return;
1087
+ }
1088
+ const fn = compileExpr(value, env.doc);
1089
+ installGuardedBinding(inst, slot, value, env.doc, name, () =>
1090
+ fn.call(env.facade, env.scope),
1091
+ );
1092
+ };
1093
+
1094
+ for (const m of members) {
1095
+ if (m.kind === 'property' && m.value) {
1096
+ if (m.propType === 'alias') {
1097
+ installAlias(inst, m.name, m.value as AliasTargetIR, env);
1098
+ } else {
1099
+ applyBinding([m.name], m.value as ValueIR);
1100
+ }
1101
+ } else if (m.kind === 'binding') {
1102
+ applyBinding(m.path, m.value);
1103
+ } else if (m.kind === 'group') {
1104
+ for (const b of m.bindings) applyBinding(b.path, b.value);
1105
+ } else if (m.kind === 'function') {
1106
+ const factory = compileHandler(
1107
+ { kind: 'block', src: m.body },
1108
+ m.params,
1109
+ env.doc,
1110
+ );
1111
+ const f = factory(env.scope);
1112
+ inst.methods.set(m.name, (...args: unknown[]) =>
1113
+ f.call(env.facade, ...args),
1114
+ );
1115
+ } else if (m.kind === 'value-source') {
1116
+ installValueSource(inst, m, env);
1117
+ }
1118
+ }
1119
+ }
1120
+
1121
+ function installGuardedBinding(
1122
+ inst: QmlInstance,
1123
+ slot: Slot,
1124
+ value: ValueIR,
1125
+ doc: QmlDocument,
1126
+ name: string,
1127
+ evalFn: () => unknown,
1128
+ ): void {
1129
+ slot.setBinding(() => {
1130
+ try {
1131
+ const v = evalFn();
1132
+ slot.lastError = null;
1133
+ return v;
1134
+ } catch (e) {
1135
+ slot.lastError = e;
1136
+ // First-pass evaluation order is unspecified (a child's binding can
1137
+ // read a root property whose own binding has not run yet) — exactly
1138
+ // as in Qt. Stay quiet during activation; instantiateDocument
1139
+ // re-evaluates whatever is still broken once the graph settles, and
1140
+ // *that* failure warns.
1141
+ if (!activating) reportBindingError(doc, value, name, e);
1142
+ return slot.peek();
1143
+ }
1144
+ });
1145
+ }
1146
+
1147
+ function isLiteral(src: string): boolean {
1148
+ return /^(-?\d+(\.\d+)?|true|false|null|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')$/.test(
1149
+ src.trim(),
1150
+ );
1151
+ }
1152
+
1153
+ function evalLiteral(src: string): unknown {
1154
+ const t = src.trim();
1155
+ if (t === 'true') return true;
1156
+ if (t === 'false') return false;
1157
+ if (t === 'null') return null;
1158
+ if (t[0] === '"' || t[0] === "'")
1159
+ return t.slice(1, -1).replace(/\\(.)/g, '$1');
1160
+ return Number(t);
1161
+ }
1162
+
1163
+ const reportedBindingErrors = new Set<string>();
1164
+ function reportBindingError(
1165
+ doc: QmlDocument,
1166
+ value: ValueIR,
1167
+ name: string,
1168
+ e: unknown,
1169
+ ): void {
1170
+ const key = `${doc.fileName}:${name}`;
1171
+ if (reportedBindingErrors.has(key)) return;
1172
+ reportedBindingErrors.add(key);
1173
+ const src = 'src' in value ? value.src : '';
1174
+ warn(
1175
+ `QML: binding for '${name}' in ${doc.fileName} threw: ${message(e)}\n \`${src}\``,
1176
+ );
1177
+ }
1178
+
1179
+ // `onFoo: expr-or-block` — but also the modern `onFoo: (args) => { … }`,
1180
+ // where the RHS evaluates to the function to invoke with the signal's args.
1181
+ const FN_VALUED = /^\s*(function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/;
1182
+
1183
+ function makeHandlerFn(
1184
+ inst: QmlInstance,
1185
+ value: ValueIR,
1186
+ params: string[],
1187
+ env: EvalEnv,
1188
+ ): (...args: unknown[]) => unknown {
1189
+ if (value.kind === 'expr' && FN_VALUED.test(value.src)) {
1190
+ const arrow = compileExpr(value, env.doc).call(env.facade, env.scope) as (
1191
+ ...args: unknown[]
1192
+ ) => unknown;
1193
+ return (...args) => arrow.apply(env.facade, args);
1194
+ }
1195
+ if (value.kind !== 'expr' && value.kind !== 'block')
1196
+ throw new Error(`QML: a handler cannot be an object (${env.doc.fileName})`);
1197
+ const f = compileHandler(value, params, env.doc)(env.scope);
1198
+ return (...args) => f.call(env.facade, ...args);
1199
+ }
1200
+
1201
+ /** Compile a stored handler in an instance's scope — how `Connections`
1202
+ * wires its deferred `onX` members to another object's signals. */
1203
+ export function makeHandlerFor(
1204
+ inst: QmlInstance,
1205
+ value: ValueIR,
1206
+ params: string[],
1207
+ ): (...args: unknown[]) => unknown {
1208
+ return makeHandlerFn(inst, value, params, ownEnv(inst));
1209
+ }
1210
+
1211
+ function attachHandler(
1212
+ inst: QmlInstance,
1213
+ onName: string,
1214
+ value: ValueIR,
1215
+ env: EvalEnv,
1216
+ ): void {
1217
+ const base = onName[2].toLowerCase() + onName.slice(3);
1218
+ // Property-change handler?
1219
+ const changed = stripChanged(onName);
1220
+ if (changed && inst.slots.has(changed)) {
1221
+ const f = makeHandlerFn(inst, value, [], env);
1222
+ inst.slots.get(changed)?.watch(() => f());
1223
+ return;
1224
+ }
1225
+ const params = inst.signalParams.get(base);
1226
+ if (!params)
1227
+ throw new Error(
1228
+ `QML: '${onName}' in ${env.doc.fileName} matches no signal or property on ${inst.typeInfo.name}`,
1229
+ );
1230
+ inst.onSignal(base, makeHandlerFn(inst, value, params, env));
1231
+ }
1232
+
1233
+ function attachAttached(
1234
+ inst: QmlInstance,
1235
+ path: string[],
1236
+ value: ValueIR,
1237
+ env: EvalEnv,
1238
+ completedQueue: Array<() => void>,
1239
+ ): void {
1240
+ const ns = path[0];
1241
+ const name = path.slice(1).join('.');
1242
+ if (ns === 'Component' && name === 'onCompleted') {
1243
+ const f = makeHandlerFn(inst, value, [], env);
1244
+ completedQueue.push(() => f());
1245
+ return;
1246
+ }
1247
+ if (ns === 'Component' && name === 'onDestruction') {
1248
+ const f = makeHandlerFn(inst, value, [], env);
1249
+ inst.onDestruction.push(() => f());
1250
+ return;
1251
+ }
1252
+ if (/^on[A-Z]/.test(name)) {
1253
+ // `Keys.onPressed`, `Keys.onReturnPressed`: stored; the view wires
1254
+ // them to the focus/key system (qtquick.tsx).
1255
+ const params = ['event'];
1256
+ inst.attachedHandlers.set(
1257
+ `${ns}.${name}`,
1258
+ makeHandlerFn(inst, value, params, env),
1259
+ );
1260
+ return;
1261
+ }
1262
+ // `Layout.fillWidth` and friends — attached values, namespaced slots.
1263
+ const slotName = `${ns}.${name}`;
1264
+ const slot = inst.slots.get(slotName) ?? inst.addSlot(slotName, undefined);
1265
+ if (value.kind === 'expr' && isLiteral(value.src)) {
1266
+ slot.assign(evalLiteral(value.src));
1267
+ return;
1268
+ }
1269
+ if (value.kind === 'expr') {
1270
+ const fn = compileExpr(value, env.doc);
1271
+ installGuardedBinding(inst, slot, value, env.doc, slotName, () =>
1272
+ fn.call(env.facade, env.scope),
1273
+ );
1274
+ return;
1275
+ }
1276
+ if (value.kind === 'block') {
1277
+ const h = compileHandler(value, [], env.doc)(env.scope);
1278
+ installGuardedBinding(inst, slot, value, env.doc, slotName, () =>
1279
+ h.call(env.facade),
1280
+ );
1281
+ }
1282
+ }
1283
+
1284
+ function installAlias(
1285
+ inst: QmlInstance,
1286
+ name: string,
1287
+ target: AliasTargetIR,
1288
+ env: EvalEnv,
1289
+ ): void {
1290
+ const [idName, ...rest] = target.path;
1291
+ const targetInst = env.ctx.lookupId(idName);
1292
+ if (!targetInst)
1293
+ throw new Error(`QML: alias '${name}' targets unknown id '${idName}'`);
1294
+ if (!rest.length)
1295
+ throw new Error(`QML: alias '${name}' must target id.property`);
1296
+ const slot = targetInst.slot(rest.join('.'));
1297
+ inst.slots.set(name, slot); // the same Slot: reads and writes forward
1298
+ }
1299
+
1300
+ function installValueSource(
1301
+ inst: QmlInstance,
1302
+ m: Extract<MemberIR, { kind: 'value-source' }>,
1303
+ env: EvalEnv,
1304
+ ): void {
1305
+ const srcType = lookupType(env.doc, m.object.type);
1306
+ const propName = m.on.join('.');
1307
+ const slot = inst.slot(propName);
1308
+ const srcInst = createTree(m.object, env.doc, env.ctx, inst);
1309
+ activate(srcInst, []);
1310
+ inst.valueSources.push(srcInst);
1311
+ if (!srcType.init)
1312
+ throw new Error(`QML: ${m.object.type} cannot be a value source here`);
1313
+ srcType.init(srcInst, { targetSlot: slot, target: inst });
1314
+ }
1315
+
1316
+ // --- anchors ---------------------------------------------------------------
1317
+
1318
+ const px = (v: unknown): number =>
1319
+ typeof v === 'number' && Number.isFinite(v) ? v : 0;
1320
+
1321
+ function lineCoord(line: AnchorLine, forInst: QmlInstance): number {
1322
+ const { inst, edge } = line;
1323
+ const horizontal =
1324
+ edge === 'left' || edge === 'right' || edge === 'horizontalCenter';
1325
+ if (inst === forInst.parentInst) {
1326
+ const size = px(inst.slot(horizontal ? 'width' : 'height').get());
1327
+ if (edge === 'left' || edge === 'top') return 0;
1328
+ if (edge === 'right' || edge === 'bottom') return size;
1329
+ return size / 2;
1330
+ }
1331
+ // Sibling: the same coordinate space (both relative to the parent).
1332
+ const pos = px(inst.slot(horizontal ? 'x' : 'y').get());
1333
+ const size = px(inst.slot(horizontal ? 'width' : 'height').get());
1334
+ if (edge === 'left' || edge === 'top') return pos;
1335
+ if (edge === 'right' || edge === 'bottom') return pos + size;
1336
+ return pos + size / 2;
1337
+ }
1338
+
1339
+ const isAnchorLine = (v: unknown): v is AnchorLine =>
1340
+ !!v &&
1341
+ typeof v === 'object' &&
1342
+ 'inst' in v &&
1343
+ 'edge' in v &&
1344
+ (v as AnchorLine).inst instanceof QmlInstance;
1345
+
1346
+ const ANCHOR_CONFIG_NAMES = [
1347
+ 'fill',
1348
+ 'centerIn',
1349
+ 'left',
1350
+ 'right',
1351
+ 'top',
1352
+ 'bottom',
1353
+ 'horizontalCenter',
1354
+ 'verticalCenter',
1355
+ ] as const;
1356
+
1357
+ function installAnchors(inst: QmlInstance): void {
1358
+ if (!inst.slots.has('anchors.fill')) return; // non-Item types
1359
+ const a = (n: string): unknown => inst.slots.get(`anchors.${n}`)?.get();
1360
+ const margin = (side: string): number => {
1361
+ const m = a(`${side}Margin`);
1362
+ return px(m !== undefined && m !== null ? m : a('margins'));
1363
+ };
1364
+ const line = (v: unknown): AnchorLine | null => (isAnchorLine(v) ? v : null);
1365
+
1366
+ const xSlot = inst.slot('x');
1367
+ const ySlot = inst.slot('y');
1368
+ const wSlot = inst.slot('width');
1369
+ const hSlot = inst.slot('height');
1370
+
1371
+ const install = (): void => {
1372
+ const fill = instanceOf(a('fill'));
1373
+ const centerIn = instanceOf(a('centerIn'));
1374
+ const L = fill ? fill.anchorLine('left') : line(a('left'));
1375
+ const R = fill ? fill.anchorLine('right') : line(a('right'));
1376
+ const T = fill ? fill.anchorLine('top') : line(a('top'));
1377
+ const B = fill ? fill.anchorLine('bottom') : line(a('bottom'));
1378
+ const HC = centerIn
1379
+ ? centerIn.anchorLine('horizontalCenter')
1380
+ : line(a('horizontalCenter'));
1381
+ const VC = centerIn
1382
+ ? centerIn.anchorLine('verticalCenter')
1383
+ : line(a('verticalCenter'));
1384
+
1385
+ if (L && R) {
1386
+ wSlot.setBinding(
1387
+ () =>
1388
+ lineCoord(R, inst) -
1389
+ margin('right') -
1390
+ (lineCoord(L, inst) + margin('left')),
1391
+ );
1392
+ xSlot.setBinding(() => lineCoord(L, inst) + margin('left'));
1393
+ } else if (L) {
1394
+ xSlot.setBinding(() => lineCoord(L, inst) + margin('left'));
1395
+ } else if (R) {
1396
+ xSlot.setBinding(
1397
+ () => lineCoord(R, inst) - margin('right') - px(wSlot.get()),
1398
+ );
1399
+ } else if (HC) {
1400
+ xSlot.setBinding(() => lineCoord(HC, inst) - px(wSlot.get()) / 2);
1401
+ }
1402
+ if (T && B) {
1403
+ hSlot.setBinding(
1404
+ () =>
1405
+ lineCoord(B, inst) -
1406
+ margin('bottom') -
1407
+ (lineCoord(T, inst) + margin('top')),
1408
+ );
1409
+ ySlot.setBinding(() => lineCoord(T, inst) + margin('top'));
1410
+ } else if (T) {
1411
+ ySlot.setBinding(() => lineCoord(T, inst) + margin('top'));
1412
+ } else if (B) {
1413
+ ySlot.setBinding(
1414
+ () => lineCoord(B, inst) - margin('bottom') - px(hSlot.get()),
1415
+ );
1416
+ } else if (VC) {
1417
+ ySlot.setBinding(() => lineCoord(VC, inst) - px(hSlot.get()) / 2);
1418
+ }
1419
+ };
1420
+
1421
+ const configured = ANCHOR_CONFIG_NAMES.some((n) => {
1422
+ const s = inst.slots.get(`anchors.${n}`);
1423
+ return s && (s.hasBinding() || s.peek() !== undefined);
1424
+ });
1425
+ // Re-anchoring at runtime re-installs the geometry bindings.
1426
+ for (const n of ANCHOR_CONFIG_NAMES)
1427
+ inst.slots.get(`anchors.${n}`)?.watch(install);
1428
+ if (configured) install();
1429
+ }
1430
+
1431
+ export type { BindingFn };