chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:29:49 +08:00
commit 505bac6b53
1470 changed files with 457757 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"files.associations": { "*.native": "html" }
}
+30
View File
@@ -0,0 +1,30 @@
# markdown-viewer
A split-pane markdown editor/preview authored in markup + Zig: the left pane is a `textarea` mirrored elm-style into the model, the right pane is one `<markdown>` element bound to the same bytes, so the preview tracks every keystroke with no debounce and no drift. The whole view lives in `src/viewer.native` (hot-reloaded in dev builds); `src/main.zig` is the logic — `Model`, `Msg`, `update`, effects, and a two-mode custom theme.
```sh
native dev
```
## What it demonstrates
- **`<markdown>` in markup** — headings on the span scale, inline styles, clickable links (pointer cursor; opened in the system browser through `fx.spawn open`/`xdg-open`), task lists, fenced code, blockquotes, GFM tables with column alignment, and `<details>` blocks whose expansion flags live in the model (`details_expanded: [16]bool`), toggled in `update`.
- **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.
- **Controlled scrolling** — the preview's scroll offset is model-owned: `on-scroll` stores the applied offset, the `value` binding echoes it back, so rebuilds (every keystroke re-renders the preview) never lose the reading position.
- **Derived state, never stored** — word/line/byte counts in the status bar are computed from the live document at view time.
Selection and copy in the preview, native scrolling, and the standard edit context menus in the editor are framework defaults — no app code.
## Bundled documents
The sidebar ships four sample documents embedded from `src/samples/` (they live under `src/` because `@embedFile` is module-rooted there): a README-style welcome with a table, a full renderer tour, a spec with task lists and details blocks, and a notes page.
## Fixed capacities
Documents cap at 16 KiB (`max_document_bytes` — the view retains editor + preview text against the 64 KiB per-view widget-text budget; over-cap opens arrive cut with an explicit `.truncated` outcome, never silently), paths at 512 bytes, the recent list at 6 entries, and `<details>` expansion flags at 16 blocks.
## Tests
`native test` (or root `zig build test-example-markdown-viewer`) drives the real dispatch paths: open/save/save-as round-trips and recent-list persistence through the fake effect executor, link clicks spawning the browser command, details toggling via automation `widget-click`, editor edits updating the preview and derived counts, the system appearance re-deriving the tokens live through platform events, the controlled preview scroll round-trip, compiled/interpreter markup parity, and automation snapshot assertions over links, table cells, and task checkboxes.
+39
View File
@@ -0,0 +1,39 @@
.{
.id = "dev.native_sdk.markdown_viewer",
.name = "markdown-viewer",
.display_name = "Markdown Viewer",
.description = "A native markdown reader with GPU-rendered text.",
.version = "0.1.0",
.platforms = .{"macos"},
.permissions = .{ "view", "command" },
.capabilities = .{ "native_views", "gpu_surfaces" },
.shell = .{
.windows = .{
.{
.label = "main",
.title = "Native SDK Markdown",
.width = 1200,
.height = 760,
// The floor the layout audit sweep proves clean
// (sidebar + editor + preview panes): the window stops
// resizing here instead of clipping the panes below it.
.min_width = 960,
.min_height = 560,
.restore_state = false,
.restore_policy = "center_on_primary",
.titlebar = "hidden_inset_tall",
.views = .{
.{ .label = "viewer-canvas", .kind = "gpu_surface", .fill = true, .role = "Markdown viewer canvas", .accessibility_label = "Markdown viewer", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
},
},
},
},
.security = .{
.navigation = .{
.allowed_origins = .{ "zero://app", "zero://inline" },
.external_links = .{ .action = "deny" },
},
},
.web_engine = "system",
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
}
+691
View File
@@ -0,0 +1,691 @@
//! markdown-viewer: a split-pane markdown editor/preview authored in
//! markup + Zig.
//!
//! The left pane is a `textarea` whose every edit mirrors into the model
//! (`canvas.TextBuffer`, the elm-style pattern); the right pane is one
//! `<markdown>` element bound to the same bytes, so the preview is always
//! exactly the document — no debounce, no cache, no drift. Links open in
//! the system browser through `fx.spawn` (`open`/`xdg-open`), and Open /
//! Save / Save As are real file I/O through `fx.readFile`/`fx.writeFile`
//! against an honest, editable path field — native-sdk has no native
//! file dialogs, so the path field IS the file picker, and the sidebar's
//! recent-files list (itself persisted through the same file effects)
//! makes it livable.
//!
//! Fixed capacities, documented where they bind: documents cap at
//! `max_document_bytes` (16 KiB — the view retains editor + preview text
//! against the 64 KiB per-view widget-text budget), paths at
//! `max_path_bytes`, the recent list at `max_recent` entries, and
//! `<details>` blocks at `max_details` model-owned expansion flags.
const std = @import("std");
const builtin = @import("builtin");
const runner = @import("runner");
const native_sdk = @import("native_sdk");
pub const panic = std.debug.FullPanic(native_sdk.debug.capturePanic);
const canvas = native_sdk.canvas;
const geometry = native_sdk.geometry;
const app_dirs = native_sdk.app_dirs;
const canvas_label = "viewer-canvas";
const window_width: f32 = 1200;
const window_height: f32 = 760;
/// Content min-size floor the window enforces: the smallest size where
/// the sidebar, editor, and preview lay out without clipping or overlap —
/// proven by the layout audit sweep in tests.zig, which sweeps from
/// exactly this floor.
pub const window_min_width: f32 = 960;
pub const window_min_height: f32 = 560;
/// The toolbar's natural height (28px controls + 2x10 padding): the
/// floor `toolbar_height` falls back to when no titlebar band overlays
/// the content (fullscreen, standard chrome, non-macOS).
pub const toolbar_natural_height: f32 = 48;
/// Document capacity. The rendered preview retains the document's plain
/// text alongside the editor's copy, and the per-view retained-text
/// budget is 64 KiB (`canvas_limits.max_canvas_widget_text_bytes_per_view`),
/// so 16 KiB leaves 2x headroom for chrome text and span payloads.
pub const max_document_bytes = 16 * 1024;
/// Path capacity; the effect channel itself binds at 1 KiB
/// (`max_effect_file_path_bytes`).
pub const max_path_bytes = 512;
/// Sidebar recent-files entries (newest first).
pub const max_recent = 6;
/// Model-owned `<details>` expansion flags, indexed by document order.
/// The renderer caps details blocks at 16 per document, matching this.
pub const max_details = 16;
const max_note_bytes = 192;
// Effect keys: caller-chosen identities, one per concurrent operation.
pub const open_key: u64 = 1;
pub const save_key: u64 = 2;
pub const recent_read_key: u64 = 3;
pub const recent_write_key: u64 = 4;
pub const link_key: u64 = 5;
const app_permissions = [_][]const u8{ native_sdk.security.permission_command, native_sdk.security.permission_view };
const shell_views = [_]native_sdk.ShellView{
.{ .label = canvas_label, .kind = .gpu_surface, .fill = true, .role = "Markdown viewer canvas", .accessibility_label = "Markdown viewer", .gpu_backend = .metal, .gpu_pixel_format = .bgra8_unorm, .gpu_present_mode = .timer, .gpu_alpha_mode = .@"opaque", .gpu_color_space = .srgb, .gpu_vsync = true },
};
const shell_windows = [_]native_sdk.ShellWindow{.{
.label = "main",
.title = "Native SDK Markdown",
.width = window_width,
.height = window_height,
.min_width = window_min_width,
.min_height = window_min_height,
.restore_state = false,
// Tall hidden-inset titlebar (declared in app.zon too, which
// threads it through the STARTUP window create): the toolbar row is
// toolbar-height, so the TALL band centers the traffic lights
// against it (the Notes look) instead of parking them high. The
// toolbar is the drag region (`window-drag` in viewer.native), pads
// its leading edge by the chrome insets `on_chrome` delivers, and
// matches its height to the band so its controls and the lights
// share a centerline.
.titlebar = .hidden_inset_tall,
.views = &shell_views,
}};
const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
// ---------------------------------------------------------------- samples
pub const Sample = struct {
id: u32,
title: []const u8,
body: []const u8,
};
pub const welcome_sample_id: u32 = 1;
pub const Model = struct {
/// The document, elm-style: the model applies every textarea edit and
/// is the single source both panes render from.
editor: canvas.TextBuffer(max_document_bytes) = .{},
/// The toolbar path field (also an elm mirror).
path_field: canvas.TextBuffer(max_path_bytes) = .{},
/// The path of the opened/saved document; empty means unsaved.
current_path_storage: [max_path_bytes]u8 = undefined,
current_path_len: usize = 0,
/// The path an in-flight Open/Save As will adopt on success.
pending_path_storage: [max_path_bytes]u8 = undefined,
pending_path_len: usize = 0,
/// Recent files, newest first, persisted via file effects.
recent_storage: [max_recent][max_path_bytes]u8 = undefined,
recent_lens: [max_recent]usize = [_]usize{0} ** max_recent,
recent_count: usize = 0,
/// Where the recent list persists (resolved from the per-app data dir
/// in `main`; empty in tests unless set — persistence then stays off).
recent_path_storage: [max_path_bytes]u8 = undefined,
recent_path_len: usize = 0,
/// `<details>` expansion flags, document order. The markup binds this
/// field directly; `update` toggles it.
details_expanded: [max_details]bool = [_]bool{false} ** max_details,
/// The sidebar sample currently loaded (0 = none: edited or opened).
active_sample_id: u32 = 0,
/// Toolbar sample-picker open state — model-owned (TEA): the anchored
/// dropdown exists only while this is true; `close_sample_picker`
/// (the surface's on-dismiss) and picking both clear it.
sample_picker_open: bool = false,
/// Theme: the app follows the system appearance — the scheme flows
/// in through `on_appearance` and the tokens re-derive from it.
system_scheme: canvas.ColorScheme = .light,
/// One-line activity note for the status bar ("Saved", "Open failed…").
note_storage: [max_note_bytes]u8 = undefined,
note_len: usize = 0,
/// Chrome overlay geometry from `on_chrome` (tall hidden-inset
/// titlebar): the toolbar pads its leading/trailing edges by the
/// insets so its controls clear the traffic lights, and matches its
/// height to the titlebar band so `cross="center"` puts its
/// controls on the lights' centerline (the system centers them in
/// the tall band). Zero insets in fullscreen and on platforms with
/// standard chrome — the height then falls back to the toolbar's
/// natural 48. The view binds the fields directly.
chrome_leading: f32 = 0,
chrome_trailing: f32 = 0,
toolbar_height: f32 = toolbar_natural_height,
/// Preview scroll offset (model-owned; the runtime echoes scrolls
/// back through `doc_scrolled` and the view's `value` binding).
doc_scroll: f32 = 0,
pub const samples = [_]Sample{
.{ .id = welcome_sample_id, .title = "Welcome", .body = @embedFile("samples/welcome.md") },
.{ .id = 2, .title = "Renderer tour", .body = @embedFile("samples/tour.md") },
.{ .id = 3, .title = "RFC: Session sync", .body = @embedFile("samples/spec.md") },
.{ .id = 4, .title = "Reading notes", .body = @embedFile("samples/notes.md") },
};
pub fn sampleById(id: u32) ?*const Sample {
for (&samples) |*sample| {
if (sample.id == id) return sample;
}
return null;
}
// ------------------------------------------------------- view bindings
pub fn document(model: *const Model) []const u8 {
return model.editor.text();
}
pub fn path(model: *const Model) []const u8 {
return model.path_field.text();
}
pub fn pathEmpty(model: *const Model) bool {
return model.path_field.isEmpty();
}
pub fn cannotSave(model: *const Model) bool {
return model.current_path_len == 0;
}
pub fn currentPath(model: *const Model) []const u8 {
return model.current_path_storage[0..model.current_path_len];
}
pub fn docTitle(model: *const Model) []const u8 {
if (sampleById(model.active_sample_id)) |sample| return sample.title;
if (model.current_path_len > 0) return pathBasename(model.currentPath());
return "Untitled";
}
pub const RecentDoc = struct {
index: usize,
name: []const u8,
path: []const u8,
};
pub fn recentDocs(model: *const Model, arena: std.mem.Allocator) []const RecentDoc {
const out = arena.alloc(RecentDoc, model.recent_count) catch return &.{};
for (out, 0..) |*slot, index| {
const entry = model.recentAt(index);
slot.* = .{ .index = index, .name = pathBasename(entry), .path = entry };
}
return out;
}
pub fn statusLine(model: *const Model, arena: std.mem.Allocator) []const u8 {
const text = model.editor.text();
const activity = model.note();
if (activity.len == 0) {
return std.fmt.allocPrint(arena, "{d} words · {d} lines · {d} bytes", .{
countWords(text), countLines(text), text.len,
}) catch "";
}
return std.fmt.allocPrint(arena, "{d} words · {d} lines · {d} bytes · {s}", .{
countWords(text), countLines(text), text.len, activity,
}) catch "";
}
// ----------------------------------------------------------- mutation
pub fn note(model: *const Model) []const u8 {
return model.note_storage[0..model.note_len];
}
pub fn setNote(model: *Model, comptime fmt: []const u8, args: anytype) void {
const written = std.fmt.bufPrint(&model.note_storage, fmt, args) catch {
model.note_len = 0;
return;
};
model.note_len = written.len;
}
pub fn loadSample(model: *Model, id: u32) void {
const sample = sampleById(id) orelse return;
model.editor.set(sample.body);
model.active_sample_id = id;
model.current_path_len = 0;
model.details_expanded = [_]bool{false} ** max_details;
// A different document starts at its top — the controlled scroll
// would otherwise echo the old document's offset into the new one.
model.doc_scroll = 0;
model.note_len = 0;
}
pub fn setPendingPath(model: *Model, value: []const u8) void {
const len = @min(value.len, max_path_bytes);
@memcpy(model.pending_path_storage[0..len], value[0..len]);
model.pending_path_len = len;
}
pub fn pendingPath(model: *const Model) []const u8 {
return model.pending_path_storage[0..model.pending_path_len];
}
pub fn adoptPendingPath(model: *Model) void {
@memcpy(model.current_path_storage[0..model.pending_path_len], model.pendingPath());
model.current_path_len = model.pending_path_len;
model.path_field.set(model.currentPath());
}
pub fn recentAt(model: *const Model, index: usize) []const u8 {
return model.recent_storage[index][0..model.recent_lens[index]];
}
/// Move `value` to the front of the recent list, deduplicated.
pub fn pushRecent(model: *Model, value: []const u8) void {
if (value.len == 0 or value.len > max_path_bytes) return;
var keep_count: usize = 0;
var kept: [max_recent][max_path_bytes]u8 = undefined;
var kept_lens: [max_recent]usize = undefined;
for (0..model.recent_count) |index| {
const entry = model.recentAt(index);
if (std.mem.eql(u8, entry, value)) continue;
if (keep_count + 1 >= max_recent) break;
@memcpy(kept[keep_count][0..entry.len], entry);
kept_lens[keep_count] = entry.len;
keep_count += 1;
}
@memcpy(model.recent_storage[0][0..value.len], value);
model.recent_lens[0] = value.len;
for (0..keep_count) |index| {
@memcpy(model.recent_storage[index + 1][0..kept_lens[index]], kept[index][0..kept_lens[index]]);
model.recent_lens[index + 1] = kept_lens[index];
}
model.recent_count = keep_count + 1;
}
pub fn setRecentStorePath(model: *Model, value: []const u8) void {
const len = @min(value.len, max_path_bytes);
@memcpy(model.recent_path_storage[0..len], value[0..len]);
model.recent_path_len = len;
}
pub fn recentStorePath(model: *const Model) []const u8 {
return model.recent_path_storage[0..model.recent_path_len];
}
/// Parse the persisted recent list (one path per line, newest first).
pub fn restoreRecent(model: *Model, bytes: []const u8) void {
model.recent_count = 0;
var lines = std.mem.splitScalar(u8, bytes, '\n');
while (lines.next()) |line| {
const trimmed = std.mem.trim(u8, line, " \t\r");
if (trimmed.len == 0 or trimmed.len > max_path_bytes) continue;
if (model.recent_count >= max_recent) break;
@memcpy(model.recent_storage[model.recent_count][0..trimmed.len], trimmed);
model.recent_lens[model.recent_count] = trimmed.len;
model.recent_count += 1;
}
}
/// Serialize the recent list for persistence (into a caller buffer;
/// bounded by construction: max_recent * (max_path_bytes + 1)).
pub fn serializeRecent(model: *const Model, buffer: []u8) []const u8 {
var len: usize = 0;
for (0..model.recent_count) |index| {
const entry = model.recentAt(index);
if (len + entry.len + 1 > buffer.len) break;
@memcpy(buffer[len .. len + entry.len], entry);
len += entry.len;
buffer[len] = '\n';
len += 1;
}
return buffer[0..len];
}
};
pub fn countWords(text: []const u8) usize {
var count: usize = 0;
var in_word = false;
for (text) |byte| {
const is_space = byte == ' ' or byte == '\t' or byte == '\n' or byte == '\r';
if (is_space) {
in_word = false;
} else if (!in_word) {
in_word = true;
count += 1;
}
}
return count;
}
pub fn countLines(text: []const u8) usize {
if (text.len == 0) return 0;
var count: usize = 1;
for (text) |byte| {
if (byte == '\n') count += 1;
}
return count;
}
fn pathBasename(value: []const u8) []const u8 {
const separator: u8 = if (builtin.os.tag == .windows) '\\' else '/';
if (std.mem.lastIndexOfScalar(u8, value, separator)) |index| {
if (index + 1 < value.len) return value[index + 1 ..];
}
return value;
}
// ------------------------------------------------------------------ update
pub const Msg = union(enum) {
edit: canvas.TextInputEvent,
edit_path: canvas.TextInputEvent,
load_sample: u32,
toggle_sample_picker,
close_sample_picker,
open_recent: usize,
open_doc,
save_doc,
save_as,
system_scheme: canvas.ColorScheme,
chrome_changed: native_sdk.WindowChrome,
/// Preview scrolls: the runtime already applied the offset; storing
/// it and echoing it back through the scroll's `value` is the
/// controlled pattern (rebuilds keep the preview's place).
doc_scrolled: canvas.ScrollState,
toggle_details: usize,
open_url: []const u8,
file_done: native_sdk.EffectFileResult,
recent_done: native_sdk.EffectFileResult,
link_done: native_sdk.EffectExit,
};
const ViewerApp = native_sdk.UiAppWithFeatures(Model, Msg, .{ .runtime_markup = dev_markup_reload });
pub const Effects = ViewerApp.Effects;
/// TEA init: restore the persisted recent list before the first paint.
pub fn boot(model: *Model, fx: *Effects) void {
if (model.recent_path_len == 0) return;
fx.readFile(.{
.key = recent_read_key,
.path = model.recentStorePath(),
.on_result = Effects.fileMsg(.recent_done),
});
}
fn persistRecent(model: *Model, fx: *Effects) void {
if (model.recent_path_len == 0) return;
var buffer: [max_recent * (max_path_bytes + 1)]u8 = undefined;
fx.writeFile(.{
.key = recent_write_key,
.path = model.recentStorePath(),
.bytes = model.serializeRecent(&buffer),
.on_result = Effects.fileMsg(.recent_done),
});
}
fn openPath(model: *Model, fx: *Effects, value: []const u8) void {
if (value.len == 0) return;
model.setPendingPath(value);
model.setNote("Opening {s}…", .{pathBasename(value)});
fx.readFile(.{
.key = open_key,
.path = model.pendingPath(),
.on_result = Effects.fileMsg(.file_done),
});
}
fn savePath(model: *Model, fx: *Effects, value: []const u8) void {
if (value.len == 0) return;
model.setPendingPath(value);
model.setNote("Saving {s}…", .{pathBasename(value)});
fx.writeFile(.{
.key = save_key,
.path = model.pendingPath(),
.bytes = model.editor.text(),
.on_result = Effects.fileMsg(.file_done),
});
}
/// The platform's open-in-browser command; `fx.spawn` copies argv at call
/// time, so building it from the drain-scratch URL is safe.
fn openInBrowser(fx: *Effects, url: []const u8) void {
const argv: []const []const u8 = switch (builtin.os.tag) {
.windows => &.{ "cmd", "/c", "start", "", url },
.macos => &.{ "open", url },
else => &.{ "xdg-open", url },
};
fx.spawn(.{
.key = link_key,
.argv = argv,
.output = .collect,
.on_exit = Effects.exitMsg(.link_done),
});
}
pub fn update(model: *Model, msg: Msg, fx: *Effects) void {
switch (msg) {
.edit => |edit| {
model.editor.apply(edit);
model.active_sample_id = 0;
if (model.editor.truncated) model.setNote("Document is full ({d} KiB cap)", .{max_document_bytes / 1024});
},
.edit_path => |edit| model.path_field.apply(edit),
.load_sample => |id| {
model.loadSample(id);
model.sample_picker_open = false;
},
.toggle_sample_picker => model.sample_picker_open = !model.sample_picker_open,
// The anchored dropdown's on-dismiss: Escape or a click outside
// the menu closes it here, model-side.
.close_sample_picker => model.sample_picker_open = false,
.open_recent => |index| {
if (index >= model.recent_count) return;
model.path_field.set(model.recentAt(index));
openPath(model, fx, model.recentAt(index));
},
.open_doc => openPath(model, fx, std.mem.trim(u8, model.path_field.text(), " ")),
.save_doc => savePath(model, fx, model.currentPath()),
.save_as => savePath(model, fx, std.mem.trim(u8, model.path_field.text(), " ")),
.system_scheme => |scheme| model.system_scheme = scheme,
// Echo the applied scroll offset back through the model: the next
// rebuild lays the preview at exactly this value, so scrolling
// never fights the reconcile.
.doc_scrolled => |state| model.doc_scroll = state.offset,
.chrome_changed => |chrome| {
model.chrome_leading = chrome.insets.left;
model.chrome_trailing = chrome.insets.right;
// Match the toolbar to the titlebar band so its centered
// controls share the traffic lights' centerline; the natural
// height is the floor when no band overlays the content.
model.toolbar_height = @max(toolbar_natural_height, chrome.insets.top);
},
.toggle_details => |index| {
if (index < max_details) model.details_expanded[index] = !model.details_expanded[index];
},
.open_url => |url| {
model.setNote("Opening link…", .{});
openInBrowser(fx, url);
},
.file_done => |result| switch (result.op) {
.read => switch (result.outcome) {
.ok => {
// COPY the payload: result.bytes is drain scratch.
model.editor.set(result.bytes);
model.active_sample_id = 0;
model.details_expanded = [_]bool{false} ** max_details;
model.doc_scroll = 0;
model.adoptPendingPath();
model.pushRecent(model.currentPath());
model.setNote("Opened {s}", .{model.docTitle()});
persistRecent(model, fx);
},
.truncated => {
model.editor.set(result.bytes);
model.active_sample_id = 0;
model.doc_scroll = 0;
model.setNote("Opened a cut copy: file exceeds the {d} KiB document cap", .{max_document_bytes / 1024});
},
else => model.setNote("Open failed: {s}", .{@tagName(result.outcome)}),
},
.write => switch (result.outcome) {
.ok => {
model.adoptPendingPath();
// The document now lives at the saved path; title from
// the file, not the sample it started from.
model.active_sample_id = 0;
model.pushRecent(model.currentPath());
model.setNote("Saved {s}", .{model.docTitle()});
persistRecent(model, fx);
},
else => model.setNote("Save failed: {s}", .{@tagName(result.outcome)}),
},
},
.recent_done => |result| {
if (result.op == .read and result.outcome == .ok) model.restoreRecent(result.bytes);
// Write acknowledgements and missing-file reads are quiet:
// the recent list is a convenience, never an error surface.
},
.link_done => |exit| {
if (exit.reason == .exited and exit.code == 0) {
model.setNote("Opened in browser", .{});
} else {
model.setNote("Browser open failed ({s})", .{@tagName(exit.reason)});
}
},
}
}
// ------------------------------------------------------------------- theme
/// A refined two-mode palette (the register's warm stone neutrals +
/// indigo accent, light oklch(0.457 0.24 277.023) = #432dd7) derived
/// per rebuild from the model's scheme; the runtime stamps the surface
/// scale afterwards. Every neutral is a stone-scale anchor converted
/// from its published oklch value, so both modes sit on the same warm
/// foundation.
pub fn viewerTokens(model: *const Model) canvas.DesignTokens {
const scheme = model.system_scheme;
var tokens = canvas.DesignTokens.theme(.{ .color_scheme = scheme });
tokens.colors = switch (scheme) {
.light => .{
.background = canvas.Color.rgb8(250, 250, 249),
.surface = canvas.Color.rgb8(255, 255, 255),
.surface_subtle = canvas.Color.rgb8(245, 245, 244),
.surface_pressed = canvas.Color.rgb8(231, 229, 228),
.text = canvas.Color.rgb8(12, 10, 9),
.text_muted = canvas.Color.rgb8(121, 113, 107),
.border = canvas.Color.rgb8(231, 229, 228),
.accent = canvas.Color.rgb8(67, 45, 215),
.accent_text = canvas.Color.rgb8(238, 242, 255),
.destructive = canvas.Color.rgb8(231, 0, 11),
.destructive_text = canvas.Color.rgb8(250, 250, 250),
.success = canvas.Color.rgb8(22, 163, 74),
.success_text = canvas.Color.rgb8(250, 250, 250),
.warning = canvas.Color.rgb8(217, 119, 6),
.warning_text = canvas.Color.rgb8(250, 250, 250),
.focus_ring = canvas.Color.rgb8(166, 160, 155),
.shadow = canvas.Color.rgba8(0, 0, 0, 26),
.disabled = canvas.Color.rgb8(245, 245, 244),
},
.dark => .{
.background = canvas.Color.rgb8(12, 10, 9),
.surface = canvas.Color.rgb8(28, 25, 23),
.surface_subtle = canvas.Color.rgb8(41, 37, 36),
.surface_pressed = canvas.Color.rgba8(255, 255, 255, 38),
.text = canvas.Color.rgb8(250, 250, 249),
.text_muted = canvas.Color.rgb8(166, 160, 155),
.border = canvas.Color.rgba8(255, 255, 255, 26),
.accent = canvas.Color.rgb8(124, 134, 255),
.accent_text = canvas.Color.rgb8(12, 10, 9),
.destructive = canvas.Color.rgb8(255, 100, 103),
.destructive_text = canvas.Color.rgb8(250, 250, 250),
.success = canvas.Color.rgb8(34, 197, 94),
.success_text = canvas.Color.rgb8(9, 9, 11),
.warning = canvas.Color.rgb8(245, 158, 11),
.warning_text = canvas.Color.rgb8(9, 9, 11),
.info = canvas.Color.rgb8(167, 139, 250),
.info_text = canvas.Color.rgb8(9, 9, 11),
.focus_ring = canvas.Color.rgb8(121, 113, 107),
.shadow = canvas.Color.rgba8(0, 0, 0, 150),
.disabled = canvas.Color.rgb8(41, 37, 36),
},
};
return tokens;
}
/// System appearance flows into the model and the tokens re-derive from
/// it — the app follows the OS scheme live, with no in-window theme UI.
pub fn onAppearance(appearance: native_sdk.Appearance) ?Msg {
return .{ .system_scheme = switch (appearance.color_scheme) {
.light => .light,
.dark => .dark,
} };
}
/// Chrome overlay geometry flows into the model (tall hidden-inset
/// titlebar): delivered before the first view build and again when it
/// changes — entering fullscreen hides the traffic lights and this goes
/// to zero.
pub fn onChrome(chrome: native_sdk.WindowChrome) ?Msg {
return .{ .chrome_changed = chrome };
}
// ------------------------------------------------------------------- view
pub const ViewerUi = canvas.Ui(Msg);
pub const viewer_markup = @embedFile("viewer.native");
/// The comptime-compiled engine: same tree, ids, and handlers as the
/// interpreter, no parser in the binary.
pub const CompiledViewerView = canvas.CompiledMarkupView(Model, Msg, viewer_markup);
// -------------------------------------------------------------------- app
/// Debug builds keep the runtime markup engine for hot reload; release
/// builds compile it out entirely.
const dev_markup_reload = builtin.mode == .Debug;
pub fn initialModel() Model {
var model = Model{};
model.loadSample(welcome_sample_id);
return model;
}
pub fn main(init: std.process.Init) !void {
const app_state = try std.heap.page_allocator.create(ViewerApp);
defer std.heap.page_allocator.destroy(app_state);
var model = initialModel();
// Resolve where the recent list persists: the per-app data directory
// (~/Library/Application Support/markdown-viewer on macOS). Failure
// just disables persistence — never a startup error.
var dir_buffer: [max_path_bytes]u8 = undefined;
var file_buffer: [max_path_bytes]u8 = undefined;
const env = native_sdk.debug.envFromMap(init.environ_map);
const platform_value = app_dirs.currentPlatform();
if (app_dirs.resolveOne(.{ .name = "markdown-viewer" }, platform_value, env, .data, &dir_buffer)) |data_dir| {
if (app_dirs.join(platform_value, &file_buffer, &.{ data_dir, "recent.txt" })) |recent_path| {
model.setRecentStorePath(recent_path);
} else |_| {}
} else |_| {}
app_state.* = ViewerApp.init(std.heap.page_allocator, model, .{
.name = "markdown-viewer",
.scene = shell_scene,
.canvas_label = canvas_label,
.update_fx = update,
.init_fx = boot,
.tokens_fn = viewerTokens,
.on_appearance = onAppearance,
.on_chrome = onChrome,
.view = CompiledViewerView.build,
.markup = if (dev_markup_reload)
.{ .source = viewer_markup, .watch_path = "src/viewer.native", .io = init.io }
else
null,
});
defer app_state.deinit();
try runner.runWithOptions(app_state.app(), .{
.app_name = "markdown-viewer",
.window_title = "Native SDK Markdown",
.bundle_id = "dev.native_sdk.markdown_viewer",
.default_frame = geometry.RectF.init(0, 0, window_width, window_height),
.restore_state = false,
.js_window_api = false,
.security = .{
.permissions = &app_permissions,
.navigation = .{ .allowed_origins = &.{ "zero://inline", "zero://app" } },
},
}, init);
}
test {
_ = @import("tests.zig");
}
@@ -0,0 +1,31 @@
# Reading notes — *A Philosophy of Software Design*
Ousterhout's core claim: **complexity is incremental**, and the fight against it is won or lost in code review, not architecture review.
## Chapter 4 — Modules should be deep
- A module is an abstraction: interface (cost) over implementation (benefit)
- *Deep* modules: small interface, lots of functionality behind it
- `write()` in Unix — one call hides buffering, scheduling, devices
- Counter-example: a class per field with getters and setters
- Shallow modules make the codebase *feel* organized while adding surface area
> "The best modules are those whose interfaces are much simpler than their implementations."
## Chapter 6 — General-purpose modules are deeper
- Somewhat counterintuitive: making it general-purpose usually makes it **smaller**
- The litmus test: ~~would this API change if the UI changed?~~*does the API mention the caller's domain?*
- Applied to our editor: `insert(text)` and `delete(range)` beat `backspace()`, `deleteSelection()`, `pasteFromClipboard()`
## Questions to bring on Thursday
1. Where do we draw the line between deep and *bloated*?
2. Is our effects channel a deep module? One call, five outcome enums…
3. Chapter 9's "better together vs. better apart" — apply it to the sync RFC
## Follow-ups
- Reread chapter 5 on information leakage — [summary here](https://example.com/notes/leakage)
- Compare with Parnas, [On the Criteria](https://example.com/parnas-1972)
- Draft a "depth review" checklist for the next review cycle
@@ -0,0 +1,61 @@
# RFC: Session sync protocol
**Status:** draft · **Owner:** platform team · **Target:** v0.4
Client sessions currently persist locally and diverge across devices. This RFC proposes a pull-based sync protocol with bounded payloads and last-writer-wins conflict resolution.
## Goals
- [x] Deterministic merge for concurrent edits on two devices
- [x] Payloads bounded at 256 KiB so a sync can never stall the UI thread
- [ ] Offline queue with at-most-once delivery
- [ ] End-to-end property tests across three simulated devices
## Non-goals
- Real-time collaborative editing (out of scope until the CRDT spike lands)
- Multi-account merge — sessions stay per-account
## Protocol sketch
1. Client sends `HEAD /sessions/:id` with its local revision
2. Server replies `204` (up to date) or `200` with the missing delta
3. Client applies the delta, then pushes its own pending ops
4. Any rejected op re-queues with exponential backoff
```
client server
|--- HEAD rev=41 ------------->|
|<-- 200 delta rev=42..44 -----|
|--- POST ops (3) ------------>|
|<-- 201 rev=45 ---------------|
```
> The delta format is the existing snapshot diff — no new wire format. A sync is just a replayed edit history.
<details>
<summary>Failure modes considered</summary>
- Server unreachable: the offline queue holds ops; the UI shows the queued count
- Delta larger than the payload bound: server falls back to a full snapshot
- Clock skew: revisions are server-assigned, wall clocks are never compared
</details>
<details>
<summary>Rejected alternatives</summary>
- **Push-based sync** — requires a persistent connection per client; the fleet cost is not justified at current scale
- **Operational transforms** — the editing model is line-based, so OT's character-level machinery buys nothing here
</details>
---
## Rollout
| Stage | Cohort | Gate |
| :----- | :------------- | :-------------------------- |
| 1 | Internal | Zero data-loss reports, 1wk |
| 2 | 5% of traffic | Error rate < 0.1% |
| 3 | Everyone | Stage 2 holds for 2wks |
@@ -0,0 +1,66 @@
# Renderer tour
Every block and inline form the native renderer supports, on one page.
## Headings scale from the body token
### So a theme change rescales everything at once
Paragraphs flow and wrap at the pane width. Inline forms compose freely: **bold**, *italic*, ***both***, `code`, ~~struck~~, [a link](https://example.com/tour), and a bare URL like https://example.com/bare — trailing punctuation is trimmed correctly (https://example.com/trim).
## Lists
- Bullets nest by two-space indent
- Second level
- Third level, still real widgets
- Ordered lists keep their numbers
1. First
2. Second
3. Third
- [x] Task items render as real (disabled) checkboxes
- [ ] Their state is display-only — the document owns it
## Code
```zig
const std = @import("std");
pub fn main() !void {
std.debug.print("fenced code keeps its whitespace\n", .{});
}
```
## Quotes and rules
> Blockquotes get a leading bar and muted text.
> Multiple lines fold into one quote block.
---
## Tables
| Alignment | Demo | Right |
| :-------- | :--------: | ------: |
| start | center | end |
| `code` | **bold** | [link](https://example.com/cell) |
| escaped \| pipe | *italic* | 42 |
## Details
<details>
<summary>Collapsed by default</summary>
The body only renders while expanded — the flag lives in the app model, indexed by document order.
</details>
<details>
<summary>A second one, independently tracked</summary>
Toggling one never disturbs the other.
</details>
![Images render as their alt text](https://example.com/diagram.png)
@@ -0,0 +1,46 @@
# Markdown Viewer
A split-pane markdown editor built with **native-sdk** — the document you are reading lives in the editor on the left, and everything on this side is rendered live by the native widget engine. No webview, no HTML: headings, tables, and links below are ordinary widgets.
Edit anything on the left and watch this pane keep up keystroke for keystroke.
## What works here
- **Inline styles** — bold, *italic*, `inline code`, ~~strikethrough~~, and [real links](https://ziglang.org) with a pointer cursor
- **Tables** with per-column alignment (see below)
- **Task lists**, fenced code blocks, and `> blockquotes`
- Collapsible `<details>` sections whose state lives in the app model, not the renderer
- Bare URLs autolink too: https://github.com
## Toolbar reference
The toolbar drives real file I/O through bounded effects — no hidden threads, no native dialogs, just an honest path field.
| Button | What it does | Effect |
| :------ | :-------------------------------------------- | --------------: |
| Open | Reads the file named in the path field | `fx.readFile` |
| Save | Writes the editor back to the opened file | `fx.writeFile` |
| Save As | Writes to the path field, adopts it as current | `fx.writeFile` |
> Files you open or save land in the **Recent** list in the sidebar, which itself persists across launches through the same file effects.
## Try it
1. Type in the editor — the word count in the status bar updates as you go
2. Click a link in this pane — it opens in your browser
3. Press the theme toggle — both panes re-render with the dark palette
```zig
// The entire preview is one markup element:
// <markdown source="{document}" on-link="open_url" ... />
pub fn update(model: *Model, msg: Msg, fx: *Effects) void {
switch (msg) {
.edit => |edit| model.editor.apply(edit),
// ...
}
}
```
---
Select any paragraph here and copy it — selection in the preview is native, per-paragraph, and free.
+649
View File
@@ -0,0 +1,649 @@
const std = @import("std");
const native_sdk = @import("native_sdk");
const main = @import("main.zig");
const canvas = native_sdk.canvas;
const geometry = native_sdk.geometry;
const testing = std.testing;
const Model = main.Model;
const Msg = main.Msg;
const ViewerUi = main.ViewerUi;
const ViewerApp = native_sdk.UiApp(Model, Msg);
const ViewerMarkup = canvas.MarkupView(Model, Msg);
const shell_views = [_]native_sdk.ShellView{
.{ .label = "viewer-canvas", .kind = .gpu_surface, .fill = true, .gpu_backend = .metal },
};
const shell_windows = [_]native_sdk.ShellWindow{.{
.label = "main",
.title = "Markdown Viewer",
.width = 1200,
.height = 760,
.views = &shell_views,
}};
const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
fn viewerOptions() ViewerApp.Options {
return .{
.name = "markdown-viewer",
.scene = shell_scene,
.canvas_label = "viewer-canvas",
.update_fx = main.update,
.init_fx = main.boot,
.tokens_fn = main.viewerTokens,
.on_appearance = main.onAppearance,
.view = main.CompiledViewerView.build,
};
}
// ------------------------------------------------------------ tree helpers
fn buildTree(arena: std.mem.Allocator, model: *const Model) !ViewerUi.Tree {
var ui = ViewerUi.init(arena);
return ui.finalize(main.CompiledViewerView.build(&ui, model));
}
fn interpretTree(arena: std.mem.Allocator, model: *const Model) !ViewerUi.Tree {
var view = try ViewerMarkup.init(arena, main.viewer_markup);
var ui = ViewerUi.init(arena);
return ui.finalize(try view.build(&ui, model));
}
fn findByText(widget: canvas.Widget, kind: canvas.WidgetKind, text: []const u8) ?canvas.Widget {
if (widget.kind == kind and std.mem.eql(u8, widget.text, text)) return widget;
for (widget.children) |child| {
if (findByText(child, kind, text)) |found| return found;
}
return null;
}
fn findByLabel(widget: canvas.Widget, label: []const u8) ?canvas.Widget {
if (std.mem.eql(u8, widget.semantics.label, label)) return widget;
for (widget.children) |child| {
if (findByLabel(child, label)) |found| return found;
}
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 subtreeHasText(widget: canvas.Widget, text: []const u8) bool {
if (std.mem.indexOf(u8, widget.text, text) != null) return true;
for (widget.children) |child| {
if (subtreeHasText(child, text)) return true;
}
return false;
}
fn collectIds(widget: canvas.Widget, ids: *std.ArrayListUnmanaged(canvas.ObjectId), allocator: std.mem.Allocator) !void {
try ids.append(allocator, widget.id);
for (widget.children) |child| try collectIds(child, ids, allocator);
}
// --------------------------------------------------------- harness helpers
fn snapshotWidgetNamed(snapshot: native_sdk.automation.snapshot.Input, role: []const u8, name: []const u8) ?native_sdk.automation.snapshot.Widget {
for (snapshot.widgets) |widget| {
if (std.mem.eql(u8, widget.role, role) and std.mem.eql(u8, widget.name, name)) return widget;
}
return null;
}
const Harness = struct {
harness: *native_sdk.TestHarness(),
app_state: *ViewerApp,
app: native_sdk.App,
fn create() !Harness {
const harness = try native_sdk.TestHarness().create(testing.allocator, .{ .size = geometry.SizeF.init(1200, 760) });
errdefer harness.destroy(testing.allocator);
harness.null_platform.gpu_surfaces = true;
const app_state = try testing.allocator.create(ViewerApp);
errdefer testing.allocator.destroy(app_state);
app_state.* = ViewerApp.init(std.heap.page_allocator, main.initialModel(), viewerOptions());
app_state.effects.executor = .fake;
const app = app_state.app();
try harness.start(app);
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.label = "viewer-canvas",
.size = geometry.SizeF.init(1200, 760),
.scale_factor = 2,
.frame_index = 1,
.timestamp_ns = 1_000_000,
.nonblank = true,
} });
return .{ .harness = harness, .app_state = app_state, .app = app };
}
fn destroy(self: *Harness) void {
self.app_state.deinit();
testing.allocator.destroy(self.app_state);
self.harness.destroy(testing.allocator);
}
fn dispatch(self: *Harness, msg: Msg) !void {
try self.app_state.dispatch(&self.harness.runtime, 1, msg);
}
fn wake(self: *Harness) !void {
try self.harness.runtime.dispatchPlatformEvent(self.app, .wake);
}
fn presentFrame(self: *Harness, frame_index: u64) !void {
try self.harness.runtime.dispatchPlatformEvent(self.app, .{ .gpu_surface_frame = .{
.label = "viewer-canvas",
.size = geometry.SizeF.init(1200, 760),
.scale_factor = 2,
.frame_index = frame_index,
.timestamp_ns = frame_index * 1_000_000,
.nonblank = true,
} });
}
fn snapshot(self: *Harness) native_sdk.automation.snapshot.Input {
return self.harness.runtime.automationSnapshot("Markdown Viewer");
}
fn clickWidget(self: *Harness, id: u64) !void {
var command_buffer: [96]u8 = undefined;
const command = try std.fmt.bufPrint(&command_buffer, "widget-click viewer-canvas {d}", .{id});
try self.harness.runtime.dispatchAutomationCommand(self.app, command);
}
};
// ------------------------------------------------------------------- tests
test "the initial tree renders the welcome sample in editor and preview" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var model = main.initialModel();
const tree = try buildTree(arena, &model);
// The toolbar is present and Save is disabled (nothing opened yet).
// Open/Save carry inline vector icons on the button itself
// (widget.icon), so the disabled state tints icon and label together
// — the gap that kept this toolbar text-only before icon-in-button.
const open = findByText(tree.root, .button, "Open").?;
try testing.expectEqualStrings("folder-open", open.icon);
const save = findByText(tree.root, .button, "Save").?;
try testing.expect(save.state.disabled);
try testing.expectEqualStrings("save", save.icon);
try testing.expect(findByText(tree.root, .button, "Save As") != null);
// The editor pane heading carries the built-in "edit" vector icon
// (kind .icon with the registry name as its text/semantics).
try testing.expect(findByText(tree.root, .icon, "edit") != null);
// The editor mirrors the sample source; the preview rendered it as
// widgets (the H1 becomes a span paragraph, the table becomes cells).
const editor = findByKind(tree.root, .textarea).?;
try testing.expect(std.mem.startsWith(u8, editor.text, "# Markdown Viewer"));
try testing.expect(findByText(tree.root, .data_cell, "Open") != null);
try testing.expect(subtreeHasText(tree.root, "Markdown Viewer"));
// The sidebar lists all samples; the welcome one is selected.
const welcome_item = findByText(tree.root, .list_item, "Welcome").?;
try testing.expect(welcome_item.state.selected);
try testing.expect(!findByText(tree.root, .list_item, "Renderer tour").?.state.selected);
// Word count derives from the live document.
const status = findByKind(tree.root, .status_bar).?;
const expected_words = main.countWords(model.document());
var prefix_buffer: [32]u8 = undefined;
const prefix = try std.fmt.bufPrint(&prefix_buffer, "{d} words", .{expected_words});
try testing.expect(std.mem.startsWith(u8, status.text, prefix));
}
test "the compiled view and the hot-reload interpreter build the same tree" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var model = main.initialModel();
model.loadSample(2);
const compiled = try buildTree(arena, &model);
const interpreted = try interpretTree(arena, &model);
var compiled_ids: std.ArrayListUnmanaged(canvas.ObjectId) = .empty;
defer compiled_ids.deinit(testing.allocator);
var interpreted_ids: std.ArrayListUnmanaged(canvas.ObjectId) = .empty;
defer interpreted_ids.deinit(testing.allocator);
try collectIds(compiled.root, &compiled_ids, testing.allocator);
try collectIds(interpreted.root, &interpreted_ids, testing.allocator);
try testing.expectEqualSlices(canvas.ObjectId, interpreted_ids.items, compiled_ids.items);
try testing.expectEqual(interpreted.handlers.len, compiled.handlers.len);
}
test "editing the textarea updates the preview and derived counts through dispatch" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var model = Model{};
model.editor.set("# One\n");
model.active_sample_id = 0;
var tree = try buildTree(arena, &model);
const editor = findByKind(tree.root, .textarea).?;
// Type through the real dispatch path: the edit lands in the mirror,
// the rebuilt preview renders the new heading.
const edit = tree.msgForTextEdit(editor.id, .{ .insert_text = "curious words" }).?;
var fx = ViewerApp.Effects.init(testing.allocator);
defer fx.deinit();
fx.executor = .fake;
main.update(&model, edit, &fx);
try testing.expectEqualStrings("# One\ncurious words", model.document());
try testing.expectEqual(@as(u32, 0), model.active_sample_id);
try testing.expectEqual(@as(usize, 4), main.countWords(model.document()));
try testing.expectEqual(@as(usize, 2), main.countLines(model.document()));
tree = try buildTree(arena, &model);
try testing.expect(subtreeHasText(tree.root, "curious words"));
}
test "open and save round-trip through the fake executor" {
var h = try Harness.create();
defer h.destroy();
const fx = &h.app_state.effects;
const model = &h.app_state.model;
// Recent-list persistence is wired to a store path in this test.
model.setRecentStorePath("/tmp/zn-markdown-viewer-test/recent.txt");
// Type a path and open it: the read request is recorded verbatim.
try h.dispatch(.{ .edit_path = .clear });
try h.dispatch(.{ .edit_path = .{ .insert_text = "/tmp/zn-md-note.md" } });
try h.dispatch(.open_doc);
try testing.expectEqual(@as(usize, 1), fx.pendingFileCount());
const read_request = fx.pendingFileAt(0).?;
try testing.expectEqual(main.open_key, read_request.key);
try testing.expectEqual(native_sdk.EffectFileOp.read, read_request.op);
try testing.expectEqualStrings("/tmp/zn-md-note.md", read_request.path);
// The read lands: the editor adopts the bytes, the path becomes
// current, the file joins the recent list, and the list persists.
try fx.feedFileResult(main.open_key, .ok, "# Loaded\n\nfrom disk\n");
try h.wake();
try testing.expectEqualStrings("# Loaded\n\nfrom disk\n", model.document());
try testing.expectEqualStrings("/tmp/zn-md-note.md", model.currentPath());
try testing.expectEqual(@as(usize, 1), model.recent_count);
try testing.expectEqualStrings("/tmp/zn-md-note.md", model.recentAt(0));
const recent_write = fx.pendingFileAt(0).?;
try testing.expectEqual(main.recent_write_key, recent_write.key);
try testing.expectEqual(native_sdk.EffectFileOp.write, recent_write.op);
try testing.expectEqualStrings("/tmp/zn-markdown-viewer-test/recent.txt", recent_write.path);
try testing.expectEqualStrings("/tmp/zn-md-note.md\n", recent_write.bytes);
try fx.feedFileResult(main.recent_write_key, .ok, "");
try h.wake();
// Save is now enabled and writes the edited document back whole.
try h.dispatch(.{ .edit = .{ .move_caret = .{ .direction = .end } } });
try h.dispatch(.{ .edit = .{ .insert_text = "appended\n" } });
try h.dispatch(.save_doc);
const write_request = fx.pendingFileAt(0).?;
try testing.expectEqual(main.save_key, write_request.key);
try testing.expectEqual(native_sdk.EffectFileOp.write, write_request.op);
try testing.expectEqualStrings("/tmp/zn-md-note.md", write_request.path);
try testing.expectEqualStrings("# Loaded\n\nfrom disk\nappended\n", write_request.bytes);
try fx.feedFileResult(main.save_key, .ok, "");
try h.wake();
try testing.expect(std.mem.indexOf(u8, model.note(), "Saved") != null);
// A failed open reports its outcome without touching the document.
try h.dispatch(.{ .edit_path = .clear });
try h.dispatch(.{ .edit_path = .{ .insert_text = "/tmp/zn-md-missing.md" } });
try h.dispatch(.open_doc);
try fx.feedFileResult(main.open_key, .not_found, "");
try h.wake();
try testing.expect(std.mem.indexOf(u8, model.note(), "not_found") != null);
try testing.expectEqualStrings("# Loaded\n\nfrom disk\nappended\n", model.document());
try testing.expectEqualStrings("/tmp/zn-md-note.md", model.currentPath());
}
test "save as adopts the path field and boot restores the recent list" {
var h = try Harness.create();
defer h.destroy();
const fx = &h.app_state.effects;
const model = &h.app_state.model;
// Save As writes the welcome sample to the typed path.
try h.dispatch(.{ .edit_path = .{ .insert_text = "/tmp/zn-md-copy.md" } });
try h.dispatch(.save_as);
const write_request = fx.pendingFileAt(0).?;
try testing.expectEqual(main.save_key, write_request.key);
try testing.expectEqualStrings("/tmp/zn-md-copy.md", write_request.path);
try testing.expectEqualStrings(model.document(), write_request.bytes);
try fx.feedFileResult(main.save_key, .ok, "");
try h.wake();
try testing.expectEqualStrings("/tmp/zn-md-copy.md", model.currentPath());
try testing.expect(!model.cannotSave());
// Boot with a store path issues the recent read; restoring parses one
// path per line, newest first, and renders into the sidebar.
model.setRecentStorePath("/tmp/zn-markdown-viewer-test/recent.txt");
main.boot(model, fx);
const read_request = fx.pendingFileAt(0).?;
try testing.expectEqual(main.recent_read_key, read_request.key);
try fx.feedFileResult(main.recent_read_key, .ok, "/tmp/a-doc.md\n/tmp/b-doc.md\n");
try h.wake();
try testing.expectEqual(@as(usize, 2), model.recent_count);
try testing.expectEqualStrings("/tmp/a-doc.md", model.recentAt(0));
// The sidebar items are named by their full path (the accessible
// label) while displaying the basename.
const snapshot = h.snapshot();
try testing.expect(snapshotWidgetNamed(snapshot, "listitem", "/tmp/a-doc.md") != null);
try testing.expect(snapshotWidgetNamed(snapshot, "listitem", "/tmp/b-doc.md") != null);
}
test "preview links open through fx.spawn and details expand through the model" {
var h = try Harness.create();
defer h.destroy();
const fx = &h.app_state.effects;
const model = &h.app_state.model;
// The welcome sample's preview exposes a real link; clicking it spawns
// the platform browser command with the link's URL.
var snapshot = h.snapshot();
const link = snapshotWidgetNamed(snapshot, "link", "real links").?;
try testing.expect(link.actions.press);
try h.clickWidget(link.id);
try testing.expectEqual(@as(usize, 1), fx.pendingSpawnCount());
const spawn_request = fx.pendingSpawnAt(0).?;
try testing.expectEqual(main.link_key, spawn_request.key);
try testing.expectEqualStrings("https://ziglang.org", spawn_request.argv[spawn_request.argv.len - 1]);
try fx.feedExit(main.link_key, 0);
try h.wake();
try testing.expect(std.mem.indexOf(u8, model.note(), "browser") != null);
// Load the spec sample: its details blocks render collapsed (the
// snapshot enumerates below-the-fold widgets too) and its task list
// renders display-only checkboxes.
const spec_item = snapshotWidgetNamed(snapshot, "listitem", "RFC: Session sync").?;
try h.clickWidget(spec_item.id);
try testing.expectEqual(@as(u32, 3), model.active_sample_id);
snapshot = h.snapshot();
try testing.expect(snapshotWidgetNamed(snapshot, "listitem", "▸ Failure modes considered") != null);
try testing.expect(snapshotWidgetNamed(snapshot, "listitem", "▸ Rejected alternatives") != null);
try testing.expect(!subtreeHasTextInSnapshot(snapshot, "Server unreachable"));
const done_task = snapshotWidgetNamed(snapshot, "checkbox", "Deterministic merge for concurrent edits on two devices").?;
try testing.expect(!done_task.enabled);
try testing.expect(done_task.selected);
// Widget clicks require a visible frame, so drive the details toggle
// on a short document typed through the real edit dispatch path.
try h.dispatch(.{ .edit = .clear });
try h.dispatch(.{ .edit = .{ .insert_text = "# T\n\n<details>\n<summary>Alpha</summary>\n\nAlpha body.\n\n</details>\n\n<details>\n<summary>Beta</summary>\n\nBeta body.\n\n</details>\n" } });
snapshot = h.snapshot();
try testing.expect(!subtreeHasTextInSnapshot(snapshot, "Alpha body."));
const summary = snapshotWidgetNamed(snapshot, "listitem", "▸ Alpha").?;
try h.clickWidget(summary.id);
try testing.expect(model.details_expanded[0]);
try testing.expect(!model.details_expanded[1]);
snapshot = h.snapshot();
try testing.expect(snapshotWidgetNamed(snapshot, "listitem", "▾ Alpha") != null);
try testing.expect(snapshotWidgetNamed(snapshot, "listitem", "▸ Beta") != null);
try testing.expect(subtreeHasTextInSnapshot(snapshot, "Alpha body."));
try testing.expect(!subtreeHasTextInSnapshot(snapshot, "Beta body."));
// Loading a sample resets the expansion flags.
const tour_item = snapshotWidgetNamed(snapshot, "listitem", "Renderer tour").?;
try h.clickWidget(tour_item.id);
try testing.expect(!model.details_expanded[0]);
}
/// True when any non-editor widget carries `needle` — the textarea always
/// holds the whole source, so it is excluded to observe the preview only.
fn subtreeHasTextInSnapshot(snapshot: native_sdk.automation.snapshot.Input, needle: []const u8) bool {
for (snapshot.widgets) |widget| {
if (std.mem.eql(u8, widget.role, "textbox")) continue;
if (std.mem.indexOf(u8, widget.name, needle) != null) return true;
if (std.mem.indexOf(u8, widget.text_value, needle) != null) return true;
}
return false;
}
test "the system appearance re-derives the tokens live, both directions" {
var fx = ViewerApp.Effects.init(testing.allocator);
defer fx.deinit();
fx.executor = .fake;
// Model level: the scheme Msg flips the derived palette both ways —
// there is no in-window theme control by design.
var model = main.initialModel();
const light_tokens = main.viewerTokens(&model);
main.update(&model, .{ .system_scheme = .dark }, &fx);
const dark_tokens = main.viewerTokens(&model);
try testing.expect(!std.meta.eql(light_tokens.colors.background, dark_tokens.colors.background));
main.update(&model, .{ .system_scheme = .light }, &fx);
try testing.expect(std.meta.eql(light_tokens.colors.background, main.viewerTokens(&model).colors.background));
// End to end: the OS appearance event reaches the canvas tokens
// through `on_appearance`, live, in both directions.
var h = try Harness.create();
defer h.destroy();
try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .appearance_changed = .{ .color_scheme = .dark } });
const dark_live = try h.harness.runtime.canvasWidgetDesignTokens(1, "viewer-canvas");
try testing.expect(std.meta.eql(dark_tokens.colors.background, dark_live.colors.background));
try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .appearance_changed = .{ .color_scheme = .light } });
const light_live = try h.harness.runtime.canvasWidgetDesignTokens(1, "viewer-canvas");
try testing.expect(std.meta.eql(light_tokens.colors.background, light_live.colors.background));
}
test "the preview scroll offset round-trips through the model" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var fx = ViewerApp.Effects.init(testing.allocator);
defer fx.deinit();
fx.executor = .fake;
var model = main.initialModel();
// The runtime delivers the applied offset; the model stores it…
main.update(&model, .{ .doc_scrolled = .{ .offset = 120, .viewport_extent = 600, .content_extent = 2400 } }, &fx);
try testing.expectEqual(@as(f32, 120), model.doc_scroll);
// …and the rebuilt tree echoes it back through the scroll's value,
// so rebuilds re-lay the preview at exactly the scrolled place.
const tree = try buildTree(arena, &model);
const preview = findByLabel(tree.root, "Preview") orelse return error.TestUnexpectedResult;
try testing.expectEqual(canvas.WidgetKind.scroll_view, preview.kind);
try testing.expectEqual(@as(f32, 120), preview.value);
// Loading a different document resets the offset: the new preview
// starts at its top instead of inheriting the old document's scroll.
main.update(&model, .{ .load_sample = 2 }, &fx);
try testing.expectEqual(@as(f32, 0), model.doc_scroll);
}
test "chrome geometry pads the toolbar and matches its height to the tall band" {
var fx = ViewerApp.Effects.init(testing.allocator);
defer fx.deinit();
fx.executor = .fake;
var model = main.initialModel();
try testing.expectEqual(main.toolbar_natural_height, model.toolbar_height);
// The tall hidden-inset band arrives through on_chrome: the toolbar
// pads past the lights and grows to the band so its centered
// controls share the lights' centerline.
const chrome: native_sdk.WindowChrome = .{
.insets = .{ .top = 52, .left = 78 },
.buttons = geometry.RectF.init(20, 19, 52, 14),
};
const msg = main.onChrome(chrome) orelse return error.TestUnexpectedResult;
main.update(&model, msg, &fx);
try testing.expectEqual(@as(f32, 78), model.chrome_leading);
try testing.expectEqual(@as(f32, 52), model.toolbar_height);
// Fullscreen zeroes the chrome: the pad collapses and the height
// falls back to the toolbar's natural floor.
const cleared = main.onChrome(.{}) orelse return error.TestUnexpectedResult;
main.update(&model, cleared, &fx);
try testing.expectEqual(@as(f32, 0), model.chrome_leading);
try testing.expectEqual(main.toolbar_natural_height, model.toolbar_height);
}
test "the viewer lays out through the canvas engine at window size" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
var model = main.initialModel();
const tree = try buildTree(arena_state.allocator(), &model);
var nodes: [1024]canvas.WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(tree.root, geometry.RectF.init(0, 0, 1200, 760), &nodes);
try testing.expect(layout.nodes.len > 0);
const editor = findByKind(tree.root, .textarea).?;
var editor_frame: ?geometry.RectF = null;
var scroll_frame: ?geometry.RectF = null;
for (layout.nodes) |node| {
if (node.widget.id == editor.id) editor_frame = node.frame;
if (node.widget.kind == .scroll_view and scroll_frame == null) scroll_frame = node.frame;
}
// Editor and preview split the space beside the sidebar.
try testing.expect(editor_frame.?.width > 350);
try testing.expect(scroll_frame.?.width > 350);
try testing.expect(editor_frame.?.height > 500);
}
test "layout audit sweep: nothing clips, overlaps, or escapes" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
var model = main.initialModel();
const tree = try buildTree(arena_state.allocator(), &model);
try canvas.expectLayoutAuditSweepClean(testing.allocator, tree.root, .{
.tokens = main.viewerTokens(&model),
.min_size = geometry.SizeF.init(main.window_min_width, main.window_min_height),
.default_size = geometry.SizeF.init(1200, 760),
});
}
test "a11y audit sweep: every interactive widget is named, reachable, and unambiguous" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
var model = main.initialModel();
const tree = try buildTree(arena_state.allocator(), &model);
try canvas.expectA11yAuditSweepClean(testing.allocator, tree.root, .{
.tokens = main.viewerTokens(&model),
.min_size = geometry.SizeF.init(main.window_min_width, main.window_min_height),
.default_size = geometry.SizeF.init(1200, 760),
});
}
// Env-gated screenshot renderer (skipped by default, never in CI): renders
// the app OFFSCREEN through the deterministic reference renderer via the
// automation screenshot artifact — no live window. PNGs land in
// /tmp/icon-batch-shots/markdown-viewer-*-artifacts/. To use:
//
// ICON_BATCH_SHOTS=1 zig build test
test "render icon-batch screenshots (env-gated)" {
if (!envGateSet("ICON_BATCH_SHOTS")) return error.SkipZigTest;
const io = testing.io;
var h = try Harness.create();
defer h.destroy();
// Light mode: toolbar with folder-open/save inline icons (Save
// disabled — icon and label grey out together).
h.harness.runtime.options.automation = native_sdk.automation.Server.init(io, "/tmp/icon-batch-shots/markdown-viewer-light-artifacts", "Markdown Viewer");
try h.harness.runtime.dispatchAutomationCommand(h.app, "screenshot viewer-canvas 2");
// Dark mode: the same icons over the re-derived dark tokens.
try h.dispatch(.{ .system_scheme = .dark });
try h.presentFrame(2);
h.harness.runtime.options.automation = native_sdk.automation.Server.init(io, "/tmp/icon-batch-shots/markdown-viewer-dark-artifacts", "Markdown Viewer");
try h.harness.runtime.dispatchAutomationCommand(h.app, "screenshot viewer-canvas 2");
}
// Env-gated homepage screenshot renderer (skipped by default, never in
// CI): the docs-homepage showcase state — the welcome sample in the split
// editor/preview — once per color scheme, same state in both. PNGs land
// in /tmp/homepage-shots/markdown-viewer-{light,dark}-artifacts/. To use:
//
// HOMEPAGE_SHOTS=1 zig build test
test "render homepage screenshots (env-gated)" {
if (!envGateSet("HOMEPAGE_SHOTS")) return error.SkipZigTest;
const io = testing.io;
var h = try Harness.create();
defer h.destroy();
// The docs site overlays CSS stoplights on the capture, inside the
// toolbar's own chrome gap. Reserve that gap for real: the standard
// macOS tall hidden-inset geometry (the same numbers the
// chrome-geometry test pins) arrives through the app's chrome
// channel, so the toolbar pads exactly where the site's dots land.
try h.dispatch(main.onChrome(.{
.insets = .{ .top = 52, .left = 78 },
.buttons = geometry.RectF.init(20, 19, 52, 14),
}).?);
try h.presentFrame(2);
// The app follows the system appearance: drive the platform event
// once per scheme, the same channel the OS uses.
try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .appearance_changed = .{ .color_scheme = .light } });
h.harness.runtime.options.automation = native_sdk.automation.Server.init(io, "/tmp/homepage-shots/markdown-viewer-light-artifacts", "Markdown Viewer");
try h.harness.runtime.dispatchAutomationCommand(h.app, "screenshot viewer-canvas 2");
try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .appearance_changed = .{ .color_scheme = .dark } });
h.harness.runtime.options.automation = native_sdk.automation.Server.init(io, "/tmp/homepage-shots/markdown-viewer-dark-artifacts", "Markdown Viewer");
try h.harness.runtime.dispatchAutomationCommand(h.app, "screenshot viewer-canvas 2");
}
test "the sample picker is a real anchored select: open, pick, and dismiss model-side" {
var h = try Harness.create();
defer h.destroy();
// The trigger opens the picker; the anchored dropdown floats below it
// in the retained tree at a real frame automation can click.
const trigger = findByLabel(h.app_state.tree.?.root, "Sample picker") orelse return error.TestUnexpectedResult;
try h.clickWidget(trigger.id);
try testing.expect(h.app_state.model.sample_picker_open);
const notes_item = findByText(h.app_state.tree.?.root, .menu_item, "Reading notes") orelse return error.TestUnexpectedResult;
const layout = try h.harness.runtime.canvasWidgetLayout(1, "viewer-canvas");
const trigger_frame = layout.findById(trigger.id).?.frame;
const item_frame = layout.findById(notes_item.id).?.frame;
try testing.expect(item_frame.y > trigger_frame.maxY());
// Picking loads the sample and closes the picker in one Msg.
try h.clickWidget(notes_item.id);
try testing.expect(!h.app_state.model.sample_picker_open);
try testing.expectEqual(@as(u32, 4), h.app_state.model.active_sample_id);
try testing.expect(findByText(h.app_state.tree.?.root, .menu_item, "Reading notes") == null);
// Escape dismisses through on-dismiss: the MODEL closes it.
try h.clickWidget(trigger.id);
try testing.expect(h.app_state.model.sample_picker_open);
try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .gpu_surface_input = .{
.label = "viewer-canvas",
.kind = .key_down,
.key = "escape",
} });
try testing.expect(!h.app_state.model.sample_picker_open);
}
/// Env-gated dump switch. `std.c.getenv` needs libc, which this test
/// build only links on targets whose platform layer pulls it in; when
/// libc is absent the gate reads as unset and the gated test skips.
fn envGateSet(name: [*:0]const u8) bool {
if (comptime !@import("builtin").link_libc) return false;
return std.c.getenv(name) != null;
}
@@ -0,0 +1,88 @@
<!-- The markdown viewer: toolbar, sidebar library, split editor/preview.
Embedded into the binary and hot-reloaded in dev: edit this file while
the app runs and the window updates without losing document state. -->
<column background="background">
<!-- The toolbar IS the titlebar (tall hidden-inset chrome): window-drag
makes its background move the window — the buttons inside stay
buttons, a double-click zooms — the leading spacer pads past the
traffic lights by the live chrome inset the model receives via
on_chrome, and the row matches its height to the tall titlebar band
so cross-centering puts its controls on the traffic lights'
centerline (both fall back — zero inset, natural height — in
fullscreen, so the toolbar reclaims the space). -->
<row gap="8" padding="10" height="{toolbar_height}" cross="center" background="surface" window-drag="true" label="Toolbar">
<spacer width="{chrome_leading}" />
<!-- The sample picker: a REAL select — the trigger shows the current
document, and the options are an anchored dropdown that floats
over the toolbar's siblings (late z-pass, window-clipped, no
reflow). Open state is the model's; Escape/click-outside dismiss
through on-dismiss, picking closes in update. -->
<stack>
<select size="sm" width="176" on-press="toggle_sample_picker" label="Sample picker">{docTitle}</select>
<if test="{sample_picker_open}">
<dropdown-menu anchor="below" anchor-alignment="stretch" on-dismiss="close_sample_picker" label="Samples">
<for each="samples" key="id" as="s">
<menu-item on-press="load_sample:{s.id}" selected="{s.id == active_sample_id}">{s.title}</menu-item>
</for>
</dropdown-menu>
</if>
</stack>
<spacer width="16" />
<!-- One size register per row: every toolbar control is sm, so the
row renders one control height. -->
<text-field size="sm" text="{path}" placeholder="/path/to/document.md" on-input="edit_path" on-submit="open_doc" grow="1" label="Document path" />
<!-- Inline button icons: icon + label are one widget, so the icon
follows the disabled tint (Save greys out whole while unsaved
paths make it unavailable) — the gap that kept this toolbar
text-only before icon-in-button landed. -->
<button size="sm" variant="outline" icon="folder-open" on-press="open_doc" disabled="{pathEmpty}">Open</button>
<button size="sm" variant="outline" icon="save" on-press="save_doc" disabled="{cannotSave}">Save</button>
<button size="sm" variant="outline" on-press="save_as" disabled="{pathEmpty}">Save As</button>
<spacer width="{chrome_trailing}" />
</row>
<separator />
<row grow="1">
<column width="216" gap="8" padding="10" background="surface">
<text foreground="text_muted">Library</text>
<column gap="2">
<for each="samples" key="id" as="s">
<list-item on-press="load_sample:{s.id}" selected="{s.id == active_sample_id}">{s.title}</list-item>
</for>
</column>
<spacer height="6" />
<text foreground="text_muted">Recent</text>
<column gap="2">
<for each="recentDocs" key="index" as="r">
<list-item on-press="open_recent:{r.index}" label="{r.path}">{r.name}</list-item>
</for>
<else>
<text foreground="text_muted">Nothing opened yet</text>
</else>
</column>
<spacer grow="1" />
</column>
<separator />
<column grow="1" padding="10" gap="6">
<row gap="6" cross="center">
<icon name="edit" width="13" height="13" foreground="text_muted" />
<text foreground="text_muted">Editor</text>
</row>
<textarea text="{document}" on-input="edit" grow="1" placeholder="# Start writing…" label="Markdown source" />
</column>
<separator />
<column grow="1">
<row padding="10" cross="center">
<text foreground="text_muted" grow="1">Preview</text>
</row>
<!-- Controlled scroll: the model owns the offset — on-scroll stores
the applied value, the binding echoes it back, so the preview
keeps its place across every rebuild. -->
<scroll grow="1" value="{doc_scroll}" on-scroll="doc_scrolled" label="Preview">
<column padding="24">
<markdown source="{document}" on-link="open_url" on-details="toggle_details" details-expanded="{details_expanded}" />
</column>
</scroll>
</column>
</row>
<status-bar>{statusLine}</status-bar>
</column>