diff --git a/changelog.d/code-component.md b/changelog.d/code-component.md new file mode 100644 index 00000000..d5db7992 --- /dev/null +++ b/changelog.d/code-component.md @@ -0,0 +1 @@ +feature: **Code component**: `ui.code` and markup `` 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. diff --git a/changelog.d/code-transform-budgets.md b/changelog.d/code-transform-budgets.md new file mode 100644 index 00000000..7a5ef722 --- /dev/null +++ b/changelog.d/code-transform-budgets.md @@ -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. diff --git a/changelog.d/markdown-list-code-rendering.md b/changelog.d/markdown-list-code-rendering.md new file mode 100644 index 00000000..7bb9db29 --- /dev/null +++ b/changelog.d/markdown-list-code-rendering.md @@ -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. diff --git a/docs/public/components/code-dark.webp b/docs/public/components/code-dark.webp new file mode 100644 index 00000000..c6cd7355 Binary files /dev/null and b/docs/public/components/code-dark.webp differ diff --git a/docs/public/components/code-hero-dark.webp b/docs/public/components/code-hero-dark.webp new file mode 100644 index 00000000..43379f7b Binary files /dev/null and b/docs/public/components/code-hero-dark.webp differ diff --git a/docs/public/components/code-hero-light.webp b/docs/public/components/code-hero-light.webp new file mode 100644 index 00000000..618b5e60 Binary files /dev/null and b/docs/public/components/code-hero-light.webp differ diff --git a/docs/public/components/code-light.webp b/docs/public/components/code-light.webp new file mode 100644 index 00000000..cfb6e96a Binary files /dev/null and b/docs/public/components/code-light.webp differ diff --git a/docs/src/app/components/code/layout.tsx b/docs/src/app/components/code/layout.tsx new file mode 100644 index 00000000..b598f499 --- /dev/null +++ b/docs/src/app/components/code/layout.tsx @@ -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; +} diff --git a/docs/src/app/components/code/page.mdx b/docs/src/app/components/code/page.mdx new file mode 100644 index 00000000..95aafecb --- /dev/null +++ b/docs/src/app/components/code/page.mdx @@ -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. + + + +## Markup + +```html + +``` + +`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 + + diff --git a/docs/src/app/components/markdown/page.mdx b/docs/src/app/components/markdown/page.mdx index 6130d61f..b4a08bbb 100644 --- a/docs/src/app/components/markdown/page.mdx +++ b/docs/src/app/components/markdown/page.mdx @@ -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), `
` 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), `
` 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. diff --git a/docs/src/app/native-ui/page.mdx b/docs/src/app/native-ui/page.mdx index b65aa36f..0450eac0 100644 --- a/docs/src/app/native-ui/page.mdx +++ b/docs/src/app/native-ui/page.mdx @@ -104,7 +104,7 @@ Color and radius attributes reference design tokens by name — literals only, v ``` -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` / `` 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 + +``` + +```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 `
` 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 `
` 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 `` element wires all of this declaratively: diff --git a/docs/src/lib/component-vocab.json b/docs/src/lib/component-vocab.json index 959324d0..32244863 100644 --- a/docs/src/lib/component-vocab.json +++ b/docs/src/lib/component-vocab.json @@ -212,6 +212,10 @@ "name": "icon", "doc": "Vector icon leaf: name selects a curated built-in stroke icon (comptime-validated), an app-registered app: (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),
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" } ] -} \ No newline at end of file +} diff --git a/docs/src/lib/components-pages.ts b/docs/src/lib/components-pages.ts index 580e0241..892cb008 100644 --- a/docs/src/lib/components-pages.ts +++ b/docs/src/lib/components-pages.ts @@ -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." }, diff --git a/examples/calculator/src/theme.zig b/examples/calculator/src/theme.zig index f66b4dea..e26b580f 100644 --- a/examples/calculator/src/theme.zig +++ b/examples/calculator/src/theme.zig @@ -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 diff --git a/examples/deck/src/theme.zig b/examples/deck/src/theme.zig index db7c7cc3..fc6a9a08 100644 --- a/examples/deck/src/theme.zig +++ b/examples/deck/src/theme.zig @@ -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), diff --git a/examples/markdown-viewer/README.md b/examples/markdown-viewer/README.md index 37cd6bcc..edfb4804 100644 --- a/examples/markdown-viewer/README.md +++ b/examples/markdown-viewer/README.md @@ -8,7 +8,7 @@ native dev ## What it demonstrates -- **`` 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 `
` blocks whose expansion flags live in the model (`details_expanded: [16]bool`), toggled in `update`. +- **`` 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 `
` 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. diff --git a/examples/markdown-viewer/src/main.zig b/examples/markdown-viewer/src/main.zig index f07b314c..c362de8f 100644 --- a/examples/markdown-viewer/src/main.zig +++ b/examples/markdown-viewer/src/main.zig @@ -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), diff --git a/examples/notes/src/main.zig b/examples/notes/src/main.zig index dd6cad6b..d3001ba4 100644 --- a/examples/notes/src/main.zig +++ b/examples/notes/src/main.zig @@ -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), diff --git a/examples/system-monitor/src/theme.zig b/examples/system-monitor/src/theme.zig index 9ca3b141..2c6df031 100644 --- a/examples/system-monitor/src/theme.zig +++ b/examples/system-monitor/src/theme.zig @@ -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), diff --git a/skill-data/native-ui/SKILL.md b/skill-data/native-ui/SKILL.md index f0783fb6..cba1a297 100644 --- a/skill-data/native-ui/SKILL.md +++ b/skill-data/native-ui/SKILL.md @@ -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:` 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: `` + +A source-bound highlighted surface shared with Markdown fences: + +```html + +``` + +- `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: `` 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), `
`. +- 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), `
`. - Not in v1 (degrades to plain text, never fails): reference links, raw HTML, footnotes, backslash escapes (except `\|` in table rows). - `
` 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. diff --git a/src/primitives/canvas/code.zig b/src/primitives/canvas/code.zig new file mode 100644 index 00000000..fadb4234 --- /dev/null +++ b/src/primitives/canvas/code.zig @@ -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 && ` 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 += 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]; +} diff --git a/src/primitives/canvas/code_tests.zig b/src/primitives/canvas/code_tests.zig new file mode 100644 index 00000000..886c1a69 --- /dev/null +++ b/src/primitives/canvas/code_tests.zig @@ -0,0 +1,1062 @@ +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"); +const ui_model = @import("ui.zig"); + +const testing = std.testing; +const Ui = ui_model.Ui(enum { noop }); + +fn spanWithFragment(spans: []const canvas.TextSpan, fragment: []const u8) ?canvas.TextSpan { + for (spans) |span| { + if (std.mem.indexOf(u8, span.text, fragment) != null) return span; + } + return null; +} + +fn findByKind(widget: canvas.Widget, kind: canvas.WidgetKind) ?canvas.Widget { + if (widget.kind == kind) return widget; + for (widget.children) |child| { + if (findByKind(child, kind)) |found| return found; + } + return null; +} + +fn findByText(widget: canvas.Widget, text: []const u8) ?canvas.Widget { + if (std.mem.eql(u8, widget.text, text)) return widget; + for (widget.children) |child| { + if (findByText(child, text)) |found| return found; + } + return null; +} + +fn countByKind(widget: canvas.Widget, kind: canvas.WidgetKind) usize { + var count: usize = @intFromBool(widget.kind == kind); + for (widget.children) |child| count += countByKind(child, kind); + return count; +} + +fn countTextSpans(widget: canvas.Widget) usize { + var count = widget.spans.len; + for (widget.children) |child| count += countTextSpans(child); + return count; +} + +fn appendTextWidgets(widget: canvas.Widget, output: *std.ArrayListUnmanaged(u8), allocator: std.mem.Allocator) !void { + if (widget.kind == .text) try output.appendSlice(allocator, widget.text); + for (widget.children) |child| try appendTextWidgets(child, output, allocator); +} + +fn displayListTextBytes(display_list: canvas.DisplayList) usize { + var count: usize = 0; + for (display_list.commands) |command| { + switch (command) { + .draw_text => |draw| count += draw.text.len, + else => {}, + } + } + return count; +} + +fn expectCompleteSpanLayouts(widget: canvas.Widget) !void { + if (widget.kind == .text and widget.spans.len > 0) { + var runs: [text_spans.max_text_span_runs_per_paragraph]text_spans.TextSpanRun = undefined; + const layout = text_spans.layoutTextSpans( + widget.spans, + .{ .size = 14, .max_width = 1 }, + &runs, + ); + try testing.expect(!layout.truncated); + try testing.expect(layout.line_count <= text_spans.max_text_span_lines_per_paragraph); + } + for (widget.children) |child| try expectCompleteSpanLayouts(child); +} + +fn allTextSpansHaveColor(widget: canvas.Widget, color: canvas.TextSpanColor) bool { + if (widget.kind == .text and widget.spans.len > 0) { + for (widget.spans) |span| { + if (span.color != color) return false; + } + } + for (widget.children) |child| { + if (!allTextSpansHaveColor(child, color)) return false; + } + return true; +} + +test "HTML and JSX highlighting distinguishes tags attributes expressions and strings" { + const source = + \\ + \\ Accessible? + \\ + ; + var storage: [text_spans.max_text_spans_per_paragraph]canvas.TextSpan = undefined; + const spans = code_model.highlight(source, code_model.languageFromName("tsx"), &storage); + + try testing.expectEqual(code_model.Language.html, code_model.languageFromName("jsx")); + try testing.expectEqual(canvas.TextSpanColor.syntax_literal, spanWithFragment(spans, "Accordion").?.color.?); + try testing.expectEqual(canvas.TextSpanColor.syntax_function, spanWithFragment(spans, "defaultValue").?.color.?); + try testing.expectEqual(canvas.TextSpanColor.syntax_literal, spanWithFragment(spans, "\"item-1\"").?.color.?); + try testing.expectEqual(canvas.TextSpanColor.syntax_literal, spanWithFragment(spans, "true").?.color.?); + try testing.expectEqual(canvas.TextSpanColor.syntax_plain, spanWithFragment(spans, "Accessible?").?.color.?); +} + +test "JSX relational less-than preserves expression state and nested tags" { + const source = "{count : }"; + var state: code_model.HighlightState = .{}; + var storage: [text_spans.max_text_spans_per_paragraph]canvas.TextSpan = undefined; + const spans = code_model.highlightWithState(source, .html, &storage, &state); + + try testing.expectEqual(canvas.TextSpanColor.syntax_plain, spanWithFragment(spans, "limit").?.color.?); + try testing.expectEqual(canvas.TextSpanColor.syntax_literal, spanWithFragment(spans, "Low").?.color.?); + try testing.expectEqual(canvas.TextSpanColor.syntax_literal, spanWithFragment(spans, "High").?.color.?); + try testing.expectEqual(@as(usize, 0), state.html_expression_depth); + try testing.expect(!state.html_in_tag); + + var chunked_state: code_model.HighlightState = .{}; + var first_storage: [text_spans.max_text_spans_per_paragraph]canvas.TextSpan = undefined; + _ = code_model.highlightWithState("{count", .html, &first_storage, &chunked_state); + var second_storage: [text_spans.max_text_spans_per_paragraph]canvas.TextSpan = undefined; + const second = code_model.highlightWithState(" : }", .html, &second_storage, &chunked_state); + try testing.expectEqual(canvas.TextSpanColor.syntax_plain, spanWithFragment(second, "limit").?.color.?); + try testing.expectEqual(@as(usize, 0), chunked_state.html_expression_depth); + try testing.expect(!chunked_state.html_in_tag); +} + +test "nested JSX attribute tags restore the enclosing opening tag" { + const source = "} disabled=\"yes\">"; + var state: code_model.HighlightState = .{}; + var storage: [text_spans.max_text_spans_per_paragraph]canvas.TextSpan = undefined; + const spans = code_model.highlightWithState(source, .html, &storage, &state); + + try testing.expectEqual( + canvas.TextSpanColor.syntax_literal, + spanWithFragment(spans, "Inner").?.color.?, + ); + try testing.expectEqual( + canvas.TextSpanColor.syntax_function, + spanWithFragment(spans, "disabled").?.color.?, + ); + try testing.expectEqual( + canvas.TextSpanColor.syntax_literal, + spanWithFragment(spans, "\"yes\"").?.color.?, + ); + try testing.expectEqual(@as(usize, 0), state.html_tag_context_len); + try testing.expect(!state.html_in_tag); +} + +test "HTML and JSX lexer state survives logical line boundaries" { + var state: code_model.HighlightState = .{}; + var first_storage: [text_spans.max_text_spans_per_paragraph]canvas.TextSpan = undefined; + _ = code_model.highlightWithState(" + ), + }); +} + fn buildMarkdownHero(ui: *Ui) Node { return heroTileStart(ui, .{ Md.view(ui, markdown_hero_sample, .{}), diff --git a/tools/native-sdk/markup_docs.zig b/tools/native-sdk/markup_docs.zig index 7f7b718d..2ccc8cc6 100644 --- a/tools/native-sdk/markup_docs.zig +++ b/tools/native-sdk/markup_docs.zig @@ -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: (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),
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; diff --git a/tools/native-sdk/markup_lsp.zig b/tools/native-sdk/markup_lsp.zig index 0336c037..94ac0dca 100644 --- a/tools/native-sdk/markup_lsp.zig +++ b/tools/native-sdk/markup_lsp.zig @@ -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 = ""; + const markdown_source = std.mem.indexOf(u8, source, "source").?; + const code_open = std.mem.indexOf(u8, source, "