ashlar-app
The runtime of an ashlar app. An app declares its document type and format, its commands and a
view; App::run() opens the window and adds everything else.
new
.format
.file_extension
.commands // CommandSpec<Session<Notes>>
.view
.run?; // the window
notes_app.cli.run; // the same commands as `notes-cli` (+ MCP)
let mut s = notes_app.headless; // tests: a Dispatch without a window
let mut d = new; // tests: the window on a virtual clock
What an app gets
| Undo | Every top-level command is exactly one undo step named after it: its edits go to a working copy committed with Store::commit. A failed or panicking command changes nothing (document, history, redo line, view state, requested effects). Nested commands join the step. Edits made directly by the view or a job's apply are steps of their own (edit_merged folds a gesture). |
| Built-in commands | file.new/open/save/save_as/revert, app.undo/redo, app.quit, app.palette (Mod+Shift+P), app.keymap, view.theme, app.document, with menu paths and shortcuts. File commands take path and discard so the CLI and agents can use them; in the window they ask (dialogs, "save changes?"). |
| Frame scheduler | Repaints only for input, a revision change during a frame, job wakes, control requests, toasts, injected agent input, and pending work that asks for its own frame when due (the settings and autosave wake hooks, the autosave writer when a write finished): no polling. Every request goes through ashlar_app::wake (wake, wake_after, waker for threads; apps add their own Reasons), so app.stats → wakes counts frames and requests per reason. An idle app, clean or with unsaved changes, draws nothing (examples/notes/tests/wake.rs). Frame times (p50/p95/max, first frame) in Runtime::stats and app.stats. |
| Crash safety | Panic hook (chains to earlier hooks) writes crash-<time>-<pid>.log (message, location, backtrace, recent log lines) to the logs folder and keeps the last panic. The view runs under catch_unwind: a panic suspends it and shows an error window (Save As / Try Again); the session survives. Commands and jobs already catch panics. A launch marker detects a launch that died before its first frame: the next launch starts in safe mode (no restored view settings) and offers to reset them. wasm: console panic hook. |
| Logging | log → stderr + <logs>/<name>.log (rotated at 4 MiB, 3 kept, written by its own thread) + an in-memory ring of 2000 lines (app.logs, crash logs). Level from <APP>_LOG or RUST_LOG. |
| Autosave | ashlar_store::Autosave with a background writer: written when dirty, at most every interval (default 60 s), woken by a timer. Unsaved work from a session that died is offered at startup (Recover / Later / Discard); recovering is one undoable step and leaves the document unsaved. Dirty mark in the title; closing with unsaved changes asks. |
| Menus and keys | In-window menu bar from the commands' menu paths (ashlar_ui::commands), or the OS menu bar with feature native-menu (macOS/Windows, ashlar_native::menu). Keymap from the registry plus user overrides saved in settings (keymap), keys::dispatch each frame, palette and keymap editor windows. Theme and view state persist in settings. |
| Agent control | --control <port> or <APP>_CONTROL_PORT (or App::control_port) serves the keyed control port. Standard app.*/engine.* plus ui.inspect, ui.click, ui.move, ui.drag, ui.scroll, ui.key, ui.type (injected egui input, answered after the frame that consumed it), ui.screenshot (PNG base64, optionally cropped to an element), ui.menu.list/invoke, ui.set (view state, theme), app.quit, app.stats, app.logs. |
| CLI / MCP | App::cli() / cli_main(app): ashlar-cli over a fresh headless session of the same app. |
| Telemetry | Feature telemetry (over ashlar-telemetry, off by default): command.run with id, ok and duration at the chokepoint, frame times, first frame, main-loop turns (hang watchdog), crash reports after the runtime's own hook. Always on when the app configures an endpoint (no notice, no switches); TRACER_DISABLED turns it off. |
Testing apps
Driver runs a Runtime on a headless egui context the way eframe does, on a virtual clock: a
frame runs only when something asked for one. driver.call("ui.click", json!({"id": "notes.add"}))
goes through the same handler as the control port. driver.advance(5.0) returns the frames run,
so idleness is a test. Screenshots there are blank images (no GPU); the plumbing is real.
Performance
cargo test -p ashlar-example-notes --release --test perf -- --ignored --nocapture --test-threads=1
(Apple Silicon, headless; UI pass time, no tessellation or GPU):
| idle repaints over 5 s | 0 |
| first frame (runtime creation → end of first frame) | ~30–40 ms |
| frame p95 scrolling 100k notes | ~0.05 ms |
a command on a 100k-note document (Arc per note) |
~1.5 ms |