Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 2 additions & 2 deletions docs/harness-limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ Vertical bands inside the Wasm root (not a DOM panel):
|------|----------|
| Composer visibility | Fully on-canvas while the harness is ready; not optional |
| Height budget | Every frame: absolute-rect bands — the transcript band is **`[left rail \| scrollArea]`** from the canvas top to the queue band (or the composer when the queue is empty). Height = viewport − dynamic composer `composer_h` − optional queue band − status bar `STATUS_BAR_H`; no inter-band gap. The rail is a sibling `Options.rect` of the scroller (closed 40 px, open 220 px TEAL session-list column); the scroller’s `Options.rect.x` is the rail width so both share `scroll_h`. Composer sits above the status bar with dynamic height from previous-frame measurement (idle ~44 px, max 124 px), full canvas width. The queue band sits **above** the composer and does **not** move it. Status bar absolute-rect flush to the canvas bottom, full width. Transcript scroller **and** composer use `Options.rect` so neither participates in root flex; the scrollArea's `.auto` bar cannot publish virtual content height as min-size (dvui `ScrollContainerWidget.deinit` overwrites `min_size.h` with full content). Tall content cannot push chrome off-canvas |
| 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. `scroll_horizontal = false` on the textEntry so the inner `ScrollContainerWidget` gives the child a finite viewport width (`.none` → child gets `viewport.w`, not the unwrapped `min_size.w`), letting `break_lines` fire at the field width instead of h-scrolling. The outer `max_size_content.w` is `max_float_safe` (stripped by `TextEntryWidget.init` before reaching the inner layout — not the wrap mechanism). 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(s) on the **same row** as the field, **bottom-pinned** (`gravity_y = 1.0`) so they stay glued to the status bar while the field grows up. Idle = one ▶ Send (submit when non-empty). Busy = ▶ enqueue (left) + ■ Stop (rightmost; protocol v9 `queueCancelFromUi` → host abort). No labelled Stop/Send pill, no hint copy. Glyphs from the embedded DejaVu Sans Symbols face (no tofu) |
| 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. `scroll_horizontal = false` on the textEntry so the inner `ScrollContainerWidget` gives the child a finite viewport width (`.none` → child gets `viewport.w`, not the unwrapped `min_size.w`), letting `break_lines` fire at the field width instead of h-scrolling. **`#734` width bound (plan #737):** the field's `max_size_content.w` is `field_w = avail_w − (TOUCH_H×n + 8)` — the leftover after a **reserved** trailing icon pack (n = 1 idle / 2 busy; the 8 is the field's right margin) — and the textEntry sits on an explicit trailing-reserved sub-rect, so a long unbreakable line's natural min width can never squeeze the ▶/■ icons off-canvas. The reserved field wrapper reports `min=max=field_w` to the box, so the icons always land post-reserve; the field wraps at `field_w` (or clips inside via `scroll_horizontal = false`). The Send/Stop icons at `gravity_y = 1.0` stay 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(s) on the **same row** as the field, **bottom-pinned** (`gravity_y = 1.0`) so they stay glued to the status bar while the field grows up. The icons sit in a **trailing-reserved strip** `TOUCH_H×n + 8` px (plan #737/#734): the field is width-bounded to the leftover after that reserve, so the icons always keep their full `TOUCH_H` square and are never squeezed off-canvas by a long unbreakable line — the field **yields** to the reserve, never the icon. Idle = one ▶ Send (submit when non-empty). Busy = ▶ enqueue (left) + ■ Stop (rightmost; 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` — 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 stores the host's monotonic `u32` tick as-is; `0` is reserved for idle / reduced motion / busy-start. The comet wraps only on `N*STEPS` (visible loop), not on a u8 fold. 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 |
Expand Down
26 changes: 26 additions & 0 deletions native/harness/build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,32 @@ pub fn build(b: *std.Build) void {
test_rich.dependOn(&b.addRunArtifact(busy_row_layout_tests).step);
}

// Host dvui testing-backend layout-rect tests for composer_chrome.zig
// (plan #737, source #734). Drives the REAL extracted `paintComposerChrome`
// and locks the trailing-reserved icon-pack geometry: the field is
// width-bounded to `avail_w − (TOUCH_H×n + 8)` so a long unbreakable line
// can never squeeze the ▶/■ icons off-canvas. composer_chrome imports
// chrome → rich/toolrun → bridge.zig, so the test needs BOTH dvui
// (dvui_testing) and the web-backend stub (mirrors queue_band_tests /
// composer_tests wiring).
{
const composer_layout_tests = b.addTest(.{
.name = "composer_layout",
.root_module = b.createModule(.{
.root_source_file = b.path("src/composer_layout.test.zig"),
.target = host_target,
.optimize = optimize,
}),
});
composer_layout_tests.root_module.addImport("dvui", dvui_testing_dep.module("dvui_testing"));
composer_layout_tests.root_module.addImport("web-backend", b.createModule(.{
.root_source_file = b.path("src/test_web_backend_stub.zig"),
.target = host_target,
.optimize = optimize,
}));
test_rich.dependOn(&b.addRunArtifact(composer_layout_tests).step);
}

// Host dvui testing-backend layout-rect tests for transcript_split.zig
// (empty collapsible left rail). Closed 40 / open 220 widths + toggle tag.
{
Expand Down
176 changes: 176 additions & 0 deletions native/harness/src/composer_layout.test.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
//! Host dvui testing-backend layout-rect tests for `composer_chrome.zig`
//! (plan #737, source #734). Lock the width-reservation geometry from the REAL
//! painted widget tree — the field lives on an explicit trailing-*reserved*
//! sub-rect `field_w = avail_w − (TOUCH_H×n + 8)`, so a long unbreakable line's
//! reported min width can never squeeze the ▶/■ icon pack off-canvas (mirrors
//! the `busy_row_layout.test.zig` pattern — drives `paintComposerChrome` the
//! same way that test drives `paintBusyRow`).
//!
//! No pixels, no SDL/GLFW/OpenGL: the dvui testing backend computes layout
//! rects (draw is a no-op). **Two frames** are needed because auto-sized boxes
//! start at 0×0 on frame 1 before children report sizes upward in deinit.
//!
//! The testing backend uses a **2× physical pixel scale** (window 600×400 →
//! 1200×800). All assertions use physical pixels via `windowRectPixels()`,
//! with `PX = 2`.
//!
//! dvui tag rects **include the widget's margin**.

const std = @import("std");
const t = std.testing;
const dvui = @import("dvui");
const metrics = @import("ui/metrics.zig");
const composer_chrome = @import("ui/composer_chrome.zig");

/// Sub-pixel rounding tolerance (physical px).
const EPS: f32 = 1.0;
/// Physical pixel scale: testing-backend init defaults to 2×.
const PX: f32 = 2;
/// Logical window content width — the composer chrome box (`expand =
/// .horizontal`) fills this, so the paint fn is invoked with `avail_w` == it.
const WIN_LW: f32 = 600;

/// Fields shared between the frame closure and each test (Zig has no closures,
/// so the test body stages these before stepping the frame).
var T_buf: [512]u8 = [_]u8{0} ** 512;
var T_busy: bool = false;
var T_want_focus: bool = true;
var T_last_res: composer_chrome.Result = .{};

const FieldRects = struct {
wrap: dvui.Rect.Physical,
field: dvui.Rect.Physical,
send: dvui.Rect.Physical,
stop: ?dvui.Rect.Physical,
};

/// Paint TWO frames of the real `paintComposerChrome` and return the tag rects
/// from the second frame. `T_last_res` holds the second frame's Result.
fn paintAndGetRects() FieldRects {
const frame = struct {
fn paint() !dvui.App.Result {
T_last_res = composer_chrome.paintComposerChrome(.{
.busy = T_busy,
.avail_w = WIN_LW,
.y = 0,
.h = metrics.COMPOSER_IDLE_CHROME_H,
.prompt_buf = &T_buf,
.want_focus = &T_want_focus,
});
return .ok;
}
}.paint;

_ = dvui.testing.step(frame) catch @panic("step 1 failed");
_ = dvui.testing.step(frame) catch @panic("step 2 failed");

const wrap = (dvui.tagGet("composer-field-wrap") orelse @panic("tag 'composer-field-wrap' not found")).rect;
const field = (dvui.tagGet("composer-field") orelse @panic("tag 'composer-field' not found")).rect;
const send = (dvui.tagGet("composer-send") orelse @panic("tag 'composer-send' not found")).rect;
const stop = if (dvui.tagGet("composer-stop")) |tr| tr.rect else null;
return .{ .wrap = wrap, .field = field, .send = send, .stop = stop };
}

/// Zero the shared prompt buffer.
fn resetBuf() void {
@memset(&T_buf, 0);
}

test "idle: field on reserved sub-rect, ▶ lands post-reserve, field right ≤ ▶ left" {
var tr = try dvui.testing.init(.{});
defer tr.deinit();
resetBuf();
T_busy = false;
T_want_focus = true;
const r = paintAndGetRects();
const win = dvui.windowRectPixels();

const fw = composer_chrome.fieldW(WIN_LW, false);
// The reserve holds the wrap to exactly `field_w` — the seam proof that
// the wrapper is a PACKED child (gravity.x=0), not a centered overlay.
try t.expectApproxEqAbs(fw * PX, r.wrap.w, EPS);
// ▶ lands right after the reserved wrap.
try t.expectApproxEqAbs(r.wrap.x + r.wrap.w, r.send.x, EPS);
// Field right edge (incl. its 8 px right margin in the tag rect) ≤ ▶ left.
try t.expect(r.field.x + r.field.w <= r.send.x + EPS);
// ▶ fully on-canvas (tag rects include the button's default margin, so we
// assert placement + on-canvas, not an exact TOUCH_H content size).
try t.expect(r.send.x + r.send.w <= win.w + EPS);
try t.expect(r.send.w > 0);
try t.expect(r.send.h > 0);
}

test "busy: ▶ + ■ both land post-reserve (2-slot reserve) and stay on-canvas" {
var tr = try dvui.testing.init(.{});
defer tr.deinit();
resetBuf();
T_busy = true;
T_want_focus = true;
const r = paintAndGetRects();
const win = dvui.windowRectPixels();
const stop = r.stop orelse @panic("busy must render ■ stop");

const fw = composer_chrome.fieldW(WIN_LW, true);
try t.expectApproxEqAbs(fw * PX, r.wrap.w, EPS);
try t.expectApproxEqAbs(r.wrap.x + r.wrap.w, r.send.x, EPS);
// ■ starts exactly one TOUCH_H after ▶ (adjacent squares, distinct tags).
try t.expectApproxEqAbs(r.send.x + r.send.w, stop.x, EPS);
// Field right edge ≤ ▶ left (field wraps one icon narrower when busy).
try t.expect(r.field.x + r.field.w <= r.send.x + EPS);
// Both icons fully on-canvas (tag rects include each button's default
// margin, so assert placement + on-canvas, not an exact TOUCH_H size).
try t.expect(stop.x + stop.w <= win.w + EPS);
try t.expect(stop.w > 0);
try t.expect(stop.h > 0);
}

test "#734 lock: a long single unbreakable line does NOT push ▶ off-canvas" {
var tr = try dvui.testing.init(.{});
defer tr.deinit();
resetBuf();
// A 200×'A' run with NO spaces: `break_lines` cannot split it, so the
// field's natural width (the full run) would, WITHOUT the reserved
// sub-rect, take the whole row and drive the icons off-canvas. With the
// fix the icons must stay exactly post-reserve.
@memset(&T_buf, 'A');
T_buf[200] = 0;
T_busy = false;
T_want_focus = true;
const r = paintAndGetRects();
const win = dvui.windowRectPixels();

// Wrap still reports exactly field_w — the field never overflowed the
// reserve (field min cannot steal the icon slot, no horizontal gutter).
try t.expectApproxEqAbs(composer_chrome.fieldW(WIN_LW, false) * PX, r.wrap.w, EPS);
// send.x lands at the reserved post-reserve position, NOT drifting right.
try t.expectApproxEqAbs((WIN_LW - composer_chrome.iconReserveW(false)) * PX, r.send.x, EPS);
// Field right edge still ≤ ▶ left.
try t.expect(r.field.x + r.field.w <= r.send.x + EPS);
// ▶ fully on-canvas — the #734 regression cannot return.
try t.expect(r.send.x + r.send.w <= win.w + EPS);
}

test "multi-line prompt: measured outer height grows (dynamic hug intact through extraction)" {
var tr = try dvui.testing.init(.{});
defer tr.deinit();
resetBuf();
T_busy = false;
T_want_focus = true;
_ = paintAndGetRects(); // single blank line → baseline measured height
try t.expect(T_last_res.measured_h != null);
const single_h = T_last_res.measured_h orelse return;

resetBuf();
const multi = "line one\nline two\nline three\nline four\n";
@memcpy(T_buf[0..multi.len], multi);
T_buf[multi.len] = 0;
T_want_focus = false;
_ = paintAndGetRects();
try t.expect(T_last_res.measured_h != null);
const multi_h = T_last_res.measured_h orelse return;

// The extraction must not have broken the vertical wrap measurement: a
// 4-line prompt reports a taller field than a single blank line. (Host
// build includes freetype, so wrapped textLayout heights are real.)
try t.expect(multi_h > single_h + EPS);
}
Loading
Loading