@pilates/core 1.0.1 → 2.0.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 (77) hide show
  1. package/dist/algorithm/cache.d.ts +7 -19
  2. package/dist/algorithm/cache.d.ts.map +1 -1
  3. package/dist/algorithm/cache.js +31 -27
  4. package/dist/algorithm/cache.js.map +1 -1
  5. package/dist/algorithm/index.d.ts +31 -0
  6. package/dist/algorithm/index.d.ts.map +1 -1
  7. package/dist/algorithm/index.js +105 -13
  8. package/dist/algorithm/index.js.map +1 -1
  9. package/dist/algorithm/round.d.ts +13 -0
  10. package/dist/algorithm/round.d.ts.map +1 -1
  11. package/dist/algorithm/round.js +33 -16
  12. package/dist/algorithm/round.js.map +1 -1
  13. package/dist/algorithm/spineless/classify.d.ts +47 -0
  14. package/dist/algorithm/spineless/classify.d.ts.map +1 -0
  15. package/dist/algorithm/spineless/classify.js +109 -0
  16. package/dist/algorithm/spineless/classify.js.map +1 -0
  17. package/dist/algorithm/spineless/field-id-pool.d.ts +28 -0
  18. package/dist/algorithm/spineless/field-id-pool.d.ts.map +1 -0
  19. package/dist/algorithm/spineless/field-id-pool.js +35 -0
  20. package/dist/algorithm/spineless/field-id-pool.js.map +1 -0
  21. package/dist/algorithm/spineless/flex-grammar.d.ts +394 -0
  22. package/dist/algorithm/spineless/flex-grammar.d.ts.map +1 -0
  23. package/dist/algorithm/spineless/flex-grammar.js +2509 -0
  24. package/dist/algorithm/spineless/flex-grammar.js.map +1 -0
  25. package/dist/algorithm/spineless/grammar.d.ts +156 -0
  26. package/dist/algorithm/spineless/grammar.d.ts.map +1 -0
  27. package/dist/algorithm/spineless/grammar.js +145 -0
  28. package/dist/algorithm/spineless/grammar.js.map +1 -0
  29. package/dist/algorithm/spineless/layout.d.ts +167 -0
  30. package/dist/algorithm/spineless/layout.d.ts.map +1 -0
  31. package/dist/algorithm/spineless/layout.js +893 -0
  32. package/dist/algorithm/spineless/layout.js.map +1 -0
  33. package/dist/algorithm/spineless/order-maintenance.bench.d.ts +25 -0
  34. package/dist/algorithm/spineless/order-maintenance.bench.d.ts.map +1 -0
  35. package/dist/algorithm/spineless/order-maintenance.bench.js +78 -0
  36. package/dist/algorithm/spineless/order-maintenance.bench.js.map +1 -0
  37. package/dist/algorithm/spineless/order-maintenance.d.ts +201 -0
  38. package/dist/algorithm/spineless/order-maintenance.d.ts.map +1 -0
  39. package/dist/algorithm/spineless/order-maintenance.js +300 -0
  40. package/dist/algorithm/spineless/order-maintenance.js.map +1 -0
  41. package/dist/algorithm/spineless/priority-queue.bench.d.ts +17 -0
  42. package/dist/algorithm/spineless/priority-queue.bench.d.ts.map +1 -0
  43. package/dist/algorithm/spineless/priority-queue.bench.js +57 -0
  44. package/dist/algorithm/spineless/priority-queue.bench.js.map +1 -0
  45. package/dist/algorithm/spineless/priority-queue.d.ts +73 -0
  46. package/dist/algorithm/spineless/priority-queue.d.ts.map +1 -0
  47. package/dist/algorithm/spineless/priority-queue.js +149 -0
  48. package/dist/algorithm/spineless/priority-queue.js.map +1 -0
  49. package/dist/algorithm/spineless/runtime.d.ts +292 -0
  50. package/dist/algorithm/spineless/runtime.d.ts.map +1 -0
  51. package/dist/algorithm/spineless/runtime.js +609 -0
  52. package/dist/algorithm/spineless/runtime.js.map +1 -0
  53. package/dist/algorithm/spineless/style-dirty.d.ts +65 -0
  54. package/dist/algorithm/spineless/style-dirty.d.ts.map +1 -0
  55. package/dist/algorithm/spineless/style-dirty.js +75 -0
  56. package/dist/algorithm/spineless/style-dirty.js.map +1 -0
  57. package/dist/dirty-flags.d.ts +30 -0
  58. package/dist/dirty-flags.d.ts.map +1 -0
  59. package/dist/dirty-flags.js +35 -0
  60. package/dist/dirty-flags.js.map +1 -0
  61. package/dist/index.d.ts +4 -1
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +9 -1
  64. package/dist/index.js.map +1 -1
  65. package/dist/inspect.d.ts +27 -0
  66. package/dist/inspect.d.ts.map +1 -0
  67. package/dist/inspect.js +61 -0
  68. package/dist/inspect.js.map +1 -0
  69. package/dist/layout-pool.d.ts +49 -0
  70. package/dist/layout-pool.d.ts.map +1 -0
  71. package/dist/layout-pool.js +75 -0
  72. package/dist/layout-pool.js.map +1 -0
  73. package/dist/node.d.ts +20 -3
  74. package/dist/node.d.ts.map +1 -1
  75. package/dist/node.js +63 -42
  76. package/dist/node.js.map +1 -1
  77. package/package.json +1 -1
@@ -0,0 +1,893 @@
1
+ /**
2
+ * `SpinelessLayout` — drives the Spineless incremental layout engine
3
+ * as a `calculateLayout`-equivalent (phase 8).
4
+ *
5
+ * The grammar (`buildFlexGrammar`) + runtime (`SpinelessRuntime`)
6
+ * compute each node's `{width, height, left, top}` in floating-point
7
+ * space; this driver writes those into `node._layout`, then runs
8
+ * integer-cell rounding and a scroll-extent pass — mirroring the
9
+ * tail of the imperative `calculateLayoutImpl`.
10
+ *
11
+ * The driver persists the grammar + runtime between `layout()` calls
12
+ * and keeps every step incremental:
13
+ *
14
+ * - DETECTION (v22) — the `Node` dirty flags scope change detection
15
+ * to the mutated region.
16
+ * - VALUE relayout — `recompute()` reports the fields it changed;
17
+ * write-back, rounding and scroll-extents (v23) touch only the
18
+ * subtrees whose layout actually moved.
19
+ * - GRAFT relayout (v21) — a single child append patches the
20
+ * runtime via `buildAppendFragment` + `graft`.
21
+ * - full REBUILD — any other structural change.
22
+ *
23
+ * @internal
24
+ */
25
+ import { roundLayout, roundLayoutFrom } from '../round.js';
26
+ import { buildAppendFragment, buildFlexGrammar, buildRemoveFragment, buildReorderFragment, } from './flex-grammar.js';
27
+ import { field } from './grammar.js';
28
+ import { SpinelessRuntime } from './runtime.js';
29
+ /** An input field's `compute` never calls `read` — guard against it. */
30
+ const NEVER_READ = () => {
31
+ throw new Error('[spineless-layout] an input field compute must not read');
32
+ };
33
+ /** Every leaf input Field (`deps: []`) the runtime currently tracks. */
34
+ function collectInputs(grammar, runtime) {
35
+ const inputs = new Set();
36
+ for (const [f, rule] of grammar) {
37
+ if (rule.deps.length === 0 && runtime.isTracked(f))
38
+ inputs.add(f);
39
+ }
40
+ return inputs;
41
+ }
42
+ /** The leaf input Fields a single node owns (its style inputs). */
43
+ function inputFieldsOf(entry, out) {
44
+ if (entry === undefined)
45
+ return;
46
+ for (const k of [
47
+ 'width',
48
+ 'height',
49
+ 'flexBasis',
50
+ 'flexGrow',
51
+ 'flexShrink',
52
+ 'gapRow',
53
+ 'gapColumn',
54
+ 'minWidth',
55
+ 'minHeight',
56
+ 'maxWidth',
57
+ 'maxHeight',
58
+ ]) {
59
+ const f = entry[k];
60
+ if (f !== undefined)
61
+ out.push(f);
62
+ }
63
+ for (const k of ['padding', 'margin']) {
64
+ const arr = entry[k];
65
+ if (arr === undefined)
66
+ continue;
67
+ for (const f of arr)
68
+ if (f !== undefined)
69
+ out.push(f);
70
+ }
71
+ }
72
+ /** The structural signature of one node — see `NodeSnap`. */
73
+ function nodeSig(node) {
74
+ const s = node.style;
75
+ return [
76
+ s.flexDirection,
77
+ s.flexWrap,
78
+ s.justifyContent,
79
+ s.alignItems,
80
+ s.alignContent,
81
+ s.alignSelf,
82
+ s.positionType,
83
+ s.display,
84
+ typeof s.width,
85
+ typeof s.height,
86
+ typeof s.flexBasis,
87
+ // Only the zero / positive BOUNDARY of a flex weight is
88
+ // structural (it flips `parentNeedsFlexDistribution`); a
89
+ // positive → positive tweak stays an incremental value change.
90
+ s.flexGrow > 0 ? 'g' : '_',
91
+ s.flexShrink > 0 ? 's' : '_',
92
+ // `aspectRatio` is captured by value at build time, so any
93
+ // change to it needs a rebuild.
94
+ s.aspectRatio === undefined ? 'n' : String(s.aspectRatio),
95
+ // Absolute children capture their `position` edges by value.
96
+ s.position
97
+ .map((p) => (p === undefined ? '_' : String(p)))
98
+ .join(','),
99
+ // Fold-predicate bits for Phase 17: each bit flips when the
100
+ // property crosses its fold boundary (default → non-default).
101
+ // Mutating a folded property must change nodeSig so the
102
+ // classifier triggers a grammar rebuild.
103
+ s.minWidth === 0 ? '_' : 'mw',
104
+ s.minHeight === 0 ? '_' : 'mh',
105
+ s.maxWidth === undefined ? '_' : 'xw',
106
+ s.maxHeight === undefined ? '_' : 'xh',
107
+ s.margin[0] === 0 ? '_' : 'm0',
108
+ s.margin[1] === 0 ? '_' : 'm1',
109
+ s.margin[2] === 0 ? '_' : 'm2',
110
+ s.margin[3] === 0 ? '_' : 'm3',
111
+ ].join('|');
112
+ }
113
+ function captureSnaps(root) {
114
+ const snaps = new Map();
115
+ captureSnapsInto(root, snaps);
116
+ return snaps;
117
+ }
118
+ /** Write `node`'s subtree snaps into `target` (used by the
119
+ * fast-path graft to extend the snap map by the appended region
120
+ * without rewalking the whole tree). */
121
+ function captureSnapsInto(node, target) {
122
+ const children = [];
123
+ for (let i = 0; i < node.getChildCount(); i++)
124
+ children.push(node.getChild(i));
125
+ target.set(node, { sig: nodeSig(node), measure: node.getMeasureFunc(), children });
126
+ for (const c of children)
127
+ captureSnapsInto(c, target);
128
+ }
129
+ /** A fresh `NodeSnap` for `node` against its current children. */
130
+ function freshSnap(node) {
131
+ const children = [];
132
+ for (let i = 0; i < node.getChildCount(); i++)
133
+ children.push(node.getChild(i));
134
+ return { sig: nodeSig(node), measure: node.getMeasureFunc(), children };
135
+ }
136
+ /** True iff `node`'s current child list still matches `snap.children`. */
137
+ function childrenUnchanged(snap, node) {
138
+ if (node.getChildCount() !== snap.children.length)
139
+ return false;
140
+ for (let i = 0; i < snap.children.length; i++) {
141
+ if (node.getChild(i) !== snap.children[i])
142
+ return false;
143
+ }
144
+ return true;
145
+ }
146
+ /**
147
+ * Collect every dirty node — descending only into subtrees the dirty
148
+ * flags say contain a change, so the walk is O(dirty region).
149
+ */
150
+ function collectDirty(node, out) {
151
+ const dirty = node.isDirty();
152
+ if (dirty)
153
+ out.push(node);
154
+ if (dirty || node._hasDirtyDescendant) {
155
+ for (let i = 0; i < node.getChildCount(); i++)
156
+ collectDirty(node.getChild(i), out);
157
+ }
158
+ }
159
+ /** Clear the dirty flags over the same region `collectDirty` walks. */
160
+ function clearDirtyRegion(node) {
161
+ if (!node.isDirty() && !node._hasDirtyDescendant)
162
+ return;
163
+ node.clearDirty();
164
+ for (let i = 0; i < node.getChildCount(); i++)
165
+ clearDirtyRegion(node.getChild(i));
166
+ }
167
+ function clearDirtyDeep(node) {
168
+ node.clearDirty();
169
+ for (let i = 0; i < node.getChildCount(); i++)
170
+ clearDirtyDeep(node.getChild(i));
171
+ }
172
+ /** Build the `fields` / `owner` indexes from a `FlexGrammarOutput`. */
173
+ function indexFields(output) {
174
+ const fields = new Map();
175
+ const owner = new Map();
176
+ for (const f of output.allFields) {
177
+ fields.set(f.node, { width: f.width, height: f.height, left: f.left, top: f.top });
178
+ owner.set(f.width, f.node);
179
+ owner.set(f.height, f.node);
180
+ owner.set(f.left, f.node);
181
+ owner.set(f.top, f.node);
182
+ }
183
+ return { fields, owner };
184
+ }
185
+ /**
186
+ * A layout driver bound to one root `Node`. Call `layout()` to
187
+ * produce a layout byte-equivalent to imperative `calculateLayout`;
188
+ * repeat calls reuse the runtime, relaying incrementally.
189
+ *
190
+ * @internal
191
+ */
192
+ export class SpinelessLayout {
193
+ root;
194
+ built = null;
195
+ /** Build / relayout counters — for tests and diagnostics. */
196
+ stats = {
197
+ fullBuilds: 0,
198
+ incrementalRelayouts: 0,
199
+ graftRelayouts: 0,
200
+ detachRelayouts: 0,
201
+ reorderRelayouts: 0,
202
+ };
203
+ /** What the most recent `layout()` call did (phase 9). */
204
+ _lastTrace = null;
205
+ constructor(root) {
206
+ this.root = root;
207
+ }
208
+ /** The `LayoutTrace` of the most recent `layout()` call, or `null`
209
+ * if `layout()` has not run yet. */
210
+ get lastTrace() {
211
+ return this._lastTrace;
212
+ }
213
+ /**
214
+ * Lay the tree out. `availableWidth` / `availableHeight` size an
215
+ * `'auto'` root, matching `calculateLayout`'s availability args.
216
+ */
217
+ layout(availableWidth, availableHeight) {
218
+ // `available` PRESENCE (defined vs not) is structural — it
219
+ // selects the root size rule shape (`rootAxisIsBareZero`).
220
+ const samePresence = this.built !== null &&
221
+ (this.built.available.width !== undefined) === (availableWidth !== undefined) &&
222
+ (this.built.available.height !== undefined) === (availableHeight !== undefined);
223
+ if (!samePresence) {
224
+ this.fullBuild(availableWidth, availableHeight);
225
+ this.stats.fullBuilds++;
226
+ this.finishWhole();
227
+ return;
228
+ }
229
+ // Classify the dirty region. The classifier collects PIVOTS —
230
+ // dirty nodes whose CHILDREN LIST changed since the last layout —
231
+ // and short-circuits to a full rebuild when any snapped node's
232
+ // sig / measure changed (those need a fresh grammar). A dirty
233
+ // node WITHOUT a snap is a freshly-introduced node (part of an
234
+ // appended subtree); the graft validator handles it, so the
235
+ // classifier just skips it.
236
+ //
237
+ // Dispatch:
238
+ // - sig / measure change → fullBuild
239
+ // - pivots.length === 0 → value relayout
240
+ // - pivots.length === 1 → graft / detach / reorder
241
+ // - pivots.length > 1 → fullBuild (multi-parent
242
+ // structural change; no
243
+ // fast-path handles it)
244
+ const dirty = [];
245
+ collectDirty(this.root, dirty);
246
+ const snaps = this.built.snaps;
247
+ const pivots = [];
248
+ let needsRebuild = false;
249
+ for (const n of dirty) {
250
+ const snap = snaps.get(n);
251
+ if (snap === undefined) {
252
+ // Freshly-introduced node — has no pre-layout snap. The graft
253
+ // validator below verifies the new region forms one subtree
254
+ // rooted under a single pivot; don't treat as a rebuild trigger.
255
+ continue;
256
+ }
257
+ if (snap.sig !== nodeSig(n) || snap.measure !== n.getMeasureFunc()) {
258
+ needsRebuild = true;
259
+ break;
260
+ }
261
+ if (!childrenUnchanged(snap, n)) {
262
+ pivots.push(n);
263
+ }
264
+ }
265
+ if (!needsRebuild && pivots.length === 0) {
266
+ const moved = this.relayoutValues(dirty, availableWidth, availableHeight);
267
+ this.stats.incrementalRelayouts++;
268
+ const rs = this.built.runtime.stats;
269
+ this._lastTrace = {
270
+ path: 'incremental',
271
+ dirtyNodes: dirty.length,
272
+ fieldsRecomputed: rs.recomputeVisited,
273
+ fieldsChanged: rs.recomputeChanged,
274
+ movedSubtrees: moved.length,
275
+ };
276
+ this.finishMoved(moved, []);
277
+ clearDirtyRegion(this.root);
278
+ return;
279
+ }
280
+ if (!needsRebuild && pivots.length === 1) {
281
+ const pivot = pivots[0];
282
+ const graft = this.tryGraftAppend(pivot, dirty, availableWidth, availableHeight);
283
+ if (graft !== null) {
284
+ this.stats.graftRelayouts++;
285
+ const rs = this.built.runtime.stats;
286
+ const survivorRoots = this.movedSubtreeRoots(graft.changed);
287
+ // CRITICAL: include graft.child explicitly. Its fields were
288
+ // computed via integrate() at graft time, not via recompute(),
289
+ // so they don't appear in `changed`. Without this, a simple-
290
+ // regime append (changed is empty) writes back NOTHING and
291
+ // the appended subtree ships with _layout = 0. The structural-
292
+ // differential fuzzer catches this.
293
+ const roots = [graft.child, ...survivorRoots.filter((r) => !isInSubtree(r, graft.child))];
294
+ this._lastTrace = {
295
+ path: 'graft',
296
+ dirtyNodes: dirty.length,
297
+ fieldsRecomputed: rs.recomputeVisited,
298
+ fieldsChanged: rs.recomputeChanged,
299
+ movedSubtrees: roots.length,
300
+ };
301
+ this.finishMoved(roots, []);
302
+ clearDirtyRegion(this.root);
303
+ return;
304
+ }
305
+ const detach = this.tryDetachRemove(pivot, dirty, availableWidth, availableHeight);
306
+ if (detach !== null) {
307
+ this.stats.detachRelayouts++;
308
+ const rs = this.built.runtime.stats;
309
+ const survivorRoots = this.movedSubtreeRoots(detach.changed);
310
+ this._lastTrace = {
311
+ path: 'detach',
312
+ dirtyNodes: dirty.length,
313
+ fieldsRecomputed: rs.recomputeVisited,
314
+ fieldsChanged: rs.recomputeChanged,
315
+ movedSubtrees: survivorRoots.length,
316
+ };
317
+ // Survivors may be empty for simple-regime removes; explicitly
318
+ // include detach.parent so its scroll extent is recomputed.
319
+ this.finishMoved(survivorRoots, [detach.parent]);
320
+ clearDirtyRegion(this.root);
321
+ return;
322
+ }
323
+ const reorder = this.tryReorder(pivot, dirty, availableWidth, availableHeight);
324
+ if (reorder !== null) {
325
+ this.stats.reorderRelayouts++;
326
+ const rs = this.built.runtime.stats;
327
+ const movedRoots = this.movedSubtreeRoots(reorder.changed);
328
+ this._lastTrace = {
329
+ path: 'reorder',
330
+ dirtyNodes: dirty.length,
331
+ fieldsRecomputed: rs.recomputeVisited,
332
+ fieldsChanged: rs.recomputeChanged,
333
+ movedSubtrees: movedRoots.length,
334
+ };
335
+ this.finishMoved(movedRoots, [reorder.reordered]);
336
+ clearDirtyRegion(this.root);
337
+ return;
338
+ }
339
+ }
340
+ this.fullBuild(availableWidth, availableHeight);
341
+ this.stats.fullBuilds++;
342
+ this.finishWhole(); // Only reached by fullBuild fallback now
343
+ }
344
+ /** Discard any persisted state and build the grammar afresh. */
345
+ fullBuild(availableWidth, availableHeight) {
346
+ const available = {};
347
+ if (availableWidth !== undefined)
348
+ available.width = availableWidth;
349
+ if (availableHeight !== undefined)
350
+ available.height = availableHeight;
351
+ const output = buildFlexGrammar(this.root, available);
352
+ const rootFields = [];
353
+ for (const f of output.allFields) {
354
+ rootFields.push(f.width, f.height, f.left, f.top);
355
+ }
356
+ const runtime = new SpinelessRuntime(output.grammar, rootFields);
357
+ runtime.init();
358
+ this.built = {
359
+ available,
360
+ output,
361
+ runtime,
362
+ inputs: collectInputs(output.grammar, runtime),
363
+ snaps: captureSnaps(this.root),
364
+ ...indexFields(output),
365
+ };
366
+ // A build computes every Field once during `init` (counted by
367
+ // `runtime.stats.initFields`) — it runs no `recompute()`, so the
368
+ // recompute-derived trace counts are all zero.
369
+ this._lastTrace = {
370
+ path: 'build',
371
+ dirtyNodes: 0,
372
+ fieldsRecomputed: 0,
373
+ fieldsChanged: 0,
374
+ movedSubtrees: 0,
375
+ };
376
+ }
377
+ /**
378
+ * Fast-path a structural change that is exactly a single child
379
+ * append at `pivot`: `buildAppendFragment` + `graft`, no whole-tree
380
+ * rebuild. Returns `null` (and changes nothing) when the change is
381
+ * not a clean append the fast-path covers — the caller then tries
382
+ * the next fast-path / rebuilds.
383
+ *
384
+ * The classifier supplies `pivot` — a dirty node whose snapshot
385
+ * exists (so sig / measure are unchanged) and whose children list
386
+ * differs from its snap's. The validator inspects ONLY pivot's
387
+ * children, not the whole tree.
388
+ */
389
+ tryGraftAppend(pivot, dirty, availableWidth, availableHeight) {
390
+ const built = this.built;
391
+ const snaps = built.snaps;
392
+ const snap = snaps.get(pivot);
393
+ // Pivot's live children must be its snapped children with exactly
394
+ // ONE inserted child (anywhere — last for simple regime, mid-list
395
+ // for non-simple). Walk pivot's children positionally —
396
+ // O(pivot's children), not O(tree).
397
+ const live = pivot.getChildCount();
398
+ const snapped = snap.children.length;
399
+ if (live !== snapped + 1)
400
+ return null;
401
+ // Find the insertion index: the first position where pivot's live
402
+ // child diverges from snap. After the new child, the tail must
403
+ // match the rest of the snap.
404
+ let insertAt = snapped; // default: appended at the end
405
+ for (let i = 0; i < snapped; i++) {
406
+ if (pivot.getChild(i) !== snap.children[i]) {
407
+ insertAt = i;
408
+ break;
409
+ }
410
+ }
411
+ // Verify the tail past the insertion matches.
412
+ for (let i = insertAt; i < snapped; i++) {
413
+ if (pivot.getChild(i + 1) !== snap.children[i])
414
+ return null;
415
+ }
416
+ const child = pivot.getChild(insertAt);
417
+ // A change inside a `display: 'none'` subtree: the hidden region
418
+ // has no grammar fields to graft onto. A node with no entry in
419
+ // `fields` is hidden (or under a hidden ancestor) — fall back to
420
+ // a rebuild, which correctly skips the whole hidden subtree.
421
+ if (!built.fields.has(pivot))
422
+ return null;
423
+ // Every other dirty-with-no-snap node must be a descendant of
424
+ // `child` (i.e. the appended subtree). The classifier already
425
+ // collected them; if any escaped this subtree, it would be an
426
+ // independent append elsewhere — handled by the multi-pivot
427
+ // fullBuild fallback, but a single-pivot append must be tight.
428
+ //
429
+ // Verifying this requires walking just the appended subtree:
430
+ // O(added subtree).
431
+ const subtreeMembers = new Set();
432
+ {
433
+ const stack = [child];
434
+ while (stack.length > 0) {
435
+ const n = stack.pop();
436
+ subtreeMembers.add(n);
437
+ for (let i = 0; i < n.getChildCount(); i++)
438
+ stack.push(n.getChild(i));
439
+ }
440
+ }
441
+ const fragment = buildAppendFragment(built.output, this.root, pivot, child, built.available);
442
+ if (fragment === null)
443
+ return null;
444
+ built.runtime.graft(fragment.additions, fragment.newRoots);
445
+ for (const [rf, rule] of fragment.rebinds)
446
+ built.runtime.rebindRule(rf, rule);
447
+ built.output = fragment.next;
448
+ // Incremental bookkeeping (Part C):
449
+ //
450
+ // `built.snaps` — update pivot's snap (children list changed) and
451
+ // add a fresh snap for every node in the appended subtree.
452
+ built.snaps.set(pivot, freshSnap(pivot));
453
+ captureSnapsInto(child, built.snaps);
454
+ // `built.fields` / `built.owner` — every new layout field belongs
455
+ // to a node in the appended subtree. Walk the subtree and gather
456
+ // each node's four layout fields from `fragment.additions`. A
457
+ // `display: 'none'` descendant has no fields (it isn't in
458
+ // additions for its layout-field keys); skip it.
459
+ for (const n of subtreeMembers) {
460
+ if (n.style.display === 'none')
461
+ continue;
462
+ const w = field(n, 'width');
463
+ const h = field(n, 'height');
464
+ const l = field(n, 'left');
465
+ const t = field(n, 'top');
466
+ if (!fragment.next.grammar.has(w))
467
+ continue;
468
+ built.fields.set(n, { width: w, height: h, left: l, top: t });
469
+ built.owner.set(w, n);
470
+ built.owner.set(h, n);
471
+ built.owner.set(l, n);
472
+ built.owner.set(t, n);
473
+ }
474
+ // `built.inputs` — add new leaf input fields from additions. For
475
+ // simple regime nothing existing changes tracking, so this is
476
+ // additive. For non-simple regime, existing inputs may have just
477
+ // become tracked (e.g. the previous last child's main-END
478
+ // margin); the runtime's tracking is post-graft, so filter the
479
+ // OLD list to only tracked + add new tracked leaves.
480
+ if (fragment.rebinds.length === 0) {
481
+ // Simple regime: no existing input changed tracking — just
482
+ // append the new tracked leaves.
483
+ for (const [f, rule] of fragment.additions) {
484
+ if (rule.deps.length === 0 && built.runtime.isTracked(f)) {
485
+ built.inputs.add(f);
486
+ }
487
+ }
488
+ }
489
+ else {
490
+ // Non-simple regime: existing inputs may have gained or lost
491
+ // tracking. The cheapest correct update is a full recompute
492
+ // (same cost as the fragment builder's O(tree) rebuild).
493
+ built.inputs = collectInputs(fragment.next.grammar, built.runtime);
494
+ }
495
+ // Pick up any value mutations in the same gap, then recompute
496
+ // (covering the grafted / rebound fields too). Iterate ONLY the
497
+ // dirty nodes' inputs — every value mutation marks its node
498
+ // dirty, so we don't need to scan the whole input set.
499
+ this.applyAvailable(availableWidth, availableHeight);
500
+ this.markDriftedInputs(dirty);
501
+ const changed = built.runtime.recompute();
502
+ return { child, changed };
503
+ }
504
+ /**
505
+ * Fast-path a structural change that is exactly a single subtree
506
+ * removal at `pivot`: `buildRemoveFragment` + `rebindRule` /
507
+ * `detach`, no whole-tree rebuild. Returns `null` (changing nothing)
508
+ * when the change is not a clean removal — the caller then tries
509
+ * the next fast-path / rebuilds.
510
+ *
511
+ * `buildRemoveFragment` must see the removed `child` still attached
512
+ * (its regime check reads the parent's live child list and it walks
513
+ * the subtree for the fields to detach), but by the time `layout()`
514
+ * runs the caller has already detached it — so the removed subtree
515
+ * is briefly re-inserted at its old index for the fragment build,
516
+ * then detached again.
517
+ *
518
+ * The classifier supplies `pivot` — the parent whose children list
519
+ * differs from its snap's. The validator inspects ONLY pivot's
520
+ * children, not the whole tree.
521
+ */
522
+ tryDetachRemove(pivot, dirty, availableWidth, availableHeight) {
523
+ const built = this.built;
524
+ const snaps = built.snaps;
525
+ const snap = snaps.get(pivot);
526
+ const snapChildren = snap.children;
527
+ // Pivot's live children must be its snapped children with exactly
528
+ // one contiguous removal. Walk pivot's children positionally —
529
+ // O(pivot's children), not O(tree).
530
+ const liveCount = pivot.getChildCount();
531
+ const snappedCount = snapChildren.length;
532
+ if (liveCount >= snappedCount)
533
+ return null;
534
+ // Find the index where they diverge; this is the start of the
535
+ // removed run.
536
+ let removedStart = 0;
537
+ while (removedStart < liveCount &&
538
+ pivot.getChild(removedStart) === snapChildren[removedStart]) {
539
+ removedStart++;
540
+ }
541
+ const removedCount = snappedCount - liveCount;
542
+ // Verify the snapped tail past the removed run still matches the
543
+ // live tail.
544
+ for (let i = removedStart; i < liveCount; i++) {
545
+ if (pivot.getChild(i) !== snapChildren[i + removedCount])
546
+ return null;
547
+ }
548
+ // Single-child removal: simple-regime requirement. (A wider
549
+ // contiguous removal would still be a valid fragment for
550
+ // non-simple regime, but the fragment builder is single-`child`
551
+ // only, so split-merge runs fall through to fullBuild.)
552
+ if (removedCount !== 1)
553
+ return null;
554
+ const child = snapChildren[removedStart];
555
+ // A removal inside a `display: 'none'` subtree: the hidden region
556
+ // has no fields to `detach`. A node absent from `fields` is hidden
557
+ // (or under a hidden ancestor) — rebuild instead.
558
+ if (!built.fields.has(pivot))
559
+ return null;
560
+ // The removed subtree's nodes must still be reachable from
561
+ // `child` through SNAP edges (the live tree no longer has them).
562
+ // Collect them via DFS over snaps.
563
+ const removedNodes = new Set();
564
+ {
565
+ const stack = [child];
566
+ while (stack.length > 0) {
567
+ const n = stack.pop();
568
+ removedNodes.add(n);
569
+ const cs = snaps.get(n);
570
+ if (cs !== undefined)
571
+ for (const c of cs.children)
572
+ stack.push(c);
573
+ }
574
+ }
575
+ // Re-attach `child` for the fragment build, then detach it again.
576
+ pivot.insertChild(child, removedStart);
577
+ const fragment = buildRemoveFragment(built.output, this.root, pivot, child, built.available);
578
+ pivot.removeChild(child);
579
+ if (fragment === null)
580
+ return null;
581
+ // Apply: rebind survivors FIRST (so they stop reading the removed
582
+ // fields), then `detach`, then adopt the next grammar.
583
+ for (const [f, rule] of fragment.rebinds)
584
+ built.runtime.rebindRule(f, rule);
585
+ const dropped = built.runtime.detach(fragment.removed);
586
+ built.output = fragment.next;
587
+ // Incremental bookkeeping (Part C):
588
+ //
589
+ // `built.snaps` — update pivot's snap (children list changed) and
590
+ // drop the removed subtree's snaps.
591
+ built.snaps.set(pivot, freshSnap(pivot));
592
+ for (const n of removedNodes)
593
+ built.snaps.delete(n);
594
+ // `built.fields` / `built.owner` — drop the removed subtree's
595
+ // entries. Each removed node may have had `display: 'none'` (no
596
+ // entry) or normal layout fields — `built.fields.delete` on a
597
+ // missing key is harmless; `built.owner.delete` likewise.
598
+ for (const n of removedNodes) {
599
+ const lf = built.fields.get(n);
600
+ if (lf !== undefined) {
601
+ built.owner.delete(lf.width);
602
+ built.owner.delete(lf.height);
603
+ built.owner.delete(lf.left);
604
+ built.owner.delete(lf.top);
605
+ built.fields.delete(n);
606
+ }
607
+ }
608
+ // `built.inputs` — remove every field detach dropped (the removed
609
+ // subtree's inputs + any survivor orphan-cleaned by detach).
610
+ // O(|dropped|) — detach's returned set is complete by construction:
611
+ // its `drop` closure is the single choke point for ALL removals,
612
+ // including orphan-cleaned surviving deps (e.g. the previous last
613
+ // child's now-unread main-end margin). Set.delete is a no-op for
614
+ // absent keys, so iterating all of `dropped` (which includes
615
+ // non-input layout fields) correctly removes only the dropped
616
+ // leaf inputs from `built.inputs`.
617
+ for (const f of dropped)
618
+ built.inputs.delete(f);
619
+ // Pick up any value mutations in the same batch, then recompute.
620
+ // Iterate ONLY the dirty nodes' inputs — every value mutation
621
+ // marks its node dirty.
622
+ this.applyAvailable(availableWidth, availableHeight);
623
+ this.markDriftedInputs(dirty);
624
+ const changed = built.runtime.recompute();
625
+ return { parent: pivot, changed };
626
+ }
627
+ /**
628
+ * Fast-path a structural change that is exactly `pivot`'s children
629
+ * being reordered (a permutation — no node added or removed):
630
+ * `buildReorderFragment` + `rebindRule`, no whole-tree rebuild.
631
+ * Returns `null` (changing nothing) when the change is not a clean
632
+ * single-parent reorder — the caller then rebuilds.
633
+ *
634
+ * The classifier supplies `pivot` — the single node whose children
635
+ * list differs from its snap's. The validator inspects ONLY pivot's
636
+ * children, not the whole tree.
637
+ */
638
+ tryReorder(pivot, dirty, availableWidth, availableHeight) {
639
+ const built = this.built;
640
+ const snap = built.snaps.get(pivot);
641
+ // Pivot's children must be a permutation of the snapped set
642
+ // (same count, same members). Otherwise it is an add/remove —
643
+ // tryGraftAppend / tryDetachRemove would have caught it.
644
+ const before = snap.children;
645
+ if (before.length !== pivot.getChildCount())
646
+ return null;
647
+ const beforeSet = new Set(before);
648
+ for (let i = 0; i < pivot.getChildCount(); i++) {
649
+ if (!beforeSet.has(pivot.getChild(i)))
650
+ return null;
651
+ }
652
+ // A reorder inside a `display: 'none'` subtree touches no laid-out
653
+ // node — the hidden region has no fields. Rebuild instead.
654
+ if (!built.fields.has(pivot))
655
+ return null;
656
+ const fragment = buildReorderFragment(built.output, this.root, pivot, built.available);
657
+ // Order: integrate the newly-read inputs, rebind the rewritten
658
+ // rules (their new deps are now all present), then detach the
659
+ // inputs no rebound rule reads any more.
660
+ built.runtime.graft(fragment.additions, fragment.newRoots);
661
+ for (const [f, rule] of fragment.rebinds)
662
+ built.runtime.rebindRule(f, rule);
663
+ if (fragment.removed.length > 0)
664
+ built.runtime.detach(fragment.removed);
665
+ built.output = fragment.next;
666
+ // Incremental bookkeeping (Part C):
667
+ //
668
+ // `built.snaps` — pivot's children list changed; everything else
669
+ // stayed put. Update just pivot's snap.
670
+ built.snaps.set(pivot, freshSnap(pivot));
671
+ // `built.fields` / `built.owner` — the same nodes own the same
672
+ // four layout fields they did before; no entry adds or drops.
673
+ // (Field identity is stable across grammar rebuilds.)
674
+ //
675
+ // `built.inputs` — a reorder can newly-track or newly-untrack
676
+ // sibling main-end margins (`additions` / `removed`). Update
677
+ // incrementally.
678
+ if (fragment.removed.length > 0) {
679
+ for (const f of fragment.removed)
680
+ built.inputs.delete(f);
681
+ }
682
+ for (const [f, rule] of fragment.additions) {
683
+ if (rule.deps.length === 0 && built.runtime.isTracked(f)) {
684
+ built.inputs.add(f);
685
+ }
686
+ }
687
+ // Pick up any value mutations in the same batch, then recompute.
688
+ // Iterate ONLY the dirty nodes' inputs — every value mutation
689
+ // marks its node dirty.
690
+ this.applyAvailable(availableWidth, availableHeight);
691
+ this.markDriftedInputs(dirty);
692
+ const changed = built.runtime.recompute();
693
+ return { reordered: pivot, changed };
694
+ }
695
+ /**
696
+ * Value relayout: re-`markDirty` only the input Fields of the dirty
697
+ * nodes (plus the root `available:*` inputs) whose value drifted,
698
+ * then `recompute()`. Returns the maximal subtree roots whose
699
+ * layout moved — for `finishMoved` to write back.
700
+ */
701
+ relayoutValues(dirty, availableWidth, availableHeight) {
702
+ this.applyAvailable(availableWidth, availableHeight);
703
+ this.markDriftedInputs(dirty);
704
+ return this.movedSubtreeRoots(this.built.runtime.recompute());
705
+ }
706
+ /**
707
+ * Re-`markDirty` only the input Fields of the dirty nodes (plus the
708
+ * root `available:*` inputs) whose live value drifted from the
709
+ * runtime's stored value. Shared by every relayout path — the value
710
+ * relayout and all three structural fast-paths.
711
+ *
712
+ * Iterates O(dirty inputs), not O(built.inputs) — every value
713
+ * mutation marks its owning node dirty, so non-dirty nodes can't
714
+ * have drifted inputs.
715
+ */
716
+ markDriftedInputs(dirty) {
717
+ const built = this.built;
718
+ const fields = [];
719
+ if (built.output.availableInputs.width !== undefined) {
720
+ fields.push(built.output.availableInputs.width);
721
+ }
722
+ if (built.output.availableInputs.height !== undefined) {
723
+ fields.push(built.output.availableInputs.height);
724
+ }
725
+ for (const n of dirty)
726
+ inputFieldsOf(built.output.styleInputs.get(n), fields);
727
+ const { runtime, output } = built;
728
+ for (const f of fields) {
729
+ // `styleInputs` can hold an input Field no rule reads (e.g. a
730
+ // flex-start container's main-END padding) — untracked, and a
731
+ // change to it cannot move any layout field. Skip it.
732
+ if (!runtime.isTracked(f))
733
+ continue;
734
+ const live = output.grammar.get(f).compute(NEVER_READ);
735
+ if (live !== runtime.evaluate(f))
736
+ runtime.markDirty(f);
737
+ }
738
+ }
739
+ /** Push new `available` values into the holder the grammar closes over. */
740
+ applyAvailable(availableWidth, availableHeight) {
741
+ const a = this.built.available;
742
+ if (availableWidth !== undefined)
743
+ a.width = availableWidth;
744
+ if (availableHeight !== undefined)
745
+ a.height = availableHeight;
746
+ }
747
+ /** Write-back + round + scroll the whole tree (after a build / graft). */
748
+ finishWhole() {
749
+ const { runtime, output } = this.built;
750
+ for (const f of output.allFields) {
751
+ writeNode(f.node, runtime, { width: f.width, height: f.height, left: f.left, top: f.top });
752
+ }
753
+ roundLayout(this.root);
754
+ recordScrollSizes(this.root);
755
+ clearDirtyDeep(this.root);
756
+ }
757
+ /**
758
+ * Reduce a set of changed Fields to the maximal moved-subtree roots.
759
+ * Used by the structural fast-paths to determine which subtrees need
760
+ * write-back after an incremental recompute.
761
+ */
762
+ movedSubtreeRoots(changed) {
763
+ const built = this.built;
764
+ const moved = new Set();
765
+ for (const f of changed) {
766
+ const n = built.owner.get(f);
767
+ if (n !== undefined)
768
+ moved.add(n);
769
+ }
770
+ const roots = [];
771
+ for (const n of moved) {
772
+ let maximal = true;
773
+ for (let p = n.getParent(); p !== null; p = p.getParent()) {
774
+ if (moved.has(p)) {
775
+ maximal = false;
776
+ break;
777
+ }
778
+ }
779
+ if (maximal)
780
+ roots.push(n);
781
+ }
782
+ return roots;
783
+ }
784
+ /**
785
+ * Write-back + round + scroll, scoped to the subtrees that moved.
786
+ * A moved subtree's parent did not move, so its rounding is stable
787
+ * and the subtree can be re-rounded in isolation; only that
788
+ * parent's own scroll extent then needs a recompute.
789
+ *
790
+ * `extraScrollParents` are additional nodes whose scroll extents
791
+ * must be recomputed — used by structural fast-paths to include
792
+ * the surviving parent of a removed or reordered subtree.
793
+ */
794
+ finishMoved(roots, extraScrollParents) {
795
+ const { runtime, fields } = this.built;
796
+ for (const root of roots) {
797
+ // Write the float layout for the whole moved subtree, so the
798
+ // re-round below has float values throughout.
799
+ const stack = [root];
800
+ while (stack.length > 0) {
801
+ const n = stack.pop();
802
+ // A `display: 'none'` node has no grammar fields — the
803
+ // emitter skips it (v29). Skip it (and its subtree) here too,
804
+ // mirroring `finishWhole`, which writes only `allFields`.
805
+ const f = fields.get(n);
806
+ if (f === undefined)
807
+ continue;
808
+ writeNode(n, runtime, f);
809
+ for (let i = 0; i < n.getChildCount(); i++)
810
+ stack.push(n.getChild(i));
811
+ }
812
+ const pos = ancestorPositions(root);
813
+ roundLayoutFrom(root, pos.floatX, pos.floatY, pos.roundedX, pos.roundedY);
814
+ recordScrollSizes(root);
815
+ }
816
+ // A moved root's parent did not move, so `recordScrollSizes`
817
+ // above never touched it — but one of its children's box did
818
+ // change, so its own scroll extent needs a recompute.
819
+ const scrollParents = new Set();
820
+ for (const root of roots) {
821
+ const p = root.getParent();
822
+ if (p !== null)
823
+ scrollParents.add(p);
824
+ }
825
+ for (const p of extraScrollParents)
826
+ scrollParents.add(p);
827
+ for (const p of scrollParents)
828
+ recomputeScroll(p);
829
+ }
830
+ }
831
+ /** Write one node's evaluated float layout into `_layout`. */
832
+ function writeNode(node, runtime, f) {
833
+ const left = runtime.evaluate(f.left);
834
+ const top = runtime.evaluate(f.top);
835
+ node._layout.left = left;
836
+ node._layout.top = top;
837
+ node._layout.width = runtime.evaluate(f.width);
838
+ node._layout.height = runtime.evaluate(f.height);
839
+ node._floatLeft = left;
840
+ node._floatTop = top;
841
+ }
842
+ /**
843
+ * The float and rounded absolute position of `node`'s parent — the
844
+ * origin `roundLayoutFrom` needs to re-round the `node` subtree. The
845
+ * ancestors did not move, so their `_floatLeft/Top` (float) and
846
+ * `_layout.left/top` (rounded) are still current.
847
+ */
848
+ function ancestorPositions(node) {
849
+ let floatX = 0;
850
+ let floatY = 0;
851
+ let roundedX = 0;
852
+ let roundedY = 0;
853
+ for (let a = node.getParent(); a !== null; a = a.getParent()) {
854
+ floatX += a._floatLeft;
855
+ floatY += a._floatTop;
856
+ roundedX += a._layout.left;
857
+ roundedY += a._layout.top;
858
+ }
859
+ return { floatX, floatY, roundedX, roundedY };
860
+ }
861
+ /**
862
+ * Post-order walk recording each node's content bounding box on
863
+ * `_layout.scrollWidth` / `scrollHeight`. Mirrors the scroll-extent
864
+ * half of `calculateLayoutImpl`'s `computeScrollSizes` — without the
865
+ * imperative layout-cache writes, which belong to the imperative
866
+ * path only.
867
+ */
868
+ function recordScrollSizes(node) {
869
+ for (let i = 0; i < node.getChildCount(); i++)
870
+ recordScrollSizes(node.getChild(i));
871
+ recomputeScroll(node);
872
+ }
873
+ /** Recompute one node's scroll extent from its direct children's boxes. */
874
+ function recomputeScroll(node) {
875
+ let contentRight = 0;
876
+ let contentBottom = 0;
877
+ for (let i = 0; i < node.getChildCount(); i++) {
878
+ const cl = node.getChild(i)._layout;
879
+ contentRight = Math.max(contentRight, cl.left + cl.width);
880
+ contentBottom = Math.max(contentBottom, cl.top + cl.height);
881
+ }
882
+ node._layout.scrollWidth = Math.max(node._layout.width, contentRight);
883
+ node._layout.scrollHeight = Math.max(node._layout.height, contentBottom);
884
+ }
885
+ /** True iff `candidate` is `root` or a descendant of `root`. */
886
+ function isInSubtree(candidate, root) {
887
+ for (let n = candidate; n !== null; n = n.getParent()) {
888
+ if (n === root)
889
+ return true;
890
+ }
891
+ return false;
892
+ }
893
+ //# sourceMappingURL=layout.js.map