chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"files.associations": { "*.native": "html" }
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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>
|
||||
|
||||

|
||||
@@ -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.
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user