diff --git a/docs/harness-limits.md b/docs/harness-limits.md index 0a0d12fd..e869fdd4 100644 --- a/docs/harness-limits.md +++ b/docs/harness-limits.md @@ -75,7 +75,7 @@ Vertical bands inside the Wasm root (not a DOM panel): | Wrap / grow | Field is **`break_lines`** + grows **vertically** with wrapped lines up to `COMPOSER_INPUT_MAX_H` (120 px) then scrolls **inside** the entry. Composer chrome box uses a dynamic absolute rect from previous-frame measurement (`composer_last_h`): idle = `COMPOSER_IDLE_CHROME_H` (44 px = TOUCH_H + 2×HUG_PAD), max = `COMPOSER_MAX_CHROME_H` (124 px). Glyphs sit `COMPOSER_TE_PAD` (5 px) inside the field stroke — `min_size_content` / `max_size_content` are passed minus 2×pad because `TextEntryWidget.init` bakes padding in. The textEntry hugs one line when idle, grows up when multi-line (one-frame settle lag), and scrolls internally past 120 px. The Send/Stop icon at `gravity_y = 1.0` stays bottom-pinned on the field baseline (adversarial review #584 Round 2 Major L1+L9). Never a horizontal gutter (repo no-h-scroll policy, #344/#457/#579) | | Icon button | Fixed **`TOUCH_H`×`TOUCH_H`** (40 px) square on the **same row** as the field, **bottom-pinned** (`gravity_y = 1.0`) so it stays glued to the status bar while the field grows up (plan #575 lock). Idle = ▶ Send (submit when non-empty); Busy = ■ Stop (protocol v9 `queueCancelFromUi` → host abort). No labelled Stop/Send pill, no hint copy. Glyphs from the embedded DejaVu Sans Symbols face (no tofu) | | Turn clock | Whole-turn **`mm:ss`** is painted **in-canvas** by the Wasm busy row (`Waiting for model… · 0:42`), protocol **v14** (`inv_set_turn_elapsed`). The **DOM host** owns the only reliable wall-clock (no WASI clock in Wasm): its ~1 Hz Busy effect pushes the elapsed seconds to the bridge (`HarnessBridge.setTurnElapsed` → `inv_set_turn_elapsed`), reset to 0 on Ready/Stop/error so no bare `0:00` lingers. The clock is client wall-time from turn start — **not** provider `usage` duration. See [feature-divide.md](feature-divide.md) | -| Busy spinner | Two instances of a **2×4 rectangle grid**: (a) **transcript busy row** — a **WARM rectangle grid** paints **left of** `Waiting for model…` on a **full-width `teal_bg` bar**; while Busy the waiting copy runs a **left-to-right cyclic WARM color wave** (same 10 Hz phase as the spinner) via `text_wave.zig` — per-scalar color from `warm_accent` (head) through `warm_muted`/`warm_border` (tail, capped — `warm_surface` #1a120c is ~1:1 on `teal_bg` and reserved for rect_spinner off-cells), ~5.4 s full cycle at 10 Hz. The wave is painted inside the same textLayout as the ` · mm:ss` clock (no separate clock textLayout). (b) **status-bar line 1** — during **Busy** the same WARM pulse runs; during **idle** (Ready / boot / error) all 8 cells paint `teal_muted` as a static, subtle grid. The grid is **4×4 px cells** with **2 px sibling gaps** (inner 10×22), centered by equal pad (**1.5 / 3.5**) inside a reserved **13×29 slot**; the slot and the **10 px `TRAIL`** before the waiting copy do not move. The pulse is a **clockwise loop**: left column **bottom→top**, right column **top→bottom**. The **DOM host** feeds the pulse phase at **`HARNESS_BUSY_TICK_HZ` = 10 Hz while Busy** (`HarnessBridge.setBusyTick` → additive `inv_set_busy_tick`). **NEW cap:** 10 Hz while Busy, **0 otherwise** — well below the dvui 60 fps ceiling, host-local `setInterval` (no transport), turns transient. Pulse is pure Wasm LUT paint (`native/harness/src/busy_spinner.zig` / `rect_spinner.zig` / `text_wave.zig`), **no I/O / alloc in the frame path**. Each `setBusyTick` triggers a full dvui `refresh()` (re-layout + repaint) at up to 10 Hz while Busy vs 1 Hz today — the 10 Hz bound is the lock, not a cached-redraw claim. **Reduced motion:** host skips tick pushes → `busy_tick` stays 0 (reserved sentinel). Both grids static (spinner head **bottom-left**; wave all `warm_accent` — the phase-0 fast-path in `text_wave.zig` paints the whole string at `ramp[0]`). The bridge maps busy ticks to 1..255 (wrapping 255→1, never 0) so a long turn (≥ 25.6 s) never flashes solid mid-animation. The live `mm:ss` **clock feed keeps running** (solid `warm_accent`, never waves). Old host + new Wasm: `busy_tick` stays 0 → static grids + static wave (graceful). New host + old Wasm: `inv_set_busy_tick` is in `REQUIRED_FNS`, so a stale build fails closed at load | +| Busy spinner | Two instances of a **2×4 rectangle grid**: (a) **transcript busy row** — a **WARM rectangle grid** paints **left of** `Waiting for model…` on a **full-width `teal_bg` bar**; while Busy the waiting copy runs a **left-to-right cyclic WARM color wave** (same 10 Hz phase as the spinner) via `text_wave.zig` — a **localized directed comet** behind the traveling head: the head scalar is `warm_accent`, the next 2 scalars behind it are `warm_muted`, the next 1 is `warm_border`, and the **rest of the line stays `warm_accent`** (readable; `warm_surface` #1a120c is ~1:1 on `teal_bg` and reserved for rect_spinner off-cells). The wave doubles its speed via `SPEED=2` — **~2.7 s full cycle** at 10 Hz (`N*STEPS/SPEED` ticks). The wave is painted inside the same textLayout as the ` · mm:ss` clock (no separate clock textLayout). (b) **status-bar line 1** — during **Busy** the same WARM pulse runs; during **idle** (Ready / boot / error) all 8 cells paint `teal_muted` as a static, subtle grid. The grid is **4×4 px cells** with **2 px sibling gaps** (inner 10×22), centered by equal pad (**1.5 / 3.5**) inside a reserved **13×29 slot**; the slot and the **10 px `TRAIL`** before the waiting copy do not move. The pulse is a **clockwise loop**: left column **bottom→top**, right column **top→bottom**. The **DOM host** feeds the pulse phase at **`HARNESS_BUSY_TICK_HZ` = 10 Hz while Busy** (`HarnessBridge.setBusyTick` → additive `inv_set_busy_tick`). **NEW cap:** 10 Hz while Busy, **0 otherwise** — well below the dvui 60 fps ceiling, host-local `setInterval` (no transport), turns transient. Pulse is pure Wasm LUT paint (`native/harness/src/busy_spinner.zig` / `rect_spinner.zig` / `text_wave.zig`), **no I/O / alloc in the frame path**. Each `setBusyTick` triggers a full dvui `refresh()` (re-layout + repaint) at up to 10 Hz while Busy vs 1 Hz today — the 10 Hz bound is the lock, not a cached-redraw claim. **Reduced motion:** host skips tick pushes → `busy_tick` stays 0 (reserved sentinel). Both grids static (spinner head **bottom-left**; wave all `warm_accent` — the phase-0 fast-path in `text_wave.zig` paints the whole string at `ramp[0]`). The bridge maps busy ticks to 1..255 (wrapping 255→1, never 0) so a long turn (≥ 25.6 s) never flashes solid mid-animation. The live `mm:ss` **clock feed keeps running** (solid `warm_accent`, never waves). Old host + new Wasm: `busy_tick` stays 0 → static grids + static wave (graceful). New host + old Wasm: `inv_set_busy_tick` is in `REQUIRED_FNS`, so a stale build fails closed at load | | Short canvas | Transcript shrinks / scrolls first — chrome keeps touch-sized targets (~40px). `SCROLL_FLOOR_H` (32 px) prevents the transcript from collapsing to zero on absurdly short canvases | | Content size | Tall messages grow **virtual** scroll size only; scroller outer height is fixed to the leftover band | | Solid chrome | Composer band uses TEAL fill so transcript paint cannot show through | diff --git a/native/harness/src/text_wave.test.zig b/native/harness/src/text_wave.test.zig index c52a720f..219dcd31 100644 --- a/native/harness/src/text_wave.test.zig +++ b/native/harness/src/text_wave.test.zig @@ -1,9 +1,10 @@ //! Host unit tests for `text_wave.zig` — pure LUT / position / distance logic; //! no dvui frame dependency. Runs under `zig build test-rich`. //! -//! Tests cover: head position, cyclic distance, color-step mapping (capped at 2 -//! = warm_border), scalar count, empty/single-char edges, UTF-8 multi-byte -//! safety, and full-cycle ramp coverage. +//! Tests cover: SPEED-doubled head position (u32 multiply), directed trail +//! distance, comet color-step occupancy (capped at 2 = warm_border; rest of the +//! line accent), scalar count, empty/single-char edges, UTF-8 multi-byte +//! safety, and the phase-0 reduced-motion fast-path rationale. const std = @import("std"); const t = std.testing; @@ -19,117 +20,147 @@ test "countScalars: ASCII + ellipsis = 18 scalars" { try t.expectEqual(@as(usize, 18), text_wave.countScalars("Waiting for model\u{2026}")); } -test "phase 0 domain: tail scalars are NOT all step 0 (why the fast-path exists)" { - // Plan #655 Goal 4 + DoD row 8: reduced motion / old host → solid - // warm_accent. The paint function fast-paths phase==0 with one addText. - // This test locks the DOMAIN LOGIC that MAKES the fast-path necessary: - // without it, tail scalars at head=0 would land at step 2 (warm_border), - // not step 0. If someone removes the fast-path, this test reminds them - // that the domain formula does NOT equal "solid" at phase 0. - // - // head at 0: cyclicDistance(i, 0, 18) = min(i, 18-i) = i for i≤9 - // N=18, denom=9. colorStep uses 3.0 multiplier, capped at 2. - // i=0: dist=0, raw=0*3/9=0 → step 0 (head) - // i=1: dist=1, raw=1*3/9=0.33 → step 0 - // i=3: dist=3, raw=3*3/9=1 → step 1 (warm_muted trail) - // i=6: dist=6, raw=6*3/9=2 → step 2 (warm_border tail) - // i=9: dist=9, raw=9*3/9=3 → min(3,2)=2 (warm_border, capped) - try t.expectEqual(@as(usize, 0), text_wave.colorStep(text_wave.cyclicDistance(0, 0.0, 18), 18)); - try t.expectEqual(@as(usize, 0), text_wave.colorStep(text_wave.cyclicDistance(1, 0.0, 18), 18)); - try t.expectEqual(@as(usize, 1), text_wave.colorStep(text_wave.cyclicDistance(3, 0.0, 18), 18)); - try t.expectEqual(@as(usize, 2), text_wave.colorStep(text_wave.cyclicDistance(6, 0.0, 18), 18)); - try t.expectEqual(@as(usize, 2), text_wave.colorStep(text_wave.cyclicDistance(9, 0.0, 18), 18)); -} - -test "phase 0: head at 0 smoke (first and last scalars)" { - // head = 0/3 = 0.0: cyclicDistance(0, 0.0, 18) = 0 → step 0 (head) - try t.expectEqual(@as(usize, 0), text_wave.colorStep(text_wave.cyclicDistance(0, text_wave.headPosition(0, 18), 18), 18)); - // cyclicDistance(9, 0.0, 18) = 9, N=18, denom=9, raw=9*3/9=3 → min(3,2)=2 (warm_border cap) - try t.expectEqual(@as(usize, 2), text_wave.colorStep(text_wave.cyclicDistance(9, 0.0, 18), 18)); -} - test "countScalars: multi-byte UTF-8 (café = 4 scalars)" { try t.expectEqual(@as(usize, 4), text_wave.countScalars("café")); } +test "countScalars: emoji (multi-byte) counts as one scalar" { + // U+1F600 (😀) is 4 bytes, 1 scalar. + const grinning = "😀"; + try t.expectEqual(@as(usize, 1), text_wave.countScalars(grinning)); +} + test "headPosition: phase 0 on 10-char text → 0.0" { try t.expectApproxEqAbs(0.0, text_wave.headPosition(0, 10), 0.01); } -test "headPosition: phase 7 on 10-char text → 2.333 (7/3)" { - try t.expectApproxEqAbs(2.333, text_wave.headPosition(7, 10), 0.05); +test "headPosition: phase 7 on 10-char text → 4.667 (14/3, phase*2)" { + // SPEED=2: raw = 7*2 = 14; period = 10*3 = 30; 14%30 = 14; /3 = 4.667. + try t.expectApproxEqAbs(4.667, text_wave.headPosition(7, 10), 0.05); } test "headPosition: phase 60 on 20-char text wraps → 0.0" { - // 20 * 3 = 60; phase 60 % 60 = 0; head = 0/3 = 0 + // 20*3 = 60; raw = 60*2 = 120; 120%60 = 0; head = 0/3 = 0. try t.expectApproxEqAbs(0.0, text_wave.headPosition(60, 20), 0.01); } -test "headPosition: phase 255 on 20-char text wraps correctly" { - // 20 * 3 = 60; 255 % 60 = 15; head = 15/3 = 5.0 - try t.expectApproxEqAbs(5.0, text_wave.headPosition(255, 20), 0.01); -} - -test "headPosition: N=0 returns 0" { - try t.expectEqual(@as(f32, 0.0), text_wave.headPosition(5, 0)); -} - -test "cyclicDistance: head at 5 on 10-char text (plan example)" { - // i=0: min(|0-5|, 10-5) = min(5,5) = 5 - try t.expectApproxEqAbs(5.0, text_wave.cyclicDistance(0, 5.0, 10), 0.01); - // i=5: min(|5-5|, 10-5) = 0 - try t.expectApproxEqAbs(0.0, text_wave.cyclicDistance(5, 5.0, 10), 0.01); - // i=9: min(4, 6) = 4 - try t.expectApproxEqAbs(4.0, text_wave.cyclicDistance(9, 5.0, 10), 0.01); - // i=6: min(1, 9) = 1 - try t.expectApproxEqAbs(1.0, text_wave.cyclicDistance(6, 5.0, 10), 0.01); - // i=1: min(4, 6) = 4 - try t.expectApproxEqAbs(4.0, text_wave.cyclicDistance(1, 5.0, 10), 0.01); -} - -test "cyclicDistance: N=1 always returns 0" { - try t.expectEqual(@as(f32, 0.0), text_wave.cyclicDistance(0, 7.5, 1)); +test "headPosition: phase 255 on 20-char text wraps via u32 (plan example)" { + // raw = 255*2 = 510 (u32, no u8 overflow); 510%60 = 30; head = 30/3 = 10.0. + try t.expectApproxEqAbs(10.0, text_wave.headPosition(255, 20), 0.01); } -test "cyclicDistance: N=0 always returns 0" { - try t.expectEqual(@as(f32, 0.0), text_wave.cyclicDistance(0, 3.0, 0)); +test "headPosition: N=18 loop period is 27 ticks (phase 0 and phase 27 → same head)" { + // 18*3/2 = 27 ticks per loop at SPEED=2. phase 27 wraps to the same head as + // phase 0 — a full loop in ~2.7 s at the locked 10 Hz host tick. + try t.expectApproxEqAbs(text_wave.headPosition(0, 18), text_wave.headPosition(27, 18), 0.01); + try t.expectApproxEqAbs(0.0, text_wave.headPosition(27, 18), 0.01); } -test "colorStep: distance 0 → step 0 (head, warm_accent)" { - try t.expectEqual(@as(usize, 0), text_wave.colorStep(0.0, 10)); -} - -test "colorStep: distance 5 on N=10 → step 2 (warm_border cap, not surface)" { - // N=10, denom=5. dist=5: raw=5*3/5=3 → min(3,2)=2 (capped at warm_border). - // Before the cap (multiplier 4.0, max 3) this was step 3 = warm_surface. - try t.expectEqual(@as(usize, 2), text_wave.colorStep(5.0, 10)); -} - -test "colorStep: N=1 always returns 0" { - try t.expectEqual(@as(usize, 0), text_wave.colorStep(100.0, 1)); -} - -test "colorStep: all 3 stops exercised across full distance range on N=20" { - // N=20, max distance = 10, denom = 10. - // 3.0 multiplier, capped at 2: - // dist 0: 0*3/10 = 0.0 → step 0 (warm_accent) - // dist 2: 2*3/10 = 0.6 → step 0 - // dist 4: 4*3/10 = 1.2 → step 1 (warm_muted) - // dist 7: 7*3/10 = 2.1 → step 2 (warm_border cap) - // dist 10: 10*3/10 = 3.0 → min(3,2)=2 (warm_border, capped) - try t.expectEqual(@as(usize, 0), text_wave.colorStep(0.0, 20)); - try t.expectEqual(@as(usize, 0), text_wave.colorStep(2.0, 20)); - try t.expectEqual(@as(usize, 1), text_wave.colorStep(4.0, 20)); - try t.expectEqual(@as(usize, 2), text_wave.colorStep(7.0, 20)); - try t.expectEqual(@as(usize, 2), text_wave.colorStep(10.0, 20)); +test "headPosition: N=0 returns 0" { + try t.expectEqual(@as(f32, 0.0), text_wave.headPosition(5, 0)); } -test "colorStep: N=2 (two chars) — head and opposite" { - // N=2, max cyclic dist = 1. denom = 1.0. - // dist 0 → 0*3/1 = 0 → step 0 - // dist 1 → 1*3/1 = 3 → min(3,2) = 2 (capped) - try t.expectEqual(@as(usize, 0), text_wave.colorStep(0.0, 2)); - try t.expectEqual(@as(usize, 2), text_wave.colorStep(1.0, 2)); +test "trailDistance: 0 at the head; increases behind the travel direction" { + // Direction of travel is L→R; the trail sits behind (already-passed, left + // of) the head. At head=5 on N=10: + try t.expectApproxEqAbs(0.0, text_wave.trailDistance(5, 5.0, 10), 0.01); // at head + try t.expectApproxEqAbs(1.0, text_wave.trailDistance(4, 5.0, 10), 0.01); // behind + try t.expectApproxEqAbs(2.0, text_wave.trailDistance(3, 5.0, 10), 0.01); // farther behind + try t.expectApproxEqAbs(5.0, text_wave.trailDistance(0, 5.0, 10), 0.01); // far behind + // Ahead of the head (not yet swept) wraps to a large trail — accent rest. + try t.expectApproxEqAbs(9.0, text_wave.trailDistance(6, 5.0, 10), 0.01); + try t.expectApproxEqAbs(6.0, text_wave.trailDistance(9, 5.0, 10), 0.01); +} + +test "trailDistance: rings around so a head at the string end keeps a contiguous behind trail" { + // Head near the last scalar on N=18: index 17 is immediately behind it. + try t.expectApproxEqAbs(0.0, text_wave.trailDistance(17, 17.0, 18), 0.01); + try t.expectApproxEqAbs(1.0, text_wave.trailDistance(16, 17.0, 18), 0.01); + // Index 0 is the head of the next ring pass — far ahead. + try t.expectApproxEqAbs(17.0, text_wave.trailDistance(0, 17.0, 18), 0.01); +} + +test "trailDistance: N=1 always returns 0" { + try t.expectEqual(@as(f32, 0.0), text_wave.trailDistance(0, 7.5, 1)); +} + +test "trailDistance: N=0 always returns 0" { + try t.expectEqual(@as(f32, 0.0), text_wave.trailDistance(0, 3.0, 0)); +} + +test "colorStep: comet spans — head accent, 2 muted, 1 dark, then accent rest" { + // Absolute spans, no N-relative gradient: + // trail 0 → 0 (head, warm_accent) + // trail 1..2 → 1 (warm_muted) + // trail 3 → 2 (warm_border, one scalar) + // trail >= 4 → 0 (accent rest of the line) + try t.expectEqual(@as(usize, 0), text_wave.colorStep(0.0)); + try t.expectEqual(@as(usize, 1), text_wave.colorStep(1.0)); + try t.expectEqual(@as(usize, 1), text_wave.colorStep(2.0)); + try t.expectEqual(@as(usize, 2), text_wave.colorStep(3.0)); + try t.expectEqual(@as(usize, 0), text_wave.colorStep(4.0)); +} + +test "colorStep: far-from-head trail is step 0 (accent rest), not dark" { + // The old N-relative curve put dist >= N/3 on warm_border; the comet keeps + // everything past the 3-scalar trail on warm_accent. + try t.expectEqual(@as(usize, 0), text_wave.colorStep(4.0)); + try t.expectEqual(@as(usize, 0), text_wave.colorStep(5.0)); + try t.expectEqual(@as(usize, 0), text_wave.colorStep(10.0)); + try t.expectEqual(@as(usize, 0), text_wave.colorStep(17.0)); +} + +test "colorStep: never returns 3 (warm_border is the dark stop, not surface)" { + var trail: f32 = 0.0; + while (trail < 256.0) : (trail += 1.0) { + const step = text_wave.colorStep(trail); + try t.expect(step <= 2); + } + // Boundary is exact: the single dark scalar ends at 3; 4 is accent. + try t.expectEqual(@as(usize, 2), text_wave.colorStep(3.0)); + try t.expectEqual(@as(usize, 0), text_wave.colorStep(4.0)); +} + +test "N=18 comet occupancy: exactly 1 dark, 2 muted, 15 accent (1 head + 14 rest)" { + // Any integer head on a ring of 18 yields every trail 0..17 exactly once, + // so the count is phase-invariant: 15 step-0 (head + 14 accent rest), + // 2 step-1 (muted), 1 step-2 (border). Dark occupancy is 1/18, not ~6/18. + var s0: usize = 0; + var s1: usize = 0; + var s2: usize = 0; + var i: usize = 0; + while (i < 18) : (i += 1) { + const step = text_wave.colorStep(text_wave.trailDistance(i, 5.0, 18)); + switch (step) { + 0 => s0 += 1, + 1 => s1 += 1, + 2 => s2 += 1, + else => unreachable, + } + } + try t.expectEqual(@as(usize, 15), s0); + try t.expectEqual(@as(usize, 2), s1); + try t.expectEqual(@as(usize, 1), s2); +} + +test "phase 0 domain: comet paints a dim tail at the trailing edge (fast-path rationale)" { + // Plan #669 Goal 4 + DoD: reduced motion / old host → solid warm_accent. + // The paint function fast-paths phase==0 with one addText. This test locks + // the DOMAIN LOGIC that MAKES the fast-path necessary: with the directed + // comet at head 0, the scalars immediately BEHIND index 0 on the ring are + // the string's trailing edge, and they land on muted/border — not accent. + // If someone removes the fast-path, reduced motion would show a stray dim + // tail once per wrap. + // + // head=0, N=18: trailDistance(i, 0, 18) = mod(0 - i + 18, 18). + // i=0 → trail 0 → step 0 (head) + // i=15 → trail 3 → step 2 (dark) + // i=16 → trail 2 → step 1 (muted) + // i=17 → trail 1 → step 1 (muted) + try t.expectEqual(@as(usize, 0), text_wave.colorStep(text_wave.trailDistance(0, text_wave.headPosition(0, 18), 18))); + try t.expectEqual(@as(usize, 2), text_wave.colorStep(text_wave.trailDistance(15, text_wave.headPosition(0, 18), 18))); + try t.expectEqual(@as(usize, 1), text_wave.colorStep(text_wave.trailDistance(16, text_wave.headPosition(0, 18), 18))); + try t.expectEqual(@as(usize, 1), text_wave.colorStep(text_wave.trailDistance(17, text_wave.headPosition(0, 18), 18))); } test "UTF-8 safety: multi-byte scalar splits at codepoint boundary" { @@ -137,20 +168,27 @@ test "UTF-8 safety: multi-byte scalar splits at codepoint boundary" { // Each scalar gets one addText slice; é is 2 bytes but one scalar. const N = text_wave.countScalars("café"); try t.expectEqual(@as(usize, 4), N); - // Verify the head position computation works for this N. - try t.expectApproxEqAbs(2.0, text_wave.headPosition(6, 4), 0.05); - // cyclic distance for i=1, head=2.0, N=4: - // |1-2.0| = 1.0; min(1.0, 3.0) = 1.0 - // denom = 2.0, raw = 1.0*3/2 = 1.5 → floor 1 → step 1 - try t.expectEqual(@as(usize, 1), text_wave.colorStep(text_wave.cyclicDistance(1, 2.0, 4), 4)); -} - -test "countScalars: emoji (multi-byte) counts as one scalar" { - // U+1F600 (😀) is 4 bytes, 1 scalar. - const grinning = "😀"; - try t.expectEqual(@as(usize, 1), text_wave.countScalars(grinning)); -} - -test "STEPS constant is 3" { + // Head at scalar 2 (SPEED=2): phase 3 → raw=6, 6%12=6, /3=2.0. + try t.expectApproxEqAbs(2.0, text_wave.headPosition(3, 4), 0.05); + // Comet behind the head at scalar 2 → scalar 1 is muted (trail 1). + try t.expectEqual(@as(usize, 1), text_wave.colorStep(text_wave.trailDistance(1, 2.0, 4))); +} + +test "headPosition: 255→1 wrap teleport — pin the discontinuous frame (review #671 L1+L9)" { + // The u8 phase 1..255 is incommensurate with the 27-tick comet period + // (period = 18*3 = 54 raw; 255*2 = 510; 510%54 = 24; head = 24/3 = 8.0). + // Next tick: phase 1 → raw=2; 2%54 = 2; head = 2/3 ≈ 0.667. The comet + // head teleports ~7 glyphs in one 100 ms frame. This test pins the + // pre-existing wrap so any follow-up remap can measure the delta. + // Fix: wider phase or wrap at a multiple of N*STEPS — not an occupancy tweak. + try t.expectApproxEqAbs(8.0, text_wave.headPosition(255, 18), 0.01); + try t.expectApproxEqAbs(0.667, text_wave.headPosition(1, 18), 0.05); + // The jump: |8.0 - 0.667| ≈ 7.33 scalars — a visible teleport. + const delta = @abs(text_wave.headPosition(255, 18) - text_wave.headPosition(1, 18)); + try t.expect(delta > 5.0); +} + +test "STEPS constant is 3, SPEED constant is 2" { try t.expectEqual(@as(usize, 3), text_wave.STEPS); + try t.expectEqual(@as(u8, 2), text_wave.SPEED); } diff --git a/native/harness/src/text_wave.zig b/native/harness/src/text_wave.zig index e73474a6..70425b10 100644 --- a/native/harness/src/text_wave.zig +++ b/native/harness/src/text_wave.zig @@ -1,11 +1,19 @@ //! Generic reusable text wave — paints a string with a left-to-right cyclic //! color wave driven by a tick phase. Same module shape as `rect_spinner.zig`: -//! callers pass a palette ramp (4-stop ColorRamp), a tag, and an id_extra -//! base. No dependency on busy row / bridge / clock. +//! callers pass a palette ramp (ColorRamp), a tag, and an id_extra base. The +//! comet wave uses 3 of the 4 stops — ramp[3] is never indexed (reserved for +//! rect_spinner off-cells). No dependency on busy row / bridge / clock. //! //! Wave algorithm: per-scalar `addText` with individual `.color_text` (the only -//! way to vary color per glyph in dvui). STEPS=3 sub-char smoothing gives ~5.4 s -//! full cycle for 18 chars at 10 Hz. +//! way to vary color per glyph in dvui). STEPS=3 sub-char smoothing gives ~2.7 s +//! full cycle for 18 chars at 10 Hz (SPEED=2 doubles the head's advance per +//! tick — 18*3/2 = 27 ticks). +//! +//! Occupancy is a **localized directed comet** behind the traveling head, not a +//! full-ring gradient: at any phase the head scalar is `warm_accent`, the next +//! 2 scalars behind it are `warm_muted`, the next 1 is `warm_border`, and the +//! rest of the line stays `warm_accent` (readable). Dark occupancy is ≤ 1 +//! scalar, never ~1/3 of the sentence. //! //! Color ramp: reuses `rect_spinner.ColorRamp` but caps at index 2 (warm_border //! #3a2818) — warm_surface (#1a120c) is ~1:1 on teal_bg and unreadable as body @@ -13,9 +21,13 @@ //! at step 2. //! //! Reduced motion: phase 0 → fast-path solid `ramp[0]` on all glyphs (no -//! per-scalar wave). The bridge reserves phase 0 for idle/stop/error — busy -//! ticks map to 1..255 and wrap at 255→1 (never 0), so the animation never -//! flashes solid after 25.6 s. +//! per-scalar wave). Without it the comet would paint a dim muted/border tail +//! at the string's trailing edge whenever the wave phase sits near index 0. +//! The bridge reserves phase 0 for idle/stop/error — busy ticks map to 1..255 +//! and wrap at 255→1 at 10 Hz (never 0), so the animation wraps after 25.6 s. +//! The 255→1 wrap is a single discontinuous frame (~7-glyph teleport at N=18) +//! because the u8 phase range is incommensurate with the 27-tick comet period; +//! fixing it requires a wider phase or a remap — not an occupancy tweak. const std = @import("std"); const dvui = @import("dvui"); @@ -23,14 +35,27 @@ const rect_spinner = @import("rect_spinner.zig"); /// Sub-char smoothing steps: 3 steps per scalar for smooth travel. pub const STEPS: usize = 3; +/// Head advance per tick. 2 doubles the wave speed: `N * STEPS / SPEED` ticks +/// per loop (18*3/2 = 27 ticks = ~2.7 s at 10 Hz). +pub const SPEED: u8 = 2; + +/// Head scalar occupies `HEAD_SPAN` scalars (accent). Absolute paint constants — +/// not transport budgets. +pub const HEAD_SPAN: f32 = 1.0; +/// `warm_muted` scalars immediately behind the head. +pub const MUTED_SPAN: f32 = 2.0; +/// Single `warm_border` scalar behind the muted trail — the dark stop is one +/// scalar, not a third of the string. +pub const DARK_SPAN: f32 = 1.0; pub const Options = struct { /// The string to wave (e.g. "Waiting for model…"). text: []const u8, /// Current tick phase from busyTick() (u8, 1..255 while busy, 0 = idle). phase: u8, - /// 4-step palette ramp: [head, trail1, trail2, rest]. text_wave caps at - /// index 2 — ramp[3] is for rect_spinner off-cells, not body text. + /// Palette ramp: text_wave uses 3 stops — ramp[0] is head + accent rest, + /// ramp[1] is muted, ramp[2] is the single dark scalar. ramp[3] is never + /// indexed by text_wave (reserved for rect_spinner off-cells). ramp: rect_spinner.ColorRamp, /// dvui tag for the inner textLayout (e.g. "busy-waiting-text"). tag: []const u8, @@ -53,33 +78,36 @@ pub fn countScalars(text: []const u8) usize { } /// Compute the float sub-char head position for a given phase and scalar count N. -/// head = (phase % (N * STEPS)) / STEPS, or 0 when N == 0. +/// head = ((phase * SPEED) % (N * STEPS)) / STEPS, or 0 when N == 0. +/// The phase*SPEED multiply is done in u32 so phase 255*2 never overflows u8. pub fn headPosition(phase: u8, N: usize) f32 { if (N == 0) return 0; - const fN: f32 = @floatFromInt(N); - const denom = fN * @as(f32, @floatFromInt(STEPS)); - const raw: f32 = @floatFromInt(phase); - return @mod(raw, denom) / @as(f32, @floatFromInt(STEPS)); + const period: u32 = @intCast(N * STEPS); // e.g. 54 for N=18 + const raw: u32 = @as(u32, phase) * @as(u32, SPEED); // 255*2 must not wrap u8 + return @as(f32, @floatFromInt(raw % period)) / @as(f32, @floatFromInt(STEPS)); } -/// Cyclic distance from scalar index `i` to float head position `head` on a -/// ring of `N` elements. Returns 0 when N <= 1. -pub fn cyclicDistance(i: usize, head: f32, N: usize) f32 { +/// How far scalar index `i` sits *behind* the float head on a ring of `N` +/// scalars: 0 at the head, increasing toward the tail (the direction of +/// travel). Ring-wrapped so a head near the end of the string still has a +/// contiguous behind-it trail. Returns 0 when N <= 1. +pub fn trailDistance(i: usize, head: f32, N: usize) f32 { if (N <= 1) return 0; - const fi: f32 = @floatFromInt(i); const fN: f32 = @floatFromInt(N); - const d = @abs(fi - head); - return @min(d, fN - d); + const fi: f32 = @floatFromInt(i); + return @mod(head - fi + fN, fN); } -/// Map cyclic distance to a color ramp index (0–2). Capped at 2 (warm_border) +/// Map a trail distance to a color ramp index (0–2). Capped at 2 (warm_border) /// — never returns 3 (warm_surface, unreadable as body text on teal_bg). -/// dist=0 → 0 (head, warm_accent), dist=N/2 → 2 (warm_border). -pub fn colorStep(dist: f32, N: usize) usize { - if (N <= 1) return 0; - const denom = @as(f32, @floatFromInt(N)) / 2.0; - const raw = dist * 3.0 / denom; - return @min(@as(usize, @intFromFloat(@floor(raw))), 2); +/// trail < HEAD_SPAN → 0 (head, warm_accent); then MUTED_SPAN of step 1; +/// then DARK_SPAN of step 2; everything farther behind → step 0 (accent rest). +/// Occupancy is absolute scalars — no N-relative full-ring gradient. +pub fn colorStep(trail: f32) usize { + if (trail < HEAD_SPAN) return 0; + if (trail < HEAD_SPAN + MUTED_SPAN) return 1; + if (trail < HEAD_SPAN + MUTED_SPAN + DARK_SPAN) return 2; + return 0; // rest of the line — accent } /// Paint the text wave: a `dvui.textLayout` with per-scalar color from the @@ -111,7 +139,8 @@ pub fn paint(src: std.builtin.SourceLocation, opts: Options) void { // Phase 0 is reserved by the bridge for idle/stop/error — busy ticks are // 1..255 and wrap 255→1 (never 0). So phase 0 reliably means reduced // motion, old host, or boot. Fast-path the whole string as one addText - // at ramp[0] — no wave, no gradient tail. + // at ramp[0] — no wave, no dim comet tail (at head 0 the scalars right + // behind index 0 on the ring would land muted/border). if (opts.phase == 0) { tl.addText(opts.text, .{ .color_text = opts.ramp[0] }); if (opts.suffix_text) |suffix| { @@ -128,8 +157,8 @@ pub fn paint(src: std.builtin.SourceLocation, opts: Options) void { while (iter.nextCodepoint()) |_| { const slice = opts.text[prev_i..iter.i]; prev_i = iter.i; - const dist = cyclicDistance(i, head, N); - const step = colorStep(dist, N); + const trail = trailDistance(i, head, N); + const step = colorStep(trail); tl.addText(slice, .{ .color_text = opts.ramp[step] }); i += 1; }