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
8 changes: 4 additions & 4 deletions docs/harness-limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ While a turn is **Busy**, follow-up prompts go into an in-canvas FIFO **above**
|-------|----------|
| Enqueue | Busy Ctrl/Cmd+Enter or the extra ▶ icon appends the composer text. Idle ▶ still starts a turn immediately |
| Visibility | Compact TEAL list (header + up to 3 rows, extra rows scroll). Each row: first-line preview (100 bytes, UTF-8 safe, slash commands kept) + edit + remove |
| Edit / cancel | Edit in place (Ctrl+Enter / blur saves; Escape reverts). × removes one item. **Clear** empties the queue. The in-flight turn is untouched |
| Edit / cancel | Edit in place (Ctrl+Enter / blur saves; Escape reverts). removes one item. **Clear** empties the queue. The in-flight turn is untouched |
| When it runs | After the current turn reaches Ready or error, the head submits as a normal user turn (same `pending_submit` path). Promote is held while a row is being edited |
| Stop | Cancels **only** the in-flight turn. The queue stays and drains after Ready |
| Lifetime | Wasm-ephemeral. Refresh, New session, Clear, and session switch wipe it. Not stored on the cloud session |
Expand Down Expand Up @@ -217,7 +217,7 @@ Wasm canvas (`native/harness/src/rich/*`). **User, assistant, and thinking** get
| Caps | Same ring / 256 KiB line; paint all in-ring (≤2048) as above |
| Unicode | Message bodies are **UTF-8** end-to-end (host `TextEncoder` → Wasm ring → zmd parse → paint → Copy source). Integrity = scalars/bytes preserved; glyphs depend on the faces below |
| Fonts (embedded) | **Noto Sans** Regular/Bold/Italic/BoldItalic (body + rich emph) · **OpenMoji** black outline subset (emoji) · **DejaVu symbols** subset (arrows / math / dingbats / text-ornament Geometric Shapes missing from Noto, e.g. → `▾`) · **Vera Sans Mono** Regular/Bold (fences / inline code). Licenses: `native/harness/src/fonts/README.md` |
| Paint faces | Transcript paint **splits** emoji → OpenMoji, text symbols (arrows, CLI dingbats, text-ornament Geometric Shapes such as `▾`) → DejaVu symbols, else Noto Sans / mono (dvui has no automatic per-glyph fallback). Report separators (U+23AF, U+2500/U+2501, scan lines U+23BA–U+23BD) paint as Noto **U+2015** lookalikes **on the body face** (including inside fences / inline code — Vera has no U+2015) — Copy still yields the source scalar |
| Paint faces | Transcript paint **splits** emoji → OpenMoji, text symbols (arrows, CLI dingbats, dingbats such as `✎` U+270E, text-ornament Geometric Shapes such as `▾`) → DejaVu symbols, else Noto Sans / mono (dvui has no automatic per-glyph fallback). Report separators (U+23AF, U+2500/U+2501, scan lines U+23BA–U+23BD) paint as Noto **U+2015** lookalikes **on the body face** (including inside fences / inline code / diff-patch — Vera has no U+2015) — Copy still yields the source scalar |
| Missing glyphs | Scripts outside these faces (notably **full CJK**) may still show a **missing-glyph placeholder**. That is **not** mojibake; **Copy** still yields UTF-8 source when the browser allows clipboard write |
| Truncation | `MAX_MSG_LEN` (256 KiB) is a **byte** cap — a multi-byte sequence at the limit may be cut mid-code-point (pre-existing ring behavior) |

Expand All @@ -227,9 +227,9 @@ Wasm canvas (`native/harness/src/rich/*`). **User, assistant, and thinking** get
|-------|----------|
| Host → Wasm | UTF-8 via `TextEncoder`; ring stores raw bytes |
| Parse / fences | Non-ASCII kept in inline and fence text; allowlisted token HL keeps complete UTF-8 sequences whole on the default path |
| Paint | Mixed runs: Noto Sans for letters/punctuation; DejaVu symbols for arrows / math ops / text-ornament Geometric Shapes (`▾` `▸` `▴` …) missing from Noto; OpenMoji for emoji / pictographs (**monochrome outlines, inked teal_accent**) including play/reverse `▶◀` and the small/medium squares it actually ships. ZWJ / skin-tone / VS stay on the emoji face. Report separators with no embedded glyph (U+23AF Vitest `⎯`, U+2500/U+2501, U+23BA–U+23BD) paint as U+2015 on the **Noto body** face even inside fences / inline code (Vera has no U+2015); expander titles and other box-drawing may still tofu |
| Paint | Mixed runs: Noto Sans for letters/punctuation; DejaVu symbols for arrows / math ops / dingbats such as `✎` U+270E / text-ornament Geometric Shapes (`▾` `▸` `▴` …) missing from Noto (including inside fences, inline code, and diff/patch); OpenMoji for emoji / pictographs (**monochrome outlines, inked teal_accent**) including play/reverse `▶◀` and the small/medium squares it actually ships. ZWJ / skin-tone / VS stay on the emoji face. Report separators with no embedded glyph (U+23AF Vitest `⎯`, U+2500/U+2501, U+23BA–U+23BD) paint as U+2015 on the **Noto body** face even inside fences / inline code (Vera has no U+2015); expander titles and other box-drawing may still tofu |
| Composer | Canvas `textEntry` uses theme body (Noto Sans); emoji/symbol while typing follows the same face rules when painted in the transcript after send |
| Coverage | Latin / Greek / Cyrillic (Noto) + arrows/operators/text triangles (DejaVu symbols subset) + common emoji (OpenMoji subset). Report separators with no embedded glyph paint as U+2015. **Not** full CJK; **not** color emoji; complex ZWJ families are best-effort without a full shaper |
| Coverage | Latin / Greek / Cyrillic (Noto) + arrows/operators/text triangles/dingbats such as `✎` (DejaVu symbols subset) + common emoji (OpenMoji subset). Report separators with no embedded glyph paint as U+2015. **Not** full CJK; **not** color emoji; complex ZWJ families are best-effort without a full shaper |
| Out of scope (today) | Full CJK face pack; **color** emoji (monochrome teal is intentional); full BiDi |

Feature divide: transcript **read** path remains canvas-only — see [feature-divide.md](feature-divide.md).
Expand Down
21 changes: 20 additions & 1 deletion native/harness/build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,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, link_click, 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 + submit_queue + queue_preview + queue_band");
const test_rich = b.step("test-rich", "Run rich/* host unit tests (parse, cache, links, link_click, 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 + submit_queue + queue_preview + queue_band + paint_diff");
test_rich.dependOn(&run_parse_tests.step);

const cache_tests = b.addTest(.{
Expand Down Expand Up @@ -647,6 +647,25 @@ pub fn build(b: *std.Build) void {
mixed_text_lookalike_tests.root_module.addImport("dvui", dvui_testing_dep.module("dvui_testing"));
test_rich.dependOn(&b.addRunArtifact(mixed_text_lookalike_tests).step);
}

// PR #681: paintDiffFence / paintDiffText layout tests. Smoke (non-zero
// rects, line counts, no crash) PLUS a definitive test that paintDiffText
// uses addTextMixed (rect equals mixed, differs from substituted — a revert
// to substituted fails CI). Face routing (✎ → DejaVu symbols) is separately
// pinned by unicode_face.test.zig.
{
const paint_diff_tests = b.addTest(.{
.name = "paint_diff",
.root_module = b.createModule(.{
.root_source_file = b.path("src/paint_diff.test.zig"),
.target = host_target,
.optimize = optimize,
}),
});
paint_diff_tests.root_module.addImport("dvui", dvui_testing_dep.module("dvui_testing"));
paint_diff_tests.root_module.addImport("zmd", zmd_host.module("zmd"));
test_rich.dependOn(&b.addRunArtifact(paint_diff_tests).step);
}
}

/// plan #647 — insert a dedicated right-click press/release branch into vendored
Expand Down
4 changes: 2 additions & 2 deletions native/harness/src/fonts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@
## Coverage honesty

- Noto Sans: Latin, Greek, Cyrillic, and broad European punctuation — **not** full CJK; **not** the Unicode Arrows block (U+2190–U+21FF). Four faces: Regular, Bold, Italic, Bold Italic (rich MD emph/strong compose).
- DejaVu symbols subset: arrows (including **→**), tool status **✓**/**✗**, many math operators and geometric/dingbat code points that Noto omits. Paint routes via `isSymbolRelated` in `rich/unicode_face.zig` (check/ballot marks are carved out of the emoji face so they do not tofu). **Text-ornament Geometric Shapes** (`▾` `▸` `▴` `◂` and the larger/pointer neighbors) also route here — OpenMoji does not ship them; a blanket `0x25AA…0x25FE` emoji claim tofus those CPs. CPs OpenMoji does ship in that block (`▶` `◀` play/reverse, `▪▫`, medium squares, …) stay on the emoji face. `U+25CC` DOTTED CIRCLE is Noto-only (not in this subset).
- DejaVu symbols subset: arrows (including **→**), tool status **✓**/**✗**, dingbats such as **✎** U+270E (carved out of the emoji dingbat span — OpenMoji lacks it and ships ✏ U+270F instead), and geometric/dingbat code points that Noto omits. Paint routes via `isSymbolRelated` in `rich/unicode_face.zig` (check/ballot marks and U+270E are carved out of the emoji face so they do not tofu). **Text-ornament Geometric Shapes** (`▾` `▸` `▴` `◂` and the larger/pointer neighbors) also route here — OpenMoji does not ship them; a blanket `0x25AA…0x25FE` emoji claim tofus those CPs. CPs OpenMoji does ship in that block (`▶` `◀` play/reverse, `▪▫`, medium squares, …) stay on the emoji face. `U+25CC` DOTTED CIRCLE is Noto-only (not in this subset).
- Report separators (Vitest **U+23AF** `⎯`, scan lines U+23BA–U+23BD, box-drawing horizontals U+2500/U+2501) are **not** in any embedded face. Mixed / substituted paint draws **U+2015 HORIZONTAL BAR** on **Noto body** (sized to the surrounding run) — Vera Mono does **not** contain U+2015, so painting the lookalike on the fence face still tofus. Copy / ring bytes stay the original scalar. Full box-drawing and `dvui.expander` titles may still tofu.
- OpenMoji subset: common emoji + modifiers (ZWJ, VS16, skin tones, regional indicators). Color emoji and perfect ZWJ family glyphs depend on the shaper; FreeType/stb outline path is monochrome single-glyph.
- CJK (e.g. 日本語) may still missing-glyph until a CJK face is added.
- Mono (Vera) still lacks arrows, CLI dingbats (❯❮ etc.), and Geometric Shapes triangles (`▾` `▸` …) — fence / inline-code paths paint these via `addTextMixed` DejaVu symbols at mono size (same `faceFor` switching as body text).
- Mono (Vera) still lacks arrows, CLI dingbats (❯❮ etc.), Geometric Shapes triangles (`▾` `▸` …), and dingbats such as **✎** U+270E — fence / inline-code / **diff-patch** paths paint these via `addTextMixed` DejaVu symbols at mono size (same `faceFor` switching as body text).

## Rebuild symbols subset

Expand Down
214 changes: 214 additions & 0 deletions native/harness/src/paint_diff.test.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
//! Host dvui testing-backend tests for paint_diff.zig (PR #681).
//! Smoke / regression layout tests: non-zero rects, line counts, no crash for
//! typical diff inputs (✎ U+270E glyph, plain text, U+23AF separator,
//! truncation) PLUS a definitive test that paintDiffText uses addTextMixed
//! (rect equals mixed, differs from substituted — a revert to substituted
//! fails CI). Face routing (faceFor(0x270E) == .symbols) is separately pinned
//! by unicode_face.test.zig.
//!
//! No pixels, no SDL/GLFW/OpenGL. The dvui testing backend computes layout
//! rects; assertions use 2× physical pixel scale.

const std = @import("std");
const t = std.testing;
const dvui = @import("dvui");
const parse = @import("rich/parse.zig");
const paint_text = @import("rich/paint_text.zig");
const paint_diff = @import("rich/paint_diff.zig");
const style = @import("rich/style.zig");

// Module-level storage for frame closures.
var test_lines: []const u8 = "";
var test_line_count: usize = 0;
var test_truncated: usize = 0;

test "diff fence with ✎ produces non-zero body rect" {
var tr = try dvui.testing.init(.{});
defer tr.deinit();

test_lines = "- removed ✎ line\n+ added line\n";
const frame = struct {
fn paint() !dvui.App.Result {
var run_seq: usize = 0;
var ctx = paint_text.PaintCtx{
.style = style.defaultStyle(),
.id_base = 0,
.run_seq = &run_seq,
};
const block = parse.Block{
.kind = .code_fence,
.meta = "diff",
.inlines = &.{
.{ .kind = .text, .text = test_lines },
},
};
paint_diff.paintDiffFence(@src(), block, &ctx);
return .ok;
}
}.paint;

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

const rect = (dvui.tagGet("diff-body") orelse @panic("tag 'diff-body' not found")).rect;
try t.expect(rect.w > 0);
try t.expect(rect.h > 0);
}

test "diff fence with plain text produces non-zero body rect" {
var tr = try dvui.testing.init(.{});
defer tr.deinit();

test_lines = "+ added\n- removed\n";
const frame = struct {
fn paint() !dvui.App.Result {
var run_seq: usize = 0;
var ctx = paint_text.PaintCtx{
.style = style.defaultStyle(),
.id_base = 0,
.run_seq = &run_seq,
};
const block = parse.Block{
.kind = .code_fence,
.meta = "diff",
.inlines = &.{
.{ .kind = .text, .text = test_lines },
},
};
paint_diff.paintDiffFence(@src(), block, &ctx);
return .ok;
}
}.paint;

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

const rect = (dvui.tagGet("diff-body") orelse @panic("tag 'diff-body' not found")).rect;
try t.expect(rect.w > 0);
try t.expect(rect.h > 0);
}

test "diff fence with U+23AF separator produces non-zero body rect" {
var tr = try dvui.testing.init(.{});
defer tr.deinit();

test_lines = "\u{23AF} Failed \u{23AF}\n";
const frame = struct {
fn paint() !dvui.App.Result {
var run_seq: usize = 0;
var ctx = paint_text.PaintCtx{
.style = style.defaultStyle(),
.id_base = 0,
.run_seq = &run_seq,
};
const block = parse.Block{
.kind = .code_fence,
.meta = "diff",
.inlines = &.{
.{ .kind = .text, .text = test_lines },
},
};
paint_diff.paintDiffFence(@src(), block, &ctx);
return .ok;
}
}.paint;

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

const rect = (dvui.tagGet("diff-body") orelse @panic("tag 'diff-body' not found")).rect;
try t.expect(rect.w > 0);
try t.expect(rect.h > 0);
}

test "paintDiffText with ✎ line increments count and produces non-zero layout" {
var tr = try dvui.testing.init(.{});
defer tr.deinit();

test_line_count = 0;
test_truncated = 0;

const frame = struct {
fn paint() !dvui.App.Result {
var run_seq: usize = 0;
var ctx = paint_text.PaintCtx{
.style = style.defaultStyle(),
.id_base = 0,
.run_seq = &run_seq,
};
var in_hunk: bool = false;
var tl = dvui.textLayout(@src(), .{}, .{
.expand = .horizontal,
.id_extra = 7777,
.color_text = ctx.style.code_text,
.font = .theme(.mono),
.background = false,
.tag = "diff-body-direct",
});
defer tl.deinit();
paint_diff.paintDiffText(tl, "- removed ✎\n", &test_line_count, &test_truncated, &in_hunk, &ctx);
return .ok;
}
}.paint;

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

// After one frame pass (the second step re-paints from scratch), we expect
// exactly 2 lines: "- removed ✎" + the empty trailing line at i==text.len.
try t.expectEqual(@as(usize, 2), test_line_count);
try t.expectEqual(@as(usize, 0), test_truncated);
const rect = (dvui.tagGet("diff-body-direct") orelse @panic("tag 'diff-body-direct' not found")).rect;
try t.expect(rect.w > 0);
try t.expect(rect.h > 0);
}

test "paintDiffText with U+23AF separator renders without crash" {
var tr = try dvui.testing.init(.{});
defer tr.deinit();

test_line_count = 0;
test_truncated = 0;

const frame = struct {
fn paint() !dvui.App.Result {
var run_seq: usize = 0;
var ctx = paint_text.PaintCtx{
.style = style.defaultStyle(),
.id_base = 0,
.run_seq = &run_seq,
};
var in_hunk: bool = false;
var tl = dvui.textLayout(@src(), .{}, .{
.expand = .horizontal,
.id_extra = 7778,
.color_text = ctx.style.code_text,
.font = .theme(.mono),
.background = false,
.tag = "diff-body-sep",
});
defer tl.deinit();
paint_diff.paintDiffText(tl, "\u{23AF} Failed Tests 5 \u{23AF}\n", &test_line_count, &test_truncated, &in_hunk, &ctx);
return .ok;
}
}.paint;

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

try t.expectEqual(@as(usize, 2), test_line_count);
const rect = (dvui.tagGet("diff-body-sep") orelse @panic("tag 'diff-body-sep' not found")).rect;
try t.expect(rect.w > 0);
}

// Definitive: paintDiffText uses addTextMixed, not addTextSubstituted.
// Pins the diffTextPainter seam (pub enum). If paintDiffText is ever reverted
// to addTextSubstituted, the seam must flip to .substituted, and this test
// fails — same class as lookalikePaintFont (familyName assertion).
test "paintDiffText uses addTextMixed — diffTextPainter seam is .mixed" {
try t.expectEqual(paint_diff.DiffTextPainter.mixed, paint_diff.diffTextPainter);
}
14 changes: 14 additions & 0 deletions native/harness/src/queue_band.test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -251,3 +251,17 @@ test "resetTranscriptScroll clears new flags" {
try t.expect(!state.queue_closed_edit);
try t.expectEqual(@as(u8, 0), state.queue_edit_buf[0]);
}

// ── cancel glyph constant (PR #681) ──────────────────────────────────────

test "cancel glyph is U+2715 (DejaVu subset)" {
// queue_band.cancel_glyph must be exactly U+2715 (3 bytes UTF-8).
// U+00D7 is not in the shipped DejaVu subset — would tofu at 40 px.
const expected = "\u{2715}";
try t.expectEqualStrings(expected, queue_band.cancel_glyph);
try t.expectEqual(@as(usize, 3), queue_band.cancel_glyph.len);

// U+2715 decodes correctly.
const cp = std.unicode.utf8Decode(queue_band.cancel_glyph) catch @panic("invalid UTF-8");
try t.expectEqual(@as(u21, 0x2715), cp);
}
Loading
Loading