Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/harness-limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
19 changes: 18 additions & 1 deletion native/harness/build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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(.{
Expand Down Expand Up @@ -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.
Expand Down
23 changes: 13 additions & 10 deletions native/harness/src/bridge.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}

Expand Down
52 changes: 26 additions & 26 deletions native/harness/src/busy_row.zig
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
});
}
35 changes: 35 additions & 0 deletions native/harness/src/busy_row_layout.test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions native/harness/src/composer_text.test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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) ────────────────────────────────
Expand Down
Loading
Loading