Compare commits

...

3 Commits

Author SHA1 Message Date
Chris Tate 9f832c765b Fix code diff metadata and selection layering 2026-08-02 18:36:49 -05:00
Chris Tate 904bafeb16 Merge origin/main into code-diff
- Bring in the disabled-button theme updates from main.
- Regenerate the docs WASM bundle with both the theme fixes and code-diff scenes.
2026-08-02 18:01:37 -05:00
Chris Tate 0d0cf1e65c Add diff annotations to code component
- Add Geist-style added and removed line annotations across the Zig and markup APIs.
- Preserve diff rendering through layout, editing, scrolling, and retained invalidation with focused tests.
- Ship dedicated light/dark docs previews, rebuilt live WASM, documentation, and a changelog fragment.
2026-08-02 17:51:45 -05:00
30 changed files with 833 additions and 145 deletions
+1
View File
@@ -0,0 +1 @@
feature: **Geist-style code diffs**: `ui.code` and `<code>` can mark added and removed logical lines with theme-aware full-row washes, renderer-owned `+`/`-` markers, optional line numbers, and unchanged syntax-highlighted clipboard source.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.
+40 -27
View File
@@ -1,11 +1,11 @@
// Regression pin for the Code component docs preview. ComponentPreview
// Regression pin for the Code component docs previews. ComponentPreview
// deliberately keeps a webp fallback under its live canvas, so a stale
// or incompatible wasm scene can otherwise fail silently and leave the
// page looking correct while it is only showing the screenshot.
//
// Require the checked-in module to instantiate the exact `code` scene
// used by /components/code. Runs after `next build` as part of
// `pnpm check`.
// Require the checked-in module to instantiate the exact `code` and
// `code-diff` scenes used by /components/code. Runs after `next build`
// as part of `pnpm check`.
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
@@ -18,33 +18,46 @@ const bytes = readFileSync(wasmPath);
const vocab = JSON.parse(readFileSync(vocabPath, "utf8"));
const { instance } = await WebAssembly.instantiate(bytes, {});
const exports = instance.exports;
const scene = new TextEncoder().encode("code");
const scenePtr = exports.preview_alloc(scene.length);
const sceneNames = ["code", "code-diff"];
if (!scenePtr) {
throw new Error("code WASM preview check could not allocate its scene name");
}
new Uint8Array(exports.memory.buffer).set(scene, scenePtr);
const handle = exports.preview_create(scenePtr, scene.length, 0);
exports.preview_free(scenePtr, scene.length);
if (!handle) {
if (
typeof exports.preview_code_diff_metadata_round_trip !== "function" ||
exports.preview_code_diff_metadata_round_trip() !== 1
) {
throw new Error(
"the checked-in component-preview.wasm cannot create the `code` scene; rebuild it with `zig build docs-wasm-preview`",
"the checked-in component-preview.wasm truncates code-diff metadata above line 96 on wasm32",
);
}
const width = exports.preview_logical_width(handle);
const height = exports.preview_logical_height(handle);
exports.preview_destroy(handle);
const expectedWidth = vocab.previews.code.width / 2;
const expectedHeight = vocab.previews.code.height / 2;
for (const sceneName of sceneNames) {
const scene = new TextEncoder().encode(sceneName);
const scenePtr = exports.preview_alloc(scene.length);
if (width !== expectedWidth || height !== expectedHeight) {
throw new Error(
`the code WASM preview is ${width}x${height}; expected the catalog's ${expectedWidth}x${expectedHeight} scene`,
);
if (!scenePtr) {
throw new Error(`${sceneName} WASM preview check could not allocate its scene name`);
}
new Uint8Array(exports.memory.buffer).set(scene, scenePtr);
const handle = exports.preview_create(scenePtr, scene.length, 0);
exports.preview_free(scenePtr, scene.length);
if (!handle) {
throw new Error(
`the checked-in component-preview.wasm cannot create the \`${sceneName}\` scene; rebuild it with \`zig build docs-wasm-preview\``,
);
}
const width = exports.preview_logical_width(handle);
const height = exports.preview_logical_height(handle);
exports.preview_destroy(handle);
const expectedWidth = vocab.previews[sceneName].width / 2;
const expectedHeight = vocab.previews[sceneName].height / 2;
if (width !== expectedWidth || height !== expectedHeight) {
throw new Error(
`the ${sceneName} WASM preview is ${width}x${height}; expected the catalog's ${expectedWidth}x${expectedHeight} scene`,
);
}
console.log(`${sceneName} WASM preview check passed: live scene instantiated at ${width}x${height}`);
}
console.log(`code WASM preview check passed: live scene instantiated at ${width}x${height}`);
+29 -6
View File
@@ -24,6 +24,27 @@ HTML-family highlighting understands HTML, XML, SVG, JSX, and TSX structure: ele
`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.
## Added and removed lines
Diff presentation follows Geist Code Block in the default and Geist theme packs, across light and dark appearances. `added-lines` and `removed-lines` apply full-width green/red washes and renderer-owned `+`/`-` markers while the underlying source, syntax highlighting, selection, and copied text stay unchanged.
<ComponentPreview name="code-diff" alt="A JavaScript configuration diff with green added and red removed lines" caption="Geist-style added and removed lines over ordinary JavaScript highlighting" />
```html
<code
source="{migration_source}"
language="javascript"
line-numbers
added-lines="5"
removed-lines="2-4"
wrap="false"
width="480"
label="Configuration migration"
/>
```
Line specs are one-based comma lists and inclusive ranges: `added-lines="5, 9-11"`. They annotate clean source—the `+` and `-` are decoration, not bytes callers must splice into the model. This keeps the selected/copied result usable and lets `language` continue highlighting the real grammar. A line cannot be both added and removed. Diff metadata is bounded to lines 1128; read-only sources longer than 128 lines keep every source byte and omit the diff treatment.
For editable code, apply each `TextInputEvent` to the same model-owned buffer that supplies `source`:
```html
@@ -53,16 +74,18 @@ Surface styling belongs to a wrapper:
```zig
ui.code(.{
.language = .html,
.editable = true,
.on_input = Ui.inputMsg(.edit_document),
.language = .javascript,
.line_numbers = true,
.added_lines = &.{5},
.removed_lines = &.{ 2, 3, 4 },
.wrap = false,
.width = 480,
.semantics = .{ .label = "Accordion example" },
}, model.component_source)
.semantics = .{ .label = "Configuration migration" },
}, model.migration_source)
```
The editable path uses the same `added_lines` and `removed_lines` options when an editor needs annotations; keep those line numbers synchronized as edits change the document.
The Zig builder composes the same way when chrome is wanted:
```zig
@@ -79,4 +102,4 @@ Zig; JavaScript and TypeScript; JSX and TSX; JSON; YAML; shell; Python; Rust; C,
## Attributes
<AttrTable element="code" attrs={["source", "language", "editable", "on-input", "line-numbers", "wrap", "width", "height", "min-width", "grow", "key", "global-key", "label"]} />
<AttrTable element="code" attrs={["source", "language", "editable", "on-input", "line-numbers", "added-lines", "removed-lines", "wrap", "width", "height", "min-width", "grow", "key", "global-key", "label"]} />
+13 -1
View File
@@ -214,7 +214,7 @@
},
{
"name": "code",
"doc": "Bare highlighted source content with no background, border, radius, shadow, or padding. 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. Wrap in a panel or card when chrome is wanted."
"doc": "Bare highlighted source content with no background, border, radius, shadow, or padding. source is one required text {binding}; language is a literal lexer name. Wraps by default, line-numbers opts into logical line numbers, added-lines/removed-lines add Geist-style diff rows, wrap=\"false\" keeps lines intact, and a definite height makes overflow scrollable. Wrap in a panel or card when chrome is wanted."
},
{
"name": "markdown",
@@ -599,6 +599,14 @@
"name": "line-numbers",
"doc": "code: opt into muted logical line numbers. Off by default; a wrapped logical line stays paired with its number."
},
{
"name": "added-lines",
"doc": "code: one-based comma/range spec (for example 5 or 5, 9-11), or one text {binding}; applies Geist's green full-line wash and renderer-owned + without changing copied source. Lines 1-128."
},
{
"name": "removed-lines",
"doc": "code: one-based comma/range spec (for example 2-4), or one text {binding}; applies Geist's red full-line wash and renderer-owned - without changing copied source. Lines 1-128."
},
{
"name": "wrap",
"doc": "code: true by default. false preserves logical lines and puts the highlighted content in one horizontal scroll region."
@@ -1196,6 +1204,10 @@
"width": 1120,
"height": 600
},
"code-diff": {
"width": 1120,
"height": 440
},
"markdown": {
"width": 1120,
"height": 880
+4 -2
View File
@@ -182,7 +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` | bare highlighted source/editor | `source="{binding}"` (required) provides source text and `language="tsx"` selects a literal lexer name; the component supplies no background, border, radius, shadow, or padding, so wrap it in a panel/card when chrome is wanted. It is read-only by default; `editable on-input="edit"` opts into multiline editing while retaining highlighting. It 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)` |
| `code` | bare highlighted source/editor | `source="{binding}"` (required) provides source text and `language="tsx"` selects a literal lexer name; the component supplies no background, border, radius, shadow, or padding, so wrap it in a panel/card when chrome is wanted. It is read-only by default; `editable on-input="edit"` opts into multiline editing while retaining highlighting. It wraps by default, `line-numbers` opts into logical line numbers, `added-lines="5"` / `removed-lines="2-4"` add Geist-style diff rows without changing copied source, 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 |
@@ -974,6 +974,7 @@ Bare source-bound highlighted content shared with Markdown fences:
```html
<code source="{snippet}" language="tsx" line-numbers wrap="false" width="480" label="Component source" />
<code source="{migration}" language="javascript" line-numbers added-lines="5" removed-lines="2-4" wrap="false" width="480" label="Configuration migration" />
```
- `source` is one required `{binding}` producing `[]const u8`; the element has no children.
@@ -982,7 +983,8 @@ Bare source-bound highlighted content shared with Markdown fences:
- Code is selectable/read-only by default. Add `editable on-input="edit"` to use the multiline editor path; the message carries `canvas.TextInputEvent`, so apply it to the same model-owned `TextBuffer` that supplies `source`. Editing retains syntax highlighting and does not add textarea chrome.
- 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, .editable = true, .on_input = Ui.inputMsg(.edit), .line_numbers = true, .wrap = false }, model.snippet)`. Omit `editable`/`on_input` for read-only code. The public lexer helpers are under `native_sdk.canvas.code`.
- `added-lines` and `removed-lines` take one-based comma lists/inclusive ranges up to line 128 (`"2-4, 7"`) or one text binding in that form. They apply Geist's full-width green/red washes and renderer-owned `+`/`-`; the markers never enter source, selection, editing, or clipboard bytes. A line cannot be both added and removed. Read-only sources longer than 128 lines preserve all code and omit diff decoration.
- Zig builder: `ui.code(.{ .language = .html, .editable = true, .on_input = Ui.inputMsg(.edit), .line_numbers = true, .added_lines = &.{5}, .removed_lines = &.{ 2, 3, 4 }, .wrap = false }, model.snippet)`. Omit `editable`/`on_input` for read-only code. The public lexer helpers are under `native_sdk.canvas.code`.
## Markdown in markup: `<markdown>`
+50
View File
@@ -145,6 +145,56 @@ pub fn languageFromFence(opening: []const u8) Language {
return languageFromName(info[0..end]);
}
/// Diff annotations share the read-only code paragraph's bounded logical
/// line model. A fixed ceiling keeps line metadata inline on the widget and
/// makes malformed markup fail before it can silently decorate the wrong
/// source row.
pub const max_diff_lines: usize = 128;
/// Parse a one-based comma/range list such as `2-4, 7`. Results are unique
/// and retain author order. Empty text means no annotated lines; zero,
/// descending ranges, values above `max_diff_lines`, and malformed pieces
/// are rejected.
pub fn parseLineNumberSpec(spec_raw: []const u8, storage: *[max_diff_lines]usize) ?[]const usize {
const spec = std.mem.trim(u8, spec_raw, " \t\r\n");
if (spec.len == 0) return storage[0..0];
var len: usize = 0;
var pieces = std.mem.splitScalar(u8, spec, ',');
while (pieces.next()) |piece_raw| {
const piece = std.mem.trim(u8, piece_raw, " \t\r\n");
if (piece.len == 0) return null;
const dash = std.mem.indexOfScalar(u8, piece, '-');
const first_text = std.mem.trim(u8, if (dash) |index| piece[0..index] else piece, " \t");
const last_text = if (dash) |index| std.mem.trim(u8, piece[index + 1 ..], " \t") else first_text;
if (first_text.len == 0 or last_text.len == 0) return null;
if (dash) |index| {
if (std.mem.indexOfScalar(u8, piece[index + 1 ..], '-') != null) return null;
}
const first = std.fmt.parseInt(usize, first_text, 10) catch return null;
const last = std.fmt.parseInt(usize, last_text, 10) catch return null;
if (first == 0 or last < first or last > max_diff_lines) return null;
var line = first;
while (line <= last) : (line += 1) {
var duplicate = false;
for (storage[0..len]) |existing| {
if (existing == line) {
duplicate = true;
break;
}
}
if (duplicate) continue;
if (len == storage.len) return null;
storage[len] = line;
len += 1;
}
}
return storage[0..len];
}
fn wordInList(word: []const u8, list: []const u8, ignore_case: bool) bool {
var words = std.mem.tokenizeScalar(u8, list, ' ');
while (words.next()) |candidate| {
+174 -1
View File
@@ -16,6 +16,29 @@ fn spanWithFragment(spans: []const canvas.TextSpan, fragment: []const u8) ?canva
return null;
}
test "diff line specs accept one-based values and compact ranges" {
var storage: [code_model.max_diff_lines]usize = undefined;
const lines = code_model.parseLineNumberSpec("2-4, 7, 3", &storage).?;
try testing.expectEqualSlices(usize, &.{ 2, 3, 4, 7 }, lines);
try testing.expectEqual(@as(usize, 0), code_model.parseLineNumberSpec("", &storage).?.len);
const invalid = [_][]const u8{ "0", "4-2", "1-129", "1,,2", "2-", "2-3-4", "nope" };
for (invalid) |spec| try testing.expect(code_model.parseLineNumberSpec(spec, &storage) == null);
}
test "widget diff metadata round-trips both 128-line masks" {
var widget = canvas.Widget{ .kind = .text, .code_line_number_digits = 3 };
const expected = canvas.CodeDiffLines{
.added = (@as(u128, 1) << 127) | (@as(u128, 1) << 64) | 1,
.removed = (@as(u128, 1) << 126) | (@as(u128, 1) << 63) | 2,
};
widget.setCodeDiffLines(expected);
try testing.expectEqual(@as(u8, 3), widget.codeLineNumberDigits());
try testing.expectEqual(expected.added, widget.codeDiffLines().?.added);
try testing.expectEqual(expected.removed, widget.codeDiffLines().?.removed);
}
fn colorAtOffset(spans: []const canvas.TextSpan, offset: usize) ?canvas.TextSpanColor {
var cursor: usize = 0;
for (spans) |span| {
@@ -781,6 +804,156 @@ test "line numbers are opt-in and stay paired with logical source lines" {
try testing.expectEqual(canvas.TextSpanColor.syntax_keyword, spanWithFragment(comment_text.spans, "const").?.color.?);
}
test "diff lines use Geist washes and renderer-owned markers without changing source" {
const source =
\\module.exports = {
\\ experimental: {
\\ appDir: true,
\\ },
\\ appDir: true,
\\}
;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
var ui = Ui.init(arena.allocator());
const view = try ui.finalize(ui.code(.{
.language = .javascript,
.line_numbers = true,
.added_lines = &.{5},
.removed_lines = &.{ 2, 3, 4 },
.wrap = false,
.width = 360,
}, source));
const paragraph = findByText(view.root, source).?;
try testing.expectEqualStrings(source, paragraph.text);
const diff_lines = paragraph.codeDiffLines().?;
try testing.expectEqual(@as(u128, 1) << 4, diff_lines.added);
try testing.expectEqual((@as(u128, 1) << 1) | (@as(u128, 1) << 2) | (@as(u128, 1) << 3), diff_lines.removed);
var nodes: [16]canvas.WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(view.root, geometry.RectF.init(0, 0, 360, 140), &nodes);
for ([_]canvas.ThemePack{ .house, .geist }) |pack| {
for ([_]canvas.ColorScheme{ .light, .dark }) |scheme| {
const tokens = canvas.DesignTokens.theme(.{ .pack = pack, .color_scheme = scheme });
const added_background = if (scheme == .dark)
canvas.Color.rgb8(18, 54, 27)
else
canvas.Color.rgb8(218, 246, 218);
const removed_background = if (scheme == .dark)
canvas.Color.rgb8(86, 26, 30)
else
canvas.Color.rgb8(255, 230, 230);
const added_foreground = if (scheme == .dark)
canvas.Color.rgb8(98, 192, 115)
else
canvas.Color.rgb8(41, 122, 58);
const removed_foreground = if (scheme == .dark)
canvas.Color.rgb8(255, 97, 102)
else
canvas.Color.rgb8(203, 42, 47);
var commands: [256]canvas.CanvasCommand = undefined;
var builder = canvas.Builder.init(&commands);
try canvas.emitWidgetLayout(&builder, layout, tokens);
var added_washes: usize = 0;
var removed_washes: usize = 0;
var added_markers: usize = 0;
var removed_markers: usize = 0;
for (builder.displayList().commands) |command| {
switch (command) {
.fill_rect => |fill| {
if (std.meta.eql(fill.fill.color, added_background)) added_washes += 1;
if (std.meta.eql(fill.fill.color, removed_background)) removed_washes += 1;
},
.draw_text => |draw| {
if (std.mem.eql(u8, draw.text, "+") and std.meta.eql(draw.color, added_foreground)) added_markers += 1;
if (std.mem.eql(u8, draw.text, "-") and std.meta.eql(draw.color, removed_foreground)) removed_markers += 1;
},
else => {},
}
}
try testing.expectEqual(@as(usize, 1), added_washes);
try testing.expectEqual(@as(usize, 3), removed_washes);
try testing.expectEqual(@as(usize, 1), added_markers);
try testing.expectEqual(@as(usize, 3), removed_markers);
}
}
}
test "diff markers reserve a gutter when line numbers are hidden" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
var ui = Ui.init(arena.allocator());
const view = try ui.finalize(ui.code(.{ .added_lines = &.{1} }, "added"));
const paragraph = findByText(view.root, "added").?;
try testing.expectEqual(@as(u8, 0), paragraph.codeLineNumberDigits());
try testing.expect(widget_metrics.widgetCodeLineNumberGutterWidth(paragraph, .{}) > 0);
}
test "unwrapped editable diffs paint markers without line numbers" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
var ui = Ui.init(arena.allocator());
const view = try ui.finalize(ui.code(.{
.editable = true,
.wrap = false,
.added_lines = &.{1},
}, "added"));
var editor = findByText(view.root, "added").?;
editor.frame = geometry.RectF.init(0, 0, 160, 48);
var commands: [64]canvas.CanvasCommand = undefined;
var builder = canvas.Builder.init(&commands);
try canvas.emitWidgetTree(&builder, editor, .{});
var added_markers: usize = 0;
for (builder.displayList().commands) |command| {
if (command == .draw_text and std.mem.eql(u8, command.draw_text.text, "+")) {
added_markers += 1;
}
}
try testing.expectEqual(@as(usize, 1), added_markers);
}
test "editable diff washes paint behind text selections" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
var ui = Ui.init(arena.allocator());
const view = try ui.finalize(ui.code(.{
.editable = true,
.wrap = false,
.added_lines = &.{1},
}, "added"));
var editor = findByText(view.root, "added").?;
editor.frame = geometry.RectF.init(0, 0, 160, 48);
editor.text_selection = .{ .anchor = 0, .focus = editor.text.len };
var tokens = canvas.DesignTokens{};
const selection_fill = canvas.Color.rgb8(7, 11, 13);
const added_wash = canvas.Color.rgb8(218, 246, 218);
tokens.colors.accent = selection_fill;
tokens.colors.background = canvas.Color.rgb8(255, 255, 255);
var commands: [128]canvas.CanvasCommand = undefined;
var builder = canvas.Builder.init(&commands);
try canvas.emitWidgetTree(&builder, editor, tokens);
var wash_index: ?usize = null;
var selection_index: ?usize = null;
for (builder.displayList().commands, 0..) |command, index| {
if (command != .fill_rect) continue;
if (std.meta.eql(command.fill_rect.fill.color, added_wash) and
command.fill_rect.rect.width == editor.frame.width)
{
wash_index = index;
}
if (std.meta.eql(command.fill_rect.fill.color, selection_fill)) selection_index = index;
}
try testing.expect(wash_index != null);
try testing.expect(selection_index != null);
try testing.expect(wash_index.? < selection_index.?);
}
test "line number gutter reserves at least three marker columns" {
const tokens = canvas.DesignTokens{};
var numbered = canvas.Widget{
@@ -1344,7 +1517,7 @@ test "large code blocks split at the paragraph line capacity without hiding thei
var group_id: ?canvas.ObjectId = null;
for (chunk_column.children) |chunk| {
try testing.expect(chunk.static_text_group_id != 0);
try testing.expectEqual(expected_offset, chunk.static_text_group_offset);
try testing.expectEqual(@as(u64, @intCast(expected_offset)), chunk.static_text_group_offset);
if (group_id) |expected| {
try testing.expectEqual(expected, chunk.static_text_group_id);
} else {
+1
View File
@@ -450,6 +450,7 @@ pub const builtinComponentDescriptor = widget_model.builtinComponentDescriptor;
pub const WidgetActions = widget_model.WidgetActions;
pub const WidgetSemantics = widget_model.WidgetSemantics;
pub const WidgetContextMenuItem = widget_model.WidgetContextMenuItem;
pub const CodeDiffLines = widget_model.CodeDiffLines;
pub const Widget = widget_model.Widget;
pub const BuiltinComponentOptions = widget_model.BuiltinComponentOptions;
pub const WidgetCommandPart = widget_model.WidgetCommandPart;
+48 -6
View File
@@ -2321,6 +2321,12 @@ pub fn Ui(comptime Msg: type) type {
/// Prefix each logical source line with a muted, monospace
/// number. Off by default.
line_numbers: bool = false,
/// One-based logical source lines rendered with Geist Code
/// Block's green added-line wash and a renderer-owned `+`.
added_lines: []const usize = &.{},
/// One-based logical source lines rendered with Geist Code
/// Block's red removed-line wash and a renderer-owned `-`.
removed_lines: []const usize = &.{},
/// Word-wrap long source lines. `false` keeps logical lines
/// intact inside one horizontal scroll region.
wrap: bool = true,
@@ -2343,6 +2349,20 @@ pub fn Ui(comptime Msg: type) type {
const terminal_editor_line = options.editable and source.len > 0 and source[source.len - 1] == '\n';
const numbered = options.line_numbers and line_count - @intFromBool(terminal_editor_line) <=
(if (options.editable) max_editable_code_lines else max_code_lines);
const added_lines = self.codeLineMask(options.added_lines);
const removed_lines = self.codeLineMask(options.removed_lines);
if (added_lines & removed_lines != 0) self.failed = true;
// Read-only presentation stays one bounded paragraph only up to
// the same logical-line ceiling as its renderer-owned numbers.
// Longer sources keep every byte and omit diff decoration.
const diff_bounded = options.editable or line_count <= max_code_lines;
const displayed_added_lines = if (diff_bounded) added_lines else 0;
const displayed_removed_lines = if (diff_bounded) removed_lines else 0;
const decorated = displayed_added_lines != 0 or displayed_removed_lines != 0;
const diff_lines: ?canvas.CodeDiffLines = if (decorated) .{
.added = displayed_added_lines,
.removed = displayed_removed_lines,
} else null;
if (options.editable) {
const retained = if (options.wrap) blk: {
// Wrapped editor geometry still uses the bounded span
@@ -2388,13 +2408,20 @@ pub fn Ui(comptime Msg: type) type {
@intCast(decimalDigits(line_count))
else
0;
if (diff_lines) |lines| editor.widget.setCodeDiffLines(lines);
editor.widget.code_editor = true;
editor.widget.code_language = options.language;
editor.widget.layout.clip_content = true;
return editor;
}
const content = if (numbered)
self.numberedCodeParagraph(source, options.language, options.wrap, line_count)
const content = if (numbered or decorated)
self.decoratedCodeParagraph(
source,
options.language,
options.wrap,
if (numbered) @intCast(decimalDigits(line_count)) else 0,
diff_lines,
)
else
self.codeParagraphChunks(source, options.language, options.wrap);
const body = if (options.wrap)
@@ -2440,19 +2467,34 @@ pub fn Ui(comptime Msg: type) type {
/// 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(
fn decoratedCodeParagraph(
self: *Self,
source: []const u8,
language: code_model.Language,
wrap: bool,
line_count: usize,
line_number_digits: u8,
diff_lines: ?canvas.CodeDiffLines,
) 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));
source_node.widget.code_line_number_digits = line_number_digits;
if (diff_lines) |lines| source_node.widget.setCodeDiffLines(lines);
return source_node;
}
fn codeLineMask(self: *Self, lines: []const usize) u128 {
var mask: u128 = 0;
for (lines) |line| {
if (line == 0 or line > code_model.max_diff_lines) {
self.failed = true;
continue;
}
const shift: u7 = @intCast(line - 1);
mask |= @as(u128, 1) << shift;
}
return mask;
}
/// 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
@@ -2521,7 +2563,7 @@ pub fn Ui(comptime Msg: type) type {
budget,
);
chunks[chunk_index].static_text_group_fingerprint = group_fingerprint;
chunks[chunk_index].widget.static_text_group_offset = chunk_start;
chunks[chunk_index].widget.static_text_group_offset = @intCast(chunk_start);
chunk_index += 1;
chunk_start = chunk_end;
}
+38 -1
View File
@@ -1868,8 +1868,9 @@ pub const markdown_on_details_message = "on-details takes a bare Msg tag whose p
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, editable, on-input, line-numbers, wrap, width, height, min-width, grow, key, global-key, and label";
pub const code_attr_message = "unknown attribute for code - it takes source, language, editable, on-input, line-numbers, added-lines, removed-lines, 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/mjs, typescript/ts, jsx/tsx, json, yaml/yml, shell/sh/bash/zsh, python/py, rust/rs, c/cpp/c++/csharp/java/kotlin/swift, go, html/xml/svg, css/scss/less, sql, or markdown/md";
pub const code_diff_lines_message = "added-lines and removed-lines take one-based lines/ranges up to 128 (for example \"2-4, 7\") or one text {binding} producing that form";
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>)";
@@ -2233,6 +2234,27 @@ fn codeLanguageName(name: []const u8) bool {
return false;
}
fn codeLineNumberSpec(spec_raw: []const u8) bool {
const spec = std.mem.trim(u8, spec_raw, " \t\r\n");
if (spec.len == 0) return true;
var pieces = std.mem.splitScalar(u8, spec, ',');
while (pieces.next()) |piece_raw| {
const piece = std.mem.trim(u8, piece_raw, " \t\r\n");
if (piece.len == 0) return false;
const dash = std.mem.indexOfScalar(u8, piece, '-');
const first_text = std.mem.trim(u8, if (dash) |index| piece[0..index] else piece, " \t");
const last_text = if (dash) |index| std.mem.trim(u8, piece[index + 1 ..], " \t") else first_text;
if (first_text.len == 0 or last_text.len == 0) return false;
if (dash) |index| {
if (std.mem.indexOfScalar(u8, piece[index + 1 ..], '-') != null) return false;
}
const first = std.fmt.parseInt(usize, first_text, 10) catch return false;
const last = std.fmt.parseInt(usize, last_text, 10) catch return false;
if (first == 0 or last < first or last > 128) return false;
}
return true;
}
/// `<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 {
@@ -2252,6 +2274,21 @@ fn validateCode(node: MarkupNode) ?MarkupErrorInfo {
}
continue;
}
if (std.mem.eql(u8, attribute.name, "added-lines") or
std.mem.eql(u8, attribute.name, "removed-lines"))
{
if (attrExpressionError(attribute.value, code_diff_lines_message)) |message| {
return attrError(node, attribute, message);
}
const expression = parseAttrExpression(attribute.value);
if (expression == null or expression.? == .equals) {
return attrError(node, attribute, code_diff_lines_message);
}
if (expression.? == .literal and !codeLineNumberSpec(expression.?.literal)) {
return attrError(node, attribute, code_diff_lines_message);
}
continue;
}
if (std.mem.eql(u8, attribute.name, "line-numbers") or
std.mem.eql(u8, attribute.name, "wrap") or
std.mem.eql(u8, attribute.name, "editable"))
@@ -791,6 +791,8 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
std.mem.eql(u8, attribute.name, "editable") or
std.mem.eql(u8, attribute.name, "on-input") or
std.mem.eql(u8, attribute.name, "line-numbers") or
std.mem.eql(u8, attribute.name, "added-lines") or
std.mem.eql(u8, attribute.name, "removed-lines") 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
@@ -815,6 +817,8 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
};
var options: Ui.CodeOptions = .{};
var added_lines_storage: [canvas.code.max_diff_lines]usize = undefined;
var removed_lines_storage: [canvas.code.max_diff_lines]usize = undefined;
if (comptime (node.attr("language") != null)) {
const name = comptime blk: {
const expression = markup.parseAttrExpression(node.attr("language").?) orelse fail(node, markup.code_language_message);
@@ -827,6 +831,20 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
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("added-lines") != null)) {
const spec = codeLineSpecAttr(node, entries, comptime node.attr("added-lines").?, ui, model, scope);
options.added_lines = canvas.code.parseLineNumberSpec(spec, &added_lines_storage) orelse blk: {
ui.failed = true;
break :blk added_lines_storage[0..0];
};
}
if (comptime (node.attr("removed-lines") != null)) {
const spec = codeLineSpecAttr(node, entries, comptime node.attr("removed-lines").?, ui, model, scope);
options.removed_lines = canvas.code.parseLineNumberSpec(spec, &removed_lines_storage) orelse blk: {
ui.failed = true;
break :blk removed_lines_storage[0..0];
};
}
if (comptime (node.attr("editable") != null)) {
options.editable = videoFlagValue(node, entries, comptime node.attr("editable").?, ui, model, scope);
}
@@ -863,6 +881,16 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res
return ui.code(options, source);
}
fn codeLineSpecAttr(comptime node: markup.MarkupNode, comptime entries: []const ScopeEntry, comptime raw: []const u8, ui: *Ui, model: *const ModelT, scope: anytype) []const u8 {
const expression = comptime (markup.parseAttrExpression(raw) orelse fail(node, markup.code_diff_lines_message));
if (comptime expression == .literal) return expression.literal;
comptime requireVariant(exprVariant(node, entries, raw), &.{.string}, node, markup.code_diff_lines_message);
return switch (evalExpr(node, entries, raw, ui, model, scope)) {
.string => |text| text,
else => runtimeFail([]const u8, ui),
};
}
fn markdownLinkConstructor(comptime node: markup.MarkupNode, comptime raw: []const u8) Ui.LinkMsgFn {
comptime {
@setEvalBranchQuota(10_000);
@@ -1356,6 +1356,15 @@ const Checker = struct {
_ = try self.attrKind(node, attribute, attribute.value);
continue;
}
if (std.mem.eql(u8, attribute.name, "added-lines") or
std.mem.eql(u8, attribute.name, "removed-lines"))
{
const expression = markup.parseAttrExpression(attribute.value) orelse continue;
if (expression == .literal) continue;
const kind = try self.attrKind(node, attribute, attribute.value);
try self.requireAttrKind(node, attribute, kind, &.{.string}, markup.code_diff_lines_message);
continue;
}
if (std.mem.eql(u8, attribute.name, "on-input")) {
try self.checkMessageAttr(node, attribute);
continue;
+28
View File
@@ -807,6 +807,8 @@ pub fn MarkupView(comptime ModelT: type, comptime MsgT: type) type {
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;
var added_lines_storage: [canvas.code.max_diff_lines]usize = undefined;
var removed_lines_storage: [canvas.code.max_diff_lines]usize = undefined;
for (node.attrs) |attribute| {
if (std.mem.eql(u8, attribute.name, "kind")) continue;
if (std.mem.eql(u8, attribute.name, "source")) {
@@ -829,6 +831,32 @@ pub fn MarkupView(comptime ModelT: type, comptime MsgT: type) type {
options.line_numbers = try self.codeFlagAttr(scope, node, attribute);
continue;
}
if (std.mem.eql(u8, attribute.name, "added-lines")) {
const typed = markup.parseAttrExpression(attribute.value) orelse
return self.failNode(node, markup.code_diff_lines_message);
const spec = if (typed == .literal)
typed.literal
else switch (try self.evalAttrExpression(scope, node, attribute)) {
.string => |text| text,
else => return self.failNode(node, markup.code_diff_lines_message),
};
options.added_lines = canvas.code.parseLineNumberSpec(spec, &added_lines_storage) orelse
return self.failNode(node, markup.code_diff_lines_message);
continue;
}
if (std.mem.eql(u8, attribute.name, "removed-lines")) {
const typed = markup.parseAttrExpression(attribute.value) orelse
return self.failNode(node, markup.code_diff_lines_message);
const spec = if (typed == .literal)
typed.literal
else switch (try self.evalAttrExpression(scope, node, attribute)) {
.string => |text| text,
else => return self.failNode(node, markup.code_diff_lines_message),
};
options.removed_lines = canvas.code.parseLineNumberSpec(spec, &removed_lines_storage) orelse
return self.failNode(node, markup.code_diff_lines_message);
continue;
}
if (std.mem.eql(u8, attribute.name, "editable")) {
options.editable = try self.codeFlagAttr(scope, node, attribute);
continue;
+10 -2
View File
@@ -1947,11 +1947,12 @@ pub const CodeModel = struct {
show_lines: bool = true,
wrap_code: bool = false,
editable_code: bool = false,
added_spec: []const u8 = "2",
count: usize = 3,
};
pub const code_markup_source =
\\<code source="{snippet}" language="tsx" editable="{editable_code}" on-input="edit" line-numbers="{show_lines}" wrap="{wrap_code}" width="240" label="Example code" />
\\<code source="{snippet}" language="tsx" editable="{editable_code}" on-input="edit" line-numbers="{show_lines}" added-lines="{added_spec}" removed-lines="3" wrap="{wrap_code}" width="240" label="Example code" />
;
pub const CodeUi = canvas.Ui(CodeMsg);
@@ -1962,6 +1963,8 @@ pub fn handCodeView(ui: *CodeUi, model: *const CodeModel) CodeUi.Node {
.editable = model.editable_code,
.on_input = CodeUi.inputMsg(.edit),
.line_numbers = model.show_lines,
.added_lines = &.{2},
.removed_lines = &.{3},
.wrap = model.wrap_code,
.width = 240,
.semantics = .{ .label = "Example code" },
@@ -1995,7 +1998,9 @@ test "code markup builds the reusable component with opt-in numbers and horizont
try testing.expect(markup_tree.root.style.radius == null);
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.expectEqual(@as(u8, 1), source.codeLineNumberDigits());
try testing.expectEqual(@as(u128, 1) << 1, source.codeDiffLines().?.added);
try testing.expectEqual(@as(u128, 1) << 2, source.codeDiffLines().?.removed);
try testing.expectEqualStrings("Example code", markup_tree.root.semantics.label);
var editable_model = model;
@@ -2051,6 +2056,9 @@ test "code markup misuse reports the component's closed contract" {
.{ .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}\" added-lines=\"0\" />", .message = canvas.ui_markup.code_diff_lines_message },
.{ .source = "<code source=\"{snippet}\" removed-lines=\"4-2\" />", .message = canvas.ui_markup.code_diff_lines_message },
.{ .source = "<code source=\"{snippet}\" removed-lines=\"{count}\" />", .message = canvas.ui_markup.code_diff_lines_message, .model_agnostic = false },
.{ .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 },
};
+5
View File
@@ -571,6 +571,11 @@ pub const attrs = [_]AttrInfo{
// hierarchy without manufacturing nested layout containers. Zero (the
// default when absent) keeps structural widget nesting as the source.
.{ .code = 93, .name = "tree-level", .class = .whole, .group = .option, .field = "tree_level" },
// Code diff metadata follows Geist Code Block's added/removed line
// declarations. Values are one-based comma/range text specs so markup
// can state compact runs (`2-4, 7`) without putting +/- bytes in source.
.{ .code = 94, .name = "added-lines", .class = .text, .group = .composite },
.{ .code = 95, .name = "removed-lines", .class = .text, .group = .composite },
};
// ----------------------------------------------------------------- events
+4 -3
View File
@@ -22,7 +22,7 @@ test "registry codes are stable: assigned at birth, never renumbered or renamed"
// the new fingerprint ONLY for additions; renames/renumbers are
// schema-version-bump events, not silent edits.
try testing.expectEqual(@as(usize, 70), schema.elements.len);
try testing.expectEqual(@as(usize, 93), schema.attrs.len);
try testing.expectEqual(@as(usize, 95), schema.attrs.len);
try testing.expectEqual(@as(usize, 14), schema.events.len);
// The element table runs through the span composite (64), the
// bubble-reactions composite (65), the media surface (66), the
@@ -44,9 +44,10 @@ test "registry codes are stable: assigned at birth, never renumbered or renamed"
// attributes axis (86) and value-x (87), and the terminal
// attributes pty (88) and scrollback (89), and the code language
// (90), line-numbers (91), and editable-code (92) declarations,
// plus the flat disclosure-tree hierarchy level tree-level (93).
// the flat disclosure-tree hierarchy level tree-level (93), and the
// code-diff line sets added-lines (94) and removed-lines (95).
try testing.expectEqual(
@as(u64, 0x590814a0a23b9935),
@as(u64, 0xf16fa9fd1d3e7f25),
tableFingerprint(schema.AttrInfo, &schema.attrs),
);
// The event table runs through the pointer-hover containment pair
+10 -1
View File
@@ -260,6 +260,8 @@ fn terminalBindingClean(previous: widget_model.TerminalBinding, next: widget_mod
}
fn widgetChange(previous: WidgetLayoutNode, next: WidgetLayoutNode, previous_index: usize, next_index: usize, tokens: DesignTokens) WidgetInvalidation {
const previous_diff_lines = previous.widget.codeDiffLines();
const next_diff_lines = next.widget.codeDiffLines();
const layout_dirty =
previous.widget.kind != next.widget.kind or
previous.depth != next.depth or
@@ -271,7 +273,9 @@ fn widgetChange(previous: WidgetLayoutNode, next: WidgetLayoutNode, previous_ind
!textSpansEqual(previous.widget.spans, next.widget.spans) or
previous.widget.code_language != next.widget.code_language 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
!codeDiffLinesEqual(previous_diff_lines, next_diff_lines) or
(previous_diff_lines == null and next_diff_lines == null and
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
@@ -446,6 +450,11 @@ fn optionalPointsEqual(a: ?geometry.PointF, b: ?geometry.PointF) bool {
return b == null;
}
fn codeDiffLinesEqual(a: ?widget_model.CodeDiffLines, b: ?widget_model.CodeDiffLines) bool {
if (a == null or b == null) return a == null and b == null;
return a.?.added == b.?.added and a.?.removed == b.?.removed;
}
/// The paint bounds of every `.input_group` ancestor's focus ring for a
/// focus-visible widget id (null when the id resolves outside any group).
fn inputGroupFocusWithinBounds(layout: anytype, maybe_id: ?ObjectId, tokens: DesignTokens) ?geometry.RectF {
+14 -9
View File
@@ -114,17 +114,22 @@ pub fn widgetTextSpanLayoutOptions(widget: Widget, tokens: DesignTokens, max_wid
const min_code_line_number_digits: usize = 3;
/// Width reserved before a numbered code paragraph: at least three muted
/// monospace marker columns (or the largest marker when it is wider) plus
/// the component's fixed marker-to-source gap. Marker bytes never live in
/// `Widget.text`; paint selects them from a compile-time table.
/// Width reserved before a decorated code paragraph: numbered blocks keep
/// at least three muted monospace columns, while a diff without numbers
/// keeps one +/- column. 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 has_diff = widget.hasCodeDiff();
const line_number_digits = widget.codeLineNumberDigits();
if (line_number_digits == 0 and !has_diff) return 0;
const zeros: [20]u8 = @splat('0');
const digits = @min(
@max(@as(usize, widget.code_line_number_digits), min_code_line_number_digits),
zeros.len,
);
const digits = if (line_number_digits == 0)
1
else
@min(
@max(@as(usize, line_number_digits), min_code_line_number_digits),
zeros.len,
);
return text_model.measureTextWidthForFont(
tokens.text_measure,
tokens.typography.mono_font_id,
+220 -70
View File
@@ -1141,6 +1141,7 @@ fn emitCodeEditorWidget(builder: *Builder, widget: Widget, tokens: DesignTokens)
.fill = colorFill(tokens.colors.surface_subtle),
});
}
try emitVisibleCodeTextSpansWidget(builder, widget, tokens, widget.frame, .{});
if (selection_range) |range| {
if (!range.isCollapsed(widget.text.len)) {
try widget_render_controls.emitWidgetTextSelectionRects(
@@ -1156,7 +1157,6 @@ fn emitCodeEditorWidget(builder: *Builder, widget: Widget, tokens: DesignTokens)
);
}
}
try emitVisibleCodeTextSpansWidget(builder, widget, tokens, widget.frame, .{});
if (selection_range) |range| {
if (!range.isCollapsed(widget.text.len)) {
try emitCodeEditorSelectedGlyphs(
@@ -1204,7 +1204,7 @@ fn emitCodeEditorWidget(builder: *Builder, widget: Widget, tokens: DesignTokens)
// duplicate the retained line-number command IDs and place a second set
// of markers at logical-line rather than visual-row positions.
if (widget.text_no_wrap) {
try emitVisibleEditableCodeLineNumberGutter(builder, widget, tokens, widget.frame, active_row);
try emitVisibleEditableCodeGutter(builder, widget, tokens, widget.frame, active_row);
}
try builder.popClip();
}
@@ -1309,7 +1309,7 @@ fn emitTextSpansWidget(builder: *Builder, widget: Widget, tokens: DesignTokens)
&runs,
);
try emitCodeLineNumberGutter(builder, widget, widget.spans, tokens, content, widget.frame, layout_options, null);
try emitCodeLineDecorations(builder, widget, widget.spans, tokens, content, widget.frame, layout_options, null, true);
// 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
@@ -1538,7 +1538,7 @@ fn emitVisibleCodeTextSpansWidget(
if (!budget.hasCommand(builder)) return;
if (paint.selection_ordinal == null) {
try emitCodeLineNumberGutter(builder, widget, spans, tokens, content, visible_bounds, layout_options, &budget);
try emitCodeLineDecorations(builder, widget, spans, tokens, content, visible_bounds, layout_options, &budget, true);
try emitStaticTextSelectionBounded(builder, widget, tokens, budget.command_ceiling);
}
if (!budget.hasCommand(builder) or budget.remainingText() == 0) return;
@@ -1634,7 +1634,7 @@ fn emitVisibleWrappedEditableCodeLines(
if (paint.selection_ordinal == null) {
var gutter_content = content;
gutter_content.x += widget.value_x;
try emitCodeLineNumberGutter(
try emitCodeLineDecorations(
builder,
widget,
widget.spans,
@@ -1643,6 +1643,7 @@ fn emitVisibleWrappedEditableCodeLines(
visible_bounds,
layout_options,
&budget,
true,
);
}
if (!budget.hasCommand(builder) or budget.remainingText() == 0) return;
@@ -1822,6 +1823,19 @@ fn emitVisibleEditableCodeLines(
var budget = CodeEmissionBudget.init(builder);
if (!budget.hasCommand(builder)) return;
if (paint.selection_ordinal == null) {
try emitCodeLineDecorations(
builder,
widget,
widget.spans,
tokens,
content,
visible_bounds,
layout_options,
&budget,
false,
);
}
var line_runs: [text_spans_model.max_text_span_runs_per_paragraph]text_spans_model.TextSpanRun = undefined;
while (line_start <= widget.text.len and logical_line <= last_line) : (logical_line += 1) {
@@ -1898,17 +1912,19 @@ fn emitVisibleEditableCodeLines(
}
/// The editable no-wrap code path scrolls source glyphs beneath a pinned
/// line-number gutter. Paint that gutter last so source, selection, IME
/// decoration, and the caret all disappear cleanly behind its opaque
/// surface instead of clashing with the anchored markers.
fn emitVisibleEditableCodeLineNumberGutter(
/// number/diff-marker gutter. Paint that gutter last so source, selection,
/// IME decoration, and the caret all disappear cleanly behind its opaque
/// surface instead of clashing with the anchored decorations.
fn emitVisibleEditableCodeGutter(
builder: *Builder,
widget: Widget,
tokens: DesignTokens,
visible_bounds: geometry.RectF,
active_row: ?geometry.RectF,
) Error!void {
if (widget.code_line_number_digits == 0) return;
const has_diff = widget.hasCodeDiff();
const line_number_digits = widget.codeLineNumberDigits();
if (line_number_digits == 0 and !has_diff) return;
var content = widget_metrics.widgetTextSpanContentFrame(widget, tokens);
content.y -= widget.value;
@@ -1972,33 +1988,71 @@ fn emitVisibleEditableCodeLineNumberGutter(
const line_top = content.y + @as(f32, @floatFromInt(logical_line)) * line_height;
const marker_bounds = geometry.RectF.init(padded.x, line_top, marker_width, line_height);
if (marker_bounds.intersects(visible_bounds)) {
var marker_buffer: [20]u8 = undefined;
const marker_formatted = std.fmt.bufPrint(
&marker_buffer,
"{d}",
.{logical_line + 1},
) catch "";
const marker_text = builder.allocTextBytes(marker_formatted) catch "";
if (marker_text.len > 0) {
const one_based_line = logical_line + 1;
if (codeDiffKind(widget, one_based_line)) |kind| {
const diff_gutter = geometry.RectF.intersection(
gutter_bounds,
geometry.RectF.init(gutter_bounds.x, line_top, gutter_bounds.width, line_height),
);
if (!diff_gutter.isEmpty()) {
try builder.fillRect(.{
.id = codeDiffGutterBackgroundCommandId(widget.id, one_based_line),
.rect = pixelSnapGeometryRect(tokens, diff_gutter),
.fill = colorFill(codeDiffBackground(tokens, kind)),
});
}
const marker = switch (kind) {
.added => "+",
.removed => "-",
};
try builder.drawText(.{
.id = codeLineNumberCommandId(widget.id, logical_line + 1),
.id = codeDiffMarkerCommandId(widget.id, one_based_line),
.font_id = tokens.typography.mono_font_id,
.size = layout_options.size,
.origin = pixelSnapTextPoint(tokens, geometry.PointF.init(
padded.x,
line_top + layout_options.size,
)),
.color = tokens.colors.text_muted,
.text = marker_text,
.color = codeDiffForeground(tokens, kind),
.text = marker,
.text_layout = .{
.max_width = marker_width,
.line_height = line_height,
.wrap = .none,
.alignment = .end,
.alignment = .start,
.measure = tokens.text_measure,
},
});
}
if (line_number_digits != 0) {
var marker_buffer: [20]u8 = undefined;
const marker_formatted = std.fmt.bufPrint(
&marker_buffer,
"{d}",
.{one_based_line},
) catch "";
const marker_text = builder.allocTextBytes(marker_formatted) catch "";
if (marker_text.len > 0) {
try builder.drawText(.{
.id = codeLineNumberCommandId(widget.id, one_based_line),
.font_id = tokens.typography.mono_font_id,
.size = layout_options.size,
.origin = pixelSnapTextPoint(tokens, geometry.PointF.init(
padded.x,
line_top + layout_options.size,
)),
.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,
},
});
}
}
}
const newline = std.mem.indexOfScalarPos(u8, widget.text, line_start, '\n') orelse break;
@@ -2006,11 +2060,49 @@ fn emitVisibleEditableCodeLineNumberGutter(
}
}
/// 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(
const CodeDiffKind = enum { added, removed };
fn codeDiffKind(widget: Widget, logical_line: usize) ?CodeDiffKind {
if (logical_line == 0 or logical_line > code_model.max_diff_lines) return null;
const lines = widget.codeDiffLines() orelse return null;
const shift: u7 = @intCast(logical_line - 1);
const bit = @as(u128, 1) << shift;
if (lines.added & bit != 0) return .added;
if (lines.removed & bit != 0) return .removed;
return null;
}
fn codeDiffBackground(tokens: DesignTokens, kind: CodeDiffKind) Color {
// Geist publishes these as component states rather than general-purpose
// surface roles. Both built-in packs share the exact pair; background
// luminance keeps custom light/dark palettes on the matching register.
const background = tokens.colors.background;
const dark = background.r * 0.2126 + background.g * 0.7152 + background.b * 0.0722 < 0.5;
return if (dark)
switch (kind) {
.added => Color.rgb8(18, 54, 27),
.removed => Color.rgb8(86, 26, 30),
}
else switch (kind) {
.added => Color.rgb8(218, 246, 218),
.removed => Color.rgb8(255, 230, 230),
};
}
fn codeDiffForeground(tokens: DesignTokens, kind: CodeDiffKind) Color {
return canvas.colorTokenValue(tokens.colors, switch (kind) {
.added => .syntax_literal,
.removed => .syntax_property,
});
}
/// Geist-style diff washes/markers plus muted logical-line numbers for one
/// coherent code paragraph. Each logical line is measured independently
/// with the same monospace layout options; summing wrapped extents keeps the
/// next marker paired with its source. `draw_gutter=false` is the first pass
/// of a horizontally scrolling editor: it paints the wash before source,
/// while the pinned gutter redraws its marker and number last.
fn emitCodeLineDecorations(
builder: *Builder,
widget: Widget,
spans: []const text_spans_model.TextSpan,
@@ -2019,8 +2111,11 @@ fn emitCodeLineNumberGutter(
visible_bounds: geometry.RectF,
layout_options: text_spans_model.TextSpanLayoutOptions,
budget: ?*CodeEmissionBudget,
draw_gutter: bool,
) Error!void {
if (widget.code_line_number_digits == 0) return;
const has_diff = widget.hasCodeDiff();
const line_number_digits = widget.codeLineNumberDigits();
if (!has_diff and (line_number_digits == 0 or !draw_gutter)) return;
const line_height = text_spans_model.textSpanLineHeight(spans, layout_options);
if (line_height <= 0 or !std.math.isFinite(line_height)) return;
@@ -2036,48 +2131,8 @@ fn emitCodeLineNumberGutter(
if (line_start == widget.text.len and widget.text.len > 0 and !widget.code_editor) 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)) {
var marker_buffer: [20]u8 = undefined;
const marker_formatted = std.fmt.bufPrint(
&marker_buffer,
"{d}",
.{logical_line},
) catch "";
const marker_text = builder.allocTextBytes(marker_formatted) catch "";
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 visual_count = if (line.len == 0) 1 else blk: {
const line_spans = [_]text_spans_model.TextSpan{.{
.text = line,
.monospace = true,
@@ -2088,8 +2143,91 @@ fn emitCodeLineNumberGutter(
layout_options,
&line_runs,
);
visual_line += @max(1, line_layout.line_count);
break :blk @max(1, line_layout.line_count);
};
const baseline = content.y + layout_options.size +
@as(f32, @floatFromInt(visual_line)) * line_height;
const row_bounds = geometry.RectF.init(
widget.frame.x,
baseline - layout_options.size,
widget.frame.width,
@as(f32, @floatFromInt(visual_count)) * line_height,
);
const diff_kind = codeDiffKind(widget, logical_line);
if (diff_kind) |kind| {
const visible_row = geometry.RectF.intersection(row_bounds, visible_bounds);
if (!visible_row.isEmpty()) {
if (budget) |admission| if (!admission.hasCommand(builder)) return;
try builder.fillRect(.{
.id = codeDiffBackgroundCommandId(widget.id, logical_line),
.rect = pixelSnapGeometryRect(tokens, visible_row),
.fill = colorFill(codeDiffBackground(tokens, kind)),
});
}
}
const marker_bounds = geometry.RectF.init(
padded.x,
baseline - layout_options.size,
marker_width,
line_height,
);
if (draw_gutter and marker_bounds.intersects(visible_bounds)) {
if (diff_kind) |kind| {
if (budget) |admission| {
if (!admission.hasCommand(builder) or admission.remainingText() == 0) return;
}
const marker = switch (kind) {
.added => "+",
.removed => "-",
};
try builder.drawText(.{
.id = codeDiffMarkerCommandId(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 = codeDiffForeground(tokens, kind),
.text = marker,
.text_layout = .{
.max_width = marker_width,
.line_height = line_height,
.wrap = .none,
.alignment = .start,
.measure = tokens.text_measure,
},
});
if (budget) |admission| admission.chargeText(marker.len);
}
if (line_number_digits != 0) {
var marker_buffer: [20]u8 = undefined;
const marker_formatted = std.fmt.bufPrint(
&marker_buffer,
"{d}",
.{logical_line},
) catch "";
const marker_text = builder.allocTextBytes(marker_formatted) catch "";
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);
}
}
visual_line += visual_count;
if (newline == null) break;
line_start = line_end + 1;
}
@@ -2171,6 +2309,18 @@ fn codeLineNumberGutterCommandId(widget_id: ObjectId) ObjectId {
return textSpanCommandId(0x5eed_59a2_0000_0015, widget_id, 0);
}
fn codeDiffBackgroundCommandId(widget_id: ObjectId, logical_line: usize) ObjectId {
return textSpanCommandId(0x5eed_59a2_0000_0019, widget_id, logical_line);
}
fn codeDiffGutterBackgroundCommandId(widget_id: ObjectId, logical_line: usize) ObjectId {
return textSpanCommandId(0x5eed_59a2_0000_001b, widget_id, logical_line);
}
fn codeDiffMarkerCommandId(widget_id: ObjectId, logical_line: usize) ObjectId {
return textSpanCommandId(0x5eed_59a2_0000_001a, widget_id, logical_line);
}
fn codeEditorActiveRowCommandId(widget_id: ObjectId, ordinal: usize) ObjectId {
return textSpanCommandId(0x5eed_59a2_0000_0016, widget_id, ordinal);
}
@@ -477,6 +477,7 @@ fn widgetTextInputFontId(widget: Widget, tokens: DesignTokens) FontId {
}
fn codeContentWidthCacheCurrent(widget: Widget, font_id: FontId, text_size: f32) bool {
if (widget.hasCodeDiff()) return false;
return widget.code_content_width_generation == text_measure_cache.textMeasureGeneration() and
widget.code_content_width_font_id == font_id and
widget.code_content_width_size_bits == @as(u32, @bitCast(text_size)) and
@@ -489,6 +490,7 @@ fn codeContentWidthCacheCurrent(widget: Widget, font_id: FontId, text_size: f32)
/// edits invalidate it before caret scrolling recomputes the new document.
pub fn cacheTextInputContentWidthForWidget(widget: *Widget, tokens: DesignTokens) void {
if (widget.kind != .textarea or !widget.code_editor or !widget.text_no_wrap) return;
if (widget.hasCodeDiff()) return;
const text_size = widgetTextInputSize(widget.*, tokens);
const font_id = widgetTextInputFontId(widget.*, tokens);
if (codeContentWidthCacheCurrent(widget.*, font_id, text_size)) return;
+47 -2
View File
@@ -264,6 +264,13 @@ pub const WidgetState = struct {
invalid: bool = false,
};
/// Two 128-line masks for code-only diff presentation. `Widget` packs them
/// into fields dormant on decorated code so ordinary widgets do not grow.
pub const CodeDiffLines = struct {
added: u128,
removed: u128,
};
pub const WidgetRenderState = struct {
/// Whether the app is active and this widget tree's window is key.
/// Runtime focus ids stay retained while false so focus-visible
@@ -875,6 +882,8 @@ pub const Widget = struct {
/// 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.
/// Bit 7 is an internal diff-metadata tag; `codeLineNumberDigits`
/// exposes only the authored digit count.
code_line_number_digits: u8 = 0,
/// Optional one-based logical depth for a flat sequence of tree rows.
/// Zero keeps the structural nesting contract. A nonzero value lets a
@@ -903,9 +912,10 @@ pub const Widget = struct {
/// 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.
/// bytes without duplicating the full source in retained storage. Fixed
/// width keeps the code-diff metadata union intact on 32-bit targets.
static_text_group_id: ObjectId = 0,
static_text_group_offset: usize = 0,
static_text_group_offset: u64 = 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
@@ -1067,6 +1077,41 @@ pub const Widget = struct {
/// serialization, or equality decisions.
group_segment: WidgetGroupSegment = .none,
children: []const Widget = &.{},
pub fn codeLineNumberDigits(self: Widget) u8 {
return self.code_line_number_digits & 0x7f;
}
pub fn hasCodeDiff(self: Widget) bool {
return self.code_line_number_digits & 0x80 != 0;
}
/// Code-only metadata occupies the otherwise unused no-wrap width-cache
/// words plus the ungrouped static-text offset. The high bit above tags
/// the union. This keeps Widget's common footprint unchanged; a diff
/// editor simply measures its longest line on demand instead of caching.
pub fn codeDiffLines(self: Widget) ?CodeDiffLines {
if (!self.hasCodeDiff()) return null;
const removed_low = @as(u64, @as(u32, @bitCast(self.code_content_width))) |
(@as(u64, self.code_content_width_size_bits) << 32);
return .{
.added = @as(u128, self.code_content_width_generation) |
(@as(u128, self.code_content_width_font_id) << 64),
.removed = @as(u128, removed_low) |
(@as(u128, self.static_text_group_offset) << 64),
};
}
pub fn setCodeDiffLines(self: *Widget, lines: CodeDiffLines) void {
std.debug.assert(self.static_text_group_id == 0);
self.code_line_number_digits |= 0x80;
self.code_content_width_generation = @truncate(lines.added);
self.code_content_width_font_id = @truncate(lines.added >> 64);
const removed_low: u64 = @truncate(lines.removed);
self.code_content_width = @bitCast(@as(u32, @truncate(removed_low)));
self.code_content_width_size_bits = @truncate(removed_low >> 32);
self.static_text_group_offset = @truncate(lines.removed >> 64);
}
};
pub const BuiltinComponentOptions = struct {
@@ -540,7 +540,7 @@ test "grouped code paragraphs select and copy across chunk boundaries" {
.text = second,
.spans = &second_spans,
.static_text_group_id = group_id,
.static_text_group_offset = first.len,
.static_text_group_offset = @intCast(first.len),
},
.{
.id = 4,
+11 -8
View File
@@ -654,6 +654,7 @@ pub fn collectCanvasWidgetTextReconcileEntries(
CanvasWidgetSourceTextFingerprint{ .len = entry.text_len, .hash = entry.text_hash }
else
canvasWidgetSourceTextFingerprint(node.widget.text);
const has_code_diff = node.widget.hasCodeDiff();
output[len] = .{
.id = node.widget.id,
.kind = node.widget.kind,
@@ -666,10 +667,10 @@ pub fn collectCanvasWidgetTextReconcileEntries(
.text_composition = node.widget.text_composition,
.value = node.widget.value,
.value_x = node.widget.value_x,
.code_content_width = node.widget.code_content_width,
.code_content_width_generation = node.widget.code_content_width_generation,
.code_content_width_font_id = node.widget.code_content_width_font_id,
.code_content_width_size_bits = node.widget.code_content_width_size_bits,
.code_content_width = if (!has_code_diff) node.widget.code_content_width else 0,
.code_content_width_generation = if (!has_code_diff) node.widget.code_content_width_generation else 0,
.code_content_width_font_id = if (!has_code_diff) node.widget.code_content_width_font_id else 0,
.code_content_width_size_bits = if (!has_code_diff) node.widget.code_content_width_size_bits else 0,
};
len += 1;
}
@@ -879,10 +880,12 @@ pub fn canvasWidgetLayoutNodeWithTextReconcileState(
if (canvasWidgetEditableTextKind(copy.widget.kind)) copy.widget.value = entry.value;
if (copy.widget.code_editor) {
copy.widget.value_x = entry.value_x;
copy.widget.code_content_width = entry.code_content_width;
copy.widget.code_content_width_generation = entry.code_content_width_generation;
copy.widget.code_content_width_font_id = entry.code_content_width_font_id;
copy.widget.code_content_width_size_bits = entry.code_content_width_size_bits;
if (!copy.widget.hasCodeDiff()) {
copy.widget.code_content_width = entry.code_content_width;
copy.widget.code_content_width_generation = entry.code_content_width_generation;
copy.widget.code_content_width_font_id = entry.code_content_width_font_id;
copy.widget.code_content_width_size_bits = entry.code_content_width_size_bits;
}
}
if (copy.widget.text_selection == null and copy.widget.text_composition == null) {
copy.widget.text_selection = entry.text_selection;
+6 -4
View File
@@ -1176,7 +1176,7 @@ pub fn RuntimeViewCanvasWidgetText(comptime RuntimeView: type) type {
}
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 focus = @as(usize, @intCast(selected_widget.static_text_group_offset)) + point_offset;
const anchor = if (extend)
self.canvas_widget_selected_text_group_anchor
else
@@ -1191,7 +1191,7 @@ pub fn RuntimeViewCanvasWidgetText(comptime RuntimeView: type) type {
if (candidate.static_text_group_id != group_id) {
continue;
}
const source_start = candidate.static_text_group_offset;
const source_start: usize = @intCast(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)
@@ -1321,7 +1321,7 @@ pub fn RuntimeViewCanvasWidgetText(comptime RuntimeView: type) type {
if (widget.static_text_group_id != group_id) {
continue;
}
const source_start = widget.static_text_group_offset;
const source_start: usize = @intCast(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);
@@ -1378,7 +1378,9 @@ pub fn RuntimeViewCanvasWidgetText(comptime RuntimeView: type) type {
self.widget_layout_nodes[edited_index].widget.text_selection = next_state.selection;
self.widget_layout_nodes[edited_index].widget.text_composition = next_state.composition;
if (text_changed) {
self.widget_layout_nodes[edited_index].widget.code_content_width_generation = 0;
if (!self.widget_layout_nodes[edited_index].widget.hasCodeDiff()) {
self.widget_layout_nodes[edited_index].widget.code_content_width_generation = 0;
}
canvas.cacheTextInputContentWidthForWidget(
&self.widget_layout_nodes[edited_index].widget,
self.widget_tokens,
+23
View File
@@ -172,6 +172,7 @@ pub const scenes = [_]Scene{
.{ .name = "skeleton", .height = 200, .build = stateless(buildSkeleton) },
.{ .name = "spinner", .height = 140, .build = stateless(buildSpinner) },
.{ .name = "code", .height = 300, .build = stateless(buildCode) },
.{ .name = "code-diff", .height = 220, .build = stateless(buildCodeDiff) },
.{ .name = "markdown", .height = 440, .build = stateless(buildMarkdown) },
.{ .name = "media-surface", .height = 280, .build = stateless(buildMediaSurface) },
.{ .name = "video", .height = 300, .build = stateless(buildVideo) },
@@ -1069,6 +1070,15 @@ const code_sample =
\\</Accordion>
;
const code_diff_sample =
\\module.exports = {
\\ experimental: {
\\ appDir: true,
\\ },
\\ appDir: true,
\\}
;
fn buildCode(ui: *Ui) Node {
return tileStart(ui, .{
ui.code(.{
@@ -1080,6 +1090,19 @@ fn buildCode(ui: *Ui) Node {
});
}
fn buildCodeDiff(ui: *Ui) Node {
return tileStart(ui, .{
ui.code(.{
.language = .javascript,
.line_numbers = true,
.added_lines = &.{5},
.removed_lines = &.{ 2, 3, 4 },
.wrap = false,
.width = 496,
}, code_diff_sample),
});
}
fn buildMarkdown(ui: *Ui) Node {
return tileStart(ui, .{
Md.view(ui, markdown_sample, .{}),
+14
View File
@@ -214,6 +214,20 @@ export fn preview_instance_bytes() usize {
return @sizeOf(Preview);
}
/// Keep the diff mask's high half honest on wasm32: code widgets reuse a
/// fixed-width metadata slot that must retain lines 97-128 even though
/// `usize` is only 32 bits in this module.
export fn preview_code_diff_metadata_round_trip() u32 {
var widget = canvas.Widget{ .kind = .text };
const expected = canvas.CodeDiffLines{
.added = @as(u128, 1) << 126,
.removed = @as(u128, 1) << 127,
};
widget.setCodeDiffLines(expected);
const actual = widget.codeDiffLines() orelse return 0;
return @intFromBool(actual.added == expected.added and actual.removed == expected.removed);
}
// ---------------------------------------------------------- lifecycle
export fn preview_create(name_ptr: ?[*]const u8, name_len: usize, dark: u32) ?*Preview {
+3 -1
View File
@@ -71,7 +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 = "Bare highlighted source content with no background, border, radius, shadow, or padding. 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. Wrap in a panel or card when chrome is wanted." },
.{ .name = "code", .doc = "Bare highlighted source content with no background, border, radius, shadow, or padding. source is one required text {binding}; language is a literal lexer name. Wraps by default, line-numbers opts into logical line numbers, added-lines/removed-lines add Geist-style diff rows, wrap=\"false\" keeps lines intact, and a definite height makes overflow scrollable. Wrap in a panel or card when chrome is wanted." },
.{ .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." },
@@ -180,6 +180,8 @@ pub const code_attr_docs = [_]Doc{
.{ .name = "language", .doc = "code: literal lexer name. Supports Zig, JavaScript/TypeScript, JSX/TSX, JSON, YAML, shell, Python, Rust, C-family, Go, HTML/XML/SVG, CSS-family, SQL, and Markdown; unknown names are a validation error." },
.{ .name = "editable", .doc = "code: true enables text editing while retaining syntax highlighting; pair it with on-input to apply TextInputEvent updates. Read-only by default." },
.{ .name = "line-numbers", .doc = "code: opt into muted logical line numbers. Off by default; a wrapped logical line stays paired with its number." },
.{ .name = "added-lines", .doc = "code: one-based comma/range spec (for example 5 or 5, 9-11), or one text {binding}; applies Geist's green full-line wash and renderer-owned + without changing copied source. Lines 1-128." },
.{ .name = "removed-lines", .doc = "code: one-based comma/range spec (for example 2-4), or one text {binding}; applies Geist's red full-line wash and renderer-owned - without changing copied source. Lines 1-128." },
.{ .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." },