feat(canvas): add reusable code component (#235)

* feat(canvas): add reusable code component

- Add highlighted code surfaces with optional line numbers and horizontal scrolling.
- Route Markdown fences through Code while preserving indentation and first-line list alignment.
- Cover the markup API with tests, documentation, previews, and changelog fragments.

* fix(canvas): preserve multiline code rendering

* fix(canvas): bound code layout capacity

* fix(canvas): bound code span retention

* fix(canvas): address code component review feedback

* fix(canvas): polish code block rendering

* fix(canvas): address remaining code review feedback

* fix(canvas): preserve long code rendering

* fix(canvas): preserve numbered code selection

* fix(canvas): fold long span selections

* fix(canvas): preserve empty code and paged selection

* fix(canvas): address code rendering review findings

* fix(canvas): resolve remaining code review issues

* fix: bound transformed code rendering
This commit is contained in:
Chris Tate
2026-07-30 13:56:52 -05:00
committed by GitHub
parent 6a871356b4
commit bd3aab4b48
61 changed files with 4781 additions and 114 deletions
+1
View File
@@ -0,0 +1 @@
feature: **Code component**: `ui.code` and markup `<code>` render highlighted source with the Geist Code Block palette in both built-in themes, wrapping by default, opt-in logical line numbers, unwrapped horizontal scrolling, and vertical scrolling for height-constrained surfaces; Markdown fences share the same component.
+1
View File
@@ -0,0 +1 @@
fix: **Bounded transformed code rendering**: heavily scaled code surfaces now degrade within the shared command and text-byte budgets instead of rejecting the entire display-list refresh.
@@ -0,0 +1 @@
fix: **Polished Markdown lists and code blocks**: bullet and ordered-list markers now align with the first content line, while fenced code preserves source indentation and applies theme-aware highlighting with richer HTML/JSX tags and attributes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/code");
export default function CodeLayout({ children }: { children: React.ReactNode }) {
return children;
}
+47
View File
@@ -0,0 +1,47 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Code
Presents source text in a themed monospace surface with deterministic syntax highlighting. Code wraps by default. Set `wrap="false"` to preserve logical lines inside one horizontal scroll region, and opt into logical line numbers with `line-numbers`.
HTML-family highlighting understands HTML, XML, SVG, JSX, and TSX structure: element or component tags, attributes, strings, comments, numbers, and JavaScript/TypeScript expressions receive distinct theme-token colors.
<ComponentPreview name="code" alt="A JSX code sample with syntax highlighting and line numbers" caption="JSX tags, attributes, strings, expressions, and an unwrapped horizontal viewport" />
## Markup
```html
<code
source="{component_source}"
language="tsx"
line-numbers
wrap="false"
width="480"
label="Accordion example"
/>
```
`source` is required and must be one `{binding}` producing text. `language` is a literal lexer name; unknown names are validation errors. Line numbers are off by default and remain decorative, so selecting and copying a numbered block returns only the source text. Numbered presentation is limited to 128 logical lines; longer sources keep all code and omit the gutter.
## Programmatic construction (Zig)
```zig
ui.code(.{
.language = .html,
.line_numbers = true,
.wrap = false,
.width = 480,
.semantics = .{ .label = "Accordion example" },
}, model.component_source)
```
The public lexer model is `native_sdk.canvas.code`. `languageFromName` resolves markup spellings, `languageFromFence` reads a Markdown info string, and `highlight` produces the same bounded, theme-colored span runs both renderers use.
## Languages
Zig; JavaScript and TypeScript; JSX and TSX; JSON; shell; Python; Rust; C, C++, C#, Java, Kotlin, and Swift; Go; HTML, XML, and SVG; CSS, SCSS, and Less; and SQL. An omitted language renders plain monospace.
## Attributes
<AttrTable element="code" attrs={["source", "language", "line-numbers", "wrap", "width", "height", "min-width", "grow", "key", "global-key", "label"]} />
+1 -1
View File
@@ -3,7 +3,7 @@ import { AttrTable } from "@/components/attr-table";
# Markdown
Renders a markdown string (a GFM subset, pipe tables included) as native widgets through the same text pipeline as every other component — deterministic layout, selectable text. `source` is required and must be one `{binding}`; the element takes no children. Links dispatch `on-link` with the URL as payload (bare URLs autolink), `<details>` blocks toggle through `on-details` plus a model-owned `details-expanded` flag list, and `#123` references linkify through `issue-link-base`.
Renders a markdown string (a GFM subset, pipe tables included) as native widgets through the same text pipeline as every other component — deterministic layout, selectable text. `source` is required and must be one `{binding}`; the element takes no children. Links dispatch `on-link` with the URL as payload (bare URLs autolink), `<details>` blocks toggle through `on-details` plus a model-owned `details-expanded` flag list, and `#123` references linkify through `issue-link-base`. Fenced blocks lower through the reusable [Code](/components/code) component, so indentation and syntax behavior stay identical.
<ComponentPreview name="markdown" alt="A markdown document rendered by the engine" caption="headings, emphasis, inline code, lists, links, and a code block" />
+20 -1
View File
@@ -104,7 +104,7 @@ Color and radius attributes reference design tokens by name — literals only, v
</row>
```
The color attributes are `background`, `foreground`, `accent`, `accent-foreground`, `border-color`, and `focus-ring`; values are `ColorTokens` field names: `background`, `surface`, `surface_subtle`, `surface_pressed`, `text`, `text_muted`, `border`, `accent`, `accent_text`, `destructive`, `destructive_text`, `success`, `success_text`, `warning`, `warning_text`, `info`, `info_text`, `focus_ring`, `shadow`, `disabled`. `info` is the violet identity hue beside the status trio — merged PR badges, "new" chips, informational callouts. `radius` takes a `RadiusTokens` name: `sm`, `md`, `lg`, `xl`.
The color attributes are `background`, `foreground`, `accent`, `accent-foreground`, `border-color`, and `focus-ring`; values are `ColorTokens` field names: `background`, `surface`, `surface_subtle`, `surface_pressed`, `text`, `text_muted`, `syntax_plain`, `syntax_comment`, `syntax_keyword`, `syntax_literal`, `syntax_function`, `syntax_property`, `syntax_constant`, `border`, `accent`, `accent_text`, `destructive`, `destructive_text`, `success`, `success_text`, `warning`, `warning_text`, `info`, `info_text`, `focus_ring`, `shadow`, `scrim`, `disabled`. The `syntax_*` roles are the Geist Code Block palette shared by both built-in packs; `ui.code` and Markdown fences use them automatically. `info` is the violet identity hue beside the status trio — merged PR badges, "new" chips, informational callouts. `radius` takes a `RadiusTokens` name: `sm`, `md`, `lg`, `xl`.
References resolve against the app's live tokens on every rebuild, so themed apps re-resolve them when the theme changes — dark mode flips `surface` for free. Anything dynamic beyond that (raw colors, per-state styling) stays in Zig via `ElementOptions.style`, which always wins over a token reference.
@@ -559,6 +559,23 @@ ui.paragraph(.{ .on_link = Ui.linkMsg(.open_url) }, &spans)
Wrapping and measurement are span-aware (the platform text provider measures every run with the font it draws with), stacked paragraphs reserve their real wrapped height, and link spans are first-class: they carry `role=link` semantics in automation snapshots, show the pointing-hand cursor (the only place the engine uses it — controls keep the platform arrow, following native convention rather than the web's), and clicking one dispatches your `Msg` with the link payload.
Source presentation is the reusable `ui.code` / `<code>` component. It wraps by default; `wrap="false"` preserves logical lines inside one horizontal scroll region, and `line-numbers` opts into a muted logical-line gutter. A definite `height` keeps overflow reachable with vertical scrolling (or both axes when wrapping is off):
```html
<code source="{component_source}" language="tsx" line-numbers wrap="false" width="480" label="Accordion example" />
```
```zig
ui.code(.{
.language = .html,
.line_numbers = true,
.wrap = false,
.width = 480,
}, model.component_source)
```
Recognized languages receive theme-aware syntax color for keywords, types/numbers, strings, and comments: Zig; JavaScript/TypeScript; JSX/TSX; JSON; shell; Python; Rust; C, C++, C#, Java, Kotlin, and Swift; Go; HTML/XML/SVG; CSS/SCSS/Less; and SQL. HTML-family highlighting distinguishes element and component tags, attributes, strings, comments, and JavaScript/TypeScript expressions. Unknown or omitted tags stay plain monospace, and the bounded highlighting fallback never drops code. See [Code](/components/code).
Markdown builds on the same model. `native_sdk.markdown` maps a GitHub-flavored subset — headings, inline styles, links (including bare `http(s)://` URLs, which autolink with trailing punctuation trimmed), bullet/ordered/task lists, fenced code blocks, blockquotes, rules, pipe tables, and `<details>` collapsibles — onto ordinary widgets:
```zig
@@ -573,6 +590,8 @@ Md.view(ui, issue.body, .{
Malformed input degrades to plain text — the build fn never fails. Task-list checkboxes render as disabled (display-only) checkboxes, and `<details>` expansion is state the caller's model owns.
Fenced code preserves every source indentation level and lowers through the same `ui.code` component, including JSX/TSX-aware HTML-family highlighting. Markdown keeps the component defaults: wrapping on, line numbers off.
GFM pipe tables map onto the real `table`/`table-row`/`table-cell` widgets: the header row renders bold, the delimiter row's `:---`/`:--:`/`---:` cells set per-column start/center/end alignment, every cell runs the full inline grammar (code, bold, links — links in cells are clickable), `\|` puts a literal pipe in a cell, and cells wrap at their column width (columns share the width equally in v1). A pipe block whose delimiter row is missing or mismatched is not a table and renders as plain paragraphs.
In markup, the `<markdown>` element wires all of this declaratively:
+75 -1
View File
@@ -212,6 +212,10 @@
"name": "icon",
"doc": "Vector icon leaf: name selects a curated built-in stroke icon (comptime-validated), an app-registered app:<name> (canvas.icons.registerAppIcons; native check verifies the name against the model contract), or one {binding} resolving to such a name. Tint via foreground, size with width/height or size."
},
{
"name": "code",
"doc": "Highlighted source-code surface. source is one required text {binding}; language is a literal lexer name. Wraps by default, line-numbers opts into logical line numbers, wrap=\"false\" keeps lines intact, and a definite height makes overflow scrollable."
},
{
"name": "markdown",
"doc": "Renders a markdown string (GFM subset, pipe tables included) as widgets; source is one {binding}, links dispatch on-link (bare URLs autolink), <details> blocks toggle via on-details + details-expanded, #123 refs linkify via issue-link-base."
@@ -271,6 +275,10 @@
{
"name": "video",
"doc": "The video leaf: plays the app's single platform-decoded video into the framework-owned media-surface (macOS decodes with AVFoundation; hosts without a decoder deliver one explicit failed event). src declares the source — an app-assets path or an http(s) URL, resolved local-first exactly like audio; autoplay (default true), loop, and muted shape the fresh playback; controls composes the house transport chrome (play/pause, scrub bar, time readout) under the picture, and without it the element is the surface alone — compose your own controls from the video command vocabulary. Until a decoded frame arrives (and in every golden, screenshot, and replay) the surface shows its deterministic placeholder: pixels are presentation chrome, transport is the journaled truth. Size it with width/height or grow (no intrinsic size); label it for screen readers."
},
{
"name": "terminal",
"doc": "The terminal leaf: renders the framework-owned emulator session behind a model-owned pty effect key — the grid as real text with geometric box drawing, a theme-derived ANSI palette, selection, cursor, and scrollback — and routes keys, IME text, and wheel scrollback to it when focused. pty is one {binding} to the u64 key the app's ptySpawn named (required; keys are model data, never markup literals; 0 renders the empty surface); scrollback echoes the app-visible offset back under the scroll value source-wins rule, and on-terminal delivers the post-change view state. The grid derives its cols/rows from the frame the layout resolves (the runtime pushes them to the pty), so size it like a leaf: grow or a definite width/height. An interactive control: give it a label."
}
],
"structure": [
@@ -526,6 +534,10 @@
"name": "on-resize",
"doc": "split element only: names a Msg variant with f32 payload; delivers the applied first-pane fraction after every divider drag, keyboard adjustment, and assistive increment/decrement. Echo it back into value - the delivered fraction never fights the reconcile."
},
{
"name": "on-terminal",
"doc": "terminal element only: names a bare Msg variant with canvas.TerminalState payload; delivers the post-change view state (scrollback, history, cols, rows) after every runtime-applied change - wheel and keyboard scrollback, and the layout-derived grid resize. Echo scrollback back into the attribute - the delivered state never fights the reconcile."
},
{
"name": "on-reach-end",
"doc": "scroll element only: Msg (tag or tag:{payload}) dispatched when a user scroll comes within one viewport of the content end - the infinite-scroll fetch signal. Fires once per approach with hysteresis: it re-arms only after the offset retreats past 1.5 viewports, which appending a batch causes on its own by growing the extent."
@@ -562,6 +574,52 @@
"doc": "markdown: literal URL prefix or one {binding}; '#123' refs become links to base ++ number (ghissue:// or https://github.com/owner/repo/issues/)."
}
],
"code": [
{
"name": "source",
"doc": "code: one required {binding} producing source text (a []const u8 field or fn; arena fns work)."
},
{
"name": "language",
"doc": "code: literal lexer name. Supports Zig, JavaScript/TypeScript, JSX/TSX, JSON, shell, Python, Rust, C-family, Go, HTML/XML/SVG, CSS-family, and SQL; unknown names are a validation error."
},
{
"name": "line-numbers",
"doc": "code: opt into muted logical line numbers. Off by default; a wrapped logical line stays paired with its number."
},
{
"name": "wrap",
"doc": "code: true by default. false preserves logical lines and puts the highlighted content in one horizontal scroll region."
},
{
"name": "width",
"doc": "Definite width (plain number)."
},
{
"name": "height",
"doc": "code: definite height (plain number). Overflow scrolls vertically; with wrap=false the region scrolls on both axes."
},
{
"name": "min-width",
"doc": "Width floor without a definite maximum."
},
{
"name": "grow",
"doc": "Flex grow factor."
},
{
"name": "key",
"doc": "Sibling-scoped identity key."
},
{
"name": "global-key",
"doc": "Parent-independent identity: ids survive reparenting between containers."
},
{
"name": "label",
"doc": "Accessible name for the code group."
}
],
"stepper": [
{
"name": "active",
@@ -662,6 +720,14 @@
{
"name": "surface",
"doc": "media-surface: one {binding} to the model-owned u64 surface id a Zig-tier producer targets (runtime.acquireMediaSurfaceProducer). Required; surface ids are model data, never markup literals; 0 leaves the surface unbound and it draws nothing, and usable ids are nonzero values below bit 63 — the reserved media-surface texture namespace, which the producer acquire refuses."
},
{
"name": "pty",
"doc": "terminal: one {binding} to the model-owned u64 pty effect key whose session the terminal renders (the key ptySpawn named). Required; pty keys are model data, never markup literals; 0 leaves the terminal unbound and it renders the empty surface."
},
{
"name": "scrollback",
"doc": "terminal only: the scrollback offset in rows above the live screen (0 is pinned to the bottom). Follows the scroll value source-wins reconcile rule - echo on-terminal's scrollback back to keep user scrollback across rebuilds; move it model-side to scroll programmatically."
}
],
"video": [
@@ -1114,6 +1180,10 @@
"width": 1120,
"height": 280
},
"code": {
"width": 1120,
"height": 600
},
"markdown": {
"width": 1120,
"height": 880
@@ -1202,6 +1272,10 @@
"width": 704,
"height": 396
},
"code-hero": {
"width": 704,
"height": 396
},
"dialog-hero": {
"width": 704,
"height": 396
@@ -1458,4 +1532,4 @@
"path": "src/components/timeline_item.zig"
}
]
}
}
+1
View File
@@ -27,6 +27,7 @@ export const componentPages: ComponentPage[] = [
{ slug: "card", name: "Card", preview: "card-hero", blurb: "The bordered, elevated surface container." },
{ slug: "chart", name: "Chart", preview: "chart-hero", blurb: "Line, bar, and band series (Zig builder)." },
{ slug: "checkbox", name: "Checkbox", preview: "checkbox-hero", blurb: "Binary choice with model-owned state." },
{ slug: "code", name: "Code", preview: "code-hero", blurb: "Highlighted source with line numbers and optional horizontal scrolling." },
{ slug: "combobox", name: "Combobox", preview: "combobox-hero", blurb: "Text entry with an anchored suggestions menu." },
{ slug: "dialog", name: "Dialog", preview: "dialog-hero", blurb: "Modal surface with model-owned dismissal." },
{ slug: "drawer", name: "Drawer", preview: "drawer-hero", blurb: "Side-anchored modal surface." },
+14
View File
@@ -93,6 +93,13 @@ pub const light_colors = canvas.ColorTokens{
.surface_pressed = Color.rgb8(229, 229, 229),
.text = Color.rgb8(10, 10, 10),
.text_muted = Color.rgb8(115, 115, 115),
.syntax_plain = Color.rgb8(23, 23, 23),
.syntax_comment = Color.rgb8(77, 77, 77),
.syntax_keyword = Color.rgb8(189, 40, 100),
.syntax_literal = Color.rgb8(41, 122, 58),
.syntax_function = Color.rgb8(120, 32, 188),
.syntax_property = Color.rgb8(203, 42, 47),
.syntax_constant = Color.rgb8(0, 104, 214),
.border = Color.rgb8(229, 229, 229),
.accent = Color.rgb8(20, 71, 230),
.accent_text = Color.rgb8(239, 246, 255),
@@ -119,6 +126,13 @@ pub const dark_colors = canvas.ColorTokens{
.surface_pressed = Color.rgba8(255, 255, 255, 38),
.text = Color.rgb8(250, 250, 250),
.text_muted = Color.rgb8(161, 161, 161),
.syntax_plain = Color.rgb8(237, 237, 237),
.syntax_comment = Color.rgb8(161, 161, 161),
.syntax_keyword = Color.rgb8(247, 95, 143),
.syntax_literal = Color.rgb8(98, 192, 115),
.syntax_function = Color.rgb8(191, 122, 240),
.syntax_property = Color.rgb8(255, 97, 102),
.syntax_constant = Color.rgb8(82, 168, 255),
.border = Color.rgba8(255, 255, 255, 26),
.accent = Color.rgb8(43, 127, 255),
// Near-black on the bright accent (6.0:1) — the light near-white
+7
View File
@@ -200,6 +200,13 @@ pub const chassis_colors = canvas.ColorTokens{
.surface_pressed = key_pressed,
.text = ink,
.text_muted = engraving,
.syntax_plain = phosphor_pale,
.syntax_comment = phosphor_dim,
.syntax_keyword = Color.rgb8(247, 95, 143),
.syntax_literal = Color.rgb8(98, 192, 115),
.syntax_function = Color.rgb8(191, 122, 240),
.syntax_property = Color.rgb8(255, 97, 102),
.syntax_constant = Color.rgb8(82, 168, 255),
.border = putty_line,
.accent = phosphor,
.accent_text = Color.rgb8(7, 21, 13),
+1 -1
View File
@@ -8,7 +8,7 @@ native dev
## What it demonstrates
- **`<markdown>` in markup** — headings on the span scale, inline styles, clickable links (pointer cursor; opened in the system browser through `fx.spawn open`/`xdg-open`), task lists, fenced code, blockquotes, GFM tables with column alignment, and `<details>` blocks whose expansion flags live in the model (`details_expanded: [16]bool`), toggled in `update`.
- **`<markdown>` in markup** — headings on the span scale, inline styles, clickable links (pointer cursor; opened in the system browser through `fx.spawn open`/`xdg-open`), first-line-aligned list markers, fenced code lowered through the reusable `code` component with preserved indentation and language-tag syntax highlighting (including JSX/TSX-aware tags and attributes), blockquotes, GFM tables with column alignment, and `<details>` blocks whose expansion flags live in the model (`details_expanded: [16]bool`), toggled in `update`.
- **Real file I/O without native dialogs** — the Native SDK has no file-dialog service, so this app uses the honest pattern: an editable path field in the toolbar. **Open** reads it (`fx.readFile`), **Save** writes the editor back to the current document, **Save As** writes to whatever the field says and adopts it. Every result is one typed Msg with an explicit outcome; failures land in the status bar, never a dialog.
- **Recent files persisted through the same effects** — opened/saved paths join a sidebar list that persists to the per-app data directory (`native_sdk.app_dirs`, resolved once in `main`) via `fx.writeFile`, and is restored at boot by `init_fx` + `fx.readFile`.
- **System appearance, followed live** — a refined stone/indigo palette (light and dark) derives per rebuild through `tokens_fn` from the scheme `on_appearance` delivers, so flipping the OS between light and dark re-themes the window immediately; there is no in-window theme control by design.
+14
View File
@@ -561,6 +561,13 @@ pub fn viewerTokens(model: *const Model) canvas.DesignTokens {
.surface_pressed = canvas.Color.rgb8(231, 229, 228),
.text = canvas.Color.rgb8(12, 10, 9),
.text_muted = canvas.Color.rgb8(121, 113, 107),
.syntax_plain = canvas.Color.rgb8(23, 23, 23),
.syntax_comment = canvas.Color.rgb8(77, 77, 77),
.syntax_keyword = canvas.Color.rgb8(189, 40, 100),
.syntax_literal = canvas.Color.rgb8(41, 122, 58),
.syntax_function = canvas.Color.rgb8(120, 32, 188),
.syntax_property = canvas.Color.rgb8(203, 42, 47),
.syntax_constant = canvas.Color.rgb8(0, 104, 214),
.border = canvas.Color.rgb8(231, 229, 228),
.accent = canvas.Color.rgb8(67, 45, 215),
.accent_text = canvas.Color.rgb8(238, 242, 255),
@@ -581,6 +588,13 @@ pub fn viewerTokens(model: *const Model) canvas.DesignTokens {
.surface_pressed = canvas.Color.rgba8(255, 255, 255, 38),
.text = canvas.Color.rgb8(250, 250, 249),
.text_muted = canvas.Color.rgb8(166, 160, 155),
.syntax_plain = canvas.Color.rgb8(237, 237, 237),
.syntax_comment = canvas.Color.rgb8(161, 161, 161),
.syntax_keyword = canvas.Color.rgb8(247, 95, 143),
.syntax_literal = canvas.Color.rgb8(98, 192, 115),
.syntax_function = canvas.Color.rgb8(191, 122, 240),
.syntax_property = canvas.Color.rgb8(255, 97, 102),
.syntax_constant = canvas.Color.rgb8(82, 168, 255),
.border = canvas.Color.rgba8(255, 255, 255, 26),
.accent = canvas.Color.rgb8(124, 134, 255),
.accent_text = canvas.Color.rgb8(12, 10, 9),
+14
View File
@@ -150,6 +150,13 @@ pub fn notesTokens(model: *const Model) canvas.DesignTokens {
.surface_pressed = canvas.Color.rgb8(231, 229, 228),
.text = canvas.Color.rgb8(12, 10, 9),
.text_muted = canvas.Color.rgb8(121, 113, 107),
.syntax_plain = canvas.Color.rgb8(23, 23, 23),
.syntax_comment = canvas.Color.rgb8(77, 77, 77),
.syntax_keyword = canvas.Color.rgb8(189, 40, 100),
.syntax_literal = canvas.Color.rgb8(41, 122, 58),
.syntax_function = canvas.Color.rgb8(120, 32, 188),
.syntax_property = canvas.Color.rgb8(203, 42, 47),
.syntax_constant = canvas.Color.rgb8(0, 104, 214),
.border = canvas.Color.rgb8(231, 229, 228),
.accent = canvas.Color.rgb8(0, 120, 111),
.accent_text = canvas.Color.rgb8(240, 253, 250),
@@ -170,6 +177,13 @@ pub fn notesTokens(model: *const Model) canvas.DesignTokens {
.surface_pressed = canvas.Color.rgba8(255, 255, 255, 38),
.text = canvas.Color.rgb8(250, 250, 249),
.text_muted = canvas.Color.rgb8(166, 160, 155),
.syntax_plain = canvas.Color.rgb8(237, 237, 237),
.syntax_comment = canvas.Color.rgb8(161, 161, 161),
.syntax_keyword = canvas.Color.rgb8(247, 95, 143),
.syntax_literal = canvas.Color.rgb8(98, 192, 115),
.syntax_function = canvas.Color.rgb8(191, 122, 240),
.syntax_property = canvas.Color.rgb8(255, 97, 102),
.syntax_constant = canvas.Color.rgb8(82, 168, 255),
.border = canvas.Color.rgba8(255, 255, 255, 26),
.accent = canvas.Color.rgb8(0, 187, 167),
.accent_text = canvas.Color.rgb8(12, 10, 9),
+14
View File
@@ -43,6 +43,13 @@ pub const light_colors = canvas.ColorTokens{
.surface_pressed = Color.rgb8(228, 228, 231),
.text = Color.rgb8(9, 9, 11),
.text_muted = Color.rgb8(113, 113, 123),
.syntax_plain = Color.rgb8(23, 23, 23),
.syntax_comment = Color.rgb8(77, 77, 77),
.syntax_keyword = Color.rgb8(189, 40, 100),
.syntax_literal = Color.rgb8(41, 122, 58),
.syntax_function = Color.rgb8(120, 32, 188),
.syntax_property = Color.rgb8(203, 42, 47),
.syntax_constant = Color.rgb8(0, 104, 214),
.border = Color.rgb8(228, 228, 231),
.accent = Color.rgb8(0, 120, 111),
.accent_text = Color.rgb8(240, 253, 250),
@@ -68,6 +75,13 @@ pub const dark_colors = canvas.ColorTokens{
.surface_pressed = Color.rgba8(255, 255, 255, 38),
.text = Color.rgb8(250, 250, 250),
.text_muted = Color.rgb8(159, 159, 169),
.syntax_plain = Color.rgb8(237, 237, 237),
.syntax_comment = Color.rgb8(161, 161, 161),
.syntax_keyword = Color.rgb8(247, 95, 143),
.syntax_literal = Color.rgb8(98, 192, 115),
.syntax_function = Color.rgb8(191, 122, 240),
.syntax_property = Color.rgb8(255, 97, 102),
.syntax_constant = Color.rgb8(82, 168, 255),
.border = Color.rgba8(255, 255, 255, 26),
.accent = Color.rgb8(0, 213, 190),
.accent_text = Color.rgb8(9, 9, 11),
+17 -2
View File
@@ -182,6 +182,7 @@ Automation drives the native path honestly: snapshots list every widget's declar
| `icon` | vector icon leaf | `name` picks the icon: a bare literal is a curated built-in stroke icon (compile-checked; 49 names: search, plus, x, x-circle, check, check-circle, chevron-up/down/left/right, arrow-up/down/right, menu, panel-left, panel-right, settings, terminal, wrench, trash, edit, copy, external-link, play, pause, skip-back/forward, shuffle, repeat, music, volume, info, alert, download, save, folder, folder-open, file-text, sun, moon, eye, clock, git-pull-request, git-merge, git-branch, circle-dot, archive, refresh-cw, send); `app:<name>` reaches an icon the app registered at boot with `canvas.icons.registerAppIcons` (declare the table as `pub const app_icons` on the app root so `native check` verifies the name against the model contract), and one `{binding}` defers the choice to model data - an unknown resolved name draws the missing-icon fallback (a slashed circle) with a Debug warning naming the value, never a silent gap; tint with `foreground`, size with `width`/`height` |
| `media-surface` | media surface leaf | composites a texture produced OUTSIDE the widget tree (video decoder, camera, an external renderer like mpv) into the layout like any widget — clipped, z-ordered, rounded. `surface="{binding}"` (required) binds the model-owned u64 surface id a Zig-tier producer targets (`runtime.acquireMediaSurfaceProducer` pushes RGBA8 frames, latest-wins, paced by the presented-frame clock; 0 = unbound, draws nothing; usable ids are nonzero values below the reserved bit 63). No intrinsic size — give it `width`/`height` or `grow`; display-only (presses fall through); `label` it (pictorial content). Texture contents are presentation chrome: goldens, reference screenshots, and session replay show the deterministic id-derived placeholder, never producer frames |
| `image` | runtime image leaf | draws a RUNTIME-REGISTERED image by its model-owned u64 ImageId — the id `Cmd.imageLoad` (TS) or `fx.loadImage`/`fx.registerImageBytes` (Zig) registered pixels under. `image="{binding}"` (required) binds a model field/fn; ids are model data, never markup literals, and 0 draws nothing (store the id only when the load reports loaded — see the Images section). No intrinsic size — give it `width`/`height` or `grow`; display-only (presses fall through); `label` it (pictorial content) |
| `code` | highlighted source surface | `source="{binding}"` (required) provides source text and `language="tsx"` selects a literal lexer name; wraps by default, `line-numbers` opts into logical line numbers, and `wrap="false"` preserves lines inside one horizontal scroll region. HTML-family highlighting distinguishes HTML/XML/SVG and JSX/TSX tags, attributes, strings, comments, and embedded expressions. Zig builder: `ui.code(CodeOptions, source)` |
| `markdown` | rendered markdown subtree | leaf; `source` is one `{binding}` — see "Markdown in markup" |
| `stepper` > `step` | composite stage track | `active="{index}"` (required) derives each step's completed/active/pending state; steps are text leaves (no attributes) joined by connectors; stepper also takes `key`, `global-key`, `label` |
| `timeline` > `timeline-item` | composite ledger list | items only inside a timeline (for/if fine); items are leaves — `title` (required), `description`, `meta`, `indicator`, `variant`, `connector="false"` on the last item, `selected`; `on-press` makes the whole item pressable with a trailing chevron |
@@ -353,7 +354,7 @@ The `examples/feed` app is the reference: a 100,000-post deterministic MIXED-HEI
Color and radius come from the design tokens, referenced by token NAME — literals only, no bindings, no raw colors (dynamic styling stays in Zig via `ElementOptions.style`):
- Color attributes: `background`, `foreground`, `accent`, `accent-foreground`, `border-color`, `focus-ring`. Values are `canvas.ColorTokens` field names — the complete list: `background`, `surface`, `surface_subtle`, `surface_pressed`, `text`, `text_muted`, `border`, `accent`, `accent_text`, `destructive`, `destructive_text`, `success`, `success_text`, `warning`, `warning_text`, `info`, `info_text`, `focus_ring`, `shadow`, `disabled`. `info` is the violet identity hue beside the status trio (merged PR badges, "new" chips). (`border-color`, not bare `border` — that name is reserved for a future width shorthand.)
- Color attributes: `background`, `foreground`, `accent`, `accent-foreground`, `border-color`, `focus-ring`. Values are `canvas.ColorTokens` field names — the complete list: `background`, `surface`, `surface_subtle`, `surface_pressed`, `text`, `text_muted`, `syntax_plain`, `syntax_comment`, `syntax_keyword`, `syntax_literal`, `syntax_function`, `syntax_property`, `syntax_constant`, `border`, `accent`, `accent_text`, `destructive`, `destructive_text`, `success`, `success_text`, `warning`, `warning_text`, `info`, `info_text`, `focus_ring`, `shadow`, `scrim`, `disabled`. The `syntax_*` roles are the Geist Code Block palette used automatically by `ui.code` and Markdown fences in both built-in packs. `info` is the violet identity hue beside the status trio (merged PR badges, "new" chips). (`border-color`, not bare `border` — that name is reserved for a future width shorthand.)
- `radius``canvas.RadiusTokens` field names: `sm`, `md`, `lg`, `xl`.
```html
@@ -967,6 +968,20 @@ Rules and semantics:
Both engines implement templates, defaults, slots, and imports: the interpreter expands at build time (hot reload re-resolves imports from disk, so edits to imported files reload), and the compiled engine inlines at comptime with the identical result. A document with imports compiles through `canvas.CompiledMarkupImports(Model, Msg, "root.native", &sources)` where `sources` is a `canvas.ui_markup.SourceFile` set (`.{ .path = "components/cards.native", .source = @embedFile("components/cards.native") }`, paths relative to the root file's directory); pass the same set on `MarkupOptions.sources` for the runtime engine. See `examples/kanban/src/board.native` + `examples/kanban/src/components/board-column.native`.
## Code in markup: `<code>`
A source-bound highlighted surface shared with Markdown fences:
```html
<code source="{snippet}" language="tsx" line-numbers wrap="false" width="480" label="Component source" />
```
- `source` is one required `{binding}` producing `[]const u8`; the element has no children.
- `language` is a literal lexer name: Zig, JS/TS, JSX/TSX, JSON, shell, Python, Rust, C-family, Go, HTML/XML/SVG, CSS-family, or SQL. Omit it for plain monospace.
- Wrapping is on by default. `wrap="false"` keeps logical lines intact inside one horizontal scroll region. A definite `height` makes overflow scroll vertically; with wrapping off, that constrained region scrolls on both axes.
- `line-numbers` is off by default. Wrapped logical lines stay paired with their number. Numbered mode is limited to 128 logical lines and a reserved share of the per-view node and text-span budgets; sources that exceed any bound preserve all code and omit the gutter.
- Zig builder: `ui.code(.{ .language = .html, .line_numbers = true, .wrap = false }, model.snippet)`. The public lexer helpers are under `native_sdk.canvas.code`.
## Markdown in markup: `<markdown>`
A leaf element that renders a markdown string (the GFM subset below) as ordinary widgets, wiring `native_sdk.markdown` for you — both engines implement it identically:
@@ -1080,7 +1095,7 @@ Md.view(ui, model.body_markdown, .{
})
```
- Supported: `#``###` headings, paragraphs with `**bold**`/`*italic*`/`` `code` ``/`~~strike~~`/`[links](url)`, bare `http(s)://` URLs (autolink, trailing punctuation trimmed), `#123` issue refs (opt-in: set `Options.issue_link_base` and the ref links to base ++ number), bullet + ordered + task lists (task checkboxes are display-only, disabled), fenced code blocks, `> blockquotes`, `---` rules, GFM pipe tables (header bold, `:---`/`:--:`/`---:` column alignment, inline spans + clickable links inside cells, `\|` escapes a pipe in a cell; columns share width equally, and a missing/mismatched delimiter row degrades the block to paragraphs), `<details><summary>`.
- Supported: `#``###` headings, paragraphs with `**bold**`/`*italic*`/`` `code` ``/`~~strike~~`/`[links](url)`, bare `http(s)://` URLs (autolink, trailing punctuation trimmed), `#123` issue refs (opt-in: set `Options.issue_link_base` and the ref links to base ++ number), bullet + ordered + task lists (task checkboxes are display-only, disabled), fenced code blocks (source indentation preserved; they lower through `ui.code` with wrapping on and line numbers off; recognized Zig, JS/TS, JSX/TSX, JSON, shell, Python, Rust, C-family, Go, HTML/XML/SVG, CSS-family, and SQL info strings get theme-token syntax highlighting; unknown tags stay plain mono), `> blockquotes`, `---` rules, GFM pipe tables (header bold, `:---`/`:--:`/`---:` column alignment, inline spans + clickable links inside cells, `\|` escapes a pipe in a cell; columns share width equally, and a missing/mismatched delimiter row degrades the block to paragraphs), `<details><summary>`.
- Not in v1 (degrades to plain text, never fails): reference links, raw HTML, footnotes, backslash escapes (except `\|` in table rows).
- `<details>` state is elm-style: the CALLER owns the expanded flags. Keep a bounded `details_expanded: [8]bool` in the model, toggle it in `update` on the details message, and pass the slice back in.
+590
View File
@@ -0,0 +1,590 @@
//! Syntax-aware source-code presentation shared by `Ui.code` and
//! Markdown fenced blocks.
//!
//! The lexer is deliberately small and deterministic: it recognizes the
//! punctuation classes that make common snippets readable, emits only
//! theme-token colors, and always preserves an unstyled remainder when a
//! token-dense source reaches the paragraph span limit.
const std = @import("std");
const text_spans = @import("text_spans.zig");
pub const TextSpan = text_spans.TextSpan;
const max_html_tag_contexts: usize = 32;
pub const Language = enum {
plain,
zig,
javascript,
typescript,
json,
shell,
python,
rust,
c_like,
go,
html,
css,
sql,
};
/// Lexer state carried between bounded source chunks by `Ui.code`.
/// Keeping it explicit lets the component reset its span budget without
/// forgetting a multiline tag, string, or block comment.
pub const HighlightState = struct {
html_in_tag: bool = false,
html_expect_tag_name: bool = false,
html_expression_depth: usize = 0,
/// Expression depth at which the current tag opened. JSX tags can sit
/// inside `{...}`; their closing `>` must return to that expression,
/// not erase it.
html_tag_expression_base: usize = 0,
/// JSX permits an element inside an attribute expression before the
/// enclosing opening tag has closed. Preserve those enclosing tag
/// contexts so the inner `>` resumes attribute highlighting instead
/// of ending it.
html_tag_context_bases: [max_html_tag_contexts]usize = [_]usize{0} ** max_html_tag_contexts,
html_tag_context_expect_names: [max_html_tag_contexts]bool = [_]bool{false} ** max_html_tag_contexts,
html_tag_context_len: usize = 0,
/// Last non-whitespace source byte from the preceding presentation
/// chunk. JSX comparison/tag disambiguation needs its left context even
/// when a bounded paragraph happens to split immediately before `<`.
html_previous_significant: u8 = 0,
html_comment: bool = false,
block_comment: bool = false,
line_comment: bool = false,
preprocessor_line: bool = false,
string_quote: ?u8 = null,
};
fn pushHtmlTagContext(state: *HighlightState) void {
if (!state.html_in_tag or state.html_tag_context_len >= max_html_tag_contexts) return;
const index = state.html_tag_context_len;
state.html_tag_context_bases[index] = state.html_tag_expression_base;
state.html_tag_context_expect_names[index] = state.html_expect_tag_name;
state.html_tag_context_len += 1;
}
fn restoreHtmlTagContext(state: *HighlightState) bool {
if (state.html_tag_context_len == 0) return false;
state.html_tag_context_len -= 1;
const index = state.html_tag_context_len;
state.html_in_tag = true;
state.html_tag_expression_base = state.html_tag_context_bases[index];
state.html_expect_tag_name = state.html_tag_context_expect_names[index];
return true;
}
/// Resolve a public language name. Unknown names remain plain instead of
/// guessing a grammar and coloring ordinary identifiers as keywords.
pub fn languageFromName(name_raw: []const u8) Language {
const name = std.mem.trim(u8, name_raw, " \t\r\n");
if (std.ascii.eqlIgnoreCase(name, "zig")) return .zig;
if (std.ascii.eqlIgnoreCase(name, "jsx") or std.ascii.eqlIgnoreCase(name, "tsx")) return .html;
if (std.ascii.eqlIgnoreCase(name, "js") or std.ascii.eqlIgnoreCase(name, "javascript")) return .javascript;
if (std.ascii.eqlIgnoreCase(name, "ts") or std.ascii.eqlIgnoreCase(name, "typescript")) return .typescript;
if (std.ascii.eqlIgnoreCase(name, "json") or std.ascii.eqlIgnoreCase(name, "jsonc")) return .json;
if (std.ascii.eqlIgnoreCase(name, "sh") or std.ascii.eqlIgnoreCase(name, "bash") or std.ascii.eqlIgnoreCase(name, "zsh") or std.ascii.eqlIgnoreCase(name, "shell")) return .shell;
if (std.ascii.eqlIgnoreCase(name, "py") or std.ascii.eqlIgnoreCase(name, "python")) return .python;
if (std.ascii.eqlIgnoreCase(name, "rs") or std.ascii.eqlIgnoreCase(name, "rust")) return .rust;
if (std.ascii.eqlIgnoreCase(name, "c") or std.ascii.eqlIgnoreCase(name, "h") or
std.ascii.eqlIgnoreCase(name, "cc") or std.ascii.eqlIgnoreCase(name, "cpp") or std.ascii.eqlIgnoreCase(name, "c++") or
std.ascii.eqlIgnoreCase(name, "cs") or std.ascii.eqlIgnoreCase(name, "csharp") or
std.ascii.eqlIgnoreCase(name, "java") or std.ascii.eqlIgnoreCase(name, "kotlin") or
std.ascii.eqlIgnoreCase(name, "swift"))
{
return .c_like;
}
if (std.ascii.eqlIgnoreCase(name, "go") or std.ascii.eqlIgnoreCase(name, "golang")) return .go;
if (std.ascii.eqlIgnoreCase(name, "html") or std.ascii.eqlIgnoreCase(name, "xml") or std.ascii.eqlIgnoreCase(name, "svg")) return .html;
if (std.ascii.eqlIgnoreCase(name, "css") or std.ascii.eqlIgnoreCase(name, "scss") or std.ascii.eqlIgnoreCase(name, "less")) return .css;
if (std.ascii.eqlIgnoreCase(name, "sql")) return .sql;
return .plain;
}
pub fn isLanguageName(name_raw: []const u8) bool {
const name = std.mem.trim(u8, name_raw, " \t\r\n");
return languageFromName(name) != .plain or
std.ascii.eqlIgnoreCase(name, "plain") or
std.ascii.eqlIgnoreCase(name, "text");
}
/// Resolve the first word of a Markdown fence's info string.
pub fn languageFromFence(opening: []const u8) Language {
const trimmed = std.mem.trim(u8, opening, " \t");
if (trimmed.len <= 3) return .plain;
var info = std.mem.trim(u8, trimmed[3..], " \t");
if (std.mem.startsWith(u8, info, "{.")) info = info[2..];
var end: usize = 0;
while (end < info.len) : (end += 1) {
const byte = info[end];
if (!(std.ascii.isAlphanumeric(byte) or byte == '_' or byte == '-' or byte == '+' or byte == '#')) break;
}
return languageFromName(info[0..end]);
}
fn wordInList(word: []const u8, list: []const u8, ignore_case: bool) bool {
var words = std.mem.tokenizeScalar(u8, list, ' ');
while (words.next()) |candidate| {
if (if (ignore_case) std.ascii.eqlIgnoreCase(word, candidate) else std.mem.eql(u8, word, candidate)) return true;
}
return false;
}
/// Zig fences dominate SDK documentation, so the hot grammar uses a
/// length-indexed map instead of rescanning a word list per identifier.
const zig_words = std.StaticStringMap(text_spans.TextSpanColor).initComptime(.{
.{ "addrspace", .syntax_keyword }, .{ "align", .syntax_keyword }, .{ "allowzero", .syntax_keyword },
.{ "and", .syntax_keyword }, .{ "anyerror", .syntax_literal }, .{ "anyframe", .syntax_keyword },
.{ "anytype", .syntax_keyword }, .{ "asm", .syntax_keyword }, .{ "async", .syntax_keyword },
.{ "await", .syntax_keyword }, .{ "bool", .syntax_literal }, .{ "break", .syntax_keyword },
.{ "callconv", .syntax_keyword }, .{ "catch", .syntax_keyword }, .{ "comptime", .syntax_keyword },
.{ "comptime_float", .syntax_literal }, .{ "comptime_int", .syntax_literal }, .{ "const", .syntax_keyword },
.{ "continue", .syntax_keyword }, .{ "defer", .syntax_keyword }, .{ "else", .syntax_keyword },
.{ "enum", .syntax_keyword }, .{ "errdefer", .syntax_keyword }, .{ "error", .syntax_keyword },
.{ "export", .syntax_keyword }, .{ "extern", .syntax_keyword }, .{ "f16", .syntax_literal },
.{ "f32", .syntax_literal }, .{ "f64", .syntax_literal }, .{ "f80", .syntax_literal },
.{ "f128", .syntax_literal }, .{ "false", .syntax_literal }, .{ "fn", .syntax_keyword },
.{ "for", .syntax_keyword }, .{ "i8", .syntax_literal }, .{ "i16", .syntax_literal },
.{ "i32", .syntax_literal }, .{ "i64", .syntax_literal }, .{ "i128", .syntax_literal },
.{ "if", .syntax_keyword }, .{ "inline", .syntax_keyword }, .{ "isize", .syntax_literal },
.{ "linksection", .syntax_keyword }, .{ "noalias", .syntax_keyword }, .{ "noinline", .syntax_keyword },
.{ "noreturn", .syntax_literal }, .{ "nosuspend", .syntax_keyword }, .{ "null", .syntax_literal },
.{ "opaque", .syntax_keyword }, .{ "or", .syntax_keyword }, .{ "orelse", .syntax_keyword },
.{ "packed", .syntax_keyword }, .{ "pub", .syntax_keyword }, .{ "resume", .syntax_keyword },
.{ "return", .syntax_keyword }, .{ "struct", .syntax_keyword }, .{ "suspend", .syntax_keyword },
.{ "switch", .syntax_keyword }, .{ "test", .syntax_keyword }, .{ "threadlocal", .syntax_keyword },
.{ "true", .syntax_literal }, .{ "try", .syntax_keyword }, .{ "type", .syntax_literal },
.{ "u8", .syntax_literal }, .{ "u16", .syntax_literal }, .{ "u32", .syntax_literal },
.{ "u64", .syntax_literal }, .{ "u128", .syntax_literal }, .{ "undefined", .syntax_literal },
.{ "union", .syntax_keyword }, .{ "unreachable", .syntax_keyword }, .{ "usize", .syntax_literal },
.{ "usingnamespace", .syntax_keyword }, .{ "var", .syntax_keyword }, .{ "void", .syntax_literal },
.{ "volatile", .syntax_keyword }, .{ "while", .syntax_keyword },
});
fn wordColor(language: Language, word: []const u8) ?text_spans.TextSpanColor {
if (word.len > 0 and word[0] == '@') return .syntax_function;
if (language == .zig) return zig_words.get(word);
if (language == .python and wordInList(word, "True False None", false)) return .syntax_literal;
if (wordInList(word, "true false null nil none undefined this self super", language == .sql)) return .syntax_literal;
const keywords = switch (language) {
.plain => return null,
.zig => unreachable,
.javascript => "async await break case catch class const continue debugger default delete do else export extends finally for from function get if import in instanceof let new of return set static switch throw try typeof var void while with yield",
.typescript => "abstract any as asserts async await bigint boolean break case catch class const constructor continue declare default delete do else enum export extends finally for from function get if implements import in infer interface instanceof is keyof let module namespace never new number object of override private protected public readonly require return satisfies set static string super switch symbol this throw try type typeof undefined unique unknown var void while with yield",
.json => "",
.shell => "case coproc do done elif else esac fi for function if in select then time until while",
.python => "and as assert async await break case class continue def del elif else except finally for from global if import in is lambda match nonlocal not or pass raise return try while with yield",
.rust => "as async await break const continue crate dyn else enum extern fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait type union unsafe use where while",
.c_like => "abstract alignas alignof asm auto break case catch class const constexpr continue default delete do else enum explicit export extends extern final finally for foreach friend goto if implements import in inline interface internal namespace native new noexcept operator override package private protected public register reinterpret_cast return sealed signed sizeof static strictfp struct switch synchronized template this throw throws trait transient try typedef typeid typename union unsigned using virtual volatile while",
.go => "break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var",
.html => "",
.css => "and important inherit initial none not only or revert unset",
.sql => "add all alter and any as asc begin between by case check column commit constraint create cross database default delete desc distinct drop else end exists foreign from full grant group having in index inner insert intersect into is join key left like limit not null on or order outer primary references right rollback row select set table then union unique update values view when where with",
};
if (wordInList(word, keywords, language == .sql)) return .syntax_keyword;
const types = switch (language) {
.rust => "bool char str String Vec Option Result Box i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64",
.c_like => "bool boolean byte char decimal double float int long object sbyte short string uint ulong ushort void",
.go => "any bool byte comparable complex64 complex128 error float32 float64 int int8 int16 int32 int64 rune string uint uint8 uint16 uint32 uint64 uintptr",
.javascript, .typescript => "Array BigInt Boolean Date Error Map Number Object Promise RegExp Set String Symbol",
.python => "bool bytes dict float int list object set str tuple",
else => "",
};
if (wordInList(word, types, false)) return .syntax_literal;
return null;
}
fn identifierStructuralColor(language: Language, source: []const u8, end: usize) ?text_spans.TextSpanColor {
var cursor = end;
while (cursor < source.len and (source[cursor] == ' ' or source[cursor] == '\t')) cursor += 1;
if (cursor >= source.len or source[cursor] == '\n') return null;
if (source[cursor] == '(' and language != .plain and language != .json) return .syntax_function;
if (source[cursor] == ':' and switch (language) {
.javascript, .typescript, .css => true,
else => false,
}) return .syntax_property;
if (source[cursor] == '{' and language == .css) return .syntax_literal;
return null;
}
fn identifierStart(byte: u8) bool {
return std.ascii.isAlphabetic(byte) or byte == '_' or byte == '@' or byte == '$';
}
fn identifierContinue(byte: u8) bool {
return std.ascii.isAlphanumeric(byte) or byte == '_' or byte == '@' or byte == '$';
}
fn htmlTagOpenerByte(byte: u8) bool {
return identifierStart(byte) or byte == '/' or byte == '!' or byte == '?' or byte == '>';
}
fn htmlPreviousAllowsTag(byte: u8) bool {
return switch (byte) {
0, '{', '(', '[', ',', ':', '?', '=', '>', '!', '&', '|', ';' => true,
else => false,
};
}
/// A `<` in HTML-family source is structural only when it can begin a tag.
/// Inside a JSX expression, the preceding token must also leave room for an
/// expression operand; `count < limit` is relational, while
/// `ok && <Badge />` starts nested JSX.
fn htmlLessThanStartsTag(source: []const u8, index: usize, state: HighlightState) bool {
if (index + 1 >= source.len or !htmlTagOpenerByte(source[index + 1])) return false;
if (state.html_expression_depth == 0) return true;
var cursor = index;
while (cursor > 0) {
cursor -= 1;
const byte = source[cursor];
if (byte == ' ' or byte == '\t' or byte == '\r' or byte == '\n') continue;
return htmlPreviousAllowsTag(byte);
}
return htmlPreviousAllowsTag(state.html_previous_significant);
}
fn updateHtmlPreviousSignificant(state: *HighlightState, source: []const u8) void {
var cursor = source.len;
while (cursor > 0) {
cursor -= 1;
const byte = source[cursor];
if (byte == ' ' or byte == '\t' or byte == '\r' or byte == '\n') continue;
state.html_previous_significant = byte;
return;
}
}
fn stringQuote(language: Language, byte: u8) bool {
return switch (language) {
.plain, .html => false,
.json => byte == '"',
// A Rust apostrophe begins a character only when a closing quote
// follows one scalar or escape; otherwise it introduces a lifetime
// (`'a`, `'static`) and must not open multiline string state.
.rust => byte == '"',
.shell, .javascript, .typescript, .go => byte == '"' or byte == '\'' or byte == '`',
else => byte == '"' or byte == '\'',
};
}
fn rustCharLiteralLength(rest: []const u8) ?usize {
if (rest.len < 3 or rest[0] != '\'') return null;
var cursor: usize = 1;
if (rest[cursor] == '\\') {
cursor += 1;
if (cursor >= rest.len) return null;
switch (rest[cursor]) {
'x' => {
cursor += 1;
if (cursor + 2 > rest.len or
!std.ascii.isHex(rest[cursor]) or
!std.ascii.isHex(rest[cursor + 1]))
{
return null;
}
cursor += 2;
},
'u' => {
cursor += 1;
if (cursor >= rest.len or rest[cursor] != '{') return null;
cursor += 1;
var digits: usize = 0;
while (cursor < rest.len and rest[cursor] != '}') : (cursor += 1) {
if (rest[cursor] == '_') continue;
if (!std.ascii.isHex(rest[cursor])) return null;
digits += 1;
}
if (digits == 0 or cursor >= rest.len) return null;
cursor += 1;
},
else => cursor += 1,
}
} else {
const scalar_len = std.unicode.utf8ByteSequenceLength(rest[cursor]) catch return null;
if (cursor + scalar_len > rest.len) return null;
_ = std.unicode.utf8Decode(rest[cursor .. cursor + scalar_len]) catch return null;
cursor += scalar_len;
}
if (cursor >= rest.len or rest[cursor] != '\'') return null;
return cursor + 1;
}
fn backslashEscapesQuote(language: Language, state: HighlightState, quote: u8) bool {
return switch (language) {
// Plain HTML attributes do not use JavaScript escapes for either
// quote style, but strings inside JSX expressions do.
.html => state.html_expression_depth > 0,
// Shell single quotes are literal. SQL quotes are escaped by
// doubling them, never with a backslash.
.shell => quote != '\'',
.sql => false,
else => true,
};
}
fn lineCommentPrefix(language: Language, rest: []const u8) usize {
if (rest.len == 0) return 0;
return switch (language) {
.zig, .javascript, .typescript, .rust, .c_like, .go => if (std.mem.startsWith(u8, rest, "//")) 2 else 0,
.shell, .python => if (rest[0] == '#') 1 else 0,
.sql => if (std.mem.startsWith(u8, rest, "--")) 2 else 0,
else => 0,
};
}
fn hasBlockComments(language: Language) bool {
return switch (language) {
.zig, .javascript, .typescript, .rust, .c_like, .go, .css, .sql => true,
else => false,
};
}
/// Add one token, coalescing adjacent tokens with the same color. The
/// final slot is a plain-syntax remainder, so capacity never drops source.
fn appendSpan(
storage: *[text_spans.max_text_spans_per_paragraph]TextSpan,
len: *usize,
source: []const u8,
start: usize,
end: usize,
color: ?text_spans.TextSpanColor,
) bool {
if (end <= start) return true;
if (len.* > 0) {
const previous = &storage[len.* - 1];
if (previous.color == color and previous.text.ptr + previous.text.len == source[start..].ptr) {
previous.text = previous.text.ptr[0 .. previous.text.len + end - start];
return true;
}
}
if (len.* + 1 >= storage.len) {
storage[len.*] = .{ .text = source[start..], .monospace = true, .color = .syntax_plain };
len.* += 1;
return false;
}
storage[len.*] = .{ .text = source[start..end], .monospace = true, .color = color };
len.* += 1;
return true;
}
/// Tokenize `source` into theme-colored monospace spans.
pub fn highlight(
source: []const u8,
language: Language,
storage: *[text_spans.max_text_spans_per_paragraph]TextSpan,
) []const TextSpan {
var state: HighlightState = .{};
return highlightWithState(source, language, storage, &state);
}
/// Stateful form used when one code surface emits multiple bounded
/// paragraphs. Each chunk gets the full span capacity while lexer context
/// survives into the next chunk.
pub fn highlightWithState(
source: []const u8,
language: Language,
storage: *[text_spans.max_text_spans_per_paragraph]TextSpan,
state: *HighlightState,
) []const TextSpan {
if (source.len == 0) return &.{};
if (language == .plain) {
storage[0] = .{ .text = source, .monospace = true, .color = .syntax_plain };
return storage[0..1];
}
var len: usize = 0;
var styling_full = false;
var index: usize = 0;
while (index < source.len) {
const start = index;
const rest = source[index..];
var color: ?text_spans.TextSpanColor = .syntax_plain;
// Artificial presentation chunks can end in the middle of a
// logical source line. A real newline ends the two line-scoped
// states before ordinary token dispatch handles that byte.
if (rest[0] == '\n') {
state.line_comment = false;
state.preprocessor_line = false;
}
if (state.line_comment) {
while (index < source.len and source[index] != '\n') index += 1;
state.line_comment = index == source.len;
color = .syntax_comment;
} else if (state.preprocessor_line) {
while (index < source.len and source[index] != '\n') index += 1;
state.preprocessor_line = index == source.len;
color = .syntax_constant;
} else if (state.html_comment) {
while (index < source.len and !std.mem.startsWith(u8, source[index..], "-->")) index += 1;
if (index < source.len) {
index = @min(source.len, index + 3);
state.html_comment = false;
}
color = .syntax_comment;
} else if (state.block_comment) {
while (index < source.len and !std.mem.startsWith(u8, source[index..], "*/")) index += 1;
if (index < source.len) {
index = @min(source.len, index + 2);
state.block_comment = false;
}
color = .syntax_comment;
} else if (state.string_quote) |quote| {
var closed = false;
while (index < source.len) {
if (source[index] == '\\' and
backslashEscapesQuote(language, state.*, quote) and
index + 1 < source.len)
{
index += 2;
continue;
}
const byte = source[index];
index += 1;
if (byte == quote) {
closed = true;
break;
}
}
if (closed) state.string_quote = null;
color = .syntax_literal;
} else if (language == .html and std.mem.startsWith(u8, rest, "<!--")) {
index += 4;
while (index < source.len and !std.mem.startsWith(u8, source[index..], "-->")) index += 1;
if (index < source.len) {
index = @min(source.len, index + 3);
} else {
state.html_comment = true;
}
color = .syntax_comment;
} else if (hasBlockComments(language) and std.mem.startsWith(u8, rest, "/*")) {
index += 2;
while (index < source.len and !std.mem.startsWith(u8, source[index..], "*/")) index += 1;
if (index < source.len) {
index = @min(source.len, index + 2);
} else {
state.block_comment = true;
}
color = .syntax_comment;
} else if (lineCommentPrefix(language, rest) != 0) {
while (index < source.len and source[index] != '\n') index += 1;
state.line_comment = index == source.len;
color = .syntax_comment;
} else if (language == .c_like and rest[0] == '#') {
while (index < source.len and source[index] != '\n') index += 1;
state.preprocessor_line = index == source.len;
color = .syntax_constant;
} else if (language == .html and
rest[0] == '<' and
htmlLessThanStartsTag(source, index, state.*))
{
index += 1;
if (index < source.len and source[index] == '/') index += 1;
pushHtmlTagContext(state);
state.html_in_tag = true;
state.html_expect_tag_name = true;
state.html_tag_expression_base = state.html_expression_depth;
color = .syntax_plain;
} else if (language == .html and
state.html_in_tag and
state.html_expression_depth == state.html_tag_expression_base and
rest[0] == '>')
{
index += 1;
if (!restoreHtmlTagContext(state)) {
state.html_in_tag = false;
state.html_expect_tag_name = false;
}
color = .syntax_plain;
} else if (language == .html and rest[0] == '{') {
index += 1;
state.html_expression_depth += 1;
color = .syntax_plain;
} else if (language == .html and state.html_expression_depth > 0 and rest[0] == '}') {
index += 1;
state.html_expression_depth -= 1;
color = .syntax_plain;
} else if (if (language == .rust) rustCharLiteralLength(rest) else null) |literal_len| {
index += literal_len;
color = .syntax_literal;
} else if (stringQuote(language, rest[0]) or
(language == .html and
(state.html_in_tag or state.html_expression_depth > 0) and
(rest[0] == '"' or rest[0] == '\'' or rest[0] == '`')))
{
const quote = rest[0];
index += 1;
var closed = false;
while (index < source.len) {
if (source[index] == '\\' and
backslashEscapesQuote(language, state.*, quote) and
index + 1 < source.len)
{
index += 2;
continue;
}
const byte = source[index];
index += 1;
if (byte == quote) {
closed = true;
break;
}
}
if (!closed) state.string_quote = quote;
color = .syntax_literal;
} else if (std.ascii.isDigit(rest[0])) {
index += 1;
while (index < source.len) {
const byte = source[index];
if (!(std.ascii.isAlphanumeric(byte) or byte == '_' or byte == '.')) break;
index += 1;
}
color = .syntax_literal;
} else if (identifierStart(rest[0])) {
index += 1;
while (index < source.len and
(identifierContinue(source[index]) or
((language == .html or language == .css) and source[index] == '-')))
{
index += 1;
}
if (language == .html and state.html_in_tag) {
if (state.html_expect_tag_name) {
color = .syntax_literal;
state.html_expect_tag_name = false;
} else if (state.html_expression_depth == 0) {
color = .syntax_function;
} else {
color = wordColor(.typescript, source[start..index]) orelse
identifierStructuralColor(.typescript, source, index) orelse
.syntax_plain;
}
} else if (language == .html and state.html_expression_depth > 0) {
color = wordColor(.typescript, source[start..index]) orelse
identifierStructuralColor(.typescript, source, index) orelse
.syntax_plain;
} else {
color = wordColor(language, source[start..index]) orelse
identifierStructuralColor(language, source, index) orelse
.syntax_plain;
}
} else {
index += 1;
}
if (language == .html) updateHtmlPreviousSignificant(state, source[start..index]);
// The last span already covers the entire plain-syntax remainder once
// capacity fills, but keep scanning it so state handed to the next
// paragraph still reflects comments, strings, and JSX expressions.
if (!styling_full and !appendSpan(storage, &len, source, start, index, color)) {
styling_full = true;
}
}
return storage[0..len];
}
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -244,7 +244,9 @@ fn auditNodeTextOverflow(layout: WidgetLayoutTree, node_index: usize, tokens: De
}
fn auditSpanParagraphOverflow(widget: Widget, frame: geometry.RectF, node_index: usize, tokens: DesignTokens, sink: *FindingSink) void {
const content = frame.inset(widget.layout.padding).normalized();
var framed_widget = widget;
framed_widget.frame = frame;
const content = widget_metrics.widgetTextSpanContentFrame(framed_widget, tokens).normalized();
var runs: [text_spans_model.max_text_span_runs_per_paragraph]text_spans_model.TextSpanRun = undefined;
const span_layout = text_spans_model.layoutTextSpans(
widget.spans,
+13 -11
View File
@@ -10,7 +10,8 @@
//!
//! Supported blocks: `#`/`##`/`###` headings (deeper levels clamp to h3),
//! paragraphs, bullet/ordered/task lists (nesting up to
//! `max_markdown_list_depth` by two-space indent), fenced code blocks,
//! `max_markdown_list_depth` by two-space indent), fenced code blocks
//! (source indentation preserved and language-tagged fences highlighted),
//! `>` blockquotes, horizontal rules, GFM pipe tables (header row +
//! delimiter row + body rows onto `table`/`data_row`/`data_cell` widgets;
//! `:---`/`:--:`/`---:` delimiter cells set per-column start/center/end
@@ -57,6 +58,7 @@
//! that exceed a capacity truncate deterministically.
const std = @import("std");
const code_model = @import("code.zig");
const geometry = @import("geometry");
const canvas = @import("root.zig");
const text_spans = @import("text_spans.zig");
@@ -291,7 +293,8 @@ pub fn Markdown(comptime Msg: type) type {
}
fn parseCodeFence(self: *Builder, lines: *LineIterator) ?Node {
_ = lines.next(); // opening fence (info string ignored)
const opening = lines.next() orelse return null;
const language = code_model.languageFromFence(opening);
const start = lines.index;
var end = start;
while (lines.next()) |line| {
@@ -299,13 +302,7 @@ pub fn Markdown(comptime Msg: type) type {
end = lines.index;
}
const code = std.mem.trimEnd(u8, lines.source[start..@min(end, lines.source.len)], "\n");
const code_span = [_]TextSpan{.{ .text = code, .monospace = true }};
return self.ui.el(.panel, .{
.padding = 12,
.style_tokens = .{ .background = .surface_subtle },
}, .{
self.ui.paragraph(.{}, &code_span),
});
return self.ui.code(.{ .language = language }, code);
}
fn parseBlockquote(self: *Builder, lines: *LineIterator) ?Node {
@@ -365,9 +362,14 @@ pub fn Markdown(comptime Msg: type) type {
.semantics = .{ .label = marker.content },
}),
};
if (depth == 0) return self.ui.row(.{ .gap = 8 }, .{ lead, content });
// The outer row must keep stretch alignment so a wrapped
// paragraph receives the row's full measured height. A
// one-child column consumes that stretched marker slot
// while laying its marker at the slot's leading edge.
const lead_top = self.ui.column(.{}, .{lead});
if (depth == 0) return self.ui.row(.{ .gap = 8 }, .{ lead_top, content });
const indent = self.ui.el(.stack, .{ .width = @as(f32, @floatFromInt(depth)) * 16 }, .{});
return self.ui.row(.{ .gap = 8 }, .{ indent, lead, content });
return self.ui.row(.{ .gap = 8 }, .{ indent, lead_top, content });
}
fn parseDetails(self: *Builder, lines: *LineIterator) ?Node {
+166 -3
View File
@@ -48,6 +48,42 @@ fn countKind(widget: canvas.Widget, kind: canvas.WidgetKind) usize {
return count;
}
fn findKind(widget: canvas.Widget, kind: canvas.WidgetKind) ?canvas.Widget {
if (widget.kind == kind) return widget;
for (widget.children) |child| {
if (findKind(child, kind)) |found| return found;
}
return null;
}
fn appendParagraphText(widget: canvas.Widget, out: *std.ArrayListUnmanaged(u8), allocator: std.mem.Allocator) !void {
if (widget.kind == .text and widget.spans.len > 0) {
try out.appendSlice(allocator, widget.text);
return;
}
for (widget.children) |child| try appendParagraphText(child, out, allocator);
}
fn hasSpan(widget: canvas.Widget, text: []const u8, color: ?canvas.TextSpanColor) bool {
for (widget.spans) |span| {
if (std.mem.eql(u8, span.text, text) and span.color == color) return true;
}
for (widget.children) |child| {
if (hasSpan(child, text, color)) return true;
}
return false;
}
fn allSpansMonospace(widget: canvas.Widget) bool {
for (widget.spans) |span| {
if (!span.monospace) return false;
}
for (widget.children) |child| {
if (!allSpansMonospace(child)) return false;
}
return true;
}
fn findParagraphContaining(widget: canvas.Widget, fragment: []const u8) ?canvas.Widget {
if (widget.kind == .text and widget.spans.len > 0 and std.mem.indexOf(u8, widget.text, fragment) != null) return widget;
for (widget.children) |child| {
@@ -72,6 +108,18 @@ fn findKindLabel(widget: canvas.Widget, kind: canvas.WidgetKind, label: []const
return null;
}
fn findRowWithDirectParagraph(widget: canvas.Widget, fragment: []const u8) ?canvas.Widget {
if (widget.kind == .row) {
for (widget.children) |child| {
if (child.kind == .text and child.spans.len > 0 and std.mem.indexOf(u8, child.text, fragment) != null) return widget;
}
}
for (widget.children) |child| {
if (findRowWithDirectParagraph(child, fragment)) |found| return found;
}
return null;
}
test "markdown maps headings, paragraphs, and inline styles onto spans" {
var doc = TestDoc.init();
defer doc.deinit();
@@ -144,11 +192,104 @@ test "markdown maps lists, task lists, code fences, quotes, and rules" {
try testing.expect(findParagraphContaining(tree.root, "one") != null);
try testing.expect(findParagraphContaining(tree.root, "quoted wisdom") != null);
try testing.expectEqual(@as(usize, 2), countKind(tree.root, .separator));
// The row keeps stretch alignment for wrapped paragraph height, while
// a leading column pins the marker itself to the first-line edge.
const bullet_row = findRowWithDirectParagraph(tree.root, "first").?;
const ordered_row = findRowWithDirectParagraph(tree.root, "one").?;
try testing.expectEqual(canvas.WidgetCrossAlignment.stretch, bullet_row.layout.cross_alignment);
try testing.expectEqual(canvas.WidgetKind.column, bullet_row.children[0].kind);
try testing.expectEqualStrings("", bullet_row.children[0].children[0].text);
try testing.expectEqual(canvas.WidgetCrossAlignment.stretch, ordered_row.layout.cross_alignment);
try testing.expectEqual(canvas.WidgetKind.column, ordered_row.children[0].kind);
try testing.expectEqualStrings("1.", ordered_row.children[0].children[0].text);
// The fenced block is a panel wrapping a mono paragraph.
// The fenced block is a panel wrapping highlighted mono spans.
try testing.expectEqual(@as(usize, 1), countKind(tree.root, .panel));
const code = findParagraphContaining(tree.root, "const x = 1;").?;
try testing.expect(code.spans[0].monospace);
try testing.expectEqual(@as(?canvas.TextSpanColor, .syntax_keyword), code.spans[0].color);
}
test "language-tagged code fences highlight tokens and preserve indentation" {
var doc = TestDoc.init();
defer doc.deinit();
const tree = try doc.build(
\\```zig
\\pub fn main() void {
\\ const message = "hello";
\\ // keep this indentation
\\ return 42;
\\}
\\```
, .{});
const panel = findKind(tree.root, .panel).?;
var source_text: std.ArrayListUnmanaged(u8) = .empty;
defer source_text.deinit(testing.allocator);
try appendParagraphText(panel, &source_text, testing.allocator);
try testing.expectEqualStrings(
"pub fn main() void {\n const message = \"hello\";\n // keep this indentation\n return 42;\n}",
source_text.items,
);
try testing.expect(allSpansMonospace(panel));
try testing.expect(hasSpan(panel, "pub", .syntax_keyword));
try testing.expect(hasSpan(panel, "void", .syntax_literal));
try testing.expect(hasSpan(panel, "\"hello\"", .syntax_literal));
try testing.expect(hasSpan(panel, "// keep this indentation", .syntax_comment));
try testing.expect(hasSpan(panel, "42", .syntax_literal));
const indented = findParagraphContaining(panel, "const message").?;
var runs: [text_spans.max_text_span_runs_per_paragraph]text_spans.TextSpanRun = undefined;
const layout = text_spans.layoutTextSpans(indented.spans, .{ .size = 14, .max_width = 10_000 }, &runs);
var indented_keyword_x: ?f32 = null;
for (layout.runs) |run| {
if (run.line_index == 1 and std.mem.eql(u8, run.text, "const")) indented_keyword_x = run.x;
}
// Four source spaces occupy real horizontal advance before `const`.
try testing.expect(indented_keyword_x != null);
try testing.expectApproxEqAbs(@as(f32, 4 * 14 * 0.6), indented_keyword_x.?, 0.01);
}
test "unrecognized code-fence languages remain plain monospace" {
var doc = TestDoc.init();
defer doc.deinit();
const tree = try doc.build(
\\```made-up-language
\\alpha 42
\\```
, .{});
const code = findParagraphContaining(tree.root, "alpha 42").?;
try testing.expectEqual(@as(usize, 1), code.spans.len);
try testing.expect(code.spans[0].monospace);
try testing.expectEqual(@as(?canvas.TextSpanColor, .syntax_plain), code.spans[0].color);
}
test "per-line syntax highlighting does not drop fenced code" {
const code_source =
"const a0 = 0;\n" ++
"const a1 = 1;\n" ++
"const a2 = 2;\n" ++
"const a3 = 3;\n" ++
"const a4 = 4;\n" ++
"const a5 = 5;\n" ++
"const a6 = 6;\n" ++
"const a7 = 7;\n" ++
"const a8 = 8;\n" ++
"const a9 = 9;\n" ++
"const a10 = 10;\n" ++
"const a11 = 11;";
const source = "```zig\n" ++ code_source ++ "\n```";
var doc = TestDoc.init();
defer doc.deinit();
const tree = try doc.build(source, .{});
const panel = findKind(tree.root, .panel).?;
var rendered: std.ArrayListUnmanaged(u8) = .empty;
defer rendered.deinit(testing.allocator);
try appendParagraphText(panel, &rendered, testing.allocator);
try testing.expectEqualStrings(code_source, rendered.items);
try testing.expect(hasSpan(panel, "const", .syntax_keyword));
}
test "details blocks are caller-controlled collapsibles" {
@@ -391,6 +532,12 @@ test "the README-shaped fixture renders through the mapper and the reference ren
const surface = try canvas.ReferenceRenderSurface.init(width, height, pixels);
try surface.renderPass(frame.renderPass(), canvas.Color.rgb8(255, 255, 255));
// Deliberate golden updates can be reviewed as pixels before pinning
// the new signature.
if (markdownGoldenDumpRequested()) {
try dumpMarkdownGoldenPng("/tmp/markdown-shots/readme.png", width, height, pixels);
}
// Golden: byte-identical reference rendering of the README fixture.
try testing.expectEqual(markdown_document_reference_signature, support.referenceSurfaceSignature(pixels));
try support.expectVisiblePixel(surface.pixelRgba8(24, 32));
@@ -402,11 +549,27 @@ test "the README-shaped fixture renders through the mapper and the reference ren
// scales, wrapped bullets and em-dash spacing at the face's real
// advances, real sans and mono outlines (fixed-pitch runs sit in their
// 0.6 em cells), GFM tables as borderless cells on hairline row
// separators, fenced-code panels, and near-black underlined links.
// separators, fenced-code panels with preserved source indentation and
// language-token colors, and near-black underlined links.
// Update deliberately when markdown rendering changes, reviewing the
// rendered pixels first (see reference_tests.zig conventions).
const markdown_document_reference_signature: u64 = 4124710367581899536;
const markdown_document_reference_signature: u64 = 12721641780797724784;
fn markdownGoldenDumpRequested() bool {
if (comptime !@import("builtin").link_libc) return false;
return std.c.getenv("MARKDOWN_GOLDEN_DUMP") != null;
}
fn dumpMarkdownGoldenPng(path: []const u8, width: usize, height: usize, pixels: []const u8) !void {
const io = testing.io;
std.Io.Dir.cwd().createDirPath(io, std.fs.path.dirname(path) orelse ".") catch {};
var file = try std.Io.Dir.cwd().createFile(io, path, .{});
defer file.close(io);
var write_buffer: [4096]u8 = undefined;
var writer = file.writer(io, &write_buffer);
try canvas.png.writeRgba8(&writer.interface, width, height, pixels);
try writer.interface.flush();
}
test "bare URLs autolink at word boundaries with trailing punctuation trimmed" {
var doc = TestDoc.init();
+4 -2
View File
@@ -357,6 +357,7 @@ pub const ColorContrast = token_model.ColorContrast;
pub const ThemeOptions = token_model.ThemeOptions;
pub const ThemePack = token_model.ThemePack;
pub const ColorTokens = token_model.ColorTokens;
pub const colorTokenValue = token_model.colorTokenValue;
pub const FontFamily = token_model.FontFamily;
pub const TypographyTokens = token_model.TypographyTokens;
pub const SpacingTokens = token_model.SpacingTokens;
@@ -530,8 +531,9 @@ pub const max_chart_axis_ticks = chart.max_chart_axis_ticks;
pub const chartPointCount = chart.chartPointCount;
pub const chartHoverIndex = chart.chartHoverIndex;
// GitHub-flavored-markdown mapper (markdown source -> widget tree + span
// model) lives in `markdown.zig`; also exported as `native_sdk.markdown`.
// Shared source-code lexer/presentation model and GitHub-flavored-markdown
// mapper. Markdown fenced blocks lower through `Ui.code`.
pub const code = @import("code.zig");
pub const markdown = @import("markdown.zig");
// Deterministic key-lookup scratch shared by the per-frame planners and
+1
View File
@@ -25,6 +25,7 @@ test {
_ = @import("text_metrics_tests.zig");
_ = @import("text_batch_tests.zig");
_ = @import("text_span_tests.zig");
_ = @import("code_tests.zig");
_ = @import("markdown_tests.zig");
_ = @import("markdown_hostile_tests.zig");
_ = @import("layout_audit_tests.zig");
+4 -4
View File
@@ -72,11 +72,11 @@ pub const max_cached_advance_run_bytes: usize = 2048;
/// the peek/fetch split.
pub const advance_cache_capacity: usize = 256;
/// Oversize scratch: one slot covering runs up to the per-view text
/// budget (`canvas_limits.max_canvas_text_bytes_per_view`). A run this
/// long still gets ONE batched call per fetch; it just is not retained
/// Oversize scratch: one slot covering runs up to the canvas-widget source
/// budget (`canvas_limits.max_canvas_widget_text_bytes_per_view`). A run
/// this long still gets ONE batched call per fetch; it just is not retained
/// across other fetches.
pub const max_batched_advance_run_bytes: usize = 32768;
pub const max_batched_advance_run_bytes: usize = 65536;
/// Process-wide invalidation stamp for everything keyed on measured
/// text: cached advances here and retained wrap results downstream.
+254
View File
@@ -4,6 +4,7 @@ const canvas = @import("root.zig");
const text_spans = @import("text_spans.zig");
const text_metrics = @import("text_metrics.zig");
const ui_model = @import("ui.zig");
const widget_text_select = @import("widget_text_select.zig");
const testing = std.testing;
const TextSpan = text_spans.TextSpan;
@@ -77,6 +78,28 @@ test "mono spans measure and draw with the mono font id" {
try testing.expectApproxEqAbs(@as(f32, 9 * 14 * 0.6), result.runs[1].width, 0.01);
}
test "mono spans preserve source indentation after line breaks" {
const spans = [_]TextSpan{
.{ .text = "fn main() {\n ", .monospace = true },
.{ .text = "return", .monospace = true, .color = .info },
.{ .text = ";\n}", .monospace = true },
};
var runs: [text_spans.max_text_span_runs_per_paragraph]TextSpanRun = undefined;
const result = layout(&spans, .{ .size = 14, .max_width = 10_000 }, &runs);
var indent: ?TextSpanRun = null;
var keyword: ?TextSpanRun = null;
for (result.runs) |run| {
if (run.line_index == 1 and std.mem.eql(u8, run.text, " ")) indent = run;
if (run.line_index == 1 and std.mem.eql(u8, run.text, "return")) keyword = run;
}
try testing.expect(indent != null);
try testing.expect(keyword != null);
try testing.expectEqual(@as(f32, 0), indent.?.x);
try testing.expect(keyword.?.x > 0);
try testing.expectApproxEqAbs(indent.?.width, keyword.?.x, 0.01);
}
const FakeMeasure = struct {
calls: usize = 0,
mono_calls: usize = 0,
@@ -198,6 +221,112 @@ test "an oversized word cluster-wraps instead of overflowing" {
try testing.expectEqual(@as(usize, 20), total);
}
test "a later visual-line page retains an over-capacity wrapped paragraph" {
const spans = [_]TextSpan{.{ .text = "a" ** 140 }};
const options = text_spans.TextSpanLayoutOptions{ .size = 14, .max_width = 1 };
var first_runs: [text_spans.max_text_span_runs_per_paragraph]TextSpanRun = undefined;
const first = layout(&spans, options, &first_runs);
try testing.expectEqual(@as(usize, 140), first.line_count);
try testing.expectEqual(text_spans.max_text_span_lines_per_paragraph, first.runs.len);
try testing.expect(first.truncated);
var later_runs: [text_spans.max_text_span_runs_per_paragraph]TextSpanRun = undefined;
const later = text_spans.layoutTextSpansFromLine(
&spans,
options,
text_spans.max_text_span_lines_per_paragraph,
&later_runs,
);
try testing.expectEqual(first.line_count, later.line_count);
try testing.expectEqual(first.size.width, later.size.width);
try testing.expectEqual(first.size.height, later.size.height);
try testing.expectEqual(@as(usize, 12), later.runs.len);
try testing.expectEqual(@as(usize, 128), later.runs[0].line_index);
try testing.expectEqual(@as(usize, 139), later.runs[later.runs.len - 1].line_index);
try testing.expect(!later.truncated);
}
const VariableClusterMeasure = struct {
fn advance(text: []const u8) f32 {
return switch (text[0]) {
'a' => 3,
0xc3 => 10, // é
0xcc => 0, // combining acute: continuation of the é cluster
0xe7 => 20, // 界
'z' => 5,
'q' => 7,
else => 1,
};
}
fn measure(_: ?*anyopaque, _: canvas.FontId, _: f32, text: []const u8) f32 {
var width: f32 = 0;
var cursor: usize = 0;
while (cursor < text.len) {
width += advance(text[cursor..]);
const sequence_len = std.unicode.utf8ByteSequenceLength(text[cursor]) catch 1;
cursor += @min(sequence_len, text.len - cursor);
}
return width;
}
fn measureAdvances(
_: ?*anyopaque,
_: canvas.FontId,
_: f32,
text: []const u8,
advances: []f32,
) bool {
@memset(advances, 0);
var cursor: usize = 0;
while (cursor < text.len) {
advances[cursor] = advance(text[cursor..]);
const sequence_len = std.unicode.utf8ByteSequenceLength(text[cursor]) catch 1;
cursor += @min(sequence_len, text.len - cursor);
}
return true;
}
};
test "visible run slicing follows measured cluster advances" {
const source = "a\xc3\xa9\xcc\x81\xe7\x95\x8czq";
const spans = [_]TextSpan{.{ .text = source }};
const batched_provider = text_metrics.TextMeasureProvider{
.measure_fn = VariableClusterMeasure.measure,
.measure_advances_fn = VariableClusterMeasure.measureAdvances,
};
const unbatched_provider = text_metrics.TextMeasureProvider{
.measure_fn = VariableClusterMeasure.measure,
};
var options = text_spans.TextSpanLayoutOptions{
.size = 14,
.max_width = 10_000,
.measure = &batched_provider,
};
var runs: [text_spans.max_text_span_runs_per_paragraph]TextSpanRun = undefined;
const result = layout(&spans, options, &runs);
try testing.expectEqual(@as(usize, 1), result.runs.len);
// The viewport begins inside 界. Keep the preceding é+combining-mark
// cluster and the following z guard, but neither the leading a nor q.
for ([_]*const text_metrics.TextMeasureProvider{
&batched_provider,
&unbatched_provider,
}) |provider| {
options.measure = provider;
const visible = text_spans.textSpanRunVisibleSlice(
spans[0],
result.runs[0],
options,
14,
20,
).?;
try testing.expectEqualStrings("\xc3\xa9\xcc\x81\xe7\x95\x8cz", visible.text);
try testing.expectEqual(@as(f32, 3), visible.x);
}
}
test "center alignment shifts whole lines" {
const spans = [_]TextSpan{.{ .text = "hi" }};
var runs: [text_spans.max_text_span_runs_per_paragraph]TextSpanRun = undefined;
@@ -507,6 +636,131 @@ test "span selection maps points to paragraph offsets and back to rects" {
try testing.expectEqual(@as(usize, 0), text_spans.textSpanSelectionRects(paragraph, &spans, options, .{ .start = 3, .end = 3 }, &rects).len);
}
test "preformatted span hit mapping preserves unpainted source whitespace" {
const trailing = "value \t\n";
const trailing_spans = [_]TextSpan{.{
.text = trailing,
.monospace = true,
.color = .syntax_plain,
}};
const options = text_spans.TextSpanLayoutOptions{ .size = 14, .max_width = 200 };
try testing.expectEqual(
trailing.len,
text_spans.textSpanOffsetForPoint(
trailing,
&trailing_spans,
options,
geometry.PointF.init(500, 2),
).?,
);
const whitespace = " \n\t";
const whitespace_spans = [_]TextSpan{.{
.text = whitespace,
.monospace = true,
.color = .syntax_plain,
}};
try testing.expectEqual(
@as(usize, 0),
text_spans.textSpanOffsetForPoint(
whitespace,
&whitespace_spans,
options,
geometry.PointF.init(-1, 2),
).?,
);
try testing.expectEqual(
whitespace.len,
text_spans.textSpanOffsetForPoint(
whitespace,
&whitespace_spans,
options,
geometry.PointF.init(500, 500),
).?,
);
}
test "span hit mapping and selection page beyond the first 128 visual lines" {
const paragraph = "a" ** 140;
const spans = [_]TextSpan{.{ .text = paragraph, .monospace = true }};
const options = text_spans.TextSpanLayoutOptions{ .size = 14, .max_width = 1 };
var runs: [text_spans.max_text_span_runs_per_paragraph]TextSpanRun = undefined;
const layout_result = layout(&spans, options, &runs);
try testing.expectEqual(@as(usize, 140), layout_result.line_count);
const later_line: usize = 132;
const y = (@as(f32, @floatFromInt(later_line)) + 0.5) * layout_result.line_height;
try testing.expectEqual(
later_line,
text_spans.textSpanOffsetForPoint(
paragraph,
&spans,
options,
geometry.PointF.init(-1, y),
).?,
);
var rects: [16]canvas.TextSelectionRect = undefined;
const selection = text_spans.textSpanSelectionRects(
paragraph,
&spans,
options,
.{ .start = later_line, .end = paragraph.len },
&rects,
);
try testing.expectEqual(paragraph.len - later_line, selection.len);
try testing.expectEqual(later_line, selection[0].range.start);
try testing.expectEqual(
@as(f32, @floatFromInt(later_line)) * layout_result.line_height,
selection[0].rect.y,
);
try testing.expectEqual(paragraph.len, selection[selection.len - 1].range.end);
// The renderer's fixed rect budget folds every remaining line,
// including the second layout page, into its final highlight.
var bounded_rects: [widget_text_select.max_static_text_selection_rects]canvas.TextSelectionRect = undefined;
const bounded = text_spans.textSpanSelectionRects(
paragraph,
&spans,
options,
.{ .start = 0, .end = paragraph.len },
&bounded_rects,
);
try testing.expectEqual(bounded_rects.len, bounded.len);
try testing.expectEqual(@as(usize, 63), bounded[bounded.len - 1].range.start);
try testing.expectEqual(paragraph.len, bounded[bounded.len - 1].range.end);
try testing.expectApproxEqAbs(@as(f32, 0), bounded[bounded.len - 1].rect.x, 0.001);
try testing.expectApproxEqAbs(63 * layout_result.line_height, bounded[bounded.len - 1].rect.y, 0.001);
try testing.expectApproxEqAbs(
(paragraph.len - 63) * layout_result.line_height,
bounded[bounded.len - 1].rect.height,
0.001,
);
}
test "span selection crosses an empty interior visual-line page" {
const paragraph = "a\n" ++
("\n" ** (text_spans.max_text_span_lines_per_paragraph * 2)) ++
"b";
const spans = [_]TextSpan{.{ .text = paragraph, .monospace = true }};
const options = text_spans.TextSpanLayoutOptions{ .size = 14, .max_width = 100 };
var rects: [4]canvas.TextSelectionRect = undefined;
const selection = text_spans.textSpanSelectionRects(
paragraph,
&spans,
options,
.{ .start = 0, .end = paragraph.len },
&rects,
);
try testing.expectEqual(@as(usize, 2), selection.len);
try testing.expectEqual(@as(usize, 0), selection[0].range.start);
try testing.expectEqual(@as(usize, 1), selection[0].range.end);
try testing.expectEqual(paragraph.len - 1, selection[1].range.start);
try testing.expectEqual(paragraph.len, selection[1].range.end);
}
test "span selection degrades to unsupported when spans alias other storage" {
const paragraph = "Hello world";
// Spans that do NOT slice into `paragraph` (a stack copy: the bytes
+555 -31
View File
@@ -41,8 +41,11 @@ const TextAlign = @import("text_layout_types.zig").TextAlign;
/// `max_text_span_lines_per_paragraph` lines. Overflow truncates
/// deterministically instead of failing.
pub const max_text_spans_per_paragraph: usize = 32;
pub const max_text_span_runs_per_paragraph: usize = 128;
pub const max_text_span_lines_per_paragraph: usize = 64;
pub const max_text_span_lines_per_paragraph: usize = 128;
// A maximally split highlighted paragraph can add one run at every span
// boundary in addition to one run per visual line.
pub const max_text_span_runs_per_paragraph: usize =
max_text_span_lines_per_paragraph + max_text_spans_per_paragraph;
pub const TextSpanWeight = enum {
regular,
@@ -143,9 +146,7 @@ pub fn textSpanFontId(span: TextSpan, typography: token_model.TypographyTokens)
}
pub fn textSpanColorValue(colors: token_model.ColorTokens, ref: TextSpanColor) Color {
return switch (ref) {
inline else => |tag| @field(colors, @tagName(tag)),
};
return token_model.colorTokenValue(colors, ref);
}
pub fn textSpanScale(span: TextSpan) f32 {
@@ -170,24 +171,34 @@ pub fn textSpanLineHeight(spans: []const TextSpan, options: TextSpanLayoutOption
return options.size * textSpansMaxScale(spans) * 1.25;
}
/// Single-line (unwrapped) advance of the whole paragraph: the intrinsic
/// width seam for widget sizing. Measures per-span with the span's font.
/// Widest logical-line advance of an unwrapped paragraph: the intrinsic
/// width seam for widget sizing. Measures per-span with the span's font
/// while carrying each line across span boundaries.
pub fn textSpansIntrinsicWidth(spans: []const TextSpan, options: TextSpanLayoutOptions) f32 {
var width: f32 = 0;
var max_width: f32 = 0;
for (spans, 0..) |span, index| {
if (index >= max_text_spans_per_paragraph) break;
var start: usize = 0;
var cursor: usize = 0;
while (cursor < span.text.len) {
if (span.text[cursor] == '\n') {
if (span.text[cursor] == '\r') {
// CR is presentation-free. In CRLF source the following
// LF owns the hard break; a bare CR remains selectable and
// copyable without painting a fallback control glyph.
width += measureSpanSlice(span, span.text[start..cursor], options);
start = cursor + 1;
} else if (span.text[cursor] == '\n') {
width += measureSpanSlice(span, span.text[start..cursor], options);
max_width = @max(max_width, width);
width = 0;
start = cursor + 1;
}
cursor += 1;
}
width += measureSpanSlice(span, span.text[start..], options);
}
return width;
return @max(max_width, width);
}
/// Wrapped paragraph height at `max_width`: the vertical-extent seam the
@@ -199,6 +210,22 @@ pub fn textSpansWrappedHeight(spans: []const TextSpan, options: TextSpanLayoutOp
}
fn measureSpanSlice(span: TextSpan, slice: []const u8, options: TextSpanLayoutOptions) f32 {
if (slice.len == 0) return 0;
const first_cr = std.mem.indexOfScalar(u8, slice, '\r') orelse
return measureSpanSliceExact(span, slice, options);
var width: f32 = 0;
var start: usize = 0;
var cr: ?usize = first_cr;
while (cr) |index| {
width += measureSpanSliceExact(span, slice[start..index], options);
start = index + 1;
cr = std.mem.indexOfScalarPos(u8, slice, start, '\r');
}
width += measureSpanSliceExact(span, slice[start..], options);
return width;
}
fn measureSpanSliceExact(span: TextSpan, slice: []const u8, options: TextSpanLayoutOptions) f32 {
if (slice.len == 0) return 0;
const font_id = textSpanFontId(span, options.typography);
const size = textSpanSize(span, options.size);
@@ -236,10 +263,211 @@ fn spanSliceAdvances(span: TextSpan, slice: []const u8, options: TextSpanLayoutO
return advances[offset..][0..slice.len];
}
pub const TextSpanRunVisibleSlice = struct {
text: []const u8,
/// Horizontal offset from the original run origin.
x: f32 = 0,
};
fn textSpanRunPrefixWidth(
span: TextSpan,
run: TextSpanRun,
options: TextSpanLayoutOptions,
end: usize,
) f32 {
return measureSpanSlice(span, run.text[0..@min(end, run.text.len)], options);
}
/// First scalar boundary whose measured prefix reaches `target_x`.
/// The logarithmic search keeps long runs outside the batched-advance
/// scratch bound from turning viewport cropping into a quadratic prefix
/// walk.
fn textSpanRunBoundaryForX(
span: TextSpan,
run: TextSpanRun,
options: TextSpanLayoutOptions,
target_x: f32,
) usize {
if (target_x <= 0) return 0;
if (target_x >= run.width) return run.text.len;
var low: usize = 0;
var high = run.text.len;
while (text_interaction.nextTextOffset(run.text, low) < high) {
var middle = text_interaction.snapTextOffset(
run.text,
low + (high - low) / 2,
);
if (middle <= low) middle = text_interaction.nextTextOffset(run.text, low);
if (middle >= high) middle = text_interaction.previousTextOffset(run.text, high);
if (middle <= low or middle >= high) break;
if (textSpanRunPrefixWidth(span, run, options, middle) < target_x) {
low = middle;
} else {
high = middle;
}
}
return high;
}
fn textSpanRunScalarAdvance(
span: TextSpan,
run: TextSpanRun,
options: TextSpanLayoutOptions,
start: usize,
) f32 {
const end = text_interaction.nextTextOffset(run.text, start);
return @max(
0,
textSpanRunPrefixWidth(span, run, options, end) -
textSpanRunPrefixWidth(span, run, options, start),
);
}
fn textSpanRunPreviousClusterStart(
span: TextSpan,
run: TextSpanRun,
options: TextSpanLayoutOptions,
start: usize,
) usize {
var cursor = start;
while (cursor > 0) {
const previous = text_interaction.previousTextOffset(run.text, cursor);
if (textSpanRunScalarAdvance(span, run, options, previous) > 0) return previous;
cursor = previous;
}
return start;
}
fn textSpanRunGuardEnd(
span: TextSpan,
run: TextSpanRun,
options: TextSpanLayoutOptions,
start: usize,
) usize {
var cursor = start;
var found_guard = false;
while (cursor < run.text.len) {
if (textSpanRunScalarAdvance(span, run, options, cursor) > 0) {
if (found_guard) return cursor;
found_guard = true;
}
cursor = text_interaction.nextTextOffset(run.text, cursor);
}
return run.text.len;
}
fn unbatchedTextSpanRunVisibleSlice(
span: TextSpan,
run: TextSpanRun,
options: TextSpanLayoutOptions,
min_x: f32,
max_x: f32,
) ?TextSpanRunVisibleSlice {
const first_boundary = textSpanRunBoundaryForX(span, run, options, @max(0, min_x));
if (first_boundary == run.text.len and min_x >= run.width) return null;
const visible_start = text_interaction.previousTextOffset(run.text, first_boundary);
const first = textSpanRunPreviousClusterStart(span, run, options, visible_start);
const last_boundary = textSpanRunBoundaryForX(span, run, options, max_x);
const last = textSpanRunGuardEnd(span, run, options, last_boundary);
if (first >= last) return null;
return .{
.text = run.text[first..last],
.x = textSpanRunPrefixWidth(span, run, options, first),
};
}
/// Return the measured portion of `run` needed to cover the horizontal
/// interval `[min_x, max_x]`, plus one shaped cluster of guard ink on each
/// side. Batched providers supply their exact per-cluster advances; the
/// unbatched seam uses a logarithmic contextual-prefix search. A provider
/// may represent a multi-codepoint cluster by placing its advance on the
/// first scalar and zero on the rest, so cut points are chosen only at the
/// next positive-advance cluster start.
pub fn textSpanRunVisibleSlice(
span: TextSpan,
run: TextSpanRun,
options: TextSpanLayoutOptions,
min_x: f32,
max_x: f32,
) ?TextSpanRunVisibleSlice {
if (run.text.len == 0) return null;
if (!std.math.isFinite(min_x) or
!std.math.isFinite(max_x) or
min_x <= 0 and max_x >= run.width)
{
return .{ .text = run.text };
}
const measured_advances = spanSliceAdvances(span, run.text, options, run.font_id, run.size);
if (measured_advances == null) {
return unbatchedTextSpanRunVisibleSlice(span, run, options, min_x, max_x);
}
var cursor: usize = 0;
var x: f32 = 0;
var previous_cluster_start: ?usize = null;
var previous_cluster_x: f32 = 0;
var first: usize = 0;
var first_x: f32 = 0;
var found_first = false;
var right_guard_seen = false;
while (cursor < run.text.len) {
const next = text_interaction.nextTextOffset(run.text, cursor);
var advance: f32 = 0;
for (measured_advances.?[cursor..next]) |value| advance += value;
advance = @max(0, advance);
if (std.math.isFinite(advance) and advance > 0) {
if (!found_first and x + advance > @max(0, min_x)) {
if (previous_cluster_start) |start| {
first = start;
first_x = previous_cluster_x;
} else {
first = cursor;
first_x = x;
}
found_first = true;
}
if (found_first and x >= max_x) {
if (right_guard_seen) {
if (first >= cursor) return null;
return .{
.text = run.text[first..cursor],
.x = first_x,
};
}
right_guard_seen = true;
}
previous_cluster_start = cursor;
previous_cluster_x = x;
}
x += advance;
cursor = next;
}
if (!found_first) {
// A zero-width run cannot be cropped meaningfully; retain it so a
// platform shaper still receives the original cluster sequence.
if (x <= 0) return .{ .text = run.text };
return null;
}
return .{
.text = run.text[first..],
.x = first_x,
};
}
const LayoutState = struct {
spans: []const TextSpan,
options: TextSpanLayoutOptions,
runs: []TextSpanRun,
/// First absolute visual line retained in `runs`. Earlier lines are
/// still measured so the returned extent and absolute baselines stay
/// identical to a full paragraph layout.
run_line_start: usize = 0,
run_len: usize = 0,
line_index: usize = 0,
line_run_start: usize = 0,
@@ -291,6 +519,7 @@ const LayoutState = struct {
self.max_line_width = @max(self.max_line_width, self.pen_x);
self.line_has_content = true;
}
if (self.line_index < self.run_line_start) return;
if (self.run_len > self.line_run_start) {
const previous = &self.runs[self.run_len - 1];
if (previous.span_index == span_index and
@@ -302,7 +531,9 @@ const LayoutState = struct {
return;
}
}
if (self.run_len >= self.runs.len or self.line_index >= max_text_span_lines_per_paragraph) {
if (self.run_len >= self.runs.len or
self.line_index >= self.run_line_start +| max_text_span_lines_per_paragraph)
{
self.truncated = true;
return;
}
@@ -359,18 +590,38 @@ pub fn layoutTextSpans(spans: []const TextSpan, options: TextSpanLayoutOptions,
if (findSpanWrapEntry(cache, key)) |entry_index| {
if (rebaseSpanWrapEntry(cache, entry_index, spans, options, runs_storage)) |layout| return layout;
}
const layout = layoutTextSpansUncached(spans, options, runs_storage);
const layout = layoutTextSpansUncached(spans, options, 0, runs_storage);
storeSpanWrapEntry(cache, key, spans, layout);
return layout;
}
return layoutTextSpansUncached(spans, options, runs_storage);
return layoutTextSpansUncached(spans, options, 0, runs_storage);
}
fn layoutTextSpansUncached(spans: []const TextSpan, options: TextSpanLayoutOptions, runs_storage: []TextSpanRun) TextSpanLayout {
/// Lay out the full paragraph while retaining only the bounded visual-line
/// page beginning at `first_line`. Runs keep absolute line indexes and
/// baselines, so a viewport can paint later pages without changing the
/// paragraph's measured size or allocating source-sized storage.
pub fn layoutTextSpansFromLine(
spans: []const TextSpan,
options: TextSpanLayoutOptions,
first_line: usize,
runs_storage: []TextSpanRun,
) TextSpanLayout {
if (first_line == 0) return layoutTextSpans(spans, options, runs_storage);
return layoutTextSpansUncached(spans, options, first_line, runs_storage);
}
fn layoutTextSpansUncached(
spans: []const TextSpan,
options: TextSpanLayoutOptions,
first_line: usize,
runs_storage: []TextSpanRun,
) TextSpanLayout {
var state = LayoutState{
.spans = spans,
.options = options,
.runs = runs_storage,
.run_line_start = first_line,
.line_height = textSpanLineHeight(spans, options),
.baseline_offset = options.size * textSpansMaxScale(spans),
.max_width = if (options.wrap != .none and options.max_width > 0 and std.math.isFinite(options.max_width))
@@ -396,11 +647,21 @@ fn layoutTextSpansUncached(spans: []const TextSpan, options: TextSpanLayoutOptio
offset += 1;
continue;
}
if (byte == '\r') {
// Preserve the byte in paragraph offsets/clipboard data but do
// not place it in a glyph run. LF remains the single hard-line
// delimiter for both Unix and Windows source.
offset += 1;
continue;
}
if (isSpanBreakByte(byte)) {
const end = spanWhitespaceEnd(text, offset);
// Whitespace at a fresh line start is consumed by the wrap;
// mid-line whitespace is held back until the next word lands.
if (state.line_has_content) {
// Prose consumes whitespace at a fresh line start, but a
// monospace span is preformatted content (markdown fences are
// assembled from these) and must keep its source indentation.
// In both cases whitespace is held until the next word lands,
// so trailing spaces still never widen a rendered line.
if (state.line_has_content or spans[span_index].monospace) {
const slice = text[offset..end];
state.recordPendingWhitespace(span_index, slice, measureSpanSlice(spans[span_index], slice, options));
}
@@ -793,7 +1054,7 @@ fn spanWhitespaceEnd(text: []const u8, start: usize) usize {
fn spanWordEnd(text: []const u8, start: usize) usize {
var end = start;
while (end < text.len and text[end] != '\n' and !isSpanBreakByte(text[end])) end += 1;
while (end < text.len and text[end] != '\n' and text[end] != '\r' and !isSpanBreakByte(text[end])) end += 1;
return end;
}
@@ -846,8 +1107,8 @@ pub fn textSpanOffsetForPoint(
point: geometry.PointF,
) ?usize {
var runs: [max_text_span_runs_per_paragraph]TextSpanRun = undefined;
const layout = layoutTextSpans(spans, options, &runs);
if (layout.runs.len == 0 or layout.line_count == 0 or layout.line_height <= 0) return null;
var layout = layoutTextSpans(spans, options, &runs);
if (layout.line_count == 0 or layout.line_height <= 0) return null;
const raw_line = point.y / layout.line_height;
const max_line = layout.line_count - 1;
@@ -856,6 +1117,50 @@ pub fn textSpanOffsetForPoint(
else
@min(max_line, @as(usize, @intFromFloat(@floor(raw_line))));
// Page the same bounded paragraph layout the viewport painter uses.
// Page zero deliberately retains only 128 visual lines; without this
// handoff every point below that page collapsed onto its last source
// offset even though later runs were visibly painted.
if (line_index >= max_text_span_lines_per_paragraph) {
layout = layoutTextSpansFromLine(spans, options, line_index, &runs);
}
if (layout.runs.len == 0) {
// Preformatted whitespace is valid selectable source even though
// the layout deliberately keeps it out of painted runs. Preserve
// both edges so a drag can select/copy an all-whitespace block.
if (paragraphOnlyUnpaintedWhitespace(paragraph)) {
const source_line_count = 1 + std.mem.count(u8, paragraph, "\n") -
@intFromBool(paragraph[paragraph.len - 1] == '\n');
const source_line: usize = if (raw_line < 0)
0
else
@min(source_line_count - 1, @as(usize, @intFromFloat(@floor(raw_line))));
const edge = unpaintedWhitespaceLineRange(paragraph, source_line);
const width = textSpanParagraphRangeWidth(
paragraph,
spans,
options,
text_interaction.TextRange.init(
edge.start,
if (edge.end > edge.start and paragraph[edge.end - 1] == '\n')
edge.end - 1
else
edge.end,
),
) orelse 0;
const midpoint = if (width > 0)
width * 0.5
else if (options.max_width > 0 and std.math.isFinite(options.max_width))
options.max_width * 0.5
else
0;
return if (point.x < midpoint) edge.start else edge.end;
}
// A later page made only of trailing blank logical lines has no
// glyph run to map. Its nearest source edge is the paragraph end.
return if (paragraph.len > 0) paragraph.len else null;
}
var result: ?usize = null;
var first_range: ?text_interaction.TextRange = null;
var last_range: ?text_interaction.TextRange = null;
@@ -878,11 +1183,73 @@ pub fn textSpanOffsetForPoint(
const first = first_range orelse return lineFallbackOffset(paragraph, layout, line_index);
if (point.x < first_x) return first.start;
if (last_range) |last| {
if (point.x >= last_end_x) return last.end;
if (point.x >= last_end_x) return textSpanLineSourceEnd(paragraph, last.end);
}
return first.start;
}
fn paragraphOnlyUnpaintedWhitespace(paragraph: []const u8) bool {
if (paragraph.len == 0) return false;
for (paragraph) |byte| {
if (byte != '\n' and byte != '\r' and !isSpanBreakByte(byte)) return false;
}
return true;
}
fn unpaintedWhitespaceLineRange(paragraph: []const u8, line_index: usize) text_interaction.TextRange {
var start: usize = 0;
var line: usize = 0;
while (line < line_index) : (line += 1) {
const newline = std.mem.indexOfScalarPos(u8, paragraph, start, '\n') orelse
return text_interaction.TextRange.init(paragraph.len, paragraph.len);
start = newline + 1;
}
const newline = std.mem.indexOfScalarPos(u8, paragraph, start, '\n');
return text_interaction.TextRange.init(start, if (newline) |index| index + 1 else paragraph.len);
}
fn textSpanParagraphRangeWidth(
paragraph: []const u8,
spans: []const TextSpan,
options: TextSpanLayoutOptions,
range: text_interaction.TextRange,
) ?f32 {
if (range.start >= range.end) return 0;
const paragraph_base = @intFromPtr(paragraph.ptr);
var covered = range.start;
var width: f32 = 0;
for (spans) |span| {
const span_start_ptr = @intFromPtr(span.text.ptr);
if (span_start_ptr < paragraph_base) return null;
const span_start = span_start_ptr - paragraph_base;
const span_end = span_start + span.text.len;
if (span_end > paragraph.len) return null;
const start = @max(range.start, span_start);
const end = @min(range.end, span_end);
if (start >= end) continue;
if (start > covered) return null;
width += measureSpanSlice(
span,
span.text[start - span_start .. end - span_start],
options,
);
covered = @max(covered, end);
if (covered >= range.end) return width;
}
return null;
}
/// Extend a painted run edge through source bytes that deliberately carry
/// no ink at the end of its visual line: spaces/tabs and one explicit
/// newline. A wrapped word begins the next line without being consumed.
fn textSpanLineSourceEnd(paragraph: []const u8, painted_end: usize) usize {
var end = @min(painted_end, paragraph.len);
while (end < paragraph.len and isSpanBreakByte(paragraph[end])) end += 1;
if (end < paragraph.len and paragraph[end] == '\r') end += 1;
if (end < paragraph.len and paragraph[end] == '\n') end += 1;
return end;
}
/// Offset within `run.text` for a run-relative x, midpoint rule per
/// codepoint, mirroring the plain-text `textLineOffsetForX`.
fn spanRunOffsetForX(span: TextSpan, run: TextSpanRun, options: TextSpanLayoutOptions, x: f32) usize {
@@ -918,8 +1285,9 @@ fn lineFallbackOffset(paragraph: []const u8, layout: TextSpanLayout, line_index:
/// Selection highlight rects (relative to the paragraph origin) for a
/// paragraph byte range: one rect per line, spanning the selected extent
/// across that line's runs. Returns the rects that fit in `output`;
/// overflow truncates deterministically like span layout itself.
/// across that line's runs. A range spanning more lines than `output`
/// holds folds the overflow into the last rectangle, matching plain text
/// selection so long selections stay truthfully highlighted.
pub fn textSpanSelectionRects(
paragraph: []const u8,
spans: []const TextSpan,
@@ -929,18 +1297,169 @@ pub fn textSpanSelectionRects(
) []const text_interaction.TextSelectionRect {
const normalized = text_interaction.snapTextRange(paragraph, range);
if (normalized.isCollapsed(paragraph.len)) return output[0..0];
if (output.len == 0) return output[0..0];
var runs: [max_text_span_runs_per_paragraph]TextSpanRun = undefined;
const layout = layoutTextSpans(spans, options, &runs);
if (layout.line_height <= 0) return output[0..0];
const first_layout = layoutTextSpans(spans, options, &runs);
if (first_layout.line_height <= 0 or first_layout.line_count == 0) return output[0..0];
const page_count = (first_layout.line_count +
max_text_span_lines_per_paragraph - 1) /
max_text_span_lines_per_paragraph;
const first_page = textSpanSelectionFirstPage(
paragraph,
spans,
options,
normalized.start,
page_count,
&runs,
) orelse return output[0..0];
var len: usize = 0;
var page_index = first_page;
while (page_index < page_count) : (page_index += 1) {
const first_line = page_index * max_text_span_lines_per_paragraph;
const layout = if (page_index == 0)
first_layout
else
layoutTextSpansFromLine(spans, options, first_line, &runs);
const page_end = appendTextSpanSelectionPage(
paragraph,
spans,
options,
normalized,
layout,
output,
&len,
);
if (page_end >= normalized.end) break;
}
return output[0..len];
}
/// Earliest visual-line page whose retained source runs can intersect a
/// selection beginning at `offset`. Non-empty pages stay logarithmic; when
/// explicit newlines create an empty page, inspect the surrounding gap
/// because emptiness alone does not order that page against the offset.
fn textSpanSelectionFirstPage(
paragraph: []const u8,
spans: []const TextSpan,
options: TextSpanLayoutOptions,
offset: usize,
page_count: usize,
runs: []TextSpanRun,
) ?usize {
var low: usize = 0;
var high = page_count;
var candidate: ?usize = null;
while (low < high) {
const middle = low + (high - low) / 2;
const page_end = textSpanSelectionPageEnd(
paragraph,
spans,
options,
middle,
runs,
);
if (page_end) |end| {
if (end <= offset) {
low = middle + 1;
} else {
candidate = middle;
high = middle;
}
continue;
}
// A runless page may sit between source before and after `offset`.
// Find its nearest retained neighbor on each side before deciding
// which half can be discarded.
var left = middle;
var left_end: ?usize = null;
while (left > low) {
left -= 1;
left_end = textSpanSelectionPageEnd(
paragraph,
spans,
options,
left,
runs,
);
if (left_end != null) break;
}
if (left_end) |end| {
if (end > offset) {
candidate = left;
high = left;
continue;
}
}
var right = middle + 1;
var right_end: ?usize = null;
while (right < high) : (right += 1) {
right_end = textSpanSelectionPageEnd(
paragraph,
spans,
options,
right,
runs,
);
if (right_end != null) break;
}
if (right_end) |end| {
if (end > offset) return right;
low = right + 1;
continue;
}
// No retained run in this interval precedes the best page already
// found by the binary search.
return candidate;
}
return candidate;
}
fn textSpanSelectionPageEnd(
paragraph: []const u8,
spans: []const TextSpan,
options: TextSpanLayoutOptions,
page_index: usize,
runs: []TextSpanRun,
) ?usize {
const layout = layoutTextSpansFromLine(
spans,
options,
page_index * max_text_span_lines_per_paragraph,
runs,
);
var page_end: ?usize = null;
for (layout.runs) |run| {
const run_range = textSpanRunParagraphRange(paragraph, run) orelse continue;
page_end = @max(page_end orelse 0, run_range.end);
}
return page_end;
}
/// Append selection geometry from one absolute visual-line page. Returns
/// the furthest paragraph byte represented by that page so the caller can
/// stop once the selected tail has been covered.
fn appendTextSpanSelectionPage(
paragraph: []const u8,
spans: []const TextSpan,
options: TextSpanLayoutOptions,
normalized: text_interaction.TextRange,
layout: TextSpanLayout,
output: []text_interaction.TextSelectionRect,
len: *usize,
) usize {
var page_end: usize = 0;
var current_line: ?usize = null;
var line_left: f32 = 0;
var line_right: f32 = 0;
var line_range = text_interaction.TextRange.init(0, 0);
for (layout.runs) |run| {
const run_range = textSpanRunParagraphRange(paragraph, run) orelse continue;
page_end = @max(page_end, run_range.end);
const start = @max(normalized.start, run_range.start);
const end = @min(normalized.end, run_range.end);
if (start >= end) continue;
@@ -955,7 +1474,7 @@ pub fn textSpanSelectionRects(
line_range = text_interaction.TextRange.init(@min(line_range.start, start), @max(line_range.end, end));
continue;
}
if (!flushSpanSelectionLine(layout, line, line_left, line_right, line_range, output, &len)) return output[0..len];
flushSpanSelectionLine(layout, line, line_left, line_right, line_range, output, len);
}
current_line = run.line_index;
line_left = @min(x0, x1);
@@ -963,9 +1482,9 @@ pub fn textSpanSelectionRects(
line_range = text_interaction.TextRange.init(start, end);
}
if (current_line) |line| {
if (!flushSpanSelectionLine(layout, line, line_left, line_right, line_range, output, &len)) return output[0..len];
flushSpanSelectionLine(layout, line, line_left, line_right, line_range, output, len);
}
return output[0..len];
return page_end;
}
fn flushSpanSelectionLine(
@@ -976,15 +1495,20 @@ fn flushSpanSelectionLine(
range: text_interaction.TextRange,
output: []text_interaction.TextSelectionRect,
len: *usize,
) bool {
if (len.* >= output.len) return false;
) void {
const top = @as(f32, @floatFromInt(line_index)) * layout.line_height;
output[len.*] = .{
const selection = text_interaction.TextSelectionRect{
.range = range,
.rect = geometry.RectF.init(left, top, @max(1, right - left), @max(1, layout.line_height)),
};
if (len.* >= output.len) {
const last = &output[output.len - 1];
last.range = text_interaction.TextRange.init(last.range.start, range.end);
last.rect = last.rect.unionWith(selection.rect);
return;
}
output[len.*] = selection;
len.* += 1;
return true;
}
/// Deep equality for widget invalidation: styles, text bytes, and link
+29
View File
@@ -153,6 +153,14 @@ fn light() ColorTokens {
.text = Color.rgb8(23, 23, 23),
// Secondary ink: gray-900 #4d4d4d.
.text_muted = Color.rgb8(77, 77, 77),
// Geist Code Block syntax inks (the published 900 steps).
.syntax_plain = Color.rgb8(23, 23, 23),
.syntax_comment = Color.rgb8(77, 77, 77),
.syntax_keyword = Color.rgb8(189, 40, 100),
.syntax_literal = Color.rgb8(41, 122, 58),
.syntax_function = Color.rgb8(120, 32, 188),
.syntax_property = Color.rgb8(203, 42, 47),
.syntax_constant = Color.rgb8(0, 104, 214),
// Hairline: black at 8% — the translucent border register.
.border = Color.rgba8(0, 0, 0, 20),
// The monochrome primary FILL: pure black #000000 filled
@@ -205,6 +213,13 @@ fn dark() ColorTokens {
.text = Color.rgb8(237, 237, 237),
// Secondary ink: gray-900 #a0a0a0.
.text_muted = Color.rgb8(160, 160, 160),
.syntax_plain = Color.rgb8(237, 237, 237),
.syntax_comment = Color.rgb8(161, 161, 161),
.syntax_keyword = Color.rgb8(247, 95, 143),
.syntax_literal = Color.rgb8(98, 192, 115),
.syntax_function = Color.rgb8(191, 122, 240),
.syntax_property = Color.rgb8(255, 97, 102),
.syntax_constant = Color.rgb8(82, 168, 255),
// Hairline: white at 14% — hairlines brighten what they overlap.
.border = Color.rgba8(255, 255, 255, 36),
// Porcelain primary with black knockout text.
@@ -247,6 +262,13 @@ fn highContrastLight() ColorTokens {
.text = Color.rgb8(0, 0, 0),
// Secondary ink darkens to gray-1000.
.text_muted = Color.rgb8(23, 23, 23),
.syntax_plain = Color.rgb8(23, 23, 23),
.syntax_comment = Color.rgb8(77, 77, 77),
.syntax_keyword = Color.rgb8(189, 40, 100),
.syntax_literal = Color.rgb8(41, 122, 58),
.syntax_function = Color.rgb8(120, 32, 188),
.syntax_property = Color.rgb8(203, 42, 47),
.syntax_constant = Color.rgb8(0, 104, 214),
.border = Color.rgba8(0, 0, 0, 180),
.accent = Color.rgb8(0, 0, 0),
.accent_text = Color.rgb8(255, 255, 255),
@@ -281,6 +303,13 @@ fn highContrastDark() ColorTokens {
.surface_pressed = Color.rgb8(41, 41, 41),
.text = Color.rgb8(255, 255, 255),
.text_muted = Color.rgb8(237, 237, 237),
.syntax_plain = Color.rgb8(237, 237, 237),
.syntax_comment = Color.rgb8(161, 161, 161),
.syntax_keyword = Color.rgb8(247, 95, 143),
.syntax_literal = Color.rgb8(98, 192, 115),
.syntax_function = Color.rgb8(191, 122, 240),
.syntax_property = Color.rgb8(255, 97, 102),
.syntax_constant = Color.rgb8(82, 168, 255),
.border = Color.rgba8(255, 255, 255, 190),
.accent = Color.rgb8(255, 255, 255),
.accent_text = Color.rgb8(0, 0, 0),
+72 -2
View File
@@ -132,6 +132,20 @@ pub const ColorTokens = struct {
text: Color = Color.rgb8(10, 10, 10),
/// Muted foreground; oklch(0.556 0 0) = #737373.
text_muted: Color = Color.rgb8(115, 115, 115),
/// Source-code ink follows the Geist Code Block palette in every
/// built-in pack. Dedicated roles keep syntax highlighting independent
/// from the app's semantic success/warning/info colors. Transparent is
/// the compatibility sentinel for an older custom palette that omitted
/// these newer fields: syntax resolution inherits `text` (or
/// `text_muted` for comments), so a dark palette never silently picks up
/// the light register's near-black ink.
syntax_plain: Color = Color.rgba8(0, 0, 0, 0),
syntax_comment: Color = Color.rgba8(0, 0, 0, 0),
syntax_keyword: Color = Color.rgba8(0, 0, 0, 0),
syntax_literal: Color = Color.rgba8(0, 0, 0, 0),
syntax_function: Color = Color.rgba8(0, 0, 0, 0),
syntax_property: Color = Color.rgba8(0, 0, 0, 0),
syntax_constant: Color = Color.rgba8(0, 0, 0, 0),
/// Border/input hairline; oklch(0.922 0 0) = #e5e5e5.
border: Color = Color.rgb8(229, 229, 229),
/// Primary; oklch(0.205 0 0) = #171717 — the monochrome near-black
@@ -183,7 +197,15 @@ pub const ColorTokens = struct {
}
pub fn light() ColorTokens {
return .{};
return .{
.syntax_plain = Color.rgb8(23, 23, 23),
.syntax_comment = Color.rgb8(77, 77, 77),
.syntax_keyword = Color.rgb8(189, 40, 100),
.syntax_literal = Color.rgb8(41, 122, 58),
.syntax_function = Color.rgb8(120, 32, 188),
.syntax_property = Color.rgb8(203, 42, 47),
.syntax_constant = Color.rgb8(0, 104, 214),
};
}
pub fn dark() ColorTokens {
@@ -203,6 +225,13 @@ pub const ColorTokens = struct {
.text = Color.rgb8(250, 250, 250),
// Muted foreground; oklch(0.708 0 0) = #a1a1a1.
.text_muted = Color.rgb8(161, 161, 161),
.syntax_plain = Color.rgb8(237, 237, 237),
.syntax_comment = Color.rgb8(161, 161, 161),
.syntax_keyword = Color.rgb8(247, 95, 143),
.syntax_literal = Color.rgb8(98, 192, 115),
.syntax_function = Color.rgb8(191, 122, 240),
.syntax_property = Color.rgb8(255, 97, 102),
.syntax_constant = Color.rgb8(82, 168, 255),
// Dark borders are translucent white (10%), not a gray fill:
// hairlines brighten what they overlap instead of muddying it.
.border = Color.rgba8(255, 255, 255, 26),
@@ -241,6 +270,13 @@ pub const ColorTokens = struct {
.surface_pressed = Color.rgb8(229, 229, 229),
.text = Color.rgb8(0, 0, 0),
.text_muted = Color.rgb8(64, 64, 64),
.syntax_plain = Color.rgb8(23, 23, 23),
.syntax_comment = Color.rgb8(77, 77, 77),
.syntax_keyword = Color.rgb8(189, 40, 100),
.syntax_literal = Color.rgb8(41, 122, 58),
.syntax_function = Color.rgb8(120, 32, 188),
.syntax_property = Color.rgb8(203, 42, 47),
.syntax_constant = Color.rgb8(0, 104, 214),
.border = Color.rgba8(0, 0, 0, 180),
// The monochrome primary at its contrast extreme: pure
// black filled controls, 21:1 against the white accent
@@ -276,6 +312,13 @@ pub const ColorTokens = struct {
.surface_pressed = Color.rgb8(38, 38, 38),
.text = Color.rgb8(255, 255, 255),
.text_muted = Color.rgb8(229, 229, 229),
.syntax_plain = Color.rgb8(237, 237, 237),
.syntax_comment = Color.rgb8(161, 161, 161),
.syntax_keyword = Color.rgb8(247, 95, 143),
.syntax_literal = Color.rgb8(98, 192, 115),
.syntax_function = Color.rgb8(191, 122, 240),
.syntax_property = Color.rgb8(255, 97, 102),
.syntax_constant = Color.rgb8(82, 168, 255),
.border = Color.rgba8(255, 255, 255, 190),
// The monochrome primary at its contrast extreme: pure
// white filled controls, 21:1 against the black accent
@@ -303,6 +346,26 @@ pub const ColorTokens = struct {
}
};
/// Resolve one named color token, including the compatibility inheritance
/// for syntax roles omitted by older custom palettes.
pub fn colorTokenValue(colors: ColorTokens, ref: std.meta.FieldEnum(ColorTokens)) Color {
const value = switch (ref) {
inline else => |tag| @field(colors, @tagName(tag)),
};
if (value.a != 0) return value;
return switch (ref) {
.syntax_comment => colors.text_muted,
.syntax_plain,
.syntax_keyword,
.syntax_literal,
.syntax_function,
.syntax_property,
.syntax_constant,
=> colors.text,
else => value,
};
}
pub const FontFamily = enum {
geist,
geist_mono,
@@ -1205,6 +1268,13 @@ pub const ColorTokenOverrides = struct {
surface_pressed: ?Color = null,
text: ?Color = null,
text_muted: ?Color = null,
syntax_plain: ?Color = null,
syntax_comment: ?Color = null,
syntax_keyword: ?Color = null,
syntax_literal: ?Color = null,
syntax_function: ?Color = null,
syntax_property: ?Color = null,
syntax_constant: ?Color = null,
border: ?Color = null,
accent: ?Color = null,
accent_text: ?Color = null,
@@ -1767,7 +1837,7 @@ pub const DesignTokenOverrides = struct {
};
pub const DesignTokens = struct {
colors: ColorTokens = .{},
colors: ColorTokens = ColorTokens.light(),
typography: TypographyTokens = .{},
spacing: SpacingTokens = .{},
radius: RadiusTokens = .{},
+304 -5
View File
@@ -20,6 +20,7 @@
const std = @import("std");
const builtin = @import("builtin");
const code_model = @import("code.zig");
const font_coverage = @import("font_coverage.zig");
const geometry = @import("geometry");
const canvas = @import("root.zig");
@@ -383,9 +384,7 @@ pub const StyleTokenRefs = struct {
};
fn colorTokenValue(colors: canvas.ColorTokens, ref: ColorTokenName) canvas.Color {
return switch (ref) {
inline else => |tag| @field(colors, @tagName(tag)),
};
return canvas.colorTokenValue(colors, ref);
}
fn radiusTokenValue(radius: canvas.RadiusTokens, ref: RadiusTokenName) f32 {
@@ -937,6 +936,11 @@ pub fn Ui(comptime Msg: type) type {
on_terminal: ?TerminalMsgFn = null,
context_menu: []const ContextMenuItem = &.{},
nodes: []const Node = &.{},
/// Internal source fingerprint for adjacent code paragraphs
/// that form one selectable document. `finalizeNode` combines
/// it with their parent structural id into a source-sensitive
/// group identity.
static_text_group_fingerprint: u64 = 0,
/// Markup authoring provenance, stamped by the markup engines
/// when the builder carries a `provenance_sink`; null for
/// builder-authored (Zig) nodes, which is itself the honest
@@ -2289,6 +2293,292 @@ pub fn Ui(comptime Msg: type) type {
return self.el(.stack, .{ .grow = grow }, .{});
}
pub const CodeOptions = struct {
key: ?UiKey = null,
global_key: ?UiKey = null,
language: code_model.Language = .plain,
/// Prefix each logical source line with a muted, monospace
/// number. Off by default.
line_numbers: bool = false,
/// Word-wrap long source lines. `false` keeps logical lines
/// intact inside one horizontal scroll region.
wrap: bool = true,
width: f32 = 0,
/// Definite surface height. Overflow scrolls vertically;
/// no-wrap surfaces scroll on both axes.
height: f32 = 0,
min_width: f32 = 0,
grow: f32 = 0,
semantics: canvas.WidgetSemantics = .{},
};
/// A themed source-code surface with bounded syntax highlighting.
/// Markdown fences lower through this same component.
pub fn code(self: *Self, options: CodeOptions, source: []const u8) Node {
const line_count = codeLineCount(source);
const numbered = options.line_numbers and line_count <= max_code_lines;
const content = if (numbered)
self.numberedCodeParagraph(source, options.language, options.wrap, line_count)
else
self.codeParagraphChunks(source, options.language, options.wrap);
const body = if (options.wrap)
if (options.height > 0) blk: {
// Generic scroll children stay viewport-sized by
// contract. Put code in an internal flow track whose
// non-growing paragraph retains its full wrapped
// height, giving the vertical scroll walker an honest
// descendant extent without remeasuring arbitrary
// public scroll content.
var tracked_content = content;
tracked_content.widget.layout.grow = 0;
const track = self.column(.{}, .{tracked_content});
break :blk self.scroll(.{ .axis = .vertical, .grow = 1 }, .{track});
} else content
else blk: {
// The engine scrollbar overlays the viewport's bottom
// edge. Reserve one quiet band inside the scrollable
// track so the final code baseline never sits under it.
const track = self.column(.{}, .{
content,
self.el(.stack, .{ .height = 8 }, .{}),
});
break :blk self.scroll(.{
.axis = if (options.height > 0) .both else .horizontal,
.grow = 1,
}, .{track});
};
var surface = self.el(.panel, .{
.key = options.key,
.global_key = options.global_key,
.width = options.width,
.height = options.height,
.min_width = options.min_width,
.grow = options.grow,
.padding = 12,
.style_tokens = .{ .background = .surface_subtle },
.semantics = options.semantics,
}, .{body});
surface.widget.layout.clip_content = true;
return surface;
}
/// Keep numbered source in one selectable paragraph. The renderer
/// owns its muted gutter, so marker digits never enter retained
/// text or clipboard bytes; it derives each marker baseline from
/// this paragraph's real wrapped layout.
fn numberedCodeParagraph(
self: *Self,
source: []const u8,
language: code_model.Language,
wrap: bool,
line_count: usize,
) Node {
var state: code_model.HighlightState = .{};
var source_node = self.codeParagraphWithState(source, language, wrap, 1, &state, null);
source_node.widget.code_line_number_digits = @intCast(decimalDigits(line_count));
return source_node;
}
/// Keep ordinary code in one text widget so static selection and copy
/// span logical lines. Only split when the paragraph layout's bounded
/// line capacity requires it; every chunk remains independently
/// visible instead of silently truncating a large source block.
fn codeParagraphChunks(self: *Self, source: []const u8, language: code_model.Language, wrap: bool) Node {
var state: code_model.HighlightState = .{};
var budget: CodeSpanBudget = .{
.remaining_chunks = codeParagraphChunkCount(source, wrap),
};
return self.codeParagraphChunksWithStateBudgeted(
source,
language,
wrap,
if (wrap) 1 else 0,
&state,
&budget,
);
}
fn codeParagraphChunksWithState(
self: *Self,
source: []const u8,
language: code_model.Language,
wrap: bool,
grow: f32,
state: *code_model.HighlightState,
) Node {
return self.codeParagraphChunksWithStateBudgeted(
source,
language,
wrap,
grow,
state,
null,
);
}
fn codeParagraphChunksWithStateBudgeted(
self: *Self,
source: []const u8,
language: code_model.Language,
wrap: bool,
grow: f32,
state: *code_model.HighlightState,
budget: ?*CodeSpanBudget,
) Node {
const chunk_count = codeParagraphChunkCount(source, wrap);
if (chunk_count == 1) {
return self.codeParagraphWithState(source, language, wrap, grow, state, budget);
}
const chunks = self.arena.alloc(Node, chunk_count) catch {
self.failed = true;
return self.column(.{}, .{});
};
const group_fingerprint = std.hash.Wyhash.hash(0, source) | 1;
var chunk_index: usize = 0;
var chunk_start: usize = 0;
while (chunk_start < source.len) {
const chunk_end = codeParagraphChunkEnd(source, chunk_start, wrap);
chunks[chunk_index] = self.codeParagraphWithState(
source[chunk_start..chunk_end],
language,
wrap,
0,
state,
budget,
);
chunks[chunk_index].static_text_group_fingerprint = group_fingerprint;
chunks[chunk_index].widget.static_text_group_offset = chunk_start;
chunk_index += 1;
chunk_start = chunk_end;
}
return self.column(.{ .grow = grow }, .{chunks[0..chunk_index]});
}
fn codeParagraphWithState(
self: *Self,
source: []const u8,
language: code_model.Language,
wrap: bool,
grow: f32,
state: *code_model.HighlightState,
budget: ?*CodeSpanBudget,
) Node {
var storage: [canvas.text_spans.max_text_spans_per_paragraph]canvas.TextSpan = undefined;
var highlighted = if (source.len == 0) blk: {
storage[0] = .{ .text = source, .monospace = true, .color = .syntax_plain };
break :blk storage[0..1];
} else code_model.highlightWithState(source, language, &storage, state);
if (budget) |span_budget| {
std.debug.assert(span_budget.remaining_chunks > 0);
span_budget.remaining_chunks -= 1;
const highlighted_total = span_budget.used +
highlighted.len +
span_budget.remaining_chunks;
if (highlighted_total <= max_code_spans_per_surface) {
span_budget.used += highlighted.len;
} else {
storage[0] = .{
.text = source,
.monospace = true,
.color = .syntax_plain,
};
highlighted = storage[0..1];
span_budget.used += 1;
}
}
return self.paragraph(.{ .wrap = wrap, .grow = grow }, highlighted);
}
/// Upper bound for formatting renderer-owned logical-line markers.
/// Sources above it keep every source byte and omit the gutter.
pub const max_code_lines: usize = 128;
pub const max_code_spans_per_surface: usize = 512;
const CodeSpanBudget = struct {
used: usize = 0,
remaining_chunks: usize,
};
fn codeParagraphChunkCount(source: []const u8, wrap: bool) usize {
if (source.len == 0) return 1;
var count: usize = 0;
var start: usize = 0;
while (start < source.len) {
start = codeParagraphChunkEnd(source, start, wrap);
count += 1;
}
return count;
}
/// Pack complete logical lines while their UTF-8 scalar count fits
/// the span layout's worst case (one glyph per visual line). A
/// single over-capacity logical line stays whole: the viewport
/// painter pages its visual runs without inserting a source break.
fn codeParagraphChunkEnd(source: []const u8, start: usize, wrap: bool) usize {
if (!wrap) {
var cursor = start;
var lines: usize = 0;
while (cursor < source.len) : (cursor += 1) {
if (source[cursor] != '\n') continue;
lines += 1;
if (lines >= max_code_logical_lines_per_paragraph and cursor + 1 < source.len) {
return cursor + 1;
}
}
return source.len;
}
var cursor = start;
var units: usize = 0;
var lines: usize = 0;
while (cursor < source.len) {
const newline = std.mem.indexOfScalarPos(u8, source, cursor, '\n');
const line_end = newline orelse source.len;
const after_line = if (newline != null) line_end + 1 else line_end;
// A trailing newline ends the final painted line without
// adding another one. An empty source line still occupies
// one visual line.
const line_units = @max(1, codeScalarCount(source[cursor..line_end]));
if (units + line_units > canvas.text_spans.max_text_span_lines_per_paragraph and cursor > start) {
return cursor;
}
units += line_units;
lines += 1;
cursor = after_line;
if (lines >= max_code_logical_lines_per_paragraph and cursor < source.len) return cursor;
}
return source.len;
}
fn codeScalarCount(source: []const u8) usize {
var count: usize = 0;
var cursor: usize = 0;
while (cursor < source.len) : (count += 1) {
const sequence_len = std.unicode.utf8ByteSequenceLength(source[cursor]) catch 1;
cursor += @min(sequence_len, source.len - cursor);
}
return count;
}
// Keep the maximal 64 KiB newline-only source within both the
// component's 512-span share and the runtime's 1024-node view cap:
// 128 logical lines per paragraph yields at most 512 chunks.
const max_code_logical_lines_per_paragraph: usize =
canvas.text_spans.max_text_span_lines_per_paragraph;
fn codeLineCount(source: []const u8) usize {
if (source.len == 0) return 1;
return std.mem.count(u8, source, "\n") +
@intFromBool(source[source.len - 1] != '\n');
}
fn decimalDigits(value: usize) usize {
var remaining = value;
var digits: usize = 1;
while (remaining >= 10) : (remaining /= 10) digits += 1;
return digits;
}
pub const ChartOptions = struct {
key: ?UiKey = null,
global_key: ?UiKey = null,
@@ -2889,14 +3179,23 @@ pub fn Ui(comptime Msg: type) type {
// way, matching the single-line measurement both layout paths
// already perform; overflow policy (`overflow`, trailing
// ellipsis by default) decides what happens past the frame.
// Span paragraphs keep wrapping.
if (node.wrap == false and widget.kind == .text and widget.spans.len == 0) {
// Span paragraphs honor the same explicit no-wrap policy.
// `Ui.code` uses this to keep highlighted logical lines
// intact inside its horizontal scroll region.
if (node.wrap == false and widget.kind == .text) {
widget.text_no_wrap = true;
}
widget.id = if (node.global_key) |global_key|
structuralId(global_id_seed, widget.kind, global_key)
else
structuralId(parent_id, widget.kind, key);
if (node.static_text_group_fingerprint != 0) {
widget.static_text_group_id = structuralId(
parent_id,
.stack,
.{ .int = node.static_text_group_fingerprint },
);
}
// Provenance record (write-back's read half): this is the one
// point where the markup-stamped source and the just-assigned
// structural id are both in hand. Explicit keys (loop item
+59 -2
View File
@@ -1847,7 +1847,7 @@ pub const known_color_token_names = schema.color_token_names;
pub const known_radius_token_names = schema.radius_token_names;
pub const style_token_literal_message = "style token attributes take a literal token name - dynamic styling stays in Zig";
pub const unknown_color_token_message = "unknown color token: color style attributes take a canvas ColorTokens field name (background, surface, surface_subtle, surface_pressed, text, text_muted, border, accent, accent_text, destructive, destructive_text, success, success_text, warning, warning_text, info, info_text, focus_ring, shadow, scrim, disabled)";
pub const unknown_color_token_message = "unknown color token: color style attributes take a canvas ColorTokens field name (background, surface, surface_subtle, surface_pressed, text, text_muted, syntax_plain, syntax_comment, syntax_keyword, syntax_literal, syntax_function, syntax_property, syntax_constant, border, accent, accent_text, destructive, destructive_text, success, success_text, warning, warning_text, info, info_text, focus_ring, shadow, scrim, disabled)";
pub const unknown_radius_token_message = "unknown radius token: radius takes a canvas RadiusTokens field name (sm, md, lg, xl)";
pub const for_children_message = "for takes one or more element children (elements, use, if/else, or a nested for) - text content is only allowed inside text-bearing elements";
@@ -1865,6 +1865,10 @@ pub const markdown_issue_link_base_message = "issue-link-base takes a literal UR
pub const markdown_on_link_message = "on-link takes a bare Msg tag whose payload is the pressed link URL (a []const u8 variant, like open_url: []const u8)";
pub const markdown_on_details_message = "on-details takes a bare Msg tag whose payload is the details block index (a usize variant, like toggle_details: usize)";
pub const markdown_details_expanded_message = "details-expanded takes one {binding} naming a []const bool iterable (a model field, pub decl, or fn - the same sources for each accepts)";
pub const code_source_message = "code requires a source attribute with one {binding} naming the source text (a []const u8 field or fn - arena fns work)";
pub const code_children_message = "code takes no children or text content - the source binding provides the code";
pub const code_attr_message = "unknown attribute for code - it takes source, language, line-numbers, wrap, width, height, min-width, grow, key, global-key, and label";
pub const code_language_message = "language takes a literal lexer name: plain, zig, javascript/js, typescript/ts, jsx/tsx, json, shell/sh/bash/zsh, python/py, rust/rs, c/cpp/c++/csharp/java/kotlin/swift, go, html/xml/svg, css/scss/less, or sql";
pub const stepper_active_message = "stepper requires an active attribute (a number or one {binding}) naming the active step index";
pub const stepper_attr_message = "unknown attribute for stepper - it takes active, key, global-key, and label";
pub const stepper_children_message = "stepper takes only step children (each step is a text leaf: <step>Work</step>)";
@@ -2215,6 +2219,58 @@ fn validateMarkdown(node: MarkupNode) ?MarkupErrorInfo {
return null;
}
fn codeLanguageName(name: []const u8) bool {
const names = "plain text zig js javascript jsx ts typescript tsx json jsonc sh bash zsh shell py python rs rust c h cc cpp c++ cs csharp java kotlin swift go golang html xml svg css scss less sql";
var known = std.mem.tokenizeScalar(u8, names, ' ');
while (known.next()) |candidate| {
if (std.ascii.eqlIgnoreCase(name, candidate)) return true;
}
return false;
}
/// `<code>` is a source-bound leaf lowered through `Ui.code`: syntax
/// language is static markup, while flags and layout values can bind.
fn validateCode(node: MarkupNode) ?MarkupErrorInfo {
for (node.children) |child| return errorAt(child, code_children_message);
var has_source = false;
for (node.attrs) |attribute| {
if (std.mem.eql(u8, attribute.name, "source")) {
has_source = true;
const expression = parseAttrExpression(attribute.value);
if (expression == null or expression.? != .binding) return attrError(node, attribute, code_source_message);
continue;
}
if (std.mem.eql(u8, attribute.name, "language")) {
const expression = parseAttrExpression(attribute.value);
if (expression == null or expression.? != .literal or !codeLanguageName(expression.?.literal)) {
return attrError(node, attribute, code_language_message);
}
continue;
}
if (std.mem.eql(u8, attribute.name, "line-numbers") or std.mem.eql(u8, attribute.name, "wrap")) {
if (attribute.value.len == 0) continue;
if (attrExpressionError(attribute.value, invalid_expression_message)) |message| {
return attrError(node, attribute, message);
}
continue;
}
const known = std.mem.eql(u8, attribute.name, "width") or
std.mem.eql(u8, attribute.name, "height") or
std.mem.eql(u8, attribute.name, "min-width") or
std.mem.eql(u8, attribute.name, "grow") or
std.mem.eql(u8, attribute.name, "label") or
std.mem.eql(u8, attribute.name, "key") or
std.mem.eql(u8, attribute.name, "global-key");
if (!known) return attrError(node, attribute, code_attr_message);
if (attrExpressionError(attribute.value, invalid_expression_message)) |message| {
return attrError(node, attribute, message);
}
if (attrCoverageError(node, attribute)) |info| return info;
}
if (!has_source) return errorAt(node, code_source_message);
return null;
}
/// `<stepper active="{index}">` takes only `<step>` text-leaf children:
/// each step's state (completed/active/pending) derives from its position
/// against the active index, so steps carry no attributes of their own.
@@ -2806,7 +2862,7 @@ fn validateVideo(node: MarkupNode) ?MarkupErrorInfo {
/// The rule hooks the composite registry entries name. A registry entry
/// whose hook this table does not implement is a compile error (below),
/// so attachment and implementation can never drift.
const rule_hook_names = [_][]const u8{ "markdown", "stepper", "step", "timeline", "timeline-item", "chart", "series", "context-menu", "input-group", "input-group-actions", "span", "reactions", "video" };
const rule_hook_names = [_][]const u8{ "markdown", "code", "stepper", "step", "timeline", "timeline-item", "chart", "series", "context-menu", "input-group", "input-group-actions", "span", "reactions", "video" };
comptime {
for (schema.elements) |entry| {
@@ -2826,6 +2882,7 @@ comptime {
/// a worse inner language than plain Zig.
fn validateRuleHook(hook: []const u8, document: MarkupDocument, node: MarkupNode, parent_element: ?[]const u8, template_limit: usize, slot_rule: SlotRule) ?MarkupErrorInfo {
if (std.mem.eql(u8, hook, "markdown")) return validateMarkdown(node);
if (std.mem.eql(u8, hook, "code")) return validateCode(node);
if (std.mem.eql(u8, hook, "stepper")) return validateStepper(node);
if (std.mem.eql(u8, hook, "step")) {
// Steps inside a stepper are consumed by validateStepper; one
@@ -263,6 +263,9 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
if (comptime std.mem.eql(u8, node.name, "markdown")) {
return buildMarkdown(node, entries, ui, model, scope);
}
if (comptime std.mem.eql(u8, node.name, "code")) {
return buildCode(node, entries, ui, model, scope);
}
if (comptime std.mem.eql(u8, node.name, "stepper")) {
return buildStepper(node, entries, ui, model, scope);
}
@@ -776,6 +779,79 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
return Md.view(ui, source_text, options);
}
// ----------------------------------------------------------- code
fn buildCode(comptime node: markup.MarkupNode, comptime entries: []const ScopeEntry, ui: *Ui, model: *const ModelT, scope: anytype) Ui.Node {
comptime {
if (node.children.len != 0) fail(node.children[0], markup.code_children_message);
for (node.attrs) |attribute| {
if (std.mem.eql(u8, attribute.name, "kind")) continue;
const known = std.mem.eql(u8, attribute.name, "source") or
std.mem.eql(u8, attribute.name, "language") or
std.mem.eql(u8, attribute.name, "line-numbers") or
std.mem.eql(u8, attribute.name, "wrap") or
std.mem.eql(u8, attribute.name, "width") or
std.mem.eql(u8, attribute.name, "height") or
std.mem.eql(u8, attribute.name, "min-width") or
std.mem.eql(u8, attribute.name, "grow") or
std.mem.eql(u8, attribute.name, "label") or
std.mem.eql(u8, attribute.name, "key") or
std.mem.eql(u8, attribute.name, "global-key");
if (!known) fail(node, markup.code_attr_message);
}
}
const source_path = comptime blk: {
const raw = node.attr("source") orelse fail(node, markup.code_source_message);
const expression = markup.parseAttrExpression(raw) orelse fail(node, markup.code_source_message);
if (expression != .binding) fail(node, markup.code_source_message);
break :blk expression.binding;
};
comptime requireVariant(pathVariant(node, entries, source_path, true), &.{.string}, node, markup.code_source_message);
const source = switch (bindingValue(node, entries, source_path, ui, model, scope, true)) {
.string => |text| text,
else => runtimeFail([]const u8, ui),
};
var options: Ui.CodeOptions = .{};
if (comptime (node.attr("language") != null)) {
const name = comptime blk: {
const expression = markup.parseAttrExpression(node.attr("language").?) orelse fail(node, markup.code_language_message);
if (expression != .literal) fail(node, markup.code_language_message);
if (!canvas.code.isLanguageName(expression.literal)) fail(node, markup.code_language_message);
break :blk expression.literal;
};
options.language = canvas.code.languageFromName(name);
}
if (comptime (node.attr("line-numbers") != null)) {
options.line_numbers = videoFlagValue(node, entries, comptime node.attr("line-numbers").?, ui, model, scope);
}
if (comptime (node.attr("wrap") != null)) {
options.wrap = videoFlagValue(node, entries, comptime node.attr("wrap").?, ui, model, scope);
}
if (comptime (node.attr("width") != null)) {
options.width = floatAttr(node, entries, comptime node.attr("width").?, ui, model, scope);
}
if (comptime (node.attr("height") != null)) {
options.height = floatAttr(node, entries, comptime node.attr("height").?, ui, model, scope);
}
if (comptime (node.attr("min-width") != null)) {
options.min_width = floatAttr(node, entries, comptime node.attr("min-width").?, ui, model, scope);
}
if (comptime (node.attr("grow") != null)) {
options.grow = floatAttr(node, entries, comptime node.attr("grow").?, ui, model, scope);
}
if (comptime (node.attr("label") != null)) {
options.semantics.label = stringAttr(node, entries, comptime node.attr("label").?, ui, model, scope, "label expects text");
}
if (comptime (node.attr("key") != null)) {
options.key = attrKey(node, entries, comptime node.attr("key").?, ui, model, scope, "keys must be integers or strings");
}
if (comptime (node.attr("global-key") != null)) {
options.global_key = attrKey(node, entries, comptime node.attr("global-key").?, ui, model, scope, "keys must be integers or strings");
}
return ui.code(options, source);
}
fn markdownLinkConstructor(comptime node: markup.MarkupNode, comptime raw: []const u8) Ui.LinkMsgFn {
comptime {
@setEvalBranchQuota(10_000);
@@ -1089,6 +1089,39 @@ test "compiled markdown element matches the interpreter and the hand-written Md.
try testing.expectEqual(summary_item.id, fixture.findByKind(expanded_compiled.root, .list_item).?.id);
}
// ------------------------------------------------------ code element parity
const CodeUi = fixture.CodeUi;
const CodeInterpreter = markup_view.MarkupView(fixture.CodeModel, fixture.CodeMsg);
const CodeCompiled = canvas.CompiledMarkupView(fixture.CodeModel, fixture.CodeMsg, fixture.code_markup_source);
test "compiled code element matches the interpreter and Ui.code" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var model = fixture.CodeModel{};
var view = try CodeInterpreter.init(arena, fixture.code_markup_source);
var interpreted_ui = CodeUi.init(arena);
const interpreted = try interpreted_ui.finalize(try view.build(&interpreted_ui, &model));
var compiled_ui = CodeUi.init(arena);
const compiled = try compiled_ui.finalize(CodeCompiled.build(&compiled_ui, &model));
var hand_ui = CodeUi.init(arena);
const hand = try hand_ui.finalize(fixture.handCodeView(&hand_ui, &model));
try expectSameTree(fixture.CodeMsg, hand, interpreted);
try expectSameTree(fixture.CodeMsg, hand, compiled);
try expectSameTexts(interpreted.root, compiled.root);
try testing.expectEqual(canvas.ScrollAxes.horizontal, fixture.findByKind(compiled.root, .scroll_view).?.scroll_axes);
model.wrap_code = true;
model.show_lines = false;
var wrapped_ui = CodeUi.init(arena);
const wrapped = try wrapped_ui.finalize(CodeCompiled.build(&wrapped_ui, &model));
try testing.expect(fixture.findByKind(wrapped.root, .scroll_view) == null);
try testing.expect(fixture.findByText(wrapped.root, .text, "1") == null);
}
// ------------------------------------------- template/use + style parity
fn expectSameStyles(expected: canvas.Widget, actual: canvas.Widget) !void {
@@ -1185,6 +1185,7 @@ const Checker = struct {
fn checkElement(self: *Checker, node: markup.MarkupNode) CheckErr!void {
if (std.mem.eql(u8, node.name, "span")) return self.checkSpan(node);
if (std.mem.eql(u8, node.name, "markdown")) return self.checkMarkdown(node);
if (std.mem.eql(u8, node.name, "code")) return self.checkCode(node);
if (std.mem.eql(u8, node.name, "stepper")) return self.checkStepper(node);
if (std.mem.eql(u8, node.name, "timeline-item")) return self.checkTimelineItem(node);
if (std.mem.eql(u8, node.name, "chart")) return self.checkChart(node);
@@ -1338,6 +1339,39 @@ const Checker = struct {
}
}
fn checkCode(self: *Checker, node: markup.MarkupNode) CheckErr!void {
for (node.attrs) |attribute| {
if (std.mem.eql(u8, attribute.name, "source")) {
const expression = markup.parseAttrExpression(attribute.value) orelse continue;
if (expression != .binding) continue;
const resolved = try self.resolveBinding(node, expression.binding, true);
try self.requireAttrKind(node, attribute, resolved.kind, &.{.string}, markup.code_source_message);
continue;
}
if (std.mem.eql(u8, attribute.name, "line-numbers") or std.mem.eql(u8, attribute.name, "wrap")) {
if (attribute.value.len == 0) continue;
_ = try self.attrKind(node, attribute, attribute.value);
continue;
}
if (std.mem.eql(u8, attribute.name, "width") or
std.mem.eql(u8, attribute.name, "height") or
std.mem.eql(u8, attribute.name, "min-width") or
std.mem.eql(u8, attribute.name, "grow"))
{
try self.checkClassAttr(node, attribute, .number);
continue;
}
if (std.mem.eql(u8, attribute.name, "key") or std.mem.eql(u8, attribute.name, "global-key")) {
try self.checkKeyAttr(node, attribute);
continue;
}
if (std.mem.eql(u8, attribute.name, "label")) {
const kind = try self.attrKind(node, attribute, attribute.value);
try self.requireAttrKind(node, attribute, kind, &.{.string}, label_attr_message);
}
}
}
fn checkStepper(self: *Checker, node: markup.MarkupNode) CheckErr!void {
for (node.attrs) |attribute| {
if (std.mem.eql(u8, attribute.name, "active")) {
+73
View File
@@ -213,6 +213,9 @@ pub fn MarkupView(comptime ModelT: type, comptime MsgT: type) type {
if (std.mem.eql(u8, node.name, "markdown")) {
return self.buildMarkdown(ui, scope, node);
}
if (std.mem.eql(u8, node.name, "code")) {
return self.buildCode(ui, scope, node);
}
if (std.mem.eql(u8, node.name, "stepper")) {
return self.buildStepper(ui, scope, node);
}
@@ -798,6 +801,76 @@ pub fn MarkupView(comptime ModelT: type, comptime MsgT: type) type {
return Md.view(ui, source_value, options);
}
// ----------------------------------------------------------- code
fn buildCode(self: *Self, ui: *Ui, scope: *Scope, node: markup.MarkupNode) BuildError!Ui.Node {
if (node.children.len != 0) return self.failNode(node.children[0], markup.code_children_message);
var options: Ui.CodeOptions = .{};
var source_text: ?[]const u8 = null;
for (node.attrs) |attribute| {
if (std.mem.eql(u8, attribute.name, "kind")) continue;
if (std.mem.eql(u8, attribute.name, "source")) {
const typed = markup.attrTyped(attribute);
if (typed != .binding) return self.failNode(node, markup.code_source_message);
source_text = switch (try self.evalBinding(scope, node, typed.binding, true)) {
.string => |text| text,
else => return self.failNode(node, markup.code_source_message),
};
continue;
}
if (std.mem.eql(u8, attribute.name, "language")) {
const typed = markup.attrTyped(attribute);
if (typed != .literal) return self.failNode(node, markup.code_language_message);
if (!canvas.code.isLanguageName(typed.literal)) return self.failNode(node, markup.code_language_message);
options.language = canvas.code.languageFromName(typed.literal);
continue;
}
if (std.mem.eql(u8, attribute.name, "line-numbers")) {
options.line_numbers = try self.codeFlagAttr(scope, node, attribute);
continue;
}
if (std.mem.eql(u8, attribute.name, "wrap")) {
options.wrap = try self.codeFlagAttr(scope, node, attribute);
continue;
}
if (std.mem.eql(u8, attribute.name, "width")) {
options.width = try self.floatAttr(scope, node, attribute);
continue;
}
if (std.mem.eql(u8, attribute.name, "height")) {
options.height = try self.floatAttr(scope, node, attribute);
continue;
}
if (std.mem.eql(u8, attribute.name, "min-width")) {
options.min_width = try self.floatAttr(scope, node, attribute);
continue;
}
if (std.mem.eql(u8, attribute.name, "grow")) {
options.grow = try self.floatAttr(scope, node, attribute);
continue;
}
if (std.mem.eql(u8, attribute.name, "key")) {
options.key = try self.attrKey(scope, node, attribute);
continue;
}
if (std.mem.eql(u8, attribute.name, "global-key")) {
options.global_key = try self.attrKey(scope, node, attribute);
continue;
}
if (std.mem.eql(u8, attribute.name, "label")) {
options.semantics.label = try self.stringAttr(scope, node, attribute, "label expects text");
continue;
}
return self.failNode(node, markup.code_attr_message);
}
return ui.code(options, source_text orelse return self.failNode(node, markup.code_source_message));
}
fn codeFlagAttr(self: *Self, scope: *Scope, node: markup.MarkupNode, attribute: markup.MarkupAttr) BuildError!bool {
if (attribute.value.len == 0) return true;
return (try self.evalAttrExpression(scope, node, attribute)).truthy();
}
// ------------------------------------------------ stepper/timeline
/// `<stepper active="{stage_index}"><step>Work</step>...</stepper>`:
@@ -1926,6 +1926,91 @@ test "markdown misuse is caught by the model-agnostic validator with positions"
try testing.expectEqual(@as(?canvas.ui_markup.MarkupErrorInfo, null), canvas.ui_markup.validate(try parser.parse()));
}
// ------------------------------------------------------- code component
pub const CodeMsg = union(enum) { noop };
pub const CodeModel = struct {
snippet: []const u8 =
\\<Accordion defaultValue={["item-1"]}>
\\ <AccordionTrigger>Accessible?</AccordionTrigger>
\\</Accordion>
,
show_lines: bool = true,
wrap_code: bool = false,
count: usize = 3,
};
pub const code_markup_source =
\\<code source="{snippet}" language="tsx" line-numbers="{show_lines}" wrap="{wrap_code}" width="240" label="Example code" />
;
pub const CodeUi = canvas.Ui(CodeMsg);
pub fn handCodeView(ui: *CodeUi, model: *const CodeModel) CodeUi.Node {
return ui.code(.{
.language = .html,
.line_numbers = model.show_lines,
.wrap = model.wrap_code,
.width = 240,
.semantics = .{ .label = "Example code" },
}, model.snippet);
}
test "code markup builds the reusable component with opt-in numbers and horizontal scrolling" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const model = CodeModel{};
const CodeMarkup = markup_view.MarkupView(CodeModel, CodeMsg);
var view = try CodeMarkup.init(arena, code_markup_source);
var markup_ui = CodeUi.init(arena);
const markup_tree = try markup_ui.finalize(try view.build(&markup_ui, &model));
var hand_ui = CodeUi.init(arena);
const hand_tree = try hand_ui.finalize(handCodeView(&hand_ui, &model));
var markup_ids: std.ArrayListUnmanaged(canvas.ObjectId) = .empty;
defer markup_ids.deinit(testing.allocator);
var hand_ids: std.ArrayListUnmanaged(canvas.ObjectId) = .empty;
defer hand_ids.deinit(testing.allocator);
try collectIds(markup_tree.root, &markup_ids, testing.allocator);
try collectIds(hand_tree.root, &hand_ids, testing.allocator);
try testing.expectEqualSlices(canvas.ObjectId, hand_ids.items, markup_ids.items);
try testing.expectEqual(canvas.ScrollAxes.horizontal, findByKind(markup_tree.root, .scroll_view).?.scroll_axes);
const source = findByText(markup_tree.root, .text, model.snippet).?;
try testing.expectEqual(@as(u8, 1), source.code_line_number_digits);
try testing.expectEqualStrings("Example code", markup_tree.root.semantics.label);
}
test "code markup misuse reports the component's closed contract" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const model = CodeModel{};
const CodeMarkup = markup_view.MarkupView(CodeModel, CodeMsg);
const cases = [_]struct { source: []const u8, message: []const u8, model_agnostic: bool = true }{
.{ .source = "<code language=\"zig\" />", .message = canvas.ui_markup.code_source_message },
.{ .source = "<code source=\"literal\" />", .message = canvas.ui_markup.code_source_message },
.{ .source = "<code source=\"{count}\" />", .message = canvas.ui_markup.code_source_message, .model_agnostic = false },
.{ .source = "<code source=\"{snippet}\" language=\"brainwave\" />", .message = canvas.ui_markup.code_language_message },
.{ .source = "<code source=\"{snippet}\" padding=\"8\" />", .message = canvas.ui_markup.code_attr_message },
.{ .source = "<code source=\"{snippet}\">text</code>", .message = canvas.ui_markup.code_children_message },
};
for (cases) |case| {
var view = try CodeMarkup.init(arena, case.source);
var ui = CodeUi.init(arena);
try testing.expectError(error.MarkupBuild, view.build(&ui, &model));
try testing.expectEqualStrings(case.message, view.diagnostic.message);
if (case.model_agnostic) {
var parser = canvas.ui_markup.Parser.init(arena, case.source);
const info = canvas.ui_markup.validate(try parser.parse()) orelse return error.TestUnexpectedResult;
try testing.expectEqualStrings(case.message, info.message);
}
}
}
// -------------------------------------------------- component catalog fixture
pub const CatalogRow = struct {
+17 -6
View File
@@ -353,6 +353,11 @@ pub const elements = [_]ElementInfo{
// owns the keyboard, so it must carry a name — the runtime then
// announces the live screen text as its content.
.{ .code = 69, .name = "terminal", .widget_kind = "terminal", .a11y_name = .control },
// The reusable code surface: a source-bound composite that lowers
// through `Ui.code` to a highlighted panel, optionally with logical
// line numbers or one horizontal scroll region for unwrapped lines.
// Markdown fences use the same builder component.
.{ .code = 70, .name = "code", .rule_hook = "code", .hit_target = false },
};
// ------------------------------------------------------------- attributes
@@ -553,6 +558,11 @@ pub const attrs = [_]AttrInfo{
// back here and user scrollback survives rebuilds; move it
// model-side to scroll programmatically. 0 is pinned to the bottom.
.{ .code = 89, .name = "scrollback", .class = .whole, .group = .option, .field = "scrollback" },
// Code-surface declarations (the <code> composite; its rule hook
// owns the closed set). Language is a literal lexer name and line
// numbers are opt-in; wrapping reuses generic attr code 16.
.{ .code = 90, .name = "language", .class = .option, .group = .composite },
.{ .code = 91, .name = "line-numbers", .class = .flag, .group = .composite },
};
// ----------------------------------------------------------------- events
@@ -597,12 +607,13 @@ pub const events = [_]EventInfo{
// tests in ui_markup_view_tests.zig hold them equal to the live structs).
pub const color_token_names = [_][]const u8{
"background", "surface", "surface_subtle", "surface_pressed",
"text", "text_muted", "border", "accent",
"accent_text", "destructive", "destructive_text", "success",
"success_text", "warning", "warning_text", "info",
"info_text", "focus_ring", "shadow", "scrim",
"disabled",
"background", "surface", "surface_subtle", "surface_pressed",
"text", "text_muted", "syntax_plain", "syntax_comment",
"syntax_keyword", "syntax_literal", "syntax_function", "syntax_property",
"syntax_constant", "border", "accent", "accent_text",
"destructive", "destructive_text", "success", "success_text",
"warning", "warning_text", "info", "info_text",
"focus_ring", "shadow", "scrim", "disabled",
};
pub const radius_token_names = [_][]const u8{ "sm", "md", "lg", "xl" };
+7 -6
View File
@@ -21,15 +21,15 @@ test "registry codes are stable: assigned at birth, never renumbered or renamed"
// (append or slot them anywhere — order carries no meaning) and pin
// the new fingerprint ONLY for additions; renames/renumbers are
// schema-version-bump events, not silent edits.
try testing.expectEqual(@as(usize, 69), schema.elements.len);
try testing.expectEqual(@as(usize, 89), schema.attrs.len);
try testing.expectEqual(@as(usize, 70), schema.elements.len);
try testing.expectEqual(@as(usize, 91), schema.attrs.len);
try testing.expectEqual(@as(usize, 13), schema.events.len);
// The element table runs through the span composite (64), the
// bubble-reactions composite (65), the media surface (66), the
// runtime-image leaf (67), the video playback composite (68), and
// the terminal leaf (69).
// the terminal leaf (69), and the reusable code composite (70).
try testing.expectEqual(
@as(u64, 0x439b5e520bedc352),
@as(u64, 0x180108eb2382ba60),
tableFingerprint(schema.ElementInfo, &schema.elements),
);
// The attr table runs through the split layout-tween attributes
@@ -42,9 +42,10 @@ test "registry codes are stable: assigned at birth, never renumbered or renamed"
// surface (81), the video element attributes controls (82),
// autoplay (83), loop (84), and muted (85), the scroll-axis
// attributes axis (86) and value-x (87), and the terminal
// attributes pty (88) and scrollback (89).
// attributes pty (88) and scrollback (89), and the code language
// (90) and line-numbers (91) declarations.
try testing.expectEqual(
@as(u64, 0x60e430e0cf2cc4f9),
@as(u64, 0x8c9311ac81d2a800),
tableFingerprint(schema.AttrInfo, &schema.attrs),
);
// The event table runs through the pointer-hover containment pair
@@ -265,9 +265,12 @@ fn widgetChange(previous: WidgetLayoutNode, next: WidgetLayoutNode, previous_ind
previous.depth != next.depth or
previous.parent_index != next.parent_index or
!rectsEqual(previous.frame, next.frame) or
previous.widget.code_line_number_digits != next.widget.code_line_number_digits or
!widgetLayoutStylesEqual(previous.widget.layout, next.widget.layout);
const content_dirty = !std.mem.eql(u8, previous.widget.text, next.widget.text) or
!textSpansEqual(previous.widget.spans, next.widget.spans) or
previous.widget.static_text_group_id != next.widget.static_text_group_id or
previous.widget.static_text_group_offset != next.widget.static_text_group_offset or
!chartDataEqual(previous.widget.chart, next.widget.chart) or
!std.mem.eql(u8, previous.widget.placeholder, next.widget.placeholder) or
!std.mem.eql(u8, previous.widget.icon, next.widget.icon) or
+57 -8
View File
@@ -42,6 +42,7 @@ const widgetControlInset = widget_metrics.widgetControlInset;
const widgetSizedDensityValue = widget_metrics.widgetSizedDensityValue;
const densityValue = widget_metrics.densityValue;
const widgetControlHeight = widget_metrics.widgetControlHeight;
const widgetCodeLineNumberGutterWidth = widget_metrics.widgetCodeLineNumberGutterWidth;
const widgetStatusBarPadding = widget_render.widgetStatusBarPadding;
const controlStrokeWidth = widget_render.controlStrokeWidth;
const componentControlVisualTokens = widget_render.componentControlVisualTokens;
@@ -103,7 +104,7 @@ pub fn layoutWidgetDepth(
.scroll_view => if (widget.layout.virtualized)
try layoutVirtualVerticalChildren(widget.children, content, index, depth, output, len, widget.value, widget.layout, tokens)
else
try layoutScrollChildren(widget.children, content, index, depth, output, len, scrollLayoutOffset(widget), tokens),
try layoutScrollChildren(widget.children, content, index, depth, output, len, widget.scroll_axes, scrollLayoutOffset(widget), tokens),
.list => if (widget.layout.virtualized)
try layoutVirtualVerticalChildren(widget.children, content, index, depth, output, len, widget.value, widget.layout, tokens)
else
@@ -825,7 +826,11 @@ fn rowChildWidth(row: Widget, available_width: f32, index: usize, tokens: Design
fn spanParagraphHeight(widget: Widget, width: f32, tokens: DesignTokens) f32 {
return text_spans_model.textSpansWrappedHeight(
widget.spans,
widgetTextSpanLayoutOptions(widget, tokens, width),
widgetTextSpanLayoutOptions(
widget,
tokens,
@max(0, width - widgetCodeLineNumberGutterWidth(widget, tokens)),
),
);
}
@@ -836,7 +841,7 @@ fn spanParagraphHeight(widget: Widget, width: f32, tokens: DesignTokens) f32 {
/// an empty frame (never hit-testable).
fn layoutTextSpanLinkChildren(
widget: Widget,
content: geometry.RectF,
raw_content: geometry.RectF,
parent_index: usize,
depth: usize,
output: []WidgetLayoutNode,
@@ -845,6 +850,12 @@ fn layoutTextSpanLinkChildren(
) Error!void {
if (widget.children.len == 0) return;
if (widget.spans.len == 0) return;
var content = raw_content;
if (widget.kind == .text) {
const gutter = @min(content.width, widgetCodeLineNumberGutterWidth(widget, tokens));
content.x += gutter;
content.width -= gutter;
}
var runs: [text_spans_model.max_text_span_runs_per_paragraph]text_spans_model.TextSpanRun = undefined;
const layout = text_spans_model.layoutTextSpans(
@@ -1051,13 +1062,22 @@ fn layoutScrollChildren(
depth: usize,
output: []WidgetLayoutNode,
len: *usize,
axes: canvas.ScrollAxes,
scroll_offset: geometry.OffsetF,
tokens: DesignTokens,
) Error!void {
const scrolled_content = content.translate(geometry.OffsetF.init(-scroll_offset.dx, -scroll_offset.dy));
for (children) |child| {
if (child.layout.anchor != null) continue;
_ = try layoutWidgetDepth(child, stackChildFrame(scrolled_content, child), parent_index, depth + 1, output, len, tokens);
var child_frame = stackChildFrame(scrolled_content, child);
// A `both` region must retain the same intrinsic width as a
// horizontal-only shelf; otherwise its horizontal axis has no
// content range even when its child paints a long no-wrap line.
if (axes.scrollsHorizontally() and child.frame.width <= 0) {
const intrinsic = intrinsicChildSize(child, tokens, depth + 1);
child_frame.width = @max(child_frame.width, intrinsic.width);
}
_ = try layoutWidgetDepth(child, child_frame, parent_index, depth + 1, output, len, tokens);
}
}
@@ -1615,8 +1635,11 @@ fn intrinsicWidgetSizeDepth(widget: Widget, tokens: DesignTokens, depth: usize)
.dialog, .drawer, .sheet => intrinsicModalSurfaceWidgetSize(widget, tokens, depth),
// Containers measure their children (matching the stacking axis the
// layout pass uses), bounded by the widget depth cap. Scroll
// viewports and virtualized containers stay zero: their content is
// allowed to overflow the space they're given.
// viewports and virtualized containers stay zero on each axis they
// scroll: their content is allowed to overflow the space they're
// given. A horizontal-only viewport still reports its child's
// height, so an inline code scroller hugs its rows instead of
// collapsing to a zero-height clip.
.row, .breadcrumb, .button_group, .pagination, .radio_group, .tabs, .toggle_group => intrinsicAxisChildrenSize(widget, tokens, .horizontal, depth),
.column, .menu_surface, .dropdown_menu, .input_group => intrinsicAxisChildrenSize(widget, tokens, .vertical, depth),
.list, .data_grid, .table => if (widget.layout.virtualized)
@@ -1640,7 +1663,10 @@ fn intrinsicWidgetSizeDepth(widget: Widget, tokens: DesignTokens, depth: usize)
// Terminals join them: the grid derives its cols/rows FROM the
// space it is given (the runtime resizes the pty to fit), so
// reporting an intrinsic size would invert the contract.
.scroll_view, .image, .split, .media_surface, .terminal => geometry.SizeF.zero(),
.scroll_view => if (!widget.layout.virtualized and widget.scroll_axes == .horizontal) blk: {
break :blk intrinsicHorizontalScrollSize(widget, tokens, depth);
} else geometry.SizeF.zero(),
.image, .split, .media_surface, .terminal => geometry.SizeF.zero(),
};
}
@@ -1723,6 +1749,28 @@ fn intrinsicOverlayChildrenSize(widget: Widget, tokens: DesignTokens, depth: usi
return paddedIntrinsicSize(widget, geometry.SizeF.init(width_max, height_max));
}
/// A horizontal viewport stays width-neutral but hugs the full vertical
/// extent of its unwrapped content. Span paragraphs report a one-line
/// intrinsic height, so measure each child through the width-aware seam at
/// its natural width; explicit newlines then contribute every painted row.
fn intrinsicHorizontalScrollSize(widget: Widget, tokens: DesignTokens, depth: usize) geometry.SizeF {
if (depth >= max_widget_depth or widget.children.len == 0) {
return geometry.SizeF.init(0, intrinsicOwnMinSize(widget).height);
}
var height_max: f32 = 0;
for (widget.children) |child| {
if (child.layout.anchor != null) continue;
const intrinsic = intrinsicChildSize(child, tokens, depth + 1);
const child_width = if (child.frame.width > 0) child.frame.width else intrinsic.width;
height_max = @max(
height_max,
wrappedVerticalExtentForWidth(child, child_width, tokens, depth + 1),
);
}
const padded = paddedIntrinsicSize(widget, geometry.SizeF.init(0, height_max));
return geometry.SizeF.init(0, padded.height);
}
fn intrinsicGridChildrenSize(widget: Widget, tokens: DesignTokens, depth: usize) geometry.SizeF {
if (depth >= max_widget_depth or widget.children.len == 0) return intrinsicOwnMinSize(widget);
var cell_width: f32 = 0;
@@ -1761,7 +1809,8 @@ fn intrinsicTextWidgetSize(widget: Widget, tokens: DesignTokens, text_size: f32)
if (widgetIsSpanParagraph(widget)) {
const options = widgetTextSpanLayoutOptions(widget, tokens, 0);
return geometry.SizeF.init(
text_spans_model.textSpansIntrinsicWidth(widget.spans, options),
text_spans_model.textSpansIntrinsicWidth(widget.spans, options) +
widgetCodeLineNumberGutterWidth(widget, tokens),
widgetLineHeight(text_size * text_spans_model.textSpansMaxScale(widget.spans)),
);
}
@@ -2669,6 +2669,41 @@ fn doubledTextMeasure(context: ?*anyopaque, font_id: FontId, size: f32, text: []
const doubled_text_measure = support.TextMeasureProvider{ .measure_fn = doubledTextMeasure };
fn countingTextMeasure(context: ?*anyopaque, font_id: FontId, size: f32, text: []const u8) f32 {
const calls: *usize = @ptrCast(@alignCast(context.?));
calls.* += 1;
_ = font_id;
return size * @as(f32, @floatFromInt(text.len));
}
test "vertical scroll layout does not intrinsically remeasure its child" {
var calls: usize = 0;
const measure = support.TextMeasureProvider{
.context = &calls,
.measure_fn = countingTextMeasure,
};
const children = [_]Widget{.{
.id = 2,
.kind = .text,
.text = "transcript row",
}};
const scroll = Widget{
.id = 1,
.kind = .scroll_view,
.scroll_axes = .vertical,
.children = &children,
};
var nodes: [2]WidgetLayoutNode = undefined;
_ = try layoutWidgetTreeWithTokens(
scroll,
geometry.RectF.init(0, 0, 240, 120),
.{ .text_measure = &measure },
&nodes,
);
try std.testing.expectEqual(@as(usize, 0), calls);
}
test "intrinsic text sizing defaults to the estimator and honors an injected provider" {
const widget = Widget{ .id = 1, .kind = .text, .text = "Refresh dashboard" };
const default_tokens = DesignTokens{};
+30 -1
View File
@@ -1,6 +1,8 @@
const std = @import("std");
const geometry = @import("geometry");
const token_model = @import("tokens.zig");
const widget_model = @import("widgets.zig");
const text_model = @import("text.zig");
const text_spans_model = @import("text_spans.zig");
const Density = token_model.Density;
@@ -103,13 +105,40 @@ pub fn widgetTextSpanLayoutOptions(widget: Widget, tokens: DesignTokens, max_wid
return .{
.size = widgetBodyTextSize(widget, tokens),
.max_width = max_width,
.wrap = .word,
.wrap = if (widget.text_no_wrap) .none else .word,
.alignment = widget.text_alignment,
.typography = tokens.typography,
.measure = tokens.text_measure,
};
}
/// Width reserved before a numbered code paragraph: the largest muted
/// monospace marker plus the component's fixed marker-to-source gap.
/// Marker bytes never live in `Widget.text`; paint selects them from a
/// compile-time table.
pub fn widgetCodeLineNumberGutterWidth(widget: Widget, tokens: DesignTokens) f32 {
if (widget.code_line_number_digits == 0) return 0;
const zeros: [20]u8 = @splat('0');
const digits = @min(@as(usize, widget.code_line_number_digits), zeros.len);
return text_model.measureTextWidthForFont(
tokens.text_measure,
tokens.typography.mono_font_id,
zeros[0..digits],
widgetBodyTextSize(widget, tokens),
) + 12;
}
/// Span paragraph content after authored padding and the engine-owned code
/// gutter. Layout, painting, hit mapping, and selection all use this exact
/// frame so numbered source stays one coherent text model.
pub fn widgetTextSpanContentFrame(widget: Widget, tokens: DesignTokens) geometry.RectF {
var content = widget.frame.inset(widget.layout.padding);
const gutter = @min(content.width, widgetCodeLineNumberGutterWidth(widget, tokens));
content.x += gutter;
content.width -= gutter;
return content;
}
/// The ONE control height register — buttons, inputs, and select
/// triggers all sit on the whole-pixel token ladder
/// (`metrics.control_height_sm`/`control_height`/`control_height_lg`,
+454 -9
View File
@@ -142,8 +142,17 @@ const widget_render_scratch = @import("lazy_tls.zig").LazyTls(WidgetRenderScratc
/// the entry points record it here.
threadlocal var scrim_viewport: ?geometry.RectF = null;
/// Visible bounds for the direct widget-tree walk, in the coordinate
/// space active at the current recursion depth. The layout walk derives
/// the same value from retained parent links; the direct walk carries it
/// alongside the builder transform/clip stack so code paragraphs use the
/// bounded emitter through both public rendering entry points.
threadlocal var tree_visible_bounds: ?geometry.RectF = null;
pub fn emitWidgetTree(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void {
scrim_viewport = widget.frame.normalized();
tree_visible_bounds = widget.frame.normalized();
defer tree_visible_bounds = null;
try emitWidgetDepth(builder, widget, tokens, 0);
}
@@ -170,6 +179,64 @@ pub fn widgetLayoutRootBounds(layout: anytype) ?geometry.RectF {
return bounds;
}
/// Accumulated transform active while `node_index` emits. Anchored surfaces
/// are hoisted into the late top-level pass, so their original ancestors do
/// not contribute transforms there.
fn widgetLayoutNodeEmissionTransform(layout: anytype, node_index: usize) ?Affine {
if (node_index >= layout.nodes.len) return null;
var indices: [widget_layout.max_widget_depth]usize = undefined;
var len: usize = 0;
var current: ?usize = node_index;
while (current) |index| {
if (index >= layout.nodes.len or len >= indices.len) return null;
indices[len] = index;
len += 1;
if (widget_tree.widgetIsAnchored(layout.nodes[index].widget)) break;
current = layout.nodes[index].parent_index;
}
var transform = Affine.identity();
while (len > 0) {
len -= 1;
transform = transform.multiply(widgetTransform(layout.nodes[indices[len]].widget));
}
return transform;
}
/// The rectangular part of one layout node that can reach the surface,
/// returned in the node's untransformed layout coordinate space. Window
/// and ancestor clip bounds are intersected in device space, then mapped
/// back through the exact transform stack active at node emission. Code
/// paragraphs use this so transformed sources neither disappear nor lose
/// later pages while still charging only visible runs to display budgets.
fn widgetLayoutNodeVisibleBounds(layout: anytype, node_index: usize, bounds: geometry.RectF) ?geometry.RectF {
if (node_index >= layout.nodes.len) return null;
var device_visible = (widgetLayoutRootBounds(layout) orelse return null).normalized();
var current = node_index;
while (true) {
// Hoisted anchored surfaces escape their original ancestor clips,
// but the window intersection still bounds display-list demand.
if (widget_tree.widgetIsAnchored(layout.nodes[current].widget)) break;
const parent_index = layout.nodes[current].parent_index orelse break;
if (parent_index >= layout.nodes.len) return null;
const parent = layout.nodes[parent_index];
if (widgetClipsContent(parent.widget)) {
const parent_transform = widgetLayoutNodeEmissionTransform(layout, parent_index) orelse return null;
const device_clip = parent_transform.transformRect(parent.frame.normalized());
device_visible = geometry.RectF.intersection(device_visible, device_clip);
if (device_visible.isEmpty()) return null;
}
current = parent_index;
}
const transform = widgetLayoutNodeEmissionTransform(layout, node_index) orelse return null;
const inverse = transform.inverse() orelse return null;
const local_visible = inverse.transformRect(device_visible);
const clipped = geometry.RectF.intersection(bounds.normalized(), local_visible.normalized());
return if (clipped.isEmpty()) null else clipped;
}
/// The late z-pass for anchored floating surfaces: they are skipped by
/// the in-tree walk above and emitted here LAST, at the top level, so no
/// ancestor scroll/clip region crops them (window-clipped, not
@@ -199,9 +266,19 @@ fn emitWidgetDepth(builder: *Builder, widget: Widget, tokens: DesignTokens, dept
const wrap_transform = !affinesEqual(transform, Affine.identity());
const inverse_transform = if (wrap_transform) transform.inverse() orelse return error.InvalidTransform else Affine.identity();
if (wrap_opacity) try builder.pushOpacity(opacity);
if (wrap_transform) try builder.transform(transform);
try emitWidgetDepthContent(builder, widget, tokens, depth);
if (wrap_transform) try builder.transform(inverse_transform);
if (wrap_transform) {
const parent_visible_bounds = tree_visible_bounds;
defer tree_visible_bounds = parent_visible_bounds;
tree_visible_bounds = if (parent_visible_bounds) |bounds|
inverse_transform.transformRect(bounds).normalized()
else
null;
try builder.transform(transform);
try emitWidgetDepthContent(builder, widget, tokens, depth);
try builder.transform(inverse_transform);
} else {
try emitWidgetDepthContent(builder, widget, tokens, depth);
}
if (wrap_opacity) try builder.popOpacity();
}
@@ -235,7 +312,21 @@ fn emitWidgetDepthContent(builder: *Builder, widget: Widget, tokens: DesignToken
.resizable, .panel => try emitPanelWidget(builder, paint_widget, tokens, depth),
.popover => try emitPopoverWidget(builder, paint_widget, tokens, depth),
.menu_surface, .dropdown_menu => try emitMenuSurfaceWidget(builder, paint_widget, tokens, depth),
.text => try emitTextWidget(builder, paint_widget, tokens),
.text => {
if (isSyntaxCodeParagraph(paint_widget)) {
if (tree_visible_bounds) |visible_bounds| {
const clipped = geometry.RectF.intersection(
paint_widget.frame.normalized(),
visible_bounds.normalized(),
);
if (!clipped.isEmpty()) {
try emitVisibleCodeTextSpansWidget(builder, paint_widget, tokens, clipped);
}
}
} else {
try emitTextWidget(builder, paint_widget, tokens);
}
},
.icon => try emitIconWidget(builder, paint_widget, tokens),
.image => try emitImageWidget(builder, paint_widget),
.media_surface => try emitMediaSurfaceWidget(builder, paint_widget),
@@ -395,6 +486,11 @@ fn buttonGroupSegmentAt(ordinal: usize, visible_total: usize) widget_model.Widge
/// docs scene and a live app would render different bars.
fn emitButtonGroupWidget(builder: *Builder, widget: Widget, tokens: DesignTokens, depth: usize) Error!void {
if (!buttonGroupStampsSegments(widget, tokens)) return emitWidgetClippedChildren(builder, widget, tokens, depth);
const parent_visible_bounds = tree_visible_bounds;
defer tree_visible_bounds = parent_visible_bounds;
if (widget.layout.clip_content) {
tree_visible_bounds = visibleBoundsInsideClip(parent_visible_bounds, widgetContentClip(widget, tokens).rect);
}
if (widget.layout.clip_content) try builder.pushClip(widgetContentClip(widget, tokens));
var emitted: usize = 0;
var previous: ?WidgetPaintOrder = null;
@@ -579,7 +675,15 @@ fn emitWidgetLayoutNodeContent(
.resizable, .panel => try widget_render_surfaces.emitPanelWidgetChrome(builder, paint_widget, tokens),
.popover => try widget_render_surfaces.emitPopoverWidgetChrome(builder, paint_widget, tokens),
.menu_surface, .dropdown_menu => try widget_render_surfaces.emitMenuSurfaceWidgetChrome(builder, paint_widget, tokens),
.text => try emitTextWidget(builder, paint_widget, tokens),
.text => {
if (isSyntaxCodeParagraph(paint_widget)) {
if (widgetLayoutNodeVisibleBounds(layout, node_index, paint_widget.frame)) |visible_bounds| {
try emitVisibleCodeTextSpansWidget(builder, paint_widget, tokens, visible_bounds);
}
} else {
try emitTextWidget(builder, paint_widget, tokens);
}
},
.icon => try emitIconWidget(builder, paint_widget, tokens),
.image => try emitImageWidget(builder, paint_widget),
.media_surface => try emitMediaSurfaceWidget(builder, paint_widget),
@@ -896,6 +1000,9 @@ fn emitMenuSurfaceWidget(builder: *Builder, widget: Widget, tokens: DesignTokens
}
fn emitScrollViewWidget(builder: *Builder, widget: Widget, tokens: DesignTokens, depth: usize) Error!void {
const parent_visible_bounds = tree_visible_bounds;
defer tree_visible_bounds = parent_visible_bounds;
tree_visible_bounds = visibleBoundsInsideClip(parent_visible_bounds, widget.frame);
try builder.pushClip(.{ .id = widgetPartId(widget.id, 1), .rect = widget.frame });
try emitWidgetChildren(builder, widget.children, tokens, depth);
try builder.popClip();
@@ -913,9 +1020,22 @@ fn emitScrollViewWidget(builder: *Builder, widget: Widget, tokens: DesignTokens,
}
fn emitWidgetClippedChildren(builder: *Builder, widget: Widget, tokens: DesignTokens, depth: usize) Error!void {
if (widget.layout.clip_content) try builder.pushClip(widgetContentClip(widget, tokens));
if (!widget.layout.clip_content) return emitWidgetChildren(builder, widget.children, tokens, depth);
const parent_visible_bounds = tree_visible_bounds;
defer tree_visible_bounds = parent_visible_bounds;
const clip = widgetContentClip(widget, tokens);
tree_visible_bounds = visibleBoundsInsideClip(parent_visible_bounds, clip.rect);
try builder.pushClip(clip);
try emitWidgetChildren(builder, widget.children, tokens, depth);
if (widget.layout.clip_content) try builder.popClip();
try builder.popClip();
}
fn visibleBoundsInsideClip(bounds: ?geometry.RectF, clip: geometry.RectF) ?geometry.RectF {
const clipped = geometry.RectF.intersection(
(bounds orelse return null).normalized(),
clip.normalized(),
);
return if (clipped.isEmpty()) null else clipped;
}
fn widgetScrollSemantics(layout: anytype, node_index: usize) widget_semantics.WidgetScrollSemantics {
@@ -992,11 +1112,22 @@ const textWrapMaxWidth = widget_metrics.textWrapMaxWidth;
/// of a `.text` widget (plain or span paragraph). Command ids are hashed
/// per line ordinal like span runs, so retained diffing stays stable.
fn emitStaticTextSelection(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void {
return emitStaticTextSelectionBounded(builder, widget, tokens, builder.commands.len);
}
fn emitStaticTextSelectionBounded(
builder: *Builder,
widget: Widget,
tokens: DesignTokens,
command_ceiling: usize,
) Error!void {
if (builder.len >= command_ceiling) return;
const range = widget_access.widgetTextSelectionRange(widget) orelse return;
if (range.isCollapsed(widget.text.len)) return;
var rect_buffer: [widget_text_select.max_static_text_selection_rects]text_model.TextSelectionRect = undefined;
const rects = widget_text_select.staticTextSelectionRects(widget, tokens, range, &rect_buffer);
for (rects, 0..) |selection, ordinal| {
if (builder.len >= command_ceiling) break;
try builder.fillRoundedRect(.{
.id = textSelectionCommandId(widget.id, ordinal),
.rect = pixelSnapGeometryRect(tokens, selection.rect),
@@ -1011,14 +1142,20 @@ fn emitStaticTextSelection(builder: *Builder, widget: Widget, tokens: DesignToke
/// decorations get stable hashed command ids derived from the widget id
/// and their ordinal, so retained diffing works across frames.
fn emitTextSpansWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void {
const content = widget.frame.inset(widget.layout.padding);
const content = widget_metrics.widgetTextSpanContentFrame(widget, tokens);
const layout_options = widget_metrics.widgetTextSpanLayoutOptions(
widget,
tokens,
textWrapMaxWidth(tokens, content.width),
);
var runs: [text_spans_model.max_text_span_runs_per_paragraph]text_spans_model.TextSpanRun = undefined;
const layout = text_spans_model.layoutTextSpans(
widget.spans,
widget_metrics.widgetTextSpanLayoutOptions(widget, tokens, textWrapMaxWidth(tokens, content.width)),
layout_options,
&runs,
);
try emitCodeLineNumberGutter(builder, widget, tokens, content, widget.frame, layout_options, null);
// Span background highlights (intra-line diff emphasis): one
// full-line-height rect per run, the same geometry selection rects
// use, painted before selection and glyphs. Edge-snapped rects of
@@ -1109,6 +1246,314 @@ fn emitTextSpansWidget(builder: *Builder, widget: Widget, tokens: DesignTokens)
}
}
fn isSyntaxColor(color: text_spans_model.TextSpanColor) bool {
return switch (color) {
.syntax_plain,
.syntax_comment,
.syntax_keyword,
.syntax_literal,
.syntax_function,
.syntax_property,
.syntax_constant,
=> true,
else => false,
};
}
/// `Ui.code` lowers to ordinary text widgets whose spans are all monospace
/// syntax-token runs. Keep that marker structural instead of adding another
/// public widget kind solely for an emission optimization.
fn isSyntaxCodeParagraph(widget: Widget) bool {
if (widget.kind != .text or widget.spans.len == 0) return false;
for (widget.spans) |span| {
if (!span.monospace or
!isSyntaxColor(span.color orelse return false) or
span.background != null or
span.underline or
span.strikethrough or
span.link.len != 0)
{
return false;
}
}
return true;
}
// A code surface shares the frame's fixed display-list stores with every
// widget emitted around it. Hold back the same practical tail as the
// terminal painter: enough commands and referenced text for trailing
// siblings, transform/clip epilogues, and window chrome. Small direct
// builders retain seven eighths of their capacity so focused unit tests
// remain representative while still leaving their enclosing epilogue room.
const code_widget_command_reserve: usize = 256;
const code_widget_text_reserve: usize = 8192;
const CodeEmissionBudget = struct {
command_ceiling: usize,
text_ceiling: usize,
text_total: usize,
fn init(builder: *const Builder) CodeEmissionBudget {
const command_reserve = @min(
code_widget_command_reserve,
@max(@as(usize, 1), builder.commands.len / 8),
);
return .{
.command_ceiling = builder.commands.len -| command_reserve,
.text_ceiling = canvas.max_display_list_text_bytes -| code_widget_text_reserve,
.text_total = displayListTextBytes(builder.displayList()),
};
}
fn hasCommand(self: CodeEmissionBudget, builder: *const Builder) bool {
return builder.len < self.command_ceiling;
}
fn remainingText(self: CodeEmissionBudget) usize {
return self.text_ceiling -| self.text_total;
}
fn chargeText(self: *CodeEmissionBudget, len: usize) void {
self.text_total += len;
}
};
fn displayListTextBytes(list: canvas.DisplayList) usize {
var total: usize = 0;
for (list.commands) |command| {
if (command == .draw_text) total += command.draw_text.text.len;
}
return total;
}
/// Keep a text-budget truncation on a UTF-8 scalar boundary. Code source
/// accepts arbitrary bytes, so invalid leading/continuation bytes simply
/// degrade to the maximal prefix the display store can retain.
fn codeTextPrefix(text: []const u8, max_len: usize) []const u8 {
var end = @min(text.len, max_len);
while (end > 0 and end < text.len and text[end] & 0xc0 == 0x80) end -= 1;
return text[0..end];
}
/// Viewport-aware code emission: the full source remains in retained
/// paragraph widgets for layout, selection, copy, and scroll extents, while
/// the display list contains only line runs that intersect the current
/// window/scroll clip. Long no-wrap runs are sliced horizontally as well.
fn emitVisibleCodeTextSpansWidget(
builder: *Builder,
widget: Widget,
tokens: DesignTokens,
visible_bounds: geometry.RectF,
) Error!void {
const content = widget_metrics.widgetTextSpanContentFrame(widget, tokens);
const layout_options = widget_metrics.widgetTextSpanLayoutOptions(
widget,
tokens,
textWrapMaxWidth(tokens, content.width),
);
const line_height = text_spans_model.textSpanLineHeight(widget.spans, layout_options);
const visible_line: usize = if (line_height > 0 and
std.math.isFinite(line_height) and
std.math.isFinite(visible_bounds.y - content.y) and
visible_bounds.y > content.y)
@intFromFloat(@floor((visible_bounds.y - content.y) / line_height))
else
0;
const last_visible_line: usize = if (line_height > 0 and
std.math.isFinite(line_height) and
std.math.isFinite(visible_bounds.maxY() - content.y) and
visible_bounds.maxY() > content.y)
@intFromFloat(@floor((visible_bounds.maxY() - content.y) / line_height))
else
visible_line;
var runs: [text_spans_model.max_text_span_runs_per_paragraph]text_spans_model.TextSpanRun = undefined;
var budget = CodeEmissionBudget.init(builder);
if (!budget.hasCommand(builder)) return;
try emitCodeLineNumberGutter(builder, widget, tokens, content, visible_bounds, layout_options, &budget);
try emitStaticTextSelectionBounded(builder, widget, tokens, budget.command_ceiling);
if (!budget.hasCommand(builder) or budget.remainingText() == 0) return;
var page_first_line = visible_line -| 1;
while (true) {
const layout = text_spans_model.layoutTextSpansFromLine(
widget.spans,
layout_options,
page_first_line,
&runs,
);
for (layout.runs) |run| {
if (run.text.len == 0) continue;
const local_bounds = text_spans_model.textSpanRunBounds(layout, run);
const run_bounds = geometry.RectF.init(
content.x + local_bounds.x,
content.y + local_bounds.y,
local_bounds.width,
local_bounds.height,
);
if (!run_bounds.intersects(visible_bounds)) continue;
const span = widget.spans[run.span_index];
const absolute_x = content.x + run.x;
const visible = text_spans_model.textSpanRunVisibleSlice(
span,
run,
layout_options,
visible_bounds.x - absolute_x,
visible_bounds.maxX() - absolute_x,
) orelse continue;
if (!budget.hasCommand(builder)) return;
const admitted_text = codeTextPrefix(visible.text, budget.remainingText());
if (admitted_text.len == 0) return;
const color = text_spans_model.textSpanColorValue(tokens.colors, span.color.?);
const origin = pixelSnapTextPoint(tokens, geometry.PointF.init(absolute_x + visible.x, content.y + run.baseline));
try builder.drawText(.{
.id = codeTextSpanRunCommandId(widget, run),
.font_id = run.font_id,
.size = run.size,
.origin = origin,
.color = color,
.text = admitted_text,
.text_layout = .{
.max_width = 0,
.line_height = layout.line_height,
.wrap = .none,
.alignment = .start,
.measure = tokens.text_measure,
},
});
budget.chargeText(admitted_text.len);
}
const next_first_line = page_first_line +| text_spans_model.max_text_span_lines_per_paragraph;
if (next_first_line <= page_first_line or
next_first_line > last_visible_line or
next_first_line >= layout.line_count)
{
break;
}
page_first_line = next_first_line;
}
}
/// Muted logical-line markers for one coherent code paragraph. Each
/// logical line is measured independently with the same monospace layout
/// options; summing those wrapped extents places the next marker on the
/// exact first visual line occupied by its source.
fn emitCodeLineNumberGutter(
builder: *Builder,
widget: Widget,
tokens: DesignTokens,
content: geometry.RectF,
visible_bounds: geometry.RectF,
layout_options: text_spans_model.TextSpanLayoutOptions,
budget: ?*CodeEmissionBudget,
) Error!void {
if (widget.code_line_number_digits == 0) return;
const line_height = text_spans_model.textSpanLineHeight(widget.spans, layout_options);
if (line_height <= 0 or !std.math.isFinite(line_height)) return;
const padded = widget.frame.inset(widget.layout.padding);
const marker_width = @max(0, content.x - 12 - padded.x);
const digits = @min(@as(usize, widget.code_line_number_digits), 20);
var line_runs: [text_spans_model.max_text_span_runs_per_paragraph]text_spans_model.TextSpanRun = undefined;
var logical_line: usize = 1;
var visual_line: usize = 0;
var line_start: usize = 0;
while (line_start <= widget.text.len) : (logical_line += 1) {
// A terminal newline closes the preceding painted line; the span
// breaker intentionally does not reserve another empty visual line.
if (line_start == widget.text.len and widget.text.len > 0) break;
const newline = std.mem.indexOfScalarPos(u8, widget.text, line_start, '\n');
const line_end = newline orelse widget.text.len;
const baseline = content.y + layout_options.size +
@as(f32, @floatFromInt(visual_line)) * line_height;
const marker_bounds = geometry.RectF.init(
padded.x,
baseline - layout_options.size,
marker_width,
line_height,
);
if (marker_bounds.intersects(visible_bounds)) {
const marker_text = codeLineNumberText(logical_line, digits);
if (budget) |admission| {
if (!admission.hasCommand(builder)) return;
if (marker_text.len > admission.remainingText()) return;
}
try builder.drawText(.{
.id = codeLineNumberCommandId(widget.id, logical_line),
.font_id = tokens.typography.mono_font_id,
.size = layout_options.size,
.origin = pixelSnapTextPoint(tokens, geometry.PointF.init(padded.x, baseline)),
.color = tokens.colors.text_muted,
.text = marker_text,
.text_layout = .{
.max_width = marker_width,
.line_height = line_height,
.wrap = .none,
.alignment = .end,
.measure = tokens.text_measure,
},
});
if (budget) |admission| admission.chargeText(marker_text.len);
}
const line = widget.text[line_start..line_end];
if (line.len == 0) {
visual_line += 1;
} else {
const line_spans = [_]text_spans_model.TextSpan{.{
.text = line,
.monospace = true,
.color = .syntax_plain,
}};
const line_layout = text_spans_model.layoutTextSpans(
&line_spans,
layout_options,
&line_runs,
);
visual_line += @max(1, line_layout.line_count);
}
if (newline == null) break;
line_start = line_end + 1;
}
}
const code_line_number_texts = blk: {
var values: [128][3]u8 = @splat(@splat(' '));
for (&values, 1..) |*text, line_number| {
var value = line_number;
var cursor: usize = text.len;
while (cursor > 0 and value > 0) {
cursor -= 1;
text[cursor] = '0' + @as(u8, @intCast(value % 10));
value /= 10;
}
}
break :blk values;
};
fn codeLineNumberText(logical_line: usize, digits: usize) []const u8 {
if (logical_line == 0 or logical_line > code_line_number_texts.len) return "";
const text = &code_line_number_texts[logical_line - 1];
const len = @min(digits, text.len);
return text[text.len - len ..];
}
fn codeTextSpanRunCommandId(widget: Widget, run: text_spans_model.TextSpanRun) ObjectId {
const range = text_spans_model.textSpanRunParagraphRange(widget.text, run) orelse
text_model.TextRange.init(0, run.text.len);
var hasher = std.hash.Wyhash.init(0x5eed_59a2_0000_0011);
hasher.update(std.mem.asBytes(&widget.id));
hasher.update(std.mem.asBytes(&run.line_index));
hasher.update(std.mem.asBytes(&range.start));
const value = hasher.final();
return if (value == 0) 1 else value;
}
fn codeLineNumberCommandId(widget_id: ObjectId, logical_line: usize) ObjectId {
return textSpanCommandId(0x5eed_59a2_0000_0012, widget_id, logical_line);
}
pub fn textSpanRunCommandId(widget_id: ObjectId, ordinal: usize) ObjectId {
return textSpanCommandId(0x5eed_59a2_0000_0001, widget_id, ordinal);
}
+2 -2
View File
@@ -57,7 +57,7 @@ pub fn staticTextSelectionForWidgetPoint(
pub fn staticTextOffsetForWidgetPoint(widget: Widget, point: geometry.PointF, tokens: DesignTokens) ?usize {
if (!widgetStaticTextSelectable(widget)) return null;
if (widget.spans.len > 0) {
const content = widget.frame.inset(widget.layout.padding);
const content = widget_metrics.widgetTextSpanContentFrame(widget, tokens);
const options = widget_metrics.widgetTextSpanLayoutOptions(widget, tokens, content.width);
return text_spans_model.textSpanOffsetForPoint(
widget.text,
@@ -82,7 +82,7 @@ pub fn staticTextSelectionRects(
) []const TextSelectionRect {
if (widget.kind != .text or widget.text.len == 0) return output[0..0];
if (widget.spans.len > 0) {
const content = widget.frame.inset(widget.layout.padding);
const content = widget_metrics.widgetTextSpanContentFrame(widget, tokens);
const options = widget_metrics.widgetTextSpanLayoutOptions(widget, tokens, content.width);
const rects = text_spans_model.textSpanSelectionRects(widget.text, widget.spans, options, range, output);
for (output[0..rects.len]) |*rect| {
+13
View File
@@ -863,6 +863,19 @@ pub const Widget = struct {
/// tooling and write-back can round-trip it. Span paragraphs
/// (`spans`) wrap by design and ignore it.
text_no_wrap: bool = false,
/// Renderer-owned logical-line gutter for a syntax-code paragraph.
/// Zero keeps an ordinary paragraph; a positive value is the decimal
/// digit width of the largest marker. The gutter is decoration, not
/// retained text, so selection/copy remains the exact source bytes.
/// Stamped only by `Ui.code`; there is no generic builder/markup
/// channel for turning arbitrary paragraphs into numbered code.
code_line_number_digits: u8 = 0,
/// Nonzero on bounded paragraph chunks that together present one
/// selectable source-code document. The group id is the structural id
/// of their internal parent; offsets order each chunk's exact source
/// bytes without duplicating the full source in retained storage.
static_text_group_id: ObjectId = 0,
static_text_group_offset: usize = 0,
/// What a single-line text run does with content that does not fit
/// its frame (`ElementOptions.overflow` / markup `overflow=` on
/// text leaves): `.ellipsis` (default) elides the tail behind a
+1
View File
@@ -6,6 +6,7 @@ pub const trace = @import("trace");
pub const diagnostics = @import("diagnostics");
pub const platform_info = @import("platform_info");
pub const canvas = @import("canvas");
pub const code = canvas.code;
pub const markdown = canvas.markdown;
pub const runtime = @import("runtime/root.zig");
@@ -501,6 +501,90 @@ test "static text drag selection highlights, copies, and clears" {
try harness.runtime.dispatchPlatformEvent(app, pointerInput(.pointer_up, 211, 75));
}
test "grouped code paragraphs select and copy across chunk boundaries" {
var app_state: ClipboardTestApp = .{};
const app = app_state.app();
const harness = try createClipboardHarness(app);
defer harness.destroy(std.testing.allocator);
const first = "alpha\r\n";
const second = "beta";
const first_spans = [_]canvas.TextSpan{.{
.text = first,
.monospace = true,
.color = .syntax_plain,
}};
const second_spans = [_]canvas.TextSpan{.{
.text = second,
.monospace = true,
.color = .syntax_plain,
}};
const group_id: canvas.ObjectId = 77;
const children = [_]canvas.Widget{
.{
.id = 2,
.kind = .text,
.frame = geometry.RectF.init(12, 16, 220, 40),
.text = first,
.spans = &first_spans,
.static_text_group_id = group_id,
.static_text_group_offset = 0,
},
.{
.id = 3,
.kind = .text,
.frame = geometry.RectF.init(12, 64, 220, 40),
.text = second,
.spans = &second_spans,
.static_text_group_id = group_id,
.static_text_group_offset = first.len,
},
.{
.id = 4,
.kind = .button,
.frame = geometry.RectF.init(12, 128, 100, 32),
.text = "Clear",
},
};
var nodes: [4]canvas.WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(
.{ .kind = .stack, .children = &children },
geometry.RectF.init(0, 0, 320, 200),
&nodes,
);
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout);
try harness.runtime.dispatchPlatformEvent(app, pointerInput(.pointer_down, 13, 18));
try harness.runtime.dispatchPlatformEvent(app, pointerInput(.pointer_drag, 231, 103));
var retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectEqualDeep(
canvas.TextSelection{ .anchor = 0, .focus = first.len },
retained.findById(2).?.widget.text_selection.?,
);
try std.testing.expectEqualDeep(
canvas.TextSelection{ .anchor = 0, .focus = second.len },
retained.findById(3).?.widget.text_selection.?,
);
try harness.runtime.dispatchPlatformEvent(app, keyInput("c", cmd));
var clipboard_buffer: [32]u8 = undefined;
try std.testing.expectEqualStrings(
"alpha\r\nbeta",
try harness.runtime.readClipboard(&clipboard_buffer),
);
// Every selected chunk survives an unchanged rebuild, and pressing
// elsewhere clears the whole grouped highlight.
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout);
retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expect(retained.findById(2).?.widget.text_selection != null);
try std.testing.expect(retained.findById(3).?.widget.text_selection != null);
try harness.runtime.dispatchPlatformEvent(app, pointerInput(.pointer_down, 20, 140));
retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expect(retained.findById(2).?.widget.text_selection == null);
try std.testing.expect(retained.findById(3).?.widget.text_selection == null);
}
test "span paragraph drag selection copies the concatenated bytes" {
var app_state: ClipboardTestApp = .{};
const app = app_state.app();
@@ -539,6 +623,52 @@ test "span paragraph drag selection copies the concatenated bytes" {
try std.testing.expectEqualStrings(paragraph, copied);
}
test "numbered code selection copies source without decorative gutter digits" {
var app_state: ClipboardTestApp = .{};
const app = app_state.app();
const harness = try createClipboardHarness(app);
defer harness.destroy(std.testing.allocator);
const source = "alpha\nbeta";
const spans = [_]canvas.TextSpan{.{
.text = source,
.monospace = true,
.color = .syntax_plain,
}};
const children = [_]canvas.Widget{.{
.id = 2,
.kind = .text,
.frame = geometry.RectF.init(12, 16, 220, 60),
.text = source,
.spans = &spans,
.code_line_number_digits = 1,
}};
var nodes: [2]canvas.WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(
.{ .kind = .stack, .children = &children },
geometry.RectF.init(0, 0, 320, 200),
&nodes,
);
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout);
// Start inside the source column (past the renderer-owned gutter) and
// drag beyond the final visual line.
try harness.runtime.dispatchPlatformEvent(app, pointerInput(.pointer_down, 34, 18));
try harness.runtime.dispatchPlatformEvent(app, pointerInput(.pointer_drag, 231, 75));
const retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
try std.testing.expectEqualDeep(
canvas.TextSelection{ .anchor = 0, .focus = source.len },
retained.nodes[1].widget.text_selection.?,
);
try harness.runtime.dispatchPlatformEvent(app, keyInput("c", cmd));
var clipboard_buffer: [32]u8 = undefined;
try std.testing.expectEqualStrings(
source,
try harness.runtime.readClipboard(&clipboard_buffer),
);
}
test "focused editable selection wins copy over a stale static selection" {
var app_state: ClipboardTestApp = .{};
const app = app_state.app();
+12 -2
View File
@@ -30,6 +30,7 @@ const geometry = @import("geometry");
const canvas = @import("canvas");
const platform = @import("../platform/root.zig");
const runtime_api = @import("api.zig");
const canvas_limits = @import("canvas_limits.zig");
const canvas_widget_runtime = @import("canvas_widget_runtime.zig");
const runtime_canvas_widget_events = @import("canvas_widget_events.zig");
@@ -225,7 +226,15 @@ pub fn RuntimeCanvasWidgetContextMenu(comptime Runtime: type) type {
// 4. Static text with a live selection: Copy only.
const selected_id = self.views[index].canvas_widget_selected_text_id;
if (selected_id != 0 and selected_id == target.id) {
const selected_group_target = if (selected_id != 0 and selected_id != target.id) blk: {
const selected_index = self.views[index].canvasWidgetNodeIndexById(selected_id) orelse break :blk false;
const target_index = self.views[index].canvasWidgetNodeIndexById(target.id) orelse break :blk false;
const selected_widget = self.views[index].widget_layout_nodes[selected_index].widget;
const target_widget = self.views[index].widget_layout_nodes[target_index].widget;
break :blk selected_widget.static_text_group_id != 0 and
selected_widget.static_text_group_id == target_widget.static_text_group_id;
} else false;
if (selected_id != 0 and (selected_id == target.id or selected_group_target)) {
if (!has_presenter) return;
items[0] = .{ .id = default_item_copy, .label = "Copy" };
_ = try showMenu(self, app, index, .{
@@ -389,7 +398,8 @@ pub fn RuntimeCanvasWidgetContextMenu(comptime Runtime: type) type {
.terminal => try applyDefaultTerminalAction(self, app, index, pending.target_id, event.item_id),
.static_copy => {
if (event.item_id != default_item_copy) return;
const text = self.views[index].canvasWidgetCopyText() orelse return;
var group_buffer: [canvas_limits.max_canvas_widget_text_bytes_per_view]u8 = undefined;
const text = self.views[index].canvasWidgetCopyText(&group_buffer) orelse return;
self.writeClipboard(text) catch return;
},
}
+7 -1
View File
@@ -103,6 +103,12 @@ pub fn RuntimeCanvasWidgetEvents(comptime Runtime: type) type {
}
if (raw.kind != .text) return press_target;
if (self.views[view_index].canvas_widget_selected_text_id != raw.id) return press_target;
if (self.views[view_index].canvas_widget_selected_text_group_id != 0 and
self.views[view_index].canvas_widget_selected_text_group_anchor !=
self.views[view_index].canvas_widget_selected_text_group_focus)
{
return null;
}
const node = self.views[view_index].widgetLayoutTree().findById(raw.id) orelse return press_target;
const selection = node.widget.text_selection orelse return press_target;
if (selection.isCollapsed(node.widget.text.len)) return press_target;
@@ -1952,7 +1958,7 @@ pub fn RuntimeCanvasWidgetEvents(comptime Runtime: type) type {
switch (action) {
.copy => {
const text = self.views[index].canvasWidgetCopyText() orelse return;
const text = self.views[index].canvasWidgetCopyText(paste_buffer) orelse return;
self.writeClipboard(text) catch return;
},
.cut => {
+7 -2
View File
@@ -149,6 +149,7 @@ pub const CanvasWidgetTextReconcileEntry = struct {
id: canvas.ObjectId = 0,
kind: canvas.WidgetKind = .text_field,
text: []const u8 = &.{},
static_text_group_id: canvas.ObjectId = 0,
source_text_len: usize = 0,
source_text_hash: u64 = 0,
/// The selection declared by the previous SOURCE tree, distinct from
@@ -604,6 +605,7 @@ pub fn collectCanvasWidgetTextReconcileEntries(
.id = node.widget.id,
.kind = node.widget.kind,
.text = text_storage[text_range.start..text_range.end],
.static_text_group_id = node.widget.static_text_group_id,
.source_text_len = source_text.len,
.source_text_hash = source_text.hash,
.source_text_selection = if (source_entry) |entry| entry.text_selection else null,
@@ -783,9 +785,12 @@ pub fn canvasWidgetLayoutNodeWithTextReconcileState(
if (copy.widget.kind == .text) {
// Static text selections survive rebuilds only while the source
// text is byte-identical; changed text drops the selection.
// text and its optional grouped document are byte-identical.
if (previous.firstWithKind(copy.widget.id, copy.widget.kind)) |entry| {
if (copy.widget.text_selection == null and std.mem.eql(u8, entry.text, copy.widget.text)) {
if (copy.widget.text_selection == null and
std.mem.eql(u8, entry.text, copy.widget.text) and
entry.static_text_group_id == copy.widget.static_text_group_id)
{
copy.widget.text_selection = entry.text_selection;
}
}
+57
View File
@@ -3744,6 +3744,63 @@ test "a widget text budget overflow on input degrades instead of exiting" {
} });
}
test "maximal numbered code fits the retained text budget" {
const TestApp = struct {
fn app(self: *@This()) App {
return .{ .context = self, .name = "gpu-widget-numbered-code-budget", .source = platform.WebViewSource.html("<h1>Hello</h1>") };
}
};
const harness = try TestHarness().create(std.testing.allocator, .{});
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
var app_state: TestApp = .{};
try harness.start(app_state.app());
_ = try harness.runtime.createView(.{
.window_id = 1,
.label = "canvas",
.kind = .gpu_surface,
.frame = geometry.RectF.init(0, 0, 320, 100),
});
const source = try std.testing.allocator.alloc(
u8,
runtime_module.max_canvas_widget_text_bytes_per_view,
);
defer std.testing.allocator.free(source);
@memset(source, 'x');
const CodeUi = canvas.Ui(enum { noop });
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
var ui = CodeUi.init(arena.allocator());
const tree = try ui.finalize(ui.code(.{
.language = .plain,
.line_numbers = true,
.wrap = false,
.width = 320,
.height = 100,
}, source));
var nodes: [16]canvas.WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(
tree.root,
geometry.RectF.init(0, 0, 320, 100),
&nodes,
);
_ = try harness.runtime.setCanvasWidgetLayout(1, "canvas", layout);
const retained = try harness.runtime.canvasWidgetLayout(1, "canvas");
var found_source = false;
for (retained.nodes) |node| {
if (node.widget.code_line_number_digits == 0) continue;
found_source = true;
try std.testing.expectEqual(@as(u8, 1), node.widget.code_line_number_digits);
try std.testing.expectEqual(source.len, node.widget.text.len);
try std.testing.expectEqualStrings(source, node.widget.text);
}
try std.testing.expect(found_source);
}
/// Scan the widget's frame for a pointer location whose caret offset is
/// exactly `target`, so multi-click tests aim at text offsets without
/// hard-coding font metrics.
+7 -3
View File
@@ -688,10 +688,14 @@ pub const RuntimeView = struct {
/// updateCanvasWidgetInteractionFromPointer), so a pointer gliding
/// within one sample repaints nothing.
canvas_widget_hover_point: ?geometry.PointF = null,
/// The static `.text` widget owning the view's active click-drag
/// selection (0 = none). One static selection per view; starting a
/// selection elsewhere (or pressing anywhere else) clears it.
/// The static `.text` widget where the view's active click-drag
/// selection began (0 = none). Ordinary paragraphs keep their range
/// on that widget. Bounded code paragraphs additionally share the
/// global group range below so one gesture can cross internal chunks.
canvas_widget_selected_text_id: canvas.ObjectId = 0,
canvas_widget_selected_text_group_id: canvas.ObjectId = 0,
canvas_widget_selected_text_group_anchor: usize = 0,
canvas_widget_selected_text_group_focus: usize = 0,
/// Multi-click chain state for the double/triple-click text
/// gestures. The runtime derives a click count from consecutive
/// primary pointer-downs (recorded timestamps within the interval,
+194 -4
View File
@@ -1061,11 +1061,19 @@ pub fn RuntimeViewCanvasWidgetText(comptime RuntimeView: type) type {
/// Click-drag selection inside one static `.text` widget. Press
/// collapses at the hit offset, drag extends from the press
/// anchor. Cross-widget selection is out of scope: the selection
/// model is the widget's own `text_selection` — there is no
/// document model ordering text across widgets to extend into.
/// anchor. Ordinary widgets remain independently selectable;
/// bounded code paragraphs opt into one ordered source group.
fn applyCanvasWidgetStaticTextPointer(self: *RuntimeView, index: usize, target_id: canvas.ObjectId, point: geometry.PointF, extend: bool) anyerror!?geometry.RectF {
const widget = self.widget_layout_nodes[index].widget;
if (widget.static_text_group_id != 0) {
return applyCanvasWidgetStaticTextGroupPointer(
self,
target_id,
point,
extend,
widget.static_text_group_id,
);
}
if (extend and self.canvas_widget_selected_text_id != target_id) return null;
const current_selection = widget.text_selection orelse canvas.TextSelection.collapsed(0);
const anchor: ?usize = if (extend) current_selection.anchor else null;
@@ -1074,11 +1082,130 @@ pub fn RuntimeViewCanvasWidgetText(comptime RuntimeView: type) type {
self.widget_layout_nodes[index].widget.text_selection = next_selection;
self.canvas_widget_selected_text_id = target_id;
self.canvas_widget_selected_text_group_id = 0;
self.canvas_widget_selected_text_group_anchor = 0;
self.canvas_widget_selected_text_group_focus = 0;
try self.refreshCanvasWidgetSemantics();
self.widget_revision += 1;
return self.canvasWidgetDirtyBounds(index, widget.frame);
}
fn applyCanvasWidgetStaticTextGroupPointer(
self: *RuntimeView,
target_id: canvas.ObjectId,
point: geometry.PointF,
extend: bool,
group_id: canvas.ObjectId,
) anyerror!?geometry.RectF {
if (extend and
(self.canvas_widget_selected_text_id != target_id or
self.canvas_widget_selected_text_group_id != group_id))
{
return null;
}
var point_index: ?usize = null;
var point_offset: usize = 0;
var best_distance = std.math.inf(f32);
const layout = self.widgetLayoutTree();
for (self.widget_layout_nodes[0..self.widget_layout_node_count], 0..) |node, candidate_index| {
const candidate = node.widget;
if (candidate.static_text_group_id != group_id or
canvasWidgetLayoutNodeHidden(layout, candidate_index) or
!canvas.widgetStaticTextSelectable(candidate))
{
continue;
}
const frame = candidate.frame.normalized();
const dx = if (point.x < frame.x)
frame.x - point.x
else if (point.x > frame.maxX())
point.x - frame.maxX()
else
0;
const dy = if (point.y < frame.y)
frame.y - point.y
else if (point.y > frame.maxY())
point.y - frame.maxY()
else
0;
const distance = dx * dx + dy * dy;
if (distance > best_distance) continue;
const local = canvas.staticTextSelectionForWidgetPoint(
candidate,
point,
null,
self.widget_tokens,
) orelse continue;
if (distance == best_distance and point_index != null) continue;
best_distance = distance;
point_index = candidate_index;
point_offset = local.focus;
}
const selected_index = point_index orelse return null;
const selected_widget = self.widget_layout_nodes[selected_index].widget;
const focus = selected_widget.static_text_group_offset + point_offset;
const anchor = if (extend)
self.canvas_widget_selected_text_group_anchor
else
focus;
const selection_start = @min(anchor, focus);
const selection_end = @max(anchor, focus);
var dirty: ?geometry.RectF = null;
var changed = false;
for (self.widget_layout_nodes[0..self.widget_layout_node_count], 0..) |*node, candidate_index| {
const candidate = node.widget;
if (candidate.static_text_group_id != group_id) {
continue;
}
const source_start = candidate.static_text_group_offset;
const source_end = source_start +| candidate.text.len;
const next_selection: ?canvas.TextSelection = if (selection_start == selection_end)
if (candidate_index == selected_index)
canvas.TextSelection.collapsed(point_offset)
else
null
else if (@max(selection_start, source_start) < @min(selection_end, source_end))
.{
.anchor = @max(selection_start, source_start) - source_start,
.focus = @min(selection_end, source_end) - source_start,
}
else
null;
const current_selection = candidate.text_selection;
const same = if (current_selection) |current|
if (next_selection) |next|
canvasTextSelectionsEqual(current, next)
else
false
else
next_selection == null;
if (same) continue;
node.widget.text_selection = next_selection;
changed = true;
dirty = unionRects(
dirty,
self.canvasWidgetDirtyBounds(candidate_index, candidate.frame),
);
}
if (!changed and
self.canvas_widget_selected_text_id == target_id and
self.canvas_widget_selected_text_group_anchor == anchor and
self.canvas_widget_selected_text_group_focus == focus)
{
return null;
}
self.canvas_widget_selected_text_id = target_id;
self.canvas_widget_selected_text_group_id = group_id;
self.canvas_widget_selected_text_group_anchor = anchor;
self.canvas_widget_selected_text_group_focus = focus;
try self.refreshCanvasWidgetSemantics();
self.widget_revision += 1;
return dirty;
}
/// Drop the view's static text selection (pointer pressed
/// elsewhere, or the copy source went away). Returns the dirty
/// bounds of the widget that lost its highlight.
@@ -1086,6 +1213,31 @@ pub fn RuntimeViewCanvasWidgetText(comptime RuntimeView: type) type {
const id = self.canvas_widget_selected_text_id;
if (id == 0) return null;
self.canvas_widget_selected_text_id = 0;
const group_id = self.canvas_widget_selected_text_group_id;
self.canvas_widget_selected_text_group_id = 0;
self.canvas_widget_selected_text_group_anchor = 0;
self.canvas_widget_selected_text_group_focus = 0;
if (group_id != 0) {
var dirty: ?geometry.RectF = null;
var changed = false;
for (self.widget_layout_nodes[0..self.widget_layout_node_count], 0..) |*node, index| {
if (node.widget.static_text_group_id != group_id or
node.widget.text_selection == null)
{
continue;
}
node.widget.text_selection = null;
changed = true;
dirty = unionRects(
dirty,
self.canvasWidgetDirtyBounds(index, node.widget.frame),
);
}
if (!changed) return null;
try self.refreshCanvasWidgetSemantics();
self.widget_revision += 1;
return dirty;
}
const index = self.canvasWidgetNodeIndexById(id) orelse return null;
if (self.widget_layout_nodes[index].widget.text_selection == null) return null;
self.widget_layout_nodes[index].widget.text_selection = null;
@@ -1098,7 +1250,7 @@ pub fn RuntimeViewCanvasWidgetText(comptime RuntimeView: type) type {
/// focused editable widget's selection or focused terminal's
/// emulator selection when it has one, else the view's static
/// text selection.
pub fn canvasWidgetCopyText(self: *const RuntimeView) ?[]const u8 {
pub fn canvasWidgetCopyText(self: *const RuntimeView, group_buffer: []u8) ?[]const u8 {
if (self.canvas_widget_focused_id != 0) {
if (self.canvasWidgetNodeIndexById(self.canvas_widget_focused_id)) |index| {
const focused = self.widget_layout_nodes[index].widget;
@@ -1111,11 +1263,49 @@ pub fn RuntimeViewCanvasWidgetText(comptime RuntimeView: type) type {
if (canvasWidgetSelectionSliceById(self, self.canvas_widget_focused_id, true)) |slice| return slice;
}
if (self.canvas_widget_selected_text_id != 0) {
if (self.canvas_widget_selected_text_group_id != 0) {
return canvasWidgetStaticTextGroupCopy(self, group_buffer);
}
if (canvasWidgetSelectionSliceById(self, self.canvas_widget_selected_text_id, false)) |slice| return slice;
}
return null;
}
fn canvasWidgetStaticTextGroupCopy(self: *const RuntimeView, output: []u8) ?[]const u8 {
const group_id = self.canvas_widget_selected_text_group_id;
if (group_id == 0) return null;
const start = @min(
self.canvas_widget_selected_text_group_anchor,
self.canvas_widget_selected_text_group_focus,
);
const end = @max(
self.canvas_widget_selected_text_group_anchor,
self.canvas_widget_selected_text_group_focus,
);
if (start >= end or end - start > output.len) return null;
var copied: usize = 0;
for (self.widget_layout_nodes[0..self.widget_layout_node_count]) |node| {
const widget = node.widget;
if (widget.static_text_group_id != group_id) {
continue;
}
const source_start = widget.static_text_group_offset;
const source_end = source_start +| widget.text.len;
const copy_start = @max(start, source_start);
const copy_end = @min(end, source_end);
if (copy_start >= copy_end) continue;
const destination_start = copy_start - start;
const len = copy_end - copy_start;
@memcpy(
output[destination_start..][0..len],
widget.text[copy_start - source_start ..][0..len],
);
copied += len;
}
if (copied != end - start) return null;
return output[0 .. end - start];
}
fn canvasWidgetSelectionSliceById(self: *const RuntimeView, id: canvas.ObjectId, editable_only: bool) ?[]const u8 {
const index = self.canvasWidgetNodeIndexById(id) orelse return null;
const widget = self.widget_layout_nodes[index].widget;
+5 -2
View File
@@ -36,8 +36,11 @@ scroll-transcript 5000
# regression lands it at several hundred us).
chart-tick 200
# markdown-doc-edit: guards markdown re-render + plan cost on a
# README-sized doc; the pre-fix number was ~2400.
markdown-doc-edit 1500
# README-sized doc. Theme-token syntax highlighting emits separate text
# runs for fenced-code tokens; healthy p50 is ~1590-1630 with fourteen
# highlighted fences (plain-fence baseline ~1370), while the pre-fix
# renderer was ~2400.
markdown-doc-edit 1800
# keystroke-measured-text: guards the batched measure seam and its
# retained caches on the provider path (a counting synthetic provider
# stands in for CoreText). Healthy p50 ~540-660 across calibration runs.
+2
View File
@@ -192,6 +192,8 @@ fn writeVocabJson(gpa: std.mem.Allocator, io: std.Io, path: []const u8) !void {
try js.beginObject();
try js.objectField("markdown");
try writeDocList(&js, &markup_docs.markdown_attr_docs);
try js.objectField("code");
try writeDocList(&js, &markup_docs.code_attr_docs);
try js.objectField("stepper");
try writeDocList(&js, &markup_docs.stepper_attr_docs);
try js.objectField("timeline");
+40
View File
@@ -171,6 +171,7 @@ pub const scenes = [_]Scene{
.{ .name = "resizable", .height = 240, .build = stateless(buildResizable) },
.{ .name = "skeleton", .height = 200, .build = stateless(buildSkeleton) },
.{ .name = "spinner", .height = 140, .build = stateless(buildSpinner) },
.{ .name = "code", .height = 300, .build = stateless(buildCode) },
.{ .name = "markdown", .height = 440, .build = stateless(buildMarkdown) },
.{ .name = "media-surface", .height = 280, .build = stateless(buildMediaSurface) },
.{ .name = "video", .height = 300, .build = stateless(buildVideo) },
@@ -198,6 +199,7 @@ pub const scenes = [_]Scene{
heroScene("chart-hero", buildChartHero),
heroScene("checkbox-hero", buildCheckboxHero),
heroScene("combobox-hero", buildComboboxHero),
heroScene("code-hero", buildCodeHero),
heroScene("dialog-hero", buildDialogHero),
heroScene("drawer-hero", buildDrawerHero),
heroScene("dropdown-menu-hero", buildDropdownMenuHero),
@@ -1056,6 +1058,28 @@ const markdown_sample =
\\```
;
const code_sample =
\\<Accordion defaultValue={["item-1"]}>
\\ <AccordionItem value="item-1">
\\ <AccordionTrigger>Is it accessible?</AccordionTrigger>
\\ <AccordionContent>
\\ Yes. It follows the WAI-ARIA design pattern.
\\ </AccordionContent>
\\ </AccordionItem>
\\</Accordion>
;
fn buildCode(ui: *Ui) Node {
return tileStart(ui, .{
ui.code(.{
.language = .html,
.line_numbers = true,
.wrap = false,
.width = 496,
}, code_sample),
});
}
fn buildMarkdown(ui: *Ui) Node {
return tileStart(ui, .{
Md.view(ui, markdown_sample, .{}),
@@ -1391,6 +1415,22 @@ const markdown_hero_sample =
\\native widgets.
;
fn buildCodeHero(ui: *Ui) Node {
return heroTileStart(ui, .{
ui.code(.{
.language = .html,
.line_numbers = true,
.wrap = false,
.width = 320,
},
\\<Button variant="primary"
\\ onPress={() => save()}>
\\ Save changes
\\</Button>
),
});
}
fn buildMarkdownHero(ui: *Ui) Node {
return heroTileStart(ui, .{
Md.view(ui, markdown_hero_sample, .{}),
+28
View File
@@ -71,6 +71,7 @@ pub const element_docs = [_]Doc{
.{ .name = "skeleton", .doc = "Loading placeholder block; size with width and height." },
.{ .name = "spinner", .doc = "Indeterminate progress spinner leaf." },
.{ .name = "icon", .doc = "Vector icon leaf: name selects a curated built-in stroke icon (comptime-validated), an app-registered app:<name> (canvas.icons.registerAppIcons; native check verifies the name against the model contract), or one {binding} resolving to such a name. Tint via foreground, size with width/height or size." },
.{ .name = "code", .doc = "Highlighted source-code surface. source is one required text {binding}; language is a literal lexer name. Wraps by default, line-numbers opts into logical line numbers, wrap=\"false\" keeps lines intact, and a definite height makes overflow scrollable." },
.{ .name = "markdown", .doc = "Renders a markdown string (GFM subset, pipe tables included) as widgets; source is one {binding}, links dispatch on-link (bare URLs autolink), <details> blocks toggle via on-details + details-expanded, #123 refs linkify via issue-link-base." },
.{ .name = "stepper", .doc = "Stage stepper: step children joined by connectors; active names the current step index (earlier steps render completed, later ones pending)." },
.{ .name = "step", .doc = "One stepper stage; only allowed inside a stepper, the label is the text content (supports {} interpolation), state derives from the stepper's active index." },
@@ -173,6 +174,20 @@ pub const markdown_attr_docs = [_]Doc{
.{ .name = "issue-link-base", .doc = "markdown: literal URL prefix or one {binding}; '#123' refs become links to base ++ number (ghissue:// or https://github.com/owner/repo/issues/)." },
};
pub const code_attr_docs = [_]Doc{
.{ .name = "source", .doc = "code: one required {binding} producing source text (a []const u8 field or fn; arena fns work)." },
.{ .name = "language", .doc = "code: literal lexer name. Supports Zig, JavaScript/TypeScript, JSX/TSX, JSON, shell, Python, Rust, C-family, Go, HTML/XML/SVG, CSS-family, and SQL; unknown names are a validation error." },
.{ .name = "line-numbers", .doc = "code: opt into muted logical line numbers. Off by default; a wrapped logical line stays paired with its number." },
.{ .name = "wrap", .doc = "code: true by default. false preserves logical lines and puts the highlighted content in one horizontal scroll region." },
.{ .name = "width", .doc = "Definite width (plain number)." },
.{ .name = "height", .doc = "code: definite height (plain number). Overflow scrolls vertically; with wrap=false the region scrolls on both axes." },
.{ .name = "min-width", .doc = "Width floor without a definite maximum." },
.{ .name = "grow", .doc = "Flex grow factor." },
.{ .name = "key", .doc = "Sibling-scoped identity key." },
.{ .name = "global-key", .doc = "Parent-independent identity: ids survive reparenting between containers." },
.{ .name = "label", .doc = "Accessible name for the code group." },
};
pub const stepper_attr_docs = [_]Doc{
.{ .name = "active", .doc = "stepper: the active step index (a number or one {binding}); earlier steps render completed, later ones pending. Required." },
.{ .name = "key", .doc = "Sibling-scoped identity key." },
@@ -314,6 +329,7 @@ pub fn attributeDoc(name: []const u8) ?[]const u8 {
if (findDoc(&for_attr_docs, name)) |doc| return doc;
if (findDoc(&template_attr_docs, name)) |doc| return doc;
if (findDoc(&markdown_attr_docs, name)) |doc| return doc;
if (findDoc(&code_attr_docs, name)) |doc| return doc;
if (findDoc(&stepper_attr_docs, name)) |doc| return doc;
if (findDoc(&timeline_attr_docs, name)) |doc| return doc;
if (findDoc(&timeline_item_attr_docs, name)) |doc| return doc;
@@ -329,6 +345,18 @@ pub fn attributeDoc(name: []const u8) ?[]const u8 {
return findDoc(&if_attr_docs, name);
}
/// Resolve an attribute in its element context before falling back to the
/// shared registry. Composite elements intentionally reuse names such as
/// `source`, so context-free lookup cannot choose the right authoring help.
pub fn attributeDocForElement(element: []const u8, name: []const u8) ?[]const u8 {
if (std.mem.eql(u8, element, "markdown")) {
if (findDoc(&markdown_attr_docs, name)) |doc| return doc;
} else if (std.mem.eql(u8, element, "code")) {
if (findDoc(&code_attr_docs, name)) |doc| return doc;
}
return attributeDoc(name);
}
fn findDoc(list: []const Doc, name: []const u8) ?[]const u8 {
for (list) |entry| {
if (std.mem.eql(u8, entry.name, name)) return entry.doc;
+21 -1
View File
@@ -315,6 +315,8 @@ pub const Server = struct {
// else takes no attributes
} else if (std.mem.eql(u8, element_name, "markdown")) {
for (markdown_attr_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "markdown attribute", doc.doc);
} else if (std.mem.eql(u8, element_name, "code")) {
for (code_attr_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "code attribute", doc.doc);
} else if (std.mem.eql(u8, element_name, "stepper")) {
for (stepper_attr_docs) |doc| try writeCompletionItem(&js, doc.name, .property, "stepper attribute", doc.doc);
} else if (std.mem.eql(u8, element_name, "step")) {
@@ -631,7 +633,11 @@ pub fn hoverAt(text: []const u8, offset: usize) ?HoverResult {
}
const open = lastTagOpen(text, start) orelse return null;
if (insideQuotes(text, open, start)) return null;
const doc = attributeDoc(word) orelse return null;
var element_start = open + 1;
if (element_start < text.len and text[element_start] == '/') element_start += 1;
var element_end = element_start;
while (element_end < text.len and isNameChar(text[element_end])) element_end += 1;
const doc = attributeDocForElement(text[element_start..element_end], word) orelse return null;
return .{ .name = word, .doc = doc };
}
@@ -646,6 +652,7 @@ pub const template_attr_docs = markup_docs.template_attr_docs;
pub const for_attr_docs = markup_docs.for_attr_docs;
pub const if_attr_docs = markup_docs.if_attr_docs;
pub const markdown_attr_docs = markup_docs.markdown_attr_docs;
pub const code_attr_docs = markup_docs.code_attr_docs;
pub const stepper_attr_docs = markup_docs.stepper_attr_docs;
pub const timeline_attr_docs = markup_docs.timeline_attr_docs;
pub const timeline_item_attr_docs = markup_docs.timeline_item_attr_docs;
@@ -660,6 +667,7 @@ pub const reactions_attr_docs = markup_docs.reactions_attr_docs;
pub const event_docs = markup_docs.event_docs;
pub const elementDoc = markup_docs.elementDoc;
pub const attributeDoc = markup_docs.attributeDoc;
pub const attributeDocForElement = markup_docs.attributeDocForElement;
// ------------------------------------------------------------------ tests
@@ -912,6 +920,18 @@ test "hoverAt resolves element and attribute docs" {
try testing.expectEqualStrings("button", closing.name);
}
test "hoverAt resolves reused attributes in their element context" {
const source = "<markdown source=\"{docs}\"/><code source=\"{snippet}\"/>";
const markdown_source = std.mem.indexOf(u8, source, "source").?;
const code_open = std.mem.indexOf(u8, source, "<code").?;
const code_source = std.mem.indexOfPos(u8, source, code_open, "source").?;
const markdown_hover = hoverAt(source, markdown_source).?;
try testing.expect(std.mem.startsWith(u8, markdown_hover.doc, "markdown:"));
const code_hover = hoverAt(source, code_source).?;
try testing.expect(std.mem.startsWith(u8, code_hover.doc, "code:"));
}
test "doc tables cover every known element, attribute, and event" {
for (ui_markup.known_element_names) |name| {
try testing.expect(elementDoc(name) != null);