@moku-labs/game 0.15.7 → 0.15.8

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.
@@ -3337,7 +3337,7 @@ function deliver$1(state, answer) {
3337
3337
  * @param ctx - Domain context of the flow plugin.
3338
3338
  * @param answer - The answer the closed gate refused.
3339
3339
  */
3340
- function hold(ctx, answer) {
3340
+ function hold$1(ctx, answer) {
3341
3341
  if (ctx.state.fx.mode === "fast") return;
3342
3342
  if (!ctx.deps.time.isRunning()) return;
3343
3343
  if (ctx.config.holdMs <= 0 && ctx.state.gate.held !== void 0) return;
@@ -3376,7 +3376,7 @@ function createGateApi(ctx) {
3376
3376
  return {
3377
3377
  answer: (answer) => {
3378
3378
  if (ctx.state.gate.open === void 0) {
3379
- hold(ctx, answer);
3379
+ hold$1(ctx, answer);
3380
3380
  return false;
3381
3381
  }
3382
3382
  if (!accepts(ctx.state.gate, answer)) return false;
@@ -5506,7 +5506,7 @@ function startDeadline(timeoutMs) {
5506
5506
  * @param running - The promise of `run()`.
5507
5507
  * @returns `true` when the loop settled, `false` when the deadline won and the loop still runs.
5508
5508
  */
5509
- async function settle$1(ctx, running) {
5509
+ async function settle$2(ctx, running) {
5510
5510
  const quiet = running.then(noop, noop);
5511
5511
  const deadline = startDeadline(ctx.config.settleTimeoutMs);
5512
5512
  try {
@@ -5535,7 +5535,7 @@ async function stopRunner(ctx) {
5535
5535
  stop.abort("stop");
5536
5536
  state.abort = stop;
5537
5537
  }
5538
- const settled = running === void 0 || await settle$1(ctx, running);
5538
+ const settled = running === void 0 || await settle$2(ctx, running);
5539
5539
  state.running = void 0;
5540
5540
  ctx.state.gate.held = void 0;
5541
5541
  if (settled) state.abort = void 0;
@@ -24372,7 +24372,7 @@ function destroyPools(state) {
24372
24372
  * view with `applyPose`.
24373
24373
  */
24374
24374
  /** How deep a parent chain is followed; deeper is treated as a loop. */
24375
- const MAX_DEPTH$5 = 32;
24375
+ const MAX_DEPTH$6 = 32;
24376
24376
  /**
24377
24377
  * The parent an entity moves with.
24378
24378
  *
@@ -24394,7 +24394,7 @@ function parentOf$1(ecs, entity) {
24394
24394
  */
24395
24395
  function isLeaving(ecs, entity) {
24396
24396
  let current = entity;
24397
- for (let depth = 0; depth < MAX_DEPTH$5 && current !== 0; depth += 1) {
24397
+ for (let depth = 0; depth < MAX_DEPTH$6 && current !== 0; depth += 1) {
24398
24398
  if (ecs.has(current, Exiting)) return true;
24399
24399
  current = parentOf$1(ecs, current);
24400
24400
  }
@@ -24500,7 +24500,7 @@ function rootPoseInto(ecs, entity, out, own) {
24500
24500
  out.pivot.x = start.pivot.x;
24501
24501
  out.pivot.y = start.pivot.y;
24502
24502
  let current = entity;
24503
- for (let depth = 0; depth < MAX_DEPTH$5; depth += 1) {
24503
+ for (let depth = 0; depth < MAX_DEPTH$6; depth += 1) {
24504
24504
  const parent = parentOf$1(ecs, current);
24505
24505
  if (parent === 0) break;
24506
24506
  const above = ecs.get(parent, Transform) ?? Transform.defaults;
@@ -24841,7 +24841,7 @@ const RETAKE_STREAK = 3;
24841
24841
  /** Frames in a row with no mark in it before a live range is kept as a picture again (spec 23's `CALM_AFTER`). */
24842
24842
  const CALM_FRAMES = 8;
24843
24843
  /** How deep a walk up the tree goes. */
24844
- const MAX_DEPTH$4 = 32;
24844
+ const MAX_DEPTH$5 = 32;
24845
24845
  /** CSS px a view may fall short of the viewport frame and still cover it: the reference frame rounds by a fraction. */
24846
24846
  const COVER_SLACK = 1;
24847
24847
  /** The whole-frame reasons after which a kept picture is taken again: the screen changed whole. */
@@ -25005,7 +25005,7 @@ function sameList$1(left, right) {
25005
25005
  */
25006
25006
  function inRange(set, node, root) {
25007
25007
  let current = node;
25008
- for (let depth = 0; depth < MAX_DEPTH$4 && current !== null && current !== root; depth += 1) {
25008
+ for (let depth = 0; depth < MAX_DEPTH$5 && current !== null && current !== root; depth += 1) {
25009
25009
  if (set.has(current)) return true;
25010
25010
  current = current.parent;
25011
25011
  }
@@ -27963,7 +27963,7 @@ function installPassCounting(pixi, sinks) {
27963
27963
  //#endregion
27964
27964
  //#region src/plugins/renderer/sync/hit-test.ts
27965
27965
  /** How deep a parent chain or a container tree is followed. */
27966
- const MAX_DEPTH$3 = 32;
27966
+ const MAX_DEPTH$4 = 32;
27967
27967
  /** Below this effective alpha a view is treated as not there. */
27968
27968
  const MIN_ALPHA = .01;
27969
27969
  /**
@@ -28045,7 +28045,7 @@ function clipBoxOf(sctx, entity) {
28045
28045
  function clippedOut(sctx, entity, x, y) {
28046
28046
  const ecs = sctx.ctx.deps.world.ecs;
28047
28047
  let current = parentOf$1(ecs, entity);
28048
- for (let depth = 0; depth < MAX_DEPTH$3 && current !== 0; depth += 1) {
28048
+ for (let depth = 0; depth < MAX_DEPTH$4 && current !== 0; depth += 1) {
28049
28049
  const box = clipBoxOf(sctx, current);
28050
28050
  if (box !== void 0 && !inBox(box, localPoint(sctx, current, x, y))) return true;
28051
28051
  current = parentOf$1(ecs, current);
@@ -28064,7 +28064,7 @@ function clippedOut(sctx, entity, x, y) {
28064
28064
  function effectiveAlpha(object, root, off) {
28065
28065
  let alpha = 1;
28066
28066
  let current = object;
28067
- for (let depth = 0; depth < MAX_DEPTH$3 && current !== void 0; depth += 1) {
28067
+ for (let depth = 0; depth < MAX_DEPTH$4 && current !== void 0; depth += 1) {
28068
28068
  if (current === root) return alpha;
28069
28069
  if (!current.visible || off?.has(current) === true) return 0;
28070
28070
  alpha *= current.alpha;
@@ -28094,7 +28094,7 @@ function isShown$1(object, root, off) {
28094
28094
  * @returns The same array.
28095
28095
  */
28096
28096
  function topFirst(container, out, depth = 0) {
28097
- if (depth >= MAX_DEPTH$3) return out;
28097
+ if (depth >= MAX_DEPTH$4) return out;
28098
28098
  const ordered = container.children.toSorted((left, right) => left.zIndex - right.zIndex);
28099
28099
  for (let index = ordered.length - 1; index >= 0; index -= 1) {
28100
28100
  const child = ordered[index];
@@ -28183,7 +28183,7 @@ function hitAll(sctx, x, y) {
28183
28183
  * hit test walks, so a box the doors report is the box a click reaches.
28184
28184
  */
28185
28185
  /** How deep a parent chain is followed; deeper is treated as a loop. */
28186
- const MAX_DEPTH$2 = 32;
28186
+ const MAX_DEPTH$3 = 32;
28187
28187
  /**
28188
28188
  * The four corners of a box, clockwise from the top-left one.
28189
28189
  *
@@ -28277,7 +28277,7 @@ function boundsOf(sctx, entity) {
28277
28277
  function layerOf(sctx, entity) {
28278
28278
  const ecs = sctx.ctx.deps.world.ecs;
28279
28279
  let top = entity;
28280
- for (let depth = 0; depth < MAX_DEPTH$2; depth += 1) {
28280
+ for (let depth = 0; depth < MAX_DEPTH$3; depth += 1) {
28281
28281
  const parent = parentOf$1(ecs, top);
28282
28282
  if (parent === 0) break;
28283
28283
  top = parent;
@@ -28692,7 +28692,7 @@ function stepParts(sctx) {
28692
28692
  //#endregion
28693
28693
  //#region src/plugins/renderer/sync/overlay.ts
28694
28694
  /** How deep a `Parent` chain or a walk up the tree goes. */
28695
- const MAX_DEPTH$1 = 32;
28695
+ const MAX_DEPTH$2 = 32;
28696
28696
  /** The owner `ui` gives the entities it spawns: the only overlays honoured (O4.8). */
28697
28697
  const UI_OWNER_NAME = "ui";
28698
28698
  /**
@@ -28761,7 +28761,7 @@ function clampInto(sctx, entity, into, raw) {
28761
28761
  function rootOf(sctx, entity) {
28762
28762
  const ecs = sctx.ctx.deps.world.ecs;
28763
28763
  let current = entity;
28764
- for (let depth = 0; depth < MAX_DEPTH$1; depth += 1) {
28764
+ for (let depth = 0; depth < MAX_DEPTH$2; depth += 1) {
28765
28765
  const parent = parentOf$1(ecs, current);
28766
28766
  if (parent === 0) return current;
28767
28767
  current = parent;
@@ -28974,7 +28974,7 @@ function drawnAlpha(sctx, record) {
28974
28974
  const alphaFilter = sctx.deps.host.pixi()?.AlphaFilter;
28975
28975
  let alpha = 1;
28976
28976
  let node = view.wrapper ?? view.object;
28977
- for (let depth = 0; depth < MAX_DEPTH$1 && node !== null; depth += 1) {
28977
+ for (let depth = 0; depth < MAX_DEPTH$2 && node !== null; depth += 1) {
28978
28978
  if (node === root) return alpha;
28979
28979
  if (!node.visible) return 0;
28980
28980
  const owner = state.viewOfTop.get(node);
@@ -36162,7 +36162,7 @@ async function settledRecord(state, bundle) {
36162
36162
  * @param size - The size of the decoded image.
36163
36163
  * @param mips - True for a texture uploaded with its full mip chain.
36164
36164
  */
36165
- function resize(target, size, mips) {
36165
+ function resize$1(target, size, mips) {
36166
36166
  if (target === void 0 || size === void 0) return;
36167
36167
  target.width = size.width;
36168
36168
  target.height = size.height;
@@ -36184,7 +36184,7 @@ function storeFont(io, record, owner, font, sizes) {
36184
36184
  const old = record.fonts.get(file.key);
36185
36185
  record.fonts.set(file.key, font);
36186
36186
  for (const page of old?.pages ?? []) io.destroyTexture(page);
36187
- for (const [at, texture] of font.pages.entries()) resize(pages[at], sizes.get(texture), false);
36187
+ for (const [at, texture] of font.pages.entries()) resize$1(pages[at], sizes.get(texture), false);
36188
36188
  file.mb = sumMb(pages);
36189
36189
  }
36190
36190
  /**
@@ -36209,7 +36209,7 @@ function store(io, record, fresh, sizes) {
36209
36209
  const old = record.textures.get(file.key);
36210
36210
  record.textures.set(file.key, fresh.texture);
36211
36211
  if (old !== void 0) io.destroyTexture(old);
36212
- resize(file, sizes.get(fresh.texture), file.mips === true);
36212
+ resize$1(file, sizes.get(fresh.texture), file.mips === true);
36213
36213
  }
36214
36214
  /**
36215
36215
  * Makes the numbers and the picture follow the files that were stored: the megabytes of the
@@ -41376,7 +41376,7 @@ function finish(ectx, run, definition, passed) {
41376
41376
  * @param definition - The definition.
41377
41377
  * @param messages - What the compiler said.
41378
41378
  */
41379
- function settle(ectx, run, definition, messages) {
41379
+ function settle$1(ectx, run, definition, messages) {
41380
41380
  const { checks } = ectx.state;
41381
41381
  if (checks.run !== run || checks.status.get(definition) !== "pending") return;
41382
41382
  const errors = messages.filter((message) => message.type === "error");
@@ -41402,7 +41402,7 @@ function checkWgsl(ectx, run, definition) {
41402
41402
  finish(ectx, run, definition, true);
41403
41403
  return;
41404
41404
  }
41405
- device.createShaderModule({ code: definition.source }).getCompilationInfo().then((info) => settle(ectx, run, definition, info.messages), () => settle(ectx, run, definition, []));
41405
+ device.createShaderModule({ code: definition.source }).getCompilationInfo().then((info) => settle$1(ectx, run, definition, info.messages), () => settle$1(ectx, run, definition, []));
41406
41406
  }
41407
41407
  /**
41408
41408
  * Reads the errors of a WebGL info log, one per `ERROR: 0:<line>: <message>` line; a summary line
@@ -43032,7 +43032,7 @@ const EFFECTS_OWNER = Object.freeze({
43032
43032
  name: "effects"
43033
43033
  });
43034
43034
  /** How deep a parent chain is followed, as `rootPoseOf` does; deeper is treated as a loop. */
43035
- const MAX_DEPTH = 32;
43035
+ const MAX_DEPTH$1 = 32;
43036
43036
  /**
43037
43037
  * The one dynamic set of an emitter container (P9): positions, frames, rotations and colours
43038
43038
  * upload every frame; `uvs` stays static, because a frame is set at birth and the same `update()`
@@ -43075,7 +43075,7 @@ function bornFields(size) {
43075
43075
  */
43076
43076
  function topOf(ecs, host) {
43077
43077
  let top = host;
43078
- for (let depth = 0; depth < MAX_DEPTH; depth += 1) {
43078
+ for (let depth = 0; depth < MAX_DEPTH$1; depth += 1) {
43079
43079
  const parent = parentOf$1(ecs, top);
43080
43080
  if (parent === 0) break;
43081
43081
  top = parent;
@@ -45182,6 +45182,10 @@ function submit(ctx, entity, answer) {
45182
45182
  }
45183
45183
  //#endregion
45184
45184
  //#region src/plugins/input/components.ts
45185
+ const scrollableDefaults = {
45186
+ axis: "y",
45187
+ moving: false
45188
+ };
45185
45189
  /**
45186
45190
  * Fresh defaults for a component that names an intent. A function, so each component type gets
45187
45191
  * its own payload object instead of sharing one.
@@ -45281,6 +45285,13 @@ const Pressed = /*#__PURE__*/ tag("Pressed");
45281
45285
  */
45282
45286
  const PointerOver = /*#__PURE__*/ tag("PointerOver");
45283
45287
  /**
45288
+ * On every ui scroll container, internal: ui writes it, input reads it, a game never sees it. A
45289
+ * press that leaves the tap slop along `axis` goes from the pressed view to the innermost scroll
45290
+ * of that axis the view hangs in by its `Parent` chain; a finger that lands on a view inside a
45291
+ * scroll that is `moving` stops it and answers nothing.
45292
+ */
45293
+ const Scrollable = /*#__PURE__*/ component("Scrollable", scrollableDefaults);
45294
+ /**
45284
45295
  * Where the one pointer is, in reference units. Written once per frame by the frame step. `id`
45285
45296
  * names the pointer it follows, `-1` at rest.
45286
45297
  */
@@ -45290,8 +45301,12 @@ const Pointer = /*#__PURE__*/ resource("Pointer", pointerDefaults);
45290
45301
  /**
45291
45302
  * @file input plugin — who is under the finger. Every lookup goes through `renderer.sync.hitTest`,
45292
45303
  * which answers `undefined` without a DOM, and every accept function refuses a view that is
45293
- * already playing its exit. A trace takes a cell only inside a circle smaller than its box.
45304
+ * already playing its exit. A trace takes a cell only inside a circle smaller than its box. The
45305
+ * scroll a press belongs to is found up the pressed view's own `Parent` chain, never under the
45306
+ * point: a view drawn over a scroll from another root keeps its press.
45294
45307
  */
45308
+ /** How deep a `Parent` chain is followed; deeper is treated as a loop. */
45309
+ const MAX_DEPTH = 32;
45295
45310
  /**
45296
45311
  * Resolves what `app.input.*` was pointed at. A `{ projection, key }` goes through
45297
45312
  * `world.projection.entityOf`, which answers for live views only, so a view in the despawn queue
@@ -45374,6 +45389,44 @@ function findPressed(ctx, x, y) {
45374
45389
  return ctx.deps.renderer.sync.hitTest(x, y, (entity) => acceptPress(ctx, entity));
45375
45390
  }
45376
45391
  /**
45392
+ * The innermost scroll a view sits in that moves now: the view itself or the nearest ancestor of
45393
+ * its `Parent` chain that carries a `Scrollable` with `moving` true and is not on its way out. A
45394
+ * finger that lands on the view stops that scroll. A view of another root drawn over the scroll,
45395
+ * a HUD button or a popup, hangs in no chain of it, so it keeps its press. Reads only, allocates
45396
+ * nothing.
45397
+ *
45398
+ * @param ctx - Domain context of the input plugin.
45399
+ * @param entity - The view the hit test found under the finger.
45400
+ * @returns The scroll, or `undefined` when no scroll the view sits in moves.
45401
+ */
45402
+ function movingScrollOf(ctx, entity) {
45403
+ const { ecs } = ctx.deps.world;
45404
+ let current = entity;
45405
+ for (let depth = 0; depth < MAX_DEPTH && current !== 0; depth += 1) {
45406
+ if (ecs.get(current, Scrollable)?.moving === true && !ecs.has(current, Exiting)) return current;
45407
+ current = parentOf$1(ecs, current);
45408
+ }
45409
+ }
45410
+ /**
45411
+ * The innermost scroll of an axis a pressed view sits in: the view itself or the nearest ancestor
45412
+ * of its `Parent` chain that carries a `Scrollable` of that axis and is not on its way out. A list
45413
+ * under a modal is in no chain of the modal's button, so a drift on that button never pans it.
45414
+ * Reads only, allocates nothing.
45415
+ *
45416
+ * @param ctx - Domain context of the input plugin.
45417
+ * @param entity - The pressed view.
45418
+ * @param axis - The dominant axis of the pan.
45419
+ * @returns The scroll, or `undefined` when the view sits in no scroll of that axis.
45420
+ */
45421
+ function scrollOnAxisOf(ctx, entity, axis) {
45422
+ const { ecs } = ctx.deps.world;
45423
+ let current = entity;
45424
+ for (let depth = 0; depth < MAX_DEPTH && current !== 0; depth += 1) {
45425
+ if (ecs.get(current, Scrollable)?.axis === axis && !ecs.has(current, Exiting)) return current;
45426
+ current = parentOf$1(ecs, current);
45427
+ }
45428
+ }
45429
+ /**
45377
45430
  * The topmost drop target at a point.
45378
45431
  *
45379
45432
  * @param ctx - Domain context of the input plugin.
@@ -46397,6 +46450,20 @@ function direction(from, to) {
46397
46450
  return dy >= 0 ? "down" : "up";
46398
46451
  }
46399
46452
  /**
46453
+ * The axis a pan moves on: the dominant one, horizontal on an exact diagonal.
46454
+ *
46455
+ * @param from - Where the press started.
46456
+ * @param to - Where the finger is.
46457
+ * @returns The axis of the larger move.
46458
+ * @example
46459
+ * ```ts
46460
+ * axisOf({ x: 0, y: 0 }, { x: 20, y: -5 }); // "x"
46461
+ * ```
46462
+ */
46463
+ function axisOf$1(from, to) {
46464
+ return Math.abs(to.x - from.x) >= Math.abs(to.y - from.y) ? "x" : "y";
46465
+ }
46466
+ /**
46400
46467
  * Takes or releases the pointer capture of the canvas. A pointer that is already gone — a down
46401
46468
  * and an up inside one frame — cannot be captured; the browser throws and the gesture goes on.
46402
46469
  *
@@ -46415,7 +46482,7 @@ function capture(ctx, on) {
46415
46482
  }
46416
46483
  /**
46417
46484
  * Ends the gesture: the press tag goes, the capture is released and the machine forgets the
46418
- * pointer, whether it moved, the entity and its key.
46485
+ * pointer, whether it moved or caught a scroll, the entity and its key.
46419
46486
  *
46420
46487
  * @param ctx - Domain context of the input plugin.
46421
46488
  */
@@ -46426,16 +46493,30 @@ function toIdle(ctx) {
46426
46493
  ctx.state.phase = "idle";
46427
46494
  ctx.state.pointerId = void 0;
46428
46495
  ctx.state.moved = false;
46496
+ ctx.state.caught = false;
46429
46497
  ctx.state.entity = void 0;
46430
46498
  ctx.state.key = void 0;
46431
46499
  ctx.state.pressedMs = 0;
46432
46500
  }
46433
46501
  /**
46502
+ * Makes a view the pressed one: it is remembered with its projection key and tagged `Pressed`.
46503
+ *
46504
+ * @param ctx - Domain context of the input plugin.
46505
+ * @param entity - The view that takes the press.
46506
+ */
46507
+ function press(ctx, entity) {
46508
+ ctx.state.entity = entity;
46509
+ ctx.state.key = ctx.deps.world.projection.keyOf(entity);
46510
+ ctx.deps.world.ecs.tag(entity, Pressed);
46511
+ }
46512
+ /**
46434
46513
  * Takes the pointer and looks for the view under it. The pointer is taken even where no view is.
46435
46514
  * While the finger rests (`rests`) the next finger that goes down takes the pointer over and this
46436
46515
  * press is cancelled; once it starts a gesture — a drag, a move past the tap slop, a long press, a
46437
- * trace — a second finger stays ignored. A view that carries `Traceable` starts a trace instead of
46438
- * a press: it is not tagged `Pressed`.
46516
+ * trace — a second finger stays ignored. When the view under the finger sits in a moving scroll,
46517
+ * that scroll takes the press: the finger stops it, and the press is caught. A view drawn over a
46518
+ * moving scroll from another root, a HUD or a popup button, keeps its press. A view that carries
46519
+ * `Traceable` starts a trace instead of a press: it is not tagged `Pressed`.
46439
46520
  *
46440
46521
  * @param ctx - Domain context of the input plugin.
46441
46522
  * @param pointer - The `Pointer` resource of this frame.
@@ -46443,9 +46524,10 @@ function toIdle(ctx) {
46443
46524
  * @param sample - The raw sample.
46444
46525
  */
46445
46526
  function onDown(ctx, pointer, point, sample) {
46446
- const { ecs, projection } = ctx.deps.world;
46527
+ const { ecs } = ctx.deps.world;
46447
46528
  ctx.state.pointerId = sample.pointerId;
46448
46529
  ctx.state.moved = false;
46530
+ ctx.state.caught = false;
46449
46531
  ctx.state.start = {
46450
46532
  x: point.x,
46451
46533
  y: point.y
@@ -46457,16 +46539,42 @@ function onDown(ctx, pointer, point, sample) {
46457
46539
  capture(ctx, true);
46458
46540
  const entity = findPressed(ctx, point.x, point.y);
46459
46541
  if (entity === void 0) return;
46542
+ const scroll = movingScrollOf(ctx, entity);
46543
+ if (scroll !== void 0) {
46544
+ ctx.state.caught = true;
46545
+ press(ctx, scroll);
46546
+ return;
46547
+ }
46460
46548
  if (ecs.has(entity, Traceable)) {
46461
46549
  beginTrace(ctx, entity, point);
46462
46550
  return;
46463
46551
  }
46464
- ctx.state.entity = entity;
46465
- ctx.state.key = projection.keyOf(entity);
46466
- ecs.tag(entity, Pressed);
46552
+ press(ctx, entity);
46467
46553
  if (ecs.has(entity, Draggable) && ecs.has(entity, Swipeable)) ctx.log.warn("input: Draggable wins over Swipeable", { view: ctx.state.key });
46468
46554
  }
46469
46555
  /**
46556
+ * The press just left the tap slop: it goes to the scroll it pans, the innermost `Scrollable` of
46557
+ * the pressed view's own `Parent` chain (the view itself included) that moves on the dominant axis
46558
+ * of the move. The view loses `Pressed`, and the press is marked caught: it is a scroll gesture
46559
+ * now, so its release answers nothing, also when the finger came back inside the slop. A
46560
+ * draggable or swipeable view keeps its gesture, a caught press keeps its scroll, and a view that
46561
+ * sits in no scroll of that axis keeps its press, also a popup button drawn over a list.
46562
+ *
46563
+ * @param ctx - Domain context of the input plugin.
46564
+ * @param point - Where the finger is, in reference units.
46565
+ */
46566
+ function handOff(ctx, point) {
46567
+ const { caught, entity, start } = ctx.state;
46568
+ const { ecs } = ctx.deps.world;
46569
+ if (entity === void 0 || caught) return;
46570
+ if (ecs.has(entity, Draggable) || ecs.has(entity, Swipeable)) return;
46571
+ const scroll = scrollOnAxisOf(ctx, entity, axisOf$1(start, point));
46572
+ if (scroll === void 0 || scroll === entity) return;
46573
+ ecs.untag(entity, Pressed);
46574
+ ctx.state.caught = true;
46575
+ press(ctx, scroll);
46576
+ }
46577
+ /**
46470
46578
  * Turns a press on a draggable view into a drag once the finger left the start by more than
46471
46579
  * `dragStartPx`. A draggable view with no projection key cannot be carried: `mute`, `lift` and
46472
46580
  * `settle` belong to a projection, so the gesture is reported and given up.
@@ -46492,13 +46600,15 @@ function onMove(ctx, point) {
46492
46600
  * Answers the release of a press: a tap when the finger stayed inside the slop, a swipe when it
46493
46601
  * went far enough and fast enough. Neither: nothing happened, and nothing has to be undone. Every
46494
46602
  * release inside the slop runs the `onTap` listeners first, so a view that carries only
46495
- * `Touchable` is heard although it answers nothing.
46603
+ * `Touchable` is heard although it answers nothing. A caught press belongs to a scroll — it
46604
+ * stopped a moving one or was handed to one — and answers nothing and runs no listener.
46496
46605
  *
46497
46606
  * @param ctx - Domain context of the input plugin.
46498
46607
  * @param entity - The pressed view.
46499
46608
  * @param point - Where the finger let go, in reference units.
46500
46609
  */
46501
46610
  function answerRelease(ctx, entity, point) {
46611
+ if (ctx.state.caught) return;
46502
46612
  const { ecs } = ctx.deps.world;
46503
46613
  const travelled = distance(ctx.state.start, point);
46504
46614
  if (travelled <= ctx.config.tapSlopPx) {
@@ -46565,13 +46675,24 @@ function rests(ctx) {
46565
46675
  return ctx.state.phase === "pressed" && !ctx.state.moved;
46566
46676
  }
46567
46677
  /**
46678
+ * Tells whether a sample takes the press past the tap slop for the first time: the press has not
46679
+ * moved yet and the finger is farther than `tapSlopPx` from the start.
46680
+ *
46681
+ * @param ctx - Domain context of the input plugin.
46682
+ * @param point - Where the finger is, in reference units.
46683
+ * @returns True in the one sample that turns the press into a moved one.
46684
+ */
46685
+ function leavesSlop(ctx, point) {
46686
+ return ctx.state.phase === "pressed" && !ctx.state.moved && distance(ctx.state.start, point) > ctx.config.tapSlopPx;
46687
+ }
46688
+ /**
46568
46689
  * Feeds one raw sample to the machine. A leave does nothing here. A sample of another pointer is
46569
46690
  * dropped while a pointer is followed: the second finger is ignored. One exception: a press while
46570
46691
  * the followed pointer rests takes the pointer over and cancels the resting press, so a resting
46571
46692
  * thumb or palm never blocks the finger that plays. A move of an idle mouse or pen hit-tests
46572
46693
  * nothing: its point is returned, so the drain moves the hover once per frame. A move of a press
46573
- * past the tap slop marks the press as moved for good. A lost capture ends the gesture like a
46574
- * cancel.
46694
+ * past the tap slop marks the press as moved for good and, in that one sample, hands it to the
46695
+ * scroll it pans. A lost capture ends the gesture like a cancel.
46575
46696
  *
46576
46697
  * @param ctx - Domain context of the input plugin.
46577
46698
  * @param pointer - The `Pointer` resource of this frame.
@@ -46594,7 +46715,10 @@ function handleSample(ctx, pointer, sample) {
46594
46715
  break;
46595
46716
  case "move":
46596
46717
  if (isHover(ctx, pointer, sample)) return point;
46597
- if (ctx.state.phase === "pressed" && !ctx.state.moved && distance(ctx.state.start, point) > ctx.config.tapSlopPx) ctx.state.moved = true;
46718
+ if (leavesSlop(ctx, point)) {
46719
+ ctx.state.moved = true;
46720
+ handOff(ctx, point);
46721
+ }
46598
46722
  if (ctx.state.phase === "tracing") extendTrace(ctx, point);
46599
46723
  else onMove(ctx, point);
46600
46724
  break;
@@ -46767,6 +46891,7 @@ function createInputState(_ctx) {
46767
46891
  phase: "idle",
46768
46892
  pointerId: void 0,
46769
46893
  moved: false,
46894
+ caught: false,
46770
46895
  entity: void 0,
46771
46896
  key: void 0,
46772
46897
  start: {
@@ -50176,6 +50301,31 @@ const Scroll = /*#__PURE__*/ component("Scroll", {
50176
50301
  min: 0
50177
50302
  });
50178
50303
  /**
50304
+ * On every scroll container: where it stands and whether it moves, for a game system and the
50305
+ * read door. `ui` writes it in a frame where the offset or `max` changed, and once more with
50306
+ * `moving: false` when it stops; a game reads it and never writes it.
50307
+ *
50308
+ * @example
50309
+ * ```ts
50310
+ * // A board system dims the events of the order strip while it rests on the orders.
50311
+ * export const stripMist = system({
50312
+ * name: "board.stripMist",
50313
+ * phase: "animate",
50314
+ * query: [ScrollState, OrderStrip],
50315
+ * run: entities => {
50316
+ * for (const [entity, scroll] of entities) {
50317
+ * // scroll: { offset: 318, max: 390, moving: false }
50318
+ * }
50319
+ * }
50320
+ * });
50321
+ * ```
50322
+ */
50323
+ const ScrollState = /*#__PURE__*/ component("ScrollState", {
50324
+ offset: 0,
50325
+ max: 0,
50326
+ moving: false
50327
+ });
50328
+ /**
50179
50329
  * On the root entity of a popup kept beneath another popup (`popup(..., { over })`). Internal to
50180
50330
  * `ui`: not exported from the package root. Every element of that root resolves `is.covered`.
50181
50331
  */
@@ -51908,8 +52058,9 @@ function windowFormOf(node) {
51908
52058
  }
51909
52059
  /**
51910
52060
  * Refuses the props of a scroll its windowed form cannot take: some but not all of `rows`,
51911
- * `rowHeight` and `row`, or a row height that is not above 0. Called before anything is spawned or
51912
- * patched, so a refused scroll leaves no half-made element.
52061
+ * `rowHeight` and `row`, a row height that is not above 0, or `axis: "x"`, since the rows of a
52062
+ * window stand one below the other. Called before anything is spawned or patched, so a refused
52063
+ * scroll leaves no half-made element.
51913
52064
  *
51914
52065
  * @param node - The node being placed.
51915
52066
  * @throws {Error} For a windowed scroll whose props do not hold.
@@ -51919,7 +52070,50 @@ function windowFormOf(node) {
51919
52070
  * ```
51920
52071
  */
51921
52072
  function checkWindow(node) {
51922
- if (node.type === "scroll") windowFormOf(node);
52073
+ if (node.type !== "scroll") return;
52074
+ if (windowFormOf(node) !== void 0 && axisOf(node) === "x") throw new Error("[game] A windowed scroll moves on y only.\n Drop axis \"x\" or use the children form.");
52075
+ }
52076
+ /**
52077
+ * The axis a scroll moves on: `"x"` when its props say so, else `"y"`.
52078
+ *
52079
+ * @param node - The node of a scroll.
52080
+ * @returns The axis.
52081
+ * @example
52082
+ * ```ts
52083
+ * axisOf({ type: "scroll", props: { axis: "x" }, children: [] }); // "x"
52084
+ * ```
52085
+ */
52086
+ function axisOf(node) {
52087
+ return node.props.axis === "x" ? "x" : "y";
52088
+ }
52089
+ /**
52090
+ * The motion record a scroll spawns with: its axis, still at the start, not placed yet. The
52091
+ * first solve puts it at its `offset` prop.
52092
+ *
52093
+ * @param node - The node of the scroll that enters.
52094
+ * @returns A new record.
52095
+ * @example
52096
+ * ```ts
52097
+ * scrollMotionOf({ type: "scroll", props: { axis: "x" }, children: [] }).mode; // "still"
52098
+ * ```
52099
+ */
52100
+ function scrollMotionOf(node) {
52101
+ return {
52102
+ axis: axisOf(node),
52103
+ mode: "still",
52104
+ offset: 0,
52105
+ max: 0,
52106
+ velocity: 0,
52107
+ from: 0,
52108
+ to: 0,
52109
+ duration: 0,
52110
+ elapsed: 0,
52111
+ pointer: 0,
52112
+ start: 0,
52113
+ seen: 0,
52114
+ placed: false,
52115
+ atProp: false
52116
+ };
51923
52117
  }
51924
52118
  /**
51925
52119
  * Builds a spacer: an empty node of a fixed height that never shrinks.
@@ -54345,15 +54539,23 @@ const VISUALS = [
54345
54539
  ];
54346
54540
  /**
54347
54541
  * The input components of an element and the Escape marker; a disabled or covered button drops
54348
- * the answering one and the marker.
54542
+ * the answering one and the marker. A scroll's three hold its runtime state.
54349
54543
  */
54350
54544
  const INPUTS = [
54351
54545
  Tappable,
54352
54546
  Touchable,
54353
54547
  LocalWrite,
54354
54548
  Scroll,
54549
+ Scrollable,
54550
+ ScrollState,
54355
54551
  Escapable
54356
54552
  ];
54553
+ /** The components of a scroll that hold where it stands: added once, never patched by a render. */
54554
+ const SCROLL_STATE = namesOf$1([
54555
+ Scroll,
54556
+ Scrollable,
54557
+ ScrollState
54558
+ ]);
54357
54559
  /**
54358
54560
  * One list of the visual and the input types. Its own function, because lint rule L5 refuses a
54359
54561
  * collection built in a module-scope declaration.
@@ -54368,6 +54570,14 @@ function concatTypes(...lists) {
54368
54570
  const LIVE_TYPES = concatTypes(VISUALS, INPUTS);
54369
54571
  /** The owned fields of a type that gives none to a plugin: one list, never a new one. */
54370
54572
  const NO_OWNED = [];
54573
+ /** The time of a reconcile a test steps by hand: no ms pass, so no scroll moves. */
54574
+ const NO_TIME = Object.freeze({
54575
+ delta: 0,
54576
+ elapsed: 0,
54577
+ scale: 1,
54578
+ frame: 0,
54579
+ idle: false
54580
+ });
54371
54581
  /**
54372
54582
  * Tells whether a list of component values holds one of a type.
54373
54583
  *
@@ -54408,6 +54618,11 @@ const ELEMENT_OWNED = namesOf$1([
54408
54618
  ]);
54409
54619
  /** The style of a scroll's content: one object, so a render never reads as a new style. */
54410
54620
  const CONTENT_STYLE = Object.freeze({ origin: "topLeft" });
54621
+ /** The style of an x scroll's content: its children stand side by side. */
54622
+ const CONTENT_ROW_STYLE = Object.freeze({
54623
+ origin: "topLeft",
54624
+ direction: "row"
54625
+ });
54411
54626
  /**
54412
54627
  * The props, besides `style`, `state` and `content`, that a live element is written from: `bind`,
54413
54628
  * `texture`, `name` and `fit` reach its visual, `intent`, `payload`, `local` and `escape` its
@@ -54445,23 +54660,24 @@ function writesSame(previous, next) {
54445
54660
  /**
54446
54661
  * The children one element is diffed against. A scroll container holds exactly one of them: the
54447
54662
  * content the finger moves, which keeps the rects of its own children and carries them along. Its
54448
- * pivot is its top-left corner, so the scroll step writes its offset straight into `y`; it lays
54449
- * its children out as a column. The content of a windowed scroll holds the slots of its window,
54450
- * the two spacers and the rows, instead of the markup's children.
54663
+ * pivot is its top-left corner, so the scroll step writes its offset straight into `x` or `y`; it
54664
+ * lays its children out as a column, or as a row on the `x` axis. The content of a windowed scroll
54665
+ * holds the slots of its window, the two spacers and the rows, instead of the markup's children.
54451
54666
  *
54452
54667
  * @param node - The node the markup wrote.
54453
54668
  * @param slots - The slots of a windowed scroll, top spacer to bottom spacer.
54669
+ * @param axis - The axis of a scroll, the one it spawned with.
54454
54670
  * @returns The children of the element.
54455
54671
  * @example
54456
54672
  * ```ts
54457
54673
  * childrenOf({ type: "scroll", props: {}, children: [] }).length; // 1
54458
54674
  * ```
54459
54675
  */
54460
- function childrenOf(node, slots) {
54676
+ function childrenOf(node, slots, axis = "y") {
54461
54677
  if (node.type !== "scroll") return node.children;
54462
54678
  return [{
54463
54679
  type: CONTENT$1,
54464
- props: { style: CONTENT_STYLE },
54680
+ props: { style: axis === "x" ? CONTENT_ROW_STYLE : CONTENT_STYLE },
54465
54681
  children: slots ?? node.children
54466
54682
  }];
54467
54683
  }
@@ -54540,16 +54756,15 @@ function styleOf(node) {
54540
54756
  return typeof style === "object" && style !== null ? style : void 0;
54541
54757
  }
54542
54758
  /**
54543
- * Refuses the tags that cannot be placed: a text field that names no local field, a horizontal
54544
- * scroll, and a windowed scroll whose props do not hold.
54759
+ * Refuses the tags that cannot be placed: a text field that names no local field, and a windowed
54760
+ * scroll whose props do not hold.
54545
54761
  *
54546
54762
  * @param node - The node being placed.
54547
- * @throws {Error} For an `input` tag without `local`, for a horizontal scroll and for a windowed
54548
- * scroll whose props do not hold.
54763
+ * @throws {Error} For an `input` tag without `local`, and for a windowed scroll whose props do
54764
+ * not hold.
54549
54765
  */
54550
54766
  function checkTag(node) {
54551
54767
  if (node.type === "input" && typeof node.props.local !== "string") throw new Error("[game] An input needs a local field.\n Write <input local=\"name\" />.");
54552
- if (node.type === "scroll" && node.props.axis === "x") throw new Error("[game] Horizontal scroll arrives after V3.\n Use axis \"y\".");
54553
54768
  checkWindow(node);
54554
54769
  }
54555
54770
  /**
@@ -54572,16 +54787,28 @@ function answerOf(node) {
54572
54787
  /**
54573
54788
  * The input components of an element: a button answers the gate, writes local state, or only
54574
54789
  * swallows the tap; a panel swallows the tap too, so nothing under it answers; a text field takes
54575
- * the tap that starts its editing; a scroll container takes the press that moves its content. A button of a covered popup answers nothing,
54576
- * so only the top popup answers the gate. A button with the `escape` prop that answers also
54577
- * carries `Escapable`, the control the Escape key taps.
54790
+ * the tap that starts its editing; a scroll container takes the press that moves its content and
54791
+ * carries where it stands, on the axis it spawned with. A button of a covered popup answers
54792
+ * nothing, so only the top popup answers the gate. A button with the `escape` prop that answers
54793
+ * also carries `Escapable`, the control the Escape key taps.
54578
54794
  *
54579
54795
  * @param element - The element to make touchable.
54580
54796
  * @returns The component values.
54581
54797
  */
54582
54798
  function inputOf(element) {
54583
54799
  const { type, node, is } = element;
54584
- if (type === "scroll") return [Touchable(), Scroll({ axis: "y" })];
54800
+ if (type === "scroll") {
54801
+ const axis = element.scroll?.axis ?? "y";
54802
+ return [
54803
+ Touchable(),
54804
+ Scroll({ axis }),
54805
+ Scrollable({
54806
+ axis,
54807
+ moving: false
54808
+ }),
54809
+ ScrollState()
54810
+ ];
54811
+ }
54585
54812
  if (type === "panel" || type === "input") return [Touchable()];
54586
54813
  if (type !== "button") return [];
54587
54814
  if (is.disabled || is.covered) return [Touchable()];
@@ -54800,6 +55027,7 @@ function createReconciler(ctx, modules, fields) {
54800
55027
  let slotsVersion = -1;
54801
55028
  let slotsRoots = -1;
54802
55029
  let placedUnder;
55030
+ let rendered = 0;
54803
55031
  /**
54804
55032
  * Registers the key of an element under its root, so `entityOf` and `find` answer for it. A key
54805
55033
  * `ui` gave a node of its own, a spacer's, is never registered.
@@ -54896,13 +55124,16 @@ function createReconciler(ctx, modules, fields) {
54896
55124
  trackHost(state, element);
54897
55125
  registerKey(root, element);
54898
55126
  if (element.type === "input") fields.enter(element);
54899
- if (element.type === "scroll") state.scrolls.add(entity);
55127
+ if (element.type === "scroll") {
55128
+ state.scrolls.add(entity);
55129
+ element.scroll = scrollMotionOf(node);
55130
+ }
54900
55131
  modules.layout.attach(element);
54901
55132
  element.basis = {
54902
55133
  epoch: state.epoch,
54903
55134
  rowHeight: range.rowHeightOf(parent, node)
54904
55135
  };
54905
- diffChildren(root, element, childrenOf(node, range.slotsFor(element)), instance);
55136
+ diffChildren(root, element, childrenOf(node, range.slotsFor(element), element.scroll?.axis), instance);
54906
55137
  root.needsSolve = true;
54907
55138
  return entity;
54908
55139
  }
@@ -55039,11 +55270,12 @@ function createReconciler(ctx, modules, fields) {
55039
55270
  /**
55040
55271
  * Writes the visual and input components of a live element again. A visual or an input the
55041
55272
  * element no longer carries is removed, so a variant can trade the rectangle for a nine-slice and
55042
- * back, and a button that became disabled stops answering the gate. `Scroll` is only ever added:
55043
- * its offset belongs to the finger, and a new look or a new rect never resets it. The look
55044
- * written is recorded as the rest of the visual, so the rest the world holds is always the one
55045
- * the element is drawn with: a restore brings it back to exactly that. The extras of the
55046
- * `components` prop are diffed last. Every call is one write pass of `UiCounters`.
55273
+ * back, and a button that became disabled stops answering the gate. `Scroll`, `Scrollable` and
55274
+ * `ScrollState` are only ever added: where a scroll stands belongs to the finger and the scroll
55275
+ * step, and a new look or a new rect never resets it. The look written is recorded as the rest
55276
+ * of the visual, so the rest the world holds is always the one the element is drawn with: a
55277
+ * restore brings it back to exactly that. The extras of the `components` prop are diffed last.
55278
+ * Every call is one write pass of `UiCounters`.
55047
55279
  *
55048
55280
  * @param element - The element that changed.
55049
55281
  */
@@ -55060,7 +55292,8 @@ function createReconciler(ctx, modules, fields) {
55060
55292
  }
55061
55293
  /**
55062
55294
  * Writes one visual or input value onto a live element: added when the entity lacks it, else
55063
- * its fields patched, but never a tag or `Scroll`, and never a field its type gives to a plugin.
55295
+ * its fields patched, but never a tag or the state of a scroll, and never a field its type gives
55296
+ * to a plugin.
55064
55297
  *
55065
55298
  * @param element - The live element.
55066
55299
  * @param value - The value of this render.
@@ -55070,7 +55303,7 @@ function createReconciler(ctx, modules, fields) {
55070
55303
  ecs.add(element.entity, value);
55071
55304
  return;
55072
55305
  }
55073
- if (value.value === true || value.type === Scroll) return;
55306
+ if (value.value === true || SCROLL_STATE.has(value.type.componentName)) return;
55074
55307
  const owned = ecs.typeOf(value.type.componentName)?.owned ?? NO_OWNED;
55075
55308
  ecs.set(element.entity, asHandle(value.type), livePatch(value.value, owned));
55076
55309
  }
@@ -55149,6 +55382,7 @@ function createReconciler(ctx, modules, fields) {
55149
55382
  */
55150
55383
  function patch(root, element, node, instance) {
55151
55384
  checkWindow(node);
55385
+ warnAxis(element, node);
55152
55386
  const change = compare(root, element, node);
55153
55387
  const motion = node.props.motion;
55154
55388
  const loopChanged = !sameLoop(element.motion?.loop, motion?.loop);
@@ -55169,7 +55403,22 @@ function createReconciler(ctx, modules, fields) {
55169
55403
  rowHeight: change.rowHeight
55170
55404
  };
55171
55405
  }
55172
- diffChildren(root, element, childrenOf(node, range.slotsFor(element)), instance);
55406
+ diffChildren(root, element, childrenOf(node, range.slotsFor(element), element.scroll?.axis), instance);
55407
+ }
55408
+ /**
55409
+ * Warns once when a render gives a live scroll another `axis`: it keeps the one it spawned with.
55410
+ *
55411
+ * @param element - The element being patched.
55412
+ * @param node - The node of this render.
55413
+ */
55414
+ function warnAxis(element, node) {
55415
+ const axis = element.scroll?.axis;
55416
+ if (axis === void 0 || axis === axisOf(node) || element.warned.has("scroll-axis-fixed")) return;
55417
+ element.warned.add("scroll-axis-fixed");
55418
+ ctx.log.warn("ui:scroll-axis-fixed", {
55419
+ key: element.key ?? element.identity,
55420
+ axis
55421
+ });
55173
55422
  }
55174
55423
  /**
55175
55424
  * Counts a patch that brings a loop of another reference that is not really another loop: the
@@ -55826,8 +56075,10 @@ function createReconciler(ctx, modules, fields) {
55826
56075
  * again, the roots a scene placed again, the popups that may leave, the pull of the text being
55827
56076
  * typed, the scroll offsets and the windowed ranges, the bound texts that show a string of
55828
56077
  * another size, the viewport, the dirty roots and the hosted views.
56078
+ *
56079
+ * @param time - The frame's `Time`: the scrolls move by its `delta`.
55829
56080
  */
55830
- function reconcile() {
56081
+ function reconcile(time = NO_TIME) {
55831
56082
  sweep();
55832
56083
  state.reconciles += 1;
55833
56084
  if (state.remount) remountRoots();
@@ -55835,13 +56086,14 @@ function createReconciler(ctx, modules, fields) {
55835
56086
  settlePopups(ctx, state, unmountRoot);
55836
56087
  fields.pull();
55837
56088
  const containers = scrollContainers();
55838
- modules.layout.scroll(containers, lookup);
56089
+ modules.layout.scroll(containers, lookup, time);
55839
56090
  range.step(containers);
55840
56091
  markResizedTexts(ctx, state, modules.layout);
55841
56092
  markResizedIcons(ctx, state, modules.layout);
55842
56093
  const viewportChanged = modules.styles.useViewport(ctx.deps.renderer.viewport.size());
55843
56094
  if (viewportChanged) state.epoch += 1;
55844
56095
  const count = dirtyRoots(viewportChanged);
56096
+ rendered = count;
55845
56097
  for (let index = 0; index < count; index += 1) {
55846
56098
  const root = dirtyList[index];
55847
56099
  if (root !== void 0) reconcileDirtyRoot(root);
@@ -55867,16 +56119,22 @@ function createReconciler(ctx, modules, fields) {
55867
56119
  /**
55868
56120
  * The second system of phase `layout`: every root takes its slot, then one solve per marked
55869
56121
  * root, a root in a slot after the root of its slot and in its content box, then the rects, the
55870
- * rest poses and the hooks, and the hosting of the hosts that came. The hints released since the last frame step are dropped at its end, so
55871
- * the `change.Box` and `change.Transform` hooks the solve plays get the hint the reconcile
55872
- * routed, and the next frame starts with none.
56122
+ * rest poses and the hooks, the scrolls settled on what the solve and the render gave them, and
56123
+ * the hosting of the hosts that came. The hints released since the last frame step are dropped
56124
+ * at its end, so the `change.Box` and `change.Transform` hooks the solve plays get the hint the
56125
+ * reconcile routed, and the next frame starts with none.
56126
+ *
56127
+ * @param time - The frame's `Time`: a scroll left moving asks for a frame only while it runs.
55873
56128
  */
55874
- function solve() {
56129
+ function solve(time = NO_TIME) {
55875
56130
  const viewport = modules.styles.viewport();
55876
56131
  viewportBox.w = viewport?.width ?? 0;
55877
56132
  viewportBox.h = viewport?.height ?? 0;
55878
56133
  assignSlots();
55879
- if (layOutRoots(viewportBox)) ctx.deps.time.wake();
56134
+ const changed = layOutRoots(viewportBox);
56135
+ if (changed) ctx.deps.time.wake();
56136
+ if (changed || rendered > 0) modules.layout.settleScroll(scrollContainers(), lookup, time);
56137
+ rendered = 0;
55880
56138
  settleOverlays(ctx, state, modules.layout);
55881
56139
  if (state.risen.size > 0) {
55882
56140
  hostViews(ctx, state, state.risen);
@@ -56053,7 +56311,8 @@ function sceneLayersOf(ctx) {
56053
56311
  *
56054
56312
  * @param ctx - Domain context of the ui plugin.
56055
56313
  * @param modules - The styles and layout modules, injected in that order.
56056
- * @returns The module: `tree`, `find` and `lint` reach the game, the rest the frame.
56314
+ * @returns The module: `tree`, `find`, `lint`, `fill` and `scroll` reach the game, the rest the
56315
+ * frame.
56057
56316
  */
56058
56317
  function createJsxApi(ctx, modules) {
56059
56318
  const state = ctx.state.jsx;
@@ -56070,6 +56329,7 @@ function createJsxApi(ctx, modules) {
56070
56329
  const frame = createReconciler(ctx, modules, fields);
56071
56330
  const focus = createFocus(ctx, frame.markPointer, topRoot, fields);
56072
56331
  const resolvedOf = (entity) => ctx.deps.world.ecs.get(entity, Text)?.resolved;
56332
+ const lookup = (entity) => state.elements.get(entity);
56073
56333
  const find = (key) => {
56074
56334
  for (const root of sortedRoots(state, layerNames())) {
56075
56335
  const entity = state.byKey.get(root.entity)?.get(key);
@@ -56090,12 +56350,25 @@ function createJsxApi(ctx, modules) {
56090
56350
  fields.fill(entity, value);
56091
56351
  return true;
56092
56352
  },
56353
+ scroll: (key, to) => {
56354
+ const entity = find(key);
56355
+ const element = entity === void 0 ? void 0 : lookup(entity);
56356
+ if (element?.scroll === void 0) {
56357
+ ctx.log.warn("ui:scroll-without-scroll", { key });
56358
+ return false;
56359
+ }
56360
+ if (!modules.layout.scrollTo(element, to, lookup)) {
56361
+ ctx.log.warn("ui:scroll-before-solve", { key });
56362
+ return false;
56363
+ }
56364
+ return true;
56365
+ },
56093
56366
  open: fields.open,
56094
56367
  pointer: (sample) => fields.pointer(sample),
56095
56368
  tapped: (entity) => focus.tapped(entity),
56096
56369
  reconcile: frame.reconcile,
56097
- solve: () => {
56098
- frame.solve();
56370
+ solve: (time) => {
56371
+ frame.solve(time);
56099
56372
  focus.refresh();
56100
56373
  fields.place();
56101
56374
  },
@@ -57200,25 +57473,63 @@ function createPopupHandler(ctx, state, jsx) {
57200
57473
  //#endregion
57201
57474
  //#region src/plugins/ui/layout/scroll.ts
57202
57475
  /**
57203
- * @file ui/layout — vertical scroll: while the container is pressed the content moves with the
57204
- * finger through one `Transform` write. No solve, no motion — the children keep their rects. A
57205
- * windowed scroll knows its content height from `rows × rowHeight`, so its range is kept even
57206
- * when no finger is on it.
57476
+ * @file ui/layout — scroll. A scroll container moves its content on its axis: with the finger,
57477
+ * then in a fling that slows down, or in a glide to a `snap` stop; it starts at its `offset`
57478
+ * prop, glides when the prop changes, and stays inside the range every solve gives it, with hard
57479
+ * edges. The content moves by one `Transform` write on the axis: no solve, no motion, the children
57480
+ * keep their rects. `Scroll` holds the offset the way the windowed range is cut with (0 and below),
57481
+ * `ScrollState` the way a game reads it (0 to `max`), `Scrollable` whether it moves, for `input`.
57482
+ * The exact numbers live on the element's `scroll` record and the samples of the press in the
57483
+ * layout state, written in place; the components get whole reference units. A frame allocates
57484
+ * nothing.
57207
57485
  */
57486
+ /** How far ahead a release carries its velocity before the nearest `snap` stop is taken, in ms. */
57487
+ const PROJECT_MS = 260;
57488
+ /** What a fling keeps of its velocity per ms. */
57489
+ const DECAY_PER_MS = .998;
57490
+ /** The speed under which a fling stops, in reference units per ms. */
57491
+ const STOP_SPEED = .02;
57492
+ /** The shortest glide, in ms. */
57493
+ const GLIDE_MS = 260;
57494
+ /** The ms each reference unit of distance adds to a glide. */
57495
+ const GLIDE_MS_PER_UNIT = 1.1;
57496
+ /** The longest glide, in ms. */
57497
+ const GLIDE_MAX_MS = 620;
57498
+ /** The samples of the last ms before a release give its velocity. */
57499
+ const VELOCITY_MS = 100;
57500
+ /** A finger that stood still for longer than this before its release lets go with no velocity, in ms. */
57501
+ const STILL_MS = 50;
57502
+ /** The numbers of one sample in the ring: where the finger was on the axis, and when. */
57503
+ const SAMPLE_STRIDE = 2;
57504
+ /** The fewest samples a release velocity is read from. */
57505
+ const MIN_SAMPLES = 2;
57208
57506
  /**
57209
- * Keeps an offset inside the scrollable range: never below the end of the content, never above
57210
- * the top.
57507
+ * Keeps an offset from the start inside 0 to `max`.
57211
57508
  *
57212
- * @param offset - Where the content would go.
57213
- * @param min - The most negative offset, which is viewport height minus content height.
57214
- * @returns The offset the content is written with.
57509
+ * @param offset - Where the content would stand, from the start.
57510
+ * @param max - The largest offset.
57511
+ * @returns The offset it stands at.
57215
57512
  * @example
57216
57513
  * ```ts
57217
- * clampOffset(40, -300); // 0
57514
+ * within(420, 390); // 390
57218
57515
  * ```
57219
57516
  */
57220
- function clampOffset(offset, min) {
57221
- return Math.min(0, Math.max(min, offset));
57517
+ function within(offset, max) {
57518
+ return Math.min(max, Math.max(0, offset));
57519
+ }
57520
+ /**
57521
+ * How far a length runs past a window, in whole reference units: 0 when it fits.
57522
+ *
57523
+ * @param length - What is scrolled.
57524
+ * @param window - What shows of it.
57525
+ * @returns The largest offset.
57526
+ * @example
57527
+ * ```ts
57528
+ * extent(696, 306); // 390
57529
+ * ```
57530
+ */
57531
+ function extent(length, window) {
57532
+ return Math.max(0, Math.round(length - window));
57222
57533
  }
57223
57534
  /**
57224
57535
  * The content entity of a scroll container: its single child.
@@ -57232,106 +57543,491 @@ function contentOf(container, lookup) {
57232
57543
  return first === void 0 ? void 0 : lookup(first);
57233
57544
  }
57234
57545
  /**
57235
- * The height of what a container scrolls: `rows × rowHeight` for a windowed scroll, which equals
57236
- * its solved content rect, else the solved content rect.
57546
+ * The largest offset of a scroll on its axis: its content's rect past its own, or
57547
+ * `rows × rowHeight` past its height for a windowed scroll.
57237
57548
  *
57238
- * @param container - The scroll element.
57549
+ * @param container - The scroll element, with a solved rect.
57239
57550
  * @param content - Its content element.
57240
- * @returns The height in reference units.
57551
+ * @param axis - The axis it moves on.
57552
+ * @returns The largest offset, in whole reference units.
57241
57553
  */
57242
- function contentHeightOf(container, content) {
57554
+ function maxOf(container, content, axis) {
57555
+ if (axis === "x") return extent(content.rect.w, container.rect.w);
57243
57556
  const window = container.window;
57244
- return window === void 0 ? content.rect.h : window.rows * window.rowHeight;
57557
+ return extent(window === void 0 ? content.rect.h : window.rows * window.rowHeight, container.rect.h);
57558
+ }
57559
+ /**
57560
+ * The `offset` prop of the scroll's last render: 0 when it is left out or is no finite number.
57561
+ *
57562
+ * @param container - The scroll element.
57563
+ * @returns The offset it asks for, from the start.
57564
+ */
57565
+ function askedOffsetOf(container) {
57566
+ const offset = container.node.props.offset;
57567
+ return typeof offset === "number" && Number.isFinite(offset) ? offset : 0;
57568
+ }
57569
+ /**
57570
+ * The `snap` prop of the scroll's last render.
57571
+ *
57572
+ * @param container - The scroll element.
57573
+ * @returns The stops, or `undefined` when it names none.
57574
+ */
57575
+ function snapOf(container) {
57576
+ const snap = container.node.props.snap;
57577
+ return Array.isArray(snap) && snap.length > 0 ? snap : void 0;
57245
57578
  }
57246
57579
  /**
57247
- * Writes an offset and the range it was clamped to, and moves the content there.
57580
+ * The stop nearest to an offset, each stop kept inside 0 to `max`; the first of two as near.
57581
+ *
57582
+ * @param snap - The stops the scroll names.
57583
+ * @param offset - Where the release would carry it.
57584
+ * @param max - The largest offset.
57585
+ * @param fallback - Where it stays when no stop is a number.
57586
+ * @returns The stop.
57587
+ * @example
57588
+ * ```ts
57589
+ * nearestStop([0, 318, 390], 300, 390, 300); // 318
57590
+ * ```
57591
+ */
57592
+ function nearestStop(snap, offset, max, fallback) {
57593
+ let nearest = fallback;
57594
+ let distance = Number.POSITIVE_INFINITY;
57595
+ for (const candidate of snap) {
57596
+ if (typeof candidate !== "number") continue;
57597
+ const kept = within(candidate, max);
57598
+ if (Math.abs(kept - offset) < distance) {
57599
+ nearest = kept;
57600
+ distance = Math.abs(kept - offset);
57601
+ }
57602
+ }
57603
+ return nearest;
57604
+ }
57605
+ /**
57606
+ * Stops a scroll where it stands.
57607
+ *
57608
+ * @param motion - Its motion.
57609
+ */
57610
+ function stop(motion) {
57611
+ motion.mode = "still";
57612
+ motion.velocity = 0;
57613
+ }
57614
+ /**
57615
+ * Starts a glide to an offset, kept inside the range, on the curve `1 − (1 − p)³` over
57616
+ * `min(620, 260 + distance × 1.1)` ms. A scroll already there stops instead.
57617
+ *
57618
+ * @param motion - Its motion.
57619
+ * @param target - Where it goes, from the start.
57620
+ */
57621
+ function glideTo(motion, target) {
57622
+ const to = within(target, motion.max);
57623
+ const distance = Math.abs(to - motion.offset);
57624
+ if (distance === 0) {
57625
+ stop(motion);
57626
+ return;
57627
+ }
57628
+ motion.mode = "glide";
57629
+ motion.velocity = 0;
57630
+ motion.from = motion.offset;
57631
+ motion.to = to;
57632
+ motion.elapsed = 0;
57633
+ motion.duration = Math.min(GLIDE_MAX_MS, GLIDE_MS + distance * GLIDE_MS_PER_UNIT);
57634
+ }
57635
+ /**
57636
+ * Where a sample of the press starts in the ring: `back` samples before the newest one, 0 being
57637
+ * the newest. The position is at the index, the time right after it.
57638
+ *
57639
+ * @param state - The layout state, which keeps the samples of the press.
57640
+ * @param back - How many samples before the newest; 0 to the number of slots minus 1.
57641
+ * @returns The index of the sample in `scrollRing`.
57642
+ */
57643
+ function slotBefore(state, back) {
57644
+ const slots = state.scrollRing.length / SAMPLE_STRIDE;
57645
+ return (state.scrollHead + slots - 1 - back) % slots * SAMPLE_STRIDE;
57646
+ }
57647
+ /**
57648
+ * Follows the finger on a held scroll. The first frame of a press, a hand-off included, takes the
57649
+ * finger where it is as the start, stops a fling or a glide where it stands, and ends the scroll's
57650
+ * standing at its `offset` prop; the offset then moves against the finger, inside 0 to `max`.
57651
+ * Every frame adds the finger's sample to the ring.
57652
+ *
57653
+ * @param state - The layout state, which keeps the samples of the press.
57654
+ * @param container - The held scroll.
57655
+ * @param motion - Its motion.
57656
+ * @param pointer - The `Pointer` resource of this frame.
57657
+ */
57658
+ function hold(state, container, motion, pointer) {
57659
+ const at = motion.axis === "x" ? pointer.x : pointer.y;
57660
+ const ring = state.scrollRing;
57661
+ const slots = ring.length / SAMPLE_STRIDE;
57662
+ if (motion.mode !== "held" || state.scrolling !== container.entity) {
57663
+ motion.mode = "held";
57664
+ motion.velocity = 0;
57665
+ motion.pointer = at;
57666
+ motion.start = motion.offset;
57667
+ motion.atProp = false;
57668
+ state.scrolling = container.entity;
57669
+ state.scrollSamples = 0;
57670
+ state.scrollMoved = state.scrollClock;
57671
+ }
57672
+ motion.offset = within(motion.start - (at - motion.pointer), motion.max);
57673
+ if (state.scrollSamples > 0 && ring[slotBefore(state, 0)] !== at) state.scrollMoved = state.scrollClock;
57674
+ ring[state.scrollHead * SAMPLE_STRIDE] = at;
57675
+ ring[state.scrollHead * SAMPLE_STRIDE + 1] = state.scrollClock;
57676
+ state.scrollHead = (state.scrollHead + 1) % slots;
57677
+ state.scrollSamples = Math.min(slots, state.scrollSamples + 1);
57678
+ }
57679
+ /**
57680
+ * The velocity of the offset at a release, in units a ms, positive toward `max`: the finger's
57681
+ * move over its samples of the last 100 ms, against the finger. 0 when the finger stood still for
57682
+ * more than 50 ms before it let go, or left a single sample.
57683
+ *
57684
+ * @param state - The layout state, which keeps the samples of the press.
57685
+ * @returns The velocity.
57686
+ */
57687
+ function releaseVelocity(state) {
57688
+ const ring = state.scrollRing;
57689
+ const now = state.scrollClock;
57690
+ if (state.scrollSamples < MIN_SAMPLES || now - state.scrollMoved > STILL_MS) return 0;
57691
+ const newest = slotBefore(state, 0);
57692
+ let oldest = newest;
57693
+ for (let back = 1; back < state.scrollSamples; back += 1) {
57694
+ const slot = slotBefore(state, back);
57695
+ if ((ring[slot + 1] ?? 0) < now - VELOCITY_MS) break;
57696
+ oldest = slot;
57697
+ }
57698
+ const span = (ring[newest + 1] ?? 0) - (ring[oldest + 1] ?? 0);
57699
+ return span > 0 ? ((ring[oldest] ?? 0) - (ring[newest] ?? 0)) / span : 0;
57700
+ }
57701
+ /**
57702
+ * Lets go of a scroll the finger held. With `snap` it glides to the stop nearest to where its
57703
+ * velocity carries in 260 ms; without, it flings, or stands when it was let go too slowly. A
57704
+ * press whose samples a later press took lets go with no velocity.
57705
+ *
57706
+ * @param state - The layout state, which keeps the samples of the press.
57707
+ * @param container - The scroll that was held.
57708
+ * @param motion - Its motion.
57709
+ * @param owned - Whether the samples are this press's.
57710
+ */
57711
+ function letGo(state, container, motion, owned) {
57712
+ const velocity = owned ? releaseVelocity(state) : 0;
57713
+ const snap = snapOf(container);
57714
+ if (snap !== void 0) {
57715
+ glideTo(motion, nearestStop(snap, motion.offset + velocity * PROJECT_MS, motion.max, motion.offset));
57716
+ return;
57717
+ }
57718
+ if (Math.abs(velocity) < STOP_SPEED) {
57719
+ stop(motion);
57720
+ return;
57721
+ }
57722
+ motion.mode = "fling";
57723
+ motion.velocity = velocity;
57724
+ }
57725
+ /**
57726
+ * Moves a flinging scroll by the frame: the offset by its velocity, the velocity down by 0.998 a
57727
+ * ms. It stops under 0.02 units a ms, or on an edge: the edges are hard and nothing passes the
57728
+ * rest of the fling to a parent.
57729
+ *
57730
+ * @param motion - Its motion.
57731
+ * @param time - The frame's `Time`.
57732
+ */
57733
+ function fling(motion, time) {
57734
+ const delta = time.delta;
57735
+ const next = motion.offset + motion.velocity * delta;
57736
+ motion.offset = within(next, motion.max);
57737
+ motion.velocity *= DECAY_PER_MS ** delta;
57738
+ if (motion.offset !== next || Math.abs(motion.velocity) < STOP_SPEED) stop(motion);
57739
+ }
57740
+ /**
57741
+ * Moves a gliding scroll by the frame along `1 − (1 − p)³`; at its end it stands on its target.
57742
+ *
57743
+ * @param motion - Its motion.
57744
+ * @param time - The frame's `Time`.
57745
+ */
57746
+ function glide(motion, time) {
57747
+ motion.elapsed += time.delta;
57748
+ const progress = Math.min(1, motion.elapsed / motion.duration);
57749
+ if (progress === 1) {
57750
+ motion.offset = motion.to;
57751
+ stop(motion);
57752
+ return;
57753
+ }
57754
+ const left = 1 - progress;
57755
+ motion.offset = motion.from + (motion.to - motion.from) * (1 - left * left * left);
57756
+ }
57757
+ /**
57758
+ * Moves the content to a whole offset on the scroll's axis, from where the solve put it: one
57759
+ * `Transform` write, none when it stands there already. The rects of the solve are whole numbers
57760
+ * that Yoga hands out as doubles; rounded, the pose is a small integer, which `Object.assign`
57761
+ * copies into the world without a box.
57248
57762
  *
57249
57763
  * @param ctx - Domain context of the ui plugin.
57764
+ * @param state - The layout state, which keeps the patches.
57250
57765
  * @param container - The scroll element.
57251
57766
  * @param content - Its content element.
57252
- * @param scroll - What its `Scroll` holds now.
57767
+ * @param motion - Its motion.
57253
57768
  */
57254
- function moveContent(ctx, container, content, scroll) {
57769
+ function moveContent(ctx, state, container, content, motion) {
57255
57770
  const ecs = ctx.deps.world.ecs;
57256
- ecs.set(container.entity, Scroll, {
57257
- offset: scroll.offset,
57258
- min: scroll.min
57259
- });
57260
- ecs.set(content.entity, Transform, { y: content.rect.y - container.rect.y + scroll.offset });
57771
+ const pose = ecs.get(content.entity, Transform);
57772
+ const offset = Math.round(motion.offset);
57773
+ if (pose === void 0) return;
57774
+ if (motion.axis === "x") {
57775
+ const x = Math.round(content.rect.x - container.rect.x) - offset;
57776
+ if (pose.x === x) return;
57777
+ state.scrollPatches.x.x = x;
57778
+ ecs.set(content.entity, Transform, state.scrollPatches.x);
57779
+ return;
57780
+ }
57781
+ const y = Math.round(content.rect.y - container.rect.y) - offset;
57782
+ if (pose.y === y) return;
57783
+ state.scrollPatches.y.y = y;
57784
+ ecs.set(content.entity, Transform, state.scrollPatches.y);
57261
57785
  }
57262
57786
  /**
57263
- * Keeps the offset of a scroll inside the range its content height gives, and writes that range,
57264
- * when either moved: a windowed list whose rows shrank, or its first solve.
57787
+ * Writes what changed of a scroll, in whole units: `Scroll` when its offset or range moved,
57788
+ * `ScrollState` when the offset, `max` or `moving` changed, `Scrollable` when `moving` did, and
57789
+ * the content's pose. A frame where nothing changed writes nothing.
57265
57790
  *
57266
57791
  * @param ctx - Domain context of the ui plugin.
57267
- * @param container - The scroll element, with a solved rect.
57792
+ * @param state - The layout state, which keeps the patches.
57793
+ * @param container - The scroll element.
57268
57794
  * @param content - Its content element.
57269
- * @param contentHeight - The height of what it scrolls.
57270
- * @returns The offset the content stands at.
57271
- */
57272
- function keepInRange(ctx, container, content, contentHeight) {
57273
- const scroll = ctx.deps.world.ecs.get(container.entity, Scroll);
57274
- if (scroll === void 0) return 0;
57275
- const min = Math.min(0, container.rect.h - contentHeight);
57276
- const offset = clampOffset(scroll.offset, min);
57277
- if (offset !== scroll.offset || min !== scroll.min) moveContent(ctx, container, content, {
57278
- ...scroll,
57279
- offset,
57280
- min
57281
- });
57282
- return offset;
57795
+ * @param motion - Its motion.
57796
+ */
57797
+ function show(ctx, state, container, content, motion) {
57798
+ const ecs = ctx.deps.world.ecs;
57799
+ const patches = state.scrollPatches;
57800
+ const offset = Math.round(motion.offset);
57801
+ const moving = motion.mode !== "still";
57802
+ const scroll = ecs.get(container.entity, Scroll);
57803
+ const shown = ecs.get(container.entity, ScrollState);
57804
+ const scrollable = ecs.get(container.entity, Scrollable);
57805
+ const rangeMoved = scroll !== void 0 && (scroll.offset !== 0 - offset || scroll.min !== 0 - motion.max);
57806
+ const stateMoved = shown !== void 0 && (shown.offset !== offset || shown.max !== motion.max || shown.moving !== moving);
57807
+ if (rangeMoved) {
57808
+ patches.scroll.offset = 0 - offset;
57809
+ patches.scroll.min = 0 - motion.max;
57810
+ ecs.set(container.entity, Scroll, patches.scroll);
57811
+ }
57812
+ if (stateMoved) {
57813
+ patches.state.offset = offset;
57814
+ patches.state.max = motion.max;
57815
+ patches.state.moving = moving;
57816
+ ecs.set(container.entity, ScrollState, patches.state);
57817
+ }
57818
+ if (scrollable !== void 0 && scrollable.moving !== moving) {
57819
+ patches.scrollable.moving = moving;
57820
+ ecs.set(container.entity, Scrollable, patches.scrollable);
57821
+ }
57822
+ moveContent(ctx, state, container, content, motion);
57283
57823
  }
57284
57824
  /**
57285
- * Re-clamps a live windowed scroll to the content height of its new rows before its range is
57286
- * cut, so a list that shrank below the window moves up in the same frame.
57825
+ * Tells whether a scroll has nothing to do this frame: its first solve has not placed it yet, or
57826
+ * it stands still and no finger is on it.
57827
+ *
57828
+ * @param motion - Its motion.
57829
+ * @param pressed - Whether a finger is on it.
57830
+ * @returns True when the frame leaves it alone.
57831
+ */
57832
+ function isIdle(motion, pressed) {
57833
+ return !motion.placed || !pressed && motion.mode === "still";
57834
+ }
57835
+ /**
57836
+ * Moves one scroll by the frame: the finger while it is pressed, the release when it lets go,
57837
+ * then a fling or a glide, and writes what changed. A still scroll no finger is on costs a tag
57838
+ * check.
57287
57839
  *
57288
57840
  * @param ctx - Domain context of the ui plugin.
57841
+ * @param state - The layout state.
57289
57842
  * @param container - The live scroll element.
57290
- * @param contentHeight - `rows × rowHeight` of this render.
57291
57843
  * @param lookup - How a child entity becomes its element.
57292
- * @returns The offset the content stands at.
57844
+ * @param pointer - The `Pointer` resource of this frame.
57845
+ * @param time - The frame's `Time`.
57846
+ * @returns True while the scroll moves.
57293
57847
  */
57294
- function clampScroll(ctx, container, contentHeight, lookup) {
57848
+ function stepOne(ctx, state, container, lookup, pointer, time) {
57849
+ const ecs = ctx.deps.world.ecs;
57850
+ const pressed = ecs.has(container.entity, Pressed);
57851
+ const released = !pressed && state.scrolling === container.entity;
57852
+ if (released) state.scrolling = void 0;
57853
+ const motion = container.scroll;
57854
+ if (motion === void 0 || isIdle(motion, pressed)) return false;
57295
57855
  const content = contentOf(container, lookup);
57296
- if (content === void 0) return ctx.deps.world.ecs.get(container.entity, Scroll)?.offset ?? 0;
57297
- return keepInRange(ctx, container, content, contentHeight);
57856
+ if (content === void 0 || !ecs.has(container.entity, Scroll)) return false;
57857
+ if (pressed) hold(state, container, motion, pointer);
57858
+ else if (motion.mode === "held") letGo(state, container, motion, released);
57859
+ if (motion.mode === "fling") fling(motion, time);
57860
+ else if (motion.mode === "glide") glide(motion, time);
57861
+ show(ctx, state, container, content, motion);
57862
+ return motion.mode !== "still";
57298
57863
  }
57299
57864
  /**
57300
- * Moves the content of every pressed scroll container with the finger. A windowed container no
57301
- * finger is on keeps its offset inside the range of its rows.
57865
+ * Moves every scroll container by the frame: with the finger while it is pressed, then in a
57866
+ * fling or a glide after it lets go. Time is the frame's `delta`, so a time scale and a pause
57867
+ * apply. While a scroll moves, `time.wake()` keeps the frames coming; at a time scale of 0 no
57868
+ * frame is asked for: a fling or a glide stands where it is and goes on once time runs again,
57869
+ * and a finger's samples wake the loop themselves.
57302
57870
  *
57303
57871
  * @param ctx - Domain context of the ui plugin.
57304
- * @param state - The layout state, which remembers where the drag started.
57872
+ * @param state - The layout state, which keeps the samples of the press.
57305
57873
  * @param containers - The live scroll elements.
57306
57874
  * @param lookup - How a child entity becomes its element.
57875
+ * @param time - The frame's `Time`.
57876
+ */
57877
+ function stepScroll(ctx, state, containers, lookup, time) {
57878
+ const pointer = ctx.deps.world.ecs.resource(Pointer);
57879
+ let moving = false;
57880
+ state.scrollClock += time.delta;
57881
+ for (const container of containers) if (stepOne(ctx, state, container, lookup, pointer, time)) moving = true;
57882
+ if (moving && time.delta > 0) ctx.deps.time.wake();
57883
+ }
57884
+ /**
57885
+ * Takes a new range: the offset keeps inside it at once, and so does a running glide. A scroll
57886
+ * that follows its `offset` prop goes on following it in the new range: standing, it stands at
57887
+ * the prop at once; gliding to it, it glides on to it from where it is. So a prop the first range
57888
+ * was too short for is reached once the content grows, a text measured again when its font
57889
+ * arrives.
57890
+ *
57891
+ * @param motion - The motion of the scroll.
57892
+ * @param max - The largest offset the solve gives it now.
57893
+ */
57894
+ function resize(motion, max) {
57895
+ if (max === motion.max) return;
57896
+ motion.max = max;
57897
+ motion.offset = within(motion.offset, max);
57898
+ motion.from = within(motion.from, max);
57899
+ motion.to = within(motion.to, max);
57900
+ if (!motion.atProp) return;
57901
+ if (motion.mode === "glide") glideTo(motion, motion.seen);
57902
+ else motion.offset = within(motion.seen, max);
57903
+ }
57904
+ /**
57905
+ * Puts a scroll the solve just spawned at its `offset` prop, inside its range, and lets it follow
57906
+ * the prop from there. Its spawn waits in the queue of the phase: these values replace the queued
57907
+ * ones at the flush, before the first draw.
57908
+ *
57909
+ * @param ctx - Domain context of the ui plugin.
57910
+ * @param container - The scroll element, live since this solve.
57911
+ * @param content - Its content element.
57912
+ * @param motion - Its motion, not placed yet.
57307
57913
  */
57308
- function stepScroll(ctx, state, containers, lookup) {
57914
+ function place$1(ctx, container, content, motion) {
57309
57915
  const ecs = ctx.deps.world.ecs;
57310
- const pointer = ecs.resource(Pointer);
57916
+ const rest = content.rest;
57917
+ motion.placed = true;
57918
+ motion.atProp = true;
57919
+ motion.max = maxOf(container, content, motion.axis);
57920
+ motion.seen = askedOffsetOf(container);
57921
+ motion.offset = within(motion.seen, motion.max);
57922
+ const offset = Math.round(motion.offset);
57923
+ const pose = motion.axis === "x" ? {
57924
+ ...rest,
57925
+ x: content.rect.x - container.rect.x - offset
57926
+ } : {
57927
+ ...rest,
57928
+ y: content.rect.y - container.rect.y - offset
57929
+ };
57930
+ ecs.add(container.entity, Scroll({
57931
+ axis: motion.axis,
57932
+ offset: 0 - offset,
57933
+ min: 0 - motion.max
57934
+ }));
57935
+ ecs.add(container.entity, ScrollState({
57936
+ offset,
57937
+ max: motion.max,
57938
+ moving: false
57939
+ }));
57940
+ ecs.add(content.entity, Transform(pose));
57941
+ }
57942
+ /**
57943
+ * Settles a placed scroll on what the solve and the render of this frame gave it: a new range at
57944
+ * once, then a glide to a new `offset` prop, which the scroll follows from then on. A scroll the
57945
+ * finger holds only remembers the prop.
57946
+ *
57947
+ * @param ctx - Domain context of the ui plugin.
57948
+ * @param state - The layout state, which keeps the patches.
57949
+ * @param container - The scroll element.
57950
+ * @param content - Its content element.
57951
+ * @param motion - Its motion, placed.
57952
+ */
57953
+ function settle(ctx, state, container, content, motion) {
57954
+ resize(motion, maxOf(container, content, motion.axis));
57955
+ const asked = askedOffsetOf(container);
57956
+ if (asked !== motion.seen) {
57957
+ motion.seen = asked;
57958
+ if (motion.mode !== "held") {
57959
+ glideTo(motion, asked);
57960
+ motion.atProp = true;
57961
+ }
57962
+ }
57963
+ show(ctx, state, container, content, motion);
57964
+ }
57965
+ /**
57966
+ * Settles every scroll on what the solve and the render of this frame gave it. A scroll the solve
57967
+ * just spawned takes its place at its `offset` prop. A placed one keeps inside a new range at
57968
+ * once, a running glide's target too, and glides to a new `offset` prop; the same value on a
57969
+ * re-render does nothing, and a scroll the finger holds only remembers it. A scroll left moving
57970
+ * wakes the loop the way `stepScroll` does: not at a time scale of 0, where it stands.
57971
+ *
57972
+ * @param ctx - Domain context of the ui plugin.
57973
+ * @param state - The layout state, which keeps the patches.
57974
+ * @param containers - The live scroll elements.
57975
+ * @param lookup - How a child entity becomes its element.
57976
+ * @param time - The frame's `Time`.
57977
+ */
57978
+ function settleScroll(ctx, state, containers, lookup, time) {
57979
+ let moving = false;
57311
57980
  for (const container of containers) {
57312
- const scroll = ecs.get(container.entity, Scroll);
57981
+ const motion = container.scroll;
57313
57982
  const content = contentOf(container, lookup);
57314
- if (scroll === void 0 || content === void 0) continue;
57315
- if (!ecs.has(container.entity, Pressed)) {
57316
- if (state.scrolling === container.entity) state.scrolling = void 0;
57317
- if (container.window !== void 0) keepInRange(ctx, container, content, contentHeightOf(container, content));
57318
- continue;
57319
- }
57320
- if (state.scrolling !== container.entity) {
57321
- state.scrolling = container.entity;
57322
- state.scrollStart = {
57323
- pointerY: pointer.y,
57324
- offset: scroll.offset
57325
- };
57326
- }
57327
- const min = Math.min(0, container.rect.h - contentHeightOf(container, content));
57328
- const offset = clampOffset(state.scrollStart.offset + (pointer.y - state.scrollStart.pointerY), min);
57329
- moveContent(ctx, container, content, {
57330
- ...scroll,
57331
- offset,
57332
- min
57333
- });
57983
+ if (motion === void 0 || content === void 0) continue;
57984
+ if (motion.placed) settle(ctx, state, container, content, motion);
57985
+ else place$1(ctx, container, content, motion);
57986
+ if (motion.mode !== "still") moving = true;
57334
57987
  }
57988
+ if (moving && time.delta > 0) ctx.deps.time.wake();
57989
+ }
57990
+ /**
57991
+ * Re-clamps a live windowed scroll to the content height of its new rows before its range is
57992
+ * cut, so a list that shrank below the window moves up in the same frame.
57993
+ *
57994
+ * @param ctx - Domain context of the ui plugin.
57995
+ * @param state - The layout state, which keeps the patches.
57996
+ * @param container - The live scroll element.
57997
+ * @param contentHeight - `rows × rowHeight` of this render.
57998
+ * @param lookup - How a child entity becomes its element.
57999
+ * @returns The offset the content stands at, 0 and below, as `Scroll` holds it.
58000
+ */
58001
+ function clampScroll(ctx, state, container, contentHeight, lookup) {
58002
+ const motion = container.scroll;
58003
+ const content = contentOf(container, lookup);
58004
+ if (motion === void 0 || !motion.placed || content === void 0) return 0;
58005
+ resize(motion, extent(contentHeight, container.rect.h));
58006
+ show(ctx, state, container, content, motion);
58007
+ return 0 - Math.round(motion.offset);
58008
+ }
58009
+ /**
58010
+ * Puts a placed scroll at an offset at once, inside its range: a fling or a glide stops, the
58011
+ * scroll no longer follows its `offset` prop, and `ScrollState` says it stands. A finger that
58012
+ * holds it goes on from there.
58013
+ *
58014
+ * @param ctx - Domain context of the ui plugin.
58015
+ * @param state - The layout state, which keeps the patches.
58016
+ * @param container - The scroll element.
58017
+ * @param to - Where it goes, from the start; `NaN` leaves it where it is.
58018
+ * @param lookup - How a child entity becomes its element.
58019
+ * @returns True when the scroll took it; false before its first solve placed it.
58020
+ */
58021
+ function scrollTo(ctx, state, container, to, lookup) {
58022
+ const motion = container.scroll;
58023
+ const content = contentOf(container, lookup);
58024
+ if (motion === void 0 || !motion.placed || content === void 0) return false;
58025
+ motion.offset = within(Number.isNaN(to) ? motion.offset : to, motion.max);
58026
+ motion.atProp = false;
58027
+ stop(motion);
58028
+ show(ctx, state, container, content, motion);
58029
+ ctx.deps.time.wake();
58030
+ return true;
57335
58031
  }
57336
58032
  //#endregion
57337
58033
  //#region src/plugins/ui/layout/solve.ts
@@ -57546,7 +58242,9 @@ function textSourceOf(ctx) {
57546
58242
  };
57547
58243
  }
57548
58244
  /**
57549
- * Writes the style of an element onto its node and keeps its measure function in step.
58245
+ * Writes the style of an element onto its node and keeps its measure function in step. An `x`
58246
+ * scroll lays its content out the way a row does: along `x`, as wide as it is, as tall as the
58247
+ * scroll.
57550
58248
  *
57551
58249
  * @param state - The layout state.
57552
58250
  * @param source - What a text element is measured through.
@@ -57555,7 +58253,8 @@ function textSourceOf(ctx) {
57555
58253
  function writeStyle(state, source, element) {
57556
58254
  const node = state.byEntity.get(element.entity);
57557
58255
  if (node === void 0 || state.yoga === void 0) return;
57558
- applyStyleToNode(state.yoga, node, element.style, element.type, element.parentType);
58256
+ const tag = element.scroll?.axis === "x" ? "row" : element.type;
58257
+ applyStyleToNode(state.yoga, node, element.style, tag, element.parentType);
57559
58258
  installMeasure(state, node, element, source);
57560
58259
  }
57561
58260
  /**
@@ -57618,8 +58317,10 @@ function createLayoutApi(ctx) {
57618
58317
  play(ctx, element, hook);
57619
58318
  },
57620
58319
  settled: (element) => canDespawn(element),
57621
- scroll: (containers, lookup) => stepScroll(ctx, state, containers, lookup),
57622
- clampScroll: (container, contentHeight, lookup) => clampScroll(ctx, container, contentHeight, lookup),
58320
+ scroll: (containers, lookup, time) => stepScroll(ctx, state, containers, lookup, time),
58321
+ settleScroll: (containers, lookup, time) => settleScroll(ctx, state, containers, lookup, time),
58322
+ clampScroll: (container, contentHeight, lookup) => clampScroll(ctx, state, container, contentHeight, lookup),
58323
+ scrollTo: (container, to, lookup) => scrollTo(ctx, state, container, to, lookup),
57623
58324
  windowOf,
57624
58325
  popupHandler: (jsx) => createPopupHandler(ctx, state, jsx),
57625
58326
  guideHandler: () => createGuideHandler(ctx),
@@ -58195,12 +58896,12 @@ function openRegistrations(ctx, jsx, layout) {
58195
58896
  name: "ui.reconcile",
58196
58897
  phase: "layout",
58197
58898
  query: [],
58198
- run: jsx.reconcile
58899
+ run: (_rows, frame) => jsx.reconcile(frame.time)
58199
58900
  })), ecs.system(system({
58200
58901
  name: "ui.solve",
58201
58902
  phase: "layout",
58202
58903
  query: [],
58203
- run: jsx.solve
58904
+ run: (_rows, frame) => jsx.solve(frame.time)
58204
58905
  })), ecs.onAdded(Tree, (entity) => {
58205
58906
  const place = projection.keyOf(entity);
58206
58907
  jsx.mountRoot(entity, place?.projection ?? "ui", ecs.get(entity, Layer)?.name ?? "ui");
@@ -58247,6 +58948,7 @@ function stopUi(state) {
58247
58948
  state.layout.nodes = 0;
58248
58949
  state.layout.yoga = void 0;
58249
58950
  state.layout.scrolling = void 0;
58951
+ state.layout.scrollSamples = 0;
58250
58952
  state.layout.iconLine = void 0;
58251
58953
  state.jsx.components.clear();
58252
58954
  state.jsx.roots.clear();
@@ -58537,7 +59239,7 @@ function createModules(ctx) {
58537
59239
  };
58538
59240
  }
58539
59241
  /**
58540
- * Creates the ui API: the four members a game calls, flat.
59242
+ * Creates the ui API: the five members a game calls, flat.
58541
59243
  *
58542
59244
  * @param ctx - Kernel context of the ui plugin.
58543
59245
  * @returns The plugin API.
@@ -58548,7 +59250,8 @@ function createUiApi(ctx) {
58548
59250
  tree: jsx.tree,
58549
59251
  find: jsx.find,
58550
59252
  lint: jsx.lint,
58551
- fill: jsx.fill
59253
+ fill: jsx.fill,
59254
+ scroll: jsx.scroll
58552
59255
  };
58553
59256
  }
58554
59257
  //#endregion
@@ -58650,8 +59353,8 @@ function createJsxState() {
58650
59353
  //#endregion
58651
59354
  //#region src/plugins/ui/layout/state.ts
58652
59355
  /**
58653
- * Creates an empty map. Its own non-exported function, because lint rule L5 refuses a collection
58654
- * built inside an exported declaration.
59356
+ * Creates an empty map. Its own non-exported function, because lint rule L5 refuses a
59357
+ * collection built inside an exported declaration.
58655
59358
  *
58656
59359
  * @returns A map with nothing in it.
58657
59360
  */
@@ -58659,13 +59362,39 @@ function emptyMap() {
58659
59362
  return /* @__PURE__ */ new Map();
58660
59363
  }
58661
59364
  /**
59365
+ * Creates the ring of pointer samples: a position and a time for each of `SAMPLES`. Its own
59366
+ * non-exported function, for the same rule.
59367
+ *
59368
+ * @returns The ring, zeroed.
59369
+ */
59370
+ function sampleRing() {
59371
+ return /* @__PURE__ */ new Float64Array(10);
59372
+ }
59373
+ /**
59374
+ * Creates the patches the scroll step writes with, every field 0 or false.
59375
+ *
59376
+ * @returns The patches.
59377
+ */
59378
+ function scrollPatches() {
59379
+ return {
59380
+ scroll: {
59381
+ offset: 0,
59382
+ min: 0
59383
+ },
59384
+ state: {
59385
+ offset: 0,
59386
+ max: 0,
59387
+ moving: false
59388
+ },
59389
+ scrollable: { moving: false },
59390
+ x: { x: 0 },
59391
+ y: { y: 0 }
59392
+ };
59393
+ }
59394
+ /**
58662
59395
  * Creates the layout branch of the ui state. Everything starts empty: Yoga arrives in `onStart`.
58663
59396
  *
58664
59397
  * @returns The module state.
58665
- * @example
58666
- * ```ts
58667
- * createLayoutState().nodes; // 0
58668
- * ```
58669
59398
  */
58670
59399
  function createLayoutState() {
58671
59400
  return {
@@ -58675,10 +59404,12 @@ function createLayoutState() {
58675
59404
  measured: 0,
58676
59405
  solves: 0,
58677
59406
  scrolling: void 0,
58678
- scrollStart: {
58679
- pointerY: 0,
58680
- offset: 0
58681
- },
59407
+ scrollClock: 0,
59408
+ scrollRing: sampleRing(),
59409
+ scrollHead: 0,
59410
+ scrollSamples: 0,
59411
+ scrollMoved: 0,
59412
+ scrollPatches: scrollPatches(),
58682
59413
  iconLine: void 0,
58683
59414
  order: 0,
58684
59415
  cleanups: []
@@ -58740,9 +59471,10 @@ function createUiState(_ctx) {
58740
59471
  };
58741
59472
  }
58742
59473
  /**
58743
- * Ui plugin: `app.ui.tree()`, `app.ui.find(...)`, `app.ui.lint()`, `app.ui.fill(...)`, the
58744
- * text field `<input local>`, the components `Box`, `LocalWrite` and
58745
- * `DebugTrigger`, and the helpers `defineComponent`, `defineStyle`, `defineTokens` and `popup`.
59474
+ * Ui plugin: `app.ui.tree()`, `app.ui.find(...)`, `app.ui.lint()`, `app.ui.fill(...)`,
59475
+ * `app.ui.scroll(...)`, the text field `<input local>`, the components `Box`, `LocalWrite`,
59476
+ * `DebugTrigger` and `ScrollState`, and the helpers `defineComponent`, `defineStyle`,
59477
+ * `defineTokens` and `popup`.
58746
59478
  *
58747
59479
  * @example
58748
59480
  * ```ts
@@ -60313,4 +61045,4 @@ const screen = [
60313
61045
  uiPlugin
60314
61046
  ];
60315
61047
  //#endregion
60316
- export { mark as $, PROBLEM_RULES as $t, types_exports$11 as A, Alpha as At, Box as B, scenesPlugin as Bt, types_exports$4 as C, Traceable as Ct, types_exports$8 as D, messageDuration as Dt, types_exports$7 as E, messageArgument as Et, types_exports$16 as F, Noise as Ft, defineStyle as G, Frames as Gt, LocalWrite as H, animPlugin as Ht, types_exports$17 as I, Outline as It, HAPTIC_KINDS as J, frameAt as Jt, textPlugin as K, rendererPlugin as Kt, uiPlugin as L, defineFilter as Lt, types_exports$13 as M, ColorMatrix as Mt, types_exports$14 as N, Displacement as Nt, types_exports$9 as O, effectsPlugin as Ot, types_exports$15 as P, Glow as Pt, haptic as Q, PERF_LOG_VERSION as Qt, resolve as R, debugPlugin as Rt, types_exports$3 as S, Touchable as St, types_exports$6 as T, i18nPlugin as Tt, popup as U, AnimPlayer as Ut, DebugTrigger as V, assetsPlugin as Vt, defineTokens as W, Animation as Wt, external as X, PERF_CAUSES as Xt, defineAnimation as Y, MONITOR_SECTIONS as Yt, frames as Z, PERF_GAP_BUCKETS as Zt, defineBundles as _, PointerOver as _t, cubicBezier as a, exit as an, spawn as at, types_exports$1 as b, Swipeable as bt, hint as c, type as cn, tween as ct, defineScene as d, lifecyclePlugin as dn, inputPlugin as dt, SHADER_WHATS as en, parallel as et, tr as f, timePlugin as fn, Draggable as ft, music as g, Pointer as gt, defineEmitter as h, Hovered as ht, screen as i, refuseStart as in, sfx as it, types_exports$12 as j, Blur as jt, types_exports$10 as k, Emitter as kt, schedule as l, modelPlugin as ln, use as lt, flattenFeatures as m, Held as mt, createPlugin as n, worldPlugin as nn, sequence as nt, defineEffect as o, slot as on, spawned as ot, defineFeature as p, clockPlugin as pn, DropTarget as pt, platformPlugin as q, PASS_KINDS as qt, defineGame as r, flowPlugin as rn, set as rt, guide as s, to as sn, stagger as st, createApp as t, DEMAND_REASONS as tn, play$1 as tt, projection as u, SaveUnreadableError as un, wait as ut, load as v, Pressable as vt, types_exports$5 as w, Traced as wt, types_exports$2 as x, Tappable as xt, types_exports as y, Pressed as yt, defineMotion as z, audioPlugin as zt };
61048
+ export { haptic as $, PERF_LOG_VERSION as $t, types_exports$11 as A, Emitter as At, Box as B, audioPlugin as Bt, types_exports$4 as C, Touchable as Ct, types_exports$8 as D, messageArgument as Dt, types_exports$7 as E, i18nPlugin as Et, types_exports$16 as F, Glow as Ft, defineTokens as G, Animation as Gt, LocalWrite as H, assetsPlugin as Ht, types_exports$17 as I, Noise as It, platformPlugin as J, PASS_KINDS as Jt, defineStyle as K, Frames as Kt, uiPlugin as L, Outline as Lt, types_exports$13 as M, Blur as Mt, types_exports$14 as N, ColorMatrix as Nt, types_exports$9 as O, messageDuration as Ot, types_exports$15 as P, Displacement as Pt, frames as Q, PERF_GAP_BUCKETS as Qt, resolve as R, defineFilter as Rt, types_exports$3 as S, Tappable as St, types_exports$6 as T, Traced as Tt, ScrollState as U, animPlugin as Ut, DebugTrigger as V, scenesPlugin as Vt, popup as W, AnimPlayer as Wt, defineAnimation as X, MONITOR_SECTIONS as Xt, HAPTIC_KINDS as Y, frameAt as Yt, external as Z, PERF_CAUSES as Zt, defineBundles as _, Pointer as _t, cubicBezier as a, refuseStart as an, sfx as at, types_exports$1 as b, Pressed as bt, hint as c, to as cn, stagger as ct, defineScene as d, SaveUnreadableError as dn, wait as dt, PROBLEM_RULES as en, mark as et, tr as f, lifecyclePlugin as fn, inputPlugin as ft, music as g, Hovered as gt, defineEmitter as h, Held as ht, screen as i, flowPlugin as in, set as it, types_exports$12 as j, Alpha as jt, types_exports$10 as k, effectsPlugin as kt, schedule as l, type as ln, tween as lt, flattenFeatures as m, clockPlugin as mn, DropTarget as mt, createPlugin as n, DEMAND_REASONS as nn, play$1 as nt, defineEffect as o, exit as on, spawn as ot, defineFeature as p, timePlugin as pn, Draggable as pt, textPlugin as q, rendererPlugin as qt, defineGame as r, worldPlugin as rn, sequence as rt, guide as s, slot as sn, spawned as st, createApp as t, SHADER_WHATS as tn, parallel as tt, projection as u, modelPlugin as un, use as ut, load as v, PointerOver as vt, types_exports$5 as w, Traceable as wt, types_exports$2 as x, Swipeable as xt, types_exports as y, Pressable as yt, defineMotion as z, debugPlugin as zt };