diff --git a/docs/harness-limits.md b/docs/harness-limits.md index 34905d24..0c55692e 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**. (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`), **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:** grid static at phase 0 (head **bottom-left**) — only the per-tick pulse push is skipped; the live `mm:ss` **clock feed keeps running**. Old host + new Wasm: `busy_tick` stays 0 → static grid (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` — 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 | | 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/build.zig b/native/harness/build.zig index b9051ba3..bba661f1 100644 --- a/native/harness/build.zig +++ b/native/harness/build.zig @@ -166,7 +166,7 @@ pub fn build(b: *std.Build) void { test_parse.dependOn(&run_parse_tests.step); // Host unit tests for cache / link allowlist / kind gate (no dvui frame). - const test_rich = b.step("test-rich", "Run rich/* host unit tests (parse, cache, links, kinds, image_cache, math, math_cache, diff_lang, highlight, unicode_face, blockquote, table, thematic, footnote, deflist) + composer_text + cwd_slot + ring_slot (#404 write seam) + chip_preview (#645) + rect_spinner (#651)"); + const test_rich = b.step("test-rich", "Run rich/* host unit tests (parse, cache, links, kinds, image_cache, math, math_cache, diff_lang, highlight, unicode_face, blockquote, table, thematic, footnote, deflist) + composer_text + cwd_slot + ring_slot (#404 write seam) + chip_preview (#645) + text_wave (#655) + rect_spinner (#651) + busy_spinner + elapsed_clock + model_catalog + session_catalog"); test_rich.dependOn(&run_parse_tests.step); const cache_tests = b.addTest(.{ @@ -279,6 +279,23 @@ pub fn build(b: *std.Build) void { test_rich.dependOn(&b.addRunArtifact(busy_spinner_tests).step); } + // Host unit tests for text_wave.zig (plan #655): head position, cyclic + // distance, color-step mapping, scalar count, empty/single-char/UTF-8 edges, + // full-cycle ramp coverage. Pure logic, no dvui frame — but text_wave.zig + // imports dvui (via rect_spinner.ColorRamp), so add dvui_testing. + { + const text_wave_tests = b.addTest(.{ + .name = "text_wave", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/text_wave.test.zig"), + .target = host_target, + .optimize = optimize, + }), + }); + text_wave_tests.root_module.addImport("dvui", dvui_testing_dep.module("dvui_testing")); + test_rich.dependOn(&b.addRunArtifact(text_wave_tests).step); + } + // Host unit tests for rect_spinner.zig (plan #651 L6 lock): TEAL_IDLE_RAMP // constant — all four entries are teal_muted. Imports rect_spinner.zig, // which needs dvui + palette; tests are pure, no frame. diff --git a/native/harness/src/bridge.zig b/native/harness/src/bridge.zig index d6b298e6..7145e339 100644 --- a/native/harness/src/bridge.zig +++ b/native/harness/src/bridge.zig @@ -106,10 +106,10 @@ var lifecycle: Lifecycle = .boot; /// busy row hides the clock while this is 0. var turn_elapsed: u32 = 0; /// Protocol v14 addendum (plan #574) — host 10 Hz busy-tick phase counter that -/// drives the 2×4 WARM spinner (clockwise pulse). Scalar u8 wraps naturally; -/// the spinner's 8-cell cycle uses `busy_tick % 8`. `0` = head at bottom-left -/// (also the static/reduced-motion / old-host value). Reset on `reset()` and on -/// a host push of 0 (idle/stop/error/clear). +/// drives the 2×4 WARM spinner (clockwise pulse) and text_wave (plan #655). +/// Reserved 0 = idle/stop/error/reduced-motion sentinel. The bridge remaps +/// host ticks to 1..255 (wrapping 255→1) so a long turn never wraps to 0 +/// mid-animation. Reset on `reset()` and on a host push of 0. var busy_tick: u8 = 0; var messages: [MAX_MSG]StoredMsg = [_]StoredMsg{.{}} ** MAX_MSG; var msg_head: usize = 0; @@ -722,13 +722,16 @@ export fn inv_set_turn_elapsed(secs: u32) void { } /// Protocol v14 addendum (plan #574) — host 10 Hz busy-tick phase for the 2×4 -/// spinner. Scalar u8 (truncated from the host's monotonic tick counter; wraps -/// naturally at 256 ≫ the 8-cell cycle). `phase == 0` → head at bottom-left, which -/// is also the reduced-motion / idle / old-host value. Each write calls -/// `refresh()` so the canvas reconstitutes at up to 10 Hz while Busy (see -/// `HARNESS_BUSY_TICK_HZ` in docs — well below the dvui 60 fps ceiling). +/// spinner and text_wave. The host passes a monotonic u32 tick counter; this +/// export remaps it to u8 reserving 0 for idle/stop/error: +/// phase == 0 → busy_tick = 0 (reduced-motion / idle / old-host sentinel) +/// phase > 0 → busy_tick = (phase-1) % 255 + 1 (range 1..255, wraps 255→1) +/// This guarantees a long busy turn (≥ 25.6 s) never wraps to 0 mid-animation, +/// preventing a 1-frame flash of solid accent in text_wave.zig. Each write +/// calls `refresh()` so the canvas reconstitutes at up to 10 Hz while Busy +/// (see `HARNESS_BUSY_TICK_HZ` in docs — well below the dvui 60 fps ceiling). export fn inv_set_busy_tick(phase: u32) void { - busy_tick = @truncate(phase); + busy_tick = if (phase == 0) @as(u8, 0) else @as(u8, @intCast((phase - 1) % 255 + 1)); refresh(); } diff --git a/native/harness/src/busy_row.zig b/native/harness/src/busy_row.zig index af3ba9c2..4070d957 100644 --- a/native/harness/src/busy_row.zig +++ b/native/harness/src/busy_row.zig @@ -1,7 +1,8 @@ //! Busy-row chrome — the 2×4 WARM spinner (plan #574, extracted to -//! `rect_spinner.zig` in #607) + "Waiting for model…" + optional v14 -//! ` · mm:ss` clock, painted as nested dvui boxes + a text layout sharing one -//! horizontal row container. +//! `rect_spinner.zig` in #607) + text-wave "Waiting for model…" (plan #655, +//! extracted to `text_wave.zig`) + optional v14 ` · mm:ss` clock, painted as +//! nested dvui boxes + one textLayout (clock is suffix_text inside text_wave +//! so the clock stays in the same text run as the waiting copy). //! //! Standalone on purpose (no `ui.zig` / `bridge` / wasm-web glue) so the host //! dvui **testing-backend** test `busy_row_layout.test.zig` can run the exact @@ -27,6 +28,7 @@ const std = @import("std"); const dvui = @import("dvui"); const palette = @import("palette.zig"); const rect_spinner = @import("rect_spinner.zig"); +const text_wave = @import("text_wave.zig"); const elapsed_clock = @import("elapsed_clock.zig"); /// Re-export geometry constants from `rect_spinner.zig` for backward compat @@ -50,11 +52,11 @@ pub const LEAD: f32 = 8; pub const TRAIL: f32 = 6; // id namespace for the busy row inner boxes — never aliases message-loop rows -// (which use their own high ids) or the busy textLayout (`0xffff_ffff`). +// (which use their own high ids) or the busy textLayout (TEXT_WAVE_ID). const LEAD_WRAPPER_ID = 0x60_0050; const SPINNER_ID = 0x60_00a0; const ROW_CONTAINER_ID = 0x60_0000; -const TEXT_ID = 0xffff_ffff; +const TEXT_WAVE_ID = 0x60_0100; /// Paint the whole busy row (spinner + "Waiting for model…" + optional v14 /// clock) inside one horizontal container. This is what `ui.zig` emits in its @@ -85,25 +87,23 @@ pub fn paintBusyRow(phase: u8, turn_elapsed: u32) void { .id_extra = SPINNER_ID, .margin_right = TRAIL, }); - { - var tl = dvui.textLayout(src, .{}, .{ - .expand = .horizontal, - .background = false, - .color_text = palette.warm_accent, - .gravity_y = 0.5, - .tag = "busy-waiting-text", - .id_extra = TEXT_ID, - }); - tl.addText("Waiting for model…", .{}); - // Protocol v14 — whole-turn clock: append ` · mm:ss` only while > 0 so - // no bare `0:00` lingers at t=0. The host resets to 0 on idle/stop/ - // error/clear. Reduced motion keeps this clock (plan #574 Major). - if (turn_elapsed > 0) { - var clock_buf: [32]u8 = undefined; - const clock = elapsed_clock.formatElapsedClock(&clock_buf, turn_elapsed); - tl.addText(" · ", .{}); - tl.addText(clock, .{}); - } - tl.deinit(); - } + // Protocol v14 — whole-turn clock: format ` · mm:ss` when > 0 and pass + // it as the suffix_text to text_wave so the clock stays in the same + // textLayout as the waiting copy (L1 Major — plan #655). Skipped at + // t=0: the host resets to 0 on idle/stop/error/clear. + var clock_buf: [32]u8 = undefined; + var suffix_buf: [64]u8 = undefined; + const suffix: ?[]const u8 = if (turn_elapsed > 0) blk: { + const clock = elapsed_clock.formatElapsedClock(&clock_buf, turn_elapsed); + break :blk std.fmt.bufPrint(&suffix_buf, " · {s}", .{clock}) catch @panic("clock suffix overflow"); + } else null; + + text_wave.paint(src, .{ + .text = "Waiting for model…", + .phase = phase, + .ramp = rect_spinner.WARM_RAMP, + .tag = "busy-waiting-text", + .id_extra = TEXT_WAVE_ID, + .suffix_text = suffix, + }); } diff --git a/native/harness/src/busy_row_layout.test.zig b/native/harness/src/busy_row_layout.test.zig index 16e73365..57eeb6e7 100644 --- a/native/harness/src/busy_row_layout.test.zig +++ b/native/harness/src/busy_row_layout.test.zig @@ -272,6 +272,41 @@ test "all 8 cells share the same painted height (CELL)" { } } +test "clock suffix: text rect wider when turn_elapsed > 0 (plan #655 L1 Major)" { + var tr = try dvui.testing.init(.{}); + defer tr.deinit(); + // elapsed=0: no clock suffix, text = "Waiting for model…" only. + const text0 = rect: { + const frame = struct { + fn paint() !dvui.App.Result { + busy_row.paintBusyRow(0, 0); + return .ok; + } + }.paint; + _ = dvui.testing.step(frame) catch @panic("step 1 failed"); + _ = dvui.testing.step(frame) catch @panic("step 2 failed"); + break :rect (dvui.tagGet("busy-waiting-text") orelse @panic("tag 'busy-waiting-text' not found")).rect; + }; + // elapsed=60: clock suffix " · 1:00" appended inside the same textLayout. + // phase=7 exercises the wave path (not the phase-0 solid path; the bridge + // fix reserves 0 for idle so busy phases are always > 0). + const text60 = rect: { + const frame = struct { + fn paint() !dvui.App.Result { + busy_row.paintBusyRow(7, 60); + return .ok; + } + }.paint; + _ = dvui.testing.step(frame) catch @panic("step 3 failed"); + _ = dvui.testing.step(frame) catch @panic("step 4 failed"); + break :rect (dvui.tagGet("busy-waiting-text") orelse @panic("tag 'busy-waiting-text' not found")).rect; + }; + // Clock suffix must add measurable width vs plain "Waiting for model…". + try t.expect(text60.w > text0.w + EPS); + // Text origin must not shift (same LEAD + spinner + TRAIL prefix). + try t.expectApproxEqAbs(text0.x, text60.x, EPS); +} + /// Paint TWO `rect_spinner.paint` instances from the same call site in one /// frame, with disjoint `tag_prefix` values and `id_extra` bases spaced /// `ID_SPAN` apart. Returns both spinners' outer rects and their 8 cells, so diff --git a/native/harness/src/composer_text.test.zig b/native/harness/src/composer_text.test.zig index 84efdaf0..0505c139 100644 --- a/native/harness/src/composer_text.test.zig +++ b/native/harness/src/composer_text.test.zig @@ -6,11 +6,10 @@ const t = std.testing; const composer_text = @import("composer_text.zig"); const kinds = @import("rich/kinds.zig"); -const BUF: [64]u8 = undefined; +var BUF: [64]u8 = undefined; fn normalize(src: []const u8, cap: usize) composer_text.NormalizeResult { - var dest: [BUF.len]u8 = BUF; - return composer_text.normalizeInto(src, dest[0..], cap); + return composer_text.normalizeInto(src, BUF[0..], cap); } // ── paint-omit rule (issue #324 / plan #340) ──────────────────────────────── diff --git a/native/harness/src/text_wave.test.zig b/native/harness/src/text_wave.test.zig new file mode 100644 index 00000000..c52a720f --- /dev/null +++ b/native/harness/src/text_wave.test.zig @@ -0,0 +1,156 @@ +//! 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. + +const std = @import("std"); +const t = std.testing; +const text_wave = @import("text_wave.zig"); + +test "countScalars: empty string → 0" { + try t.expectEqual(@as(usize, 0), text_wave.countScalars("")); +} + +test "countScalars: ASCII + ellipsis = 18 scalars" { + // "Waiting for model…": W(1) a(2) i(3) t(4) i(5) n(6) g(7) ' '(8) + // f(9) o(10) r(11) ' '(12) m(13) o(14) d(15) e(16) l(17) …(18) + 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 "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 60 on 20-char text wraps → 0.0" { + // 20 * 3 = 60; phase 60 % 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 "cyclicDistance: N=0 always returns 0" { + try t.expectEqual(@as(f32, 0.0), text_wave.cyclicDistance(0, 3.0, 0)); +} + +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 "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 "UTF-8 safety: multi-byte scalar splits at codepoint boundary" { + // "café" = 4 scalars: c(1B) a(1B) f(1B) é(2B) + // 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" { + try t.expectEqual(@as(usize, 3), text_wave.STEPS); +} diff --git a/native/harness/src/text_wave.zig b/native/harness/src/text_wave.zig new file mode 100644 index 00000000..e73474a6 --- /dev/null +++ b/native/harness/src/text_wave.zig @@ -0,0 +1,140 @@ +//! 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. +//! +//! 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. +//! +//! 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 +//! text. The 4-stop LUT exists for `rect_spinner` filled cells; text_wave stops +//! 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. + +const std = @import("std"); +const dvui = @import("dvui"); +const rect_spinner = @import("rect_spinner.zig"); + +/// Sub-char smoothing steps: 3 steps per scalar for smooth travel. +pub const STEPS: usize = 3; + +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. + ramp: rect_spinner.ColorRamp, + /// dvui tag for the inner textLayout (e.g. "busy-waiting-text"). + tag: []const u8, + /// Base id for the inner textLayout's id_extra. + id_extra: usize, + /// Optional suffix appended at ramp[0] after all wave scalars, in the same + /// textLayout (e.g. " · 0:01" for the busy clock). Keeps the clock in the + /// same text run so kerning / middot spacing is correct. + suffix_text: ?[]const u8 = null, +}; + +/// Count UTF-8 scalars in text. Returns 0 for empty. +pub fn countScalars(text: []const u8) usize { + var n: usize = 0; + var iter = (std.unicode.Utf8Iterator{ .bytes = text, .i = 0 }); + while (iter.nextCodepoint()) |_| { + n += 1; + } + return n; +} + +/// 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. +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)); +} + +/// 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 { + 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); +} + +/// Map cyclic 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); +} + +/// Paint the text wave: a `dvui.textLayout` with per-scalar color from the +/// ramp, driven by `opts.phase`. When phase == 0 (reduced motion / idle / +/// boot), all glyphs paint solid `ramp[0]` (warm_accent) — no per-scalar +/// wave. Empty text → no-op (still creates + deinits the textLayout so the +/// caller's layout chain stays intact). +pub fn paint(src: std.builtin.SourceLocation, opts: Options) void { + const N = countScalars(opts.text); + + var tl = dvui.textLayout(src, .{}, .{ + .expand = .horizontal, + .background = false, + .padding = dvui.Rect.all(0), + .color_text = opts.ramp[0], + .gravity_y = 0.5, + .tag = opts.tag, + .id_extra = opts.id_extra, + }); + defer tl.deinit(); + + if (N == 0) { + if (opts.suffix_text) |suffix| { + tl.addText(suffix, .{ .color_text = opts.ramp[0] }); + } + return; + } + + // 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. + if (opts.phase == 0) { + tl.addText(opts.text, .{ .color_text = opts.ramp[0] }); + if (opts.suffix_text) |suffix| { + tl.addText(suffix, .{ .color_text = opts.ramp[0] }); + } + return; + } + + const head = headPosition(opts.phase, N); + + var iter = (std.unicode.Utf8Iterator{ .bytes = opts.text, .i = 0 }); + var i: usize = 0; + var prev_i: usize = 0; + 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); + tl.addText(slice, .{ .color_text = opts.ramp[step] }); + i += 1; + } + + if (opts.suffix_text) |suffix| { + tl.addText(suffix, .{ .color_text = opts.ramp[0] }); + } +}