diff --git a/docs/harness-limits.md b/docs/harness-limits.md index 5769c6aa..2dc32c8d 100644 --- a/docs/harness-limits.md +++ b/docs/harness-limits.md @@ -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 | @@ -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) | @@ -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). diff --git a/native/harness/build.zig b/native/harness/build.zig index b05123f6..51cd71d4 100644 --- a/native/harness/build.zig +++ b/native/harness/build.zig @@ -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(.{ @@ -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 diff --git a/native/harness/src/fonts/README.md b/native/harness/src/fonts/README.md index e1089e93..bed50608 100644 --- a/native/harness/src/fonts/README.md +++ b/native/harness/src/fonts/README.md @@ -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 diff --git a/native/harness/src/paint_diff.test.zig b/native/harness/src/paint_diff.test.zig new file mode 100644 index 00000000..de747bdb --- /dev/null +++ b/native/harness/src/paint_diff.test.zig @@ -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); +} diff --git a/native/harness/src/queue_band.test.zig b/native/harness/src/queue_band.test.zig index b0dd39a7..54fb28f1 100644 --- a/native/harness/src/queue_band.test.zig +++ b/native/harness/src/queue_band.test.zig @@ -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); +} diff --git a/native/harness/src/rich/mixed_text.zig b/native/harness/src/rich/mixed_text.zig index 61be6373..f2489f70 100644 --- a/native/harness/src/rich/mixed_text.zig +++ b/native/harness/src/rich/mixed_text.zig @@ -102,9 +102,10 @@ pub fn addTextMixed( /// Paint `text` on a single `base` face/ink, substituting report-separator CPs /// (U+23AF etc., no embedded glyph) with Noto U+2015 at the surrounding size. -/// For mono / plain paths that bypass addTextMixed (diff/patch fences, plain-body -/// fallback). Lookalike is always Noto — Vera has no U+2015, so painting the -/// substitute on `base` when `base` is mono still tofus (fences / inline code). +/// For mono / plain paths that bypass addTextMixed (plain-body fallback). +/// Diff/patch fences use addTextMixed (DejaVu symbols for ✎ U+270E etc.). +/// Lookalike is always Noto — Vera has no U+2015, so painting the substitute +/// on `base` when `base` is mono still tofus (fences / inline code). /// Ring/Copy bytes stay the original scalars. pub fn addTextSubstituted( tl: *dvui.TextLayoutWidget, diff --git a/native/harness/src/rich/paint_diff.zig b/native/harness/src/rich/paint_diff.zig index 8cf59e25..524c3552 100644 --- a/native/harness/src/rich/paint_diff.zig +++ b/native/harness/src/rich/paint_diff.zig @@ -1,5 +1,13 @@ //! Unified diff / patch fence paint — line colors from palette (no freehand hex). //! EMBER on '-' lines is intentional **removed-line** semantics, not error chrome. +//! +//! Test seam: diffTextPainter / DiffTextPainter pins that paintDiffText uses +//! addTextMixed (face-aware DejaVu symbols for ✎ U+270E etc.), not the +//! face-blind addTextSubstituted (Vera .notdef). A revert must flip this +//! constant; the test in paint_diff.test.zig fails if it does. +pub const DiffTextPainter = enum { mixed, substituted }; +pub const diffTextPainter: DiffTextPainter = .mixed; + const std = @import("std"); const dvui = @import("dvui"); const parse = @import("parse.zig"); @@ -49,6 +57,7 @@ pub fn paintDiffFence(src: std.builtin.SourceLocation, block: parse.Block, ctx: .color_text = ctx.style.code_text, .font = .theme(.mono), .background = false, + .tag = "diff-body", }); defer tl.deinit(); @@ -65,7 +74,7 @@ pub fn paintDiffFence(src: std.builtin.SourceLocation, block: parse.Block, ctx: } } -fn paintDiffText( +pub fn paintDiffText( tl: *dvui.TextLayoutWidget, text: []const u8, line_count: *usize, @@ -85,9 +94,10 @@ fn paintDiffText( const kind = classifyLine(line, in_hunk.*); if (std.mem.startsWith(u8, line, "@@")) in_hunk.* = true; const color = lineColor(kind, ctx); - // Mono (Vera) lacks U+23AF/U+2500/U+2501 — substitute U+2015 so a - // report banner inside a diff/patch fence does not tofu (paint-only). - mixed_text.addTextSubstituted(tl, line, .theme(.mono), .{ + // Mixed: DejaVu for text symbols (✎ U+270E, arrows, …) at mono + // size; report-bar lookalike (U+23AF/U+2500/U+2501 → U+2015) stays + // inside addTextMixed. Substituted-only would pin Vera and tofu. + mixed_text.addTextMixed(tl, line, .theme(.mono), .{ .color_text = color, }); if (at_nl and line_count.* + 1 < cap) { diff --git a/native/harness/src/rich/unicode_face.zig b/native/harness/src/rich/unicode_face.zig index 954bdc2a..828c5362 100644 --- a/native/harness/src/rich/unicode_face.zig +++ b/native/harness/src/rich/unicode_face.zig @@ -42,10 +42,12 @@ pub fn isEmojiRelated(cp: u21) bool { 0x25E7...0x25EA => true, // ◧◨◩◪ 0x25ED...0x25EE => true, // ◭◮ 0x25FB...0x25FE => true, // ◻◼◽◾ - // Misc symbols + dingbats as emoji — except text check/ballot marks used in - // toolTrace ("✓ ok" / "✗ failed"). OpenMoji subset lacks those glyphs; DejaVu - // symbols has them (isSymbolRelated). Without this carve-out paint shows tofu. - 0x2600...0x2712 => true, + // Misc symbols + dingbats as emoji — except text marks OpenMoji does + // not ship. U+2713…U+2718 (✓/✗) already sit in the hole before 0x2719. + // U+270E LOWER RIGHT PENCIL: OpenMoji subset lacks it (has U+270F ✏); + // DejaVu symbols has it → isSymbolRelated → .symbols (body + mono mixed). + 0x2600...0x270D => true, + 0x270F...0x2712 => true, 0x2719...0x276D => true, // CLI dingbats 0x276E–0x27BF: carved out so ❯❮ and similar route to // isSymbolRelated → DejaVu (OpenMoji lacks them). Explicit emoji keepers @@ -153,6 +155,17 @@ test "tool status check and ballot marks route to symbols not emoji" { try std.testing.expect(faceFor(0x2764) == .emoji); } +test "U+270E pencil routes to symbols not emoji" { + // OpenMoji subset lacks U+270E (it ships ✏ U+270F). DejaVu has 270E. + try std.testing.expect(!isEmojiRelated(0x270E)); + try std.testing.expect(isSymbolRelated(0x270E)); + try std.testing.expect(faceFor(0x270E) == .symbols); + // Neighbors stay emoji (OpenMoji has 270F; 270D/2712 stay in the emoji span) + try std.testing.expect(faceFor(0x270F) == .emoji); + try std.testing.expect(faceFor(0x270D) == .emoji); + try std.testing.expect(faceFor(0x2712) == .emoji); +} + test "utf8 stress sample splits emoji from latin" { const grin = "\xf0\x9f\x98\x80"; // U+1F600 const cafe = "caf\xc3\xa9"; // café diff --git a/native/harness/src/ui/queue_band.zig b/native/harness/src/ui/queue_band.zig index 9a3068ca..317945cc 100644 --- a/native/harness/src/ui/queue_band.zig +++ b/native/harness/src/ui/queue_band.zig @@ -1,4 +1,6 @@ //! Compact in-canvas submit-queue list above the composer (plan #664). +//! Cancel button glyph: ✕ U+2715 (DejaVu symbols subset; Vera lacks U+00D7). +pub const cancel_glyph = "\u{2715}"; const std = @import("std"); const dvui = @import("dvui"); const bridge = @import("../bridge.zig"); @@ -213,7 +215,7 @@ fn paintRow(src: std.builtin.SourceLocation, i: u32) void { beginEdit(i); } } - if (dvui.button(@src(), "×", .{}, .{ + if (dvui.button(@src(), cancel_glyph, .{}, .{ .gravity_y = 0.5, .style = .content, .font = chrome.composerIconFont(),