diff --git a/build.zig b/build.zig index eda31b457..c9f779c76 100644 --- a/build.zig +++ b/build.zig @@ -987,10 +987,10 @@ pub fn build(b: *std.Build) void { // this step until the encoder comment, the host decoder comment, and // the patterns below move with it. addFileContainsCheckStep(b, file_contains_checker, test_step, "test-wire-format-version-prose", "Verify wire-format version prose matches the packet version constant", &.{ - .{ .path = "src/primitives/canvas/serialization.zig", .pattern = "pub const binary_packet_version: u8 = 5;" }, - .{ .path = "src/primitives/canvas/serialization.zig", .pattern = "Compact binary gpu-surface packet encoding (wire format v5)." }, - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "Compact binary gpu-surface packet decoding (wire format v5)." }, - .{ .path = "src/platform/windows/gpu_surface_renderer.cpp", .pattern = "Compact binary gpu-surface packet decoding (wire format v5)." }, + .{ .path = "src/primitives/canvas/serialization.zig", .pattern = "pub const binary_packet_version: u8 = 7;" }, + .{ .path = "src/primitives/canvas/serialization.zig", .pattern = "Compact binary gpu-surface packet encoding (wire format v7)." }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "Compact binary gpu-surface packet decoding (wire format v7)." }, + .{ .path = "src/platform/windows/gpu_surface_renderer.cpp", .pattern = "Compact binary gpu-surface packet decoding (wire format v7)." }, }); addFileContainsCheckStep(b, file_contains_checker, test_step, "test-windows-gpu-packet-presenter", "Verify Windows uses retained Direct2D packets with recovery, bounded resources, and dirty-region pixel fallback", &.{ .{ .path = "src/platform/windows/root.zig", .pattern = ".present_gpu_surface_packet_binary_fn = presentGpuSurfacePacketBinary" }, diff --git a/docs/src/app/docs/capabilities/page.mdx b/docs/src/app/docs/capabilities/page.mdx index ff113a33c..b706786da 100644 --- a/docs/src/app/docs/capabilities/page.mdx +++ b/docs/src/app/docs/capabilities/page.mdx @@ -19,7 +19,7 @@ Web content itself is declare-to-use: an app ships the embedded web layer only w Open URL in system browser - runtime.openExternalUrl(url) + fx.openUrl(url) / runtime.openExternalUrl(url) native-sdk.os.openUrl network macOS, Linux, and Windows system WebView; macOS Chromium diff --git a/docs/src/app/docs/native-surfaces/page.mdx b/docs/src/app/docs/native-surfaces/page.mdx index 083a657fd..3ff272fa1 100644 --- a/docs/src/app/docs/native-surfaces/page.mdx +++ b/docs/src/app/docs/native-surfaces/page.mdx @@ -104,7 +104,9 @@ fn view(ui: *Ui, model: *const Model) Ui.Node { }); } -fn panes(model: *const Model, out: []PreviewApp.WebViewPane) usize { +fn panes(model: *const Model, context: PreviewApp.ChromeContext, out: []PreviewApp.WebViewPane) usize { + // Panes reconcile per window; this webview lives in the main one. + if (!context.is_main) return 0; out[0] = .{ .label = "preview", .anchor = "preview-pane", @@ -115,6 +117,8 @@ fn panes(model: *const Model, out: []PreviewApp.WebViewPane) usize { } ``` +The hook takes the same `ChromeContext` as `chrome.build_window`, naming the window being reconciled: switch on `context.canvas_label` (or `context.is_main`) and return only that window's panes. A webview belongs to one window, so answering with the whole app's pane set makes every other window resolve an anchor its widget tree does not contain — correct behaviour, but a `no canvas widget carries semantics label ...` warning on every rebuild of every other window. + Panes re-apply after every rebuild and presented frame, reconciling against the runtime's actual webview state, so shell relayouts (window restores, resizes) cannot leave the webview detached from its anchor. Pane URLs are subject to `security.navigation.allowed_origins`. A scene whose only webviews are children like this never grows an implicit `main` webview — the window stays canvas-first. The seam is engine-agnostic (it rides the same `PlatformServices` webview surface both the system and Chromium hosts implement), though `gpu_surface` canvases currently require the system engine on macOS. `examples/canvas-preview` is the live proof, with a smoke test at `zig build test-canvas-preview-smoke`. ## Imperative View API diff --git a/docs/src/app/docs/native-ui/page.mdx b/docs/src/app/docs/native-ui/page.mdx index a02250aa9..05155891b 100644 --- a/docs/src/app/docs/native-ui/page.mdx +++ b/docs/src/app/docs/native-ui/page.mdx @@ -472,6 +472,12 @@ case "build_finished": +Handing a URL to the user's default handler is the same fire-and-forget shape: `fx.openUrl(url)` from a Zig `update_fx` arm asks the OS to open it — the browser for `http`/`https`, the mail client for `mailto` — through the platform verb the [bridge](/docs/bridge/builtin-commands) exposes to web content as `native-sdk.os.openUrl`. The URL is treated as hostile, because cores build them from terminal output, fetch bodies, and pastes: schemes are an allowlist (`http`, `https`, `mailto`, matched case-insensitively), and an empty URL, one past the 4 KiB bound, one carrying a NUL or any other control byte, or one naming an unvetted scheme — `file:` and `javascript:` included — is refused whole rather than trimmed into something openable. Unlike `runtime.openExternalUrl`, it is not gated on the webview external-link policy: that policy governs links *web content* follows, while this call comes from the app's own `update`. Fake execution and session replay never open anything. + +```zig +.open_docs => fx.openUrl("https://example.com/docs/start"), +``` + Failure and overflow are always visible: a spawn that cannot run delivers an exit Msg with reason `rejected`, a fetch that cannot run delivers a response Msg with outcome `rejected`, and a file effect that cannot run delivers a result Msg with outcome `rejected`; dropped or truncated lines carry counts and flags; `cancel` kills and reaps the process and always ends in exactly one `cancelled` exit Msg, with no further line Msgs after it. Tests use the fake executor (`effects.executor = .fake`) to assert on spawn, fetch, and file requests and feed synthetic lines, stderr (`feedStderr`, collect spawns), exits, responses, and file results back deterministically — set it before the first frame and `init_fx` boot spawns are recorded too. See `examples/effects-probe`. For timestamps, the facade owns the clocks (Zig 0.16 puts `std.time` behind `std.Io`, which `update` never sees): `native_sdk.nowMs()` / `nowNanoseconds()` read the wall clock and `monotonicMs()` / `monotonicNanoseconds()` the duration clock. Time-dependent logic stores the `native_sdk.Clock` seam in the model (`.system` by default) so tests substitute a deterministic `native_sdk.TestClock` and advance it by hand. @@ -729,6 +735,8 @@ This is a machine check, not a review item. An unnamed control, an icon-only con Zig-built views get the same discipline at tree level: `canvas.expectA11yAuditSweepClean` lays out the real tree and reports interactive widgets announced with no name (including dynamic labels that resolve empty at runtime), focusable widgets keyboard traversal can never reach, and identically labeled sibling controls — adopt it in your test suite next to `canvas.expectLayoutAuditSweepClean`. Contrast checking and focus-visible styling checks are not part of the audit yet. +Zig views also carry two flags that are easy to confuse, and the engine keeps them strictly apart. `semantics.decorative` is the accessibility one: the widget and its subtree leave the semantic tree and can never take focus, while painting, layout, hit-testing, and event routing are untouched. It is the `aria-hidden` counterpart, and the right flag for chrome drawn for the eye alone — a search field's magnifier glyph, a rendered caret, a leading rule beside a labeled control. `semantics.hidden` is the VISIBILITY one: the widget keeps its layout box — the space stays reserved and siblings do not reflow — but drops out of painting, hit-testing, focus, and the semantic tree. That is the flag for an empty reserved slot (a fixed-width space held for an affordance that is not currently shown) and for `ui.nav`'s retained-but-inactive pages. A `hidden` icon paints nothing, so reaching for it to quiet a screen reader leaves a blank gap where the glyph should be; `decorative` is what that case wants. + ## Tooling - `native markup check src/app.native` — instant validation with `file:line:column` errors, including the font-coverage tofu guard: literal text with a codepoint outside the bundled face is a teaching error naming the character (it renders as a tofu box on the reference/screenshot and mobile paths — [register a font](/docs/fonts) that covers it and bind the text from the model, or use a vector icon or plain words; the static check knows only the bundled face's coverage). Dynamic strings get the same lesson as a Debug-build diagnostic when the view builds. Accessibility findings ride the same pass: unnamed interactive controls and role misuse are errors, unnamed images and redundant labels are warnings (see [Accessibility](#accessibility)). diff --git a/examples/canvas-preview/src/main.zig b/examples/canvas-preview/src/main.zig index e8d32c34d..471c6e7c6 100644 --- a/examples/canvas-preview/src/main.zig +++ b/examples/canvas-preview/src/main.zig @@ -132,7 +132,10 @@ pub fn view(ui: *PreviewUi, model: *const Model) PreviewUi.Node { // ------------------------------------------------------ webview pane seam -pub fn panes(model: *const Model, out: []PreviewApp.WebViewPane) usize { +pub fn panes(model: *const Model, context: PreviewApp.ChromeContext, out: []PreviewApp.WebViewPane) usize { + // The preview webview is declared in the main window's scene, so + // only the main window's rebuild has an anchor widget for it. + if (!context.is_main) return 0; out[0] = .{ .label = webview_label, .anchor = pane_anchor, diff --git a/examples/split-collapse/src/main.zig b/examples/split-collapse/src/main.zig index 6be45a08d..dd56b4d0c 100644 --- a/examples/split-collapse/src/main.zig +++ b/examples/split-collapse/src/main.zig @@ -212,8 +212,11 @@ pub const web_pane_anchor = "content-web-pane"; /// pane, reflowing through the whole tween (the heavy field shape). var web_pane_enabled = false; -fn webPanes(model: *const Model, out: []SplitCollapseApp.WebViewPane) usize { +fn webPanes(model: *const Model, context: SplitCollapseApp.ChromeContext, out: []SplitCollapseApp.WebViewPane) usize { _ = model; + // One window, one pane — the discriminator is still honoured so the + // example teaches the per-window shape. + if (!context.is_main) return 0; out[0] = .{ .label = web_pane_label, .anchor = web_pane_anchor, diff --git a/examples/workbench/src/main.zig b/examples/workbench/src/main.zig index fe8079cbe..da3ed2b00 100644 --- a/examples/workbench/src/main.zig +++ b/examples/workbench/src/main.zig @@ -227,7 +227,10 @@ pub const CompiledWorkbenchView = canvas.CompiledMarkupView(Model, Msg, workbenc /// The web pane: snapped to the markup's anchor column every presented /// frame — the split divider reflows live web content. Setting `url` /// navigates; bumping `reload_token` reloads the same URL. -pub fn webPanes(model: *const Model, out: []WorkbenchApp.WebViewPane) usize { +pub fn webPanes(model: *const Model, context: WorkbenchApp.ChromeContext, out: []WorkbenchApp.WebViewPane) usize { + // The browser pane belongs to the main window's scene; any other + // window owns no pane. + if (!context.is_main) return 0; out[0] = .{ .label = web_view_label, .anchor = web_pane_anchor, diff --git a/examples/workbench/src/tests.zig b/examples/workbench/src/tests.zig index 759f449da..b99be0e8c 100644 --- a/examples/workbench/src/tests.zig +++ b/examples/workbench/src/tests.zig @@ -74,6 +74,19 @@ fn fakeEffects() app.Effects { return fx; } +/// The main window's chrome context, as the runtime hands it to +/// `webPanes` on every rebuild — the pane hook is per-WINDOW, so the +/// unit tests must ask it the same question the runtime does. +fn mainPaneContext() WorkbenchApp.ChromeContext { + return .{ + .canvas_label = app.canvas_label, + .window_id = 1, + .size = geometry.SizeF.init(1280, 800), + .tokens = .{}, + .is_main = true, + }; +} + fn expectTerminalCursorPaint(harness: *native_sdk.TestHarness(), terminal_id: canvas.ObjectId, expected: CursorPaintKind) !void { const cursor_id = canvas.terminal_grid.paintIdBase(terminal_id) + 0x61_0002; const command = (try harness.runtime.canvasDisplayList(1, app.canvas_label)).findCommandById(cursor_id) orelse return error.TestExpectedCursor; @@ -186,7 +199,7 @@ test "the address bar commits a navigation the web pane picks up" { var panes: [1]WorkbenchApp.WebViewPane = undefined; // Boot: the home page is the pane's URL, and back/forward are dead. - try testing.expectEqual(@as(usize, 1), app.webPanes(&model, &panes)); + try testing.expectEqual(@as(usize, 1), app.webPanes(&model, mainPaneContext(), &panes)); try testing.expectEqualStrings(app.web_view_label, panes[0].label); try testing.expectEqualStrings(app.web_pane_anchor, panes[0].anchor orelse ""); try testing.expectEqualStrings(app.home_url, panes[0].url); @@ -196,12 +209,12 @@ test "the address bar commits a navigation the web pane picks up" { // Typing alone navigates NOTHING: the pane follows committed // history, never the in-progress edit. app.update(&model, .{ .address_edit = .{ .insert_text = "!" } }, &fx); - _ = app.webPanes(&model, &panes); + _ = app.webPanes(&model, mainPaneContext(), &panes); try testing.expectEqualStrings(app.home_url, panes[0].url); // Submitting commits it. app.update(&model, .navigate, &fx); - _ = app.webPanes(&model, &panes); + _ = app.webPanes(&model, mainPaneContext(), &panes); try testing.expectEqualStrings("https://ziglang.org!", panes[0].url); try testing.expect(!model.back_disabled()); try testing.expect(model.forward_disabled()); @@ -297,10 +310,10 @@ test "reload bumps the pane token without changing the URL" { var model = bootedModel(&fx); var panes: [1]WorkbenchApp.WebViewPane = undefined; - _ = app.webPanes(&model, &panes); + _ = app.webPanes(&model, mainPaneContext(), &panes); const before = panes[0].reload_token; app.update(&model, .reload, &fx); - _ = app.webPanes(&model, &panes); + _ = app.webPanes(&model, mainPaneContext(), &panes); try testing.expect(panes[0].reload_token != before); try testing.expectEqualStrings(app.home_url, panes[0].url); } diff --git a/skill-data/automation/SKILL.md b/skill-data/automation/SKILL.md index 6d8a03742..44ba956ce 100644 --- a/skill-data/automation/SKILL.md +++ b/skill-data/automation/SKILL.md @@ -114,7 +114,7 @@ Semantics: 7. Use `native automate widget-click ` to exercise pointer-style retained widget routing. `widget-hold ` drives a press-and-hold through the same path — pointer down, the reserved hold timer fired, then the suppressed release — so `on_hold` Msgs are live-drivable (a target without `on_hold` degrades to the click a real long press is). `widget-context-press ` is the secondary click: it presents the widget's context menu, or dispatches `on_hold` immediately when the route declares none. 8. Use `native automate widget-drag [start-y-ratio end-y-ratio]` for continuous pointer controls. 9. Use `native automate widget-wheel ` for retained widget scroll input. Wheel targets must be interactive/scrollable widgets — a plain layout column or text node is not a wheel target; aim at the scroll/list widget id from the snapshot. Failures land in the snapshot as named reasons: `error event=automation.widget_wheel name=WheelTargetUnknown|WheelTargetNotInteractive|WheelTargetHasEmptyBounds detail=""`. -10. Use `native automate widget-key [text]` for focused retained widget keyboard input. The key accepts modifier chords — `cmd+a`, `cmd+c`, `cmd+v`, `cmd+x`, `ctrl+shift+arrowleft` (`cmd` sets the primary shortcut modifier on every platform) — so select-all/copy/cut/paste and shift-extended selection are drivable; after a copy, widget lines in the snapshot show the live selection as `selection=a..b`, and the copied text lands on the real system clipboard (`pbpaste` on macOS). +10. Use `native automate widget-key [text]` for focused retained widget keyboard input. The key accepts modifier chords — `cmd+a`, `cmd+c`, `cmd+v`, `cmd+x`, `ctrl+shift+arrowleft` (`cmd` sets the primary shortcut modifier on every platform) — so select-all/copy/cut/paste and shift-extended selection are drivable; after a copy, widget lines in the snapshot show the live selection as `selection=a..b`, and the copied text lands on the real system clipboard (`pbpaste` on macOS). One `widget-key` is one KEYSTROKE: the runtime dispatches the real `key_down` AND its paired `key_up` (the release carries the chord's modifiers but no text), so key-lifetime latches retire the way they do under real hardware and driving the same chord twice in a row runs it twice. 11. Use `native automate widget-pinch [x y]` for trackpad pinch gestures against a gpu-surface view: the runtime dispatches the real `pinch_begin`/`pinch_change`/`pinch_end` platform events, with one change carrying `scale - 1`. `` is the FINAL multiplicative zoom for the gesture — the cumulative gesture scale (the product of `1 + delta`) lands exactly on it — `1.5` zooms in 50%, `0.5` zooms out to half. The optional anchor point is view-local points, defaulting to the view center. Apps hear it through the pinch channel (`Options.on_pinch` / the TS core's `pinchMsg`). 12. Use `native automate screenshot [scale]` to capture the named `gpu_surface` view's canvas as `screenshot-.png` (the CLI prints the artifact path and waits for the file). 13. Use `native automate tray-action ` to select a status-item dropdown row through the same platform event a real menu-bar click emits (command dispatch with source `.tray`). The live tray is visible in `snapshot.txt` as a `tray title="..." items=N` line followed by ` tray-item #id label="..." command="..." enabled=...` rows — the macOS menu bar is outside every window capture, so the snapshot is the only automation evidence the model-driven tray exists, and the `#id` there is what `tray-action` takes. Unknown ids degrade into the dispatch-error ring as `automation.tray_action`. diff --git a/skill-data/native-ui/SKILL.md b/skill-data/native-ui/SKILL.md index 734042200..c6d60181d 100644 --- a/skill-data/native-ui/SKILL.md +++ b/skill-data/native-ui/SKILL.md @@ -102,13 +102,16 @@ const shell_views = [_]native_sdk.ShellView{ .{ .label = "preview", .kind = .webview, .parent = "app-canvas", .url = "https://example.com/", .x = 240, .y = 76, .width = 704, .height = 548 }, }; // view: ui.panel(.{ .grow = 1, .semantics = .{ .label = "preview-pane" } }, .{}) -fn panes(model: *const Model, out: []App.WebViewPane) usize { +fn panes(model: *const Model, context: App.ChromeContext, out: []App.WebViewPane) usize { + if (!context.is_main) return 0; // this webview lives in the main window out[0] = .{ .label = "preview", .anchor = "preview-pane", .url = model.url(), .reload_token = model.reload_token }; return 1; } // options: .web_panes = panes, ``` +The hook takes the same `ChromeContext` as `build_window`, because panes reconcile PER WINDOW: switch on `context.canvas_label` (or `context.is_main`) and return only the panes that window owns. Returning the whole app's pane set for every window makes each other window resolve an anchor that is not in its tree, which logs `webview pane "": no canvas widget carries semantics label ...` on every rebuild. + URL changes navigate; bumping `reload_token` reloads the same URL (the CenterPane/Preview-tab shape). Pane URLs must pass `security.navigation.allowed_origins`. Panes reconcile against the runtime's live webview state on every rebuild and presented frame, so shell relayouts cannot detach them. `examples/canvas-preview` is the live reference; `zig build test-canvas-preview-smoke` verifies it. ### Menu-bar extra (status item) @@ -232,6 +235,7 @@ Layout: `gap` (flow containers only — stacking containers `stack`/`panel`/`car Appearance/state: `variant` (default|primary|secondary|outline|ghost|destructive), `size` (the control scale default|sm|lg|icon on every sized element; on `text` also the typography rungs heading|display — named typography token steps (`heading_size` 28, `display_size` 48, themable like every token) for section headings and hero stats/timer numerals. The two axes stay apart: heading/display on a control is a teaching error naming text as their home, unknown values list the vocabulary, and numeric sizes are refused by design — retheme the typography tokens to move the whole scale), `disabled`, `checked`, `selected`, `value`, `placeholder`, `icon` (`button`, `toggle-button`, `list-item`, `menu-item`: vector icon drawn inline — buttons/toggle-buttons before the label, list/menu items as a leading slot; a teaching error anywhere else. A built-in name, `app:`, or one `{binding}` resolving to such a name). **One size register per row**: every control class shares the control height at a given register (default 36, sm 31.5, lg 40.5 before density), so a toolbar/filter row reads as one height exactly when every control in it carries the SAME `size` — mixing `size="sm"` buttons with a default field renders two heights in one row, and hand-sized pressable panels (`height="30"`) never land on the scale; compose rows from real controls at one register. Focus: `autofocus` (focusable controls only — a teaching error elsewhere): moves keyboard focus to the element when it MOUNTS or when the bound value turns on, edge-triggered so holding it true never re-steals focus from the user. The TEA way to focus an editor on note-create (`` or mount the field under an `` with `autofocus="true"`; Zig views use `ElementOptions.autofocus`) and to give keyboard-first apps their first focus without a click. Semantics: `role` (listitem, treeitem, button, ...; `treeitem` also makes the row part of its tree's roving keyboard focus set), `label` (accessible name — it REPLACES the element's text content as the announced name, so snapshot greps and screen readers see the label, never the text; don't `label` an element whose visible text you grep for), `expanded` (tree rows: disclosure state, model-owned — omit on leaves). Accessible names are ENFORCED: an interactive control with no text content, no `text=`, and no `label=` is a validation error (icon-only controls need `label`; text-entry controls need `label` or `placeholder`), unknown/misused literal roles are errors (`role="tree"` on a text leaf can never hold rows), unnamed avatars and labels duplicating the text content are warnings (`label=""` marks an image decorative). Zig-built trees get the same discipline from `canvas.expectA11yAuditSweepClean` (missing names as the bridges would announce them, focusables clipped out of keyboard reach, identically labeled siblings) — adopt it next to the layout sweep. +Visibility vs. announcement (Zig-only, no markup attributes — and NOT interchangeable): `semantics.decorative` is the ACCESSIBILITY flag — the widget and its subtree leave the semantic tree (and automation snapshots) and can never take focus, while paint, layout, hit-testing, and event routing are untouched. It is `aria-hidden`, and the flag for chrome drawn for the eye alone: a search field's magnifier glyph, a rendered caret, a leading rule. `semantics.hidden` is the VISIBILITY flag — the widget keeps its layout box (space reserved, siblings do not reflow) but drops out of paint, hit-testing, focus, AND the semantic tree; use it for an empty reserved slot (fixed-width room held for an affordance not currently shown) and for `ui.nav`'s retained-but-inactive pages. A `hidden` icon paints NOTHING, so reaching for it to quiet a screen reader leaves a blank gap where the glyph should be — that case wants `decorative`. Identity: `key` (sibling-scoped), `global-key` (parent-independent — use for items that move between containers, e.g. board cards; ids then survive reparenting). Window chrome: `window-drag="true"` (Zig: `.window_drag = true`) marks the element as a window-drag surface for hidden-titlebar windows — pressing its background or plain text/icons inside moves the WINDOW (drag starts only on actual movement), double-click zooms per the OS convention, and press-claiming children (buttons, fields) stay fully interactive via the ordinary press fall-through. macOS-only; elsewhere the press is dead space. See "Hidden titlebar" below. Render channel (Zig-only, no markup attributes): `ElementOptions.opacity` and `ElementOptions.transform` wrap the element's emitted commands without reflowing siblings — the defaults (1, identity) emit nothing, opacity 0 culls painting (pair with `disabled` when fading interactive content), and a transform moves both rendering and pointer hit-testing while accessibility frames stay at the layout frame. Pair with `UiApp.Options.animations` for tweening. @@ -337,7 +341,7 @@ The desktop list convention — click selects, the primary action (open the reco ## Widget budgets and virtualization -Every view has fixed per-view capacities (`src/runtime/canvas_limits.zig`): **1024 retained widget nodes** (`max_canvas_widget_nodes_per_view` — the budget that matters for tree design; semantics and spans match it), 64 KiB retained widget text, **512 declared context-menu items** summed across all widgets of the view (`max_canvas_widget_context_menu_items_per_view` — separators count as items), **64 chart series / 16384 chart points** summed across all charts of the view (`max_canvas_widget_chart_*` — `ui.chart` downsamples every series to 256 points, so this is 64 maximal series or hundreds of sparklines), and per-frame content budgets (2048 commands, 8192 glyphs, 32 KiB frame text, 2048 path elements shared by icons and charts). Overflow is loud: `error.WidgetLayoutListFull` / `error.WidgetNodeLimitReached` / `error.WidgetContextMenuLimitReached` / `error.WidgetAnchoredSurfaceLimitReached` (at most **16 anchored floating surfaces** mounted per view — `max_canvas_widget_anchored_per_view`) fail tests under the harness's propagate policy and log a teaching diagnostic naming the budget in production (the app degrades to the previous frame). Watch headroom without overflowing: automation snapshots report `widget_nodes=N/1024 widget_semantics=N/1024 context_menu_items=N/512` on every gpu_surface view line. +Every view has fixed per-view capacities (`src/runtime/canvas_limits.zig`): **1024 retained widget nodes** (`max_canvas_widget_nodes_per_view` — the budget that matters for tree design; semantics and spans match it), 64 KiB retained widget text, **512 declared context-menu items** summed across all widgets of the view (`max_canvas_widget_context_menu_items_per_view` — separators count as items), **64 chart series / 16384 chart points** summed across all charts of the view (`max_canvas_widget_chart_*` — `ui.chart` downsamples every series to 256 points, so this is 64 maximal series or hundreds of sparklines), and per-frame content budgets (2048 commands, 8192 glyphs, 64 KiB frame text, 2048 path elements shared by icons and charts, 32768 packed terminal cells). The `.terminal` widget does not spend the command budget: a screen is packed `cell_grid` commands, one per row, (`canvas.cell_grid`) carrying every cell's background, cluster, foreground and style, which every renderer expands itself. Its budget is **32768 cells** per view (`max_canvas_cells_per_view`, 20 B each), so a 300x100 truecolor viewport costs 103 commands (one per row, the retained-patch granularity: a keystroke re-encodes one row) and 585 KB. Past the cell budget it DEGRADES rather than failing — fewer complete rows from the top, and it reports what it dropped (`canvas.terminal_grid.paintReport`, `Builder.degradation`, and a teaching log line naming the budget), so a half-blank terminal is never silent. Overflow is loud: `error.WidgetLayoutListFull` / `error.WidgetNodeLimitReached` / `error.WidgetContextMenuLimitReached` / `error.WidgetAnchoredSurfaceLimitReached` (at most **16 anchored floating surfaces** mounted per view — `max_canvas_widget_anchored_per_view`) fail tests under the harness's propagate policy and log a teaching diagnostic naming the budget in production (the app degrades to the previous frame). Watch headroom without overflowing: automation snapshots report `widget_nodes=N/1024 widget_semantics=N/1024 context_menu_items=N/512` on every gpu_surface view line. Budget rules of thumb: 1024 nodes is roomy for a three-pane desktop app (~500 nodes measured for a dense sidebar + markdown detail + run surface), but node count scales with what is MOUNTED, not what is visible — so bound every unbounded collection: diff --git a/src/automation/protocol.zig b/src/automation/protocol.zig index 3c16a2886..2947580f6 100644 --- a/src/automation/protocol.zig +++ b/src/automation/protocol.zig @@ -51,7 +51,7 @@ pub const fingerprint: u64 = layout_fingerprint.hash(layoutDescription(semantic_ /// enum). Declared-shape changes — new verbs, renamed actions, budget /// or naming changes — need NO action here: the fingerprint moves on /// its own. -pub const semantic_epoch: u32 = 1; +pub const semantic_epoch: u32 = 2; /// The canonical description the protocol fingerprint hashes: the /// command vocabulary (the `Action` enum, reflected — names and values, diff --git a/src/automation/snapshot.zig b/src/automation/snapshot.zig index 4cddeffec..3bc5fa1ea 100644 --- a/src/automation/snapshot.zig +++ b/src/automation/snapshot.zig @@ -211,6 +211,8 @@ pub const Widget = struct { /// fallback surface) and what `widget-context-menu` invokes by /// index. context_menu: []const WidgetContextMenuItem = &.{}, + /// Non-default context-menu policy name. Empty means `automatic`. + context_menu_policy: []const u8 = "", }; /// One status-item dropdown row as the runtime last applied it. Slices @@ -589,6 +591,7 @@ pub fn writeText(input: Input, writer: anytype) !void { try writeWidgetActions(widget.actions, writer); try writeWidgetTextRanges(widget, writer); try writeWidgetContextMenu(widget, writer); + try writeWidgetContextMenuPolicy(widget, writer); try writer.writeByte('\n'); } if (input.tray) |tray| { @@ -718,6 +721,7 @@ pub fn writeA11yText(input: Input, writer: anytype) !void { try writeWidgetActions(widget.actions, writer); try writeWidgetTextRanges(widget, writer); try writeWidgetContextMenu(widget, writer); + try writeWidgetContextMenuPolicy(widget, writer); try writer.writeByte('\n'); } } @@ -865,6 +869,11 @@ fn writeWidgetContextMenu(widget: Widget, writer: anytype) !void { try writer.writeByte(']'); } +fn writeWidgetContextMenuPolicy(widget: Widget, writer: anytype) !void { + if (widget.context_menu_policy.len == 0) return; + try writer.print(" context_menu_policy={s}", .{widget.context_menu_policy}); +} + test "snapshot emits window and source" { var buffer: [512]u8 = undefined; var writer = std.Io.Writer.fixed(&buffer); diff --git a/src/platform/macos/appkit_host.h b/src/platform/macos/appkit_host.h index 296b0d2e9..9d5855e7f 100644 --- a/src/platform/macos/appkit_host.h +++ b/src/platform/macos/appkit_host.h @@ -257,6 +257,11 @@ typedef struct { double y; int open; int focused; + /* WINDOW_FRAME: nonzero while the window occupies its own + * fullscreen Space. Read from the window's style mask on every frame + * emit, so a transition the USER started (green button, a Space + * gesture) reports exactly like one the app asked for. */ + int fullscreen; /* WINDOW_FRAME: nonzero while the window is alive but hidden by * its close_policy (.hide intercepted a user close). open stays 1 * for the window's whole hidden stretch. */ @@ -425,6 +430,7 @@ int native_sdk_appkit_close_window(native_sdk_appkit_host_t *host, uint64_t wind // window controls on chromeless windows. Returns 0 when the window id // is unknown. int native_sdk_appkit_minimize_window(native_sdk_appkit_host_t *host, uint64_t window_id); +int native_sdk_appkit_set_window_fullscreen(native_sdk_appkit_host_t *host, uint64_t window_id, int fullscreen); // The show verb: bring the window back to the glass and activate the // app (deminiaturize + makeKeyAndOrderFront) — the counterpart to a // close_policy .hide hide, and the tray-menu "Open" consequence. diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index 5ec3ce627..00d425224 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -2875,6 +2875,243 @@ static BOOL NativeSdkPacketDrawCommand(NSDictionary *command, CGContextRef conte /* Kind dispatch shared by direct draws and raster-cache fills: expects * clip/transform state already applied to the current graphics context. */ + +/* ------------------------------------------------------------------ + * Packed cell-grid rendering. + * + * The SPEC is the engine's reference renderer (canvas/reference.zig, + * `drawCellGrid` / `drawCellDecorations`), because that is the oracle + * the automation screenshots and every golden test go through. This + * function mirrors it deliberately: + * + * - TWO PASSES, and the order is the contract: every background + * first, then every glyph and decoration. One pass would let cell + * N+1's background erase the part of cell N's glyph that overhangs + * into it, which real mono faces do constantly. + * - Decoration geometry matches `CellDecoration`: underline sits + * 2 thicknesses off the cell bottom, its second bar 4, strikethrough + * at 55% of the cell, overline at the top, thickness + * max(1, round(size/12)). + * - Dotted/dashed underlines walk the same segment periods; curly + * walks the same one-point triangle-wave ticks. + * + * KNOWN DIVERGENCE, stated rather than hidden: glyph RASTERIZATION + * differs. The reference renderer fills the engine's own outline + * through its vector core; this draws through CoreText with the host's + * resolved face. Both put the glyph at the same pen and the same + * baseline in the same cell, so layout is identical, but antialiasing + * and hinting are not byte-identical between the two. That is already + * true of every `draw_text` command in this file — the packet path has + * never been byte-identical to the reference rasterizer, only + * geometrically identical. + * + * `bold` and `italic` reach the cell but are NOT synthesised here: with + * a single registered mono face there is no companion to switch to, and + * faking them with a synthetic oblique or a stroke would put the host + * ahead of the reference renderer, which carries them without applying + * them. Same behaviour on both sides is worth more than either one + * being prettier. */ + +enum { + NativeSdkCellFlagBold = 1 << 0, + NativeSdkCellFlagItalic = 1 << 1, + NativeSdkCellFlagStrikethrough = 1 << 2, + NativeSdkCellFlagOverline = 1 << 3, + NativeSdkCellFlagHasBackground = 1 << 4, + NativeSdkCellFlagHasUnderlineColor = 1 << 5, +}; + +/* Underline style occupies bits 6..8, cell width bits 9..10 — the + * engine's `CellFlags` packing (canvas/cell_grid.zig). */ +static inline uint8_t NativeSdkCellUnderlineStyle(uint16_t flags) { return (uint8_t)((flags >> 6) & 0x7); } +static inline uint8_t NativeSdkCellWidthKind(uint16_t flags) { return (uint8_t)((flags >> 9) & 0x3); } + +static CGFloat NativeSdkCellStrokeWidth(CGFloat fontSize) { + return MAX(1, round(fontSize / 12)); +} + +/* Faux-weight rules, mirrored from the engine's + * canvas/cell_grid.zig `CellSynthesis`. They exist so a `\x1b[1m` run + * is visible on an app that registered no bold companion, and they are + * duplicated here rather than derived because a bold run that renders + * bold on the reference path and regular on this one is worse than no + * bold at all. */ +static CGFloat NativeSdkCellBoldOffset(CGFloat fontSize) { + return MAX(1, round(fontSize / 14)); +} +static const CGFloat NativeSdkCellItalicTangent = 0.2; + +/* The face a cell's style asks for, and what is left to synthesize. + * Mirrors `CellGrid.face`: real companions win, a half-family is used + * for the half it covers, and only what is missing is faked. */ +typedef struct { + unsigned long long fontId; + BOOL syntheticBold; + BOOL syntheticItalic; +} NativeSdkCellFace; + +static NativeSdkCellFace NativeSdkCellFaceFor(NSDictionary *grid, uint16_t flags) { + const BOOL wantBold = (flags & NativeSdkCellFlagBold) != 0; + const BOOL wantItalic = (flags & NativeSdkCellFlagItalic) != 0; + const unsigned long long regular = (unsigned long long)NativeSdkPacketNumber(grid[@"font"], 1); + const unsigned long long bold = (unsigned long long)NativeSdkPacketNumber(grid[@"boldFont"], 0); + const unsigned long long italic = (unsigned long long)NativeSdkPacketNumber(grid[@"italicFont"], 0); + const unsigned long long boldItalic = (unsigned long long)NativeSdkPacketNumber(grid[@"boldItalicFont"], 0); + NativeSdkCellFace face = {regular, NO, NO}; + if (wantBold && wantItalic) { + if (boldItalic != 0) { face.fontId = boldItalic; return face; } + if (bold != 0) { face.fontId = bold; face.syntheticItalic = YES; return face; } + if (italic != 0) { face.fontId = italic; face.syntheticBold = YES; return face; } + face.syntheticBold = YES; + face.syntheticItalic = YES; + return face; + } + if (wantBold) { + if (bold != 0) { face.fontId = bold; return face; } + face.syntheticBold = YES; + return face; + } + if (wantItalic) { + if (italic != 0) { face.fontId = italic; return face; } + face.syntheticItalic = YES; + return face; + } + return face; +} + +static void NativeSdkCellFillRect(NSRect rect, NSColor *color) { + if (!color || NSIsEmptyRect(rect)) return; + [color setFill]; + NSRectFillUsingOperation(rect, NSCompositingOperationSourceOver); +} + +static BOOL NativeSdkPacketDrawCellGrid(NSDictionary *grid, CGFloat opacity) { + if (!grid) return NO; + NSArray *cells = [grid[@"cells"] isKindOfClass:[NSArray class]] ? grid[@"cells"] : nil; + if (!cells) return NO; + NSUInteger cols = (NSUInteger)NativeSdkPacketNumber(grid[@"cols"], 0); + if (cols == 0) return YES; + NSPoint origin = NativeSdkPacketPoint(grid[@"origin"]); + CGFloat cellWidth = NativeSdkPacketNumber(grid[@"cellWidth"], 0); + CGFloat cellHeight = NativeSdkPacketNumber(grid[@"cellHeight"], 0); + CGFloat baseline = NativeSdkPacketNumber(grid[@"baseline"], 0); + CGFloat size = MAX(1, NativeSdkPacketNumber(grid[@"size"], 12)); + if (cellWidth <= 0 || cellHeight <= 0) return YES; + const CGFloat thickness = NativeSdkCellStrokeWidth(size); + + /* Pass 1: backgrounds. */ + NSUInteger index = 0; + for (id cellObject in cells) { + NSDictionary *cell = NativeSdkPacketDictionary(cellObject); + NSUInteger column = index++; + if (!cell || column >= cols) continue; + uint16_t flags = (uint16_t)NativeSdkPacketNumber(cell[@"flags"], 0); + if (!(flags & NativeSdkCellFlagHasBackground)) continue; + NSColor *background = NativeSdkPacketColor(cell[@"bg"], opacity); + if (!background) continue; + NativeSdkCellFillRect(NSMakeRect(origin.x + (CGFloat)column * cellWidth, origin.y, cellWidth, cellHeight), background); + } + + /* Pass 2: ink and decorations. */ + index = 0; + for (id cellObject in cells) { + NSDictionary *cell = NativeSdkPacketDictionary(cellObject); + NSUInteger column = index++; + if (!cell || column >= cols) continue; + uint16_t flags = (uint16_t)NativeSdkPacketNumber(cell[@"flags"], 0); + if (NativeSdkCellWidthKind(flags) == 2) continue; /* spacer */ + NSColor *foreground = NativeSdkPacketColor(cell[@"fg"], opacity); + if (!foreground) continue; + const CGFloat x = origin.x + (CGFloat)column * cellWidth; + /* A wide cell inks and decorates across both of its columns. */ + const CGFloat inkWidth = NativeSdkCellWidthKind(flags) == 1 ? cellWidth * 2 : cellWidth; + + NSString *cluster = [cell[@"text"] isKindOfClass:[NSString class]] ? cell[@"text"] : nil; + if (cluster.length > 0) { + /* Face selection is per CELL, not per row: one row mixes + * regular, bold, and italic freely and every one of them + * inks at the same pen with the same advance. */ + const NativeSdkCellFace face = NativeSdkCellFaceFor(grid, flags); + NSFont *cellFont = NativeSdkFontForFontId(face.fontId, size); + if (cellFont) { + const NSPoint pen = NSMakePoint(x, origin.y + baseline - size); + NSDictionary *attributes = @{ + NSFontAttributeName : cellFont, + NSForegroundColorAttributeName : foreground, + }; + if (face.syntheticItalic) { + /* Shear about the BASELINE, the same axis and the + * same tangent the reference renderer bakes into its + * glyph affine. Saved/restored per cell so the shear + * cannot leak into a neighbour's ink. */ + CGContextRef context = NSGraphicsContext.currentContext.CGContext; + CGContextSaveGState(context); + const CGFloat baselineY = origin.y + baseline; + CGContextTranslateCTM(context, 0, baselineY); + CGContextConcatCTM(context, CGAffineTransformMake(1, 0, NativeSdkCellItalicTangent, 1, 0, 0)); + CGContextTranslateCTM(context, 0, -baselineY); + [cluster drawAtPoint:pen withAttributes:attributes]; + if (face.syntheticBold) { + [cluster drawAtPoint:NSMakePoint(pen.x + NativeSdkCellBoldOffset(size), pen.y) withAttributes:attributes]; + } + CGContextRestoreGState(context); + } else { + [cluster drawAtPoint:pen withAttributes:attributes]; + if (face.syntheticBold) { + [cluster drawAtPoint:NSMakePoint(pen.x + NativeSdkCellBoldOffset(size), pen.y) withAttributes:attributes]; + } + } + } + } + + if (flags & NativeSdkCellFlagOverline) { + NativeSdkCellFillRect(NSMakeRect(x, origin.y, inkWidth, thickness), foreground); + } + if (flags & NativeSdkCellFlagStrikethrough) { + NativeSdkCellFillRect(NSMakeRect(x, origin.y + round(cellHeight * 0.55) - thickness, inkWidth, thickness), foreground); + } + const uint8_t underlineStyle = NativeSdkCellUnderlineStyle(flags); + if (underlineStyle == 0) continue; + NSColor *underlineColor = (flags & NativeSdkCellFlagHasUnderlineColor) + ? NativeSdkPacketColor(cell[@"ul"], opacity) + : foreground; + if (!underlineColor) underlineColor = foreground; + const NSRect line = NSMakeRect(x, origin.y + cellHeight - thickness * 2, inkWidth, thickness); + switch (underlineStyle) { + case 1: /* single */ + NativeSdkCellFillRect(line, underlineColor); + break; + case 2: /* double */ + NativeSdkCellFillRect(line, underlineColor); + NativeSdkCellFillRect(NSMakeRect(x, origin.y + cellHeight - thickness * 4, inkWidth, thickness), underlineColor); + break; + case 3: { /* curly: one-point ticks tracing a triangle wave */ + const CGFloat amplitude = thickness; + const CGFloat period = MAX(4, round(size / 3)); + for (CGFloat step = 0; step < line.size.width; step += 1) { + CGFloat phase = fmod(step, period) / period; + CGFloat ramp = phase < 0.5 ? phase * 2 : (1 - phase) * 2; + NativeSdkCellFillRect(NSMakeRect(line.origin.x + step, line.origin.y - amplitude + ramp * amplitude * 2, 1, thickness), underlineColor); + } + break; + } + case 4: /* dotted */ + case 5: { /* dashed */ + const CGFloat period = underlineStyle == 4 ? MAX(2, round(line.size.height * 2)) : MAX(4, round(line.size.height * 6)); + const CGFloat on = underlineStyle == 4 ? MAX(1, round(period * 0.5)) : MAX(2, round(period * 0.6)); + for (CGFloat start = 0; start < line.size.width; start += period) { + CGFloat width = MIN(on, line.size.width - start); + NativeSdkCellFillRect(NSMakeRect(line.origin.x + start, line.origin.y, width, line.size.height), underlineColor); + } + break; + } + default: + break; + } + } + return YES; +} + static BOOL NativeSdkPacketDrawCommandBody(NSDictionary *command, NSString *kind, CGFloat opacity, CGContextRef context, CGFloat scale, BOOL hasEffectiveClip, NSRect effectiveClip, NSDictionary *imageCache) { BOOL ok = YES; if ([kind hasPrefix:@"fill_rect"] || [kind hasPrefix:@"fill_rounded_rect"]) { @@ -2901,6 +3138,8 @@ static BOOL NativeSdkPacketDrawCommandBody(NSDictionary *command, NSString *kind ok = NativeSdkPacketDrawPaintedPath(path, NativeSdkPacketDictionary(command[@"paint"]), opacity, YES); } else if ([kind isEqualToString:@"draw_text"]) { ok = NativeSdkPacketDrawText(NativeSdkPacketDictionary(command[@"text"]), opacity); + } else if ([kind isEqualToString:@"cell_grid"]) { + ok = NativeSdkPacketDrawCellGrid(NativeSdkPacketDictionary(command[@"cellGrid"]), opacity); } else if ([kind isEqualToString:@"shadow"] || [kind isEqualToString:@"blur"]) { ok = NativeSdkPacketDrawEffect(NativeSdkPacketDictionary(command[@"effect"]), opacity, context, scale, command[@"transform"], hasEffectiveClip, effectiveClip); } else if ([kind isEqualToString:@"draw_image"]) { @@ -2952,7 +3191,11 @@ static BOOL NativeSdkGpuCompositeEnabled(void) { static BOOL NativeSdkPacketCommandRasterCacheable(NSDictionary *command, NSString *kind) { if (command[@"transform"]) return NO; if (command[@"clip"] && !NativeSdkPacketArray(command[@"clip"], 4)) return NO; - if ([kind isEqualToString:@"draw_text"] || [kind isEqualToString:@"shadow"]) return YES; + /* A cell-grid row is a pure function of its command (its cells carry + * their own colours and clusters), so its raster caches like any + * text run — which is what makes an unchanged row a blit instead of + * a per-cell re-raster on every dirty update. */ + if ([kind isEqualToString:@"draw_text"] || [kind isEqualToString:@"shadow"] || [kind isEqualToString:@"cell_grid"]) return YES; if ([kind hasPrefix:@"fill_rect"] || [kind hasPrefix:@"fill_rounded_rect"] || [kind hasPrefix:@"stroke_rect"] || [kind hasPrefix:@"draw_line"]) return YES; if ([kind isEqualToString:@"fill_path"] || [kind isEqualToString:@"stroke_path"]) return YES; if ([kind isEqualToString:@"draw_image"]) return YES; @@ -2977,7 +3220,7 @@ static NSRect NativeSdkPacketAlignRectToPixels(NSRect rect, CGFloat scale, NSUIn } /* --------------------------------------------------------------------------- - * Compact binary gpu-surface packet decoding (wire format v5). + * Compact binary gpu-surface packet decoding (wire format v7). * * Little-endian, length-prefixed, mirror of the engine's binary packet * encoder (serialization.zig, `writeCanvasGpuPacketBinary` and the patch @@ -3097,6 +3340,7 @@ static CGFloat NativeSdkBinaryReadF32(NativeSdkBinaryPacketReader *reader) { case 11: return @"draw_text"; case 12: return @"shadow"; case 13: return @"blur"; + case 14: return @"cell_grid"; default: return nil; } } @@ -3349,6 +3593,102 @@ static CGFloat NativeSdkBinaryReadF32(NativeSdkBinaryPacketReader *reader) { NativeSdkBinaryCommandFlagEffect = 0x80, }; +/* One packed cell-grid ROW (wire v6). + * + * The engine's `cell_grid` command carries a terminal row as a lattice + * of cells rather than as per-run fills and text draws, so a dense + * screen costs one command per row instead of two per cell. Cells + * arrive as a DELTA stream: a tag byte per cell whose low bit means + * "same colours and flags as the previous cell", which is why a plain + * row is roughly a byte a column on the wire. + * + * Decoded into the same NSDictionary shape the rest of this file works + * in: cells become an NSArray of per-cell dictionaries so the draw path + * below reads them without a second parse. The SPEC for what these + * pixels must be is the engine's reference renderer + * (canvas/reference.zig, drawCellGrid / drawCellDecorations); this + * decoder mirrors it deliberately, including the two-pass order. */ +static NSDictionary *NativeSdkBinaryReadCellGrid(NativeSdkBinaryPacketReader *reader) { + uint32_t fontId = NativeSdkBinaryReadU32(reader); + uint32_t boldFontId = NativeSdkBinaryReadU32(reader); + uint32_t italicFontId = NativeSdkBinaryReadU32(reader); + uint32_t boldItalicFontId = NativeSdkBinaryReadU32(reader); + NSNumber *fontSize = NativeSdkBinaryReadF32Number(reader); + NSArray *origin = NativeSdkBinaryReadF32Array(reader, 2); + NSNumber *cellWidth = NativeSdkBinaryReadF32Number(reader); + NSNumber *cellHeight = NativeSdkBinaryReadF32Number(reader); + NSNumber *baseline = NativeSdkBinaryReadF32Number(reader); + uint16_t cols = NativeSdkBinaryReadU16(reader); + uint16_t rows = NativeSdkBinaryReadU16(reader); + uint32_t cellCount = NativeSdkBinaryReadU32(reader); + if (reader->failed || !origin || !fontSize) return nil; + /* A row cannot be longer than the engine's own column ceiling; a + * count past it is a framing violation, not a big screen. */ + if (cellCount > 4096 || cellCount > reader->length - reader->offset) { + reader->failed = YES; + return nil; + } + + NSMutableArray *cells = [NSMutableArray arrayWithCapacity:cellCount]; + uint8_t fg[4] = {0, 0, 0, 0}; + uint8_t bg[4] = {0, 0, 0, 0}; + uint8_t underline[4] = {0, 0, 0, 0}; + uint16_t cellFlags = 0; + BOOL haveStyle = NO; + for (uint32_t index = 0; index < cellCount; index++) { + uint8_t tag = NativeSdkBinaryReadU8(reader); + if (reader->failed) return nil; + BOOL sameStyle = (tag & 1) != 0; + BOOL hasCluster = (tag & 2) != 0; + if (!sameStyle) { + for (int channel = 0; channel < 4; channel++) fg[channel] = NativeSdkBinaryReadU8(reader); + for (int channel = 0; channel < 4; channel++) bg[channel] = NativeSdkBinaryReadU8(reader); + for (int channel = 0; channel < 4; channel++) underline[channel] = NativeSdkBinaryReadU8(reader); + cellFlags = NativeSdkBinaryReadU16(reader); + haveStyle = YES; + } else if (!haveStyle) { + /* "Same as the previous cell" with no previous cell. */ + reader->failed = YES; + return nil; + } + NSString *cluster = nil; + if (hasCluster) { + uint8_t length = NativeSdkBinaryReadU8(reader); + if (reader->failed || length > reader->length - reader->offset) { + reader->failed = YES; + return nil; + } + if (!NativeSdkBinaryHasBytes(reader, length)) return nil; + cluster = [[NSString alloc] initWithBytes:reader->bytes + reader->offset length:length encoding:NSUTF8StringEncoding]; + reader->offset += length; + if (!cluster) cluster = @""; + } + if (reader->failed) return nil; + NSMutableDictionary *cell = [NSMutableDictionary dictionaryWithCapacity:6]; + cell[@"fg"] = @[ @(fg[0] / 255.0), @(fg[1] / 255.0), @(fg[2] / 255.0), @(fg[3] / 255.0) ]; + cell[@"bg"] = @[ @(bg[0] / 255.0), @(bg[1] / 255.0), @(bg[2] / 255.0), @(bg[3] / 255.0) ]; + cell[@"ul"] = @[ @(underline[0] / 255.0), @(underline[1] / 255.0), @(underline[2] / 255.0), @(underline[3] / 255.0) ]; + cell[@"flags"] = @(cellFlags); + if (cluster) cell[@"text"] = cluster; + [cells addObject:cell]; + } + if (reader->failed) return nil; + return @{ + @"font" : @(fontId), + @"boldFont" : @(boldFontId), + @"italicFont" : @(italicFontId), + @"boldItalicFont" : @(boldItalicFontId), + @"size" : fontSize, + @"origin" : origin, + @"cellWidth" : cellWidth, + @"cellHeight" : cellHeight, + @"baseline" : baseline, + @"cols" : @(cols), + @"rows" : @(rows), + @"cells" : cells, + }; +} + static NSDictionary *NativeSdkBinaryReadCommand(NativeSdkBinaryPacketReader *reader) { uint8_t kindCode = NativeSdkBinaryReadU8(reader); uint8_t flags = NativeSdkBinaryReadU8(reader); @@ -3408,6 +3748,13 @@ static CGFloat NativeSdkBinaryReadF32(NativeSdkBinaryPacketReader *reader) { if (!effect) return nil; command[@"effect"] = effect; } + /* Implied by the KIND, not by a flag bit: the flag byte is full, and + * every cell_grid carries this payload while no other kind does. */ + if (kindCode == 14) { + NSDictionary *grid = NativeSdkBinaryReadCellGrid(reader); + if (!grid) return nil; + command[@"cellGrid"] = grid; + } return reader->failed ? nil : command; } @@ -3425,7 +3772,7 @@ static CGFloat NativeSdkBinaryReadF32(NativeSdkBinaryPacketReader *reader) { if (memcmp(bytes, "NSGP", 4) != 0) return nil; reader.offset = 4; uint8_t version = NativeSdkBinaryReadU8(&reader); - if (version != 5) return nil; + if (version != 7) return nil; uint8_t loadActionCode = NativeSdkBinaryReadU8(&reader); uint8_t packetFlags = NativeSdkBinaryReadU8(&reader); (void)NativeSdkBinaryReadU8(&reader); /* reserved */ @@ -4223,6 +4570,17 @@ - (BOOL)ensureCanvasCompositor { if (!bitmap) return nil; CGContextSetAllowsAntialiasing(bitmap, true); CGContextSetShouldAntialias(bitmap, true); + /* Font smoothing is macOS's stem darkening for text, and it is OFF by + * default on a transparent backing. Every glyph this host draws lands in + * a CGBitmapContext like this one, so leaving it off renders ALL text + * systematically thin - measured at 35% fewer fully-solid stem pixels + * (2341 vs 3164 at 13pt/scale 2) with the bundled JetBrains Mono NL. + * Measure with phux-cockpit's scripts/measure-glyph-smoothing.m before + * changing this; the deficit is invisible in the CPU reference renderer, + * which never touches CoreText, so no reference screenshot can catch a + * regression here. Allows- must precede Should-: the former gates it. */ + CGContextSetAllowsFontSmoothing(bitmap, true); + CGContextSetShouldSmoothFonts(bitmap, true); CGContextTranslateCTM(bitmap, 0, (CGFloat)rasterHeight); CGContextScaleCTM(bitmap, scale, -scale); CGContextTranslateCTM(bitmap, -minX / scale, -minY / scale); @@ -4899,6 +5257,11 @@ - (NativeSdkPacketCommandRaster *)rasterCacheBuildEntryForCommand:(NSDictionary if (!bitmap) return nil; CGContextSetAllowsAntialiasing(bitmap, true); CGContextSetShouldAntialias(bitmap, true); + /* Stem darkening for text; see the raster path above for why and for how + * to measure it. This is the CACHED command raster, so a glyph rasterized + * thin here stays thin for the life of the cache entry. */ + CGContextSetAllowsFontSmoothing(bitmap, true); + CGContextSetShouldSmoothFonts(bitmap, true); CGContextTranslateCTM(bitmap, 0, (CGFloat)rasterHeight); CGContextScaleCTM(bitmap, scale, -scale); CGContextTranslateCTM(bitmap, -minX / scale, -minY / scale); @@ -5112,6 +5475,12 @@ - (NSInteger)drawPacketCommands:(NSArray *)commands keys:(NSArray *)keys pixels: CGContextSetAllowsAntialiasing(context, true); CGContextSetShouldAntialias(context, true); + /* Stem darkening for text; see the raster path above for why and for how + * to measure it. This is the MAIN per-present surface pass - the one that + * draws the terminal cell grid - so it is the site the faint-text report + * was actually about. */ + CGContextSetAllowsFontSmoothing(context, true); + CGContextSetShouldSmoothFonts(context, true); CGContextTranslateCTM(context, 0, (CGFloat)pixelHeight); CGContextScaleCTM(context, scale, -scale); @@ -7992,6 +8361,24 @@ - (void)miniaturizeWindowWithId:(uint64_t)windowId { [window miniaturize:nil]; } +/* SET fullscreen, not toggle. AppKit only offers `toggleFullScreen:`, + * so the state is compared first and the verb only fires when it + * differs — that is what makes the engine-side call idempotent, and + * what stops an app restoring a remembered layout from flipping OUT of + * fullscreen because it was already in. + * + * The confirmation arrives through the window's own delegate callbacks + * (the same ones that keep `WindowInfo.fullscreen` current), so a + * transition the USER started from the green button and one the app + * asked for are reported identically. */ +- (void)setWindowWithId:(uint64_t)windowId fullscreen:(BOOL)fullscreen { + NSWindow *window = self.windows[@(windowId)]; + if (!window) return; + const BOOL isFullscreen = (window.styleMask & NSWindowStyleMaskFullScreen) != 0; + if (isFullscreen == fullscreen) return; + [window toggleFullScreen:nil]; +} + // The window-drag region channel. Called synchronously while the runtime // dispatches the pointer-down that started the gesture, so // NSApp.currentEvent IS that mouse-down NSEvent (the host forwards input @@ -9923,6 +10310,10 @@ - (void)emitWindowFrameForWindowId:(uint64_t)windowId open:(BOOL)open { // frame emit while a window sits in the policy-hidden set // carries it, and hide/show flip the set before they emit. .hidden = [self.policyHiddenWindows containsObject:@(windowId)] ? 1 : 0, + // Host truth, same as `hidden`: the style mask is what AppKit + // flips on both sides of a fullscreen transition, whoever + // started it. + .fullscreen = (window.styleMask & NSWindowStyleMaskFullScreen) != 0 ? 1 : 0, .label = label.UTF8String, .label_len = [label lengthOfBytesUsingEncoding:NSUTF8StringEncoding], }]; @@ -12434,6 +12825,13 @@ int native_sdk_appkit_minimize_window(native_sdk_appkit_host_t *host, uint64_t w return 1; } +int native_sdk_appkit_set_window_fullscreen(native_sdk_appkit_host_t *host, uint64_t window_id, int fullscreen) { + NativeSdkAppKitHost *object = (__bridge NativeSdkAppKitHost *)host; + if (!object.windows[@(window_id)]) return 0; + [object setWindowWithId:window_id fullscreen:fullscreen != 0]; + return 1; +} + int native_sdk_appkit_show_window(native_sdk_appkit_host_t *host, uint64_t window_id) { NativeSdkAppKitHost *object = (__bridge NativeSdkAppKitHost *)host; if (!object.windows[@(window_id)]) return 0; diff --git a/src/platform/macos/root.zig b/src/platform/macos/root.zig index 43582cc51..fdd743cef 100644 --- a/src/platform/macos/root.zig +++ b/src/platform/macos/root.zig @@ -55,6 +55,10 @@ const AppKitEvent = extern struct { y: f64, open: c_int, focused: c_int, + /// WINDOW_FRAME: nonzero while the window occupies its own + /// fullscreen Space (read from the style mask, so user-started and + /// app-started transitions report identically). + fullscreen: c_int, /// WINDOW_FRAME: nonzero while the window is alive but hidden by /// its close_policy (`open` stays 1 for the whole hidden stretch). hidden: c_int, @@ -180,6 +184,7 @@ extern fn native_sdk_appkit_set_window_content_min_size(host: *AppKitHost, windo extern fn native_sdk_appkit_focus_window(host: *AppKitHost, window_id: u64) c_int; extern fn native_sdk_appkit_close_window(host: *AppKitHost, window_id: u64) c_int; extern fn native_sdk_appkit_minimize_window(host: *AppKitHost, window_id: u64) c_int; +extern fn native_sdk_appkit_set_window_fullscreen(host: *AppKitHost, window_id: u64, fullscreen: c_int) c_int; extern fn native_sdk_appkit_show_window(host: *AppKitHost, window_id: u64) c_int; extern fn native_sdk_appkit_set_window_close_policy(host: *AppKitHost, window_id: u64, close_policy: c_int) c_int; extern fn native_sdk_appkit_start_window_drag(host: *AppKitHost, window_id: u64) c_int; @@ -710,6 +715,7 @@ pub const MacPlatform = struct { .focus_window_fn = focusWindow, .close_window_fn = closeWindow, .minimize_window_fn = minimizeWindow, + .set_window_fullscreen_fn = setWindowFullscreen, .show_window_fn = showWindow, .quit_app_fn = quitApp, .start_window_drag_fn = startWindowDrag, @@ -936,6 +942,7 @@ fn appkitCallback(context: ?*anyopaque, event: *const AppKitEvent) callconv(.c) .open = event.open != 0, .focused = event.focused != 0, .hidden = event.hidden != 0, + .fullscreen = event.fullscreen != 0, } }); }, .view_focused => state.emit(.{ .view_focused = .{ @@ -1325,6 +1332,11 @@ fn minimizeWindow(context: ?*anyopaque, window_id: platform_mod.WindowId) anyerr if (native_sdk_appkit_minimize_window(self.host, window_id) == 0) return error.WindowNotFound; } +fn setWindowFullscreen(context: ?*anyopaque, window_id: platform_mod.WindowId, fullscreen: bool) anyerror!void { + const self: *MacPlatform = @ptrCast(@alignCast(context.?)); + if (native_sdk_appkit_set_window_fullscreen(self.host, window_id, if (fullscreen) 1 else 0) == 0) return error.WindowNotFound; +} + fn showWindow(context: ?*anyopaque, window_id: platform_mod.WindowId) anyerror!void { const self: *MacPlatform = @ptrCast(@alignCast(context.?)); if (native_sdk_appkit_show_window(self.host, window_id) == 0) return error.WindowNotFound; diff --git a/src/platform/null_platform.zig b/src/platform/null_platform.zig index dc62bf25d..adcb69e5d 100644 --- a/src/platform/null_platform.zig +++ b/src/platform/null_platform.zig @@ -342,6 +342,9 @@ pub const NullPlatform = struct { window_always_on_top: [max_windows]bool = [_]bool{false} ** max_windows, window_click_through: [max_windows]bool = [_]bool{false} ** max_windows, window_activate_on_show: [max_windows]bool = [_]bool{true} ** max_windows, + /// Fullscreen set calls per window (`set_window_fullscreen_fn`), + /// indexed like the windows array. + window_fullscreen_calls: [max_windows]u32 = @splat(0), /// Minimize calls per window (`minimize_window_fn`), indexed like /// `windows`: the observable seam for app-drawn minimize controls — /// the null platform has no Dock to genie into, so the count IS the @@ -825,6 +828,7 @@ pub const NullPlatform = struct { .focus_window_fn = focusWindow, .close_window_fn = closeWindow, .minimize_window_fn = minimizeWindow, + .set_window_fullscreen_fn = setWindowFullscreen, .show_window_fn = showWindow, .quit_app_fn = quitApp, .start_window_drag_fn = startWindowDrag, @@ -1191,6 +1195,16 @@ pub const NullPlatform = struct { self.removeWebViewsForWindow(window_id); } + fn setWindowFullscreen(context: ?*anyopaque, window_id: WindowId, fullscreen: bool) anyerror!void { + const self: *NullPlatform = @ptrCast(@alignCast(context.?)); + const index = self.findWindowIndex(window_id) orelse return error.WindowNotFound; + // The modeled host answers the request immediately; a real one + // confirms through its own window event, which is why the + // runtime keeps no fullscreen bookkeeping of its own. + self.windows[index].fullscreen = fullscreen; + self.window_fullscreen_calls[index] += 1; + } + fn minimizeWindow(context: ?*anyopaque, window_id: WindowId) anyerror!void { const self: *NullPlatform = @ptrCast(@alignCast(context.?)); const index = self.findWindowIndex(window_id) orelse return error.WindowNotFound; @@ -2659,6 +2673,18 @@ pub const NullPlatform = struct { /// Test seam: show calls observed for a window (the un-hide verb's /// pinned observable, like `minimizeCountForWindow`). + /// Fullscreen SET calls the modeled host received for a window. + pub fn fullscreenCountForWindow(self: *const NullPlatform, window_id: WindowId) u32 { + const index = self.findWindowIndex(window_id) orelse return 0; + return self.window_fullscreen_calls[index]; + } + + /// The modeled host's current fullscreen state for a window. + pub fn windowIsFullscreen(self: *const NullPlatform, window_id: WindowId) bool { + const index = self.findWindowIndex(window_id) orelse return false; + return self.windows[index].fullscreen; + } + pub fn showCountForWindow(self: *const NullPlatform, window_id: WindowId) u32 { const index = self.findWindowIndex(window_id) orelse return 0; return self.window_show_count[index]; diff --git a/src/platform/types.zig b/src/platform/types.zig index 4d970a660..96161ee53 100644 --- a/src/platform/types.zig +++ b/src/platform/types.zig @@ -669,6 +669,16 @@ pub const WindowInfo = struct { focused: bool = false, /// Alive but policy-hidden — see `WindowState.hidden`. hidden: bool = false, + /// The window occupies its own fullscreen Space (macOS) or the + /// platform equivalent. + /// + /// The READ half of the fullscreen capability, and it reports + /// transitions the USER started from the green button exactly like + /// ones the app asked for via `set_window_fullscreen_fn`. Before + /// this the flag existed only on `WindowState` and nothing ever + /// filled it, so even the window-state store persisted a constant + /// false. + fullscreen: bool = false, pub fn state(self: WindowInfo) WindowState { return .{ @@ -680,6 +690,7 @@ pub const WindowInfo = struct { .open = self.open, .focused = self.focused, .hidden = self.hidden, + .fullscreen = self.fullscreen, }; } }; @@ -2471,6 +2482,21 @@ pub const PlatformServices = struct { /// controls — chromeless windows have no system button to click. /// Platforms without the concept leave this null. minimize_window_fn: ?*const fn (context: ?*anyopaque, window_id: WindowId) anyerror!void = null, + /// The real OS fullscreen verb (macOS `toggleFullScreen:` into its + /// own Space, Windows/GTK their equivalents). + /// + /// SET, not toggle, so the call is idempotent and an app can drive + /// fullscreen from state it already owns — restoring a remembered + /// layout at launch, or binding its own shortcut — instead of having + /// to track parity against `WindowInfo.fullscreen`. A host whose + /// native verb is a toggle compares the window's current state and + /// only flips when it differs. + /// + /// The counterpart to the `WindowInfo.fullscreen` the platform + /// already REPORTS: without it an app could be told it was + /// fullscreen and never ask to be, which is the asymmetry this + /// closes. Platforms without the concept leave it null. + set_window_fullscreen_fn: ?*const fn (context: ?*anyopaque, window_id: WindowId, fullscreen: bool) anyerror!void = null, /// The real OS show verb: unhide + order front. It activates by /// default; windows created with `activate_on_show = false` use the /// platform's passive variant. This is the counterpart to a @@ -2900,6 +2926,11 @@ pub const PlatformServices = struct { return minimize_fn(self.context, window_id); } + pub fn setWindowFullscreen(self: PlatformServices, window_id: WindowId, fullscreen: bool) anyerror!void { + const fullscreen_fn = self.set_window_fullscreen_fn orelse return error.UnsupportedService; + return fullscreen_fn(self.context, window_id, fullscreen); + } + pub fn showWindow(self: PlatformServices, window_id: WindowId) anyerror!void { const show_fn = self.show_window_fn orelse return error.UnsupportedService; return show_fn(self.context, window_id); diff --git a/src/platform/windows/gpu_surface_renderer.cpp b/src/platform/windows/gpu_surface_renderer.cpp index f58345384..cfccaceae 100644 --- a/src/platform/windows/gpu_surface_renderer.cpp +++ b/src/platform/windows/gpu_surface_renderer.cpp @@ -16,13 +16,20 @@ namespace { -/* Compact binary gpu-surface packet decoding (wire format v5). +/* Compact binary gpu-surface packet decoding (wire format v7). * * This independent decoder deliberately repeats the encoder's tags and * bounds rather than sharing packed structs across the Zig/C++ ABI. A * version or layout disagreement is a refused present, which makes the * runtime resynchronize/fall back instead of drawing corrupt content. */ -constexpr uint8_t kPacketVersion = 5; +/* v6 added the `cell_grid` command kind (14), a packed terminal row; + * v7 gave it a bold/italic font family. + * This decoder does not implement it: an unknown kind fails validation + * (see the `default:` arm below), which refuses the whole packet and + * drops the frame to the engine's pixel fallback. Every packet WITHOUT + * a terminal in it keeps the retained Direct2D path, which is why the + * version moves rather than the decoder rejecting v6 wholesale. */ +constexpr uint8_t kPacketVersion = 7; constexpr size_t kRetainedCommandCap = 2048; constexpr size_t kDirtyRectCap = kWindowsGpuDirtyRectCap; constexpr uint32_t kMaxSurfacePixels = 8192; diff --git a/src/primitives/canvas/a11y_audit.zig b/src/primitives/canvas/a11y_audit.zig index 880b8ce77..1b4a4e616 100644 --- a/src/primitives/canvas/a11y_audit.zig +++ b/src/primitives/canvas/a11y_audit.zig @@ -96,7 +96,7 @@ pub fn auditWidgetA11y(layout: WidgetLayoutTree, storage: []A11yAuditFinding) A1 var index: usize = 0; while (index < node_count) : (index += 1) { - if (!nodePainted(layout, index)) continue; + if (!nodeAnnounced(layout, index)) continue; auditMissingLabel(layout, index, &sink); auditFocusReachable(layout, index, &sink); auditDuplicateSiblingLabel(layout, index, node_count, &sink); @@ -118,14 +118,16 @@ const FindingSink = struct { } }; -/// Announced at all: hidden subtrees and fully transparent subtrees are -/// removed from both the frame and the semantic tree, so the audit stays +/// Announced at all: hidden subtrees, DECORATIVE subtrees, and fully +/// transparent subtrees never reach the semantic tree, so the audit stays /// quiet about them (the semantics collector skips them the same way). -fn nodePainted(layout: WidgetLayoutTree, node_index: usize) bool { +/// `decorative` is the deliberate opt-out — a magnifier glyph the author +/// declared as decoration must not then be reported as an unnamed image. +fn nodeAnnounced(layout: WidgetLayoutTree, node_index: usize) bool { var current: ?usize = node_index; while (current) |index| { const widget = layout.nodes[index].widget; - if (widget.semantics.hidden) return false; + if (widget.semantics.concealedFromAccessibility()) return false; if (widget.opacity <= 0) return false; current = layout.nodes[index].parent_index; } @@ -177,7 +179,9 @@ fn roleAnnouncesContent(role: WidgetRole) bool { fn subtreeHasText(widget: Widget) bool { for (widget.children) |child| { - if (child.semantics.hidden) continue; + // Unannounced children (hidden or decorative) contribute nothing + // to the row's spoken name — the collector never emits them. + if (child.semantics.concealedFromAccessibility()) continue; if (!allBlank(child.semantics.label) or !allBlank(child.text)) return true; if (subtreeHasText(child)) return true; } @@ -292,7 +296,7 @@ fn auditDuplicateSiblingLabel(layout: WidgetLayoutTree, node_index: usize, node_ const other = layout.nodes[earlier]; if (other.parent_index != parent_index) continue; if (other.widget.id == 0) continue; - if (!nodePainted(layout, earlier)) continue; + if (!nodeAnnounced(layout, earlier)) continue; if (!frameHasArea(other.frame)) continue; if (widget_semantics.semanticRole(other.widget) != role) continue; if (!std.mem.eql(u8, announcedName(other.widget), name)) continue; diff --git a/src/primitives/canvas/cell_grid.zig b/src/primitives/canvas/cell_grid.zig new file mode 100644 index 000000000..951195e1a --- /dev/null +++ b/src/primitives/canvas/cell_grid.zig @@ -0,0 +1,410 @@ +//! The packed cell grid: one display-list command for a whole terminal +//! screen. +//! +//! Every other canvas command draws one shape. This one draws a +//! REGULAR LATTICE of them — a `cols` x `rows` array of cells, each +//! carrying its own background, foreground, grapheme cluster, and style +//! — and every renderer expands it itself. It exists because a terminal +//! is the one surface whose content scales with AREA rather than with +//! design: a screen styled per cell merges into nothing, and paying two +//! display-list commands per cell (one background run, one text run) +//! put a 200x60 truecolor viewport at ~24,000 commands against a +//! per-view budget of 4,096 — 9 rows of 60, the rest bare background. +//! No budget raise reaches that shape: one command slot costs ~700 B +//! across the view's retained mirrors, so 60,000 commands (300x100) is +//! 40 MiB per view before the pixels exist. +//! +//! A grid is ONE command and ONE retained key. Its cost is linear in +//! CELLS, at 20 bytes each: a 300x100 screen is 600 KB of cells and 1 +//! command, and it either paints whole or reports that it could not. +//! The retained diff replaces it wholesale — a screen that changed is +//! one changed key, which is also what makes reflow safe (a row that +//! loses a run cannot leave an orphaned per-run command behind, because +//! there are no per-run commands). +//! +//! What the grid does NOT carry, deliberately: +//! - Box-drawing cells. They render as exact GEOMETRY at cell bounds +//! (`terminal_box.zig`) because glyphs fill the em box, not the +//! padded cell, and borders drawn from them show seams. Those cells +//! keep their background in the grid and paint no ink from it. +//! - The cursor, the keyboard caret, the selection wash, the +//! scrollback thumb. All are a handful of commands per frame and +//! all composite OVER the grid. +//! +//! Colors arrive RESOLVED and 8-bit. A terminal's color model is +//! 8-bit-per-channel by construction (ANSI, 256-color, and truecolor +//! all land there), so storing cells at canvas `Color` precision would +//! quadruple the array to buy nothing. `CellColor` converts at the +//! edges. + +const std = @import("std"); +const geometry = @import("geometry"); +const canvas = @import("root.zig"); +const drawing_model = @import("drawing.zig"); +const text_metrics = @import("text_metrics.zig"); + +const ObjectId = canvas.ObjectId; +const FontId = canvas.FontId; +const Color = drawing_model.Color; + +/// A cell's color at the terminal's own precision. Conversion is +/// round-trip stable for any color that entered as 8-bit — which is +/// every color a terminal produces. +pub const CellColor = extern struct { + r: u8 = 0, + g: u8 = 0, + b: u8 = 0, + a: u8 = 0, + + pub fn fromColor(color: Color) CellColor { + return .{ + .r = channelToU8(color.r), + .g = channelToU8(color.g), + .b = channelToU8(color.b), + .a = channelToU8(color.a), + }; + } + + pub fn toColor(self: CellColor) Color { + return Color.rgba( + @as(f32, @floatFromInt(self.r)) / 255.0, + @as(f32, @floatFromInt(self.g)) / 255.0, + @as(f32, @floatFromInt(self.b)) / 255.0, + @as(f32, @floatFromInt(self.a)) / 255.0, + ); + } + + pub fn eql(self: CellColor, other: CellColor) bool { + return self.r == other.r and self.g == other.g and + self.b == other.b and self.a == other.a; + } + + pub fn bits(self: CellColor) u32 { + return @as(u32, self.r) | + (@as(u32, self.g) << 8) | + (@as(u32, self.b) << 16) | + (@as(u32, self.a) << 24); + } +}; + +fn channelToU8(value: f32) u8 { + if (!(value > 0)) return 0; + if (value >= 1) return 255; + return @intFromFloat(@round(value * 255)); +} + +/// SGR underline styles. `none` means the cell carries no underline at +/// all — the flag and the style are one field, so a cell can never be +/// "underlined with no style" or "styled but not underlined". +pub const CellUnderline = enum(u3) { + none, + single, + double, + curly, + dotted, + dashed, +}; + +/// Cell occupancy. A `wide` cell paints a two-column cluster; the +/// `spacer` column after it carries the primary's background (the +/// producer resolves that) and no ink of its own. +pub const CellWidth = enum(u2) { narrow, wide, spacer }; + +/// Everything about a cell that is not a color or a cluster. Packed to +/// 16 bits so `Cell` stays 20 bytes. +pub const CellFlags = packed struct(u16) { + bold: bool = false, + italic: bool = false, + strikethrough: bool = false, + overline: bool = false, + /// Whether `bg` paints. A cell without one shows the grid's surface + /// background, which the painter already filled. + has_background: bool = false, + /// Whether `underline_color` paints. Without it an underline takes + /// the cell's foreground, the SGR default. + has_underline_color: bool = false, + underline: CellUnderline = .none, + width: CellWidth = .narrow, + _reserved: u5 = 0, + + pub fn bits(self: CellFlags) u16 { + return @bitCast(self); + } + + pub fn fromBits(value: u16) CellFlags { + return @bitCast(value); + } +}; + +/// One resolved cell, 20 bytes. +/// +/// The cluster lives in the grid's shared `text` blob rather than in +/// the cell, because clusters are variable-length and most cells hold +/// one ASCII byte: an inline buffer sized for the worst grapheme would +/// dwarf everything else here. `text_len == 0` paints no ink (an empty +/// cell, a spacer, or a box-drawing cell the geometry painter owns). +pub const Cell = extern struct { + /// Byte offset of this cell's cluster in the grid's `text`. + text_offset: u32 = 0, + fg: CellColor = .{}, + bg: CellColor = .{}, + underline_color: CellColor = .{}, + /// `CellFlags` bits. Stored as an integer so the struct stays + /// `extern` — a stable layout the wire format and the host + /// renderers can read without re-deriving Zig's packing rules. + flags: u16 = 0, + /// Cluster byte length. A grapheme past 255 bytes is not a + /// terminal cell; the painter drops the tail rather than grow every + /// cell by three bytes. + text_len: u8 = 0, + _reserved: u8 = 0, + + pub fn style(self: Cell) CellFlags { + return CellFlags.fromBits(self.flags); + } + + pub fn cluster(self: Cell, text: []const u8) []const u8 { + if (self.text_len == 0) return ""; + const start = self.text_offset; + const end = start + self.text_len; + if (end > text.len) return ""; + return text[start..end]; + } + + /// Whether this cell puts ink on the glass beyond its background. + pub fn hasInk(self: Cell) bool { + if (self.text_len != 0) return true; + const flags = self.style(); + return flags.underline != .none or flags.strikethrough or flags.overline; + } +}; + +comptime { + // The whole point of the primitive: a screen costs cells, and a + // cell costs 20 bytes. A regression here silently multiplies every + // per-view budget that counts them. + std.debug.assert(@sizeOf(Cell) == 20); +} + +/// The command. +/// +/// Geometry is implied rather than stored per cell: cell (x, y) covers +/// `origin + (x * cell_width, y * cell_height)` at `cell_width x +/// cell_height`. That is what makes the array packed — and it is also +/// the terminal's own model, so nothing is lost. +pub const CellGrid = struct { + id: ObjectId = 0, + /// Top-left of cell (0, 0) in canvas points. + origin: geometry.PointF = .{}, + /// One cell's advance and one row's height, in canvas points. + /// Deliberately not device-pixel quantized: the advance has to be + /// the one the text renderer walks a run by, or glyphs drift out of + /// their cells across a wide row. + cell_width: f32 = 0, + cell_height: f32 = 0, + cols: u16 = 0, + rows: u16 = 0, + /// Row-major, exactly `cols * rows` entries. A grid whose slice is + /// shorter paints only the cells it has (renderers bound their + /// loops by the slice), which keeps a malformed command from + /// reading past its array. + cells: []const Cell = &.{}, + /// Cluster bytes the cells index into. + text: []const u8 = "", + /// The REGULAR mono face. `bold_font_id` / `italic_font_id` / + /// `bold_italic_font_id` are its companions; 0 means "not + /// registered", and a cell asking for a variant that is not there + /// falls back to synthesis (see `CellFace`). + font_id: FontId = 0, + bold_font_id: FontId = 0, + italic_font_id: FontId = 0, + bold_italic_font_id: FontId = 0, + font_size: f32 = 0, + /// Baseline offset from a row's top edge, in canvas points. The + /// painter computes it once from the cell box and the font size so + /// every renderer puts the baseline in the same place instead of + /// re-deriving it from metrics it may not share. + baseline: f32 = 0, + /// Measurement seam, exactly as `DrawText.text_layout.measure` + /// carries one: process-local layout context, excluded from + /// equality, hashing, and serialization. + measure: ?*const text_metrics.TextMeasureProvider = null, + + /// The face a cell's style asks for, and whether the renderer has + /// to synthesize the difference. + /// + /// A terminal's bold and italic are SGR attributes, not layout: the + /// cell they land in is fixed by its index either way. So face + /// selection changes ink and never geometry — a bold row covers the + /// same rects as a regular one, which is the invariant the whole + /// packed-cell model rests on. + pub fn face(self: CellGrid, flags: CellFlags) CellFace { + const want_bold = flags.bold; + const want_italic = flags.italic; + if (want_bold and want_italic and self.bold_italic_font_id != 0) { + return .{ .font_id = self.bold_italic_font_id }; + } + if (want_bold and want_italic) { + // A half-family (bold but no bold-italic) still beats + // synthesizing both: take the real weight and shear it. + if (self.bold_font_id != 0) return .{ .font_id = self.bold_font_id, .synthetic_italic = true }; + if (self.italic_font_id != 0) return .{ .font_id = self.italic_font_id, .synthetic_bold = true }; + return .{ .font_id = self.font_id, .synthetic_bold = true, .synthetic_italic = true }; + } + if (want_bold) { + if (self.bold_font_id != 0) return .{ .font_id = self.bold_font_id }; + return .{ .font_id = self.font_id, .synthetic_bold = true }; + } + if (want_italic) { + if (self.italic_font_id != 0) return .{ .font_id = self.italic_font_id }; + return .{ .font_id = self.font_id, .synthetic_italic = true }; + } + return .{ .font_id = self.font_id }; + } + + pub fn cellCount(self: CellGrid) usize { + return @as(usize, self.cols) * @as(usize, self.rows); + } + + /// The cell at (x, y), or null when the command's slice does not + /// reach it. + pub fn at(self: CellGrid, x: usize, y: usize) ?Cell { + if (x >= self.cols or y >= self.rows) return null; + const index = y * @as(usize, self.cols) + x; + if (index >= self.cells.len) return null; + return self.cells[index]; + } + + /// The rect cell (x, y) covers. + pub fn cellRect(self: CellGrid, x: usize, y: usize) geometry.RectF { + return geometry.RectF.init( + self.origin.x + @as(f32, @floatFromInt(x)) * self.cell_width, + self.origin.y + @as(f32, @floatFromInt(y)) * self.cell_height, + self.cell_width, + self.cell_height, + ); + } + + /// The whole lattice, which is also the command's raster extent: + /// every cell's ink is clipped to its own cell by construction, so + /// unlike a text run the grid can never ink past its declared + /// bounds. + pub fn bounds(self: CellGrid) geometry.RectF { + return geometry.RectF.init( + self.origin.x, + self.origin.y, + @as(f32, @floatFromInt(self.cols)) * self.cell_width, + @as(f32, @floatFromInt(self.rows)) * self.cell_height, + ).normalized(); + } +}; + +/// The face one cell draws with, plus what the renderer must fake. +/// +/// Synthesis is a FALLBACK, never the plan: registering real companion +/// faces (`DesignTokens.typography.mono_bold_font_id` and friends) +/// switches these flags off and the ink comes from the type designer +/// instead of from arithmetic. Both renderers apply the same synthesis +/// rules (`CellSynthesis`) so a bold run is never bold on one path and +/// regular on the other — a mismatch between the oracle and the host is +/// worse than no bold at all. +pub const CellFace = struct { + font_id: FontId, + synthetic_bold: bool = false, + synthetic_italic: bool = false, +}; + +/// The synthesis rules, in one place because two renderers implement +/// them and they have to agree. +pub const CellSynthesis = struct { + /// Faux bold draws the glyph twice, the second pass offset in x. + /// Deterministic, and it cannot change the advance: the pen is the + /// cell, so the extra pass only thickens ink inside it. + pub fn boldOffset(font_size: f32) f32 { + return @max(1, @round(font_size / 14)); + } + + /// Faux italic shears about the BASELINE by this tangent (~11 + /// degrees, the conventional oblique). Sheared ink can overhang into + /// the next cell, which is exactly why every renderer paints all + /// backgrounds before any glyph. + pub const italic_tangent: f32 = 0.2; +}; + +/// Decoration geometry, shared by every renderer so the reference +/// rasterizer and the host encoders cannot disagree about where an +/// underline sits. +/// +/// Thicknesses scale with the font size and floor at one point, the +/// same rule the terminal box painter uses for its strokes. +pub const CellDecoration = struct { + /// The rect an underline of `style` fills for a cell of `rect`. + /// `double` returns the upper bar; `underlineSecondRect` returns + /// the lower one. + pub fn underlineRect(rect: geometry.RectF, font_size: f32) geometry.RectF { + const thickness = strokeWidth(font_size); + return geometry.RectF.init(rect.x, rect.y + rect.height - thickness * 2, rect.width, thickness); + } + + pub fn underlineSecondRect(rect: geometry.RectF, font_size: f32) geometry.RectF { + const thickness = strokeWidth(font_size); + return geometry.RectF.init(rect.x, rect.y + rect.height - thickness * 4, rect.width, thickness); + } + + pub fn strikethroughRect(rect: geometry.RectF, font_size: f32) geometry.RectF { + const thickness = strokeWidth(font_size); + return geometry.RectF.init(rect.x, rect.y + @round(rect.height * 0.55) - thickness, rect.width, thickness); + } + + pub fn overlineRect(rect: geometry.RectF, font_size: f32) geometry.RectF { + const thickness = strokeWidth(font_size); + return geometry.RectF.init(rect.x, rect.y, rect.width, thickness); + } + + pub fn strokeWidth(font_size: f32) f32 { + return @max(1, @round(font_size / 12)); + } + + /// Dotted and dashed underlines paint as a run of segments inside + /// the underline rect; curly paints as a sampled wave. Both are + /// expressed as "the i-th segment rect" so every renderer walks the + /// identical geometry. Returns null past the last segment. + pub fn dashSegment(rect: geometry.RectF, style: CellUnderline, index: usize) ?geometry.RectF { + const period: f32 = switch (style) { + .dotted => @max(2, @round(rect.height * 2)), + .dashed => @max(4, @round(rect.height * 6)), + else => return if (index == 0) rect else null, + }; + const on: f32 = switch (style) { + .dotted => @max(1, @round(period * 0.5)), + .dashed => @max(2, @round(period * 0.6)), + else => period, + }; + const start = @as(f32, @floatFromInt(index)) * period; + if (start >= rect.width) return null; + const width = @min(on, rect.width - start); + return geometry.RectF.init(rect.x + start, rect.y, width, rect.height); + } + + /// A curly underline's `index`-th sample rect: the wave is drawn as + /// a column of one-point-wide ticks whose y follows a triangle, so + /// a CPU rasterizer and a GPU encoder produce the same pixels + /// without either owning a curve rasterizer. + pub fn curlSegment(rect: geometry.RectF, font_size: f32, index: usize) ?geometry.RectF { + const start = @as(f32, @floatFromInt(index)); + if (start >= rect.width) return null; + const thickness = strokeWidth(font_size); + const amplitude = thickness; + const period = @max(4, @round(font_size / 3)); + const phase = @mod(start, period) / period; + // Triangle wave in [0, 1]: up for the first half, down for the + // second, so consecutive ticks trace a zigzag. + const ramp = if (phase < 0.5) phase * 2 else (1 - phase) * 2; + return geometry.RectF.init( + rect.x + start, + rect.y - amplitude + ramp * amplitude * 2, + 1, + thickness, + ); + } +}; diff --git a/src/primitives/canvas/commands.zig b/src/primitives/canvas/commands.zig index 45aa35128..6ac2deecb 100644 --- a/src/primitives/canvas/commands.zig +++ b/src/primitives/canvas/commands.zig @@ -3,6 +3,7 @@ const geometry = @import("geometry"); const canvas = @import("root.zig"); const chart_model = @import("chart.zig"); const drawing_model = @import("drawing.zig"); +const cell_grid_model = @import("cell_grid.zig"); const text_model = @import("text.zig"); const render_model = @import("render.zig"); const frame_model = @import("frame.zig"); @@ -25,6 +26,8 @@ const DrawImage = drawing_model.DrawImage; const Shadow = drawing_model.Shadow; const Blur = drawing_model.Blur; const DrawText = text_model.DrawText; +const CellGrid = cell_grid_model.CellGrid; +const Cell = cell_grid_model.Cell; const GlyphAtlasEntry = text_model.GlyphAtlasEntry; const GlyphAtlasPlan = text_model.GlyphAtlasPlan; const GlyphAtlasPlanner = text_model.GlyphAtlasPlanner; @@ -62,6 +65,10 @@ pub const CanvasCommand = union(enum) { stroke_path: StrokePath, draw_image: DrawImage, draw_text: DrawText, + /// A whole terminal screen as ONE command (see `cell_grid.zig`): + /// a packed cell lattice every renderer expands itself. The one + /// command whose cost is linear in AREA rather than in shapes. + cell_grid: CellGrid, shadow: Shadow, blur: Blur, @@ -76,6 +83,7 @@ pub const CanvasCommand = union(enum) { .stroke_path => |value| value.id, .draw_image => |value| value.id, .draw_text => |value| value.id, + .cell_grid => |value| value.id, .shadow => |value| value.id, .blur => |value| value.id, .pop_clip, .push_opacity, .pop_opacity, .transform => 0, @@ -95,6 +103,10 @@ pub const CanvasCommand = union(enum) { .stroke_path => |value| if (drawing_model.pathBounds(value.elements)) |rect| drawing_model.strokeBounds(rect, value.stroke.width) else null, .draw_image => |value| value.dst.normalized(), .draw_text => |value| text_model.textBounds(value), + // Exact by construction: every cell's ink is clipped to its + // own cell, so unlike a text run a grid can never paint past + // the lattice it declares. + .cell_grid => |value| value.bounds(), .shadow => |value| drawing_model.shadowBounds(value), .blur => |value| value.rect.normalized().inflate(geometry.InsetsF.all(nonNegative(value.radius))), }; @@ -375,7 +387,60 @@ fn nonNegative(value: f32) f32 { /// `max_canvas_text_bytes_per_view` draw-text budget — a lockstep test /// keeps the two from drifting — so the store can only overflow on a /// frame the per-view display-list copy would refuse anyway. -pub const max_display_list_text_bytes: usize = 32768; +/// +/// Raised 32 KiB -> 64 KiB with the terminal work: a full-width +/// terminal viewport is ONE widget whose every visible cell is a +/// presented byte (a 300x100 grid is ~30 KB before any chrome, and a +/// split of two such panes doubles it), so the old store made a wide +/// screen degrade to fewer painted rows while 96% of the command budget +/// sat unused. See `canvas_limits.max_canvas_text_bytes_per_view` for +/// the memory accounting. +pub const max_display_list_text_bytes: usize = 65536; + +/// Commands one view's display list may hold. Mirrors the runtime's +/// per-view `max_canvas_commands_per_view` — a lockstep test keeps the +/// two from drifting — so canvas-tier emitters that must size their own +/// degradation against the frame ceiling (the terminal grid painter and +/// its tests) can read it without importing the runtime. +pub const max_display_list_commands: usize = 2048; + +/// Cells one frame's `cell_grid` commands may hold between them. +/// +/// The budget that replaced the terminal's command budget: a screen +/// costs CELLS now, and `cell_grid.Cell` is 20 bytes, so this is 640 KB +/// of builder-owned storage and the same again in each view's retained +/// copy. 32768 covers a 300x100 viewport (30,000) with room over, and +/// two 160x100 split panes exactly. Mirrors the runtime's per-view +/// `max_canvas_cells_per_view`, which a lockstep test pins. +pub const max_display_list_cells: usize = 32768; + +/// A per-view store an emitter can run out of mid-frame. +pub const DisplayListStore = enum { commands, text_bytes, path_elements, glyphs, cells }; + +/// Content an emitter DROPPED because a per-view store ran out. +/// +/// Almost every emitter fails the frame loudly instead +/// (`error.DisplayListFull` and friends). The terminal grid painter is +/// the deliberate exception: a screen denser than the display list can +/// express paints fewer COMPLETE rows so the rest of the frame still +/// reaches the glass. Degrading is the right call; degrading SILENTLY +/// is not — a user staring at a half-blank terminal has no way to learn +/// that a budget, not the program, ate the bottom of the screen. So the +/// painter records what it dropped here, `Builder.reset` clears it, and +/// the runtime turns a change in this record into one teaching log line +/// (canvas_widget_display.zig). Direct painter callers read it off the +/// builder, or take the richer `terminal_grid.paintReport` return. +pub const DisplayListDegradation = struct { + /// The emitter's identity — the terminal widget's id (the value + /// handed to `terminal_grid.paintIdBase`), 0 for anonymous paints. + id: ObjectId = 0, + /// The store that ran out. + store: DisplayListStore, + /// Units the emitter placed and units it was handed, in the + /// emitter's own terms (a terminal grid counts ROWS). + produced: usize = 0, + requested: usize = 0, +}; pub const Builder = struct { commands: []CanvasCommand, @@ -412,16 +477,48 @@ pub const Builder = struct { /// its forced clip contains the fallback. text_bytes: [max_display_list_text_bytes]u8 = undefined, text_byte_len: usize = 0, + /// Builder-owned storage for `cell_grid` cells, same lifetime rule + /// as `path_elements`: an emitted grid's slice stays valid for the + /// builder's life, so a display list accumulated across several + /// emit calls keeps every grid intact. + cells: [max_display_list_cells]cell_grid_model.Cell = undefined, + cell_len: usize = 0, + /// What this frame's emitters could NOT place (see + /// `DisplayListDegradation`). Null is the healthy frame; the last + /// emitter to run short wins, which is the one an author must fix + /// first. Never an error channel — the frame it describes is a + /// complete, presentable frame that is missing content. + degradation: ?DisplayListDegradation = null, pub fn init(commands: []CanvasCommand) Builder { return .{ .commands = commands }; } + /// Re-point an EXISTING builder at a command buffer without + /// materialising a fresh one. `Builder` carries its stores inline + /// (text bytes, path elements, a frame's worth of packed cells), so + /// `builder.* = Builder.init(...)` builds a multi-megabyte temporary + /// on the caller's stack and copies it — enough to overflow a + /// thread. This is the in-place form for pooled builders. + pub fn initAt(self: *Builder, commands: []CanvasCommand) void { + self.commands = commands; + self.reset(); + } + pub fn reset(self: *Builder) void { self.len = 0; self.path_element_len = 0; self.label_byte_len = 0; self.text_byte_len = 0; + self.cell_len = 0; + self.degradation = null; + } + + /// Record dropped content on the frame being built (see + /// `DisplayListDegradation`). Emitters call this INSTEAD of failing + /// when their contract is to degrade. + pub fn noteDegradation(self: *Builder, value: DisplayListDegradation) void { + self.degradation = value; } /// Reserve `count` path elements in the builder-owned store. The @@ -456,6 +553,16 @@ pub const Builder = struct { return self.text_bytes[start..self.text_byte_len]; } + /// Reserve `count` cells in the builder-owned store. The returned + /// slice is caller-filled and stays valid for the life of the + /// builder (until `reset`), the `allocPathElements` contract. + pub fn allocCells(self: *Builder, count: usize) error{CellGridCellListFull}![]cell_grid_model.Cell { + if (self.cell_len + count > self.cells.len) return error.CellGridCellListFull; + const start = self.cell_len; + self.cell_len += count; + return self.cells[start..self.cell_len]; + } + pub fn displayList(self: *const Builder) DisplayList { return .{ .commands = self.commands[0..self.len] }; } @@ -518,6 +625,10 @@ pub const Builder = struct { try self.append(.{ .draw_text = value }); } + pub fn cellGrid(self: *Builder, value: CellGrid) error{DisplayListFull}!void { + try self.append(.{ .cell_grid = value }); + } + pub fn shadow(self: *Builder, value: Shadow) error{DisplayListFull}!void { try self.append(.{ .shadow = value }); } diff --git a/src/primitives/canvas/equality.zig b/src/primitives/canvas/equality.zig index f98bb57b3..bfba86db6 100644 --- a/src/primitives/canvas/equality.zig +++ b/src/primitives/canvas/equality.zig @@ -23,6 +23,9 @@ const StrokePath = drawing_model.StrokePath; const DrawImage = drawing_model.DrawImage; const Shadow = drawing_model.Shadow; const Blur = drawing_model.Blur; +const cell_grid_model = @import("cell_grid.zig"); +const CellGrid = cell_grid_model.CellGrid; +const Cell = cell_grid_model.Cell; const Glyph = text_model.Glyph; const DrawText = text_model.DrawText; const TextLayoutOptions = text_model.TextLayoutOptions; @@ -83,6 +86,10 @@ pub fn commandsEqual(a: CanvasCommand, b: CanvasCommand) bool { .draw_text => |other| drawTextsEqual(value, other), else => false, }, + .cell_grid => |value| switch (b) { + .cell_grid => |other| cellGridsEqual(value, other), + else => false, + }, .shadow => |value| switch (b) { .shadow => |other| shadowsEqual(value, other), else => false, @@ -144,6 +151,24 @@ pub fn drawTextsEqual(a: DrawText, b: DrawText) bool { optionalTextLayoutOptionsEqual(a.text_layout, b.text_layout); } +/// Grid equality is CONTENT equality: two grids are the same command +/// when they cover the same lattice with the same cells and the same +/// cluster bytes. The cells compare as raw bytes — `Cell` is `extern` +/// with no padding holes by construction (a comptime assert pins its +/// size), so `std.mem.eql` over the byte view is both exact and the +/// fastest thing available for a 30,000-element array. `measure` is +/// excluded, exactly as `DrawText`'s is: process-local layout context, +/// not drawn content. +pub fn cellGridsEqual(a: CellGrid, b: CellGrid) bool { + if (a.id != b.id or a.cols != b.cols or a.rows != b.rows or + a.font_id != b.font_id or a.font_size != b.font_size or + a.cell_width != b.cell_width or a.cell_height != b.cell_height or + a.baseline != b.baseline or !pointsEqual(a.origin, b.origin)) return false; + if (a.cells.len != b.cells.len) return false; + if (!std.mem.eql(u8, a.text, b.text)) return false; + return std.mem.eql(u8, std.mem.sliceAsBytes(a.cells), std.mem.sliceAsBytes(b.cells)); +} + pub fn optionalTextLayoutOptionsEqual(a: ?TextLayoutOptions, b: ?TextLayoutOptions) bool { if (a) |left| { if (b) |right| return textLayoutOptionsEqual(left, right); diff --git a/src/primitives/canvas/font_ttf.zig b/src/primitives/canvas/font_ttf.zig index e43b35790..e70c0fcd5 100644 --- a/src/primitives/canvas/font_ttf.zig +++ b/src/primitives/canvas/font_ttf.zig @@ -52,11 +52,12 @@ pub const Error = error{ /// Noto Sans KR 479 61 0 0 0 0 /// Noto Serif JP 465 40 0 0 0 0 /// Yuji Mai (brush kanji) 738 22 198 5 1 3 +/// JetBrainsMono Nerd 4050 132 79 4 1 3 /// -/// 1024 points / 128 contours cover the densest measured glyph (Yuji -/// Mai's 738-point brush kanji; Noto Sans TC's 685) with ~1.4x headroom; -/// the depth/element budgets carry 1.3-2x over the deepest measured -/// use (the bundled Geist's own accent stacking). +/// 4096 points / 256 contours cover the complete patched terminal face as +/// well as the measured CJK/brush faces. The high point count comes from a +/// handful of dense icon outlines, not ordinary text; registration remains +/// bounded and rejects anything beyond this measured production ceiling. /// /// The composite budgets bound a composite glyph's FLATTENED outline /// (`maxp.maxCompositePoints`/`maxCompositeContours`: totals across the @@ -71,17 +72,28 @@ pub const Error = error{ /// 198 points (Yuji Mai), 10 contours (Geist Mono) — 5-12x headroom. /// /// Stack shape: the simple-glyph parse buffers -/// (`flags`/`xs`/`ys`/`end_points`) total ~9.5 KiB and live in exactly +/// (`flags`/`xs`/`ys`/`end_points`) total 36.5 KiB and live in exactly /// ONE frame at a time — simple glyphs are leaves, so composite /// recursion stacks only the small component-walk frames (depth <= 4), -/// never these arrays. -pub const max_glyph_points: usize = 1024; -pub const max_glyph_contours: usize = 128; +/// never these arrays. The reference renderer's 133 KiB path builder is +/// separate per-thread heap scratch, so it does not overlap this storage +/// on the render-thread stack. +pub const max_glyph_points: usize = 4096; +pub const max_glyph_contours: usize = 256; pub const max_composite_points: usize = max_glyph_points; pub const max_composite_contours: usize = max_glyph_contours; pub const max_composite_depth: usize = 4; pub const max_composite_components: usize = 8; +const simple_glyph_stack_scratch_bytes = + max_glyph_contours * @sizeOf(u16) + + max_glyph_points * (@sizeOf(u8) + 2 * @sizeOf(f32)); +comptime { + if (simple_glyph_stack_scratch_bytes > 40 * 1024) { + @compileError("TrueType simple-glyph stack scratch exceeds its supported 40 KiB bound"); + } +} + /// The bundled Geist Regular face (OFL), embedded so the reference /// renderer paints real text without any platform font machinery. It /// serves the sans font ids (weight/italic span variants included) at diff --git a/src/primitives/canvas/font_ttf_tests.zig b/src/primitives/canvas/font_ttf_tests.zig index c1fe0ac61..5fbe70520 100644 --- a/src/primitives/canvas/font_ttf_tests.zig +++ b/src/primitives/canvas/font_ttf_tests.zig @@ -279,7 +279,7 @@ test "out of range glyph ids error instead of reading wild" { /// Fixed-buffer big-endian byte builder for synthetic tables. const ByteBuilder = struct { - bytes: [32768]u8 = undefined, + bytes: [131072]u8 = undefined, len: usize = 0, fn appendU8(self: *ByteBuilder, value: u8) void { @@ -532,7 +532,11 @@ test "synthetic dense glyphs beyond the old Latin-sized budgets parse and outlin // The dense glyph outlines completely: every on-curve ring is // moveTo + 12 lineTo (the walk returns to the start point) + close, // so 84 * 14 elements, starting at the first ring's origin. - var builder = vector.PathBuilder(2048){}; + const path_capacity = @max( + font_ttf.max_glyph_points + 3 * font_ttf.max_glyph_contours, + font_ttf.max_composite_points + 3 * font_ttf.max_composite_contours, + ); + var builder = vector.PathBuilder(path_capacity){}; try face.glyphOutline(1, Affine.identity(), &builder); try std.testing.expectEqual(@as(usize, 84 * 14), builder.slice().len); const first = builder.slice()[0]; diff --git a/src/primitives/canvas/gpu.zig b/src/primitives/canvas/gpu.zig index 81b7b3236..0893b0c51 100644 --- a/src/primitives/canvas/gpu.zig +++ b/src/primitives/canvas/gpu.zig @@ -1,6 +1,7 @@ const geometry = @import("geometry"); const canvas = @import("root.zig"); const drawing_model = @import("drawing.zig"); +const cell_grid_model = @import("cell_grid.zig"); const text_model = @import("text.zig"); const render_model = @import("render.zig"); @@ -115,6 +116,7 @@ pub const CanvasGpuCommandKind = enum { stroke_path, draw_image, draw_text, + cell_grid, shadow, blur, unsupported, @@ -173,6 +175,26 @@ pub const CanvasGpuText = struct { text_layout: ?TextLayoutOptions = null, }; +/// A packed cell-grid ROW on the wire. Carries the lattice geometry and +/// the cells themselves; hosts expand it exactly as the reference +/// renderer does (canvas/reference.zig `drawCellGrid`), which is the +/// spec for what the pixels must be. +pub const CanvasGpuCellGrid = struct { + font_id: FontId = 0, + bold_font_id: FontId = 0, + italic_font_id: FontId = 0, + bold_italic_font_id: FontId = 0, + font_size: f32 = 0, + origin: geometry.PointF = .{}, + cell_width: f32 = 0, + cell_height: f32 = 0, + baseline: f32 = 0, + cols: u16 = 0, + rows: u16 = 0, + cells: []const cell_grid_model.Cell = &.{}, + text: []const u8 = "", +}; + pub const CanvasGpuShadow = struct { rect: geometry.RectF = .{}, radius: Radius = .{}, @@ -210,6 +232,7 @@ pub const CanvasGpuCommand = struct { cap: LineCap = .butt, image: ?CanvasGpuImage = null, text: ?CanvasGpuText = null, + cells: ?CanvasGpuCellGrid = null, effect: CanvasGpuEffect = .none, clip: ?geometry.RectF = null, opacity: f32 = 1, @@ -477,6 +500,29 @@ pub fn canvasGpuCommandFromRenderCommand(command: RenderCommand, command_index: }; switch (command.command) { + .cell_grid => |value| { + packet_command.kind = .cell_grid; + packet_command.pipeline = .glyph_run; + packet_command.cells = .{ + .font_id = value.font_id, + .bold_font_id = value.bold_font_id, + .italic_font_id = value.italic_font_id, + .bold_italic_font_id = value.bold_italic_font_id, + .font_size = value.font_size, + .origin = value.origin, + .cell_width = value.cell_width, + .cell_height = value.cell_height, + .baseline = value.baseline, + .cols = value.cols, + .rows = value.rows, + .cells = value.cells, + .text = value.text, + }; + // A grid inks glyphs, so it depends on the face the same way + // a text run does; it names no SHAPED glyph run, so it + // claims no atlas or layout resource. + packet_command.uses_resource = true; + }, .fill_rect => |value| { packet_command.kind = canvasGpuFillRectKind(value.fill); packet_command.pipeline = canvasGpuFillPipeline(value.fill); diff --git a/src/primitives/canvas/reference.zig b/src/primitives/canvas/reference.zig index bbf819b58..0319cd863 100644 --- a/src/primitives/canvas/reference.zig +++ b/src/primitives/canvas/reference.zig @@ -26,6 +26,9 @@ const DrawImage = drawing_model.DrawImage; const Shadow = drawing_model.Shadow; const Blur = drawing_model.Blur; const DrawText = text_model.DrawText; +const cell_grid_model = @import("cell_grid.zig"); +const CellGrid = cell_grid_model.CellGrid; +const CellDecoration = cell_grid_model.CellDecoration; const TextLayoutOptions = text_model.TextLayoutOptions; const TextLine = text_model.TextLine; const RenderCommand = render_model.RenderCommand; @@ -51,30 +54,29 @@ const font_ttf = @import("font_ttf.zig"); /// admits — a simple glyph's maxima and a composite's flattened maxima /// (`maxp.maxCompositePoints`/`maxCompositeContours`, which is what /// this builder actually receives when a composite renders). The -/// budgets are currently equal, so the max is 1408 either way; the -/// derivation keeps capacity honest if they ever diverge. Stack shape: -/// at 28 B per element this is ~39 KiB in `drawGlyphOutline`; the edge -/// accumulator below it is the per-thread heap-resident -/// `vector.GlyphRasterizer` (see `reference_glyph_raster_scratch`), so -/// the builder is the only glyph raster state on the stack. +/// budgets are currently equal, so the max is 4864 either way; the +/// derivation keeps capacity honest if they ever diverge. At 28 B per +/// element the builder is ~133 KiB, so it lives beside the rasterizer in +/// per-thread heap scratch rather than in `drawGlyphOutline`'s stack. const reference_glyph_path_capacity: usize = @max( font_ttf.max_glyph_points + 3 * font_ttf.max_glyph_contours, font_ttf.max_composite_points + 3 * font_ttf.max_composite_contours, ); -/// Per-thread rasterizer for glyph fills: `vector.GlyphRasterizer`'s +const ReferenceGlyphPathBuilder = vector.PathBuilder(reference_glyph_path_capacity); + +/// Per-thread path and raster scratch for glyph fills: the path builder is +/// ~133 KiB and `vector.GlyphRasterizer` is ~1.9 MiB. The latter's /// derived budgets guarantee every outline the font registration gate -/// admits rasterizes (never a block fallback), which sizes it at -/// ~508 KiB — a per-thread heap slot behind one TLS pointer (the -/// lazy_tls pattern), not a stack temporary and not static TLS. Only -/// threads that ink a glyph through the reference renderer allocate it. -/// The array carries no default and stays uninitialized, exactly like -/// the stack `Rasterizer` it replaces; `vector.fillGlyphPath` resets it -/// per glyph. -const ReferenceGlyphRasterScratch = struct { +/// admits rasterizes (never a block fallback). Together they occupy ~2.0 +/// MiB behind one lazy TLS pointer, not the render-thread stack or static +/// TLS, and only threads that ink a glyph allocate them. The arrays carry +/// no defaults and stay uninitialized; each operation resets their lengths. +const ReferenceGlyphScratch = struct { + path: ReferenceGlyphPathBuilder, raster: vector.GlyphRasterizer, }; -const reference_glyph_raster_scratch = @import("lazy_tls.zig").LazyTls(ReferenceGlyphRasterScratch); +const reference_glyph_scratch = @import("lazy_tls.zig").LazyTls(ReferenceGlyphScratch); const referenceBlurKernel = reference_blur.referenceBlurKernel; const referenceBlurSampleWithKernel = reference_blur.referenceBlurSampleWithKernel; @@ -307,6 +309,7 @@ pub const ReferenceRenderSurface = struct { .shadow => |value| try self.drawShadow(command, value, draw_bounds), .blur => |value| try self.drawBlur(command, value, draw_bounds), .draw_text => |value| try self.drawText(command, value, draw_bounds), + .cell_grid => |value| try self.drawCellGrid(command, value, draw_bounds), else => return error.ReferenceRenderUnsupportedCommand, } } @@ -754,6 +757,141 @@ pub const ReferenceRenderSurface = struct { } } + /// The packed cell grid (`cell_grid.zig`), the renderer that makes + /// this module the terminal's oracle. + /// + /// Two passes, and the order is the contract: EVERY background + /// first, then every glyph and decoration. A single pass would let + /// cell N+1's background erase the part of cell N's glyph that + /// overhangs into it — real mono faces overhang constantly (italics + /// and box-adjacent glyphs worst), and the seam would appear only + /// on styled screens, which is the worst kind of bug to find. + /// + /// Each cell's ink goes through `drawGlyphBox`, the exact path a + /// `draw_text` glyph takes: same outline rasterizer, same coverage + /// blend, same block fallback for unmapped codepoints. A grid cell + /// and a text run therefore paint the same glyph the same way, by + /// construction rather than by inspection. + fn drawCellGrid(self: ReferenceRenderSurface, command: RenderCommand, value: CellGrid, draw_bounds: geometry.RectF) Error!void { + if (value.cell_width <= 0 or value.cell_height <= 0) return; + if (value.cols == 0 or value.rows == 0) return; + + // Pass 1: backgrounds. + for (value.cells, 0..) |cell, index| { + const flags = cell.style(); + if (!flags.has_background) continue; + const x = index % value.cols; + const y = index / value.cols; + if (y >= value.rows) break; + const rect = command.transform.transformRect(value.cellRect(x, y)).normalized(); + self.fillTextRect(rect, draw_bounds, cell.bg.toColor(), command.opacity); + } + + // Pass 2: ink and decorations. The synthetic `DrawText` carries + // exactly what the glyph path reads — face, size, colour — so a + // cell is a one-glyph run in every way that reaches a pixel. + var run = DrawText{ + .id = value.id, + .font_id = value.font_id, + .size = value.font_size, + .origin = value.origin, + .color = Color.rgba(0, 0, 0, 1), + }; + for (value.cells, 0..) |cell, index| { + const flags = cell.style(); + if (flags.width == .spacer) continue; + if (!cell.hasInk()) continue; + const x = index % value.cols; + const y = index / value.cols; + if (y >= value.rows) break; + const rect = value.cellRect(x, y); + run.color = cell.fg.toColor(); + + const cluster = cell.cluster(value.text); + if (cluster.len > 0) { + // The face this cell's SGR style asks for. Real + // companion faces when the app registered them, + // synthesis otherwise — and either way the pen and the + // advance below are the CELL's, so weight and slant + // never move the lattice. + const cell_face = value.face(flags); + run.font_id = cell_face.font_id; + const baseline = rect.y + value.baseline; + // A wide cell inks across two columns, so its fallback + // block and its outline centring both use the doubled + // advance. + const advance = if (flags.width == .wide) value.cell_width * 2 else value.cell_width; + // Every codepoint of the cluster paints at the SAME pen: + // the primary plus its combining marks are one glyph + // stack over one cell, and advancing between them would + // walk the marks into the next column. + var iterator = std.unicode.Utf8Iterator{ .bytes = cluster, .i = 0 }; + while (iterator.nextCodepoint()) |codepoint| { + const glyph_rect = geometry.RectF.init(rect.x, baseline - value.font_size, advance, value.font_size); + if (!self.drawGlyphOutlineSynthesized(command, run, draw_bounds, codepoint, rect.x, baseline, advance, cell_face)) { + // Unmapped codepoint: the documented block + // fallback, at the cell's own rect. + self.fillTextRect(command.transform.transformRect(glyph_rect).normalized(), draw_bounds, run.color, command.opacity); + } + } + } + + self.drawCellDecorations(command, value, cell, flags, rect, draw_bounds); + } + } + + /// Underline (six styles), strikethrough, and overline for one + /// cell. Geometry comes from `CellDecoration` rather than from + /// numbers written here, so a host encoder that wants to match this + /// renderer reads the same source. + fn drawCellDecorations( + self: ReferenceRenderSurface, + command: RenderCommand, + value: CellGrid, + cell: cell_grid_model.Cell, + flags: cell_grid_model.CellFlags, + rect: geometry.RectF, + draw_bounds: geometry.RectF, + ) void { + const width = if (flags.width == .wide) + geometry.RectF.init(rect.x, rect.y, rect.width * 2, rect.height) + else + rect; + if (flags.overline) { + self.fillCellRect(command, CellDecoration.overlineRect(width, value.font_size), draw_bounds, cell.fg.toColor()); + } + if (flags.strikethrough) { + self.fillCellRect(command, CellDecoration.strikethroughRect(width, value.font_size), draw_bounds, cell.fg.toColor()); + } + if (flags.underline == .none) return; + const color = if (flags.has_underline_color) cell.underline_color.toColor() else cell.fg.toColor(); + const line = CellDecoration.underlineRect(width, value.font_size); + switch (flags.underline) { + .none => {}, + .single => self.fillCellRect(command, line, draw_bounds, color), + .double => { + self.fillCellRect(command, line, draw_bounds, color); + self.fillCellRect(command, CellDecoration.underlineSecondRect(width, value.font_size), draw_bounds, color); + }, + .dotted, .dashed => { + var segment: usize = 0; + while (CellDecoration.dashSegment(line, flags.underline, segment)) |piece| : (segment += 1) { + self.fillCellRect(command, piece, draw_bounds, color); + } + }, + .curly => { + var segment: usize = 0; + while (CellDecoration.curlSegment(line, value.font_size, segment)) |piece| : (segment += 1) { + self.fillCellRect(command, piece, draw_bounds, color); + } + }, + } + } + + fn fillCellRect(self: ReferenceRenderSurface, command: RenderCommand, rect: geometry.RectF, draw_bounds: geometry.RectF, color: Color) void { + self.fillTextRect(command.transform.transformRect(rect).normalized(), draw_bounds, color, command.opacity); + } + fn drawTextLine(self: ReferenceRenderSurface, command: RenderCommand, value: DrawText, draw_bounds: geometry.RectF, line: TextLine) Error!void { if (line.glyph_len > 0 and line.glyph_start < value.glyphs.len) { // An elided line inks only its kept prefix, then the marker. @@ -847,7 +985,26 @@ pub const ReferenceRenderSurface = struct { baseline: f32, cell_advance: f32, ) bool { - const face = referenceFaceForFontId(self.fonts, value.font_id); + return self.drawGlyphOutlineSynthesized(command, value, draw_bounds, codepoint, pen_x, baseline, cell_advance, .{ .font_id = value.font_id }); + } + + /// `drawGlyphOutline` with an explicit face and the terminal's + /// synthesis flags. Real companion faces make both flags false and + /// this is the plain path; a missing companion falls back to + /// arithmetic the AppKit decoder mirrors exactly + /// (`cell_grid.CellSynthesis`). + fn drawGlyphOutlineSynthesized( + self: ReferenceRenderSurface, + command: RenderCommand, + value: DrawText, + draw_bounds: geometry.RectF, + codepoint: u21, + pen_x: f32, + baseline: f32, + cell_advance: f32, + cell_face: cell_grid_model.CellFace, + ) bool { + const face = referenceFaceForFontId(self.fonts, cell_face.font_id); const glyph = face.glyphIndex(codepoint); if (glyph == 0) return false; @@ -863,12 +1020,22 @@ pub const ReferenceRenderSurface = struct { const scale = value.size / face.units_per_em; // Font units are y-up; bake the flip and em scaling into the pen // placement, then apply the command transform on top. - const local = Affine{ .a = scale, .b = 0, .c = 0, .d = -scale, .tx = pen_x + cell_inset, .ty = baseline }; + // + // Faux italic is a SHEAR about the baseline, which is exactly + // what `c` does here: x' = a*x + c*y + tx, and y is measured + // from the baseline. It moves ink, never the pen — so a synthetic + // italic cell still starts and advances where its index says. + const shear: f32 = if (cell_face.synthetic_italic) + scale * cell_grid_model.CellSynthesis.italic_tangent + else + 0; + const local = Affine{ .a = scale, .b = 0, .c = shear, .d = -scale, .tx = pen_x + cell_inset, .ty = baseline }; const total = command.transform.multiply(local); - var builder = vector.PathBuilder(reference_glyph_path_capacity){}; - face.glyphOutline(glyph, total, &builder) catch return false; - if (builder.slice().len == 0) return true; // Space: nothing to ink. + const scratch = reference_glyph_scratch.get(); + scratch.path.reset(); + face.glyphOutline(glyph, total, &scratch.path) catch return false; + if (scratch.path.slice().len == 0) return true; // Space: nothing to ink. const pixel_rect = referencePixelRect(draw_bounds, self.width, self.height) orelse return true; // Glyph coverage blends in sRGB, not linear light (see @@ -890,14 +1057,34 @@ pub const ReferenceRenderSurface = struct { // `max_raster_width` (surface-shaped, not font-shaped) and // nothing else. vector.fillGlyphPath( - &reference_glyph_raster_scratch.get().raster, - builder.slice(), + &scratch.raster, + scratch.path.slice(), Affine.identity(), .nonzero, vector.default_tolerance, referenceVectorClip(pixel_rect), &sink, ) catch return false; + + // Faux bold: the same outline again, offset in x. Thickening ink + // inside the cell cannot change the advance, which is why this + // is safe in a lattice where position is the index. + if (cell_face.synthetic_bold) { + const offset = cell_grid_model.CellSynthesis.boldOffset(value.size); + const bold_local = Affine{ .a = scale, .b = 0, .c = shear, .d = -scale, .tx = pen_x + cell_inset + offset, .ty = baseline }; + scratch.path.reset(); + face.glyphOutline(glyph, command.transform.multiply(bold_local), &scratch.path) catch return true; + if (scratch.path.slice().len == 0) return true; + vector.fillGlyphPath( + &scratch.raster, + scratch.path.slice(), + Affine.identity(), + .nonzero, + vector.default_tolerance, + referenceVectorClip(pixel_rect), + &sink, + ) catch return true; + } return true; } @@ -1124,7 +1311,6 @@ fn referenceScaleRect(rect: geometry.RectF, scale: f32) geometry.RectF { return geometry.RectF.init(rect.x * scale, rect.y * scale, rect.width * scale, rect.height * scale); } - fn referencePixelCenter(x: usize, y: usize) geometry.PointF { return geometry.PointF.init(@as(f32, @floatFromInt(x)) + 0.5, @as(f32, @floatFromInt(y)) + 0.5); } diff --git a/src/primitives/canvas/render.zig b/src/primitives/canvas/render.zig index af94386b7..7771d7d64 100644 --- a/src/primitives/canvas/render.zig +++ b/src/primitives/canvas/render.zig @@ -718,6 +718,9 @@ fn renderPipelineKind(command: CanvasCommand) RenderPipelineKind { .fill_path, .stroke_path => .path, .draw_image => .image, .draw_text => .glyph_run, + // A grid is glyphs plus solid cell rects. It batches with text + // because glyphs are the part a pipeline switch would cost. + .cell_grid => .glyph_run, .shadow => .shadow, .blur => .blur, }; diff --git a/src/primitives/canvas/render_fingerprints.zig b/src/primitives/canvas/render_fingerprints.zig index 422fcbd56..0ae0b15ed 100644 --- a/src/primitives/canvas/render_fingerprints.zig +++ b/src/primitives/canvas/render_fingerprints.zig @@ -1,3 +1,4 @@ +const std = @import("std"); const canvas = @import("root.zig"); const drawing_model = @import("drawing.zig"); const hash_model = @import("hash.zig"); @@ -16,6 +17,7 @@ const Shadow = drawing_model.Shadow; const Blur = drawing_model.Blur; const Glyph = text_model.Glyph; const DrawText = text_model.DrawText; +const CellGrid = @import("cell_grid.zig").CellGrid; const TextLayoutOptions = text_model.TextLayoutOptions; const resourceHashTag = hash_model.resourceHashTag; @@ -97,6 +99,29 @@ pub fn linearGradientFingerprint(gradient: LinearGradient) u64 { return hash; } +/// A grid's content fingerprint. +/// +/// Covers exactly what a renderer draws: the lattice geometry, the font, +/// the cluster blob, and every cell byte. The cells fold in as raw bytes +/// (`Cell` is `extern` and hole-free — a comptime assert pins its size), +/// which makes the hash both exact and cheap enough to run per frame +/// over a 30,000-cell screen. `measure` is excluded, like `DrawText`'s. +pub fn cellGridFingerprint(grid: CellGrid) u64 { + var hash = resourceHashTag("cell_grid"); + hash = resourceHashU64(hash, grid.font_id); + hash = resourceHashF32(hash, grid.font_size); + hash = resourceHashPoint(hash, grid.origin); + hash = resourceHashF32(hash, grid.cell_width); + hash = resourceHashF32(hash, grid.cell_height); + hash = resourceHashF32(hash, grid.baseline); + hash = resourceHashU32(hash, grid.cols); + hash = resourceHashU32(hash, grid.rows); + hash = resourceHashBytes(hash, grid.text); + hash = resourceHashUsize(hash, grid.cells.len); + hash = resourceHashBytes(hash, std.mem.sliceAsBytes(grid.cells)); + return hash; +} + pub fn drawTextFingerprint(text: DrawText) u64 { var hash = resourceHashTag("glyph_run"); hash = resourceHashU64(hash, text.font_id); @@ -190,6 +215,10 @@ fn resourceHashCanvasCommand(hash: u64, command: anytype) u64 { next = resourceHashPoint(next, value.to); next = resourceHashStroke(next, value.stroke); }, + .cell_grid => |value| { + next = resourceHashOptionalObjectId(next, nonZeroObjectId(value.id)); + next = resourceHashU64(next, cellGridFingerprint(value)); + }, .fill_path => |value| { next = resourceHashOptionalObjectId(next, nonZeroObjectId(value.id)); next = resourceHashPath(next, value.elements); diff --git a/src/primitives/canvas/render_generic_resources.zig b/src/primitives/canvas/render_generic_resources.zig index ff74ca73e..bd67ae084 100644 --- a/src/primitives/canvas/render_generic_resources.zig +++ b/src/primitives/canvas/render_generic_resources.zig @@ -16,6 +16,7 @@ const textBounds = text_model.textBounds; const drawImageFingerprint = fingerprints.drawImageFingerprint; const drawTextFingerprint = fingerprints.drawTextFingerprint; +const cellGridFingerprint = fingerprints.cellGridFingerprint; const linearGradientFingerprint = fingerprints.linearGradientFingerprint; const shadowFingerprint = fingerprints.shadowFingerprint; const blurFingerprint = fingerprints.blurFingerprint; @@ -103,6 +104,19 @@ pub const RenderResourcePlanner = struct { .text_len = value.text.len, .fingerprint = drawTextFingerprint(value), }), + .cell_grid => |value| try self.append(.{ + .kind = .glyph_run, + .command_index = index, + .id = nonZeroObjectId(value.id), + .bounds = value.bounds(), + .font_id = value.font_id, + // A grid names no shaped glyphs: renderers map each + // cell's cluster themselves, so the resource is the + // FACE plus the cluster blob, not a glyph run. + .glyph_count = 0, + .text_len = value.text.len, + .fingerprint = cellGridFingerprint(value), + }), .shadow => |value| try self.append(.{ .kind = .shadow, .command_index = index, diff --git a/src/primitives/canvas/root.zig b/src/primitives/canvas/root.zig index e32bf4f62..c903819f9 100644 --- a/src/primitives/canvas/root.zig +++ b/src/primitives/canvas/root.zig @@ -214,6 +214,22 @@ pub const DiffKind = command_model.DiffKind; pub const DiffChange = command_model.DiffChange; pub const Builder = command_model.Builder; pub const max_display_list_text_bytes = command_model.max_display_list_text_bytes; +pub const max_display_list_commands = command_model.max_display_list_commands; +pub const max_display_list_cells = command_model.max_display_list_cells; + +// The packed terminal cell grid (cell_grid.zig): one command for a +// whole screen, expanded by every renderer. +pub const cell_grid = @import("cell_grid.zig"); +pub const CellGrid = cell_grid.CellGrid; +pub const Cell = cell_grid.Cell; +pub const CellColor = cell_grid.CellColor; +pub const CellFlags = cell_grid.CellFlags; +pub const CellUnderline = cell_grid.CellUnderline; +pub const CellWidth = cell_grid.CellWidth; +pub const CellDecoration = cell_grid.CellDecoration; +pub const cellGridFingerprint = @import("render_fingerprints.zig").cellGridFingerprint; +pub const DisplayListStore = command_model.DisplayListStore; +pub const DisplayListDegradation = command_model.DisplayListDegradation; // Canvas render data and cache plans live in `render.zig`; root keeps the public API stable. pub const max_render_state_stack = render_model.max_render_state_stack; @@ -454,6 +470,7 @@ pub const WidgetActions = widget_model.WidgetActions; pub const WidgetSemantics = widget_model.WidgetSemantics; pub const WidgetContextMenuItem = widget_model.WidgetContextMenuItem; pub const CodeDiffLines = widget_model.CodeDiffLines; +pub const WidgetContextMenuPolicy = widget_model.WidgetContextMenuPolicy; pub const Widget = widget_model.Widget; pub const BuiltinComponentOptions = widget_model.BuiltinComponentOptions; pub const WidgetCommandPart = widget_model.WidgetCommandPart; diff --git a/src/primitives/canvas/serialization.zig b/src/primitives/canvas/serialization.zig index 8a729ef01..786a6c9fa 100644 --- a/src/primitives/canvas/serialization.zig +++ b/src/primitives/canvas/serialization.zig @@ -1,3 +1,4 @@ +const std = @import("std"); const geometry = @import("geometry"); const json = @import("json"); const canvas = @import("root.zig"); @@ -5,6 +6,7 @@ const drawing_model = @import("drawing.zig"); const text_model = @import("text.zig"); const render_model = @import("render.zig"); const gpu_model = @import("gpu.zig"); +const cell_grid_model = @import("cell_grid.zig"); const ObjectId = canvas.ObjectId; const CanvasCommand = canvas.CanvasCommand; @@ -89,6 +91,21 @@ pub fn writeCommandJson(command: CanvasCommand, writer: anytype) !void { try writer.writeAll(",\"fill\":"); try writeFillJson(value.fill, writer); }, + // A grid's JSON is its SHAPE, not its 30,000 cells: the display + // list's JSON form is a debugging and snapshot surface, and a + // per-cell dump would bury every other command in the scene. The + // cell bytes reach a renderer through the command itself, never + // through this encoding. + .cell_grid => |value| { + try writer.print(",\"id\":{d},\"origin\":", .{value.id}); + try writePointJson(value.origin, writer); + try writer.print(",\"cols\":{d},\"rows\":{d},\"cell\":[{d},{d}],\"font\":{d},\"size\":{d},\"cells\":{d},\"text_len\":{d}", .{ + value.cols, value.rows, + value.cell_width, value.cell_height, + value.font_id, value.font_size, + value.cells.len, value.text.len, + }); + }, .stroke_rect => |value| { try writer.print(",\"id\":{d},\"rect\":", .{value.id}); try writeRectJson(value.rect, writer); @@ -980,7 +997,7 @@ fn writeGlyphsJson(glyphs: []const Glyph, writer: anytype) !void { } // --------------------------------------------------------------------------- -// Compact binary gpu-surface packet encoding (wire format v5). +// Compact binary gpu-surface packet encoding (wire format v7). // // The version this comment names, the `binary_packet_version` constant // below, and both host decoders' spec comments (appkit_host.m and the @@ -1019,6 +1036,27 @@ fn writeGlyphsJson(glyphs: []const Glyph, writer: anytype) !void { // font override, final pen x/baseline, and advance; synthesized elision // markers ride as positioned UTF-8 fragments. // +// v6 (from v5): a new command kind, `cell_grid` (code 14), carrying one +// packed terminal row — the lattice geometry plus its cells. The payload +// follows the standard command fields and is implied by the KIND, not by +// a flag bit (the flag byte is full). Cells encode as a delta stream: a +// per-cell tag byte whose low bit says "same style as the previous cell", +// so a row of default-styled text costs about a byte a column while a +// truecolor row pays its real 15. Cluster bytes ride INLINE per cell +// rather than as offsets into a shared blob, so a row decodes without +// the rest of the screen. Hosts that do not implement the kind refuse +// the packet (unknown kind -> refused present -> recorded fallback), +// which is exactly what the Direct2D host does today. +// +// v7 (from v6): a cell_grid carries its FONT FAMILY — the regular face +// plus bold, italic, and bold-italic companion ids — so a renderer picks +// the face a cell's SGR style asks for. A companion id of 0 means the +// app registered none, and the renderer synthesizes instead (offset +// double-draw for bold, baseline shear for italic; both hosts and the +// reference renderer use the identical rules from +// canvas/cell_grid.zig `CellSynthesis`). Face selection never moves the +// lattice: a cell's position is its index whatever it is drawn with. +// // Layout: // "NSGP" u8[4] | version u8 | load_action u8 (1 load / 2 clear / // 3 patch) | flags u8 (bit0 scissor, bit1 dirty rect list) | reserved u8 @@ -1031,12 +1069,20 @@ fn writeGlyphsJson(glyphs: []const Glyph, writer: anytype) !void { // image_index u32 (0xFFFFFFFF = none) } // | load/clear: command_count u32 | commands { key u64, command (see // writeCanvasGpuCommandBinary) } +// | cell_grid command payload (kind 14, after the standard fields): +// font_id u32 | bold_font_id u32 | italic_font_id u32 +// | bold_italic_font_id u32 | font_size f32 | origin f32[2] | cell_w f32 +// | cell_h f32 | baseline f32 | cols u16 | rows u16 +// | cell_count u32 | cells { tag u8 (bit0 same-style-as-previous, +// bit1 has-cluster), [fg u8[4] bg u8[4] underline u8[4] +// flags u16 when !same-style], [len u8 + UTF-8 bytes when +// has-cluster] } // | patch: evict_count u32 | evict keys u64[] // | upsert_count u32 | upserts { key u64, command } // | order_count u32 | order keys u64[] pub const binary_packet_magic = "NSGP"; -pub const binary_packet_version: u8 = 5; +pub const binary_packet_version: u8 = 7; /// Most dirty rects a patch header carries: enough to keep far-apart /// small changes (a switch plus a status line) from fusing into a @@ -1154,6 +1200,35 @@ pub fn canvasGpuCommandFingerprint(command: CanvasGpuCommand) u64 { } else { h = hash.resourceHashU8(h, 0); } + // The cell grid. `writeBinaryCellGrid` puts every one of these on the + // wire, so every one of them must hash: a cell_grid leaves shape, + // paint, text, image and effect empty and its bounds are purely + // geometric, which makes `.cells` the ONLY field that varies when a + // terminal row's glyphs change. Omitting it classified every typed + // character as "unchanged" and retained the stale row forever. + if (command.cells) |grid| { + h = hash.resourceHashU8(h, 1); + h = hash.resourceHashU64(h, grid.font_id); + h = hash.resourceHashU64(h, grid.bold_font_id); + h = hash.resourceHashU64(h, grid.italic_font_id); + h = hash.resourceHashU64(h, grid.bold_italic_font_id); + h = hash.resourceHashF32(h, grid.font_size); + h = hash.resourceHashPoint(h, grid.origin); + h = hash.resourceHashF32(h, grid.cell_width); + h = hash.resourceHashF32(h, grid.cell_height); + h = hash.resourceHashF32(h, grid.baseline); + h = hash.resourceHashU32(h, grid.cols); + h = hash.resourceHashU32(h, grid.rows); + h = hash.resourceHashBytes(h, grid.text); + h = hash.resourceHashUsize(h, grid.cells.len); + // `Cell` is `extern` and hole-free (a comptime assert pins its + // 20-byte size), so the raw bytes are an exact, cheap digest -- + // the same technique render_fingerprints.zig cellGridFingerprint + // already uses over a full screen every frame. + h = hash.resourceHashBytes(h, std.mem.sliceAsBytes(grid.cells)); + } else { + h = hash.resourceHashU8(h, 0); + } switch (command.effect) { .none => h = hash.resourceHashU8(h, 0), .shadow => |shadow| { @@ -1303,6 +1378,69 @@ fn writeCanvasGpuCommandBinary(command: CanvasGpuCommand, writer: anytype) !void if (command.image) |image| try writeBinaryImage(image, writer); if (command.text) |text| try writeBinaryText(text, writer); if (command.effect != .none) try writeBinaryEffect(command.effect, writer); + // Implied by the KIND rather than a flag: the flag byte is full, and + // a payload every `cell_grid` carries and no other kind does needs + // no bit to announce it. + if (command.kind == .cell_grid) { + if (command.cells) |grid| try writeBinaryCellGrid(grid, writer); + } +} + +/// One packed cell-grid row (wire v6). +/// +/// The cells encode as a DELTA stream because a terminal row is +/// overwhelmingly one style: each cell leads with a tag byte whose low +/// bit means "same colours and flags as the cell before me", so a +/// default-styled row costs a byte a column plus its characters, and +/// only a genuinely per-cell-styled row pays the full 15 bytes. That is +/// what keeps an incremental present small — a keystroke re-encodes one +/// row, and a plain row is a few hundred bytes. +fn writeBinaryCellGrid(grid: gpu_model.CanvasGpuCellGrid, writer: anytype) !void { + try writer.writeInt(u32, @intCast(grid.font_id), .little); + try writer.writeInt(u32, @intCast(grid.bold_font_id), .little); + try writer.writeInt(u32, @intCast(grid.italic_font_id), .little); + try writer.writeInt(u32, @intCast(grid.bold_italic_font_id), .little); + try writeBinaryF32(grid.font_size, writer); + try writeBinaryPoint(grid.origin, writer); + try writeBinaryF32(grid.cell_width, writer); + try writeBinaryF32(grid.cell_height, writer); + try writeBinaryF32(grid.baseline, writer); + try writer.writeInt(u16, grid.cols, .little); + try writer.writeInt(u16, grid.rows, .little); + try writer.writeInt(u32, @intCast(grid.cells.len), .little); + + var previous: ?cell_grid_model.Cell = null; + for (grid.cells) |cell| { + const cluster = cell.cluster(grid.text); + const same_style = if (previous) |prior| + prior.fg.eql(cell.fg) and prior.bg.eql(cell.bg) and + prior.underline_color.eql(cell.underline_color) and + prior.flags == cell.flags + else + false; + var tag: u8 = 0; + if (same_style) tag |= 1; + if (cluster.len > 0) tag |= 2; + try writer.writeByte(tag); + if (!same_style) { + try writeBinaryCellColor(cell.fg, writer); + try writeBinaryCellColor(cell.bg, writer); + try writeBinaryCellColor(cell.underline_color, writer); + try writer.writeInt(u16, cell.flags, .little); + } + if (cluster.len > 0) { + try writer.writeByte(@intCast(cluster.len)); + try writer.writeAll(cluster); + } + previous = cell; + } +} + +fn writeBinaryCellColor(color: cell_grid_model.CellColor, writer: anytype) !void { + try writer.writeByte(color.r); + try writer.writeByte(color.g); + try writer.writeByte(color.b); + try writer.writeByte(color.a); } /// Stable wire codes for the command kind — pinned independently of the @@ -1325,6 +1463,7 @@ fn binaryCommandKindCode(kind: gpu_model.CanvasGpuCommandKind) u8 { .draw_text => 11, .shadow => 12, .blur => 13, + .cell_grid => 14, .unsupported => 255, }; } diff --git a/src/primitives/canvas/terminal_grid.zig b/src/primitives/canvas/terminal_grid.zig index f36fa7d8c..3e1fb4973 100644 --- a/src/primitives/canvas/terminal_grid.zig +++ b/src/primitives/canvas/terminal_grid.zig @@ -16,26 +16,69 @@ //! painter draws the colors it is handed, so two producers with //! different palette policies still paint through one code path. //! -//! Budgets: painting degrades ROW-ATOMICALLY under three ceilings — the -//! display-list command budget, the text-byte store, and the -//! glyph-atlas proxy — so a pathological screen paints fewer complete -//! rows instead of failing the whole frame. Each ceiling's preflight -//! mirrors the paint loop's emissions exactly; counting bytes the loop -//! would suppress would silently blank every row after a paintable one. +//! Budgets: painting degrades ROW-ATOMICALLY under the frame's shared +//! ceilings — the packed-cell store, the cluster-text store, the +//! display-list command budget (box geometry and selection washes), and +//! the glyph-atlas proxy — so a pathological screen paints fewer +//! complete rows instead of failing the whole frame. Degrading is never +//! SILENT: `paintReport` returns the rows that reached the list and the +//! store that stopped it, and every paint — including the +//! void-returning `paint` — records the same fact on the builder +//! (`canvas.DisplayListDegradation`) for the runtime and the host app to +//! surface. A user looking at a half-blank terminal must be able to +//! learn that a budget, not their program, ate the bottom of the screen. +//! +//! A screen is ONE COMMAND PER ROW. The painter emits packed +//! `cell_grid` commands (`cell_grid.zig`) carrying every cell's +//! background, cluster, foreground, and style, and every renderer +//! expands the lattice itself. What that replaced: one background +//! command per contiguous same-colour run plus one text command per +//! contiguous same-foreground run, which merged into nothing on a +//! styled screen and put a 200x60 truecolor viewport at ~24,000 +//! commands against a budget of 4,096 — nine rows of sixty, the rest +//! bare background. Measured after: 60 of 60 rows, and a 300x100 +//! truecolor screen (30,000 cells) in 103 commands. Cost is linear in +//! CELLS at 20 bytes each, and bounded in commands by `max_rows`. +//! +//! Why per ROW and not one command for the whole screen: a retained +//! command is the unit of CHANGE. One command per screen makes a +//! keystroke re-encode and re-upload every cell, which is the +//! full-surface cost the packed cell exists to remove, merely moved +//! from the CPU rasterizer to the wire. A row is the granularity a +//! terminal actually changes at, so per-row keys keep an incremental +//! present small without teaching every renderer and both hosts a +//! sub-command dirty protocol. +//! +//! Three things fall out of that shape, and all three were bugs before: +//! - Reflow is safe. A row is one retained key replaced wholesale, +//! so a row that loses a run cannot orphan a per-run command whose +//! stale pixels then survive a resize. +//! - Cell geometry is exact. A cell's position is its INDEX, so a +//! combining mark or a wide cluster can no longer advance its +//! neighbours out of their columns, whatever the face does. +//! - Every SGR attribute has somewhere to live: bold, italic, +//! strikethrough, overline, six underline styles, and an underline +//! colour, none of which a `draw_text` run could carry. +//! +//! What still emits its own commands, deliberately: box-drawing cells +//! (exact GEOMETRY at cell bounds — glyphs fill the em box, not the +//! padded cell, and borders drawn from them show seams), the selection +//! wash, the cursor, the keyboard caret, and the scrollback thumb. All +//! composite OVER the lattice and all are a handful of commands. const std = @import("std"); const canvas = @import("root.zig"); const geometry = @import("geometry"); const box = @import("terminal_box.zig"); -/// Grid ceilings, derived from the per-view canvas budgets: the glyph -/// budget bounds how many cells can hold ink at once, and the command -/// budget bounds per-row style runs. A viewport is clamped to these -/// before it reaches an emulator, so a huge window degrades to a -/// bounded grid instead of a budget error. +/// Grid ceilings bound allocation and command-id geometry. Cell count is not a +/// glyph-atlas proxy: the atlas charges distinct glyph/subpixel keys, while a +/// terminal commonly repeats a small alphabet across tens of thousands of +/// cells. The painter's own distinct-glyph, text, path, and command preflights +/// remain the resource fences. pub const max_cols: usize = 320; pub const max_rows: usize = 96; -pub const max_cells: usize = 7168; +pub const max_cells: usize = max_cols * max_rows; /// Painter budgets when the grid rides the WIDGET tree, where it shares /// the per-view stores with every other widget. The command reserve is @@ -57,6 +100,10 @@ pub const widget_text_reserve: usize = 8192; pub const widget_path_reserve: usize = 2 * chart_ceiling + 16; const chart_ceiling: usize = canvas.max_chart_points_per_series; pub const widget_glyph_budget: usize = 8192 - 512; +/// Packed cells held back for a SECOND terminal in the same view: a +/// split gives each pane half the store, which is exactly what two +/// 160x100 panes need out of the 32768-cell frame budget. +pub const widget_cell_reserve: usize = canvas.max_display_list_cells / 2; /// One text run's staging capacity — shared by the paint loop's scratch /// and the preflight's per-cell cap so measure and emission agree. @@ -72,6 +119,17 @@ const text_scratch_bytes: usize = canvas.max_display_list_text_bytes; /// wide character never paints over half its width). pub const TerminalWide = enum(u2) { narrow, wide, spacer }; +/// SGR underline styles a cell can carry. Selected by +/// `TerminalCell.underline_style` and painted only when `underline` is +/// set, so the historical boolean keeps its exact meaning. +pub const TerminalUnderline = enum { + single, + double, + curly, + dotted, + dashed, +}; + /// One resolved viewport cell. `cp == 0` paints no ink (an empty cell, /// or one whose style suppressed it — the producer resolves invisible /// styling to 0 so measure and paint cannot disagree). Box-drawing code @@ -89,6 +147,21 @@ pub const TerminalCell = struct { bg: ?canvas.Color = null, underline: bool = false, wide: TerminalWide = .narrow, + /// The remaining SGR attributes. They arrive resolved like the + /// colours do and ride the packed cell grid to every renderer; a + /// producer that does not track one simply leaves the default. + /// `bold` and `italic` reach the cell but need registered companion + /// faces to change the GLYPH — with a single mono face they are + /// carried, not synthesised, which is the honest behaviour and what + /// the docs already state about weight axes. + bold: bool = false, + italic: bool = false, + strikethrough: bool = false, + overline: bool = false, + /// Which underline `underline` paints. + underline_style: TerminalUnderline = .single, + /// SGR 58 underline colour; null takes the cell foreground. + underline_color: ?canvas.Color = null, }; pub const TerminalRow = struct { @@ -99,12 +172,30 @@ pub const TerminalRow = struct { selection: ?[2]u16 = null, }; -pub const TerminalCursorShape = enum { block, bar, underline }; +/// `block_hollow` is a SHAPE the emulator asked for, distinct from the +/// hollow outline an unfocused window paints: a focused terminal whose +/// program requested a hollow block gets one, and the focus-driven +/// outline stays a property of focus. Conflating them (the historical +/// behaviour) made "hollow" mean two different things at once. +pub const TerminalCursorShape = enum { block, block_hollow, bar, underline }; pub const TerminalCursor = struct { x: u16 = 0, y: u16 = 0, shape: TerminalCursorShape = .block, + /// The emulator asked the cursor to blink. + /// + /// The painter draws the cursor's visible pose and nothing else — + /// blinking is TIME, and a painter has none. The runtime reads this + /// off the focused terminal's grid and arms the same looping + /// opacity animation a text caret uses, keyed on + /// `cursorCommandId(widget_id)`. A renderer that ignores the + /// animation still draws the cursor in the right place, so the + /// degradation is a cursor that does not blink, never one that goes + /// missing. + blinking: bool = false, + /// The cursor sits on a wide cell and covers two columns. + wide: bool = false, }; pub const TerminalCellPos = struct { x: u16 = 0, y: u16 = 0 }; @@ -225,15 +316,14 @@ pub fn cellMetrics(tokens: canvas.DesignTokens) TerminalCellMetrics { return .{ .width = width, .height = height, .font_size = font_size }; } -/// Clamp a proposed grid to the canvas budgets: the glyph budget bounds -/// total cells, so a very wide viewport trades rows for columns to stay -/// under the cell ceiling instead of overflowing the frame. +/// Clamp a proposed grid to the allocation and command-id geometry bounds. +/// Painter resources are content-dependent and are fenced during paint; they +/// must not shrink the PTY to a fraction of its visible pane. pub fn clampGrid(proposed_cols: usize, proposed_rows: usize) TerminalCellPos { - var c = std.math.clamp(proposed_cols, 2, max_cols); - var r = std.math.clamp(proposed_rows, 2, max_rows); - if (c * r > max_cells) r = @max(2, max_cells / c); - if (c * r > max_cells) c = @max(2, max_cells / r); - return .{ .x = @intCast(c), .y = @intCast(r) }; + return .{ + .x = @intCast(std.math.clamp(proposed_cols, 2, max_cols)), + .y = @intCast(std.math.clamp(proposed_rows, 2, max_rows)), + }; } // ------------------------------------------------------------- painting @@ -285,6 +375,71 @@ pub const TerminalPaintOptions = struct { /// columns. Painting stops row-atomically BEFORE the row whose new /// code points would cross it. 0 means unbounded. glyph_budget: usize = 0, + /// Packed-grid CELLS to hold back for the widgets after this one + /// (another terminal in a split, a code view). The grid degrades to + /// fewer painted rows instead of starving them. + cell_reserve: usize = 0, +}; + +/// What stopped a paint short of the grid's last row. +/// +/// The four store values are BUDGET losses — content the frame could +/// not hold. `.viewport` is not a loss at all: rows starting below the +/// frame's bottom edge have nowhere to paint, which is what a viewport +/// taller than its widget honestly looks like. `.torn` is the +/// defensive case: a builder store hit its floor mid-row despite the +/// preflights, and the partial row was rolled back whole. +pub const TerminalPaintStop = enum { + commands, + /// The frame's packed-cell store. The terminal's own budget now: a + /// screen costs cells, and this is the one that bounds how big a + /// screen can be. + cells, + text_bytes, + path_elements, + glyphs, + viewport, + torn, + + /// Whether this stop means content was LOST to a budget (as opposed + /// to a viewport that simply ends). + pub fn isBudget(self: TerminalPaintStop) bool { + return self != .viewport; + } +}; + +/// What one paint actually put on the glass. +/// +/// `rows_painted < rows_total` with a budget `stopped_by` is +/// TRUNCATION: the rows past `rows_painted` are not on the glass and +/// the surface below them is bare grid background. Callers surface it +/// (a status line, a log line, a smaller requested grid); the painter +/// additionally records budget stops on the builder, so callers of the +/// void-returning `paint` are never left guessing. +pub const TerminalPaintReport = struct { + /// Rows the producer handed over. + rows_total: usize = 0, + /// Rows painted COMPLETE, counted from the top (the painter never + /// skips a row and resumes: it stops). + rows_painted: usize = 0, + stopped_by: ?TerminalPaintStop = null, + + /// Content was lost to a budget (not merely clipped by the frame). + pub fn truncated(self: TerminalPaintReport) bool { + const stop = self.stopped_by orelse return false; + return stop.isBudget(); + } + + fn store(stop: TerminalPaintStop) ?canvas.DisplayListStore { + return switch (stop) { + .commands, .torn => .commands, + .cells => .cells, + .text_bytes => .text_bytes, + .path_elements => .path_elements, + .glyphs => .glyphs, + .viewport => null, + }; + } }; /// The painter's command-id base for a caller's `id_base` (typically a @@ -316,6 +471,18 @@ pub fn paintIdBase(id: u64) u64 { /// keeping the whole namespace under the 2^24 stride). pub const reserved_id_offset: u64 = 0x62_0000; +/// The command id the painter gives a grid's CURSOR. +/// +/// Public because blinking is the runtime's job, not the painter's: the +/// painter draws the cursor's visible pose, and the runtime arms the +/// ping-pong opacity animation that makes it blink (the same one a text +/// caret uses). Both sides need the id, so it lives here rather than +/// being re-derived from the painter's internal offsets. +pub fn cursorCommandId(id: u64) u64 { + if (id == 0) return 0; + return paintIdBase(id) +% 0x61_0002; +} + /// The paint's command-id derivation: keyed grids take `base + offset` /// (offsets < 2^24, disjoint per widget by the `paintIdBase` stride); /// an anonymous grid (caller id 0) emits every command with id 0, the @@ -444,53 +611,37 @@ fn displayListPathElements(list: canvas.DisplayList) usize { return total; } -/// An UPPER BOUND on the display-list commands one row emits, assuming -/// no runs merge (the pathological worst case): a background run per -/// cell that carries one, plus the cell's ink — eight commands for a -/// pure-double box joint, two (text run + underline) for a styled -/// character, none for an empty cell — plus one selection wash. Real -/// rows merge runs and cost far less; this bound is what the per-row -/// preflight checks so a row is only started when it can finish whole, -/// and it is content-accurate (a cheap ASCII row costs ~2/column, not a -/// flat worst-case-per-column reserve that would starve wide grids). +fn multiCodepointCluster(cell: TerminalCell) bool { + if (cell.cp == 0 or cell.cluster.len == 0) return false; + const primary_len = std.unicode.utf8CodepointSequenceLength(cell.cp) catch 1; + return cell.cluster.len > primary_len; +} + +/// Exact upper bound on the commands emitted for one row. +/// +/// Backgrounds and text runs cost NOTHING here any more: they are cells +/// in the row's slice of the packed grid, and the whole grid is one +/// command. What still emits per row is the ink the lattice cannot +/// express — box-drawing geometry at exact cell bounds — plus the +/// selection wash. fn rowCommandCost(row: TerminalRow) usize { - var total: usize = 1; // the selection wash + var total: usize = if (row.selection != null) 1 else 0; var i: usize = 0; - while (i < row.cells.len) : (i += 1) { + while (i < row.cells.len) { const cell = row.cells[i]; - if (cell.wide == .spacer) continue; - if (cell.bg != null) total += 1; - if (cell.cp == 0) continue; - if (box.isBoxDrawing(cell.cp)) { - if (box.mergesHorizontally(cell.cp)) { - // A horizontally-merged run (a long `─` border) paints - // ONE geometry command plus a possible underline, no - // matter how wide — mirror the paint loop's merge (same - // code point, foreground, and underline) so the estimate - // does not charge nine per column for what collapses to - // two commands. Backgrounds still count per cell (their - // own run pass is separate). - var span: usize = 1; - while (i + span < row.cells.len) : (span += 1) { - const next = row.cells[i + span]; - if (next.cp != cell.cp or !colorEql(next.fg, cell.fg) or next.underline != cell.underline) break; - if (next.bg != null) total += 1; - } - // A merged run's worst-case ink: a double piece (═) - // paints TWO parallel bars, plus a possible underline. - total += 3; - i += span - 1; - } else { - // The glyph's own worst case (kept lockstep with the - // paint switch by `box.maxCommands`) plus a possible - // underline — a row of one-bar pieces costs what it - // paints, never a flat joint-worst-case that starves - // wide grids. - total += box.maxCommands(cell.cp) + 1; + if (cell.wide == .spacer or cell.cp == 0 or !box.isBoxDrawing(cell.cp)) { + i += 1; + continue; + } + var span: usize = 1; + if (box.mergesHorizontally(cell.cp)) { + while (i + span < row.cells.len) : (span += 1) { + const next = row.cells[i + span]; + if (next.cp != cell.cp or !colorEql(next.fg, cell.fg)) break; } - } else { - total += 2; // the text run plus its underline } + total += box.maxCommands(cell.cp); + i += span; } return total; } @@ -499,12 +650,135 @@ fn colorEql(a: canvas.Color, b: canvas.Color) bool { return a.r == b.r and a.g == b.g and a.b == b.b and a.a == b.a; } +/// Slots in the cluster intern table. Open-addressed, power of two, +/// zero meaning empty (a stored value is `offset + 1` so offset 0 never +/// aliases an empty slot). A screen's DISTINCT clusters are its +/// alphabet — hundreds, not tens of thousands — so 4096 slots keep the +/// table far under half load and lookups O(1); a screen that somehow +/// fills it simply stops interning and appends, which costs bytes, not +/// correctness. +const cluster_intern_slots: usize = 4096; + +/// Intern `cluster` into `blob`, returning its offset. Repeat clusters +/// (every space, every `e`) resolve to the copy already there. +fn internCluster( + cluster: []const u8, + blob: *[text_scratch_bytes]u8, + blob_len: *usize, + slots: *[cluster_intern_slots]u32, +) ?u32 { + if (cluster.len == 0) return null; + var hash: u32 = 2166136261; + for (cluster) |byte| hash = (hash ^ byte) *% 16777619; + var index: usize = (hash *% 0x9E37_79B1) >> (32 - 12); + var probes: usize = 0; + while (probes < cluster_intern_slots) : (probes += 1) { + const entry = slots[index]; + if (entry == 0) break; + const start = entry - 1; + const end = start + cluster.len; + if (end <= blob_len.* and std.mem.eql(u8, blob[start..end], cluster)) return start; + index = (index + 1) & (cluster_intern_slots - 1); + } + if (blob_len.* + cluster.len > blob.len) return null; + const offset: u32 = @intCast(blob_len.*); + @memcpy(blob[offset..][0..cluster.len], cluster); + blob_len.* += cluster.len; + if (probes < cluster_intern_slots) slots[index] = offset + 1; + return offset; +} + +/// Resolve one producer cell into its packed form. Box-drawing cells +/// keep their BACKGROUND here and contribute no cluster: their ink is +/// exact geometry the painter emits separately, and a glyph drawn in +/// their place would show seams between rows. +fn packCell( + cell: TerminalCell, + blob: *[text_scratch_bytes]u8, + blob_len: *usize, + slots: *[cluster_intern_slots]u32, +) canvas.Cell { + var flags = canvas.CellFlags{ + .bold = cell.bold, + .italic = cell.italic, + .strikethrough = cell.strikethrough, + .overline = cell.overline, + .width = switch (cell.wide) { + .narrow => .narrow, + .wide => .wide, + .spacer => .spacer, + }, + }; + if (cell.underline) { + flags.underline = switch (cell.underline_style) { + .single => .single, + .double => .double, + .curly => .curly, + .dotted => .dotted, + .dashed => .dashed, + }; + } + var packed_cell = canvas.Cell{ + .fg = canvas.CellColor.fromColor(cell.fg), + }; + if (cell.bg) |color| { + packed_cell.bg = canvas.CellColor.fromColor(color); + flags.has_background = true; + } + if (cell.underline_color) |color| { + packed_cell.underline_color = canvas.CellColor.fromColor(color); + flags.has_underline_color = true; + } + const inks = cell.cp != 0 and cell.wide != .spacer and !box.isBoxDrawing(cell.cp); + if (inks and cell.cluster.len > 0 and cell.cluster.len <= 255) { + if (internCluster(cell.cluster, blob, blob_len, slots)) |offset| { + packed_cell.text_offset = offset; + packed_cell.text_len = @intCast(cell.cluster.len); + } + } + packed_cell.flags = flags.bits(); + return packed_cell; +} + +/// Fill one lattice row. Columns past the producer's row are left as +/// default cells — no background, no ink — which is exactly what a +/// short row looks like. +fn fillGridRow( + row: TerminalRow, + out: []canvas.Cell, + blob: *[text_scratch_bytes]u8, + blob_len: *usize, + slots: *[cluster_intern_slots]u32, +) void { + const shared = @min(out.len, row.cells.len); + for (out[0..shared], row.cells[0..shared]) |*slot, cell| { + slot.* = packCell(cell, blob, blob_len, slots); + } + for (out[shared..]) |*slot| slot.* = .{}; +} + /// Paint the grid into the display list: per-row background runs, the /// selection wash, per-run text, decorations, the cursor, the keyboard /// caret, and the scrollback indicator. Row commands carry stable ids /// derived from `options.id_base` so the retained renderer's diff keeps /// damage row-shaped. +/// +/// The historical signature, kept for every existing caller: it paints +/// exactly what `paintReport` paints and drops the report. Budget +/// truncation still reaches the caller — the report's facts are +/// recorded on `builder.degradation` either way — but a caller that +/// wants the row counts in hand should call `paintReport` directly. pub fn paint(grid: TerminalGrid, builder: *canvas.Builder, options: TerminalPaintOptions) !void { + _ = try paintReport(grid, builder, options); +} + +/// `paint`, returning what it managed to place: the rows painted, the +/// rows the producer handed over, and the store that stopped it (see +/// `TerminalPaintReport`). Budget stops are also recorded on the +/// builder so the runtime and the host app can surface them without +/// threading a return value through every emitter. +pub fn paintReport(grid: TerminalGrid, builder: *canvas.Builder, options: TerminalPaintOptions) !TerminalPaintReport { + var report = TerminalPaintReport{ .rows_total = grid.rows.len }; const tokens = options.tokens; const metrics = cellMetrics(tokens); // The command-id namespace (see `paintIdBase`): the widget part-id @@ -521,7 +795,14 @@ pub fn paint(grid: TerminalGrid, builder: *canvas.Builder, options: TerminalPain // ABSOLUTE ceiling, not a per-paint one. 0 stays the unbounded // (test) mode. const fixed_overhead: usize = 8; - if (options.command_budget > 0 and builder.len + fixed_overhead > options.command_budget) return; + if (options.command_budget > 0 and builder.len + fixed_overhead > options.command_budget) { + // Not one row, not even the surface: the loudest degradation + // there is, and the one most likely to read as "the terminal + // widget is broken" if it stayed silent. + if (report.rows_total > 0) report.stopped_by = .commands; + noteTerminalDegradation(builder, options.id_base, report); + return report; + } // The terminal surface: full-bleed background under the grid. try builder.fillRect(.{ @@ -584,94 +865,202 @@ pub fn paint(grid: TerminalGrid, builder: *canvas.Builder, options: TerminalPain // stroke a terminal font would carry at this size). const box_thickness: f32 = @max(1, @round(metrics.font_size / 8)); - // A run's staging buffer, sized to the whole text store (see - // `text_scratch_bytes`): any cluster the store can hold stages - // whole. The run-break flushes when the buffer nears full, so long - // runs simply split across draw commands rather than overflow. - var text_scratch: [text_scratch_bytes]u8 = undefined; + // The lattice this paint will cover. Columns come from the widest + // row the producer handed over (short rows pad with empty cells), so + // the grid stays the rectangle every renderer indexes by (x, y) + // arithmetic instead of a ragged array with a per-row offset table. + var cols: usize = 0; + for (grid.rows) |row| cols = @max(cols, row.cells.len); + cols = @min(cols, max_cols); + + // The cluster INTERN table, reset PER ROW. A row repeats a tiny + // alphabet across its columns, so a row's text blob holds one copy + // of each distinct cluster in it — a 300-column ASCII row interns to + // under a hundred bytes instead of 300. + // + // Per row, not per screen, because a row is a retained KEY: a blob + // shared across the screen would put every row's fingerprint on + // every other row's characters, so one keystroke that introduced a + // new letter would re-encode and re-upload the whole screen. That is + // measurable — it showed up as 31 upserts and 8.4 KB per keystroke + // before this was per-row, against 1 upsert and ~400 bytes after. + var blob: [text_scratch_bytes]u8 = undefined; + var blob_len: usize = 0; + var intern_slots: [cluster_intern_slots]u32 = undefined; + // Rows painted so far (the loop paints 0..N contiguously from the // top and stops at the first row a budget rejects): the cursor and // caret below are suppressed on any row this never reached, so a row // dropped for budget never leaves its lone cursor floating over the // blank background. var painted_rows: usize = 0; + // Commands this paint will emit: the prologue already in the + // builder, plus per accepted row one grid command and whatever box + // geometry and wash that row needs. + var projected_commands: usize = builder.len; + // Cells are claimed from the builder's store up front for the most + // rows that could possibly paint, then the unused tail is handed + // back (the store is a bump allocator, and nothing else claims cells + // in between). That buys a single pass: a row can be filled and then + // rejected by a later budget without leaving a hole. + const cell_ceiling = builder.cells.len -| options.cell_reserve; + const cells_available = cell_ceiling -| builder.cell_len; + // A viewport of empty rows has no cells to store but still has + // ROWS: they paint as bare surface, and the cursor sitting on one + // is on a painted row. Bounding those by the cell store would + // suppress the cursor of an empty terminal. + const cell_row_limit = if (cols == 0) grid.rows.len else cells_available / cols; + const claim_rows = @min(grid.rows.len, cell_row_limit); + var cells: []canvas.Cell = &.{}; + if (claim_rows > 0) { + cells = builder.allocCells(claim_rows * cols) catch &.{}; + } + if (cells.len == 0 and grid.rows.len > 0 and cols > 0) report.stopped_by = .cells; + for (grid.rows, 0..) |row, row_index| { - // Command-count stop, ATOMIC per row: this row's actual - // upper-bound cost plus the epilogue must fit the budget, so a - // cheap wide row paints while a genuinely too-dense row (and - // everything after it) is skipped whole — never started and torn. + if (row_index >= claim_rows) { + // Ran out of cell store rather than out of screen. + if (row_index < grid.rows.len) report.stopped_by = .cells; + break; + } + const row_y = origin_y + @as(f32, @floatFromInt(row_index)) * cell_h; + // Rows STARTING at or past the frame's bottom paint nothing + // visible; a row straddling the edge still paints and the clip + // crops it, so content reaches the very edge without spilling. + if (row_y >= options.frame.y + options.frame.height) { + report.stopped_by = .viewport; + break; + } + // Command-count stop, ATOMIC per row. Backgrounds and text no + // longer cost commands at all — they are cells now — so this + // prices only what still emits: the row's box-drawing geometry + // and its selection wash. The running PROJECTION is what makes + // it correct: those commands are emitted in the second pass + // below, so `builder.len` does not move while this loop runs and + // charging against it would price every row as if it were the + // first. + // +1 for the row's own grid command (see the per-row emission + // below). + const row_commands = rowCommandCost(row) + 1; if (options.command_budget > 0 and - builder.len + rowCommandCost(row) + epilogue_reserve > options.command_budget) break; - // Text stop, ATOMIC per row, against the view-global running - // total (all draw_text already in the list plus this row's - // bytes) — stop BEFORE a row that would cross the per-view text - // ceiling, never emit its first runs and then have the frame - // rejected at commit. A row that ADDS no text can never cross - // anything, so it paints even when earlier widgets already sit - // past the grid's reserved share — the ceiling bounds what the - // grid adds, not what its siblings spent. - const row_text = rowTextBytes(row); - if (row_text > 0 and text_total + row_text > text_ceiling) break; - // Path-element stop, ATOMIC per row, against the view-global - // running total (static sibling paths included): a row of rounded - // corners is skipped BEFORE it would cross the per-view - // path-element budget; a row adding none paints regardless. - const row_paths = rowPathElements(row); - if (row_paths > 0 and path_total + row_paths > path_ceiling) break; + projected_commands + row_commands + epilogue_reserve > options.command_budget) + { + report.stopped_by = .commands; + break; + } + projected_commands += row_commands; // Glyph-budget stop, same row-atomic shape: stop BEFORE the row - // whose new DISTINCT code points would cross the atlas proxy — - // the frame degrades to fewer rows instead of failing whole on - // `GlyphAtlasListFull`. + // whose new DISTINCT code points would cross the atlas proxy. if (glyph_budget > 0) { glyphs_counted += rowNewGlyphs(row, &glyph_seen) * atlas_variants_per_glyph; - if (glyphs_counted > glyph_budget) break; + if (glyphs_counted > glyph_budget) { + report.stopped_by = .glyphs; + break; + } + } + // Text stop, ATOMIC per row, against the interned blob. The row + // is filled into a SNAPSHOT of the intern state so a row that + // crosses the ceiling can be rolled back whole. + if (cols > 0) { + const row_cells = cells[row_index * cols ..][0..cols]; + blob_len = 0; + @memset(&intern_slots, 0); + fillGridRow(row, row_cells, &blob, &blob_len, &intern_slots); + // A row that adds no cluster bytes can never cross anything, + // so a blank or box-only row paints even when earlier widgets + // already sit past the grid's reserved share — the ceiling + // bounds what the grid adds, not what its siblings spent. + if (blob_len > 0 and text_total + blob_len > text_ceiling) { + report.stopped_by = .text_bytes; + break; + } + const row_text = builder.allocTextBytes(blob[0..blob_len]) catch { + report.stopped_by = .text_bytes; + break; + }; + text_total += blob_len; + // The row's grid command, emitted here rather than in a + // later pass so it lands BEFORE that row's box geometry and + // selection wash, which composite over it. + builder.cellGrid(.{ + .id = ids.at(0x60_0000 + @as(u64, @intCast(row_index))), + .origin = geometry.PointF.init( + origin_x, + origin_y + @as(f32, @floatFromInt(row_index)) * cell_h, + ), + .cell_width = cell_w, + .cell_height = cell_h, + .cols = @intCast(cols), + .rows = 1, + .cells = row_cells, + .text = row_text, + .font_id = tokens.typography.mono_font_id, + .bold_font_id = tokens.typography.mono_bold_font_id, + .italic_font_id = tokens.typography.mono_italic_font_id, + .bold_italic_font_id = tokens.typography.mono_bold_italic_font_id, + .font_size = metrics.font_size, + // The baseline the canvas boxes a run at: one em of + // ascent above the origin and a quarter below, centred + // in the cell. Computed once so every renderer puts it + // in the same place instead of re-deriving it from + // metrics it may not share. + .baseline = (cell_h - metrics.font_size * 1.25) * 0.5 + metrics.font_size, + .measure = tokens.text_measure, + }) catch { + report.stopped_by = .commands; + break; + }; } - // Row-atomic rollback snapshot: the preflights above make a tear - // unreachable, but if a builder store is ever exhausted mid-row - // anyway, these restore points drop the partial row's commands - // whole (see the `row_torn` handling below) so nothing half a row - // ever reaches the glass. - const row_start_len = builder.len; - const row_start_paths = builder.path_element_len; - const row_start_text = builder.text_byte_len; + painted_rows = row_index + 1; + } + + // Hand back the cells of rows that never painted: the store is a + // bump allocator and nothing else claimed cells in between. + if (claim_rows > painted_rows) builder.cell_len -= (claim_rows - painted_rows) * cols; + + // Over the lattice: box-drawing geometry (exact cell-bound shapes, + // never glyphs) and the selection wash. Both composite ON TOP of the + // grid, which is why they run after it rather than inside the fill + // loop above. + for (grid.rows[0..painted_rows], 0..) |row, row_index| { const row_y = origin_y + @as(f32, @floatFromInt(row_index)) * cell_h; - // Rows STARTING at or past the frame's bottom paint nothing - // visible; a row straddling the edge still paints and the clip - // crops it, so content reaches the very edge without spilling. - if (row_y >= options.frame.y + options.frame.height) break; const row_id = @as(u64, @intCast(row_index)) << 16; - - // Background runs: contiguous cells sharing a non-default bg - // (the producer already extended a wide primary's background - // onto its spacer column). - var run_start: usize = 0; - var run_color: ?canvas.Color = null; + const row_paths = rowPathElements(row); + const paths_fit = row_paths == 0 or path_total + row_paths <= path_ceiling; var x: usize = 0; - while (x <= row.cells.len) : (x += 1) { - const bg: ?canvas.Color = if (x < row.cells.len) row.cells[x].bg else null; - if (run_color) |color| { - const same = if (bg) |next| colorEql(color, next) else false; - if (!same) { - try builder.fillRect(.{ - .id = ids.at(row_id + 1 + run_start), - .rect = geometry.RectF.init( - origin_x + @as(f32, @floatFromInt(run_start)) * cell_w, - row_y, - @as(f32, @floatFromInt(x - run_start)) * cell_w, - cell_h, - ), - .fill = .{ .color = color }, - }); - run_color = bg; - run_start = x; + while (x < row.cells.len) : (x += 1) { + const cell = row.cells[x]; + if (cell.wide == .spacer or cell.cp == 0) continue; + if (!box.isBoxDrawing(cell.cp)) continue; + // Merge a run of the same seamless piece (a border's long + // `─`) into ONE command: fewer commands and one unbroken bar + // instead of per-cell segments. + var span: usize = 1; + if (box.mergesHorizontally(cell.cp)) { + while (x + span < row.cells.len) : (span += 1) { + const next = row.cells[x + span]; + if (next.cp != cell.cp or !colorEql(next.fg, cell.fg)) break; } - } else if (bg != null) { - run_color = bg; - run_start = x; } + if (!paths_fit and cellPathElements(cell) > 0) { + x += span - 1; + continue; + } + const rect = geometry.RectF.init( + origin_x + @as(f32, @floatFromInt(x)) * cell_w, + row_y, + @as(f32, @floatFromInt(span)) * cell_w, + cell_h, + ); + // Eight-command stride per column: a pure-double joint emits + // up to eight commands, so a shorter stride would collide + // the ids of adjacent double cells. + box.paint(builder, ids.at(row_id + 0x6000 + @as(u64, @intCast(x)) * commands_per_cell), rect, cell.cp, cell.fg, box_thickness) catch {}; + x += span - 1; } + if (paths_fit) path_total += row_paths; - // Selection wash (under the ink, over the backgrounds). + // Selection wash (over the backgrounds and the ink). if (row.selection) |range| { const wash = canvas.Color.rgba( grid.selection_color.r, @@ -679,7 +1068,7 @@ pub fn paint(grid: TerminalGrid, builder: *canvas.Builder, options: TerminalPain grid.selection_color.b, 0.30, ); - try builder.fillRect(.{ + builder.fillRect(.{ .id = ids.at(row_id + 0x4000), .rect = geometry.RectF.init( origin_x + @as(f32, @floatFromInt(range[0])) * cell_w, @@ -688,217 +1077,11 @@ pub fn paint(grid: TerminalGrid, builder: *canvas.Builder, options: TerminalPain cell_h, ), .fill = .{ .color = wash }, - }); - } - - // Text runs: contiguous cells sharing a foreground, flushed on - // color or decoration change (bold/italic render with the one - // mono face — weight axes require registered companion faces, - // a limitation the docs state outright). - // A builder-store exhaustion mid-row (the display list, its text - // bytes, or its path elements filling despite the reserves) TEARS - // the row: the reserves make it unreachable in the widget path, - // but if it ever happens the row is left incomplete, so it must - // NOT count as painted and no further row may start — otherwise - // the cursor could paint over content the tear dropped. - var row_torn = false; - var run_len: usize = 0; - var run_x: usize = 0; - var run_fg: canvas.Color = grid.foreground; - var run_underline = false; - var text_len: usize = 0; - // Multi-codepoint clusters (combining marks) paint as their OWN - // single-cell run at their exact cell origin: inside a merged - // run their advance would come from the text layout, and a - // layout that advances a combining mark by a full glyph would - // shift every later cell in the run off the grid — while the - // backgrounds, cursor, and selection stayed cell-aligned. - // Isolating the cluster pins its neighbors to their own cell - // origins whatever the mark advances to; plain single-scalar - // runs keep the merged fast path. - var break_after_cluster = false; - x = 0; - while (x <= row.cells.len) : (x += 1) { - var cp: u21 = 0; - var fg = grid.foreground; - var underline = false; - var skip = false; - var box_cp: u21 = 0; - var cell_bytes: usize = 0; - var multi_cluster = false; - if (x < row.cells.len) { - const cell = row.cells[x]; - if (cell.wide == .spacer) skip = true; - cp = cell.cp; - fg = cell.fg; - underline = cell.underline; - // Box-drawing, block, and shade cells render as GEOMETRY - // at exact cell bounds, never font glyphs: glyphs fill - // the em box, not the padded cell, so borders drawn from - // them show seams between rows and columns. The cell - // still breaks the text run (cp zeroes) and paints below. - if (cp != 0 and !skip and box.isBoxDrawing(cp)) { - box_cp = cp; - cp = 0; - } - // The current cell's full byte need: the run breaks - // BEFORE a cell that would not fit the remaining - // scratch, so the cell restarts in a fresh buffer and a - // large grapheme landing near the buffer's end keeps - // all its marks instead of being cut. - if (cp != 0 and !skip) { - cell_bytes = cell.cluster.len; - const primary_len = std.unicode.utf8CodepointSequenceLength(cp) catch 1; - multi_cluster = cell.cluster.len > primary_len; - } - } - const breaks = x == row.cells.len or skip or cp == 0 or - !colorEql(fg, run_fg) or underline != run_underline or - multi_cluster or break_after_cluster or - text_len + cell_bytes > text_scratch.len; - if (breaks and run_len > 0 and text_len > 0) { - // The row-wise text ceiling reserves enough that this - // append fits; the catch is a defensive floor that stops - // the run cleanly if it ever did not (never a torn cell). - const run_text = builder.allocTextBytes(text_scratch[0..text_len]) catch { - row_torn = true; - break; - }; - try builder.drawText(.{ - .id = ids.at(row_id + 0x8000 + run_x), - .font_id = tokens.typography.mono_font_id, - .size = metrics.font_size, - // `origin` is the BASELINE, not the glyph top: the - // canvas boxes a run as one em of ascent above the - // origin and a quarter below, so centering that - // 1.25em box in the cell puts the baseline at - // row top + (cell - 1.25*size)/2 + size. Anchoring - // the top here instead paints every row one line - // high — and the clip swallows row zero whole. - .origin = geometry.PointF.init( - origin_x + @as(f32, @floatFromInt(run_x)) * cell_w, - row_y + (cell_h - metrics.font_size * 1.25) * 0.5 + metrics.font_size, - ), - .color = run_fg, - .text = run_text, - // The run carries the measurement seam for one - // reason: a command's raster extent comes from its - // own declared bounds, and without a provider those - // bounds are the estimator's 0.6 em mono pitch. A - // host face with a wider pitch (macOS resolves the - // mono id to the system monospaced face at 0.618 em - // when Geist Mono is absent) then inks past the - // declared extent, and a full-width row loses its - // last cell — measured: column 50 of 50 sheared to a - // two-pixel sliver. Measuring the run the way the - // host inks it makes the bounds cover the ink for - // whatever face resolves. Line breaking stays off - // (`wrap = .none`, no `max_width`), so the run still - // paints as one line at this exact baseline. - .text_layout = .{ - .wrap = .none, - .line_height = cell_h, - .measure = tokens.text_measure, - }, - }); - if (run_underline) { - try builder.fillRect(.{ - .id = ids.at(row_id + 0xc000 + run_x), - .rect = geometry.RectF.init( - origin_x + @as(f32, @floatFromInt(run_x)) * cell_w, - row_y + cell_h - 2, - @as(f32, @floatFromInt(run_len)) * cell_w, - 1, - ), - .fill = .{ .color = run_fg }, - }); - } - text_len = 0; - run_len = 0; - } - if (x >= row.cells.len or skip) continue; - if (box_cp != 0) { - // Merge a run of the same seamless piece (a border's - // long `─`) into ONE command: fewer commands and one - // unbroken bar instead of per-cell segments. Cells merge - // only when they match on EVERY channel the span paints — - // code point, foreground, AND underline — so a run where - // underline toggles mid-way breaks into separate spans - // and each keeps its own decoration. - var span: usize = 1; - if (box.mergesHorizontally(box_cp)) { - while (x + span < row.cells.len) : (span += 1) { - const next = row.cells[x + span]; - if (next.cp != box_cp or !colorEql(next.fg, fg) or next.underline != underline) break; - } - } - const rect = geometry.RectF.init( - origin_x + @as(f32, @floatFromInt(x)) * cell_w, - row_y, - @as(f32, @floatFromInt(span)) * cell_w, - cell_h, - ); - // Eight-command stride per column: a pure-double joint - // emits up to eight commands (two bars per side), so a - // four-wide stride would collide the ids of adjacent - // double cells and fail the retained diff with - // DuplicateObjectId. - box.paint(builder, ids.at(row_id + 0x6000 + @as(u64, @intCast(x)) * commands_per_cell), rect, box_cp, fg, box_thickness) catch { - row_torn = true; - break; - }; - // A box/block cell can still carry SGR underline: the - // geometry replaces the glyph, not its decoration, so an - // underlined `─` or `█` keeps its underline like any other - // styled cell (0xd000 id range, clear of the box geometry - // and text ranges). - if (underline) { - builder.fillRect(.{ - .id = ids.at(row_id + 0xd000 + @as(u64, @intCast(x))), - .rect = geometry.RectF.init(rect.x, row_y + cell_h - 2, rect.width, 1), - .fill = .{ .color = fg }, - }) catch { - row_torn = true; - break; - }; - } - x += span - 1; - continue; - } - if (cp == 0) continue; - if (run_len == 0) { - run_x = x; - run_fg = fg; - run_underline = underline; - } - const cell = row.cells[x]; - // Stage the whole cluster; the break above guaranteed it - // fits, and the min is a defensive floor that can only cut - // where the preflight already stopped the row. - const take = @min(cell.cluster.len, text_scratch.len - text_len); - @memcpy(text_scratch[text_len..][0..take], cell.cluster[0..take]); - text_len += take; - run_len += if (cell.wide == .wide) 2 else 1; - break_after_cluster = multi_cluster; + }) catch {}; } - // A torn row is not painted content: roll the builder back to the - // row's start so no partial row reaches the glass, then stop (the - // suppressed cursor/caret rely on `painted_rows` not advancing). - if (row_torn) { - builder.len = row_start_len; - builder.path_element_len = row_start_paths; - builder.text_byte_len = row_start_text; - break; - } - // Advance the view-global running totals by exactly what this row - // added (the builder-owned deltas): the terminal's own text/path - // emissions all flow through `allocTextBytes`/`allocPathElements`, - // so the counter deltas are its contribution to the per-view - // budgets the next row's preflight checks. - text_total += builder.text_byte_len - row_start_text; - path_total += builder.path_element_len - row_start_paths; - painted_rows = row_index + 1; } + report.rows_painted = painted_rows; + noteTerminalDegradation(builder, options.id_base, report); // The cursor, over the ink: solid only while this live terminal owns // keyboard focus. Blur leaves the conventional hollow terminal @@ -913,12 +1096,19 @@ pub fn paint(grid: TerminalGrid, builder: *canvas.Builder, options: TerminalPain grid.cursor_color.b, if (grid.running) 0.45 else 0.22, ); + // A cursor on a wide cell covers both of its columns. + const cursor_w = if (cursor.wide) cell_w * 2 else cell_w; const rect = switch (cursor.shape) { .bar => geometry.RectF.init(cursor_x, cursor_y, 2, cell_h), - .underline => geometry.RectF.init(cursor_x, cursor_y + cell_h - 2, cell_w, 2), - .block => geometry.RectF.init(cursor_x, cursor_y, cell_w, cell_h), + .underline => geometry.RectF.init(cursor_x, cursor_y + cell_h - 2, cursor_w, 2), + .block, .block_hollow => geometry.RectF.init(cursor_x, cursor_y, cursor_w, cell_h), }; - if (options.focused and grid.running) { + // Two independent reasons to paint hollow, kept independent: the + // emulator ASKED for a hollow block, or the window does not own + // the keyboard. Either one outlines; only a focused, live, + // solid-shape cursor fills. + const shape_is_hollow = cursor.shape == .block_hollow; + if (options.focused and grid.running and !shape_is_hollow) { try builder.fillRect(.{ .id = ids.at(0x61_0002), .rect = rect, @@ -975,4 +1165,22 @@ pub fn paint(grid: TerminalGrid, builder: *canvas.Builder, options: TerminalPain } } try builder.popClip(); + return report; +} + +/// Stamp a budget stop on the frame being built (see +/// `canvas.DisplayListDegradation`). A viewport stop is not a loss and +/// records nothing; a healthy paint clears nothing either — the record +/// belongs to whichever emitter last ran short, and `Builder.reset` +/// starts every frame clean. +fn noteTerminalDegradation(builder: *canvas.Builder, id: u64, report: TerminalPaintReport) void { + if (!report.truncated()) return; + const stop = report.stopped_by orelse return; + const store = TerminalPaintReport.store(stop) orelse return; + builder.noteDegradation(.{ + .id = id, + .store = store, + .produced = report.rows_painted, + .requested = report.rows_total, + }); } diff --git a/src/primitives/canvas/terminal_grid_tests.zig b/src/primitives/canvas/terminal_grid_tests.zig index ef7c02776..84966cbcd 100644 --- a/src/primitives/canvas/terminal_grid_tests.zig +++ b/src/primitives/canvas/terminal_grid_tests.zig @@ -9,6 +9,7 @@ const canvas = @import("root.zig"); const geometry = @import("geometry"); const grid_model = @import("terminal_grid.zig"); const box = @import("terminal_box.zig"); +const equality_model = @import("equality.zig"); const testing = std.testing; @@ -42,1052 +43,1058 @@ fn baseGrid(rows: []const grid_model.TerminalRow) grid_model.TerminalGrid { }; } -fn paintInto(grid: grid_model.TerminalGrid, commands: []canvas.CanvasCommand, options: grid_model.TerminalPaintOptions) !canvas.Builder { - var builder = canvas.Builder.init(commands); - try grid_model.paint(grid, &builder, options); - return builder; +/// Paint into a builder the CALLER owns. +/// +/// Deliberately not "return a builder": an emitter's slices point into +/// the builder's own stores (text bytes, path elements, a grid's +/// cells), so handing a Builder back by value aims every one of them at +/// a dead stack frame. Text runs were small enough to survive that by +/// luck; a packed cell grid is not. +fn paintInto(grid: grid_model.TerminalGrid, builder: *canvas.Builder, options: grid_model.TerminalPaintOptions) !void { + try grid_model.paint(grid, builder, options); } -test "the painter emits merged text runs with per-run colors" { - const row_cells = comptime asciiRow("hi red", white); - var cells = row_cells; - // Recolor the "red" run. - cells[3].fg = red; - cells[4].fg = red; - cells[5].fg = red; - const rows = [_]grid_model.TerminalRow{.{ .cells = &cells }}; - - var commands: [256]canvas.CanvasCommand = undefined; - var builder = try paintInto(baseGrid(&rows), &commands, .{ - .frame = geometry.RectF.init(0, 0, 400, 100), +// ------------------------------------------------- cell-grid helpers +// +// The painter emits ONE `cell_grid` command for a screen (see +// canvas/cell_grid.zig), so the assertions below read cells rather than +// per-run `draw_text` commands. What each test PINS is unchanged; only +// the surface it reads moved. + +/// The widget tier's options, exactly as `emitTerminalWidget` builds +/// them: the frame command ceiling minus the chrome reserve, plus the +/// text, path, glyph, and cell reserves. +fn widgetPaintOptions(frame: geometry.RectF) grid_model.TerminalPaintOptions { + return .{ + .frame = frame, .tokens = .{}, - }); - const list = builder.displayList(); + .id_base = 1, + .command_budget = canvas.max_display_list_commands - grid_model.widget_command_reserve, + .text_reserve = grid_model.widget_text_reserve, + .path_reserve = grid_model.widget_path_reserve, + .glyph_budget = grid_model.widget_glyph_budget, + }; +} - var saw_hi = false; - var saw_red = false; +/// The grid command for lattice row `y`. The painter emits one per row +/// (the retained-patch granularity), so a row IS a command. +fn rowCellGrid(list: canvas.DisplayList, y: usize) ?canvas.CellGrid { + var seen: usize = 0; for (list.commands) |command| { - switch (command) { - .draw_text => |text| { - if (std.mem.eql(u8, text.text, "hi")) { - saw_hi = true; - try testing.expectEqual(white.r, text.color.r); - } - if (std.mem.eql(u8, text.text, "red")) { - saw_red = true; - try testing.expectEqual(red.r, text.color.r); - try testing.expectEqual(red.g, text.color.g); - } - }, - else => {}, - } + if (command != .cell_grid) continue; + if (seen == y) return command.cell_grid; + seen += 1; } - try testing.expect(saw_hi); - try testing.expect(saw_red); + return null; } -test "wide cells advance two columns and spacers carry no ink" { - // "你!" — the wide cell occupies columns 0-1, '!' lands at column 2. - const cells = [_]grid_model.TerminalCell{ - .{ .cp = 0x4F60, .cluster = "\xe4\xbd\xa0", .fg = white, .wide = .wide }, - .{ .wide = .spacer }, - cell('!', "!", white), - }; - const rows = [_]grid_model.TerminalRow{.{ .cells = &cells }}; - - var commands: [64]canvas.CanvasCommand = undefined; - var builder = try paintInto(baseGrid(&rows), &commands, .{ - .frame = geometry.RectF.init(0, 0, 400, 100), - .tokens = .{}, - }); - const list = builder.displayList(); - - const tokens: canvas.DesignTokens = .{}; - const metrics = grid_model.cellMetrics(tokens); - var saw_bang = false; - for (list.commands) |command| { - switch (command) { - .draw_text => |text| { - if (std.mem.eql(u8, text.text, "!")) { - saw_bang = true; - // One wide cluster before it: x = 2 cells. - try testing.expectApproxEqAbs(metrics.width * 2, text.origin.x, 0.01); - } - }, - else => {}, - } - } - try testing.expect(saw_bang); +fn firstCellGrid(list: canvas.DisplayList) ?canvas.CellGrid { + return rowCellGrid(list, 0); } -test "box-drawing cells render as geometry, never text" { - const cells = [_]grid_model.TerminalCell{ - .{ .cp = 0x2500, .fg = white }, // ─ - .{ .cp = 0x2500, .fg = white }, - .{ .cp = 0x2502, .fg = white }, // │ - }; - const rows = [_]grid_model.TerminalRow{.{ .cells = &cells }}; +fn gridCell(list: canvas.DisplayList, x: usize, y: usize) ?canvas.Cell { + const grid = rowCellGrid(list, y) orelse return null; + return grid.at(x, 0); +} - var commands: [64]canvas.CanvasCommand = undefined; - var builder = try paintInto(baseGrid(&rows), &commands, .{ - .frame = geometry.RectF.init(0, 0, 400, 100), - .tokens = .{}, - }); - const list = builder.displayList(); +fn gridCluster(list: canvas.DisplayList, x: usize, y: usize) []const u8 { + const grid = rowCellGrid(list, y) orelse return ""; + const cell_value = grid.at(x, 0) orelse return ""; + return cell_value.cluster(grid.text); +} - var text_commands: usize = 0; - var fills: usize = 0; - for (list.commands) |command| { - switch (command) { - .draw_text => text_commands += 1, - .fill_rect => fills += 1, - else => {}, - } +/// The cluster bytes of row `y`, concatenated into `out` — the row as a +/// renderer would ink it. +fn gridRowText(list: canvas.DisplayList, y: usize, out: []u8) []const u8 { + const grid = rowCellGrid(list, y) orelse return ""; + var len: usize = 0; + var x: usize = 0; + while (x < grid.cols) : (x += 1) { + const cell_value = grid.at(x, 0) orelse continue; + const bytes = cell_value.cluster(grid.text); + if (len + bytes.len > out.len) break; + @memcpy(out[len..][0..bytes.len], bytes); + len += bytes.len; } - try testing.expectEqual(@as(usize, 0), text_commands); - // Background + the merged ─ run + the │ bar at least. - try testing.expect(fills >= 3); + return out[0..len]; } -test "the selection wash and keyboard caret paint from snapshot state" { - const row_cells = comptime asciiRow("select me", white); - const rows = [_]grid_model.TerminalRow{.{ .cells = &row_cells, .selection = .{ 0, 5 } }}; - var grid = baseGrid(&rows); - grid.select_head = .{ .x = 5, .y = 0 }; - - var commands: [64]canvas.CanvasCommand = undefined; - var builder = try paintInto(grid, &commands, .{ - .frame = geometry.RectF.init(0, 0, 400, 100), - .tokens = .{}, - }); - const list = builder.displayList(); - - var saw_wash = false; - var saw_caret = false; +/// Rows that reached the display list — one grid command each. +fn gridPaintedRows(list: canvas.DisplayList) usize { + var count: usize = 0; for (list.commands) |command| { - switch (command) { - .fill_rect => |fill| { - if (fill.fill == .color and fill.fill.color.a > 0.29 and fill.fill.color.a < 0.31) saw_wash = true; - }, - .stroke_rect => saw_caret = true, - else => {}, - } + if (command == .cell_grid) count += 1; } - try testing.expect(saw_wash); - try testing.expect(saw_caret); + return count; } -test "the cursor register: filled only while focused and live, hollow otherwise" { - const rows = [_]grid_model.TerminalRow{.{ .cells = &.{} }}; - var grid = baseGrid(&rows); - grid.cursor = .{ .x = 0, .y = 0 }; +const screen_cols: usize = 200; +const screen_rows: usize = 60; - var commands: [16]canvas.CanvasCommand = undefined; - var builder = try paintInto(grid, &commands, .{ - .frame = geometry.RectF.init(0, 0, 100, 40), - .tokens = .{}, - .focused = true, - }); - var running_alpha: f32 = 0; - for (builder.displayList().commands) |command| { - switch (command) { - .fill_rect => |fill| { - if (fill.fill == .color and fill.fill.color.b == blue.b and fill.fill.color.a < 1) running_alpha = fill.fill.color.a; - }, - else => {}, - } - } - try testing.expectApproxEqAbs(@as(f32, 0.45), running_alpha, 0.001); +/// A cell's cluster: one byte out of a fixed alphabet, so a screen's +/// text cost is one byte per cell (a terminal's ordinary case) and its +/// distinct-glyph cost stays a small alphabet. +fn screenCluster(index: usize) []const u8 { + const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; + const at = index % alphabet.len; + return alphabet[at .. at + 1]; +} - var blurred_commands: [16]canvas.CanvasCommand = undefined; - var blurred_builder = try paintInto(grid, &blurred_commands, .{ - .frame = geometry.RectF.init(0, 0, 100, 40), - .tokens = .{}, - .focused = false, - }); - var blurred_alpha: f32 = 0; - for (blurred_builder.displayList().commands) |command| { - switch (command) { - .stroke_rect => |stroke| { - if (stroke.stroke.fill == .color and stroke.stroke.fill.color.b == blue.b) { - blurred_alpha = stroke.stroke.fill.color.a; - try testing.expectEqual(@as(f32, 1), stroke.stroke.width); - } - }, - else => {}, - } - } - try testing.expectApproxEqAbs(@as(f32, 0.45), blurred_alpha, 0.001); +/// A colour nothing merges with: consecutive indices always differ in +/// the red channel, so neither the background-run nor the text-run +/// merge can join two neighbours. +fn screenColor(index: usize, bias: f32) canvas.Color { + const red_channel: f32 = @floatFromInt(index % 251); + const green_channel: f32 = @floatFromInt((index / 251) % 241); + return canvas.Color.rgba(red_channel / 251.0, green_channel / 241.0, bias, 1); +} - grid.running = false; - var ended_commands: [16]canvas.CanvasCommand = undefined; - var ended_builder = try paintInto(grid, &ended_commands, .{ - .frame = geometry.RectF.init(0, 0, 100, 40), - .tokens = .{}, - .focused = true, - }); - var ended_alpha: f32 = 0; - for (ended_builder.displayList().commands) |command| { - switch (command) { - .stroke_rect => |stroke| { - if (stroke.stroke.fill == .color and stroke.stroke.fill.color.b == blue.b) { - ended_alpha = stroke.stroke.fill.color.a; - try testing.expectEqual(@as(f32, 1), stroke.stroke.width); - } - }, - else => {}, +/// Build a `screen_cols` x `screen_rows` viewport whose every cell +/// carries its own foreground and background — the truecolor worst +/// case, where nothing merges and every cell costs one background +/// command plus one text command. +fn buildTruecolorScreen( + cells: []grid_model.TerminalCell, + rows: []grid_model.TerminalRow, +) void { + for (rows, 0..) |*row, row_index| { + const row_cells = cells[row_index * screen_cols ..][0..screen_cols]; + for (row_cells, 0..) |*entry, column| { + const index = row_index * screen_cols + column; + entry.* = .{ + .cp = screenCluster(index)[0], + .cluster = screenCluster(index), + .fg = screenColor(index, 0.25), + .bg = screenColor(index, 0.75), + }; } + row.* = .{ .cells = row_cells }; } - try testing.expectApproxEqAbs(@as(f32, 0.22), ended_alpha, 0.001); } -test "the terminal widget cursor follows logical focus independently of the outer ring" { - const rows = [_]grid_model.TerminalRow{.{ .cells = &.{} }}; - var grid = baseGrid(&rows); - grid.cursor = .{ .x = 0, .y = 0 }; - const terminal = canvas.Widget{ - .id = 9, - .kind = .terminal, - .frame = geometry.RectF.init(0, 0, 200, 100), - .terminal = .{ .pty = 1, .grid = &grid }, - }; - var nodes: [2]canvas.WidgetLayoutNode = undefined; - const layout = try canvas.layoutWidgetTree(terminal, terminal.frame, &nodes); - const cursor_id = grid_model.paintIdBase(terminal.id) + 0x61_0002; - - // Logical focus fills the cursor even when the modality-specific - // outer ring is quiet. - var focused_commands: [32]canvas.CanvasCommand = undefined; - var focused_builder = canvas.Builder.init(&focused_commands); - try layout.emitDisplayListWithState(&focused_builder, .{}, .{ .focused_id = terminal.id }); - var saw_filled_cursor = false; - var saw_outer_ring = false; - for (focused_builder.displayList().commands) |command| { - switch (command) { - .fill_rect => |fill| if (fill.id == cursor_id) { - saw_filled_cursor = true; - }, - .stroke_rect => |stroke| if (stroke.id != cursor_id) { - saw_outer_ring = true; - }, - else => {}, - } - } - try testing.expect(saw_filled_cursor); - try testing.expect(!saw_outer_ring); - - var blurred_commands: [32]canvas.CanvasCommand = undefined; - var blurred_builder = canvas.Builder.init(&blurred_commands); - try layout.emitDisplayListWithState(&blurred_builder, .{}, .{ - .keyboard_active = false, - .focused_id = terminal.id, - .focus_visible_id = terminal.id, - }); - var saw_hollow_cursor = false; - var kept_outer_ring = false; - for (blurred_builder.displayList().commands) |command| { - switch (command) { - .stroke_rect => |stroke| if (stroke.id == cursor_id) { - saw_hollow_cursor = true; - } else { - kept_outer_ring = true; - }, - else => {}, +/// The same viewport with REALISTIC styling: a styled span every four +/// columns (50 foreground runs per row) over two background bands — a +/// heavier read of syntax-highlighted source, an htop meter row, or a +/// colored build log, at ~52 commands per row. +fn buildStyledScreen( + cells: []grid_model.TerminalCell, + rows: []grid_model.TerminalRow, +) void { + for (rows, 0..) |*row, row_index| { + const row_cells = cells[row_index * screen_cols ..][0..screen_cols]; + for (row_cells, 0..) |*entry, column| { + const index = row_index * screen_cols + column; + entry.* = .{ + .cp = screenCluster(index)[0], + .cluster = screenCluster(index), + .fg = screenColor(column / 4, 0.25), + .bg = if (column < 24) screenColor(row_index, 0.75) else null, + }; } + row.* = .{ .cells = row_cells }; } - try testing.expect(saw_hollow_cursor); - // Key-window/app activity gates the terminal's ownership cue only; - // retained focus-visible chrome keeps its normal restoration state. - try testing.expect(kept_outer_ring); } -test "the scrollback thumb paints only while the viewport is in history" { - const rows = [_]grid_model.TerminalRow{.{ .cells = &.{} }}; - var grid = baseGrid(&rows); +test "a truecolor 200x60 screen paints every row" { + // The measurement this whole primitive exists for. Every cell + // carries its own foreground AND background, so nothing merges — + // the shape that cost ~400 display-list commands per row and + // painted 9 rows of 60 against a 4,096-command budget. + // + // As a packed lattice it is ONE command and 12,000 cells, and every + // row paints. + const cells = try testing.allocator.alloc(grid_model.TerminalCell, screen_cols * screen_rows); + defer testing.allocator.free(cells); + const rows = try testing.allocator.alloc(grid_model.TerminalRow, screen_rows); + defer testing.allocator.free(rows); + buildTruecolorScreen(cells, rows); + + const commands = try testing.allocator.alloc(canvas.CanvasCommand, canvas.max_display_list_commands); + defer testing.allocator.free(commands); + var builder = canvas.Builder.init(commands); + const options = widgetPaintOptions(geometry.RectF.init(0, 0, 1600, 1200)); + const report = try grid_model.paintReport(baseGrid(rows), &builder, options); + const list = builder.displayList(); + const grid = firstCellGrid(list) orelse return error.TestExpectedCellGrid; + + std.debug.print( + "\n[terminal] truecolor {d}x{d}: {d}/{d} rows painted, {d} commands, {d} cells, {d} text bytes\n", + .{ + screen_cols, screen_rows, + report.rows_painted, report.rows_total, + builder.len, grid.cells.len, + builder.text_byte_len, + }, + ); - // Pinned to the bottom: no thumb. - grid.scrollbar = .{ .offset = 76, .len = 24, .total = 100 }; - var pinned_commands: [16]canvas.CanvasCommand = undefined; - var pinned = try paintInto(grid, &pinned_commands, .{ - .frame = geometry.RectF.init(0, 0, 100, 200), - .tokens = .{}, - }); - const pinned_count = pinned.displayList().commands.len; + try testing.expectEqual(screen_rows, report.rows_painted); + try testing.expect(report.stopped_by == null); + try testing.expect(!report.truncated()); + try testing.expect(builder.degradation == null); + // One command per ROW plus the surface fill and the clip pair: the + // count scales with rows, never with styling. + try testing.expectEqual(screen_rows, gridPaintedRows(list)); + try testing.expect(builder.len <= screen_rows + 8); + try testing.expectEqual(@as(u16, screen_cols), grid.cols); + try testing.expectEqual(@as(u16, 1), grid.rows); + try testing.expectEqual(screen_cols, grid.cells.len); + // Every cell kept its own colours: nothing merged, nothing was lost. + const first = grid.at(0, 0) orelse return error.TestExpectedCell; + const second = grid.at(1, 0) orelse return error.TestExpectedCell; + try testing.expect(!first.fg.eql(second.fg)); + try testing.expect(first.style().has_background); + // The cluster blob INTERNED: 12,000 cells drawn from a 36-character + // alphabet cost 36 bytes, not 12,000. + try testing.expect(grid.text.len <= 64); +} - // Scrolled into history: exactly one extra fill (the thumb). - grid.scrollbar = .{ .offset = 10, .len = 24, .total = 100 }; - var history_commands: [16]canvas.CanvasCommand = undefined; - var history = try paintInto(grid, &history_commands, .{ - .frame = geometry.RectF.init(0, 0, 100, 200), - .tokens = .{}, - }); - try testing.expectEqual(pinned_count + 1, history.displayList().commands.len); +test "a realistically styled 200x60 screen paints every row" { + // The case the budget must actually carry: 50 foreground runs and a + // couple of background runs per row — syntax-highlighted source, + // htop, a colored build log. At the old 2,048-command ceiling this + // screen painted 34 of its 60 rows; the whole point of the raise is + // that it now paints all 60. + const cells = try testing.allocator.alloc(grid_model.TerminalCell, screen_cols * screen_rows); + defer testing.allocator.free(cells); + const rows = try testing.allocator.alloc(grid_model.TerminalRow, screen_rows); + defer testing.allocator.free(rows); + buildStyledScreen(cells, rows); + + const commands = try testing.allocator.alloc(canvas.CanvasCommand, canvas.max_display_list_commands); + defer testing.allocator.free(commands); + var builder = canvas.Builder.init(commands); + const options = widgetPaintOptions(geometry.RectF.init(0, 0, 1600, 1200)); + const report = try grid_model.paintReport(baseGrid(rows), &builder, options); + + std.debug.print( + "\n[terminal] styled {d}x{d}: {d}/{d} rows painted, {d} commands, {d} text bytes\n", + .{ screen_cols, screen_rows, report.rows_painted, report.rows_total, builder.len, builder.text_byte_len }, + ); + + try testing.expectEqual(screen_rows, report.rows_painted); + try testing.expectEqual(screen_rows, gridPaintedRows(builder.displayList())); + try testing.expect(report.stopped_by == null); + try testing.expect(!report.truncated()); + // A complete paint leaves no degradation note behind. + try testing.expect(builder.degradation == null); } -test "the command budget degrades row-wise, never overflowing the frame" { - // Three rows of alternating colors (no run merging), tight budget. - var cells: [3][8]grid_model.TerminalCell = undefined; - for (&cells) |*row_cells| { - for (row_cells, 0..) |*entry, index| { - entry.* = cell('x', "x", if (index % 2 == 0) white else red); - entry.bg = if (index % 2 == 0) blue else null; - } - } - const rows = [_]grid_model.TerminalRow{ - .{ .cells = &cells[0] }, - .{ .cells = &cells[1] }, - .{ .cells = &cells[2] }, - }; +test "a viewport taller than its frame is clipped, not truncated" { + // Rows below the frame's bottom edge have nowhere to paint. That is + // a viewport ending, not a budget eating content, and it must not + // raise the degradation alarm. + const row_a = comptime asciiRow("first", white); + const row_b = comptime asciiRow("second", white); + const rows = [_]grid_model.TerminalRow{ .{ .cells = &row_a }, .{ .cells = &row_b } }; - var commands: [512]canvas.CanvasCommand = undefined; - var builder = try paintInto(baseGrid(&rows), &commands, .{ - .frame = geometry.RectF.init(0, 0, 400, 200), + var commands: [64]canvas.CanvasCommand = undefined; + var builder = canvas.Builder.init(&commands); + const metrics = grid_model.cellMetrics(canvas.DesignTokens{}); + const report = try grid_model.paintReport(baseGrid(&rows), &builder, .{ + // One cell tall: the second row starts past the bottom. + .frame = geometry.RectF.init(0, 0, 400, metrics.height), .tokens = .{}, - .command_budget = 60, + .id_base = 1, }); - // Never exceeds the budget; the later rows dropped whole. - try testing.expect(builder.displayList().commands.len <= 60); + + try testing.expectEqual(@as(usize, 1), report.rows_painted); + try testing.expectEqual(grid_model.TerminalPaintStop.viewport, report.stopped_by.?); + try testing.expect(!report.truncated()); + try testing.expect(builder.degradation == null); } -test "the text budget stops before a row it cannot hold whole" { +test "a text-budget stop names the text store on the builder" { + // The other cliff a wide screen can hit: the frame's text store, + // shared with every other widget's glyphs. It must report as text, + // not as commands — an author who reads "commands" would tune the + // wrong knob. const row_a = comptime asciiRow("aaaa", white); const row_b = comptime asciiRow("bbbb", white); - const rows = [_]grid_model.TerminalRow{ - .{ .cells = &row_a }, - .{ .cells = &row_b }, - }; + const rows = [_]grid_model.TerminalRow{ .{ .cells = &row_a }, .{ .cells = &row_b } }; var commands: [64]canvas.CanvasCommand = undefined; - var builder = try paintInto(baseGrid(&rows), &commands, .{ + var builder = canvas.Builder.init(&commands); + const report = try grid_model.paintReport(baseGrid(&rows), &builder, .{ .frame = geometry.RectF.init(0, 0, 400, 200), .tokens = .{}, - // Reserve all but 6 bytes of the store: row a (4 bytes) fits, - // row b would cross, so it drops whole. - .text_reserve = canvas.max_display_list_text_bytes - 6, + .id_base = 7, + // Room for row a's interned 'a' and nothing more. + .text_reserve = canvas.max_display_list_text_bytes - 1, }); - var texts: usize = 0; - var saw_a = false; - for (builder.displayList().commands) |command| { - switch (command) { - .draw_text => |text| { - texts += 1; - if (std.mem.eql(u8, text.text, "aaaa")) saw_a = true; - }, - else => {}, - } - } - try testing.expectEqual(@as(usize, 1), texts); - try testing.expect(saw_a); + + try testing.expectEqual(@as(usize, 1), report.rows_painted); + try testing.expectEqual(grid_model.TerminalPaintStop.text_bytes, report.stopped_by.?); + const note = builder.degradation orelse return error.TestExpectedDegradationNote; + try testing.expectEqual(canvas.DisplayListStore.text_bytes, note.store); + try testing.expectEqual(@as(u64, 7), note.id); + try testing.expectEqual(@as(usize, 1), note.produced); + try testing.expectEqual(@as(usize, 2), note.requested); } -test "the glyph budget stops before the row whose new code points cross it" { - // Row a introduces 4 distinct glyphs, row b 4 more. - const row_a = comptime asciiRow("abcd", white); - const row_b = comptime asciiRow("efgh", white); - const rows = [_]grid_model.TerminalRow{ - .{ .cells = &row_a }, - .{ .cells = &row_b }, - }; +test "a grid that cannot seat its prologue reports painting nothing" { + // The loudest degradation: a builder so full the grid cannot even + // lay down its background. Silence here reads as "the terminal + // widget is broken". + const row = comptime asciiRow("row", white); + const rows = [_]grid_model.TerminalRow{.{ .cells = &row }}; var commands: [64]canvas.CanvasCommand = undefined; - var builder = try paintInto(baseGrid(&rows), &commands, .{ + var builder = canvas.Builder.init(&commands); + const report = try grid_model.paintReport(baseGrid(&rows), &builder, .{ .frame = geometry.RectF.init(0, 0, 400, 200), .tokens = .{}, - // Atlas-entry units: each new code point charges four subpixel - // variants, so row a (4 cps = 16 entries) fits a 24-entry budget - // and row b (16 more) crosses it. - .glyph_budget = 24, + .id_base = 3, + // Under the painter's own fixed prologue/epilogue overhead. + .command_budget = 4, }); - var saw_a = false; - var saw_b = false; - for (builder.displayList().commands) |command| { - switch (command) { - .draw_text => |text| { - if (std.mem.eql(u8, text.text, "abcd")) saw_a = true; - if (std.mem.eql(u8, text.text, "efgh")) saw_b = true; - }, - else => {}, - } - } - try testing.expect(saw_a); - try testing.expect(!saw_b); -} -test "a wide row of mergeable box glyphs paints under the widget budget" { - // 320 identical `─` cells merge to ONE geometry command, so the cost - // estimate must not charge nine per column and skip the row. - var box_cells: [320]grid_model.TerminalCell = undefined; - for (&box_cells) |*c| c.* = .{ .cp = 0x2500, .fg = white }; - const rows = [_]grid_model.TerminalRow{.{ .cells = &box_cells }}; + try testing.expectEqual(@as(usize, 0), report.rows_painted); + try testing.expectEqual(@as(usize, 0), builder.len); + try testing.expectEqual(grid_model.TerminalPaintStop.commands, report.stopped_by.?); + const note = builder.degradation orelse return error.TestExpectedDegradationNote; + try testing.expectEqual(@as(usize, 0), note.produced); + try testing.expectEqual(@as(usize, 1), note.requested); +} - var commands: [2048]canvas.CanvasCommand = undefined; - var builder = try paintInto(baseGrid(&rows), &commands, .{ - .frame = geometry.RectF.init(0, 0, 2600, 40), +test "widening a pane dirties the columns it reveals" { + // The stale-column bug's display-list tier: a pane painted wide, + // repainted narrow (a split opening), then wide again (the split + // collapsing). The columns the narrow frame hid must come back + // BLANK, which means the narrow -> wide diff has to name them dirty + // — a repaint bounded by the narrow pane's right edge leaves the old + // wide frame's glyphs standing exactly where they were. + // + // The painter itself is stateless (it rebuilds the list every + // frame), so what this pins is that the rebuilt list's diff covers + // the revealed region. The retained PACKET tier has its own hole + // here — it erases clips entirely — and its own regression test + // (runtime/canvas_frame_patch_tests.zig). + const wide_cells = comptime asciiRow("phalls-Mac-mini ~ %", white); + const narrow_cells = comptime asciiRow("phalls-Mac-min", white); + const wide_rows = [_]grid_model.TerminalRow{ .{ .cells = &wide_cells }, .{ .cells = &wide_cells } }; + const narrow_rows = [_]grid_model.TerminalRow{ .{ .cells = &narrow_cells }, .{} }; + + const metrics = grid_model.cellMetrics(canvas.DesignTokens{}); + const wide_frame = geometry.RectF.init(0, 0, metrics.width * 40, metrics.height * 2); + const narrow_frame = geometry.RectF.init(0, 0, metrics.width * 20, metrics.height * 2); + + var wide_commands: [128]canvas.CanvasCommand = undefined; + var wide_builder = canvas.Builder.init(&wide_commands); + try paintInto(baseGrid(&wide_rows), &wide_builder, .{ + .frame = wide_frame, + .tokens = .{}, + .id_base = 11, + }); + var narrow_commands: [128]canvas.CanvasCommand = undefined; + var narrow_builder = canvas.Builder.init(&narrow_commands); + try paintInto(baseGrid(&narrow_rows), &narrow_builder, .{ + .frame = narrow_frame, .tokens = .{}, - .command_budget = 1792, + .id_base = 11, }); - var box_fills: usize = 0; - for (builder.displayList().commands) |command| { - if (command == .fill_rect) box_fills += 1; + + // The frame that reveals the hidden columns: narrow list -> wide list. + var changes: [512]canvas.DiffChange = undefined; + const reveal = try canvas.DisplayList.diff( + narrow_builder.displayList(), + wide_builder.displayList(), + &changes, + ); + try testing.expect(reveal.len > 0); + var dirty: ?geometry.RectF = null; + for (reveal) |change| { + const bounds = change.dirty_bounds orelse continue; + dirty = if (dirty) |current| geometry.RectF.unionWith(current, bounds) else bounds; } - // Background + the single merged bar (+ no wash: no selection). The - // row painted rather than being skipped for a bogus 2,880 estimate. - try testing.expect(box_fills >= 2); + const revealed = dirty orelse return error.TestExpectedDirtyBounds; + // Every column the narrow pane hid is inside the repaint. + try testing.expect(revealed.x <= narrow_frame.width); + try testing.expect(revealed.x + revealed.width >= wide_frame.width); + + // ...and nothing from the narrow frame survives into the wide list: + // both rows carry ink again, not the blank second row the narrow + // pane left behind. + try testing.expectEqual(@as(usize, 2), gridPaintedRows(wide_builder.displayList())); + var wide_text: [64]u8 = undefined; + try testing.expectEqualStrings("phalls-Mac-mini~%", gridRowText(wide_builder.displayList(), 1, &wide_text)); } -test "the text preflight counts referenced sibling text already in the list" { - // A prior widget's REFERENCED draw_text (not builder-owned) counts - // against the per-view text budget, so the grid must degrade against - // it. Pre-emit a referenced-text command consuming most of the view - // budget, then a terminal row that would cross the ceiling drops. - const filler = "x" ** (canvas.max_display_list_text_bytes - 4); - const row = comptime asciiRow("cells", white); +test "resetting a builder clears a previous frame's degradation note" { + const row = comptime asciiRow("row", white); const rows = [_]grid_model.TerminalRow{.{ .cells = &row }}; var commands: [64]canvas.CanvasCommand = undefined; var builder = canvas.Builder.init(&commands); - // Referenced text (a slice not from allocTextBytes), as a sibling - // text widget emits. - try builder.drawText(.{ .id = 1, .font_id = 2, .size = 12, .origin = geometry.PointF.init(0, 0), .color = white, .text = filler }); - try testing.expectEqual(@as(usize, 0), builder.text_byte_len); // referenced, not builder-owned - try grid_model.paint(baseGrid(&rows), &builder, .{ - .frame = geometry.RectF.init(0, 0, 400, 100), + .frame = geometry.RectF.init(0, 0, 400, 200), .tokens = .{}, + .id_base = 3, + .command_budget = 4, }); - // Only 4 bytes of headroom remained, so the 5-byte row dropped: no - // grid text command, and the frame's total text stays within budget. - for (builder.displayList().commands) |command| { - switch (command) { - .draw_text => |t| try testing.expect(!std.mem.eql(u8, t.text, "cells")), - else => {}, + try testing.expect(builder.degradation != null); + builder.reset(); + try testing.expect(builder.degradation == null); +} + +// ------------------------------------------------ the packed primitive +// +// The cell grid is a CanvasCommand like any other, so it owes the same +// four contracts every command owes: equality, a content fingerprint, +// retained diffing, and a renderer that draws it. These pin all four. + +const wide_cols: usize = 300; +const wide_rows_count: usize = 100; + +/// A 300x100 viewport with a distinct truecolor foreground AND +/// background on every one of its 30,000 cells — the density the +/// display-list model could not express at any budget. +fn buildWideTruecolorScreen( + cells: []grid_model.TerminalCell, + rows: []grid_model.TerminalRow, +) void { + for (rows, 0..) |*row, row_index| { + const row_cells = cells[row_index * wide_cols ..][0..wide_cols]; + for (row_cells, 0..) |*entry, column| { + const index = row_index * wide_cols + column; + entry.* = .{ + .cp = screenCluster(index)[0], + .cluster = screenCluster(index), + .fg = screenColor(index, 0.25), + .bg = screenColor(index, 0.75), + }; } + row.* = .{ .cells = row_cells }; } } -test "the prologue budget check accounts for commands already in the builder" { - // A builder already near an absolute command ceiling must degrade to - // nothing rather than emit the prologue and overrun. Pre-fill the - // builder, then paint with a budget at the current length. - const rows = [_]grid_model.TerminalRow{.{ .cells = &.{} }}; - var commands: [16]canvas.CanvasCommand = undefined; - var builder = canvas.Builder.init(&commands); - for (0..10) |i| { - try builder.fillRect(.{ .id = 1000 + i, .rect = geometry.RectF.init(0, 0, 1, 1), .fill = .{ .color = white } }); - } - const before = builder.len; - try grid_model.paint(baseGrid(&rows), &builder, .{ - .frame = geometry.RectF.init(0, 0, 100, 40), +test "a 300x100 truecolor screen paints every row as one command" { + // The requirement the primitive exists to meet. + const cells = try testing.allocator.alloc(grid_model.TerminalCell, wide_cols * wide_rows_count); + defer testing.allocator.free(cells); + const rows = try testing.allocator.alloc(grid_model.TerminalRow, wide_rows_count); + defer testing.allocator.free(rows); + buildWideTruecolorScreen(cells, rows); + + const commands = try testing.allocator.alloc(canvas.CanvasCommand, canvas.max_display_list_commands); + defer testing.allocator.free(commands); + var builder = canvas.Builder.init(commands); + // The direct-painter tier (a host that owns its own builder): the + // whole cell store, no split reserve. + const report = try grid_model.paintReport(baseGrid(rows), &builder, .{ + .frame = geometry.RectF.init(0, 0, 2400, 2000), .tokens = .{}, - // No room for the prologue above the already-consumed commands. - .command_budget = before + 2, + .id_base = 1, + .command_budget = canvas.max_display_list_commands - grid_model.widget_command_reserve, + .text_reserve = grid_model.widget_text_reserve, + .glyph_budget = grid_model.widget_glyph_budget, }); - // Nothing emitted (and no unbalanced clip): the len is unchanged. - try testing.expectEqual(before, builder.len); + const list = builder.displayList(); + const grid = firstCellGrid(list) orelse return error.TestExpectedCellGrid; + + std.debug.print( + "\n[terminal] truecolor {d}x{d}: {d}/{d} rows painted, {d} commands, {d} cells ({d} KB), {d} text bytes\n", + .{ + wide_cols, wide_rows_count, + report.rows_painted, report.rows_total, + builder.len, grid.cells.len, + (grid.cells.len * @sizeOf(canvas.Cell)) / 1024, builder.text_byte_len, + }, + ); + + try testing.expectEqual(wide_rows_count, report.rows_painted); + try testing.expect(report.stopped_by == null); + try testing.expect(builder.degradation == null); + try testing.expectEqual(@as(u16, wide_cols), grid.cols); + try testing.expectEqual(@as(u16, 1), grid.rows); + try testing.expectEqual(wide_cols, grid.cells.len); + try testing.expectEqual(wide_rows_count, gridPaintedRows(list)); + // Memory is linear in cells and nothing else: 30,000 cells at 20 B. + try testing.expectEqual(@as(usize, 20), @sizeOf(canvas.Cell)); + try testing.expect(wide_cols * wide_rows_count * @sizeOf(canvas.Cell) <= 640 * 1024); + // Commands scale with ROWS, not with styling: one grid per row plus + // the surface fill and the clip pair. + try testing.expect(builder.len <= wide_rows_count + 8); } -test "id_base near the u64 ceiling wraps instead of trapping" { - // paintIdBase spans the full u64 space, so every emitted id offset - // must wrap. An id_base whose spread lands near maxInt paints a - // nonempty row without an overflow trap in safe builds. +test "the grid carries every SGR attribute the producer resolves" { + // Six attributes the producer used to discard because the display + // list had nowhere to put them. const cells = [_]grid_model.TerminalCell{ - cell('x', "x", white), - .{ .cp = 0x256C, .fg = white }, // ╬ — the eight-command joint - .{ .cp = 0x256D, .fg = white }, // ╭ — a rounded corner (path elements) + .{ .cp = 'a', .cluster = "a", .fg = white, .bold = true }, + .{ .cp = 'b', .cluster = "b", .fg = white, .italic = true }, + .{ .cp = 'c', .cluster = "c", .fg = white, .strikethrough = true }, + .{ .cp = 'd', .cluster = "d", .fg = white, .overline = true }, + .{ .cp = 'e', .cluster = "e", .fg = white, .underline = true, .underline_style = .curly }, + .{ .cp = 'f', .cluster = "f", .fg = white, .underline = true, .underline_style = .dashed, .underline_color = red }, }; - const rows = [_]grid_model.TerminalRow{.{ .cells = &cells, .selection = .{ 0, 2 } }}; - var grid = baseGrid(&rows); - grid.cursor = .{ .x = 0, .y = 0 }; - grid.select_head = .{ .x = 0, .y = 0 }; + const rows = [_]grid_model.TerminalRow{.{ .cells = &cells }}; - var commands: [128]canvas.CanvasCommand = undefined; + var commands: [64]canvas.CanvasCommand = undefined; var builder = canvas.Builder.init(&commands); - // Any id_base is legal; the painter must not trap on the wrap. - try grid_model.paint(grid, &builder, .{ - .frame = geometry.RectF.init(0, 0, 120, 40), + try paintInto(baseGrid(&rows), &builder, .{ + .frame = geometry.RectF.init(0, 0, 400, 100), .tokens = .{}, - .id_base = 0xffff_ffff_ffff_ffff, }); - try testing.expect(builder.displayList().commands.len > 0); + const list = builder.displayList(); + + try testing.expect((gridCell(list, 0, 0).?).style().bold); + try testing.expect((gridCell(list, 1, 0).?).style().italic); + try testing.expect((gridCell(list, 2, 0).?).style().strikethrough); + try testing.expect((gridCell(list, 3, 0).?).style().overline); + try testing.expectEqual(canvas.CellUnderline.curly, (gridCell(list, 4, 0).?).style().underline); + + const colored = gridCell(list, 5, 0).?; + try testing.expectEqual(canvas.CellUnderline.dashed, colored.style().underline); + try testing.expect(colored.style().has_underline_color); + try testing.expectEqual(canvas.CellColor.fromColor(red), colored.underline_color); + + // A cell with no underline declared carries none, whatever style it + // names: the flag and the style are one field. + try testing.expectEqual(canvas.CellUnderline.none, (gridCell(list, 0, 0).?).style().underline); } -test "an underlined merged double run stays within the command budget" { - // A merged ═ paints TWO bars plus its underline; the cost estimate - // must charge all three or a row of unmergeable underlined doubles - // (alternating colors break every merge) overruns the ceiling. - var cells: [320]grid_model.TerminalCell = undefined; - for (&cells, 0..) |*c, i| { - c.* = .{ .cp = 0x2550, .fg = if (i % 2 == 0) white else red, .underline = true }; - } - const rows = [_]grid_model.TerminalRow{ - .{ .cells = &cells }, - .{ .cells = &cells }, - }; +test "two grids of the same screen are equal; one changed cell is not" { + const row_a = comptime asciiRow("hello", white); + var row_b = row_a; + const rows_a = [_]grid_model.TerminalRow{.{ .cells = &row_a }}; - var commands: [2048]canvas.CanvasCommand = undefined; - var builder = try paintInto(baseGrid(&rows), &commands, .{ - .frame = geometry.RectF.init(0, 0, 2600, 80), + var commands_a: [64]canvas.CanvasCommand = undefined; + var builder_a = canvas.Builder.init(&commands_a); + try paintInto(baseGrid(&rows_a), &builder_a, .{ + .frame = geometry.RectF.init(0, 0, 400, 100), .tokens = .{}, - .command_budget = 1000, + .id_base = 3, }); - // Whatever painted, the ceiling held. - try testing.expect(builder.displayList().commands.len <= 1000); -} -test "an anonymous grid keeps the unkeyed convention: every command id 0" { - const cells = [_]grid_model.TerminalCell{ - cell('x', "x", white), - .{ .cp = 0x256C, .fg = white }, // ╬ - .{ .cp = 0x2596, .fg = white }, // quadrant - .{ .cp = 0x256D, .fg = white }, // rounded corner - }; - const rows = [_]grid_model.TerminalRow{.{ .cells = &cells, .selection = .{ 0, 1 } }}; - var grid = baseGrid(&rows); - grid.cursor = .{ .x = 0, .y = 0 }; + var commands_b: [64]canvas.CanvasCommand = undefined; + var builder_b = canvas.Builder.init(&commands_b); + try paintInto(baseGrid(&rows_a), &builder_b, .{ + .frame = geometry.RectF.init(0, 0, 400, 100), + .tokens = .{}, + .id_base = 3, + }); - var commands: [128]canvas.CanvasCommand = undefined; - var builder = try paintInto(grid, &commands, .{ - .frame = geometry.RectF.init(0, 0, 200, 40), + const grid_a = firstCellGrid(builder_a.displayList()) orelse return error.TestExpectedCellGrid; + const grid_b = firstCellGrid(builder_b.displayList()) orelse return error.TestExpectedCellGrid; + try testing.expect(equality_model.commandsEqual(.{ .cell_grid = grid_a }, .{ .cell_grid = grid_b })); + + // One recolored cell breaks equality — the diff must see a screen + // that changed, whatever changed in it. + row_b[2].fg = red; + const rows_b = [_]grid_model.TerminalRow{.{ .cells = &row_b }}; + var commands_c: [64]canvas.CanvasCommand = undefined; + var builder_c = canvas.Builder.init(&commands_c); + try paintInto(baseGrid(&rows_b), &builder_c, .{ + .frame = geometry.RectF.init(0, 0, 400, 100), .tokens = .{}, - .id_base = 0, + .id_base = 3, }); - for (builder.displayList().commands) |command| { - const id = switch (command) { - .fill_rect => |c| c.id, - .stroke_rect => |c| c.id, - .draw_text => |c| c.id, - .push_clip => |c| c.id, - .stroke_path => |c| c.id, - .draw_line => |c| c.id, - else => continue, - }; - try testing.expectEqual(@as(canvas.ObjectId, 0), id); - } + const grid_c = firstCellGrid(builder_c.displayList()) orelse return error.TestExpectedCellGrid; + try testing.expect(!equality_model.commandsEqual(.{ .cell_grid = grid_a }, .{ .cell_grid = grid_c })); } -test "two keyed grids in one builder never share a nonzero command id" { - const row_cells = comptime asciiRow("ab", white); - const rows = [_]grid_model.TerminalRow{.{ .cells = &row_cells }}; - var grid = baseGrid(&rows); - grid.cursor = .{ .x = 0, .y = 0 }; +test "a grid's fingerprint is stable for identical content and moves for any cell" { + const row = comptime asciiRow("hello", white); + var changed = row; + const rows = [_]grid_model.TerminalRow{.{ .cells = &row }}; - var commands: [128]canvas.CanvasCommand = undefined; - var builder = canvas.Builder.init(&commands); - // The adversarial pair: id B chosen so B's base lands inside A's - // old wide-offset namespace. Under the strided scheme both stay - // disjoint by construction. - try grid_model.paint(grid, &builder, .{ .frame = geometry.RectF.init(0, 0, 100, 40), .tokens = .{}, .id_base = 1 }); - try grid_model.paint(grid, &builder, .{ .frame = geometry.RectF.init(0, 60, 100, 40), .tokens = .{}, .id_base = 0x64dd_ccf4_0000_0001 }); - var seen: std.ArrayListUnmanaged(canvas.ObjectId) = .empty; - defer seen.deinit(testing.allocator); - for (builder.displayList().commands) |command| { - const id = switch (command) { - .fill_rect => |c| c.id, - .stroke_rect => |c| c.id, - .draw_text => |c| c.id, - .push_clip => |c| c.id, - else => continue, - }; - if (id == 0) continue; - for (seen.items) |prior| try testing.expect(prior != id); - try seen.append(testing.allocator, id); - } + var commands_a: [64]canvas.CanvasCommand = undefined; + var builder_a = canvas.Builder.init(&commands_a); + try paintInto(baseGrid(&rows), &builder_a, .{ .frame = geometry.RectF.init(0, 0, 400, 100), .tokens = .{}, .id_base = 5 }); + var commands_b: [64]canvas.CanvasCommand = undefined; + var builder_b = canvas.Builder.init(&commands_b); + try paintInto(baseGrid(&rows), &builder_b, .{ .frame = geometry.RectF.init(0, 0, 400, 100), .tokens = .{}, .id_base = 5 }); + + const grid_a = firstCellGrid(builder_a.displayList()) orelse return error.TestExpectedCellGrid; + const grid_b = firstCellGrid(builder_b.displayList()) orelse return error.TestExpectedCellGrid; + // Stable: the retained patch path re-encodes only what changed, so a + // fingerprint that drifted between identical frames would re-upload + // the whole screen every frame. + try testing.expectEqual(canvas.cellGridFingerprint(grid_a), canvas.cellGridFingerprint(grid_b)); + + // ...and sensitive: a single cell's background must move it, or a + // patch would skip a screen that visibly changed. + changed[1].bg = blue; + const changed_rows = [_]grid_model.TerminalRow{.{ .cells = &changed }}; + var commands_c: [64]canvas.CanvasCommand = undefined; + var builder_c = canvas.Builder.init(&commands_c); + try paintInto(baseGrid(&changed_rows), &builder_c, .{ .frame = geometry.RectF.init(0, 0, 400, 100), .tokens = .{}, .id_base = 5 }); + const grid_c = firstCellGrid(builder_c.displayList()) orelse return error.TestExpectedCellGrid; + try testing.expect(canvas.cellGridFingerprint(grid_a) != canvas.cellGridFingerprint(grid_c)); } -test "pure-double corners draw nested L joins, never through-bars" { - const cells = [_]grid_model.TerminalCell{.{ .cp = 0x2554, .fg = white }}; // ╔ - const rows = [_]grid_model.TerminalRow{.{ .cells = &cells }}; - - var commands: [32]canvas.CanvasCommand = undefined; - var builder = try paintInto(baseGrid(&rows), &commands, .{ - .frame = geometry.RectF.init(0, 0, 40, 40), - .tokens = .{}, - }); - // Background + exactly four bars (two nested Ls). - var bar_fills: usize = 0; - var min_x: f32 = 1000; - var min_y: f32 = 1000; - for (builder.displayList().commands) |command| { - switch (command) { - .fill_rect => |fill| { - // Skip the full-bleed background. - if (fill.rect.width >= 40) continue; - bar_fills += 1; - min_x = @min(min_x, fill.rect.x); - min_y = @min(min_y, fill.rect.y); - }, - else => {}, - } +test "the retained diff replaces a changed screen wholesale" { + // The reflow-safety property: a screen is ONE key, so a row that + // loses content cannot leave an orphaned per-run command behind. + const before = comptime asciiRow("phalls-Mac-mini ~ %", white); + const after = comptime asciiRow("phalls-Mac-min", white); + const rows_before = [_]grid_model.TerminalRow{.{ .cells = &before }}; + const rows_after = [_]grid_model.TerminalRow{.{ .cells = &after }}; + + var commands_a: [64]canvas.CanvasCommand = undefined; + var builder_a = canvas.Builder.init(&commands_a); + try paintInto(baseGrid(&rows_before), &builder_a, .{ .frame = geometry.RectF.init(0, 0, 400, 100), .tokens = .{}, .id_base = 9 }); + var commands_b: [64]canvas.CanvasCommand = undefined; + var builder_b = canvas.Builder.init(&commands_b); + try paintInto(baseGrid(&rows_after), &builder_b, .{ .frame = geometry.RectF.init(0, 0, 400, 100), .tokens = .{}, .id_base = 9 }); + + var changes: [64]canvas.DiffChange = undefined; + const diff = try canvas.DisplayList.diff(builder_a.displayList(), builder_b.displayList(), &changes); + + // Exactly one CHANGED grid key, and no removals at all: nothing can + // be orphaned because nothing was ever per-run. + var changed_grids: usize = 0; + var removals: usize = 0; + const grid_id = (firstCellGrid(builder_a.displayList()) orelse return error.TestExpectedCellGrid).id; + for (diff) |change| { + if (change.kind == .removed) removals += 1; + if (change.kind == .changed and change.id != null and change.id.? == grid_id) changed_grids += 1; } - try testing.expectEqual(@as(usize, 4), bar_fills); - // ╔ opens down+right: no bar reaches the cell's left or top edge — - // the old through-bars did (their stubs crossed the joint). - try testing.expect(min_x > 0); - try testing.expect(min_y > 0); + try testing.expectEqual(@as(usize, 1), changed_grids); + try testing.expectEqual(@as(usize, 0), removals); } -test "a wide row of one-bar box pieces paints under the widget budget" { - // 198 columns of │ emit one bar each; the cost estimate must charge - // what they paint, not a flat joint worst case that rejects the row. - var cells: [198]grid_model.TerminalCell = undefined; - for (&cells) |*c| c.* = .{ .cp = 0x2502, .fg = white }; +test "golden: the reference renderer inks cell backgrounds, glyphs, and decorations" { + // The correctness ORACLE. Automation screenshots go through this + // renderer, so what it draws is what the app is verified against — + // and any host encoder has to match it. + const cells = [_]grid_model.TerminalCell{ + .{ .cp = 'A', .cluster = "A", .fg = white, .bg = red }, + .{ .cp = 0, .bg = blue }, + .{ .cp = 'B', .cluster = "B", .fg = white, .underline = true, .underline_color = red }, + }; const rows = [_]grid_model.TerminalRow{.{ .cells = &cells }}; - var commands: [2048]canvas.CanvasCommand = undefined; - var builder = try paintInto(baseGrid(&rows), &commands, .{ - .frame = geometry.RectF.init(0, 0, 1700, 40), + var commands: [64]canvas.CanvasCommand = undefined; + var builder = canvas.Builder.init(&commands); + try paintInto(baseGrid(&rows), &builder, .{ + .frame = geometry.RectF.init(0, 0, 120, 40), .tokens = .{}, - .command_budget = 1792, }); - var bars: usize = 0; - for (builder.displayList().commands) |command| { - switch (command) { - .fill_rect => |fill| { - if (fill.rect.width < 40) bars += 1; - }, - else => {}, - } - } - try testing.expectEqual(@as(usize, 198), bars); -} -test "a row adding no text paints even when siblings spent the text share" { - // Earlier widgets already sit past the grid's reserved text share; - // an all-box row ADDS no text, so it must paint — the ceiling bounds - // what the grid adds, never what siblings spent. - const filler = "x" ** (canvas.max_display_list_text_bytes - 100); - var cells: [8]grid_model.TerminalCell = undefined; - for (&cells) |*c| c.* = .{ .cp = 0x2500, .fg = white }; - const rows = [_]grid_model.TerminalRow{.{ .cells = &cells }}; + const width: usize = 120; + const height: usize = 40; + const list = builder.displayList(); + var render_commands: [64]canvas.RenderCommand = undefined; + var render_batches: [64]canvas.RenderBatch = undefined; + var resources: [64]canvas.RenderResource = undefined; + var resource_cache_entries: [64]canvas.RenderResourceCacheEntry = undefined; + var resource_cache_actions: [128]canvas.RenderResourceCacheAction = undefined; + var atlas_glyphs: [256]canvas.GlyphAtlasEntry = undefined; + var changes: [64]canvas.DiffChange = undefined; + const frame = try list.framePlan(null, .{ + .surface_size = geometry.SizeF.init(width, height), + }, .{ + .render_commands = &render_commands, + .render_batches = &render_batches, + .resources = &resources, + .resource_cache_entries = &resource_cache_entries, + .resource_cache_actions = &resource_cache_actions, + .glyph_atlas_entries = &atlas_glyphs, + .changes = &changes, + }); - var commands: [64]canvas.CanvasCommand = undefined; - var builder = canvas.Builder.init(&commands); - try builder.drawText(.{ .id = 1, .font_id = 2, .size = 12, .origin = geometry.PointF.init(0, 0), .color = white, .text = filler }); + const pixels = try testing.allocator.alloc(u8, width * height * 4); + defer testing.allocator.free(pixels); + const surface = try canvas.ReferenceRenderSurface.init(width, height, pixels); + try surface.renderPass(frame.renderPass(), canvas.Color.rgb8(0, 0, 0)); - try grid_model.paint(baseGrid(&rows), &builder, .{ - .frame = geometry.RectF.init(0, 0, 100, 40), - .tokens = .{}, - // The default widget reserve leaves less than the filler already - // consumed; the box row adds zero text and must still paint. - .text_reserve = grid_model.widget_text_reserve, - }); - var box_fills: usize = 0; - for (builder.displayList().commands) |command| { - switch (command) { - .fill_rect => |fill| { - if (fill.rect.width < 100) box_fills += 1; - }, - else => {}, + const metrics = grid_model.cellMetrics(canvas.DesignTokens{}); + const sample = struct { + fn at(px: []const u8, w: usize, x: usize, y: usize) [4]u8 { + const index = (y * w + x) * 4; + return .{ px[index], px[index + 1], px[index + 2], px[index + 3] }; } - } - try testing.expect(box_fills >= 1); -} - -test "a combining-mark cluster paints alone; its neighbor keeps its cell origin" { - // "e" + combining acute in cell 0, "!" in cell 1: the cluster must - // paint as its own run and "!" must start at exactly one cell width - // — a layout that advanced the mark by a full glyph inside a merged - // run would have shifted "!" off the grid. - const cells = [_]grid_model.TerminalCell{ - .{ .cp = 'e', .cluster = "e\u{0301}", .fg = white }, - cell('!', "!", white), }; - const rows = [_]grid_model.TerminalRow{.{ .cells = &cells }}; - var commands: [32]canvas.CanvasCommand = undefined; - var builder = try paintInto(baseGrid(&rows), &commands, .{ - .frame = geometry.RectF.init(0, 0, 100, 40), - .tokens = .{}, - }); - const tokens: canvas.DesignTokens = .{}; - const metrics = grid_model.cellMetrics(tokens); - var saw_cluster = false; - var saw_bang = false; - for (builder.displayList().commands) |command| { - switch (command) { - .draw_text => |text| { - if (std.mem.eql(u8, text.text, "e\u{0301}")) { - saw_cluster = true; - try testing.expectApproxEqAbs(@as(f32, 0), text.origin.x, 0.01); - } - if (std.mem.eql(u8, text.text, "!")) { - saw_bang = true; - try testing.expectApproxEqAbs(metrics.width, text.origin.x, 0.01); - } - }, - else => {}, + // Cell 0's background is red, cell 1's is blue: the lattice inked + // both, including the cell that carries no glyph at all. + const cell_w: usize = @intFromFloat(metrics.width); + const red_px = sample.at(pixels, width, cell_w / 2, 2); + try testing.expect(red_px[0] > 200 and red_px[1] < 60 and red_px[2] < 60); + const blue_px = sample.at(pixels, width, cell_w + cell_w / 2, 2); + try testing.expect(blue_px[2] > 200 and blue_px[0] < 60); + + // Cell 2's underline paints in its own colour, not the foreground: + // a row of white text with a red underline must show red there. + const underline_y: usize = @intFromFloat(metrics.height - canvas.CellDecoration.strokeWidth(metrics.font_size) * 2); + const underline_px = sample.at(pixels, width, cell_w * 2 + cell_w / 2, underline_y); + try testing.expect(underline_px[0] > underline_px[2]); + + // And the glyph itself inked: some pixel inside cell 0 is neither + // its background nor untouched. + var glyph_ink = false; + var y: usize = 0; + while (y < @as(usize, @intFromFloat(metrics.height))) : (y += 1) { + var x: usize = 0; + while (x < cell_w) : (x += 1) { + const px = sample.at(pixels, width, x, y); + if (px[1] > 90 and px[2] > 90) glyph_ink = true; } } - try testing.expect(saw_cluster); - try testing.expect(saw_bang); + try testing.expect(glyph_ink); } -test "the widget diff reports paint damage for a changed bound grid" { - const row_cells = comptime asciiRow("a", white); - const rows = [_]grid_model.TerminalRow{.{ .cells = &row_cells }}; - const grid_a = baseGrid(&rows); - var grid_b = baseGrid(&rows); - grid_b.cursor = .{ .x = 0, .y = 0 }; - - var widget = canvas.Widget{ - .id = 11, - .kind = .terminal, - .frame = geometry.RectF.init(0, 0, 200, 100), - .terminal = .{ .pty = 1, .grid = &grid_a }, +test "the packed row reaches the GPU packet, and one changed row is one upsert" { + // The incremental contract, pinned at the layer that decides it. + // + // A screen is one grid command PER ROW, so the retained diff sees a + // keystroke as one changed key. Measured on the real app at 1100x640 + // this is `present_patch_upserts=1` / `present_patch_bytes=417`; the + // regression this guards is a screen-wide grid (or a screen-wide + // shared text blob), either of which makes every row's fingerprint + // move together and turns a keystroke back into a full re-upload. + const before = comptime asciiRow("ready", white); + var after_cells = before; + after_cells[0].fg = red; + const rows_before = [_]grid_model.TerminalRow{ + .{ .cells = &before }, + .{ .cells = &before }, + .{ .cells = &before }, + }; + const rows_after = [_]grid_model.TerminalRow{ + .{ .cells = &after_cells }, + .{ .cells = &before }, + .{ .cells = &before }, }; - var nodes_a: [4]canvas.WidgetLayoutNode = undefined; - const layout_a = try canvas.layoutWidgetTree(widget, geometry.RectF.init(0, 0, 200, 100), &nodes_a); - widget.terminal.grid = &grid_b; - var nodes_b: [4]canvas.WidgetLayoutNode = undefined; - const layout_b = try canvas.layoutWidgetTree(widget, geometry.RectF.init(0, 0, 200, 100), &nodes_b); - - var output: [8]canvas.WidgetInvalidation = undefined; - const changes = try layout_a.diff(layout_b, &output); - var saw_paint_dirty = false; - for (changes) |change| { - if (change.id == 11 and change.paint_dirty) saw_paint_dirty = true; - } - try testing.expect(saw_paint_dirty); -} - -test "clampGrid trades rows for columns under the cell ceiling" { - const clamped = grid_model.clampGrid(400, 100); - try testing.expectEqual(@as(u16, grid_model.max_cols), clamped.x); - try testing.expect(@as(usize, clamped.x) * @as(usize, clamped.y) <= grid_model.max_cells); - const tiny = grid_model.clampGrid(0, 0); - try testing.expectEqual(@as(u16, 2), tiny.x); - try testing.expectEqual(@as(u16, 2), tiny.y); -} -test "the path reserve holds one maximal filled-line chart series" { - // A 256-point filled line strokes its polyline and fills its area - // (points plus closure vertices): the reserve must cover both, or a - // terminal of rounded corners starves the chart into - // ChartPathElementListFull. - try testing.expect(grid_model.widget_path_reserve >= 2 * canvas.max_chart_points_per_series + 3); -} + var commands_a: [64]canvas.CanvasCommand = undefined; + var builder_a = canvas.Builder.init(&commands_a); + try paintInto(baseGrid(&rows_before), &builder_a, .{ + .frame = geometry.RectF.init(0, 0, 400, 200), + .tokens = .{}, + .id_base = 4, + }); + var commands_b: [64]canvas.CanvasCommand = undefined; + var builder_b = canvas.Builder.init(&commands_b); + try paintInto(baseGrid(&rows_after), &builder_b, .{ + .frame = geometry.RectF.init(0, 0, 400, 200), + .tokens = .{}, + .id_base = 4, + }); -/// A mono face with a WIDER pitch than the estimator's 0.6 em — what -/// macOS resolves the mono id to when Geist Mono is absent (the system -/// monospaced face, 0.618 em). Everything mono measures at this pitch, -/// so cells and runs both derive from it. -const wide_pitch_em: f32 = 0.62; - -fn measureWidePitch(context: ?*anyopaque, font_id: canvas.FontId, size: f32, text: []const u8) f32 { - _ = context; - _ = font_id; - var count: f32 = 0; - var index: usize = 0; - while (index < text.len) : (index += 1) { - if (text[index] & 0xc0 != 0x80) count += 1; + try testing.expectEqual(@as(usize, 3), gridPaintedRows(builder_a.displayList())); + + // Exactly one row's fingerprint moved. The other two must be + // BYTE-identical, which is what lets the packet skip re-encoding + // them — a shared text blob across rows breaks this immediately. + var changed: usize = 0; + var y: usize = 0; + while (y < 3) : (y += 1) { + const row_a = rowCellGrid(builder_a.displayList(), y) orelse return error.TestExpectedCellGrid; + const row_b = rowCellGrid(builder_b.displayList(), y) orelse return error.TestExpectedCellGrid; + if (canvas.cellGridFingerprint(row_a) != canvas.cellGridFingerprint(row_b)) changed += 1; } - return count * size * wide_pitch_em; + try testing.expectEqual(@as(usize, 1), changed); } -const wide_pitch_provider = canvas.TextMeasureProvider{ .measure_fn = measureWidePitch }; - -test "a full-width row's runs declare bounds that cover their ink" { - // The regression: a merged run's raster extent is its own declared - // bounds, and an estimator-only bound (0.6 em) falls short of a - // wider host face's ink — the row's last cell sheared off at the - // widget edge. Cells and bounds must both come from the measurement - // the host inks with. - const tokens = canvas.DesignTokens{ .text_measure = &wide_pitch_provider }; - const metrics = grid_model.cellMetrics(tokens); - try testing.expectApproxEqAbs( - tokens.typography.label_size * wide_pitch_em, - metrics.width, - 0.001, - ); +test "a cell-grid row encodes to the wire and stays compact for plain text" { + // The wire form the AppKit host decodes (serialization.zig v6). A + // row is a delta stream — a tag byte per cell, style repeated only + // when it changes — which is what keeps a one-row patch in the + // hundreds of bytes instead of the kilobytes a raw 20-byte-per-cell + // dump would cost. + var plain: [80]grid_model.TerminalCell = undefined; + for (&plain) |*entry| entry.* = cell('x', "x", white); + const rows = [_]grid_model.TerminalRow{.{ .cells = &plain }}; - const row_cells = comptime asciiRow("0123456789012345678901234567890123456789", white); - const rows = [_]grid_model.TerminalRow{.{ .cells = &row_cells }}; - const frame = geometry.RectF.init( - 0, - 0, - metrics.width * @as(f32, @floatFromInt(row_cells.len)), - metrics.height * 2, - ); + var commands: [32]canvas.CanvasCommand = undefined; + var builder = canvas.Builder.init(&commands); + try paintInto(baseGrid(&rows), &builder, .{ + .frame = geometry.RectF.init(0, 0, 800, 40), + .tokens = .{}, + .id_base = 6, + }); + const list = builder.displayList(); - var commands: [512]canvas.CanvasCommand = undefined; - var builder = try paintInto(baseGrid(&rows), &commands, .{ - .frame = frame, - .tokens = tokens, + var render_commands: [32]canvas.RenderCommand = undefined; + var render_batches: [32]canvas.RenderBatch = undefined; + var resources: [32]canvas.RenderResource = undefined; + var resource_cache_entries: [32]canvas.RenderResourceCacheEntry = undefined; + var resource_cache_actions: [64]canvas.RenderResourceCacheAction = undefined; + var atlas_glyphs: [64]canvas.GlyphAtlasEntry = undefined; + var changes: [32]canvas.DiffChange = undefined; + const frame = try list.framePlan(null, .{ + .surface_size = geometry.SizeF.init(800, 40), + }, .{ + .render_commands = &render_commands, + .render_batches = &render_batches, + .resources = &resources, + .resource_cache_entries = &resource_cache_entries, + .resource_cache_actions = &resource_cache_actions, + .glyph_atlas_entries = &atlas_glyphs, + .changes = &changes, }); - var saw_run = false; - for (builder.displayList().commands) |command| { - switch (command) { - .draw_text => |text| { - saw_run = true; - const bounds = canvas.CanvasCommand{ .draw_text = text }; - const rect = bounds.bounds() orelse return error.MissingTextBounds; - const cells: f32 = @floatFromInt(text.text.len); - const ink_right = text.origin.x + cells * metrics.width; - try testing.expect(rect.x + rect.width >= ink_right); - // The run's own frame has to hold it too — a row that - // fits the grid's columns can never need more width - // than the cells it was measured into. - try testing.expect(ink_right <= frame.x + frame.width + 0.001); - }, - else => {}, - } + // Every command of a terminal frame is representable now — that is + // what returns the view to the retained packet path instead of a + // full-surface CPU upload every frame. + var gpu_commands: [32]canvas.CanvasGpuCommand = undefined; + const packet = try frame.renderPass().gpuPacket(&gpu_commands); + try testing.expect(packet.fullyRepresentable()); + try testing.expectEqual(@as(usize, 0), packet.unsupported_command_count); + + var saw_grid = false; + for (gpu_commands[0..packet.commands.len]) |command| { + if (command.kind != .cell_grid) continue; + saw_grid = true; + const grid = command.cells orelse return error.TestExpectedCellGridPayload; + try testing.expectEqual(@as(u16, 80), grid.cols); + try testing.expectEqual(@as(u16, 1), grid.rows); } - try testing.expect(saw_run); -} - -test "cell metrics derive from the mono face and never collapse" { - const metrics = grid_model.cellMetrics(.{}); - try testing.expect(metrics.width > 0); - try testing.expect(metrics.height >= metrics.font_size); + try testing.expect(saw_grid); + + // The encoded packet holds the whole row well under a kilobyte: 80 + // identically styled cells cost one style block and a tag plus a + // character each. + var buffer: [4096]u8 = undefined; + var writer = std.Io.Writer.fixed(&buffer); + try packet.writeBinary(&writer); + try testing.expect(writer.buffered().len < 1024); } -test "the terminal widget paints its bound grid and the honest empty surface unbound" { - const row_cells = comptime asciiRow("bound", white); - const rows = [_]grid_model.TerminalRow{.{ .cells = &row_cells }}; - const grid = baseGrid(&rows); - - // A realistic per-view builder (the widget path floors a builder at - // or under the reserve to a degrade-only budget). - var commands: [2048]canvas.CanvasCommand = undefined; - var builder = canvas.Builder.init(&commands); - const bound = canvas.Widget{ - .id = 7, - .kind = .terminal, - .frame = geometry.RectF.init(0, 0, 400, 200), - .terminal = .{ .pty = 1, .grid = &grid }, - }; - try canvas.emitWidgetTree(&builder, bound, .{}); - var saw_text = false; - for (builder.displayList().commands) |command| { - switch (command) { - .draw_text => |text| { - if (std.mem.eql(u8, text.text, "bound")) saw_text = true; - }, - else => {}, - } - } - try testing.expect(saw_text); - - // Unbound: one background fill, no text, never a hole. - var empty_commands: [16]canvas.CanvasCommand = undefined; - var empty_builder = canvas.Builder.init(&empty_commands); - const unbound = canvas.Widget{ - .id = 8, - .kind = .terminal, - .frame = geometry.RectF.init(0, 0, 400, 200), - }; - try canvas.emitWidgetTree(&empty_builder, unbound, .{}); - var fills: usize = 0; - var texts: usize = 0; - for (empty_builder.displayList().commands) |command| { - switch (command) { - .fill_rect => fills += 1, - .draw_text => texts += 1, - else => {}, - } +// -------------------------------------------------- bold and italic +// +// SGR weight and slant are INK, never layout. Everything below exists +// to keep it that way: a lattice whose cell rects moved with the face +// would give up the guarantee the whole packed-cell model rests on. + +/// Paint one row of `text` with a style applied to every cell. +fn paintStyledRow( + comptime text: []const u8, + builder: *canvas.Builder, + bold: bool, + italic: bool, + tokens: canvas.DesignTokens, +) !void { + var cells = comptime asciiRow(text, white); + for (&cells) |*entry| { + entry.bold = bold; + entry.italic = italic; } - try testing.expectEqual(@as(usize, 1), fills); - try testing.expectEqual(@as(usize, 0), texts); + const rows = [_]grid_model.TerminalRow{.{ .cells = &cells }}; + try paintInto(baseGrid(&rows), builder, .{ + .frame = geometry.RectF.init(0, 0, 600, 60), + .tokens = tokens, + .id_base = 21, + }); } -test "a wide cheap terminal paints its rows under the widget command budget" { - // 198 columns of plain ASCII across several rows: the per-row cost - // preflight must let these cheap rows paint (a flat worst-case-per- - // column reserve would seat none). Regression for the over-reserve - // that blanked wide grids. - var storage: [6][198]grid_model.TerminalCell = undefined; - for (&storage) |*row_cells| { - for (row_cells) |*entry| entry.* = cell('a', "a", white); +test "a bold or italic row occupies exactly the cell rects a regular row does" { + // The invariant. A bold face has different advances than a regular + // one; in a lattice that must change nothing, because a cell's + // position is its INDEX. + var regular_commands: [32]canvas.CanvasCommand = undefined; + var regular = canvas.Builder.init(®ular_commands); + try paintStyledRow("weight", ®ular, false, false, .{}); + + var bold_commands: [32]canvas.CanvasCommand = undefined; + var bold = canvas.Builder.init(&bold_commands); + try paintStyledRow("weight", &bold, true, false, .{}); + + var italic_commands: [32]canvas.CanvasCommand = undefined; + var italic = canvas.Builder.init(&italic_commands); + try paintStyledRow("weight", &italic, false, true, .{}); + + const regular_grid = firstCellGrid(regular.displayList()) orelse return error.TestExpectedCellGrid; + const bold_grid = firstCellGrid(bold.displayList()) orelse return error.TestExpectedCellGrid; + const italic_grid = firstCellGrid(italic.displayList()) orelse return error.TestExpectedCellGrid; + + try testing.expectEqual(regular_grid.cols, bold_grid.cols); + try testing.expectEqual(regular_grid.cols, italic_grid.cols); + try testing.expectEqual(regular_grid.cell_width, bold_grid.cell_width); + try testing.expectEqual(regular_grid.cell_width, italic_grid.cell_width); + try testing.expectEqual(regular_grid.baseline, bold_grid.baseline); + + // Every cell rect is identical, column by column. + var column: usize = 0; + while (column < regular_grid.cols) : (column += 1) { + try testing.expectEqualDeep(regular_grid.cellRect(column, 0), bold_grid.cellRect(column, 0)); + try testing.expectEqualDeep(regular_grid.cellRect(column, 0), italic_grid.cellRect(column, 0)); } - var rows: [6]grid_model.TerminalRow = undefined; - for (&rows, 0..) |*r, i| r.* = .{ .cells = &storage[i] }; - - var commands: [2048]canvas.CanvasCommand = undefined; - var builder = try paintInto(baseGrid(&rows), &commands, .{ - .frame = geometry.RectF.init(0, 0, 1600, 200), - .tokens = .{}, - .command_budget = 1792, - }); - var text_rows: usize = 0; - for (builder.displayList().commands) |command| { - switch (command) { - .draw_text => |t| if (std.mem.eql(u8, t.text, "a" ** 198)) { - text_rows += 1; - }, - else => {}, - } - } - // Every row's run merges to one draw_text; all six must paint. - try testing.expectEqual(@as(usize, 6), text_rows); + // ...and so is the command's raster extent. + try testing.expectEqualDeep(regular_grid.bounds(), bold_grid.bounds()); + try testing.expectEqualDeep(regular_grid.bounds(), italic_grid.bounds()); + + // The styles DID reach the cells — this is not passing because + // nothing was applied. + try testing.expect((bold_grid.at(0, 0).?).style().bold); + try testing.expect((italic_grid.at(0, 0).?).style().italic); } -test "a focused bound terminal's ring id never collides with a grid command" { - const row_cells = comptime asciiRow("shell", white); - const rows = [_]grid_model.TerminalRow{.{ .cells = &row_cells }}; - const grid = baseGrid(&rows); +test "face selection prefers real companions and synthesizes only what is missing" { + var full = canvas.DesignTokens{}; + full.typography.mono_bold_font_id = 64; + full.typography.mono_italic_font_id = 65; + full.typography.mono_bold_italic_font_id = 66; - var commands: [2048]canvas.CanvasCommand = undefined; + var commands: [32]canvas.CanvasCommand = undefined; var builder = canvas.Builder.init(&commands); - const focused = canvas.Widget{ - // A high-entropy id in the range where the multiplicative spread - // could alias a widgetPartId ring slot. - .id = 0x2af6_89b9_153d_e19a, - .kind = .terminal, - .frame = geometry.RectF.init(0, 0, 400, 200), - .state = .{ .focused = true }, - .terminal = .{ .pty = 1, .grid = &grid }, - }; - try canvas.emitWidgetTree(&builder, focused, .{}); - var ring_id: canvas.ObjectId = 0; - for (builder.displayList().commands) |command| { - if (command == .stroke_rect) ring_id = command.stroke_rect.id; - } - try testing.expect(ring_id != 0); - // No other command shares the ring's id. - var collisions: usize = 0; - for (builder.displayList().commands) |command| { - const id = switch (command) { - .fill_rect => |c| c.id, - .draw_text => |c| c.id, - .push_clip => |c| c.id, - .stroke_path => |c| c.id, - else => continue, - }; - if (id == ring_id) collisions += 1; - } - try testing.expectEqual(@as(usize, 0), collisions); + try paintStyledRow("x", &builder, true, true, full); + const grid = firstCellGrid(builder.displayList()) orelse return error.TestExpectedCellGrid; + + // A complete family: the real bold-italic face, nothing faked. + const chosen = grid.face(.{ .bold = true, .italic = true }); + try testing.expectEqual(@as(canvas.FontId, 66), chosen.font_id); + try testing.expect(!chosen.synthetic_bold); + try testing.expect(!chosen.synthetic_italic); + try testing.expectEqual(@as(canvas.FontId, 64), grid.face(.{ .bold = true }).font_id); + try testing.expectEqual(@as(canvas.FontId, 65), grid.face(.{ .italic = true }).font_id); + try testing.expectEqual(grid.font_id, grid.face(.{}).font_id); + + // A HALF family: real weight, sheared. Better than faking both. + var half = canvas.CellGrid{ .font_id = 2, .bold_font_id = 64 }; + const mixed = half.face(.{ .bold = true, .italic = true }); + try testing.expectEqual(@as(canvas.FontId, 64), mixed.font_id); + try testing.expect(!mixed.synthetic_bold); + try testing.expect(mixed.synthetic_italic); + + // NO family: the regular face, both synthesized — carried and + // visible rather than silently dropped. + var bare = canvas.CellGrid{ .font_id = 2 }; + const faked = bare.face(.{ .bold = true, .italic = true }); + try testing.expectEqual(@as(canvas.FontId, 2), faked.font_id); + try testing.expect(faked.synthetic_bold); + try testing.expect(faked.synthetic_italic); } -test "a focused terminal wears the house focus ring" { - var commands: [16]canvas.CanvasCommand = undefined; - var builder = canvas.Builder.init(&commands); - const focused = canvas.Widget{ - .id = 9, - .kind = .terminal, - .frame = geometry.RectF.init(0, 0, 400, 200), - .state = .{ .focused = true }, +test "an italic glyph's overhang survives the next cell's background" { + // The two-pass order exists for exactly this: a sheared glyph leans + // into its neighbour, and a renderer that filled each cell's + // background just before its glyph would erase the lean. + // + // Rendered for real: an italic 'H' in cell 0 against a bright + // background in cell 1. If the passes ever collapse, the ink that + // crosses the boundary disappears. + var cells = [_]grid_model.TerminalCell{ + .{ .cp = 'H', .cluster = "H", .fg = white, .italic = true }, + .{ .bg = blue }, }; - try canvas.emitWidgetTree(&builder, focused, .{}); - var rings: usize = 0; - for (builder.displayList().commands) |command| { - switch (command) { - .stroke_rect => rings += 1, - else => {}, - } - } - try testing.expectEqual(@as(usize, 1), rings); -} - -test "the terminal widget's register: focusable, press-claiming, I-beam, editable-text role" { - const widget_access = @import("widget_access.zig"); - const widget_semantics = @import("widget_semantics.zig"); - const widget = canvas.Widget{ .id = 3, .kind = .terminal }; - try testing.expect(canvas.widgetKindHitTarget(.terminal)); - try testing.expect(widget_access.isFocusable(widget)); - try testing.expect(canvas.widgetClaimsPress(widget)); - try testing.expectEqual(canvas.WidgetCursor.text, canvas.cursorForWidgetTarget(.terminal, .{})); - try testing.expectEqual(canvas.WidgetRole.textbox, widget_semantics.semanticRole(widget)); - // Deliberately NOT a text-input kind: the emulator owns the editing - // model, so the TextBuffer pipeline must never claim it. - try testing.expect(!canvas.widgetTextInputKind(.terminal)); -} - -test "rounded-corner path elements survive the builder's lifetime" { - // The stroke_path command's elements must be builder-owned, not a - // stack local: after paint returns, reading them back (the retained - // renderer's path) must still see the move/line/quad verbs. - const cells = [_]grid_model.TerminalCell{.{ .cp = 0x256D, .fg = white }}; // ╭ const rows = [_]grid_model.TerminalRow{.{ .cells = &cells }}; - var commands: [64]canvas.CanvasCommand = undefined; - var builder = try paintInto(baseGrid(&rows), &commands, .{ - .frame = geometry.RectF.init(0, 0, 40, 40), + const metrics = grid_model.cellMetrics(canvas.DesignTokens{}); + const width: usize = @intFromFloat(@round(metrics.width * 2)); + const height: usize = @intFromFloat(@round(metrics.height)); + + var commands: [32]canvas.CanvasCommand = undefined; + var builder = canvas.Builder.init(&commands); + try paintInto(baseGrid(&rows), &builder, .{ + .frame = geometry.RectF.init(0, 0, @floatFromInt(width), @floatFromInt(height)), .tokens = .{}, }); - var saw_path = false; - for (builder.displayList().commands) |command| { - switch (command) { - .stroke_path => |path| { - saw_path = true; - try testing.expectEqual(@as(usize, 3), path.elements.len); - try testing.expectEqual(canvas.PathVerb.move_to, path.elements[0].verb); - try testing.expectEqual(canvas.PathVerb.quad_to, path.elements[2].verb); - }, - else => {}, + + var render_commands: [32]canvas.RenderCommand = undefined; + var render_batches: [32]canvas.RenderBatch = undefined; + var resources: [32]canvas.RenderResource = undefined; + var resource_cache_entries: [32]canvas.RenderResourceCacheEntry = undefined; + var resource_cache_actions: [64]canvas.RenderResourceCacheAction = undefined; + var atlas_glyphs: [64]canvas.GlyphAtlasEntry = undefined; + var changes: [32]canvas.DiffChange = undefined; + const frame = try builder.displayList().framePlan(null, .{ + .surface_size = geometry.SizeF.init(@floatFromInt(width), @floatFromInt(height)), + }, .{ + .render_commands = &render_commands, + .render_batches = &render_batches, + .resources = &resources, + .resource_cache_entries = &resource_cache_entries, + .resource_cache_actions = &resource_cache_actions, + .glyph_atlas_entries = &atlas_glyphs, + .changes = &changes, + }); + + const pixels = try testing.allocator.alloc(u8, width * height * 4); + defer testing.allocator.free(pixels); + const surface = try canvas.ReferenceRenderSurface.init(width, height, pixels); + try surface.renderPass(frame.renderPass(), canvas.Color.rgb8(0, 0, 0)); + + // Cell 1 is a blue field. Any pixel there that is NOT blue is the + // italic glyph leaning across the boundary — the ink the ordering + // protects. + const boundary: usize = @intFromFloat(metrics.width); + var leaned = false; + var y: usize = 0; + while (y < height) : (y += 1) { + var x = boundary; + while (x < width) : (x += 1) { + const index = (y * width + x) * 4; + const r = pixels[index]; + const g = pixels[index + 1]; + if (r > 90 and g > 90) leaned = true; } } - try testing.expect(saw_path); + try testing.expect(leaned); } -test "adjacent double-cross cells never collide command ids" { - // ╬ emits up to eight commands; the per-column id stride must be at - // least eight so neighbors never share an object id. - const cells = [_]grid_model.TerminalCell{ - .{ .cp = 0x256C, .fg = white }, // ╬ - .{ .cp = 0x256C, .fg = white }, - .{ .cp = 0x256C, .fg = white }, - }; +/// Render one styled row and return how many pixels carry ink. +fn styledRowInk(comptime text: []const u8, bold: bool, italic: bool) !usize { + var cells = comptime asciiRow(text, white); + for (&cells) |*entry| { + entry.bold = bold; + entry.italic = italic; + } const rows = [_]grid_model.TerminalRow{.{ .cells = &cells }}; - var commands: [128]canvas.CanvasCommand = undefined; - var builder = try paintInto(baseGrid(&rows), &commands, .{ - .frame = geometry.RectF.init(0, 0, 120, 40), + const metrics = grid_model.cellMetrics(canvas.DesignTokens{}); + const width: usize = @intFromFloat(@round(metrics.width * @as(f32, @floatFromInt(text.len)) + 4)); + const height: usize = @intFromFloat(@round(metrics.height)); + + var commands: [32]canvas.CanvasCommand = undefined; + var builder = canvas.Builder.init(&commands); + try paintInto(baseGrid(&rows), &builder, .{ + .frame = geometry.RectF.init(0, 0, @floatFromInt(width), @floatFromInt(height)), .tokens = .{}, - .id_base = 1, }); - var seen: std.ArrayListUnmanaged(canvas.ObjectId) = .empty; - defer seen.deinit(testing.allocator); - for (builder.displayList().commands) |command| { - const id = switch (command) { - .fill_rect => |c| c.id, - .stroke_rect => |c| c.id, - .draw_text => |c| c.id, - .push_clip => |c| c.id, - else => continue, - }; - if (id == 0) continue; - for (seen.items) |prior| try testing.expect(prior != id); - try seen.append(testing.allocator, id); - } -} -test "the text preflight accounts for bytes earlier widgets already consumed" { - // A grid sharing the builder with a widget that already spent most - // of the text store must degrade against the REMAINING space, never - // a fresh store — otherwise its rows pass preflight and then lose - // text when allocTextBytes fails on the shared counter. - const row = comptime asciiRow("cells", white); - const rows = [_]grid_model.TerminalRow{.{ .cells = &row }}; + var render_commands: [32]canvas.RenderCommand = undefined; + var render_batches: [32]canvas.RenderBatch = undefined; + var resources: [32]canvas.RenderResource = undefined; + var resource_cache_entries: [32]canvas.RenderResourceCacheEntry = undefined; + var resource_cache_actions: [64]canvas.RenderResourceCacheAction = undefined; + var atlas_glyphs: [64]canvas.GlyphAtlasEntry = undefined; + var changes: [32]canvas.DiffChange = undefined; + const frame = try builder.displayList().framePlan(null, .{ + .surface_size = geometry.SizeF.init(@floatFromInt(width), @floatFromInt(height)), + }, .{ + .render_commands = &render_commands, + .render_batches = &render_batches, + .resources = &resources, + .resource_cache_entries = &resource_cache_entries, + .resource_cache_actions = &resource_cache_actions, + .glyph_atlas_entries = &atlas_glyphs, + .changes = &changes, + }); - var commands: [64]canvas.CanvasCommand = undefined; - var builder = canvas.Builder.init(&commands); - // Pre-consume all but 2 bytes of the store, as an earlier widget - // would. - const filler = [_]u8{'x'} ** (canvas.max_display_list_text_bytes - 2); - _ = try builder.allocTextBytes(&filler); + const pixels = try testing.allocator.alloc(u8, width * height * 4); + defer testing.allocator.free(pixels); + const surface = try canvas.ReferenceRenderSurface.init(width, height, pixels); + try surface.renderPass(frame.renderPass(), canvas.Color.rgb8(0, 0, 0)); - try grid_model.paint(baseGrid(&rows), &builder, .{ - .frame = geometry.RectF.init(0, 0, 400, 100), - .tokens = .{}, - }); - // The row (5 bytes) could not fit the 2 remaining, so it dropped - // whole: no grid text command, and the shared counter never - // overflowed. - try testing.expect(builder.text_byte_len <= canvas.max_display_list_text_bytes); - for (builder.displayList().commands) |command| { - switch (command) { - .draw_text => |text| try testing.expect(!std.mem.eql(u8, text.text, "cells")), - else => {}, - } + var ink: usize = 0; + for (0..width * height) |index| { + if (pixels[index * 4] > 40) ink += 1; } + return ink; } -test "box drawing classifies the block and ignores neighbors" { - try testing.expect(box.isBoxDrawing(0x2500)); - try testing.expect(box.isBoxDrawing(0x259F)); - try testing.expect(!box.isBoxDrawing(0x24FF)); - try testing.expect(!box.isBoxDrawing(0x25A0)); - try testing.expect(box.mergesHorizontally(0x2500)); - try testing.expect(!box.mergesHorizontally(0x2502)); +test "synthetic bold inks more than regular, and italic inks differently, at the same cells" { + // The measurement behind "bold is visible". Faux bold is a second + // offset pass, so it strictly ADDS covered pixels; faux italic is a + // shear, so it moves them without adding a whole pass. Both draw at + // the same pens — the geometry test above pins that separately — + // so any difference here is ink and only ink. + const regular = try styledRowInk("mono", false, false); + const bold = try styledRowInk("mono", true, false); + const italic = try styledRowInk("mono", false, true); + + std.debug.print( + "\n[terminal] ink coverage: regular={d} bold={d} italic={d}\n", + .{ regular, bold, italic }, + ); + + try testing.expect(regular > 0); + // Bold is heavier by a real margin, not by a pixel or two. + try testing.expect(bold > regular); + try testing.expect(bold - regular > regular / 20); + // Italic is a different shape, not merely the same one again. + try testing.expect(italic != regular); } diff --git a/src/primitives/canvas/tokens.zig b/src/primitives/canvas/tokens.zig index 9bd8032df..f5ad9155c 100644 --- a/src/primitives/canvas/tokens.zig +++ b/src/primitives/canvas/tokens.zig @@ -385,6 +385,16 @@ pub const FontFamily = enum { pub const TypographyTokens = struct { font_id: FontId = default_sans_font_id, mono_font_id: FontId = default_mono_font_id, + /// Companion mono faces for SGR bold and italic, registered by the + /// app (`Runtime.registerCanvasFont`) and named here. 0 means "not + /// registered": the terminal painter still CARRIES the attribute and + /// the renderers synthesize it (a second offset pass for bold, a + /// baseline shear for italic) rather than dropping it. Real faces + /// are strictly better ink; synthesis is what keeps `\x1b[1m` + /// visible for an app that has not supplied one. + mono_bold_font_id: FontId = 0, + mono_italic_font_id: FontId = 0, + mono_bold_italic_font_id: FontId = 0, font_family: FontFamily = default_sans_font_family, mono_font_family: FontFamily = default_mono_font_family, body_size: f32 = 14, diff --git a/src/primitives/canvas/ui.zig b/src/primitives/canvas/ui.zig index fd958c0ce..309f69234 100644 --- a/src/primitives/canvas/ui.zig +++ b/src/primitives/canvas/ui.zig @@ -915,6 +915,13 @@ pub fn Ui(comptime Msg: type) type { /// authored menu, platform-appropriate presentation. Markup /// authors declare this with a `` child element. context_menu: []const ContextMenuItem = &.{}, + /// Which context menus this widget permits. `.automatic` keeps + /// the backward-compatible declared-menu-then-SDK-default + /// behavior. `.declared_only` suppresses SDK-provided defaults + /// while preserving `context_menu`; `.disabled` suppresses all + /// menu handling and leaves secondary-button input on the + /// ordinary routed/captured pointer path. + context_menu_policy: canvas.WidgetContextMenuPolicy = .automatic, }; /// One `ElementOptions.context_menu` entry: the chrome-menu item @@ -3746,7 +3753,11 @@ pub fn Ui(comptime Msg: type) type { }, }, .style = options.style, - .semantics = options.semantics, + .semantics = semantics: { + var semantics = options.semantics; + semantics.context_menu_policy = options.context_menu_policy; + break :semantics semantics; + }, .window_drag = options.window_drag, .overscroll = options.overscroll, .resize_duration_ms = options.resize_duration, diff --git a/src/primitives/canvas/ui_tests.zig b/src/primitives/canvas/ui_tests.zig index b035fa6a9..3b6f27a13 100644 --- a/src/primitives/canvas/ui_tests.zig +++ b/src/primitives/canvas/ui_tests.zig @@ -1567,6 +1567,20 @@ test "virtualWindow without a source falls back to the request viewport" { try testing.expect(ui.virtualWindow(options).isEmpty()); } +test "terminal context menu policy flows from ElementOptions into the widget tree" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + var ui = InboxUi.init(arena_state.allocator()); + + const tree = try ui.finalize(ui.terminal(.{ + .pty = 7, + .context_menu_policy = .disabled, + })); + + try testing.expectEqual(canvas.WidgetKind.terminal, tree.root.kind); + try testing.expectEqual(canvas.WidgetContextMenuPolicy.disabled, tree.root.semantics.context_menu_policy); +} + test "widget kind codes are pinned: assigned at birth, declaration-order-independent" { // The FULL golden table. `structuralId` hashes `widgetKindCode`, so // this table IS the id vocabulary persisted state references: diff --git a/src/primitives/canvas/vector.zig b/src/primitives/canvas/vector.zig index 9c7ed609f..27cb9109f 100644 --- a/src/primitives/canvas/vector.zig +++ b/src/primitives/canvas/vector.zig @@ -92,18 +92,18 @@ const max_piece_points: usize = 4 * max_curve_segments + 4; // so the arithmetic is written out and lockstep-pinned by a test in // font_ttf_tests.zig against the source constants). // -// Worst admitted glyph (1024 points / 128 contours, simple and +// Worst admitted glyph (4096 points / 256 contours, simple and // flattened-composite maxima are gated equal; glyph outlines emit only // move/line/quad/close): // -// quads <= points + contours = 1024 + 128 = 1152 +// quads <= points + contours = 4096 + 256 = 4352 // (each point emits at most one line or quad; each contour adds at // most one closing quad — the same walk the glyph path capacity in // reference.zig is derived from) -// edges <= quads * segments + contours = 1152 * 16 + 128 = 18,560 +// edges <= quads * segments + contours = 4352 * 16 + 256 = 69,888 // (each quad flattens to at most `max_glyph_curve_segments` line // segments, lines and the per-contour close edge are one each) -// scanline crossings <= edges = 18,560 +// scanline crossings <= edges = 69,888 // (an edge crosses a scanline at most once — the half-open span // test in `sweep` — so the edge bound is also the airtight crossing // bound, with no reliance on curve monotonicity under float @@ -115,8 +115,7 @@ const max_piece_points: usize = 4 * max_curve_segments + 4; // flattening has no finite bound at all — a full-em quad at an // S-pixel em wants ceil(sqrt((S/2) / 0.25)) segments (128 at S = 8192) // — and even the generic `max_curve_segments` cap of 48 would put the -// worst case at 1152 * 48 + 128 = 55,424 edges = 1.06 MiB of edge -// storage plus 433 KiB of crossings per rasterizer: memory-unreasonable +// worst case at 4352 * 48 + 256 = 209,152 edges, which is memory-unreasonable // for a per-thread buffer. Flattening tolerance is a quality knob, not // correctness, so glyph fills clamp subdivision instead: at 16 segments // the clamp only binds when a curve's flatness deviation exceeds @@ -127,13 +126,13 @@ const max_piece_points: usize = 4 * max_curve_segments + 4; // span fractions of the em, so everything the golden suite renders // stays below the clamp and byte-identical. // -// Storage: 18,560 edges * 20 B = 362.5 KiB plus 18,560 crossings * 8 B -// = 145 KiB, ~508 KiB per `GlyphRasterizer`. The glyph raster caller +// Storage: 69,888 edges * 20 B plus 69,888 crossings * 8 B is ~1.9 MiB per +// `GlyphRasterizer`. The glyph raster caller // (reference.zig) keeps ONE per render thread behind a lazily // heap-allocated pointer (`canvas.lazy_tls`), so it costs neither stack // nor static TLS, and threads that never ink a glyph never allocate it. pub const max_glyph_curve_segments: usize = 16; -pub const max_glyph_fill_edges: usize = 18_560; +pub const max_glyph_fill_edges: usize = 69_888; pub const max_glyph_scanline_crossings: usize = max_glyph_fill_edges; /// Half-open device-pixel clip window: pixels with `x0 <= x < x1` and @@ -542,7 +541,7 @@ pub fn RasterizerType(comptime edge_capacity: usize, comptime crossing_capacity: max_x: f32 = 0, max_y: f32 = 0, /// Scanline scratch for `sweep`, in the struct rather than its - /// stack frame so the glyph instantiation's 145 KiB rides the + /// stack frame so the glyph instantiation's ~546 KiB rides the /// same per-thread heap slot as its edges. crossings: [crossing_capacity]Crossing = undefined, @@ -671,7 +670,7 @@ pub fn RasterizerType(comptime edge_capacity: usize, comptime crossing_capacity: pub const Rasterizer = RasterizerType(max_edges, max_scanline_crossings); /// The glyph-fill instantiation, sized by the derived budgets above so -/// any outline the font registration gate admits rasterizes (~508 KiB — +/// any outline the font registration gate admits rasterizes (~1.9 MiB — /// callers keep one per thread on the heap, never on the stack; see /// `fillGlyphPath`). pub const GlyphRasterizer = RasterizerType(max_glyph_fill_edges, max_glyph_scanline_crossings); @@ -743,7 +742,7 @@ pub fn fillPath( /// budget-admitted glyph NEVER fails with `VectorPathTooComplex` — the /// registration promise ("a registered face always resolves at render /// time") holds at the raster layer too. `raster` is caller-owned -/// storage (~508 KiB: keep one per thread on the heap, e.g. behind +/// storage (~1.9 MiB: keep one per thread on the heap, e.g. behind /// `canvas.lazy_tls`, never on the stack); it is reset here, so it needs /// no initialization beyond existing. pub fn fillGlyphPath( diff --git a/src/primitives/canvas/widget_access.zig b/src/primitives/canvas/widget_access.zig index f2307ddfd..f8c325a0b 100644 --- a/src/primitives/canvas/widget_access.zig +++ b/src/primitives/canvas/widget_access.zig @@ -43,13 +43,17 @@ pub fn cursorForWidgetTarget(kind: WidgetKind, state: WidgetState) WidgetCursor }; } +/// Focus follows the ACCESSIBILITY tree, not the paint: a widget the +/// collector never emits cannot be a ring-focus stop, or Tab would land +/// on a node assistive tech has no way to announce. So `decorative` +/// (painted, unannounced) stands down here exactly like `hidden` does. pub fn semanticFocusable(widget: Widget, actions: WidgetActions) bool { - if (widget.id == 0 or widget.state.disabled or widget.semantics.hidden) return false; + if (widget.id == 0 or widget.state.disabled or widget.semantics.concealedFromAccessibility()) return false; return widget.semantics.focusable or widget.semantics.actions.focus or actions.focus or defaultFocusable(widget); } pub fn isFocusable(widget: Widget) bool { - if (widget.id == 0 or widget.state.disabled or widget.semantics.hidden) return false; + if (widget.id == 0 or widget.state.disabled or widget.semantics.concealedFromAccessibility()) return false; return widget.semantics.focusable or widget.semantics.actions.focus or defaultFocusable(widget); } diff --git a/src/primitives/canvas/widget_invalidation.zig b/src/primitives/canvas/widget_invalidation.zig index fab718fce..2389c0a5d 100644 --- a/src/primitives/canvas/widget_invalidation.zig +++ b/src/primitives/canvas/widget_invalidation.zig @@ -837,7 +837,9 @@ fn widgetSemanticsEqual(a: WidgetSemantics, b: WidgetSemantics) bool { a.list_item_count == b.list_item_count and widgetActionsEqual(a.actions, b.actions) and a.hidden == b.hidden and - a.focusable == b.focusable; + a.decorative == b.decorative and + a.focusable == b.focusable and + a.context_menu_policy == b.context_menu_policy; } fn widgetActionsEqual(a: WidgetActions, b: WidgetActions) bool { diff --git a/src/primitives/canvas/widget_render.zig b/src/primitives/canvas/widget_render.zig index e0f4209e1..4695c267a 100644 --- a/src/primitives/canvas/widget_render.zig +++ b/src/primitives/canvas/widget_render.zig @@ -2795,6 +2795,7 @@ fn emitTerminalWidget(builder: *Builder, widget: Widget, tokens: DesignTokens, f .text_reserve = canvas.terminal_grid.widget_text_reserve, .path_reserve = canvas.terminal_grid.widget_path_reserve, .glyph_budget = canvas.terminal_grid.widget_glyph_budget, + .cell_reserve = canvas.terminal_grid.widget_cell_reserve, }); } else { try builder.fillRect(.{ diff --git a/src/primitives/canvas/widget_runtime.zig b/src/primitives/canvas/widget_runtime.zig index 0a5e8b0d4..d10ba16a6 100644 --- a/src/primitives/canvas/widget_runtime.zig +++ b/src/primitives/canvas/widget_runtime.zig @@ -23,6 +23,7 @@ const WidgetKind = widget_model.WidgetKind; const WidgetCursor = widget_model.WidgetCursor; const WidgetState = widget_model.WidgetState; const WidgetRenderState = widget_model.WidgetRenderState; +const WidgetContextMenuPolicy = widget_model.WidgetContextMenuPolicy; const Widget = widget_model.Widget; const WidgetLayoutNode = event_model.WidgetLayoutNode; const WidgetHit = event_model.WidgetHit; @@ -73,6 +74,30 @@ pub const WidgetLayoutTree = struct { return null; } + /// The nearest explicit context-menu policy from `node_index` toward + /// the root. `.automatic` inherits; an invalid node fails open to the + /// compatibility default. + pub fn contextMenuPolicyAt(self: WidgetLayoutTree, node_index: usize) WidgetContextMenuPolicy { + var current: ?usize = node_index; + while (current) |index| { + if (index >= self.nodes.len) return .automatic; + const node = self.nodes[index]; + if (node.widget.semantics.context_menu_policy != .automatic) { + return node.widget.semantics.context_menu_policy; + } + current = node.parent_index; + } + return .automatic; + } + + pub fn contextMenuPolicyById(self: WidgetLayoutTree, id: ObjectId) WidgetContextMenuPolicy { + if (id == 0) return .automatic; + for (self.nodes, 0..) |node, index| { + if (node.widget.id == id) return self.contextMenuPolicyAt(index); + } + return .automatic; + } + pub fn virtualRangeById(self: WidgetLayoutTree, id: ObjectId) ?VirtualListRange { if (id == 0) return null; for (self.nodes) |node| { diff --git a/src/primitives/canvas/widget_runtime_tests.zig b/src/primitives/canvas/widget_runtime_tests.zig index 10e5291a3..e29a9719b 100644 --- a/src/primitives/canvas/widget_runtime_tests.zig +++ b/src/primitives/canvas/widget_runtime_tests.zig @@ -872,6 +872,38 @@ test "widget layout diff separates paint and semantics dirtiness" { try expectRect(geometry.RectF.init(8, 12, 80, 48), image_invalidations[0].dirty_bounds); } +test "context menu policy-only changes publish semantics invalidation" { + const previous_child = [_]Widget{.{ + .id = 2, + .kind = .terminal, + .frame = geometry.RectF.init(10, 10, 100, 30), + }}; + const disabled_child = [_]Widget{.{ + .id = 2, + .kind = .terminal, + .frame = geometry.RectF.init(10, 10, 100, 30), + .semantics = .{ .context_menu_policy = .disabled }, + }}; + var previous_nodes: [2]WidgetLayoutNode = undefined; + var disabled_nodes: [2]WidgetLayoutNode = undefined; + const previous = try layoutWidgetTree(.{ .kind = .stack, .children = &previous_child }, geometry.RectF.init(0, 0, 140, 80), &previous_nodes); + const disabled = try layoutWidgetTree(.{ .kind = .stack, .children = &disabled_child }, geometry.RectF.init(0, 0, 140, 80), &disabled_nodes); + + var invalidations_buffer: [2]WidgetInvalidation = undefined; + const invalidations = try WidgetLayoutTree.diff(previous, disabled, &invalidations_buffer); + try std.testing.expectEqual(@as(usize, 1), invalidations.len); + try std.testing.expect(!invalidations[0].layout_dirty); + try std.testing.expect(!invalidations[0].paint_dirty); + try std.testing.expect(invalidations[0].semantics_dirty); + try std.testing.expect(invalidations[0].dirty_bounds == null); +} + +test "context menu policy storage stays within the v0.8.1 Widget size budget" { + if (@sizeOf(usize) == 8) { + try std.testing.expect(@sizeOf(Widget) <= 776); + } +} + test "widget layout diff marks style changes as paint dirty" { const previous_child = [_]Widget{.{ .id = 2, diff --git a/src/primitives/canvas/widget_semantics.zig b/src/primitives/canvas/widget_semantics.zig index 2fc0b2829..37207f3f6 100644 --- a/src/primitives/canvas/widget_semantics.zig +++ b/src/primitives/canvas/widget_semantics.zig @@ -45,7 +45,11 @@ pub fn collectWidgetSemantics(layout: anytype, output: []WidgetSemanticsNode, sc } const role = semanticRole(node.widget); - if (node.widget.semantics.hidden) { + // `hidden` (not painted at all) and `decorative` (painted, but + // deliberately outside the accessibility tree) both drop the node + // AND everything under it, the way `aria-hidden` does — a + // decorative wrapper cannot leak its children back into the tree. + if (node.widget.semantics.concealedFromAccessibility()) { hidden_depth = node.depth; continue; } diff --git a/src/primitives/canvas/widget_semantics_tests.zig b/src/primitives/canvas/widget_semantics_tests.zig index 925b048c5..96c536a7b 100644 --- a/src/primitives/canvas/widget_semantics_tests.zig +++ b/src/primitives/canvas/widget_semantics_tests.zig @@ -2704,3 +2704,101 @@ test "widget list layout groups list items semantically" { try std.testing.expectEqual(@as(u32, 1), semantics[2].list.item_index); try std.testing.expectEqual(@as(u32, 2), semantics[2].list.item_count); } + +test "hidden reserves an unpainted slot while decorative paints without announcing" { + // The two flags the engine deliberately keeps apart: + // hidden - laid out (the box, and with it the reserved space, + // survives), paints nothing, announces nothing. The + // empty fixed-width slot. + // decorative - laid out AND painted, announces nothing. The + // aria-hidden counterpart for chrome an author draws + // for the eye alone: a search field's magnifier + // glyph, a rendered caret, a leading rule. + const tokens = DesignTokens{}; + + const decorative_leaf = Widget{ + .id = 2, + .kind = .text, + .frame = geometry.RectF.init(0, 0, 48, 16), + .text = "12:00", + .semantics = .{ .decorative = true }, + }; + var decorative_commands: [8]CanvasCommand = undefined; + var decorative_builder = Builder.init(&decorative_commands); + try emitWidgetTree(&decorative_builder, decorative_leaf, tokens); + try std.testing.expect(decorative_builder.displayList().commandCount() > 0); + + var hidden_leaf = decorative_leaf; + hidden_leaf.semantics = .{ .hidden = true }; + var hidden_commands: [8]CanvasCommand = undefined; + var hidden_builder = Builder.init(&hidden_commands); + try emitWidgetTree(&hidden_builder, hidden_leaf, tokens); + try std.testing.expectEqual(@as(usize, 0), hidden_builder.displayList().commandCount()); + + // `hidden` is not `display: none`. The flagged slot keeps its + // definite box, so the label beside it lands at the same x under + // either flag — the property a fixed-width "marker not currently + // showing" spacer depends on. + var label_x: ?f32 = null; + inline for ([_]WidgetSemantics{ .{ .hidden = true }, .{ .decorative = true } }) |slot_semantics| { + const children = [_]Widget{ + .{ + .id = 2, + .kind = .stack, + .layout = .{ + .min_size = .{ .width = 24, .height = 16 }, + .max_size = .{ .width = 24, .height = 16 }, + }, + .semantics = slot_semantics, + }, + .{ .id = 3, .kind = .text, .text = "Terminal" }, + }; + const root = Widget{ .id = 1, .kind = .row, .children = &children }; + var nodes: [4]WidgetLayoutNode = undefined; + const layout = try layoutWidgetTree(root, geometry.RectF.init(0, 0, 240, 32), &nodes); + try std.testing.expectEqual(@as(ObjectId, 2), layout.nodes[1].widget.id); + try std.testing.expectEqual(@as(f32, 24), layout.nodes[1].frame.normalized().width); + try std.testing.expectEqual(@as(ObjectId, 3), layout.nodes[2].widget.id); + const x = layout.nodes[2].frame.normalized().x; + if (label_x) |first| try std.testing.expectEqual(first, x) else label_x = x; + } + + // Announcement: `decorative` drops the node AND its subtree, the way + // `aria-hidden` does, so a decorative wrapper cannot leak a labeled + // child back into the tree. + const nested = [_]Widget{ + .{ .id = 4, .kind = .button, .text = "Nested" }, + }; + const semantic_children = [_]Widget{ + .{ + .id = 2, + .kind = .stack, + .semantics = .{ .decorative = true }, + .children = &nested, + }, + .{ .id = 3, .kind = .button, .text = "Real" }, + }; + const semantic_root = Widget{ + .id = 1, + .kind = .row, + .semantics = .{ .label = "Tab strip" }, + .children = &semantic_children, + }; + var semantic_nodes: [8]WidgetLayoutNode = undefined; + const semantic_layout = try layoutWidgetTree(semantic_root, geometry.RectF.init(0, 0, 240, 32), &semantic_nodes); + var semantics_buffer: [8]WidgetSemanticsNode = undefined; + const semantics = try semantic_layout.collectSemantics(&semantics_buffer); + try std.testing.expectEqual(@as(usize, 2), semantics.len); + try std.testing.expectEqual(@as(ObjectId, 1), semantics[0].id); + try std.testing.expectEqual(@as(ObjectId, 3), semantics[1].id); + + // A node the collector never emits cannot be a keyboard focus stop + // either, or Tab would land where nothing can be announced. + try std.testing.expect(canvas.widgetIsFocusable(.{ .id = 5, .kind = .button, .text = "Save" })); + try std.testing.expect(!canvas.widgetIsFocusable(.{ + .id = 5, + .kind = .button, + .text = "Save", + .semantics = .{ .decorative = true }, + })); +} diff --git a/src/primitives/canvas/widgets.zig b/src/primitives/canvas/widgets.zig index 75ab44379..0cb6434af 100644 --- a/src/primitives/canvas/widgets.zig +++ b/src/primitives/canvas/widgets.zig @@ -812,8 +812,46 @@ pub const WidgetSemantics = struct { list_item_index: ?u32 = null, list_item_count: ?u32 = null, actions: WidgetActions = .{}, + /// VISIBILITY, not an accessibility annotation: the widget and its + /// subtree keep their layout box — the space stays reserved and + /// siblings do not reflow — but drop out of painting, hit-testing, + /// focus traversal, drag/drop, and the accessibility tree. This is + /// the flag for "lay out an empty slot here": a fixed-width spacer + /// that reserves room for an affordance that is not currently shown, + /// or `Ui.nav`'s retained-but-inactive pages. + /// + /// It is NOT the way to keep a decoration off a screen reader — + /// `hidden` paints nothing, which is why a `hidden` magnifier glyph + /// or caret renders as blank space. Use `decorative` for that. hidden: bool = false, + /// ACCESSIBILITY only: the widget and its subtree are omitted from + /// the accessibility tree and can never be focusable, while painting, + /// layout, hit-testing, and event routing stay exactly as they are. + /// The `aria-hidden` / `role="presentation"` counterpart, and the + /// right flag for chrome that is meaningful to the eye but noise to + /// assistive tech: a search field's magnifier glyph, a rendered + /// caret, a decorative rule beside a labeled control. + /// + /// Deliberately separate from `hidden`, which suppresses paint too. + /// Marking an interactive control decorative hides a working control + /// from assistive tech — reach for it on decoration only. + /// + /// Retained metadata that fits existing struct padding, so it costs + /// no bytes on the `Widget` hot path. + decorative: bool = false, focusable: bool = false, + /// Context-menu selection policy. This is retained action metadata and + /// occupies existing struct padding, keeping every `Widget` compact. + context_menu_policy: WidgetContextMenuPolicy = .automatic, + + /// True when this widget (and its subtree) stays out of the + /// accessibility tree — either because nothing paints (`hidden`) or + /// because the paint is decoration (`decorative`). The single + /// predicate every accessibility-facing walk asks, so the two flags + /// can never drift apart on the announcement side. + pub fn concealedFromAccessibility(self: WidgetSemantics) bool { + return self.hidden or self.decorative; + } }; /// One declared context-menu entry carried on a widget (label/enabled/ @@ -825,6 +863,17 @@ pub const WidgetContextMenuItem = struct { separator: bool = false, }; +/// Which context menus a widget permits. `.automatic` preserves the +/// platform defaults (declared items first, then SDK text/terminal menus), +/// `.declared_only` suppresses those SDK defaults while retaining an +/// app-declared menu, and `.disabled` bypasses context-menu handling so the +/// secondary-button stream follows ordinary widget routing and capture. +pub const WidgetContextMenuPolicy = enum { + automatic, + declared_only, + disabled, +}; + /// Per-region edge behavior of a scroll container. `.default` follows /// the `ScrollPhysics.overscroll` design token (off by default: scroll /// regions pin at their content edges); `.none` and `.rubber_band` pin diff --git a/src/runtime/automation_snapshot.zig b/src/runtime/automation_snapshot.zig index 83eedbf84..b7711c2ba 100644 --- a/src/runtime/automation_snapshot.zig +++ b/src/runtime/automation_snapshot.zig @@ -44,16 +44,31 @@ pub fn RuntimeAutomationSnapshot(comptime Runtime: type) type { .text_layout_line_budget = canvas_limits.max_canvas_text_layout_lines_per_view, }; } + var window_count: usize = 0; var view_count: usize = 0; var widget_count: usize = 0; var menu_item_count: usize = 0; - for (self.windows[0..count], 0..) |window, index| { - self.automation_windows[index] = .{ + // CLOSED windows are skipped, not reported. A closed window + // keeps its runtime table slot (with `info.open = false`) + // until its label or id is re-created — `removeWindowAt` runs + // at re-creation, not at close — so `self.windows[0..count]` + // still carries every window the app has ever declared. Their + // views are gone from `self.views` the moment they close, so + // an unfiltered pass published a window line with no views + // under it: a ghost that made every automation assertion on + // window COUNT (a settings window that must close, a + // reconciled window set) pass against a window that is not on + // screen. The output index advances only for the windows that + // survive the filter. + for (self.windows[0..count]) |window| { + if (!window.info.open) continue; + self.automation_windows[window_count] = .{ .id = window.info.id, .title = if (window.info.title.len > 0) window.info.title else title, .bounds = window.info.frame, .focused = window.info.focused, }; + window_count += 1; if (view_count < self.automation_views.len) { const views = self.listViews(window.info.id, self.automation_views[view_count..]); view_count += views.len; @@ -61,7 +76,7 @@ pub fn RuntimeAutomationSnapshot(comptime Runtime: type) type { appendAutomationWidgets(self, window.info.id, &widget_count, &menu_item_count); } return .{ - .windows = self.automation_windows[0..count], + .windows = self.automation_windows[0..window_count], .views = self.automation_views[0..view_count], .widgets = self.automation_widgets[0..widget_count], .diagnostics = automationDiagnostics(self), @@ -238,6 +253,7 @@ pub fn RuntimeAutomationSnapshot(comptime Runtime: type) type { .text_selection = canvasTextRange(node.text_selection), .text_composition = canvasTextRange(node.text_composition), .context_menu = automationWidgetContextMenu(self, layout, node.id, menu_item_count), + .context_menu_policy = automationWidgetContextMenuPolicy(layout, node.id), }; widget_count.* += 1; } @@ -267,6 +283,12 @@ pub fn RuntimeAutomationSnapshot(comptime Runtime: type) type { return self.automation_widget_menu_items[start .. start + count]; } + fn automationWidgetContextMenuPolicy(layout: canvas.WidgetLayoutTree, id: canvas.ObjectId) []const u8 { + const policy = layout.contextMenuPolicyById(id); + if (policy == .automatic) return ""; + return @tagName(policy); + } + pub fn frameDiagnostics(self: *Runtime) FrameDiagnostics { return self.last_diagnostics; } diff --git a/src/runtime/automation_widget_dispatch.zig b/src/runtime/automation_widget_dispatch.zig index 9209cdf0d..799ee655a 100644 --- a/src/runtime/automation_widget_dispatch.zig +++ b/src/runtime/automation_widget_dispatch.zig @@ -198,14 +198,15 @@ pub fn RuntimeAutomationWidgetDispatch(comptime Runtime: type) type { /// replays, and resolves through `dispatchContextMenuAction` /// into the widget's `.context_menu` handler. Named errors say /// why an invocation cannot happen: no declared menu, an index - /// past the declared items, a separator slot, a disabled item — - /// the same items the snapshot lists per widget — or a dismissal - /// handler that presented a superseding menu, or closed the - /// target's view, mid-verb. + /// past the declared items, a separator slot, a disabled item or + /// widget policy — the same items and policy the snapshot lists — + /// or a dismissal handler that presented a superseding menu, or + /// closed the target's view, mid-verb. pub fn dispatchAutomationWidgetContextMenuItem(self: *Runtime, app: runtime_api.App(Runtime), item: automation_commands.AutomationWidgetContextMenuItem) anyerror!void { const view_index = try automationWidgetTargetViewIndex(self, item.target); const node_index = self.views[view_index].canvasWidgetNodeIndexById(item.target.id) orelse return error.InvalidCommand; const widget = self.views[view_index].widget_layout_nodes[node_index].widget; + if (self.views[view_index].widgetLayoutTree().contextMenuPolicyAt(node_index) == .disabled) return error.ContextMenuDisabled; if (widget.context_menu.len == 0) return error.ContextMenuUndeclared; if (item.item_index >= widget.context_menu.len) return error.ContextMenuItemOutOfRange; const declared = widget.context_menu[item.item_index]; @@ -326,23 +327,66 @@ pub fn RuntimeAutomationWidgetDispatch(comptime Runtime: type) type { } }); } + /// A keystroke is a PRESS AND A RELEASE, and this verb synthesizes + /// both — the `widget-drag` (down/drag/up) and `widget-pinch` + /// (begin/change/end) discipline applied to the keyboard, at one + /// timestamp because press and release are one gesture. + /// + /// Emitting only the down left every key-lifetime latch armed + /// forever. Those latches exist because a physical chord's release + /// carries different modifier flags than its press, so the + /// classification is latched on the view at the down and retired + /// at the up (`consumeCanvasWidgetTabInputFocusEntry`, + /// `consumeCanvasWidgetTerminalPasteKeyLifetime`, and any + /// app-level shortcut latch built the same way). With no release + /// ever arriving, the SECOND drive of the same chord found the + /// latch still held from the first and was swallowed as the + /// missing release — `widget-key canvas cmd+g` twice in a row did + /// the work once. + /// + /// Paired here rather than as a new explicit release action: real + /// hardware has no press without a release, so a harness that can + /// emit an unpaired one is a harness that can reach states no user + /// can, and every existing automation script keeps its wire format + /// and its meaning (one `widget-key` line is still one keystroke). + /// An explicit release verb would have made correctness opt-in and + /// left every script written before it driving half a keystroke. + /// + /// The release carries the chord's modifiers (a user releases G + /// while Cmd is still down) but never `text`: committed text + /// belongs to the press, and `key_up` is deliberately barren in + /// both text paths (`canvasWidgetTextEditEventFromGpuInput` and + /// the target-less commit fallback), so a release can never + /// double-insert what the press already typed. pub fn dispatchAutomationWidgetKeyInput(self: *Runtime, app: runtime_api.App(Runtime), key: AutomationWidgetKey) anyerror!void { const view_index = try automationGpuSurfaceViewIndexByLabel(self, key.view_label); try self.focusView(self.views[view_index].window_id, self.views[view_index].label); + const window_id = self.views[view_index].window_id; + const label = self.views[view_index].label; + const modifiers: platform.ShortcutModifiers = .{ + .shift = key.modifiers.shift, + .control = key.modifiers.control, + .option = key.modifiers.option, + .command = key.modifiers.command, + .primary = key.modifiers.primary, + }; + const timestamp_ns = automationInputTimestampNs(); try self.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ - .window_id = self.views[view_index].window_id, - .label = self.views[view_index].label, + .window_id = window_id, + .label = label, .kind = .key_down, - .timestamp_ns = automationInputTimestampNs(), + .timestamp_ns = timestamp_ns, .key = key.key, .text = key.text, - .modifiers = .{ - .shift = key.modifiers.shift, - .control = key.modifiers.control, - .option = key.modifiers.option, - .command = key.modifiers.command, - .primary = key.modifiers.primary, - }, + .modifiers = modifiers, + } }); + try self.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = window_id, + .label = label, + .kind = .key_up, + .timestamp_ns = timestamp_ns, + .key = key.key, + .modifiers = modifiers, } }); } diff --git a/src/runtime/canvas_frame.zig b/src/runtime/canvas_frame.zig index 1388c40da..75902d816 100644 --- a/src/runtime/canvas_frame.zig +++ b/src/runtime/canvas_frame.zig @@ -461,7 +461,13 @@ pub fn RuntimeCanvasFrames(comptime Runtime: type) type { var baseline_adopted = false; if (self.options.pixel_present_retained_baseline) { if (gatherCanvasPacketCurrentCommands(record_frame)) |current| { - adoptCanvasPixelPresentBaseline(&self.views[index], current, record_frame.surface_size, record_frame.scale); + adoptCanvasPixelPresentBaseline( + &self.views[index], + current, + record_frame.surface_size, + record_frame.scale, + canvasPacketClipSet(record_frame.render_plan.commands), + ); baseline_adopted = true; } } @@ -586,6 +592,11 @@ pub fn RuntimeCanvasFrames(comptime Runtime: type) type { // or carries duplicate keys — those frames present // through the non-retained encoding below. const current = if (view_index != null) gatherCanvasPacketCurrentCommands(canvas_frame) else null; + // This frame's clip rects (see `CanvasClipSet`): the + // render planner erases clips, so the baseline has to + // carry them separately for the next frame's dirty + // derivation to see one move. + const clip_set = canvasPacketClipSet(canvas_frame.render_plan.commands); // Patch derivation runs eagerly with the gather (one // profile stage, one scratch fill the encoder below // reads); null when the view holds no usable baseline. @@ -638,7 +649,7 @@ pub fn RuntimeCanvasFrames(comptime Runtime: type) type { else => return err, }; self.frame_profile.end(.present, present_begin); - adoptCanvasPacketBaseline(view, current_commands, packet); + adoptCanvasPacketBaseline(view, current_commands, packet, clip_set); view.gpu_present_packet_mode = .patch; view.gpu_present_patch_bytes = base.binary.len; view.gpu_present_patch_upsert_count = stats.upsert_count; @@ -682,7 +693,7 @@ pub fn RuntimeCanvasFrames(comptime Runtime: type) type { }; self.frame_profile.end(.present, present_begin); view.canvas_packet_generation = generation; - adoptCanvasPacketBaseline(view, current_commands, packet); + adoptCanvasPacketBaseline(view, current_commands, packet, clip_set); view.gpu_present_packet_mode = .full; view.gpu_present_patch_bytes = 0; view.gpu_present_patch_upsert_count = 0; @@ -1112,7 +1123,7 @@ pub fn RuntimeCanvasFrames(comptime Runtime: type) type { self.views[index].canvas_packet_baseline_scale == frame_options.scale) { if (gatherCanvasPacketCurrentCommandsFromPlan(render_plan.commands, frame_options.surface_size, render_plan.bounds)) |current| { - if (canvasPacketPatchDirtyBounds(&self.views[index], current)) |patch_dirty| { + if (canvasPacketPatchDirtyBounds(&self.views[index], current, canvasPacketClipSet(render_plan.commands))) |patch_dirty| { var refined = patch_dirty; if (overrides_dirty) |overrides_rect| refined.add(overrides_rect); const clipped = bleedAlignedCanvasDirtyBounds(refined.bounds, frame_options.scale, 1, frame_options.surface_size); @@ -1438,6 +1449,67 @@ fn gatherCanvasPacketCurrentCommandsFromPlan(render_commands: []const canvas.Ren return scratch.packet_current[0..count]; } +/// A frame's distinct CLIP RECTS. +/// +/// The render planner erases `push_clip`/`pop_clip`: they never become +/// render commands, only a `clip` field on the commands they enclose +/// (`canvas.RenderCommand`). So the retained packet baseline — a mirror +/// of RENDER commands — holds no key for a clip, and the refined dirty +/// rect derived from it can only name pixels that changed commands and +/// evicted keys cover. Pixels REVEALED by a clip that grew (or VACATED +/// by one that shrank) belong to neither: the host scissors to a region +/// that never reaches them and its retained backing keeps whatever it +/// last drew there. A terminal pane widening back to full width after a +/// split collapse is exactly that frame, and it left stale glyphs +/// standing in the revealed columns. +/// +/// So the refinement carries the clip set with it: rects present on one +/// side and not the other are added to the dirty region, which covers +/// both revealed and vacated pixels exactly. Frames whose clips did not +/// move pay nothing, and a tween that moves a clip every frame keeps +/// its region-scoped patch instead of resyncing. +/// +/// The set is small by construction — one entry per clipping widget — +/// so the fixed slot count is generous; a frame past it marks +/// `overflow` and the refinement refuses (the caller keeps its +/// conservative dirty bounds, which is what every frame had before this +/// existed). +const CanvasClipSet = struct { + rects: [canvas_limits.max_canvas_packet_clip_rects_per_view]geometry.RectF = undefined, + count: usize = 0, + overflow: bool = false, + + fn contains(self: CanvasClipSet, rect: geometry.RectF) bool { + for (self.rects[0..self.count]) |entry| { + if (rectsEqual(entry, rect)) return true; + } + return false; + } + + fn add(self: *CanvasClipSet, rect: geometry.RectF) void { + if (self.contains(rect)) return; + if (self.count == self.rects.len) { + self.overflow = true; + return; + } + self.rects[self.count] = rect; + self.count += 1; + } +}; + +fn rectsEqual(a: geometry.RectF, b: geometry.RectF) bool { + return a.x == b.x and a.y == b.y and a.width == b.width and a.height == b.height; +} + +fn canvasPacketClipSet(render_commands: []const canvas.RenderCommand) CanvasClipSet { + var set = CanvasClipSet{}; + for (render_commands) |command| { + const clip = command.clip orelse continue; + set.add(clip); + } + return set; +} + fn canvasPacketCurrentKeyLessThan(current: []const CanvasPacketCurrentCommand, a: u32, b: u32) bool { return current[a].key < current[b].key; } @@ -1542,7 +1614,18 @@ const CanvasPacketPatchDirty = struct { } }; -fn canvasPacketPatchDirtyBounds(view: anytype, current: []const CanvasPacketCurrentCommand) ?CanvasPacketPatchDirty { +fn canvasPacketPatchDirtyBounds( + view: anytype, + current: []const CanvasPacketCurrentCommand, + clips: CanvasClipSet, +) ?CanvasPacketPatchDirty { + // Clip geometry first (see `CanvasClipSet`): a set too large to + // compare refuses the refinement outright, and any rect on one side + // only contributes the pixels it reveals or vacates. + if (clips.overflow or view.canvas_packet_baseline_clip_overflow) return null; + var baseline_clips = CanvasClipSet{}; + baseline_clips.count = view.canvas_packet_baseline_clip_count; + for (0..baseline_clips.count) |index| baseline_clips.rects[index] = view.canvas_packet_baseline_clip_rects[index]; const baseline_count = view.canvas_packet_baseline_count; const baseline_keys = view.canvas_packet_baseline_keys[0..baseline_count]; const baseline_fingerprints = view.canvas_packet_baseline_fingerprints[0..baseline_count]; @@ -1558,6 +1641,12 @@ fn canvasPacketPatchDirtyBounds(view: anytype, current: []const CanvasPacketCurr @memset(stable, false); var dirty = CanvasPacketPatchDirty{}; + for (clips.rects[0..clips.count]) |rect| { + if (!baseline_clips.contains(rect)) dirty.add(rect); + } + for (baseline_clips.rects[0..baseline_clips.count]) |rect| { + if (!clips.contains(rect)) dirty.add(rect); + } for (current, 0..) |entry, index| { scratch.packet_upsert[index] = true; if (findCanvasPacketBaselineIndex(baseline_keys, baseline_sorted, entry.key)) |baseline_index| { @@ -1674,8 +1763,13 @@ fn canvasPacketFullBinaryByteSize( /// A successful retained present (full or patch) makes `current` the /// view's baseline: the engine-side mirror of what the host now retains. -fn adoptCanvasPacketBaseline(view: anytype, current: []const CanvasPacketCurrentCommand, packet: canvas.CanvasGpuPacket) void { - adoptCanvasBaselineEntries(view, current, packet.surface_size, packet.scale); +fn adoptCanvasPacketBaseline( + view: anytype, + current: []const CanvasPacketCurrentCommand, + packet: canvas.CanvasGpuPacket, + clips: CanvasClipSet, +) void { + adoptCanvasBaselineEntries(view, current, packet.surface_size, packet.scale, clips); view.canvas_packet_baseline_pixels = false; } @@ -1684,12 +1778,24 @@ fn adoptCanvasPacketBaseline(view: anytype, current: []const CanvasPacketCurrent /// describes what the presented PIXEL buffer shows. Marked so the packet /// patch gate refuses it — only the frame planner's dirty-bounds /// refinement may consume a pixel-adopted baseline. -fn adoptCanvasPixelPresentBaseline(view: anytype, current: []const CanvasPacketCurrentCommand, surface_size: geometry.SizeF, scale: f32) void { - adoptCanvasBaselineEntries(view, current, surface_size, scale); +fn adoptCanvasPixelPresentBaseline( + view: anytype, + current: []const CanvasPacketCurrentCommand, + surface_size: geometry.SizeF, + scale: f32, + clips: CanvasClipSet, +) void { + adoptCanvasBaselineEntries(view, current, surface_size, scale, clips); view.canvas_packet_baseline_pixels = true; } -fn adoptCanvasBaselineEntries(view: anytype, current: []const CanvasPacketCurrentCommand, surface_size: geometry.SizeF, scale: f32) void { +fn adoptCanvasBaselineEntries( + view: anytype, + current: []const CanvasPacketCurrentCommand, + surface_size: geometry.SizeF, + scale: f32, + clips: CanvasClipSet, +) void { for (current, 0..) |entry, index| { view.canvas_packet_baseline_keys[index] = entry.key; view.canvas_packet_baseline_fingerprints[index] = entry.fingerprint; @@ -1698,6 +1804,9 @@ fn adoptCanvasBaselineEntries(view: anytype, current: []const CanvasPacketCurren view.canvas_packet_baseline_count = current.len; view.canvas_packet_baseline_surface_size = surface_size; view.canvas_packet_baseline_scale = scale; + for (clips.rects[0..clips.count], 0..) |rect, index| view.canvas_packet_baseline_clip_rects[index] = rect; + view.canvas_packet_baseline_clip_count = clips.count; + view.canvas_packet_baseline_clip_overflow = clips.overflow; view.canvas_packet_baseline_valid = true; } diff --git a/src/runtime/canvas_frame_patch_tests.zig b/src/runtime/canvas_frame_patch_tests.zig index 3d8998ed6..4692a25c8 100644 --- a/src/runtime/canvas_frame_patch_tests.zig +++ b/src/runtime/canvas_frame_patch_tests.zig @@ -352,13 +352,15 @@ fn buildScriptScene( count += 1; for (0..message_count) |row| { const y: f32 = @as(f32, @floatFromInt(row)) * 28 + 12 - scroll_offset; - commands[count] = .{ .fill_rounded_rect = .{ - .id = @intCast(1_000 + row), - .rect = geometry.RectF.init(8, y, patch_surface_width - 16, 24), - .radius = canvas.Radius.all(6), - // The "toggle": message 0's bubble flips color from step 1 on. - .fill = .{ .color = if (row == 0 and step >= 1) canvas.Color.rgb8(37, 99, 235) else canvas.Color.rgb8(30, 41, 59) }, - } }; + commands[count] = .{ + .fill_rounded_rect = .{ + .id = @intCast(1_000 + row), + .rect = geometry.RectF.init(8, y, patch_surface_width - 16, 24), + .radius = canvas.Radius.all(6), + // The "toggle": message 0's bubble flips color from step 1 on. + .fill = .{ .color = if (row == 0 and step >= 1) canvas.Color.rgb8(37, 99, 235) else canvas.Color.rgb8(30, 41, 59) }, + }, + }; count += 1; const text = std.fmt.bufPrint(&text_storage[row], "message {d} body{s}", .{ row, @@ -1133,6 +1135,62 @@ test "command fingerprints cover every encoded field" { changed.effect = .{ .shadow = .{ .rect = geometry.RectF.init(0, 0, 4, 4), .blur = 3 } }; try std.testing.expect(canvas.canvasGpuCommandFingerprint(changed) != base_fingerprint); + // PROBE (not upstream): `.cells` is an encoded field — + // serialization.zig writeBinaryCellGrid puts every cell's fg, bg, + // underline_color, flags and cluster text on the wire. A cell_grid + // command built the way gpu.zig builds it leaves shape/paint/text/ + // image/effect at their empty defaults and its bounds are purely + // geometric (cell_grid.zig bounds()), so `.cells` is the ONLY field + // that varies when typed text changes. + const grid_cells = [_]canvas.Cell{ + .{ .text_offset = 0, .text_len = 1, .fg = .{ .r = 244, .g = 247, .b = 251, .a = 255 } }, + .{ .text_offset = 1, .text_len = 1, .fg = .{ .r = 244, .g = 247, .b = 251, .a = 255 } }, + }; + const grid_base = canvas.CanvasGpuCommand{ + .command_index = 0, + .id = 0x61_0001, + .kind = .cell_grid, + .bounds = geometry.RectF.init(0, 0, 20, 10), + .cells = .{ + .font_id = 1, + .font_size = 13, + .origin = geometry.PointF.init(0, 0), + .cell_width = 10, + .cell_height = 10, + .baseline = 8, + .cols = 2, + .rows = 1, + .cells = &grid_cells, + .text = "ab", + }, + }; + const grid_fingerprint = canvas.canvasGpuCommandFingerprint(grid_base); + + // The user types: the cluster text changes "ab" -> "xb". + var typed = grid_base; + typed.cells.?.text = "xb"; + try std.testing.expect(canvas.canvasGpuCommandFingerprint(typed) != grid_fingerprint); + + // ...and a per-cell foreground changes to the background colour. + const recoloured_cells = [_]canvas.Cell{ + .{ .text_offset = 0, .text_len = 1, .fg = .{ .r = 9, .g = 11, .b = 15, .a = 255 } }, + .{ .text_offset = 1, .text_len = 1, .fg = .{ .r = 9, .g = 11, .b = 15, .a = 255 } }, + }; + var recoloured = grid_base; + recoloured.cells.?.cells = &recoloured_cells; + try std.testing.expect(canvas.canvasGpuCommandFingerprint(recoloured) != grid_fingerprint); + + // A whole screenful of different text, different length, different + // colours -- still the same fingerprint. + const other_cells = [_]canvas.Cell{ + .{ .text_offset = 0, .text_len = 1, .fg = .{ .r = 255, .g = 0, .b = 0, .a = 255 }, .flags = 1 }, + .{ .text_offset = 1, .text_len = 1, .fg = .{ .r = 0, .g = 255, .b = 0, .a = 255 }, .flags = 2 }, + }; + var wholly_different = grid_base; + wholly_different.cells.?.cells = &other_cells; + wholly_different.cells.?.text = "ZQ"; + try std.testing.expect(canvas.canvasGpuCommandFingerprint(wholly_different) != grid_fingerprint); + // Keyed commands retain under their ObjectId; unkeyed commands get a // synthetic key that changes when their index or content moves. try std.testing.expectEqual(@as(u64, 41), canvas.canvasGpuPacketCommandKey(base, base_fingerprint)); @@ -1254,3 +1312,110 @@ test "pixel presents adopt a dirty-refinement baseline only for opted-in hosts a try std.testing.expectEqual(canvas.binary_packet_load_action_patch, harness.null_platform.gpu_surface_packet_present_binary_load_action); try std.testing.expectEqual(platform.GpuPresentPacketMode.patch, harness.runtime.views[0].gpu_present_packet_mode); } + +test "widening a clip dirties the pixels it reveals" { + // The stale-column bug: a terminal pane narrows and widens again (a + // split collapsing back to full width), and the columns the narrow + // frame hid keep the glyphs the WIDE frame drew there. + // + // The render planner erases `push_clip`/`pop_clip` — they never + // become render commands, only a `clip` field on the commands inside + // them — so the retained packet baseline holds no key for a clip and + // the refined dirty rect derived from it names only what changed + // commands and evicted keys cover. Whenever a command's own bounds + // move with the clip, that covers the revealed pixels; a clip that + // moves over content whose bounds and fingerprints do NOT move has + // nothing naming them, and the host scissors to a region that never + // reaches them while its retained backing keeps whatever it last + // drew. This test pins the closed contract: the baseline carries its + // clip rects and the next frame adds the difference to the dirty + // region, in both directions. + // + // Frames that only change CONTENT under a stationary clip keep their + // region-scoped patches — that is the whole incremental path and it + // must survive. + var app_state: PatchHarnessApp = .{}; + const harness = try createPatchHarness(&app_state); + defer harness.destroy(std.testing.allocator); + var buffers = try PresentBuffers.init(std.testing.allocator); + defer buffers.deinit(std.testing.allocator); + const view = &harness.runtime.views[0]; + + // A clipped "pane" of keyed rows, the terminal grid's shape: one + // clip around a column of rows. Only the row TINT changes with the + // pane width, so the narrow frame keeps every row's key and the + // revealed pixels can only come from the clip. + const row_count: usize = 24; + var commands: [row_count + 2]canvas.CanvasCommand = undefined; + const Pane = struct { + fn build(storage: *[row_count + 2]canvas.CanvasCommand, width: f32) []const canvas.CanvasCommand { + storage[0] = .{ .push_clip = .{ + .id = 900, + .rect = geometry.RectF.init(0, 0, width, patch_surface_height), + } }; + for (0..row_count) |row| { + storage[row + 1] = .{ + .fill_rect = .{ + .id = @intCast(1_000 + row), + // Confined to the LEFT half in every frame, so no + // command's bounds or fingerprint moves when the clip + // does: the revealed pixels have nothing but the clip + // difference naming them. + .rect = geometry.RectF.init(0, @as(f32, @floatFromInt(row)) * 9, patch_surface_width / 2, 8), + .fill = .{ .color = canvas.Color.rgb8(30, 41, 59) }, + }, + }; + } + storage[row_count + 1] = .pop_clip; + return storage[0 .. row_count + 2]; + } + }; + + // Wide: the full-width pane, baselined with a keyed full present. + _ = try harness.runtime.setCanvasDisplayList(1, "canvas", .{ + .commands = Pane.build(&commands, patch_surface_width), + }); + _ = try presentFrame(harness, &buffers, 90); + try std.testing.expectEqual(platform.GpuPresentPacketMode.full, view.gpu_present_packet_mode); + try std.testing.expectEqual(@as(usize, 1), view.canvas_packet_baseline_clip_count); + + // Content changes under the SAME clip still ride a region-scoped + // patch: carrying clips must cost the incremental path nothing on + // ordinary frames. + commands[5].fill_rect.fill = .{ .color = canvas.Color.rgb8(37, 99, 235) }; + _ = try harness.runtime.setCanvasDisplayList(1, "canvas", .{ .commands = commands[0 .. row_count + 2] }); + const toggled = try presentFrame(harness, &buffers, 91); + try std.testing.expectEqual(platform.GpuPresentPacketMode.patch, view.gpu_present_packet_mode); + const toggled_dirty = toggled.frame.dirty_bounds orelse return error.TestExpectedDirtyBounds; + try std.testing.expect(toggled_dirty.height < patch_surface_height); + + // Narrow: the split opens and the pane's clip shrinks to half width. + _ = try harness.runtime.setCanvasDisplayList(1, "canvas", .{ + .commands = Pane.build(&commands, patch_surface_width / 2), + }); + _ = try presentFrame(harness, &buffers, 92); + try std.testing.expectEqual(patch_surface_width / 2, view.canvas_packet_baseline_clip_rects[0].width); + + // Wide again: the split collapses. THIS is the frame that used to + // ship a dirty rect stopping at the narrow pane's right edge, so the + // revealed columns kept the glyphs the first wide frame drew there. + _ = try harness.runtime.setCanvasDisplayList(1, "canvas", .{ + .commands = Pane.build(&commands, patch_surface_width), + }); + const widened = try presentFrame(harness, &buffers, 93); + try std.testing.expectEqual(CanvasPresentationMode.gpu_packet, widened.mode); + const revealed = widened.frame.dirty_bounds orelse return error.TestExpectedDirtyBounds; + // Every column the narrow pane hid is inside the repaint, even + // though not one command changed. + try std.testing.expect(revealed.x <= patch_surface_width / 2); + try std.testing.expect(revealed.x + revealed.width >= patch_surface_width); + try std.testing.expectEqual(patch_surface_width, view.canvas_packet_baseline_clip_rects[0].width); + + // ...and the incremental path resumes on the next content-only frame. + commands[7].fill_rect.fill = .{ .color = canvas.Color.rgb8(220, 38, 38) }; + _ = try harness.runtime.setCanvasDisplayList(1, "canvas", .{ .commands = commands[0 .. row_count + 2] }); + const resumed = try presentFrame(harness, &buffers, 94); + try std.testing.expectEqual(platform.GpuPresentPacketMode.patch, view.gpu_present_packet_mode); + const resumed_dirty = resumed.frame.dirty_bounds orelse return error.TestExpectedDirtyBounds; + try std.testing.expect(resumed_dirty.height < patch_surface_height); +} diff --git a/src/runtime/canvas_frame_retained_tests.zig b/src/runtime/canvas_frame_retained_tests.zig index e16e1a19f..694bfd47f 100644 --- a/src/runtime/canvas_frame_retained_tests.zig +++ b/src/runtime/canvas_frame_retained_tests.zig @@ -535,7 +535,7 @@ test "runtime next canvas frame retains renderer cache families" { try std.testing.expectEqual(@as(usize, 1), first_frame.visual_effect_plan.shadowCount()); try std.testing.expectEqual(@as(usize, 1), first_frame.visual_effect_cache_plan.uploadCount()); - const first_info = runtimeViewInfo(harness.runtime.views[0]); + const first_info = runtimeViewInfo(&harness.runtime.views[0]); try std.testing.expectEqual(@as(usize, 1), first_info.canvas_frame_path_geometry_count); try std.testing.expect(first_info.canvas_frame_path_geometry_vertex_count > 0); try std.testing.expect(first_info.canvas_frame_path_geometry_index_count > 0); @@ -605,7 +605,7 @@ test "runtime next canvas frame retains renderer cache families" { try std.testing.expectEqual(@as(usize, 0), retained_frame.visual_effect_cache_plan.uploadCount()); try std.testing.expectEqual(@as(usize, 1), retained_frame.visual_effect_cache_plan.retainCount()); - const retained_info = runtimeViewInfo(harness.runtime.views[0]); + const retained_info = runtimeViewInfo(&harness.runtime.views[0]); try std.testing.expectEqual(@as(usize, 1), retained_info.canvas_frame_path_geometry_retain_count); try std.testing.expectEqual(@as(usize, 1), retained_info.canvas_frame_image_retain_count); try std.testing.expectEqual(@as(usize, 1), retained_info.canvas_frame_layer_retain_count); diff --git a/src/runtime/canvas_limits.zig b/src/runtime/canvas_limits.zig index 69daf3051..1cb55494a 100644 --- a/src/runtime/canvas_limits.zig +++ b/src/runtime/canvas_limits.zig @@ -16,6 +16,32 @@ const canvas = @import("canvas"); // in the Runtime (in-place constructed, large fields left uninitialized), // measured at 61.3 MiB -> 119.3 MiB (RuntimeView 1.12 MiB -> 2.65 MiB x 32 // view slots); pages are only touched as views use their capacity. +// +// Raised again (2048 -> 4096) for the TERMINAL surface, which is the +// densest widget the toolkit hosts: a terminal row costs one background +// command per contiguous same-colour run plus one text command per +// contiguous same-foreground run, so a styled 200-column row runs 30-60 +// commands where a whole three-pane desktop view runs a few hundred. At +// 2048 a 60-row viewport had ~30 commands per row to spend and a +// realistically colored screen (syntax highlighting, htop meters, a +// colored build log) truncated from the bottom; 4096 doubles that to +// ~64. Measured cost: each command slot carries ~696 B across the +// view's retained mirrors (the display list 120 B, the presented mirror +// 40 B, the packet baseline key/fingerprint/bounds 32 B, render +// animations 136 B, render overrides 48 B, and the path-geometry, +// image, layer, resource, visual-effect and text-layout caches 320 B), +// so RuntimeView measures 3.44 MiB -> 4.83 MiB and the 32-slot Runtime +// 110.0 MiB -> 154.5 MiB of fixed-capacity address space. +// +// Back to 2048 with the packed cell grid. The terminal was the only +// thing that ever needed 4096: it now costs ONE command per row (a +// 300x100 truecolor screen is 103 commands, where per-run painting +// wanted ~60,000), and its real budget is `max_canvas_cells_per_view`. +// Measured: the three-pane desktop shape this budget was raised for in +// the first place peaks around 500 commands, and the framework's own +// suite — every widget, chart, markdown, code, and terminal test — +// passes at 2048. Taking it back returns ~45 MiB of the Runtime's +// fixed-capacity address space (1.39 MiB per view slot x 32). pub const max_canvas_commands_per_view: usize = 2048; pub const max_canvas_gradient_stops_per_view: usize = 64; // Raised 128 -> 2048 with icon-in-button and the 41-icon registry: vector @@ -32,7 +58,30 @@ pub const max_canvas_gradient_stops_per_view: usize = 64; // draw paths. pub const max_canvas_path_elements_per_view: usize = 2048; pub const max_canvas_glyphs_per_view: usize = 8192; -pub const max_canvas_text_bytes_per_view: usize = 32768; +// Packed terminal CELLS per view (canvas.cell_grid): the budget that +// replaced the terminal's command budget. A `cell_grid` command is one +// command carrying a whole screen, so a terminal's cost stopped being +// "commands" and became "area" — and area is what a terminal actually +// scales with. One cell is 20 B, so 32768 is 640 KB in the view's +// retained copy and the same again in the frame's builder-owned store +// (threadlocal, one per planning thread). It covers a 300x100 viewport +// (30,000 cells) with room over, or two 160x100 split panes exactly. +// Beyond it the painter degrades row-atomically and says so, the same +// contract every other frame budget carries. +pub const max_canvas_cells_per_view: usize = 32768; +// Frame TEXT bytes: every `draw_text` in the finished display list, +// builder-owned and referenced alike. Raised 32 KiB -> 64 KiB with the +// terminal work: a terminal viewport is one widget whose every visible +// cell is a presented byte, so a 200x60 screen is ~12 KB and a 300x100 +// one ~30 KB before any chrome — and a split of two panes doubles that +// while each pane must also hold back a share for the other. The old +// 32 KiB made a wide screen degrade on TEXT with 96% of the command +// budget unspent, which is the wrong cliff in the wrong place. Memory +// is cheap here compared with the command budget: one byte array per +// view (32 KiB -> 64 KiB x 32 view slots = 1 MiB -> 2 MiB), plus the +// matching builder-owned store (`canvas.max_display_list_text_bytes`, +// which a lockstep test keeps equal) and the display-list copy scratch. +pub const max_canvas_text_bytes_per_view: usize = 65536; // Retained packet commands per gpu-surface view: the host-side command // dictionary that incremental (`patch`) presents edit, and the engine's // per-view key+fingerprint mirror that derives those patches. Derived @@ -41,11 +90,21 @@ pub const max_canvas_text_bytes_per_view: usize = 32768; // loudly, before this one can. The AppKit host pins the same value // (NATIVE_SDK_PACKET_RETAINED_COMMAND_CAP in appkit_host.m); a frame past // either side's cap presents FULL (and the host drops its retained -// state), never a partial dictionary. Engine memory is two u64 arrays: -// 16 B x 2048 = 32 KiB per view x 32 view slots = 1 MiB fixed address -// space; host memory is the decoded command dictionaries, realistically -// a few hundred KB for a dense view. +// state), never a partial dictionary. Engine memory is two u64 arrays +// plus the per-key bounds: 32 B x 4096 = 128 KiB per view x 32 view +// slots = 4 MiB fixed address space; host memory is the decoded command +// dictionaries, realistically a few hundred KB for a dense view. pub const max_canvas_retained_packet_commands_per_view: usize = max_canvas_commands_per_view; +// Distinct CLIP RECTS a frame's retained baseline can carry. The render +// planner erases `push_clip`/`pop_clip` into a per-command `clip` field, +// so no retained key names a clip and the patch-derived dirty rect +// cannot see one move on its own — the baseline keeps the rects beside +// the keys and the next frame adds the difference (revealed and vacated +// pixels both). One entry per clipping widget, so 32 covers a scroll +// pane per split plus chrome; a frame past it refuses the refinement and +// keeps the conservative dirty bounds. Memory is 16 B x 32 = 512 B per +// view x 32 view slots = 16 KiB. +pub const max_canvas_packet_clip_rects_per_view: usize = 32; pub const max_canvas_diff_changes_per_view: usize = max_canvas_commands_per_view * 2 + 1; pub const max_canvas_render_animations_per_view: usize = max_canvas_commands_per_view; // Sized to the widget loop-animation budget below plus caret headroom: @@ -76,12 +135,12 @@ pub const max_canvas_visual_effect_cache_actions_per_view: usize = max_canvas_vi // deriving this from `max_canvas_commands_per_view` makes plan-list // overflow structurally unreachable — the command budget fails first, // loudly, at build time. Memory is scratch + per-view cache: the -// per-frame planning arrays are threadlocal (TextLayoutPlan 96 B x 2048 = -// 192 KiB, cache entries 96 B x 2048 = 192 KiB, cache actions 96 B x -// 4096 = 384 KiB — ~0.8 MiB once per thread, was ~0.2 MiB), and each -// RuntimeView retains one cache-entry array (96 B x 2048 = 192 KiB x 32 -// view slots = 6 MiB fixed address space, was 1.5 MiB; pages touch only -// as views lay out text). +// per-frame planning arrays are threadlocal (TextLayoutPlan 96 B x 4096 = +// 384 KiB, cache entries 96 B x 4096 = 384 KiB, cache actions 96 B x +// 8192 = 768 KiB — ~1.5 MiB once per thread), and each RuntimeView +// retains one cache-entry array (96 B x 4096 = 384 KiB x 32 view slots +// = 12 MiB fixed address space; pages touch only as views lay out +// text). pub const max_canvas_text_layouts_per_view: usize = max_canvas_commands_per_view; // Wrapped text lines across all of a frame's layout plans (the plan // arrays above index into one shared line pool). Sized with the diff --git a/src/runtime/canvas_widget_clipboard_tests.zig b/src/runtime/canvas_widget_clipboard_tests.zig index 6c52fd92d..6271073d1 100644 --- a/src/runtime/canvas_widget_clipboard_tests.zig +++ b/src/runtime/canvas_widget_clipboard_tests.zig @@ -709,3 +709,55 @@ test "focused editable selection wins copy over a stale static selection" { var clipboard_buffer: [64]u8 = undefined; try std.testing.expectEqualStrings("Field", try harness.runtime.readClipboard(&clipboard_buffer)); } + +test "a blinking terminal cursor arms the runtime's blink; a steady one does not" { + // `TerminalCursor.blinking` is the emulator's DECSCUSR answer, and + // blinking is TIME — a painter has none. The runtime arms the same + // looping opacity animation a text caret uses, keyed on the cursor + // command. This pins the wiring end to end; what it cannot pin is a + // real shell asking for it, since a default DECSCUSR is steady. + var app_state: ClipboardTestApp = .{}; + const app = app_state.app(); + const harness = try createClipboardHarness(app); + defer harness.destroy(std.testing.allocator); + + var grid = canvas.TerminalGrid{ + .background = canvas.Color.rgba(0, 0, 0, 1), + .foreground = canvas.Color.rgba(1, 1, 1, 1), + .cursor_color = canvas.Color.rgba(1, 1, 1, 1), + .selection_color = canvas.Color.rgba(0, 0.5, 1, 1), + .cursor = .{ .x = 0, .y = 0, .blinking = true }, + }; + const terminal = canvas.Widget{ + .id = 2, + .kind = .terminal, + .frame = geometry.RectF.init(12, 16, 200, 80), + .terminal = .{ .pty = 7, .grid = &grid }, + }; + var nodes: [2]canvas.WidgetLayoutNode = undefined; + const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{terminal} }, geometry.RectF.init(0, 0, 320, 200), &nodes); + _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout); + _ = try harness.runtime.emitCanvasWidgetDisplayList(1, "canvas", .{}); + + // Focused by CLICK — deliberately not by keyboard. A terminal cursor + // blinks on focus, not on focus-VISIBLE (the keyboard ring), which a + // caret-shaped gate would have got wrong. + try harness.runtime.dispatchPlatformEvent(app, pointerInput(.pointer_down, 40, 40)); + try std.testing.expectEqual( + canvas.terminal_grid.cursorCommandId(2), + harness.runtime.views[0].canvas_widget_caret_blink_id, + ); + + // A steady cursor (DECSCUSR 2, the common default) arms nothing — + // an always-on blink would be a worse bug than none. + grid.cursor = .{ .x = 0, .y = 0, .blinking = false }; + _ = try harness.runtime.emitCanvasWidgetDisplayList(1, "canvas", .{}); + try std.testing.expectEqual(@as(canvas.ObjectId, 0), harness.runtime.views[0].canvas_widget_caret_blink_id); + + // ...and neither does a cursor whose session has ended: that pose is + // already the dim hollow at-rest cursor. + grid.cursor = .{ .x = 0, .y = 0, .blinking = true }; + grid.running = false; + _ = try harness.runtime.emitCanvasWidgetDisplayList(1, "canvas", .{}); + try std.testing.expectEqual(@as(canvas.ObjectId, 0), harness.runtime.views[0].canvas_widget_caret_blink_id); +} diff --git a/src/runtime/canvas_widget_context_menu.zig b/src/runtime/canvas_widget_context_menu.zig index d69aafa2e..9ab682e1a 100644 --- a/src/runtime/canvas_widget_context_menu.zig +++ b/src/runtime/canvas_widget_context_menu.zig @@ -20,6 +20,8 @@ //! native menu they present no synthesized surface (there are no //! app-declared items to mount), while their available keyboard paths //! remain unchanged. +//! `ElementOptions.context_menu_policy` can keep only tier 1 or bypass +//! this menu path entirely; its `.automatic` default preserves the order. //! //! Presentation is asynchronous (macOS `popUpMenuPositioningItem` runs a //! nested tracking loop): the platform emits a `context_menu_action` @@ -89,16 +91,75 @@ pub const PendingCanvasWidgetContextMenu = struct { pub fn RuntimeCanvasWidgetContextMenu(comptime Runtime: type) type { return struct { - /// True when the input event belongs to the secondary button and - /// must be consumed by the context-menu path instead of the - /// primary pointer pipeline (a right-click must never act as a - /// press). - pub fn canvasWidgetContextPointerInput(input_event: platform.GpuSurfaceInputEvent) bool { - if (input_event.button != 1) return false; - return switch (input_event.kind) { + /// Choose context-menu versus ordinary routing on secondary down, + /// then retain that owner through the matching pointer's later + /// phases. Widget-tree rebuilds may change policy but never an + /// in-flight gesture. Returns true for the consumed menu owner. + pub fn canvasWidgetContextPointerInput(self: *Runtime, input_event: platform.GpuSurfaceInputEvent) bool { + const pointer_phase = switch (input_event.kind) { .pointer_down, .pointer_up, .pointer_drag, .pointer_move, .pointer_cancel => true, else => false, }; + if (!pointer_phase) return false; + const index = runtimeFindViewIndex(self, input_event.window_id, input_event.label) orelse return input_event.button == 1; + const view = &self.views[index]; + + if (input_event.kind == .pointer_down) { + if (input_event.button != 1) return false; + // A new secondary down supersedes any stale owner on this + // view, matching the existing one-pressed-widget capacity. + const routed = CanvasWidgetEventMethods().routeCanvasWidgetPointerInput(self, input_event, &self.widget_event_route_entries) catch { + view.canvas_widget_secondary_gesture_owner = .context_menu; + view.canvas_widget_secondary_gesture_pointer_id = input_event.pointer_id; + return true; + }; + const owner: @TypeOf(view.canvas_widget_secondary_gesture_owner) = if (routed) |pointer_event| + if (contextMenuPolicyForTarget(self, index, pointer_event.target) == .disabled) .ordinary else .context_menu + else + .context_menu; + view.canvas_widget_secondary_gesture_owner = owner; + view.canvas_widget_secondary_gesture_pointer_id = input_event.pointer_id; + return owner == .context_menu; + } + + const owner = view.canvas_widget_secondary_gesture_owner; + if (owner == .none) { + // A labelled secondary phase with no matching down stays + // consumed, preserving the pre-policy fail-closed behavior. + return input_event.button == 1; + } + if (owner == .context_menu and input_event.button != 1 and view.canvas_widget_pressed_id != 0) { + // Async presenters (GTK) can leave the menu open while an + // ordinary primary press rebuilds the underlying app. That + // press owns the shared pressed-widget slot; route its + // buttonless drag/up/cancel ordinarily without retiring the + // still-open menu gesture (the menu action or secondary + // terminal phase does that). + return false; + } + if (view.canvas_widget_secondary_gesture_pointer_id != input_event.pointer_id) { + // One view has one pressed-widget slot. Until the owning + // pointer ends, no other pointer phase may enter that shared + // pipeline and release or replace its capture, even when the + // host leaves button=0 on the later phase. + return true; + } + + // Desktop mouse streams share pointer_id=0 across every button. + // Only the secondary up that matches the initiating action may + // retire this owner; a chorded primary release remains ordinary + // and leaves the later secondary release live. Pointer cancel is + // whole-pointer termination and remains valid without a button. + if (input_event.kind == .pointer_up and input_event.button != 1) { + return false; + } + + const consume = owner == .context_menu; + if (input_event.kind == .pointer_up or input_event.kind == .pointer_cancel) { + view.canvas_widget_secondary_gesture_owner = .none; + view.canvas_widget_secondary_gesture_pointer_id = 0; + } + return consume; } /// Present the context menu for a secondary-button press: hit-test @@ -120,6 +181,7 @@ pub fn RuntimeCanvasWidgetContextMenu(comptime Runtime: type) type { const pointer_event = routed orelse return; const index = runtimeFindViewIndex(self, input_event.window_id, input_event.label) orelse return; const point = geometry.PointF.init(input_event.x, input_event.y); + const policy = contextMenuPolicyForTarget(self, index, pointer_event.target); var items: [platform.max_context_menu_items]platform.ContextMenuItem = undefined; const has_presenter = self.options.platform.services.show_context_menu_fn != null; @@ -190,6 +252,18 @@ pub fn RuntimeCanvasWidgetContextMenu(comptime Runtime: type) type { return; } + // `.declared_only` stops after the declared-menu tier. Keep the + // existing context-press fallback (the on-hold alternative), + // but never synthesize an SDK text/terminal menu. + if (policy == .declared_only) { + try self.dispatchEvent(app, .{ .canvas_widget_context_press = .{ + .window_id = input_event.window_id, + .view_label = self.views[index].label, + .press_target = pointer_event.press_target, + } }); + return; + } + // 2/3. Editable text and terminal targets: standard menus // wired to the existing clipboard and committed-input // paths. Focus the target first so paste lands where the @@ -372,6 +446,12 @@ pub fn RuntimeCanvasWidgetContextMenu(comptime Runtime: type) type { // the main queue); GTK popovers are asynchronous and CAN. if (pending.window_id != event.window_id or pending.token != event.token) return; self.canvas_widget_context_menu_pending = null; + // Native presenters may resolve selection/dismissal without + // forwarding the physical secondary up (the platform menu owns + // that tracking loop). The action is therefore a second valid + // terminal for a menu-owned gesture. Never clear `.ordinary`: + // policy-disabled capture still requires its matching up/cancel. + clearCanvasWidgetContextGesture(self, pending.window_id, pending.viewLabel()); if (event.item_id == 0) { // Dismissed without a selection. App menus tell the app: // UiApp disarms the token's presented-items snapshot and @@ -557,6 +637,19 @@ pub fn RuntimeCanvasWidgetContextMenu(comptime Runtime: type) type { return result; } + fn contextMenuPolicyForTarget(self: *const Runtime, view_index: usize, target: ?canvas.WidgetHit) canvas.WidgetContextMenuPolicy { + const hit = target orelse return .automatic; + return self.views[view_index].widgetLayoutTree().contextMenuPolicyAt(hit.index); + } + + fn clearCanvasWidgetContextGesture(self: *Runtime, window_id: platform.WindowId, label: []const u8) void { + const index = runtimeFindViewIndex(self, window_id, label) orelse return; + const view = &self.views[index]; + if (view.canvas_widget_secondary_gesture_owner != .context_menu) return; + view.canvas_widget_secondary_gesture_owner = .none; + view.canvas_widget_secondary_gesture_pointer_id = 0; + } + fn CanvasWidgetEventMethods() type { return runtime_canvas_widget_events.RuntimeCanvasWidgetEvents(Runtime); } diff --git a/src/runtime/canvas_widget_context_menu_tests.zig b/src/runtime/canvas_widget_context_menu_tests.zig index a5a4fa112..4827e6620 100644 --- a/src/runtime/canvas_widget_context_menu_tests.zig +++ b/src/runtime/canvas_widget_context_menu_tests.zig @@ -21,6 +21,10 @@ const canvas_limits = @import("canvas_limits.zig"); const MenuTestApp = struct { pointer_count: u32 = 0, raw_input_count: u32 = 0, + context_press_count: u32 = 0, + last_pointer_target: canvas.ObjectId = 0, + last_pointer_captured: canvas.ObjectId = 0, + last_pointer_phase: canvas.WidgetPointerPhase = .hover, menu_count: u32 = 0, last_menu_target: canvas.ObjectId = 0, last_menu_item_index: usize = 0, @@ -64,8 +68,14 @@ const MenuTestApp = struct { fn event(context: *anyopaque, runtime: *Runtime, event_value: Event) anyerror!void { const self: *@This() = @ptrCast(@alignCast(context)); switch (event_value) { - .canvas_widget_pointer => self.pointer_count += 1, + .canvas_widget_pointer => |pointer_event| { + self.pointer_count += 1; + self.last_pointer_target = if (pointer_event.target) |target| target.id else 0; + self.last_pointer_captured = pointer_event.pointer.captured_id orelse 0; + self.last_pointer_phase = pointer_event.pointer.phase; + }, .gpu_surface_input => self.raw_input_count += 1, + .canvas_widget_context_press => self.context_press_count += 1, .canvas_widget_context_menu => |menu_event| { self.menu_count += 1; self.last_menu_target = menu_event.target_id; @@ -161,6 +171,52 @@ fn createMenuHarness(app: App) !*TestHarness() { return harness; } +fn installTerminal( + harness: *TestHarness(), + grid: *canvas.TerminalGrid, + policy: canvas.WidgetContextMenuPolicy, + pty: u64, + text: []const u8, +) !void { + grid.screen_text = text; + const terminal = canvas.Widget{ + .id = 2, + .kind = .terminal, + .frame = geometry.RectF.init(12, 16, 280, 120), + .text = text, + .terminal = .{ .pty = pty, .grid = grid }, + .semantics = .{ .label = "Session", .context_menu_policy = policy }, + }; + var nodes: [2]canvas.WidgetLayoutNode = undefined; + const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{terminal} }, geometry.RectF.init(0, 0, 320, 200), &nodes); + _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout); +} + +fn testTerminalGrid() canvas.TerminalGrid { + return .{ + .background = canvas.Color.rgba(0, 0, 0, 1), + .foreground = canvas.Color.rgba(1, 1, 1, 1), + .cursor_color = canvas.Color.rgba(1, 1, 1, 1), + .selection_color = canvas.Color.rgba(0, 0.5, 1, 1), + }; +} + +fn secondaryPointer(kind: platform.GpuSurfaceInputKind, pointer_id: u64, x: f32, y: f32) platform.Event { + return .{ .gpu_surface_input = .{ + .window_id = 1, + .label = "canvas", + .kind = kind, + .button = switch (kind) { + .pointer_down, .pointer_up, .pointer_cancel => 1, + else => 0, + }, + .pointer_id = pointer_id, + .x = x, + .y = y, + .timestamp_ns = 1_000_000_000, + } }; +} + test "right click over a widget with a declared menu presents it natively and dispatches the selection" { var app_state: MenuTestApp = .{}; const app = app_state.app(); @@ -178,6 +234,7 @@ test "right click over a widget with a declared menu presents it natively and di .frame = geometry.RectF.init(10, 10, 200, 40), .text = "Task", .context_menu = &items, + .semantics = .{ .context_menu_policy = .declared_only }, }; var nodes: [2]canvas.WidgetLayoutNode = undefined; const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{row} }, geometry.RectF.init(0, 0, 320, 200), &nodes); @@ -511,6 +568,8 @@ test "right click on a terminal presents Copy and Paste wired to selection and c // Secondary-click focus ensures the eventual paste addresses this // terminal even when another editor previously owned focus. try std.testing.expectEqual(@as(canvas.ObjectId, 2), harness.runtime.views[0].canvas_widget_focused_id); + try std.testing.expectEqual(canvas.WidgetContextMenuPolicy.automatic, harness.runtime.views[0].widget_layout_nodes[1].widget.semantics.context_menu_policy); + try std.testing.expectEqual(@as(u32, 0), app_state.pointer_count); try harness.runtime.dispatchPlatformEvent(app, menuAction(harness.null_platform.context_menu_token, 2)); var clipboard_buffer: [64]u8 = undefined; @@ -545,6 +604,261 @@ test "right click on a terminal presents Copy and Paste wired to selection and c try std.testing.expectEqualStrings(shortcut_paste, app_state.last_edit_insert[0..app_state.last_edit_insert_len]); } +test "disabled terminal context menus bypass menu handling and retain pointer routing capture and semantics" { + var app_state: MenuTestApp = .{}; + const app = app_state.app(); + const harness = try createMenuHarness(app); + defer harness.destroy(std.testing.allocator); + + var grid = canvas.TerminalGrid{ + .background = canvas.Color.rgba(0, 0, 0, 1), + .foreground = canvas.Color.rgba(1, 1, 1, 1), + .cursor_color = canvas.Color.rgba(1, 1, 1, 1), + .selection_color = canvas.Color.rgba(0, 0.5, 1, 1), + .screen_text = "alpha beta", + }; + const terminal = canvas.Widget{ + .id = 2, + .kind = .terminal, + .frame = geometry.RectF.init(12, 16, 280, 120), + .text = grid.screen_text, + .terminal = .{ .pty = 7, .grid = &grid }, + .semantics = .{ .label = "Session", .context_menu_policy = .disabled }, + }; + var nodes: [2]canvas.WidgetLayoutNode = undefined; + const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{terminal} }, geometry.RectF.init(0, 0, 320, 200), &nodes); + _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout); + + // The terminal remains the terminal: textbox semantics expose its live + // value, and hover keeps the native I-beam cursor. + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = "canvas", + .kind = .pointer_move, + .x = 100, + .y = 40, + } }); + try std.testing.expectEqual(platform.Cursor.text, harness.runtime.views[0].canvas_widget_cursor); + const retained = try harness.runtime.canvasWidgetLayout(1, "canvas"); + try std.testing.expectEqual(canvas.WidgetKind.terminal, retained.nodes[1].widget.kind); + try std.testing.expectEqualStrings("alpha beta", retained.nodes[1].widget.text); + try std.testing.expectEqual(canvas.WidgetContextMenuPolicy.disabled, retained.nodes[1].widget.semantics.context_menu_policy); + + const snapshot = harness.runtime.automationSnapshot("Terminal policy"); + var terminal_snapshot: ?automation.snapshot.Widget = null; + for (snapshot.widgets) |widget| { + if (widget.id == 2) terminal_snapshot = widget; + } + try std.testing.expectEqualStrings("textbox", terminal_snapshot.?.role); + try std.testing.expectEqualStrings("alpha beta", terminal_snapshot.?.text_value); + try std.testing.expectEqualStrings("disabled", terminal_snapshot.?.context_menu_policy); + var snapshot_buffer: [16384]u8 = undefined; + var snapshot_writer = std.Io.Writer.fixed(&snapshot_buffer); + try automation.snapshot.writeText(snapshot, &snapshot_writer); + try std.testing.expect(std.mem.indexOf(u8, snapshot_writer.buffered(), "context_menu_policy=disabled") != null); + + app_state.pointer_count = 0; + app_state.raw_input_count = 0; + try harness.runtime.dispatchPlatformEvent(app, rightClick(100, 40)); + try std.testing.expectEqual(@as(usize, 0), harness.null_platform.context_menu_request_count); + try std.testing.expectEqual(@as(u32, 0), app_state.request_count); + try std.testing.expectEqual(@as(u32, 0), app_state.context_press_count); + try std.testing.expectEqual(@as(u32, 1), app_state.pointer_count); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), app_state.last_pointer_target); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), harness.runtime.views[0].canvas_widget_pressed_id); + + // Ordinary routing owns the gesture lifetime: movement and release + // outside the terminal remain captured by the terminal target. + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = "canvas", + .kind = .pointer_drag, + .x = 310, + .y = 180, + .delta_x = 210, + .delta_y = 140, + } }); + try std.testing.expectEqual(canvas.WidgetPointerPhase.move, app_state.last_pointer_phase); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), app_state.last_pointer_target); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), app_state.last_pointer_captured); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_input = .{ + .window_id = 1, + .label = "canvas", + .kind = .pointer_up, + .button = 1, + .x = 310, + .y = 180, + } }); + try std.testing.expectEqual(canvas.WidgetPointerPhase.up, app_state.last_pointer_phase); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), app_state.last_pointer_target); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), app_state.last_pointer_captured); + try std.testing.expectEqual(@as(canvas.ObjectId, 0), harness.runtime.views[0].canvas_widget_pressed_id); + try std.testing.expectEqual(@as(u32, 3), app_state.pointer_count); + try std.testing.expectEqual(@as(u32, 3), app_state.raw_input_count); +} + +test "disabled ancestor policy blocks child menus across pointer automation and snapshots" { + var app_state: MenuTestApp = .{}; + const app = app_state.app(); + const harness = try createMenuHarness(app); + defer harness.destroy(std.testing.allocator); + + const items = [_]canvas.WidgetContextMenuItem{.{ .label = "Open" }}; + const child = canvas.Widget{ + .id = 2, + .kind = .list_item, + .frame = geometry.RectF.init(10, 10, 200, 40), + .text = "Task", + .context_menu = &items, + }; + var nodes: [2]canvas.WidgetLayoutNode = undefined; + const layout = try canvas.layoutWidgetTree(.{ + .id = 1, + .kind = .stack, + .children = &.{child}, + .semantics = .{ .context_menu_policy = .disabled }, + }, geometry.RectF.init(0, 0, 320, 200), &nodes); + _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout); + + const snapshot = harness.runtime.automationSnapshot("Inherited policy"); + var child_snapshot: ?automation.snapshot.Widget = null; + for (snapshot.widgets) |widget| { + if (widget.id == 2) child_snapshot = widget; + } + try std.testing.expectEqualStrings("disabled", child_snapshot.?.context_menu_policy); + try std.testing.expectError(error.ContextMenuDisabled, harness.runtime.dispatchAutomationCommand(app, "widget-context-menu canvas 2 0")); + + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_down, 0, 50, 20)); + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_up, 0, 50, 20)); + try std.testing.expectEqual(@as(usize, 0), harness.null_platform.context_menu_request_count); + try std.testing.expectEqual(@as(u32, 2), app_state.pointer_count); +} + +test "disabled secondary down retains ordinary capture through automatic terminal rebuild" { + var app_state: MenuTestApp = .{}; + const app = app_state.app(); + const harness = try createMenuHarness(app); + defer harness.destroy(std.testing.allocator); + + var grid = testTerminalGrid(); + try installTerminal(harness, &grid, .disabled, 7, "shell: idle"); + + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_down, 41, 100, 40)); + try std.testing.expectEqual(@as(u32, 1), app_state.pointer_count); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), harness.runtime.views[0].canvas_widget_pressed_id); + try std.testing.expectEqual(.ordinary, harness.runtime.views[0].canvas_widget_secondary_gesture_owner); + + // The model switches terminal process/mode while the gesture stands: + // same semantic terminal identity, new pty/text, automatic menu policy. + // Retained capture and gesture ownership must survive the adoption. + try installTerminal(harness, &grid, .automatic, 8, "agent: running"); + const rebuilt = try harness.runtime.canvasWidgetLayout(1, "canvas"); + try std.testing.expectEqual(@as(u64, 8), rebuilt.nodes[1].widget.terminal.pty); + try std.testing.expectEqualStrings("agent: running", rebuilt.nodes[1].widget.text); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), harness.runtime.views[0].canvas_widget_pressed_id); + + // A different pointer's buttonless release is consumed but cannot + // terminate pointer 41's ordinary gesture or clear its shared capture. + var other_pointer_up = secondaryPointer(.pointer_up, 42, 100, 40); + other_pointer_up.gpu_surface_input.button = 0; + try harness.runtime.dispatchPlatformEvent(app, other_pointer_up); + try std.testing.expectEqual(@as(u32, 1), app_state.pointer_count); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), harness.runtime.views[0].canvas_widget_pressed_id); + try std.testing.expectEqual(.ordinary, harness.runtime.views[0].canvas_widget_secondary_gesture_owner); + + // The matching up follows the down-time ordinary decision despite the + // live widget now requesting automatic menus, and releases capture. + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_up, 41, 100, 40)); + try std.testing.expectEqual(@as(u32, 2), app_state.pointer_count); + try std.testing.expectEqual(canvas.WidgetPointerPhase.up, app_state.last_pointer_phase); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), app_state.last_pointer_target); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), app_state.last_pointer_captured); + try std.testing.expectEqual(@as(canvas.ObjectId, 0), harness.runtime.views[0].canvas_widget_pressed_id); + try std.testing.expectEqual(.none, harness.runtime.views[0].canvas_widget_secondary_gesture_owner); + try std.testing.expectEqual(@as(usize, 0), harness.null_platform.context_menu_request_count); +} + +test "disabled secondary owner survives a chorded primary release with desktop pointer id" { + var app_state: MenuTestApp = .{}; + const app = app_state.app(); + const harness = try createMenuHarness(app); + defer harness.destroy(std.testing.allocator); + + var grid = testTerminalGrid(); + try installTerminal(harness, &grid, .disabled, 7, "shell: idle"); + + var primary_down = secondaryPointer(.pointer_down, 0, 100, 40); + primary_down.gpu_surface_input.button = 0; + try harness.runtime.dispatchPlatformEvent(app, primary_down); + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_down, 0, 100, 40)); + try std.testing.expectEqual(.ordinary, harness.runtime.views[0].canvas_widget_secondary_gesture_owner); + + var primary_up = secondaryPointer(.pointer_up, 0, 100, 40); + primary_up.gpu_surface_input.button = 0; + try harness.runtime.dispatchPlatformEvent(app, primary_up); + try std.testing.expectEqual(.ordinary, harness.runtime.views[0].canvas_widget_secondary_gesture_owner); + try std.testing.expectEqual(@as(u32, 3), app_state.pointer_count); + + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_up, 0, 100, 40)); + try std.testing.expectEqual(.none, harness.runtime.views[0].canvas_widget_secondary_gesture_owner); + try std.testing.expectEqual(@as(u32, 4), app_state.pointer_count); + try std.testing.expectEqual(canvas.WidgetPointerPhase.up, app_state.last_pointer_phase); +} + +test "disabled secondary down retains ordinary cancel through declared policy rebuild" { + var app_state: MenuTestApp = .{}; + const app = app_state.app(); + const harness = try createMenuHarness(app); + defer harness.destroy(std.testing.allocator); + + var grid = testTerminalGrid(); + try installTerminal(harness, &grid, .disabled, 7, "shell: idle"); + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_down, 51, 100, 40)); + try installTerminal(harness, &grid, .declared_only, 9, "shell: command mode"); + + // A secondary-labelled cancel still follows the down-time ordinary + // owner after policy changes, clearing pressed capture. + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_cancel, 51, 100, 40)); + try std.testing.expectEqual(@as(u32, 2), app_state.pointer_count); + try std.testing.expectEqual(canvas.WidgetPointerPhase.cancel, app_state.last_pointer_phase); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), app_state.last_pointer_target); + try std.testing.expectEqual(@as(canvas.ObjectId, 2), app_state.last_pointer_captured); + try std.testing.expectEqual(@as(canvas.ObjectId, 0), harness.runtime.views[0].canvas_widget_pressed_id); + try std.testing.expectEqual(.none, harness.runtime.views[0].canvas_widget_secondary_gesture_owner); + try std.testing.expectEqual(@as(usize, 0), harness.null_platform.context_menu_request_count); +} + +test "automatic terminal menu gesture stays consumed after disabled rebuild" { + var app_state: MenuTestApp = .{}; + const app = app_state.app(); + const harness = try createMenuHarness(app); + defer harness.destroy(std.testing.allocator); + + var grid = testTerminalGrid(); + try installTerminal(harness, &grid, .automatic, 7, "shell: idle"); + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_down, 61, 100, 40)); + try std.testing.expectEqual(@as(usize, 1), harness.null_platform.context_menu_request_count); + try std.testing.expectEqual(@as(u32, 0), app_state.pointer_count); + try std.testing.expectEqual(@as(canvas.ObjectId, 0), harness.runtime.views[0].canvas_widget_pressed_id); + try std.testing.expectEqual(.context_menu, harness.runtime.views[0].canvas_widget_secondary_gesture_owner); + + try installTerminal(harness, &grid, .disabled, 8, "agent: running"); + // Motion commonly carries button=0. The menu-owned decision still + // consumes it, so no ordinary press/capture appears after the rebuild. + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_drag, 61, 140, 70)); + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_up, 61, 140, 70)); + try std.testing.expectEqual(@as(u32, 0), app_state.pointer_count); + try std.testing.expectEqual(@as(canvas.ObjectId, 0), harness.runtime.views[0].canvas_widget_pressed_id); + try std.testing.expectEqual(.none, harness.runtime.views[0].canvas_widget_secondary_gesture_owner); + try std.testing.expectEqual(@as(usize, 1), harness.null_platform.context_menu_request_count); + + // Default behavior remains repeatable after the gesture retires. + try installTerminal(harness, &grid, .automatic, 8, "agent: running"); + try harness.runtime.dispatchPlatformEvent(app, secondaryPointer(.pointer_down, 62, 100, 40)); + try std.testing.expectEqual(@as(usize, 2), harness.null_platform.context_menu_request_count); + try std.testing.expectEqual(@as(u32, 0), app_state.pointer_count); +} + test "terminal Paste disables after exit and a pending live menu revalidates before dispatch" { var app_state: MenuTestApp = .{}; const app = app_state.app(); @@ -725,8 +1039,16 @@ test "the widget-context-menu verb dispatches selections through context_menu_ac .frame = geometry.RectF.init(10, 60, 200, 40), .text = "Bare", }; - var nodes: [3]canvas.WidgetLayoutNode = undefined; - const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{ row, plain } }, geometry.RectF.init(0, 0, 320, 200), &nodes); + const disabled = canvas.Widget{ + .id = 4, + .kind = .list_item, + .frame = geometry.RectF.init(10, 110, 200, 40), + .text = "Disabled menu", + .context_menu = &items, + .semantics = .{ .context_menu_policy = .disabled }, + }; + var nodes: [4]canvas.WidgetLayoutNode = undefined; + const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{ row, plain, disabled } }, geometry.RectF.init(0, 0, 320, 200), &nodes); _ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout); // Invoking "Delete" (index 2) takes the same dispatch a real pick @@ -745,6 +1067,7 @@ test "the widget-context-menu verb dispatches selections through context_menu_ac try std.testing.expectError(error.ContextMenuItemOutOfRange, harness.runtime.dispatchAutomationCommand(app, "widget-context-menu canvas 2 4")); try std.testing.expectError(error.ContextMenuItemSeparator, harness.runtime.dispatchAutomationCommand(app, "widget-context-menu canvas 2 1")); try std.testing.expectError(error.ContextMenuItemDisabled, harness.runtime.dispatchAutomationCommand(app, "widget-context-menu canvas 2 3")); + try std.testing.expectError(error.ContextMenuDisabled, harness.runtime.dispatchAutomationCommand(app, "widget-context-menu canvas 4 0")); try std.testing.expectEqual(@as(u32, 1), app_state.menu_count); } @@ -1064,6 +1387,7 @@ test "automation snapshots list each widget's declared context-menu items in inv .frame = geometry.RectF.init(10, 10, 200, 40), .text = "Task", .context_menu = &items, + .semantics = .{ .context_menu_policy = .declared_only }, }; var nodes: [2]canvas.WidgetLayoutNode = undefined; const layout = try canvas.layoutWidgetTree(.{ .kind = .stack, .children = &.{row} }, geometry.RectF.init(0, 0, 320, 200), &nodes); @@ -1075,6 +1399,7 @@ test "automation snapshots list each widget's declared context-menu items in inv // List position = the widget-context-menu item index; separators // keep their slots and disabled items say so. try std.testing.expect(std.mem.indexOf(u8, writer.buffered(), "context_menu=[\"Complete\",separator,\"Archive\"(disabled)]") != null); + try std.testing.expect(std.mem.indexOf(u8, writer.buffered(), "context_menu_policy=declared_only") != null); } test "automation snapshots report per-view context-menu item headroom" { diff --git a/src/runtime/canvas_widget_display.zig b/src/runtime/canvas_widget_display.zig index fdaccb8c3..97585a0b1 100644 --- a/src/runtime/canvas_widget_display.zig +++ b/src/runtime/canvas_widget_display.zig @@ -26,6 +26,67 @@ const platformWidgetAccessibilityTextRange = widget_bridge.platformWidgetAccessi const platformWidgetAccessibilityActions = widget_bridge.platformWidgetAccessibilityActions; const canvasWidgetSelectedState = widget_bridge.canvasWidgetSelectedState; +const widget_display_log = std.log.scoped(.zero_canvas_widget_display); + +/// Per-thread emit scratch for `refreshCanvasWidgetDisplayList`. +/// +/// These three buffers are sized from the per-view frame budgets, and at +/// the terminal-scale command budget they no longer fit a stack frame: +/// the display list alone is `max_canvas_commands_per_view` x 120 B, the +/// chrome copy store carries the frame's glyph and text pools, and the +/// diff output is twice the command budget. Together with the widget +/// emit recursion that runs ON TOP of them, stack instances overflowed +/// the thread (a measured segfault inside the button emitter, one budget +/// raise after they last fit). Threadlocal, allocated once per thread, +/// pointer stable for its lifetime — the same treatment the frame +/// planner's scratch already gets. The refresh is a leaf frame builder +/// and never re-enters itself, so one instance per thread is enough. +const WidgetDisplayScratch = struct { + commands: [max_canvas_commands_per_view]canvas.CanvasCommand = undefined, + changes: [max_canvas_diff_changes_per_view]canvas.DiffChange = undefined, + chrome: CanvasDisplayListScratch = .{}, + builder: canvas.Builder = undefined, +}; +const widget_display_scratch = canvas.lazy_tls.LazyTls(WidgetDisplayScratch); + +/// Teach a degraded frame once, at the edge. +/// +/// Emitters that DEGRADE instead of failing (today: the terminal grid +/// painter, which drops whole rows rather than take the frame down) +/// leave a `canvas.DisplayListDegradation` on the builder. A per-frame +/// log line would drown a terminal that stays too dense for a minute, +/// so this only speaks when the record CHANGES — the moment the screen +/// starts losing rows, each time the loss deepens or shifts budget, and +/// once more when it recovers. The alternative is what this replaces: +/// a user staring at a half-blank terminal with nothing in any log. +fn reportCanvasWidgetDisplayListDegradation( + view: anytype, + label: []const u8, + current: ?canvas.DisplayListDegradation, +) void { + const previous = view.canvas_widget_display_list_degradation; + if (degradationsEqual(previous, current)) return; + view.canvas_widget_display_list_degradation = current; + if (current) |note| { + widget_display_log.warn( + "view \"{s}\": widget @{d} painted {d} of {d} rows — the frame's canvas_limits.max_canvas_{s}_per_view budget ran out. The rest of the surface is bare background; reduce the widget's size or its styling density, or raise the budget.", + .{ label, note.id, note.produced, note.requested, @tagName(note.store) }, + ); + } else if (previous) |note| { + widget_display_log.info( + "view \"{s}\": widget @{d} paints all {d} rows again (the {s} budget recovered)", + .{ label, note.id, note.requested, @tagName(note.store) }, + ); + } +} + +fn degradationsEqual(a: ?canvas.DisplayListDegradation, b: ?canvas.DisplayListDegradation) bool { + const left = a orelse return b == null; + const right = b orelse return false; + return left.id == right.id and left.store == right.store and + left.produced == right.produced and left.requested == right.requested; +} + pub fn RuntimeCanvasWidgetDisplay(comptime Runtime: type) type { return struct { pub fn emitCanvasWidgetDisplayList(self: *Runtime, window_id: platform.WindowId, label: []const u8, tokens: canvas.DesignTokens) anyerror!platform.ViewInfo { @@ -336,24 +397,33 @@ pub fn RuntimeCanvasWidgetDisplay(comptime Runtime: type) type { // so `first_view_built` -> this = reconcile + emit. defer launch_timing.lapOnce("first_display_list_emitted"); - var commands: [max_canvas_commands_per_view]canvas.CanvasCommand = undefined; - var chrome_storage = CanvasDisplayListScratch{}; - var builder = canvas.Builder.init(&commands); + const scratch = widget_display_scratch.get(); + scratch.chrome.reset(); + scratch.builder.initAt(&scratch.commands); + const builder = &scratch.builder; + const chrome_storage = &scratch.chrome; const current = self.views[view_index].canvasDisplayList(); const prefix_count = self.views[view_index].canvas_widget_display_list_prefix_count; const suffix_count = self.views[view_index].canvas_widget_display_list_suffix_count; if (prefix_count > current.commands.len or suffix_count > current.commands.len - prefix_count) return error.InvalidCommand; - for (current.commands[0..prefix_count]) |command| try chrome_storage.appendCopiedCommand(&builder, command); - try self.views[view_index].widgetLayoutTree().emitDisplayListWithState(&builder, self.views[view_index].widget_tokens, self.views[view_index].canvasWidgetRenderState()); + for (current.commands[0..prefix_count]) |command| try chrome_storage.appendCopiedCommand(builder, command); + try self.views[view_index].widgetLayoutTree().emitDisplayListWithState(builder, self.views[view_index].widget_tokens, self.views[view_index].canvasWidgetRenderState()); const suffix_start = current.commands.len - suffix_count; - for (current.commands[suffix_start..current.commands.len]) |command| try chrome_storage.appendCopiedCommand(&builder, command); + for (current.commands[suffix_start..current.commands.len]) |command| try chrome_storage.appendCopiedCommand(builder, command); const display_list = builder.displayList(); + // Content an emitter DROPPED rather than fail the frame (the + // terminal grid's row-atomic degradation). The frame below is + // valid and presentable; what it is missing must not be. + reportCanvasWidgetDisplayListDegradation( + &self.views[view_index], + self.views[view_index].label, + builder.degradation, + ); if (display_list.commands.len + self.views[view_index].canvas_widget_display_list_reserved_count > max_canvas_commands_per_view) { return error.CanvasCommandLimitReached; } - var canvas_changes: [max_canvas_diff_changes_per_view]canvas.DiffChange = undefined; - const changes = try canvas.DisplayList.diff(self.views[view_index].canvasDisplayList(), display_list, &canvas_changes); + const changes = try canvas.DisplayList.diff(self.views[view_index].canvasDisplayList(), display_list, &scratch.changes); try self.views[view_index].copyCanvasDisplayList(display_list); reconcileCanvasWidgetCaretBlink(self, view_index); reconcileCanvasWidgetLoopAnimations(self, view_index); @@ -544,7 +614,20 @@ pub fn RuntimeCanvasWidgetDisplay(comptime Runtime: type) type { fn canvasWidgetCaretBlinkTarget(view: anytype) ?CanvasWidgetCaretBlinkTarget { if (!view.focused) return null; const focused_id = view.canvas_widget_focused_id; - if (focused_id == 0 or view.canvas_widget_focus_visible_id != focused_id) return null; + if (focused_id == 0) return null; + // A terminal whose emulator asked for a blinking cursor + // blinks through the SAME looping opacity animation a text + // caret uses — the painter has no clock, so the runtime owns + // the phase for both. + // + // Checked BEFORE the focus-visible gate below on purpose: a + // text caret only blinks once focus is VISIBLE (the + // keyboard-driven focus ring), but a terminal cursor blinks + // whenever the terminal holds focus, however it got it. A + // terminal focused by click would otherwise sit steady while + // the program that asked for `\x1b[1 q` waited for it. + if (canvasWidgetTerminalBlinkTarget(view, focused_id)) |target| return target; + if (view.canvas_widget_focus_visible_id != focused_id) return null; if (!view.canEditCanvasWidgetText(focused_id)) return null; const node_index = view.canvasWidgetNodeIndexById(focused_id) orelse return null; const widget = view.widget_layout_nodes[node_index].widget; @@ -560,6 +643,23 @@ pub fn RuntimeCanvasWidgetDisplay(comptime Runtime: type) type { }; } +/// The blink target of a focused `.terminal` widget: its cursor +/// command, when the producer's grid says the cursor blinks and the +/// session is live. A stopped session's cursor is already the dim +/// hollow at-rest pose and must not pulse. +fn canvasWidgetTerminalBlinkTarget(view: anytype, focused_id: canvas.ObjectId) ?CanvasWidgetCaretBlinkTarget { + const node_index = view.canvasWidgetNodeIndexById(focused_id) orelse return null; + const widget = view.widget_layout_nodes[node_index].widget; + if (widget.kind != .terminal) return null; + const grid = widget.terminal.grid orelse return null; + const cursor = grid.cursor orelse return null; + if (!cursor.blinking or !grid.running) return null; + return .{ + .command_id = canvas.terminal_grid.cursorCommandId(widget.id), + .bounds = view.widget_layout_nodes[node_index].frame, + }; +} + const CanvasWidgetCaretBlinkTarget = struct { command_id: canvas.ObjectId, bounds: geometry.RectF, diff --git a/src/runtime/canvas_widget_text_tests.zig b/src/runtime/canvas_widget_text_tests.zig index 219767a99..8ff062763 100644 --- a/src/runtime/canvas_widget_text_tests.zig +++ b/src/runtime/canvas_widget_text_tests.zig @@ -78,6 +78,29 @@ test "the builder's presented-text store mirrors the per-view draw-text budget" ); } +test "the canvas-tier command mirror matches the per-view command budget" { + // The terminal grid painter degrades against the FRAME command + // ceiling, and it lives in the canvas module — below the runtime + // that owns the budget. `canvas.max_display_list_commands` is its + // read of that number; drift would make the painter reserve rows + // against a budget nobody enforces. + try std.testing.expectEqual( + @import("canvas_limits.zig").max_canvas_commands_per_view, + canvas.max_display_list_commands, + ); +} + +test "the canvas-tier cell mirror matches the per-view cell budget" { + // A terminal screen's cost is CELLS now, and the painter degrades + // against the frame ceiling from inside the canvas module. Drift + // would let a grid claim cells the runtime's retained copy cannot + // hold, which fails the frame instead of dropping rows. + try std.testing.expectEqual( + @import("canvas_limits.zig").max_canvas_cells_per_view, + canvas.max_display_list_cells, + ); +} + test "closing an earlier view transfers a later view's expanded text storage" { const TestApp = struct { fn app(self: *@This()) App { diff --git a/src/runtime/core.zig b/src/runtime/core.zig index 878e63dac..ca4375105 100644 --- a/src/runtime/core.zig +++ b/src/runtime/core.zig @@ -674,6 +674,7 @@ pub const Runtime = struct { pub const focusWindow = WindowViewMethods.focusWindow; pub const closeWindow = WindowViewMethods.closeWindow; pub const minimizeWindow = WindowViewMethods.minimizeWindow; + pub const setWindowFullscreen = WindowViewMethods.setWindowFullscreen; pub const showWindow = WindowViewMethods.showWindow; pub const quitApp = WindowViewMethods.quitApp; pub const createShellWindow = WindowViewMethods.createShellWindow; @@ -1116,6 +1117,10 @@ pub const testing = struct { return runtime.canvasFrameScratchStorage(); } + /// By POINTER, deliberately: a `RuntimeView` is multiple megabytes + /// of retained frame storage (a terminal's cell grid alone is 640 + /// KB), so taking one by value copies the whole thing onto the + /// caller's stack and overflows a test thread. pub fn runtimeViewInfo(view: anytype) platform.ViewInfo { return view.info(); } diff --git a/src/runtime/effects.zig b/src/runtime/effects.zig index 2dc7d8782..bf48f1a81 100644 --- a/src/runtime/effects.zig +++ b/src/runtime/effects.zig @@ -17,7 +17,11 @@ //! fire-and-forget exception: the OS owns delivery after the one //! loop-thread platform call, so there is no meaningful terminal Msg. //! Invalid requests and unavailable services fail closed; fake execution -//! and session replay never emit a real notification. Clipboard effects +//! and session replay never emit a real notification. Opening a URL in +//! the user's default handler (`openUrl`) is the same shape, with the +//! same reasoning, over an ALLOWLIST of schemes (http, https, mailto) — +//! app cores hand it untrusted bytes, so anything else is a whole +//! no-op. Clipboard effects //! (`writeClipboard`/`readClipboard`) keep //! the same shape over the platform pasteboard — the seam the //! runtime's cmd+C copy uses — executed synchronously on the loop @@ -269,6 +273,12 @@ pub const WindowActionBinding = struct { context: *anyopaque, close_fn: *const fn (context: *anyopaque, window_label: []const u8) bool, minimize_fn: *const fn (context: *anyopaque, window_label: []const u8) bool, + fullscreen_fn: *const fn (context: *anyopaque, window_label: []const u8, fullscreen: bool) bool, + /// Whether the window is fullscreen RIGHT NOW. A read, not a verb: + /// `toggleFullscreenWindow` needs the OS's answer rather than a + /// state the app has to mirror, and the runtime already tracks it + /// (`WindowInfo.fullscreen`) with no platform round-trip. + fullscreen_state_fn: *const fn (context: *anyopaque, window_label: []const u8) bool, show_fn: *const fn (context: *anyopaque, window_label: []const u8) bool, quit_fn: *const fn (context: *anyopaque) bool, }; @@ -308,6 +318,11 @@ pub const max_window_action_label = 64; pub const WindowActionState = struct { close_count: u32 = 0, minimize_count: u32 = 0, + fullscreen_count: u32 = 0, + /// The fullscreen state the last `setWindowFullscreen` asked for, + /// so a fake-executor test can assert the REQUEST and not just that + /// one happened. + fullscreen_requested: bool = false, show_count: u32 = 0, quit_count: u32 = 0, last_label_buffer: [max_window_action_label]u8 = @splat(0), @@ -7416,6 +7431,44 @@ pub fn Effects(comptime Msg: type) type { services.showNotification(options) catch {}; } + /// Ask the desktop host to open `url` in the user's default + /// handler — the browser for `http`/`https`, the mail client for + /// `mailto`. This is the `update`-side seam for the same + /// platform verb the webview bridge exposes as + /// `native-sdk.os.openUrl`; before it, a Zig core with no + /// webview had no way to reach it. Fire-and-forget for the same + /// reason `showNotification` is: the OS owns whether a handler + /// actually launched, so no success Msg would be truthful. + /// + /// The URL is treated as HOSTILE — apps hand this bytes that + /// came from terminal output, fetch bodies, and pastes — and is + /// validated against an ALLOWLIST of schemes + /// (`validation.open_url_schemes`: http, https, mailto) before + /// the platform sees it. Empty, over-bound + /// (`platform.max_external_url_bytes`), NUL- or control-byte- + /// bearing, and unrecognised-scheme URLs — `file:` and + /// `javascript:` among them — fail closed: a refused request is + /// a whole no-op, never a trimmed or coerced open. Nothing + /// reaches the platform, so a test's null platform records + /// nothing (`lastExternalUrl()`), which is how a rejection is + /// observed. + /// + /// Unlike the runtime's `openExternalUrl`, this is NOT gated on + /// the app's webview external-link policy: that policy governs + /// links WEB CONTENT follows, and this call comes from the app's + /// own `update`. + /// + /// The call runs synchronously on the loop thread, where the + /// platform launch services expect to be entered. Fake execution + /// and session replay suppress the platform call so tests stay + /// hermetic and a replay never reopens an external window. + pub fn openUrl(self: *Self, url: []const u8) void { + if (self.executor == .fake or self.replay) return; + validation.validateOpenUrl(url) catch return; + const services = self.services orelse return; + services.openExternalUrl(url) catch {}; + } + /// Put text on the system clipboard through the platform /// pasteboard — the same seam the runtime's cmd+C copy uses — /// and deliver exactly one terminal Msg with an explicit @@ -8041,6 +8094,51 @@ pub fn Effects(comptime Msg: type) type { _ = binding.minimize_fn(binding.context, window_label); } + /// Enter or leave fullscreen for a window by its declared + /// label — the real OS verb (macOS moves the window to its own + /// Space). + /// + /// SET rather than toggle: an app that remembers a window was + /// fullscreen can restore it without first reading back the + /// current state and computing parity. `toggleFullscreenWindow` + /// is the convenience over it for a menu item or a shortcut, + /// which is the case that genuinely wants "the other one". + /// + /// This is the write half of a capability the platform already + /// REPORTED (`WindowInfo.fullscreen`): before it, an app could + /// be told it was fullscreen and never ask to be. Note that + /// supplying ANY custom menu bar replaces the stock one — the + /// standard View ▸ Enter Full Screen item included — so a + /// custom-menu app binds its own item to this. + /// + /// Fire-and-forget, same contract as `closeWindow`. + pub fn setWindowFullscreen(self: *Self, window_label: []const u8, fullscreen: bool) void { + self.window_action_state.fullscreen_count += 1; + self.window_action_state.fullscreen_requested = fullscreen; + self.window_action_state.record(window_label); + if (self.executor == .fake) return; + const binding = self.window_actions orelse return; + _ = binding.fullscreen_fn(binding.context, window_label, fullscreen); + } + + /// `setWindowFullscreen` against the window's CURRENT state — + /// the menu-item and keyboard-shortcut shape, and the one a + /// custom-menu app binds its own "Enter Full Screen" item to. + /// An unknown label is a no-op like every other window verb. + pub fn toggleFullscreenWindow(self: *Self, window_label: []const u8) void { + self.setWindowFullscreen(window_label, !self.windowIsFullscreen(window_label)); + } + + /// Whether a declared window is fullscreen right now. Reads the + /// runtime's tracked `WindowInfo.fullscreen`, so it is correct + /// for transitions the USER started from the green button too. + /// False under the fake executor and for unknown labels. + pub fn windowIsFullscreen(self: *Self, window_label: []const u8) bool { + if (self.executor == .fake) return false; + const binding = self.window_actions orelse return false; + return binding.fullscreen_state_fn(binding.context, window_label); + } + /// Show a window by its declared label: unhide + order front, /// activating by default and remaining passive for an /// `activate_on_show = false` overlay. It is the counterpart to diff --git a/src/runtime/effects_open_url_tests.zig b/src/runtime/effects_open_url_tests.zig new file mode 100644 index 000000000..474054a23 --- /dev/null +++ b/src/runtime/effects_open_url_tests.zig @@ -0,0 +1,121 @@ +//! Open-URL effect coverage: `fx.openUrl` reaches the platform's +//! external-URL service on the loop thread, refuses hostile input +//! (unvetted schemes, control bytes, over-bound URLs) whole before the +//! platform sees any of it, and stays inert under fake execution and +//! session replay so tests/replays never launch a browser. + +const std = @import("std"); +const platform = @import("../platform/root.zig"); +const effects_mod = @import("effects.zig"); + +const Msg = enum { unused }; +const TestEffects = effects_mod.Effects(Msg); + +test "open-url effect hands an allowed URL to the platform service" { + var null_platform = platform.NullPlatform.init(.{}); + var host = null_platform.platform(); + var fx = TestEffects.init(std.testing.allocator); + defer fx.deinit(); + fx.bindServices(&host.services); + + fx.openUrl("https://example.com/docs/start"); + try std.testing.expectEqualStrings("https://example.com/docs/start", null_platform.lastExternalUrl()); + + // The other vetted schemes ride the same path. + fx.openUrl("http://example.com/plain"); + try std.testing.expectEqualStrings("http://example.com/plain", null_platform.lastExternalUrl()); + fx.openUrl("mailto:hello@example.com"); + try std.testing.expectEqualStrings("mailto:hello@example.com", null_platform.lastExternalUrl()); + + // Schemes are case-insensitive per RFC 3986, so the allowlist + // matches them that way rather than refusing a legitimate URL. + fx.openUrl("HTTPS://example.com/shouty"); + try std.testing.expectEqualStrings("HTTPS://example.com/shouty", null_platform.lastExternalUrl()); +} + +test "open-url effect refuses a javascript: URL" { + var null_platform = platform.NullPlatform.init(.{}); + var host = null_platform.platform(); + var fx = TestEffects.init(std.testing.allocator); + defer fx.deinit(); + fx.bindServices(&host.services); + + fx.openUrl("javascript:alert(1)"); + // Case games do not widen an allowlist. + fx.openUrl("JavaScript:alert(1)"); + + try std.testing.expectEqualStrings("", null_platform.lastExternalUrl()); +} + +test "open-url effect refuses a file: URL" { + var null_platform = platform.NullPlatform.init(.{}); + var host = null_platform.platform(); + var fx = TestEffects.init(std.testing.allocator); + defer fx.deinit(); + fx.bindServices(&host.services); + + fx.openUrl("file:///etc/passwd"); + fx.openUrl("FILE:///etc/passwd"); + + try std.testing.expectEqualStrings("", null_platform.lastExternalUrl()); +} + +test "open-url effect refuses an over-long URL" { + var null_platform = platform.NullPlatform.init(.{}); + var host = null_platform.platform(); + var fx = TestEffects.init(std.testing.allocator); + defer fx.deinit(); + fx.bindServices(&host.services); + + var long_url: [platform.max_external_url_bytes + 1]u8 = undefined; + const prefix = "https://example.com/"; + @memcpy(long_url[0..prefix.len], prefix); + @memset(long_url[prefix.len..], 'x'); + fx.openUrl(&long_url); + + // Rejected WHOLE: the bound never truncates a URL into a shorter + // one the OS would happily open. + try std.testing.expectEqualStrings("", null_platform.lastExternalUrl()); + + // One byte under the bound still rides through, so the rejection + // above is the bound and not a broken path. + fx.openUrl(long_url[0..platform.max_external_url_bytes]); + try std.testing.expectEqual(platform.max_external_url_bytes, null_platform.lastExternalUrl().len); +} + +test "open-url effect refuses an embedded NUL and other control bytes" { + var null_platform = platform.NullPlatform.init(.{}); + var host = null_platform.platform(); + var fx = TestEffects.init(std.testing.allocator); + defer fx.deinit(); + fx.bindServices(&host.services); + + fx.openUrl("https://example.com/\x00javascript:alert(1)"); + fx.openUrl("https://example.com/ ok"); + fx.openUrl("https://example.com/\nfollow"); + fx.openUrl(""); + fx.openUrl("https://"); + fx.openUrl("ftp://example.com/file.zip"); + + try std.testing.expectEqualStrings("", null_platform.lastExternalUrl()); +} + +test "open-url effect is inert without a service and during fake execution or replay" { + var unbound = TestEffects.init(std.testing.allocator); + defer unbound.deinit(); + unbound.openUrl("https://example.com/no-host"); + + var null_platform = platform.NullPlatform.init(.{}); + var host = null_platform.platform(); + var fx = TestEffects.init(std.testing.allocator); + defer fx.deinit(); + fx.bindServices(&host.services); + + fx.executor = .fake; + fx.openUrl("https://example.com/fake"); + try std.testing.expectEqualStrings("", null_platform.lastExternalUrl()); + + fx.armReplay(); + fx.openUrl("https://example.com/replay"); + try std.testing.expectEqualStrings("", null_platform.lastExternalUrl()); +} diff --git a/src/runtime/gpu_surface_events.zig b/src/runtime/gpu_surface_events.zig index 01a31e27f..b816fa8e0 100644 --- a/src/runtime/gpu_surface_events.zig +++ b/src/runtime/gpu_surface_events.zig @@ -202,10 +202,11 @@ pub fn RuntimeGpuSurfaceEvents(comptime Runtime: type) type { // pacing no-op. self.options.platform.services.noteGpuSurfaceInput(input_event.window_id, input_event.label) catch {}; // Secondary-button (right/ctrl-click, touch long-press) input - // is the context-menu gesture: the press presents the - // native menu and the whole button-1 stream is consumed so a - // right-click never acts as a primary press. - if (ContextMenuMethods().canvasWidgetContextPointerInput(input_event)) { + // is normally the context-menu gesture: the press presents the + // native menu and the whole button-1 stream is consumed. A + // widget with context-menu policy `.disabled` deliberately + // keeps the stream on the ordinary routed/captured path. + if (ContextMenuMethods().canvasWidgetContextPointerInput(self, input_event)) { if (runtimeFindViewIndex(self, input_event.window_id, input_event.label)) |index| { self.views[index].recordGpuSurfaceInputTimestamp(input_event.timestamp_ns); // A consumed cancel is still the pointer leaving the @@ -213,13 +214,10 @@ pub fn RuntimeGpuSurfaceEvents(comptime Runtime: type) type { // the proven pointer's departure retires hover-Msg // containment too, so a window exit mid-secondary // stream never strands an entered element. Honesty - // about scope: only the secondary DOWN/UP/CANCEL - // stream is consumed here — hosts report drag - // MOTION without a button, so it rides the primary - // path and containment follows the pointer through - // a right-drag (the mouseenter/mouseleave - // convention), exactly as the wash does for the - // same journaled moves. + // Gesture ownership was fixed on down: a menu-owned + // drag stays consumed even on hosts whose motion leaves + // button=0, while a policy-disabled down keeps every + // matching phase on ordinary routing/capture. if (input_event.kind == .pointer_cancel and self.views[index].canvas_widget_hover_pointer_live and self.views[index].canvas_widget_hover_pointer_id == input_event.pointer_id) diff --git a/src/runtime/runtime_event_tests.zig b/src/runtime/runtime_event_tests.zig index 317220af1..c5fa7d486 100644 --- a/src/runtime/runtime_event_tests.zig +++ b/src/runtime/runtime_event_tests.zig @@ -497,7 +497,7 @@ test "runtime dispatches GPU surface events" { try std.testing.expectEqual(@as(usize, 2), frame.widget_node_count); try std.testing.expectEqual(@as(usize, 1), frame.widget_semantics_count); var view_json_buffer: [8192]u8 = undefined; - const view_json = try writeViewJson(runtimeViewInfo(harness.runtime.views[0]), &view_json_buffer); + const view_json = try writeViewJson(runtimeViewInfo(&harness.runtime.views[0]), &view_json_buffer); try std.testing.expect(std.mem.indexOf(u8, view_json, "\"gpuWidth\":640") != null); try std.testing.expect(std.mem.indexOf(u8, view_json, "\"gpuHeight\":360") != null); try std.testing.expect(std.mem.indexOf(u8, view_json, "\"gpuScale\":2") != null); diff --git a/src/runtime/tests.zig b/src/runtime/tests.zig index fdf93dc4b..a5d36086a 100644 --- a/src/runtime/tests.zig +++ b/src/runtime/tests.zig @@ -21,6 +21,7 @@ test { _ = @import("effects_file_tests.zig"); _ = @import("effects_clipboard_tests.zig"); _ = @import("effects_notification_tests.zig"); + _ = @import("effects_open_url_tests.zig"); _ = @import("effects_audio_tests.zig"); _ = @import("effects_audio_capture_tests.zig"); _ = @import("effects_video_tests.zig"); diff --git a/src/runtime/ui_app.zig b/src/runtime/ui_app.zig index 562cea6bb..c07982c65 100644 --- a/src/runtime/ui_app.zig +++ b/src/runtime/ui_app.zig @@ -205,7 +205,46 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe /// `prefix_commands` commands followed by `suffix_commands` /// commands (up to `prefix_commands` under /// `variable_prefix`). + /// + /// Called for the MAIN canvas only. A multi-window app wants + /// `build_window` instead — this signature cannot say which + /// window it is painting, so chrome built here is the main + /// window's by construction. build: *const fn (model: *const ModelT, builder: *canvas.Builder, size: geometry.SizeF, tokens: canvas.DesignTokens) anyerror!void, + /// The per-WINDOW chrome builder, and the one a multi-window + /// app should implement. + /// + /// When set it replaces `build` for every window, main and + /// secondary alike, and the context names which window is + /// being painted. Without it, secondary windows get their + /// widget tree and no chrome at all — which for an app whose + /// terminals ARE chrome means a window that opens, lays out + /// correctly, and paints nothing. + /// + /// Additive on purpose: `build` keeps working untouched, and + /// the migration is one line — rename `build` to + /// `build_window` and take `context` instead of + /// `size`/`tokens` (`context.size`, `context.tokens`). + build_window: ?*const fn (model: *const ModelT, builder: *canvas.Builder, context: ChromeContext) anyerror!void = null, + }; + + /// What a chrome build is painting into. A struct rather than + /// more parameters so the next thing a window needs to know + /// does not break every caller again. + pub const ChromeContext = struct { + /// The canvas view label of the window being painted — + /// `Options.canvas_label` for the main window, the + /// descriptor's canvas label for a secondary one. The + /// discriminator an app switches on to pick which of its + /// windows' content to draw. + canvas_label: []const u8, + /// The platform window id behind that label. + window_id: platform.WindowId, + /// This window's canvas size, not the main window's. + size: geometry.SizeF, + tokens: canvas.DesignTokens, + /// Whether this is the app's main scene window. + is_main: bool, }; /// A live webview region hosted alongside the canvas — the "both @@ -708,7 +747,22 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe /// Engine-agnostic: the webview backend is whatever the build /// selected (`-Dweb-engine=system|cef`); platforms without /// child webviews log a warning and continue. - web_panes: ?*const fn (model: *const ModelT, out: []WebViewPane) usize = null, + /// + /// Takes the same `ChromeContext` as `build_window`, and for + /// the same reason: panes are reconciled PER WINDOW, so a + /// signature that could not say which window it was being + /// asked about had to answer with the whole app's pane set + /// every time. A webview belongs to exactly one window, so + /// every OTHER window's rebuild then resolved that pane's + /// anchor against a widget tree that does not contain it and + /// logged "no canvas widget carries semantics label ..." — + /// correct behaviour (the pane is found and snapped in the + /// window that owns it) buried under a warning on every + /// rebuild of every other window. Switch on + /// `context.canvas_label` (or `context.is_main`) and return + /// only the panes belonging to that window; returning 0 is + /// the right answer for a window that hosts none. + web_panes: ?*const fn (model: *const ModelT, context: ChromeContext, out: []WebViewPane) usize = null, /// Menu-bar extra installed once, on the installing frame. /// macOS-proven (`NSStatusItem`); platforms without a /// status-bar service log a warning and continue. @@ -1406,6 +1460,8 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe .context = runtime, .close_fn = effectsCloseWindowByLabel, .minimize_fn = effectsMinimizeWindowByLabel, + .fullscreen_fn = effectsSetWindowFullscreenByLabel, + .fullscreen_state_fn = effectsWindowIsFullscreenByLabel, .show_fn = effectsShowWindowByLabel, .quit_fn = effectsQuitApp, }); @@ -1928,7 +1984,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe // so hover enters keep flowing even when an idle app // performs no further rebuild. if (self.options.chrome) |chrome| { - try self.installChromeDisplayList(runtime, window_id, chrome, layout, tokens); + try self.installChromeDisplayList(runtime, window_id, self.options.canvas_label, self.canvas_size, chrome, layout, tokens, &self.main_tree_current); } else { try self.publishWidgetLayoutTracked(runtime, window_id, self.options.canvas_label, layout, &self.main_tree_current); if (self.installed and self.rebuildEmitsTokens(runtime, window_id, self.options.canvas_label, tokens)) { @@ -1960,7 +2016,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe } try self.scheduleAnimations(runtime, window_id); try self.scheduleLayoutTweens(runtime, window_id); - self.applyWebPanes(runtime, window_id, layout); + self.applyWebPanes(runtime, window_id, self.options.canvas_label, self.canvas_size, tokens, layout); try self.applyTerminalLayout(runtime, window_id, layout, tokens); self.applyStatusItem(runtime); self.applyVideoDeclaration(runtime); @@ -2797,6 +2853,13 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe /// model — every dispatched Msg funnels through here after the /// main rebuild, so all open windows always render the same /// model generation. + /// Whether secondary windows paint chrome — true exactly when + /// the app implements the per-window builder. + fn slotPaintsChrome(self: *const Self) bool { + const chrome = self.options.chrome orelse return false; + return chrome.build_window != null; + } + fn rebuildWindowSlots(self: *Self, runtime: *Runtime) anyerror!void { for (self.window_slots[0..self.window_slot_count]) |*slot| { if (!slot.installed) continue; @@ -2837,10 +2900,33 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe // matching handler tree lands below, wherever the rebuild // was driven from (the full pass or a direct resize/install // site). - try self.publishWidgetLayoutTracked(runtime, slot.window_id, slot.canvasLabel(), layout, &slot.tree_current); - if (slot.installed and self.rebuildEmitsTokens(runtime, slot.window_id, slot.canvasLabel(), tokens)) { - _ = try runtime.emitCanvasWidgetDisplayList(slot.window_id, slot.canvasLabel(), tokens); + // The SAME branch the main canvas takes. A secondary window + // that published its widget layout and emitted with + // `WithChrome(.{})` got a chrome prefix of zero — so an app + // whose terminals are chrome commands rendered its tab strip, + // its dividers, and its widget bounds over an empty canvas. + // Gated on `build_window`, not merely on `chrome`: a chrome + // builder that cannot be told which window it is painting + // would paint the MAIN window's content into this one, which + // is a different wrong answer from the blank canvas it used + // to give. An app opts into per-window chrome by + // implementing `build_window`; until it does, nothing about + // its secondary windows changes. + if (self.slotPaintsChrome()) { + const chrome = self.options.chrome.?; + try self.installChromeDisplayList(runtime, slot.window_id, slot.canvasLabel(), slot.canvas_size, chrome, layout, tokens, &slot.tree_current); + } else { + try self.publishWidgetLayoutTracked(runtime, slot.window_id, slot.canvasLabel(), layout, &slot.tree_current); + if (slot.installed and self.rebuildEmitsTokens(runtime, slot.window_id, slot.canvasLabel(), tokens)) { + _ = try runtime.emitCanvasWidgetDisplayList(slot.window_id, slot.canvasLabel(), tokens); + } } + // Per-window terminal sizing and web panes. Both used to run + // only for the main canvas, so a secondary window's ptys + // would have kept whatever grid they were born with even + // once its cells painted. + try self.applyTerminalLayout(runtime, slot.window_id, layout, tokens); + self.applyWebPanes(runtime, slot.window_id, slot.canvasLabel(), slot.canvas_size, tokens, layout); slot.tree = tree; slot.arena_index = next_index; live_tree_reset = false; @@ -2987,10 +3073,28 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe /// frame, URL, or reload token changed. Failures degrade to a /// logged warning so a missing webview or a denied origin never /// takes the render loop down. - fn applyWebPanes(self: *Self, runtime: *Runtime, window_id: platform.WindowId, layout: canvas.WidgetLayoutTree) void { + fn applyWebPanes( + self: *Self, + runtime: *Runtime, + window_id: platform.WindowId, + canvas_label: []const u8, + canvas_size: geometry.SizeF, + tokens: canvas.DesignTokens, + layout: canvas.WidgetLayoutTree, + ) void { const panes_fn = self.options.web_panes orelse return; var panes: [max_web_panes]WebViewPane = undefined; - const count = @min(panes_fn(&self.model, &panes), max_web_panes); + // The window discriminator, built exactly like + // `installChromeDisplayList`'s so an app switches on ONE + // context shape whichever per-window hook it implements. + const declared = panes_fn(&self.model, .{ + .canvas_label = canvas_label, + .window_id = window_id, + .size = canvas_size, + .tokens = tokens, + .is_main = std.mem.eql(u8, canvas_label, self.options.canvas_label), + }, &panes); + const count = @min(declared, max_web_panes); for (panes[0..count]) |pane| self.applyWebPane(runtime, window_id, layout, pane); } @@ -3085,10 +3189,37 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe /// runtime then regenerates the widget span on internal state /// changes while preserving the chrome via /// `emitCanvasWidgetDisplayListWithChrome`. - fn installChromeDisplayList(self: *Self, runtime: *Runtime, window_id: platform.WindowId, chrome: ChromeOptions, layout: canvas.WidgetLayoutTree, tokens: canvas.DesignTokens) anyerror!void { + /// Install one window's chrome + widget display list. + /// + /// Parameterized by WINDOW rather than reading the main + /// canvas's fields, because secondary windows take this exact + /// path now: the chrome prefix is what produces a terminal's + /// cells, so a window that skipped it rendered its tab strip and + /// its splits over an empty black canvas. + fn installChromeDisplayList( + self: *Self, + runtime: *Runtime, + window_id: platform.WindowId, + canvas_label: []const u8, + canvas_size: geometry.SizeF, + chrome: ChromeOptions, + layout: canvas.WidgetLayoutTree, + tokens: canvas.DesignTokens, + tree_current: *bool, + ) anyerror!void { var chrome_commands: [canvas_limits.max_canvas_commands_per_view]canvas.CanvasCommand = undefined; var chrome_builder = canvas.Builder.init(&chrome_commands); - try chrome.build(&self.model, &chrome_builder, self.canvas_size, tokens); + if (chrome.build_window) |build_window| { + try build_window(&self.model, &chrome_builder, .{ + .canvas_label = canvas_label, + .window_id = window_id, + .size = canvas_size, + .tokens = tokens, + .is_main = std.mem.eql(u8, canvas_label, self.options.canvas_label), + }); + } else { + try chrome.build(&self.model, &chrome_builder, canvas_size, tokens); + } const chrome_list = chrome_builder.displayList(); if (chrome.variable_prefix) { if (chrome_list.commands.len < chrome.suffix_commands or @@ -3107,13 +3238,12 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe try layout.emitDisplayList(&builder, tokens); for (chrome_list.commands[prefix_len..]) |command| try builder.append(command); - _ = try runtime.setCanvasDisplayList(window_id, self.options.canvas_label, builder.displayList()); - // The main install window opens at the layout's true - // adoption inside the tracked publication (see `rebuild`) - // and closes when the caller adopts the matching handler - // tree. - try self.publishWidgetLayoutTracked(runtime, window_id, self.options.canvas_label, layout, &self.main_tree_current); - _ = try runtime.emitCanvasWidgetDisplayListWithChrome(window_id, self.options.canvas_label, tokens, .{ + _ = try runtime.setCanvasDisplayList(window_id, canvas_label, builder.displayList()); + // The install window opens at the layout's true adoption + // inside the tracked publication (see `rebuild`) and closes + // when the caller adopts the matching handler tree. + try self.publishWidgetLayoutTracked(runtime, window_id, canvas_label, layout, tree_current); + _ = try runtime.emitCanvasWidgetDisplayListWithChrome(window_id, canvas_label, tokens, .{ .prefix_command_count = prefix_len, .suffix_command_count = chrome.suffix_commands, }); @@ -4300,7 +4430,14 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe // canvas, so the reconciliation ride-along here converges // without a dedicated event. if (runtime.canvasWidgetLayout(frame_event.window_id, self.options.canvas_label)) |layout| { - self.applyWebPanes(runtime, frame_event.window_id, layout); + self.applyWebPanes( + runtime, + frame_event.window_id, + self.options.canvas_label, + self.canvas_size, + runtime.tokensWithTextMeasure(self.effectiveTokens()), + layout, + ); } else |_| {} } // Terminal outbound pacing: a child that read without @@ -4342,7 +4479,15 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe slot.canvas_size = frame_event.size; slot.pixel_snap_scale = scale; try self.rebuildWindowSlot(runtime, slot); - _ = try runtime.emitCanvasWidgetDisplayList(slot.window_id, slot.canvasLabel(), runtime.tokensWithTextMeasure(self.slotEffectiveTokens(slot))); + // Only the chrome-less path needs this: `rebuildWindowSlot` + // skips its own emit while `installed` is still false, but + // the chrome branch always emits WITH the right prefix + // split — and re-emitting here would overwrite it with a + // zero-prefix list, erasing the chrome that was just + // installed. + if (!self.slotPaintsChrome()) { + _ = try runtime.emitCanvasWidgetDisplayList(slot.window_id, slot.canvasLabel(), runtime.tokensWithTextMeasure(self.slotEffectiveTokens(slot))); + } slot.installed = true; } else if (@abs(slot.pixel_snap_scale - scale) > 0.001) { // THIS window moved to a different density (the main @@ -4380,6 +4525,17 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe // fully once, while an idle completion for the current owner // is allowed to skip. try self.presentFrame(runtime, frame_event, slot.canvasLabel(), installing, clear_color); + if (installing) return; + // The per-frame hook, for THIS window. It used to fire only + // for the main canvas, which for a terminal app is the pump + // that sizes the viewport and the pty — so a secondary + // window's terminals would have kept their birth grid + // forever, whatever the window was resized to. + const on_frame = self.options.on_frame orelse return; + const gpu_frame = runtime.gpuSurfaceFrame(slot.window_id, slot.canvasLabel()) catch return; + if (on_frame(&self.model, gpu_frame)) |msg| { + try self.dispatch(runtime, slot.window_id, msg); + } } /// A face joined the runtime's font registry after this app's @@ -6141,6 +6297,22 @@ fn effectsMinimizeWindowByLabel(context: *anyopaque, window_label: []const u8) b return true; } +fn effectsSetWindowFullscreenByLabel(context: *anyopaque, window_label: []const u8, fullscreen: bool) bool { + const runtime: *Runtime = @ptrCast(@alignCast(context)); + const window_id = effectsWindowIdByLabel(runtime, window_label) orelse return false; + runtime.setWindowFullscreen(window_id, fullscreen) catch return false; + return true; +} + +fn effectsWindowIsFullscreenByLabel(context: *anyopaque, window_label: []const u8) bool { + const runtime: *Runtime = @ptrCast(@alignCast(context)); + var buffer: [platform.max_windows]platform.WindowInfo = undefined; + for (runtime.listWindows(&buffer)) |info| { + if (info.open and std.mem.eql(u8, info.label, window_label)) return info.fullscreen; + } + return false; +} + fn effectsShowWindowByLabel(context: *anyopaque, window_label: []const u8) bool { const runtime: *Runtime = @ptrCast(@alignCast(context)); // A policy-hidden window keeps `open` true, so the same live-window diff --git a/src/runtime/ui_app_tests.zig b/src/runtime/ui_app_tests.zig index b433db001..6975f0211 100644 --- a/src/runtime/ui_app_tests.zig +++ b/src/runtime/ui_app_tests.zig @@ -3521,7 +3521,10 @@ fn previewView(ui: *PreviewApp.Ui, model: *const PreviewModel) PreviewApp.Ui.Nod }); } -fn previewPanes(model: *const PreviewModel, out: []PreviewApp.WebViewPane) usize { +fn previewPanes(model: *const PreviewModel, context: PreviewApp.ChromeContext, out: []PreviewApp.WebViewPane) usize { + // The preview webview lives in the main window; other windows own + // no pane and say so. + if (!context.is_main) return 0; out[0] = .{ .label = "preview", .anchor = preview_pane_anchor, diff --git a/src/runtime/ui_app_window_tests.zig b/src/runtime/ui_app_window_tests.zig index dbe5ba16b..9282971b4 100644 --- a/src/runtime/ui_app_window_tests.zig +++ b/src/runtime/ui_app_window_tests.zig @@ -1556,3 +1556,238 @@ test "closing the pin-owning window releases its snapshot and pin" { } }); try std.testing.expectEqual(@as(u32, 0), fixture.app_state.model.sends); } + +// ------------------------------------------- per-window chrome +// +// The bug this pins: a secondary window published its widget layout and +// emitted with a chrome prefix of ZERO, so an app whose real content is +// chrome commands (a terminal's cells) opened a window that laid out +// correctly — tab strip, splits, widget bounds — and painted nothing +// inside it. + +const chrome_window_label = "chrome-panel"; +const chrome_canvas_label = "chrome-panel-canvas"; + +const ChromeModel = struct { open: bool = true }; +const ChromeMsg = union(enum) { noop }; +const ChromeApp = ui_app_model.UiApp(ChromeModel, ChromeMsg); + +fn chromeUpdate(model: *ChromeModel, msg: ChromeMsg) void { + _ = model; + _ = msg; +} + +fn chromeView(ui: *ChromeApp.Ui, model: *const ChromeModel) ChromeApp.Ui.Node { + _ = model; + return ui.stack(.{}, .{ui.text(.{}, "main")}); +} + +fn chromeWindowView(ui: *ChromeApp.Ui, model: *const ChromeModel, window_label: []const u8) ChromeApp.Ui.Node { + _ = model; + _ = window_label; + return ui.stack(.{}, .{ui.text(.{}, "panel")}); +} + +fn chromeWindows(model: *const ChromeModel, scratch: *ChromeApp.WindowsScratch) []const ChromeApp.WindowDescriptor { + if (!model.open) return &.{}; + scratch.windows[0] = .{ + .label = chrome_window_label, + .canvas_label = chrome_canvas_label, + .title = "Chrome Panel", + .width = 320, + .height = 240, + }; + return scratch.windows[0..1]; +} + +/// One chrome command per window, tinted by WHICH window — the whole +/// point of the context. +fn chromeBuildWindow(model: *const ChromeModel, builder: *canvas.Builder, context: ChromeApp.ChromeContext) anyerror!void { + _ = model; + const red: f32 = if (context.is_main) 1 else 0; + try builder.fillRect(.{ + .id = 4242, + .rect = geometry.RectF.fromSize(context.size), + .fill = .{ .color = canvas.Color.rgba(red, 0, 1 - red, 1) }, + }); +} + +const chrome_canvas_label_main = "chrome-main-canvas"; +const chrome_views = [_]app_manifest.ShellView{ + .{ .label = chrome_canvas_label_main, .kind = .gpu_surface, .fill = true, .gpu_backend = .metal }, +}; +const chrome_windows_scene = [_]app_manifest.ShellWindow{.{ + .label = "main", + .title = "Chrome", + .width = 400, + .height = 300, + .views = &chrome_views, +}}; +const chrome_scene: app_manifest.ShellConfig = .{ .windows = &chrome_windows_scene }; + +fn createChromeFixture(with_window_builder: bool) !struct { harness: *core.TestHarness(), app_state: *ChromeApp, app: core.App } { + const harness = try core.TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(400, 300) }); + errdefer harness.destroy(std.testing.allocator); + harness.null_platform.gpu_surfaces = true; + const app_state = try ChromeApp.create(std.heap.page_allocator, .{ + .name = "ui-app-chrome-window", + .scene = chrome_scene, + .canvas_label = chrome_canvas_label_main, + .update = chromeUpdate, + .view = chromeView, + .windows_fn = chromeWindows, + .window_view = chromeWindowView, + .chrome = .{ + .prefix_commands = 4, + .variable_prefix = true, + .build = chromeBuildMain, + .build_window = if (with_window_builder) chromeBuildWindow else null, + }, + }); + const app = app_state.app(); + try harness.start(app); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = chrome_canvas_label_main, + .size = geometry.SizeF.init(400, 300), + .scale_factor = 1, + .frame_index = 1, + .timestamp_ns = 1_000_000, + .nonblank = true, + } }); + return .{ .harness = harness, .app_state = app_state, .app = app }; +} + +fn chromeBuildMain(model: *const ChromeModel, builder: *canvas.Builder, size: geometry.SizeF, tokens: canvas.DesignTokens) anyerror!void { + _ = model; + _ = tokens; + try builder.fillRect(.{ + .id = 4242, + .rect = geometry.RectF.fromSize(size), + .fill = .{ .color = canvas.Color.rgba(1, 0, 0, 1) }, + }); +} + +fn chromeWindowIdFor(harness: anytype, label: []const u8) ?support.platform.WindowId { + var buffer: [support.platform.max_windows]support.platform.WindowInfo = undefined; + for (harness.runtime.listWindows(&buffer)) |info| { + if (std.mem.eql(u8, info.label, label)) return info.id; + } + return null; +} + +test "a secondary window paints its chrome, tinted by the window it is painting" { + const fixture = try createChromeFixture(true); + defer { + fixture.app_state.destroy(); + fixture.harness.destroy(std.testing.allocator); + } + const window_id = chromeWindowIdFor(fixture.harness, chrome_window_label) orelse return error.TestExpectedWindow; + try fixture.harness.runtime.dispatchPlatformEvent(fixture.app, .{ .gpu_surface_frame = .{ + .window_id = window_id, + .label = chrome_canvas_label, + .size = geometry.SizeF.init(320, 240), + .scale_factor = 1, + .frame_index = 1, + .timestamp_ns = 2_000_000, + .nonblank = true, + } }); + + // The secondary window's list carries the chrome command — the + // thing that used to be missing entirely. + const list = try fixture.harness.runtime.canvasDisplayList(window_id, chrome_canvas_label); + var chrome_fill: ?canvas.Color = null; + for (list.commands) |command| { + if (command == .fill_rect and command.fill_rect.id == 4242) chrome_fill = command.fill_rect.fill.color; + } + const fill = chrome_fill orelse return error.TestExpectedChromeCommand; + // ...and the builder knew WHICH window: blue here, red on the main + // canvas. A builder that could not be told would have painted the + // main window's content into this one. + try std.testing.expectEqual(@as(f32, 0), fill.r); + try std.testing.expectEqual(@as(f32, 1), fill.b); + const main_list = try fixture.harness.runtime.canvasDisplayList(1, chrome_canvas_label_main); + var main_fill: ?canvas.Color = null; + for (main_list.commands) |command| { + if (command == .fill_rect and command.fill_rect.id == 4242) main_fill = command.fill_rect.fill.color; + } + try std.testing.expectEqual(@as(f32, 1), (main_fill orelse return error.TestExpectedChromeCommand).r); +} + +test "without a per-window builder a secondary window keeps its old chrome-less list" { + // Additive by construction: an app that has not migrated sees no + // behaviour change, because a `build` that cannot name a window + // would paint the MAIN window's content into this one — a different + // wrong answer, not a fix. + const fixture = try createChromeFixture(false); + defer { + fixture.app_state.destroy(); + fixture.harness.destroy(std.testing.allocator); + } + const window_id = chromeWindowIdFor(fixture.harness, chrome_window_label) orelse return error.TestExpectedWindow; + try fixture.harness.runtime.dispatchPlatformEvent(fixture.app, .{ .gpu_surface_frame = .{ + .window_id = window_id, + .label = chrome_canvas_label, + .size = geometry.SizeF.init(320, 240), + .scale_factor = 1, + .frame_index = 1, + .timestamp_ns = 2_000_000, + .nonblank = true, + } }); + const list = try fixture.harness.runtime.canvasDisplayList(window_id, chrome_canvas_label); + for (list.commands) |command| { + if (command == .fill_rect) try std.testing.expect(command.fill_rect.id != 4242); + } +} + +// -------------------------------------------------------- fullscreen +// +// The platform could REPORT fullscreen and never enter or leave it, so +// an app could be told it was fullscreen and never ask to be. These pin +// the write half and the read half against the modeled host. + +test "the fullscreen verb is a SET, is idempotent, and reports back" { + const fixture = try Fixture.create(); + defer fixture.destroy(); + try fixture.clickSettingsButton(); + const main = fixture.settingsWindowInfo() orelse return error.TestExpectedWindow; + try std.testing.expect(!main.fullscreen); + var buffer: [support.platform.max_windows]support.platform.WindowInfo = undefined; + + try fixture.harness.runtime.setWindowFullscreen(main.id, true); + try std.testing.expectEqual(@as(u32, 1), fixture.harness.null_platform.fullscreenCountForWindow(main.id)); + + // The READ half: the runtime's tracked window info follows, so a + // transition the user started from the green button and one the app + // asked for report identically. + try fixture.harness.runtime.dispatchPlatformEvent(fixture.app, .{ .window_frame_changed = .{ + .id = main.id, + .label = settings_window_label, + .frame = main.frame, + .scale_factor = main.scale_factor, + .open = true, + .focused = true, + .fullscreen = true, + } }); + const after = blk: { + for (fixture.harness.runtime.listWindows(&buffer)) |info| { + if (info.id == main.id) break :blk info; + } + return error.TestExpectedWindow; + }; + try std.testing.expect(after.fullscreen); + + // SET, not toggle: asking for the state it already holds is a + // no-op at the host, which is what lets an app restore a remembered + // layout without first computing parity. + try fixture.harness.runtime.setWindowFullscreen(main.id, true); + try std.testing.expect(fixture.harness.null_platform.windowIsFullscreen(main.id)); + + try fixture.harness.runtime.setWindowFullscreen(main.id, false); + try std.testing.expect(!fixture.harness.null_platform.windowIsFullscreen(main.id)); +} + +test "a closed window refuses the fullscreen verb like every other window verb" { + const fixture = try Fixture.create(); + defer fixture.destroy(); + try std.testing.expectError(error.WindowNotFound, fixture.harness.runtime.setWindowFullscreen(9999, true)); +} diff --git a/src/runtime/validation.zig b/src/runtime/validation.zig index 60bb4bf64..ac885fe33 100644 --- a/src/runtime/validation.zig +++ b/src/runtime/validation.zig @@ -12,6 +12,43 @@ pub fn validateCommandName(name: []const u8) !void { } } +/// The schemes an app-facing open-URL request may name, matched +/// case-insensitively against the front of the URL (schemes are +/// case-insensitive per RFC 3986). An ALLOWLIST, not a denylist: +/// `file:` (hands the local disk to whatever handler claims it) and +/// `javascript:` (runs code inside the receiving handler) are refused by +/// simply not appearing, and so is every scheme nobody has vetted. A URL +/// assembled from untrusted bytes — terminal output, a fetch body, a +/// paste — can only reach the OS by matching an entry here. +pub const open_url_schemes = [_][]const u8{ "http://", "https://", "mailto:" }; + +/// Validate a URL the app asked the OS to open in the user's default +/// handler (`Effects.openUrl`). Bounded by +/// `platform.max_external_url_bytes`, the same bound the webview +/// bridge's `native-sdk.os.openUrl` enforces. Every failure rejects the +/// URL WHOLE — nothing here trims, escapes, or coerces a malformed URL +/// into a valid one. +pub fn validateOpenUrl(url: []const u8) !void { + if (url.len == 0) return error.InvalidOpenUrl; + if (url.len > platform.max_external_url_bytes) return error.OpenUrlTooLarge; + // A NUL truncates the URL at the C boundary every host crosses, and + // control bytes, whitespace, and DEL never appear in a well-formed + // URL — the whole class is refused rather than stripped, so a + // "https://ok\x00javascript:..." style splice cannot survive as its + // prefix. + for (url) |ch| { + if (ch <= 0x20 or ch == 0x7f) return error.InvalidOpenUrl; + } + for (open_url_schemes) |scheme| { + if (!std.ascii.startsWithIgnoreCase(url, scheme)) continue; + // A bare scheme names no target; only a scheme with something + // after it is worth handing to the OS. + if (url.len == scheme.len) return error.InvalidOpenUrl; + return; + } + return error.UnsupportedOpenUrlScheme; +} + pub fn validateRevealPath(path: []const u8) !void { if (path.len == 0) return error.InvalidRevealPath; if (path.len > platform.max_reveal_path_bytes) return error.RevealPathTooLarge; diff --git a/src/runtime/view.zig b/src/runtime/view.zig index 0e00d3878..c0b07b27a 100644 --- a/src/runtime/view.zig +++ b/src/runtime/view.zig @@ -17,6 +17,7 @@ const max_canvas_gradient_stops_per_view = canvas_limits.max_canvas_gradient_sto const max_canvas_path_elements_per_view = canvas_limits.max_canvas_path_elements_per_view; const max_canvas_glyphs_per_view = canvas_limits.max_canvas_glyphs_per_view; const max_canvas_text_bytes_per_view = canvas_limits.max_canvas_text_bytes_per_view; +const max_canvas_cells_per_view = canvas_limits.max_canvas_cells_per_view; const max_canvas_render_animations_per_view = canvas_limits.max_canvas_render_animations_per_view; const max_canvas_render_animation_dirty_bounds_per_view = canvas_limits.max_canvas_render_animation_dirty_bounds_per_view; const max_canvas_render_overrides_per_view = canvas_limits.max_canvas_render_overrides_per_view; @@ -134,6 +135,17 @@ pub const CanvasWidgetClaimedKeyGrace = enum { } }; +/// Owner chosen by a secondary-button down for that gesture's full +/// lifetime. The SDK currently retains one pressed widget per view, so one +/// secondary gesture per view is the matching honest capacity; pointer_id +/// prevents a different pointer from terminating that standing gesture on +/// hosts that distinguish identities (desktop mouse hosts use id 0). +pub const CanvasWidgetSecondaryGestureOwner = enum { + none, + context_menu, + ordinary, +}; + /// Blur-side IME hygiene, shared by EVERY focus-mutation entry point — /// the pointer-driven focus move, the programmatic `focusView`, and the /// window-level `clearFocusedView` blur all route here so the class is @@ -274,6 +286,17 @@ pub const RuntimeView = struct { canvas_packet_baseline_count: usize = 0, canvas_packet_baseline_surface_size: geometry.SizeF = geometry.SizeF.init(0, 0), canvas_packet_baseline_scale: f32 = 1, + /// The baseline frame's distinct CLIP RECTS (see + /// `canvas_frame.CanvasClipSet`). Clips are erased by the render + /// planner — they never become render commands — so no retained key + /// names one, and a clip that moved reveals (or vacates) pixels the + /// key+fingerprint edit script cannot describe. The next frame's + /// dirty derivation compares its clip set against this one and adds + /// the difference; `overflow` marks a set too large to compare, and + /// refuses the refinement rather than guess. + canvas_packet_baseline_clip_rects: [canvas_limits.max_canvas_packet_clip_rects_per_view]geometry.RectF = undefined, + canvas_packet_baseline_clip_count: usize = 0, + canvas_packet_baseline_clip_overflow: bool = false, canvas_packet_baseline_keys: [max_canvas_retained_packet_commands_per_view]u64 = undefined, canvas_packet_baseline_fingerprints: [max_canvas_retained_packet_commands_per_view]u64 = undefined, /// Draw-order-parallel bounds of the retained baseline commands: the @@ -300,7 +323,19 @@ pub const RuntimeView = struct { canvas_glyph_count: usize = 0, canvas_text_bytes: [max_canvas_text_bytes_per_view]u8 = undefined, canvas_text_len: usize = 0, + /// The retained copy of every `cell_grid` command's cells. One + /// terminal screen is one command but 30,000 cells, so this is the + /// view's largest single array — and the reason a terminal's cost is + /// now linear in AREA instead of quadratic in styling. + canvas_cells: [max_canvas_cells_per_view]canvas.Cell = undefined, + canvas_cell_count: usize = 0, canvas_display_list_widget_owned: bool = false, + /// What the last widget emit could NOT place (see + /// `canvas.DisplayListDegradation`): the terminal grid painter's + /// row-atomic degradation, retained so the runtime logs the cliff on + /// its EDGES rather than once per frame, and so an app or a test can + /// ask a view whether its content is complete. + canvas_widget_display_list_degradation: ?canvas.DisplayListDegradation = null, canvas_widget_display_list_prefix_count: usize = 0, canvas_widget_display_list_suffix_count: usize = 0, canvas_widget_display_list_reserved_count: usize = 0, @@ -602,6 +637,12 @@ pub const RuntimeView = struct { /// the item under the pointer itself eases into its destination. canvas_widget_drag_landing_source_id: canvas.ObjectId = 0, canvas_widget_drag_landing_origin: geometry.PointF = .{}, + /// Context-menu versus ordinary routing is chosen exactly once on a + /// secondary down and survives widget-tree rebuilds until the matching + /// pointer's up/cancel. This prevents a policy change from leaking a + /// consumed menu gesture into capture, or stranding ordinary capture. + canvas_widget_secondary_gesture_owner: CanvasWidgetSecondaryGestureOwner = .none, + canvas_widget_secondary_gesture_pointer_id: u64 = 0, /// The STANDING hover-Msg containment chain: every widget on the /// last resolved raw hover hit's ancestor path that listens for /// hover Msgs (`Widget.hover_msgs`), outermost first @@ -934,6 +975,7 @@ pub const RuntimeView = struct { pub const copyCanvasGradientStops = CanvasFrameMethods.copyCanvasGradientStops; pub const copyCanvasPathElements = CanvasFrameMethods.copyCanvasPathElements; pub const copyCanvasGlyphs = CanvasFrameMethods.copyCanvasGlyphs; + pub const copyCanvasCells = CanvasFrameMethods.copyCanvasCells; pub const copyCanvasText = CanvasFrameMethods.copyCanvasText; const CanvasWidgetTreeMethods = view_widget_tree.RuntimeViewCanvasWidgetTree(RuntimeView); diff --git a/src/runtime/view_canvas.zig b/src/runtime/view_canvas.zig index 51b828e82..885682aa2 100644 --- a/src/runtime/view_canvas.zig +++ b/src/runtime/view_canvas.zig @@ -9,6 +9,7 @@ const max_canvas_gradient_stops_per_view = canvas_limits.max_canvas_gradient_sto const max_canvas_path_elements_per_view = canvas_limits.max_canvas_path_elements_per_view; const max_canvas_glyphs_per_view = canvas_limits.max_canvas_glyphs_per_view; const max_canvas_text_bytes_per_view = canvas_limits.max_canvas_text_bytes_per_view; +const max_canvas_cells_per_view = canvas_limits.max_canvas_cells_per_view; const appendCanvasSummaryChange = canvas_frame_helpers.appendCanvasSummaryChange; const unionRects = canvas_frame_helpers.unionRects; @@ -31,6 +32,7 @@ pub const CanvasRenderAnimationDirtyBounds = struct { pub const CanvasResourceCounts = struct { command_count: usize = 0, + cell_count: usize = 0, gradient_stop_count: usize = 0, path_element_count: usize = 0, glyph_count: usize = 0, @@ -62,6 +64,13 @@ pub const CanvasResourceCounts = struct { try addCanvasCount(&self.text_byte_count, value.text.len, max_canvas_text_bytes_per_view, error.CanvasTextTooLarge); try addCanvasCount(&self.glyph_count, value.glyphs.len, max_canvas_glyphs_per_view, error.CanvasGlyphLimitReached); }, + // A grid charges CELLS and its interned cluster blob. It + // charges no glyphs: renderers map each cell's cluster + // themselves, so there is no shaped-glyph array to bound. + .cell_grid => |value| { + try addCanvasCount(&self.cell_count, value.cells.len, max_canvas_cells_per_view, error.CanvasCellLimitReached); + try addCanvasCount(&self.text_byte_count, value.text.len, max_canvas_text_bytes_per_view, error.CanvasTextTooLarge); + }, .shadow => |value| { _ = value; }, @@ -89,6 +98,20 @@ pub const CanvasDisplayListScratch = struct { glyph_count: usize = 0, text_bytes: [max_canvas_text_bytes_per_view]u8 = undefined, text_len: usize = 0, + cells: [max_canvas_cells_per_view]canvas.Cell = undefined, + cell_count: usize = 0, + + /// Empty the scratch WITHOUT rebuilding it. Same reason as + /// `canvas.Builder.initAt`: the struct carries a frame's worth of + /// inline storage, so `scratch.* = .{}` is a megabyte-scale stack + /// temporary. + pub fn reset(self: *CanvasDisplayListScratch) void { + self.gradient_stop_count = 0; + self.path_element_count = 0; + self.glyph_count = 0; + self.text_len = 0; + self.cell_count = 0; + } pub fn appendCopiedCommand(self: *CanvasDisplayListScratch, builder: *canvas.Builder, command: canvas.CanvasCommand) anyerror!void { try builder.append(try self.copyCanvasCommand(command)); @@ -140,6 +163,12 @@ pub const CanvasDisplayListScratch = struct { copy.glyphs = try self.copyCanvasGlyphs(value.glyphs); break :blk .{ .draw_text = copy }; }, + .cell_grid => |value| blk: { + var copy = value; + copy.cells = try self.copyCanvasCells(value.cells); + copy.text = try self.copyCanvasText(value.text); + break :blk .{ .cell_grid = copy }; + }, .shadow => |value| .{ .shadow = value }, .blur => |value| .{ .blur = value }, }; @@ -180,6 +209,15 @@ pub const CanvasDisplayListScratch = struct { return self.path_elements[start..end]; } + pub fn copyCanvasCells(self: *CanvasDisplayListScratch, cells: []const canvas.Cell) anyerror![]const canvas.Cell { + const end = self.cell_count + cells.len; + if (end > self.cells.len) return error.CanvasCellLimitReached; + const start = self.cell_count; + @memcpy(self.cells[start..end], cells); + self.cell_count = end; + return self.cells[start..end]; + } + pub fn copyCanvasGlyphs(self: *CanvasDisplayListScratch, glyphs: []const canvas.Glyph) anyerror![]const canvas.Glyph { const end = self.glyph_count + glyphs.len; if (end > self.glyphs.len) return error.CanvasGlyphLimitReached; @@ -287,6 +325,7 @@ pub fn RuntimeViewCanvasFrame(comptime RuntimeView: type) type { self.canvas_path_element_count = 0; self.canvas_glyph_count = 0; self.canvas_text_len = 0; + self.canvas_cell_count = 0; for (display_list.commands) |command| { self.canvas_commands[self.canvas_command_count] = try self.copyCanvasCommand(command); @@ -811,6 +850,12 @@ pub fn RuntimeViewCanvasFrame(comptime RuntimeView: type) type { copy.glyphs = try self.copyCanvasGlyphs(value.glyphs); break :blk .{ .draw_text = copy }; }, + .cell_grid => |value| blk: { + var copy = value; + copy.cells = try self.copyCanvasCells(value.cells); + copy.text = try self.copyCanvasText(value.text); + break :blk .{ .cell_grid = copy }; + }, .shadow => |value| .{ .shadow = value }, .blur => |value| .{ .blur = value }, }; @@ -851,6 +896,15 @@ pub fn RuntimeViewCanvasFrame(comptime RuntimeView: type) type { return self.canvas_path_elements[start..end]; } + pub fn copyCanvasCells(self: *RuntimeView, cells: []const canvas.Cell) anyerror![]const canvas.Cell { + const end = self.canvas_cell_count + cells.len; + if (end > self.canvas_cells.len) return error.CanvasCellLimitReached; + const start = self.canvas_cell_count; + @memcpy(self.canvas_cells[start..end], cells); + self.canvas_cell_count = end; + return self.canvas_cells[start..end]; + } + pub fn copyCanvasGlyphs(self: *RuntimeView, glyphs: []const canvas.Glyph) anyerror![]const canvas.Glyph { const end = self.canvas_glyph_count + glyphs.len; if (end > self.canvas_glyphs.len) return error.CanvasGlyphLimitReached; diff --git a/src/runtime/window_storage.zig b/src/runtime/window_storage.zig index bf57cdd1b..7b403b4be 100644 --- a/src/runtime/window_storage.zig +++ b/src/runtime/window_storage.zig @@ -194,6 +194,10 @@ pub fn RuntimeWindowStorage(comptime Runtime: type) type { self.windows[index].info.scale_factor = state.scale_factor; self.windows[index].info.open = state.open; self.windows[index].info.hidden = state.hidden; + // The read half of the fullscreen capability: whoever caused + // the transition — the app's `setWindowFullscreen`, the + // green button, or a Space gesture — reports it the same way. + self.windows[index].info.fullscreen = state.fullscreen; if (!self.windows[index].main_frame_set) { self.windows[index].main_frame = geometry.RectF.init(0, 0, state.frame.width, state.frame.height); } diff --git a/src/runtime/window_views.zig b/src/runtime/window_views.zig index 4bd738024..127ab57b3 100644 --- a/src/runtime/window_views.zig +++ b/src/runtime/window_views.zig @@ -146,6 +146,18 @@ pub fn RuntimeWindowViews(comptime Runtime: type) type { try self.options.platform.services.minimizeWindow(window_id); } + /// Enter or leave fullscreen for a tracked window. SET, not + /// toggle, so an app can drive it from state it already owns; + /// the runtime keeps no fullscreen bookkeeping of its own — + /// `WindowInfo.fullscreen` is refreshed by the platform's own + /// window event, which is the single source of truth for a + /// transition the user can also start from the OS. + pub fn setWindowFullscreen(self: *Runtime, window_id: platform.WindowId, fullscreen: bool) anyerror!void { + const index = Self.findWindowIndexById(self, window_id) orelse return error.WindowNotFound; + if (!self.windows[index].info.open) return error.WindowNotFound; + try self.options.platform.services.setWindowFullscreen(window_id, fullscreen); + } + /// The real OS show verb: unhide and order front — the counterpart /// to a `close_policy = .hide` hide, and what a tray "Open" /// action resolves to. Like `closeWindow`, the runtime flag