# Introduction ashlar is a Rust library for building desktop apps (and, for its headless core, web apps) on [egui](https://github.com/emilk/egui). It gives an app the parts most apps rebuild by hand: - **Documents with undo**: the document is an `Arc` snapshot with a revision number, every edit is an undo step, and the file format is versioned with migrations. Autosave and crash recovery come with it. - **Commands**: one registry drives the menus, keyboard shortcuts, the command palette, a CLI, a control port and an MCP server. Anything a user can do, a script or an AI agent can do too. - **Background jobs** with progress, cancellation and panic isolation, whose results land on the UI thread as one undo step. - **Files and settings**: per-app folders, atomic writes, keyed settings, recent files. - **Native OS features**: file dialogs, the menu bar, a tray icon, global shortcuts, notifications, drag and drop, single instance. - **UI components** on egui: lists and tables that stay fast at any length, a sidebar, tabs, forms, dialogs and popovers, toasts, markdown, code and diff views, a chat kit, animated values. - **A runtime** with crash safety, telemetry, and a headless mode for tests and agents. Domain widgets (a timeline for a video editor, a canvas for a CAD tool) stay in the app. ashlar provides the general pieces they are built from. ![The component gallery: a 100,000-row virtualized list, a table with variable row heights and a tree](img/lists.png) *The component gallery (`cargo run -p ashlar-example-gallery`), rendered headless.* ## Why - **Everything is a command.** Menus, shortcuts, the palette, the CLI, the control port and MCP all go through one registry and one execute chokepoint. - **It does not crash.** The library has no `unwrap`, `panic!` or `unsafe` outside tests. A panic in a view, a command or a job is caught, the session keeps running, and unsaved work is recovered on the next launch. - **An idle app does no work.** Frames are drawn only when something asks for one: input, a document change, a finished job, a running animation. - **Apps can be tested and driven without a window.** Every app takes `--headless --snap out.png --tree tree.json`, runs `.ui` scripts on a virtual clock, and serves golden screenshots and named scenes. ## Status | | | |---|---| | Version | 0.2. The API will change between minor versions until 1.0. | | License | [BSD 3-Clause](https://github.com/Himasnhu-AT/ashlar/blob/main/LICENSE) | | Rust | 1.95 or newer (edition 2024) | | egui / eframe | 0.36, wgpu renderer | | Platforms | macOS (Apple silicon and Intel), Windows 10+ x86_64, Linux x86_64 (X11 and Wayland), web (`wasm32-unknown-unknown`, experimental for the windowed runtime) | The full API reference is on [docs.rs/ashlar-rs](https://docs.rs/ashlar-rs). The source is on [GitHub](https://github.com/Himasnhu-AT/ashlar). ## Where to start 1. [Install](guide/installation.md) the crate. 2. Build [your first app](guide/first-app.md). 3. Read the `notes` example in the repository ([examples/notes](https://github.com/Himasnhu-AT/ashlar/tree/main/examples/notes)): a complete app with typed commands, undo, autosave, a virtualized list, scenes and a `notes-cli` binary. # Installation ```sh cargo add ashlar-rs ``` The package is published as `ashlar-rs`; the library is imported as `ashlar`: ```rust,ignore use ashlar::prelude::*; ``` Documents are serialized with serde, so most apps also add it: ```sh cargo add serde --features derive ``` ## Feature flags | Feature | Default | What it adds | | --- | --- | --- | | `accesskit` | on | Screen readers and other assistive technology see the window (AccessKit) | | `headless` | on | `--headless` on the app's command line: no window, `.ui` scripts, `--snap`, `--tree` | | `native-menu` | off | The app's commands in the macOS and Windows menu bar | | `tray` | off | A tray or status-bar icon with a menu (`App::tray`) | | `hotkey` | off | System-wide shortcuts (`App::global_shortcut`) | | `telemetry` | off | Crash, performance and usage reports to a server you host | ```sh cargo add ashlar-rs --features native-menu,telemetry ``` `default-features = false` drops `accesskit` and `headless` for a smaller binary (about 1 MB). ## What the umbrella crate contains `ashlar` re-exports the layers as modules: `ashlar::app`, `ashlar::command`, `ashlar::store`, `ashlar::jobs`, `ashlar::fs`, `ashlar::native`, `ashlar::ui`, `ashlar::control`, `ashlar::cli`, `ashlar::mcp`, plus `ashlar::egui` and `ashlar::serde_json`. `ashlar::prelude` has what most apps need: `App`, `Cx`, `Session`, `Document`, `Driver`, `Effect`, `CommandSpec`, `CommandError`, `Param`, `Params`, `cmd!`, `query!`, `always`, `Jobs`, `apply`, `Format`, `Revision`, `ToastLevel`, `egui`, `Value` and `json!`. Some crates are not re-exported. Add them yourself when you need them: | Crate | When | | --- | --- | | `ashlar-motion` | Spring and tween curves for `Cx::motion` and `ashlar::ui::motion` | | `ashlar-diff` | Building the `FileDiff`s that `ashlar::ui::diff_view` shows | | `ashlar-debug` | `.ui` script reports and golden snapshots in tests (usually a dev-dependency) | | `ashlar-native` with `notify` | System notifications | Each `ashlar-*` crate also works on its own. A headless tool that only needs documents and undo can depend on `ashlar-store` alone. ## Linux build dependencies Debian and Ubuntu package names: ```sh sudo apt-get install libgtk-3-dev libxdo-dev libxkbcommon-dev libdbus-1-dev ``` ## Binary size The smallest app (`examples/hello`) is 13.6 MB stripped in a release build on macOS arm64. A profile with fat LTO, one codegen unit, `opt-level = "s"` and stripping brings it to 9.0 MB: ```toml [profile.release-small] inherits = "release" lto = "fat" codegen-units = 1 opt-level = "s" strip = true ``` ```sh cargo build --profile release-small ``` # Your first app This is the example from the README: a list of notes with one command. It is compiled as a doc test, so it stays correct. ```sh cargo new jot && cd jot cargo add ashlar-rs cargo add serde --features derive ``` ```rust,ignore use ashlar::prelude::*; #[derive(Clone, Default, serde::Serialize, serde::Deserialize)] struct Doc { notes: Vec, } fn add(s: &mut Session, p: &Value) -> Result { let text = p.get("text").and_then(Value::as_str).unwrap_or("New note").to_string(); s.edit("Add Note", |d| d.notes.push(text)); // one undo step per command Ok(json!(s.doc().notes.len())) } fn main() -> Result<(), ashlar::app::Error> { App::::new("jot", env!("CARGO_PKG_VERSION")) .format(Format::new("example.jot", "jot 0.1")) .commands([cmd!("note.add", "Add Note", ["Edit"], Some("Mod+Shift+N"), [Param::string("text")], always, add)]) .view(|ui, cx: &mut Cx<'_, Doc>| { for n in &cx.doc().notes { ui.label(n); } if ui.button("Add").clicked() { cx.dispatch("note.add", json!({})); } }) .run() // undo/redo, save/open/autosave, menus, palette, crash guard, control port } ``` ```sh cargo run ``` ## What each part does **The document.** `Doc` is any type that is `Clone + Default + Serialize + Deserialize + Send + Sync + 'static`; such types implement `Document` automatically. The session keeps it as an `Arc` and gives each state a `Revision`. **The command.** `add` runs on the `Session`. `s.edit(label, f)` changes the document and records one undo step named "Add Note". The command reads its parameters from a JSON `Value` and returns a JSON result, which is what the CLI, the control port and MCP see. **`cmd!`** registers it: id `note.add`, label "Add Note", in the Edit menu, on `Mod+Shift+N` (`Mod` is ⌘ on macOS and Ctrl elsewhere), with one optional string parameter `text`, always enabled. See [Commands](commands.md). **`Format`** names the file format (`example.jot`) and the program that writes it (`jot 0.1`). Without one the app gets a default format called `.document`. Either way the app has File > New, Open, Save, Save As and Revert, and autosave with recovery. Setting a format (or a file extension) also turns on opening files dropped on the window. See [Documents and undo](documents.md). **The view** is a closure that draws the UI each frame with egui. `Cx` gives it the document, and `cx.dispatch` runs a command after the view returns. See [Views and components](views.md). **`run()`** opens the window. It also adds: - Undo and Redo (`Mod+Z`, `Mod+Shift+Z`), and the File and View menus. - The command palette (`Mod+Shift+P`) and a keyboard shortcut editor. - Autosave every 60 seconds and a recovery dialog after a crash. - A crash guard: a panic in the view shows an error instead of closing the window. - `--control ` for agents, and `--headless` for tests (see [Testing apps](testing.md)). ## Smaller still The smallest app has an empty document and no commands: ```rust,ignore use ashlar::prelude::*; #[derive(Clone, Default, serde::Serialize, serde::Deserialize)] struct Doc {} fn main() -> Result<(), ashlar::app::Error> { let mut on = false; App::::new("hello", env!("CARGO_PKG_VERSION")) .view(move |ui, _cx: &mut Cx<'_, Doc>| { ui.heading("Hello, ashlar"); ui.checkbox(&mut on, "Switch"); }) .run() } ``` ## More builder options | Method | What it sets | | --- | --- | | `.title("Notes")` | The window title (default: the app name) | | `.organization("com", "Acme")` | The reverse-domain prefix and organisation used for the app's folders | | `.file_extension("notes")` | The extension of the app's documents (open dialog, drops) | | `.window_size(960.0, 640.0)` | The first window size | | `.viewport(\|b\| b.with_min_inner_size([480.0, 320.0]))` | Any egui `ViewportBuilder` option | | `.icon(include_bytes!("icon.png"))` | The window and taskbar icon (PNG bytes) | | `.setup(\|ctx\| { … })` | Runs once with the egui context before the first frame (fonts) | | `.autosave(Some(Duration::from_secs(30)))` | Autosave interval; `None` turns autosave and recovery off | | `.remember_window(true)` | Restore the window's position and size (default on) | | `.follow_system_accent(true)` | Take the accent colour from the OS (default on) | | `.instructions("…")` | What MCP clients are told about the app | | `.scene("empty", \|s\| { … })` | A named starting state for tests and agents | The full list is on [docs.rs](https://docs.rs/ashlar-app/latest/ashlar_app/struct.App.html). # Documents and undo ## The session A command runs on a `Session`, and a view reaches the same session through `Cx`. The session holds the document store, the file it came from and view state. | Call | What it does | | --- | --- | | `s.doc()` | The current document, `&D` | | `s.snapshot()` | The current document as an `Arc`, to keep or send to a job | | `s.revision()` | The `Revision` of the current state; key caches by it | | `s.is_dirty()` | Unsaved changes since the last save | | `s.edit(label, \|d\| …)` | Change the document as one undo step called `label` | | `s.try_edit(label, \|d\| …)` | The same, but `f` returns a `Result`; on `Err` nothing changes | | `s.edit_merged(label, key, \|d\| …)` | Fold this edit into the previous one with the same label and key | | `s.undo()` / `s.redo()` | Step through the history; returns the label | | `s.can_undo()` / `s.can_redo()` | Whether there is a step | Edits are copy-on-write: the store clones the document once per edit, so keep large parts behind `Arc` and use `Arc::make_mut` for the part you change. The `notes` example keeps each note as `Arc`, so editing one note copies one note, not all of them. ```rust,ignore fn delete(s: &mut Session, p: &Value) -> Result { let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| CommandError::bad_params("", "`id` is required"))?; s.try_edit("Delete Note", |d| { let i = d.index_of(id).ok_or_else(|| CommandError::failed("", format!("no note {id}")))?; d.notes.remove(i); Ok(json!({"deleted": id})) }) } ``` ## Gestures as one step Typing into a field or dragging a slider produces many edits. `edit_merged` folds them into one undo step as long as the label and merge key stay the same: ```rust,ignore if response.changed() { cx.edit_merged("Edit Note", &format!("note-{id}"), |d| d.set_title(id, &title)); } ``` ## View state View state is data that is not in the document and not undoable: the selection, a scroll target, an open panel. It is JSON keyed by name, and agents can set it with the `ui.set` control method. ```rust,ignore s.set_view("selected", &id); let selected: Option = s.view("selected"); let zoom = s.view_or("zoom", 1.0_f64); ``` ## File formats A `Format` describes how documents are stored: a JSON envelope with the format id, the program that wrote it, a schema version and the document. ```rust,ignore fn v1_to_v2(mut file: Value) -> Result { // rewrite file["document"] from schema 1 to schema 2 Ok(file) } let format = Format::::new("acme.notes", concat!("Notes ", env!("CARGO_PKG_VERSION"))) .migrations(vec![v1_to_v2]); ``` - The schema version is the number of migrations plus one. Add a migration whenever the document shape changes; older files are migrated on open. - Files from a newer version, or of another format, are refused with a `FormatError` (`TooNew`, `WrongFormat`) instead of being misread. - `.max_bytes(n)` caps the size of a file that will be read; `.pretty(true)` writes indented JSON. - Saves are atomic: a crash during a save leaves the old file or the new one, never a mix. ## Saving and opening The built-in commands handle files: `file.new`, `file.open`, `file.save`, `file.save_as`, `file.revert` and `app.quit`. They ask before discarding unsaved changes, and agents can pass `{"path": "…"}` to `file.open` and `file.save_as` to skip the dialog. From code, the session has `s.open(path)`, `s.save()`, `s.save_as(path)`, `s.new_document()`, `s.path()` and `s.display_name()`. ## Autosave and recovery Autosave is on by default, every 60 seconds while the document has unsaved changes. Snapshots go to a `Recovery` folder in the app's data folder. When the app ends normally they are removed. When it crashes or is killed, the next launch finds them and shows a dialog that offers to recover the work. ```rust,ignore App::::new("jot", "0.1.0") .autosave(Some(std::time::Duration::from_secs(30))) // or None to turn it off ``` ## Asking the window for something A command has no window. It asks for one with an `Effect`, which a headless session (CLI, MCP) ignores: ```rust,ignore s.toast(ToastLevel::Success, "Exported"); s.request(Effect::OpenPalette); s.request(Effect::PickSaveAs); ``` The effects are `Toast`, `OpenPalette`, `OpenKeymap`, `Theme`, `PickOpen`, `PickSaveAs`, `ConfirmDiscard` and `Quit`. ## Using the store on its own The store is the `ashlar-store` crate. It is headless and builds for wasm32, so a CLI tool or a server can use `Store`, `History`, `Format` and `Autosave` without the UI. See [docs.rs/ashlar-store](https://docs.rs/ashlar-store). # Commands A command is a named action with typed parameters. Every command an app registers shows up in the menus, the command palette, the keyboard shortcut editor, the app's CLI, its control port and its MCP server. They all run it through the same execute chokepoint, which validates the parameters, checks that the command is enabled, catches panics and records undo. ## Writing one A command body is a plain function from the session and the JSON parameters to a JSON result: ```rust,ignore fn add(s: &mut Session, p: &Value) -> Result { let title = p.get("title").and_then(Value::as_str).unwrap_or("Untitled note").to_string(); let id = s.edit("New Note", |d| d.push(title)); s.set_view("selected", &id); Ok(json!({"id": id, "count": s.doc().notes.len()})) } ``` Return `CommandError::bad_params(id, msg)` for bad input and `CommandError::failed(id, msg)` when the command cannot do its work. An empty `id` is filled in with the command's own id. ## Registering it `CommandSpec::new` and its `const` setters: ```rust,ignore const ADD: &[Param] = &[Param::string("title"), Param::string("body")]; const ID: &[Param] = &[Param::int_in("id", 1, i64::MAX).about("default: the selected note")]; pub fn commands() -> Vec>> { vec![ CommandSpec::new("note.add", "New Note", add).menu(&["Note"]).shortcut("Mod+Shift+N").params(Params::Static(ADD)), CommandSpec::new("note.delete", "Delete Note", delete) .menu(&["Note"]) .shortcut("Mod+Backspace") .params(Params::Static(ID)) .enabled(has_target), CommandSpec::new("note.list", "List Notes", list).params(Params::Static(LIST)).query(), ] } ``` or the `cmd!` and `query!` macros, which fit a command on one line: ```rust,ignore cmd!("note.generate", "Generate Notes…", ["Note"], None, [Param::int_in("count", 1, 1_000_000).about("how many")], always, generate) query!("note.count", "Count Notes", [], count) ``` `cmd!` takes the id, label, menu path, shortcut, parameters, enabled predicate and body. `query!` makes a command that only reads: always enabled, not in menus, not recorded in the command journal. `.query()` does the same for a `CommandSpec`. Pass the list to the app with `App::commands(…)`, or one at a time with `App::command(…)`. ## Parameters | Constructor | JSON | | --- | --- | | `Param::bool("name")` | boolean | | `Param::int("name")`, `Param::int_in("name", min, max)` | integer | | `Param::float("name")`, `Param::float_in("name", min, max)` | number | | `Param::string("name")` | string | | `Param::one_of("name", &["a", "b"])` | one of these strings | | `Param::json("name")` | any JSON value | `.required()` makes a parameter mandatory and `.about("…")` describes it. The registry turns the list into a JSON Schema, which the MCP server and `describe` show, and validates every call against it before the body runs. ## Enabled predicates An enabled predicate returns `Err(reason)` to disable a command. Menus and the palette grey it out and show the reason; a call through the CLI or MCP fails with that reason. ```rust,ignore fn has_target(s: &Session) -> Result<(), String> { match s.view::("selected") { Some(id) if s.doc().get(id).is_some() => Ok(()), _ => Err("no note selected".into()), } } ``` `always` is the predicate of a command that is always available. ## Menus and shortcuts `.menu(&["Note"])` puts the command in the Note menu; a longer path makes submenus. The built-in File, Edit and View menus are already there. Menus are drawn in the window; with the `native-menu` feature they go to the macOS and Windows menu bar. Shortcuts use one portable syntax: `Mod+Shift+S`, where `Mod` is ⌘ on macOS and Ctrl elsewhere, and `Ctrl` is the literal Control key. Several shortcuts are separated by ` / ` (`"Mod+Shift+Z / Mod+Y"`). Users can rebind them in the Keyboard Shortcuts editor (View > Keyboard Shortcuts…, the `app.keymap` command), which also shows conflicts. ## The palette `Mod+Shift+P` (the `app.palette` command) opens the command palette. It lists the app's commands with their menu paths and shortcuts and filters them with a fuzzy matcher. ## Running commands from a view ```rust,ignore if ui.button("New Note").clicked() { cx.dispatch("note.add", json!({"title": "Groceries"})); // runs after the view returns; errors become toasts } if let Ok(result) = cx.execute("note.list", json!({"limit": 1})) { // runs now, returns the result ui.label(result["count"].to_string()); } ``` `dispatch` is the usual choice: the command runs after the frame's view returns, so it never changes the document while the view is reading it. ## Built-in commands | Id | Label | Shortcut | | --- | --- | --- | | `file.new` | New | `Mod+N` | | `file.open` | Open… | `Mod+O` | | `file.save` | Save | `Mod+S` | | `file.save_as` | Save As… | `Mod+Shift+S` | | `file.revert` | Revert to Saved | | | `app.quit` | Quit | `Mod+Q` | | `app.undo` | Undo | `Mod+Z` | | `app.redo` | Redo | `Mod+Shift+Z / Mod+Y` | | `app.palette` | Command Palette… | `Mod+Shift+P` | | `app.keymap` | Keyboard Shortcuts… | | | `view.theme` | Switch Theme | | | `app.document` | Document Info | | When two commands share an id, the first one registered wins and a warning is logged. The File and Edit built-ins come before the app's commands and the View ones after, so an app can replace `view.theme` but not `file.save`. # Views and components ## The view and `Cx` The view is a closure `FnMut(&mut egui::Ui, &mut Cx<'_, D>)` that draws the whole window each frame. State the view keeps between frames lives in the closure: ```rust,ignore fn view() -> impl FnMut(&mut egui::Ui, &mut Cx<'_, Notes>) { let mut query = String::new(); move |ui, cx| { ui.text_edit_singleline(&mut query); for n in cx.doc().notes.iter().filter(|n| n.title.contains(&query)) { ui.label(&n.title); } } } ``` What `Cx` offers: | Call | What it does | | --- | --- | | `cx.doc()`, `cx.snapshot()`, `cx.revision()` | The document, as `&D` or `Arc`, and its revision | | `cx.dispatch(id, params)` | Run a command after the view returns | | `cx.execute(id, params)` | Run a command now and get its result | | `cx.edit(label, f)`, `cx.edit_merged(label, key, f)` | Edit the document directly, as an undo step | | `cx.view(key)`, `cx.view_or(key, default)`, `cx.set_view(key, &value)` | View state | | `cx.tokens()` | The theme's design tokens (`cx.tokens().space.md`) | | `cx.motion(id, target, curve)` | An animated value that jumps for the user's own edits and animates for undo, agents and jobs | | `cx.origin()` | Who changed the document since the last frame | | `cx.dropped_files()` | Files dropped on the window that the runtime did not open | | `cx.jobs`, `cx.toasts`, `cx.settings` | Background jobs, toasts, app settings | | `cx.session()`, `cx.session_mut()`, `cx.ctx()` | The session and the egui context | Frames are drawn only when something asks for one. Cache derived data by `cx.revision()` instead of recomputing it each frame: the revision changes only when the document does. ## Components The components are in `ashlar::ui` (the `ashlar-ui` crate). They take state by reference and report what the user did; the app owns its data and selection. Every component registers an automation id, so tests and agents can find it, and reports AccessKit roles to screen readers. ### Lists and tables `list::Row` is a cheap row for short lists. For long ones, `virtual_list::VirtualList` lays out only the visible rows, so a frame costs the same at 100 rows or 100,000: ```rust,ignore use ashlar::ui::virtual_list::{RowHeights, VirtualList}; let out = VirtualList::new("files", names.len(), RowHeights::Uniform(22.0)) .selected(*selected) .show(ui, |ui, row| { ui.label(&names[row.index]); }); if let Some(i) = out.select { *selected = Some(i); } ``` `VirtualTable` does the same with columns and sorting, `selection::Selection` adds multi-selection with a cursor and an anchor, and `tree::TreeView` shows a hierarchy. ![A virtualized list, table and tree](../img/lists.png) ### Sidebar ```rust,ignore use ashlar::ui::sidebar::{Sidebar, SidebarItem, SidebarSection}; let items: Vec = names.iter().map(|n| SidebarItem::new(n)).collect(); let sections = [SidebarSection::new("Projects", &items)]; let out = Sidebar::new("nav", "Navigation", &mut state, §ions).show(ui); if let Some((at, name)) = out.renamed { names[at.item] = name; } ``` Sections fold and show counts; rows have an icon, a label, a detail and a badge, and support type-ahead and rename in place. ![The sidebar](../img/sidebar.png) ### Tabs ```rust,ignore use ashlar::ui::tabs::TabStrip; let out = TabStrip::new("documents", &titles, &mut selected).closable(true).reorderable(true).show(ui); if let Some(i) = out.closed { titles.remove(i); } if let Some((from, to)) = out.moved { let t = titles.remove(from); titles.insert(to, t); } ``` Tabs that do not fit go to an overflow menu. For panels that users rearrange, see [Panel docking](../docs/ui/ui-docking.md). ![Tab strips](../img/tabs.png) ### Controls `controls` has text buttons, a switch, a segmented control, radio groups, badges, keycaps, progress bars and spinners; `buttons::IconButton`, `slider::Slider`, `slider::Knob`, `value_field::ValueField` (a numeric drag field), `swatches::SwatchGrid` and `search::SearchField` cover the rest. ```rust,ignore use ashlar::ui::controls::{Button, Switch, Variant}; if Button::new("export.run", "Export").variant(Variant::Primary).loading(busy).show(ui).clicked() { cx.dispatch("doc.export", json!({})); } Switch::new("settings.sync", "Sync", &mut sync).show(ui); ``` Value edits (fields, sliders, knobs) report `edit::EditPhase` `Begin`, `Update`, `Commit` and `Cancel`, which map onto `edit_merged` for one undo step per drag. ![Buttons, switches, segmented controls, badges and progress](../img/controls.png) ### Forms The settings-form pattern: sections of rows with a label, a description and a control, a "modified" marker with a reset button, and a filter. ```rust,ignore use ashlar::ui::{controls::Switch, form}; form::filter(ui, "settings", &query, |f| { f.section("general", "General", |s| { s.row("launch", "Launch at login") .description("Starts with your session") .keywords(&["startup", "boot"]) .bind(&mut launch, false) .control(|ui, id, on| Switch::new(id, "", on).show(ui)); }); }); ``` ![A settings form with a filter](../img/settings.png) ### Overlays and dialogs `overlay::Select`, `overlay::Combobox` and `overlay::Popover` float next to a control; `ashlar::ui::help(response, text)` adds a tooltip that screen readers also read. `dialog::Dialog` is a modal with keyboard defaults: ```rust,ignore use ashlar::ui::dialog::{Dialog, DialogButton, DialogOutcome}; let buttons = [DialogButton::cancel("Cancel"), DialogButton::destructive("Delete").default()]; match Dialog::new("confirm-delete", "Delete 3 files?", &buttons).show(ui.ctx(), |ui| { ui.label("This cannot be undone."); }) { Some(DialogOutcome::Button(1)) => { /* delete */ } Some(_) => { /* closed */ } None => {} } ``` ### Toasts and the status bar ```rust,ignore use ashlar::ui::toasts::Toast; cx.toasts.push(Toast::success("Exported 12 files").action("Undo", "app.undo")); ``` From a command, use `s.toast(ToastLevel::Info, "…")`. With `App::run_toast_actions(true)`, a clicked action whose command is registered runs like a menu pick. `status_bar::StatusBar` is a strip along the bottom with left and right sections; `content` has `EmptyState`, `Banner`, `Card`, `Collapsible` and `Accordion`. ### Markdown, code and diffs ```rust,ignore use ashlar::ui::{code_view::CodeView, markdown}; let style = markdown::MarkdownStyle::from_visuals(ui.visuals(), 14.0); markdown::show(ui, "help", text, &style); CodeView::new("preview", source).lang_for_path("src/main.rs").revision(rev).show(ui); ``` The markdown view handles headings, lists, task lists, tables, code blocks with a Copy button and links, and is safe to call on text that is still streaming in. `CodeView` is virtualized, with line numbers, selection and syntax colours for 18 languages. `diff_view::DiffView` shows unified or split diffs of `ashlar_diff::FileDiff`s with word-level emphasis. ![The code view](../img/code.png) ### Chat `chat` has a transcript, message and tool-call views, and a composer with an `@` / `/` popup. They draw plain data and know nothing about where messages come from. ```rust,ignore use ashlar::ui::chat::{Composer, Message, Transcript}; Transcript::new("chat", &messages, revision).stick_to_bottom(true).show(ui, |ui, m| { let id = format!("chat.msg.{}", m.id); Message::new(&id, m.role, &m.text).streaming(m.streaming).show(ui); }); if let Some(text) = Composer::new("chat.input", &mut draft).placeholder("Ask anything").show(ui).submitted { // send `text` } ``` ![The chat kit: transcript, markdown replies, a tool call awaiting approval, and the composer](../img/chat.png) ### Motion `ashlar::ui::motion` animates values with springs that keep their velocity when the target changes, and asks for frames only while something moves: ```rust,ignore use ashlar::ui::motion; let alpha = motion::opacity(ui.ctx(), id, visible); let width = motion::f32(ui.ctx(), id.with("w"), if open { 240.0 } else { 0.0 }, ashlar_motion::tokens::SNAP.enter); ``` Curves come from the `ashlar-motion` crate (`tokens::SNAP`, `SETTLE`, `GLIDE`, `POP` and others). Motion follows the OS reduced-motion setting; `App::motion(prefs)` overrides it. ### Theme `tokens` holds the design tokens (OKLCH colours, spacing, radii, type sizes, motion) for light, dark and system modes; `cx.tokens()` returns the current ones. `fonts` installs app fonts with a CJK fallback chain. The accent follows the OS accent colour unless `App::follow_system_accent(false)` is set. The gallery example shows every component: ```sh cargo run -p ashlar-example-gallery ``` The full list is on [docs.rs/ashlar-ui](https://docs.rs/ashlar-ui). # Background jobs Long work (an export, an import, indexing) runs as a job on a worker thread, so the UI never blocks. A job: - works on a snapshot moved into it (an `Arc` of the document, a path), never on live state; - reports progress and checks for cancellation through a `Progress`; - runs under `catch_unwind`, so a panic fails the job and nothing else; - finishes with an `Apply`: a closure the UI thread runs on the session. Edits made there are one undo step. ## Starting one from a view ```rust,ignore use ashlar::prelude::*; if ui.button("Count words").clicked() { let doc = cx.snapshot(); cx.jobs.spawn("count", "Counting words", doc, |doc, progress| { let total = doc.notes.len() as u64; let mut words = 0; for (i, n) in doc.notes.iter().enumerate() { if !progress.steps(i as u64, total) { return Err("cancelled".into()); // the job was cancelled } words += n.body.split_whitespace().count(); } Ok(apply(move |s: &mut Session| { s.toast(ToastLevel::Info, format!("{words} words")); Ok(json!(words)) })) }); } ``` `spawn(kind, label, input, work)` returns a `JobId`. `work` gets the input and a `&Progress`: | `Progress` call | What it does | | --- | --- | | `set(fraction)` | Report 0..=1; returns `false` once the job was cancelled | | `steps(done, total)` | The same, as steps | | `message(text)` | Say what the job is doing ("Reading frame 12 of 300") | | `report(fraction, text)` | Both at once | | `is_cancelled()`, `token()` | Check for cancellation, or hand a `CancelToken` to other code | Return `Err(message)` to fail the job; the runtime shows the message as an error toast. ## Variants `cx.jobs.job(kind, label)` describes a job before starting it: - `.here()` runs it on the calling thread, now (a command that needs the result at once, a CLI). It is still listed, reports progress and runs under `catch_unwind`. - `.spawn_streaming(input, work)` also gets an `Updates` handle, to post partial results while it runs (search hits as they are found). - `.spawn_stepped(input, step)` splits the work into steps. On the web, where there are no threads, stepped jobs advance a few steps per frame so a frame never freezes. ## Watching and cancelling `cx.jobs.list()` describes every running job (kind, label, progress, message) for a progress panel; `cx.jobs.is_busy()` says whether any is running; `cx.jobs.cancel(id)` and `cx.jobs.cancel_all()` cancel. `status_bar::progress` draws a compact progress indicator. Agents see jobs too: `app.stats` over the control port includes them. ## Idle cost A job wakes the UI only when it starts, moves its progress by more than half a percent, or finishes. An app with no running jobs does no work for them. Jobs live in the `ashlar-jobs` crate, which is headless and can be used without the rest. See [docs.rs/ashlar-jobs](https://docs.rs/ashlar-jobs). # Files and settings ## App folders Each app gets four folders, in the places the OS expects: `config`, `data`, `cache` and `logs`. They are named from `App::organization(qualifier, org)` and the app name (the default organisation is `org` / `ashlar`). ```rust,ignore App::::new("notes", "0.1.0").organization("com", "Acme") ``` To put everything somewhere else (tests, a portable install), pass the folders yourself or set `ASHLAR_DIRS_ROOT`: ```rust,ignore use ashlar::fs::AppDirs; App::::new("notes", "0.1.0").dirs(AppDirs::under("/tmp/notes-test")) ``` What goes where: | Folder | Contents | | --- | --- | | `config` | Settings, the control key file | | `data` | `Recovery` (autosave snapshots) | | `logs` | The rotating log file and crash logs | | `cache` | Free for the app | ## Settings `cx.settings` is keyed JSON with typed getters. Changes are saved after a short delay, with an atomic write, and flushed when the app ends. ```rust,ignore let recent_limit: u32 = cx.settings.get_or("recent.limit", 10); if ui.checkbox(&mut sync, "Sync").changed() { let _ = cx.settings.set("sync", &sync); } ``` `get`, `get_or`, `get_value`, `set`, `set_value`, `remove` and `keys` cover most uses. The runtime keeps its own entries there too, such as the window placement under `"window"`. ## Recent files `RecentFiles` is a most-recently-used list that lives in the settings: ```rust,ignore use ashlar::fs::RecentFiles; let mut recent = RecentFiles::load(cx.settings, "recent", 10); if let Some(path) = cx.session().path() { recent.push(path); } recent.store(cx.settings, "recent"); for p in recent.paths() { ui.label(p.display().to_string()); } ``` `prune(|p| p.exists())` drops files that are gone. ## Atomic writes `ashlar::fs::atomic_write(path, bytes)` replaces a file so that a crash or a power cut leaves the old content or the new, never a torn mix. Document saves, autosave and settings all use it. ## File dialogs The built-in `file.open` and `file.save_as` show the native dialogs. For other files use `ashlar::native::dialogs::FileDialog`: ```rust,ignore use ashlar::native::dialogs::FileDialog; match FileDialog::new().title("Import image").filter("Images", &["png", "jpg"]).open_file() { Ok(path) => cx.dispatch("image.import", json!({"path": path})), Err(e) => log::info!("no file: {e}"), // NativeError::Cancelled when the user cancels } ``` `open_files`, `pick_folder` and `save_file` work the same way. On Linux they use the XDG desktop portal; on the web only opening is available. The `ashlar-fs` crate is headless and builds for wasm32. See [docs.rs/ashlar-fs](https://docs.rs/ashlar-fs). # Native OS features `ashlar::native` (the `ashlar-native` crate) wraps OS features behind one API. Where a feature is not available on a platform, the call returns `NativeError::Unsupported`; nothing panics. Most apps use these through the `App` builder rather than calling the crate directly. | Feature | macOS | Windows | Linux | Web | |---|---|---|---|---| | File dialogs | yes | yes | yes (portal) | open only | | Native menu bar | yes | yes | in-window menu | in-window menu | | Tray icon | yes | yes | yes (D-Bus) | no | | Global shortcuts | yes | yes | X11 only | no | | Clipboard (text, image) | yes | yes | yes | no | | Notifications | bundled apps | yes | yes (D-Bus) | no | | Single instance | yes | yes | yes | no | The full matrix is in the [ashlar-native README](https://github.com/Himasnhu-AT/ashlar/blob/main/crates/native/README.md#support-matrix). ## Menu bar The app's commands, grouped by their `.menu(&[…])` path, form the menu bar. By default it is drawn inside the window. With the `native-menu` feature it moves to the macOS menu bar and the Windows window menu: ```sh cargo add ashlar-rs --features native-menu ``` ## Tray icon With the `tray` feature, `App::tray` shows a tray or status-bar icon. Each menu item's id is a command id; choosing it runs the command. Clicking the icon shows and focuses the window. ```rust,ignore use ashlar::command::menu::{Item, MenuNode, MenuSpec, Submenu}; use ashlar::native::tray::{Icon, TraySpec}; let icon = Icon { rgba: icon_rgba, width: 32, height: 32 }; let menu = MenuSpec::new(vec![Submenu::new("Tray", vec![MenuNode::Item(Item::new("note.add", "New Note"))])]); App::::new("notes", "0.1.0").tray(TraySpec::new("notes", "Notes", icon, menu).template(true)) ``` `template(true)` makes macOS tint the icon for the menu bar. Where the desktop has no tray, the icon is skipped with a log line. ## Global shortcuts With the `hotkey` feature, `App::global_shortcut(command, chord)` runs a command when the chord is pressed anywhere, even while another app has the focus. The window comes to the front first. ```rust,ignore App::::new("notes", "0.1.0").global_shortcut("note.add", "Mod+Shift+Space") ``` A chord without Ctrl, Alt or ⌘, or one another app already holds, is skipped with a log line. ## Drag and drop When the app sets a `Format` or a `file_extension`, files dropped on the window open: the first file with a known extension goes to `file.open` (asking to save a dirty document first). Other files reach the view: ```rust,ignore for path in cx.dropped_files() { cx.toasts.info(format!("Dropped {}", path.display())); } ``` `App::accept_drops(false)` leaves drops entirely to the view. ## Single instance ```rust,ignore App::::new("notes", "0.1.0").single_instance(true) ``` A second launch hands its arguments (the files to open) to the running app and exits. The running app opens them with `file.open` and comes to the front. Desktop only. ## Notifications Notifications need the `notify` feature of `ashlar-native`: ```sh cargo add ashlar-native --features notify ``` ```rust,ignore use ashlar_native::notify::{self, Notification}; let _ = notify::notify(&Notification::new("Export finished", "12 files written").with_id("export")); ``` A notification with the same id replaces the previous one. `notify::on_click(waker)` reports clicks. On macOS this works only in an app bundle with a bundle identifier; elsewhere it returns `Unsupported`. ## Other features `ashlar::native` also has the clipboard with rich types (`clipboard`), keep-awake (`power`), files opened from the file manager (`open_files`), system preferences such as accent colour, reduced motion and contrast (`system`), and, behind their own features, dock and taskbar progress (`progress`), the OS keychain, and dragging files out of the window (`drag-out`, macOS). See [docs.rs/ashlar-native](https://docs.rs/ashlar-native). # Agents and automation Because every action is a command, an ashlar app can be driven from outside in four ways, all with one vocabulary of methods: | Surface | Transport | Use it for | | --- | --- | --- | | CLI | one-shot verbs, one-line JSON replies | scripts, CI, batch jobs | | Control port | JSON lines over TCP on `127.0.0.1`, with a key | driving the running window | | MCP | JSON-RPC 2.0 over stdio | AI agents | | Child process | JSON lines over stdin/stdout | a parent process that owns the app | The method list, the security model and the reply format are in the [control protocol](../docs/control-protocol.md) reference. ## A CLI binary `App::cli()` turns the app definition into a command-line tool that runs the same commands on a headless session. Put the app definition in a library function and add a second binary: ```rust,ignore // src/bin/notes-cli.rs fn main() -> std::process::ExitCode { ashlar::app::cli_main(notes::app()) } ``` ```text notes-cli commands [--json] [--query Q] list commands notes-cli describe one command in full (parameter schema) notes-cli docs [category|path.md] [--query Q] the documentation notes-cli exec [json|-] run one command notes-cli run [--keep-going] run {"command","params"} lines notes-cli mcp [--isolate] [--per-command] MCP server on stdio notes-cli serve control protocol on stdio notes-cli version [--json] ``` ```sh notes-cli exec note.add '{"title": "Groceries"}' # {"ok":true,"result":{"count":1,"id":1}} ``` Replies are one line, `{"ok":true,"result":…}` or `{"ok":false,"error":"…"}`. Exit codes are 0 (ok), 1 (the command failed) and 2 (usage). `commands`, `describe`, `exec`, `run` and `mcp` take `--connect PORT` to drive the running app instead of a fresh headless session. To add your own verbs, take the `ashlar::cli::Cli` from `App::cli()` and call `.verb(…)` before `.run()`. ## The control port Start the app with a port, and it serves the control protocol on loopback: ```sh notes --control 7700 # or NOTES_CONTROL_PORT=7700, or App::control_port(7700) notes-cli exec note.list '{}' --connect 7700 ``` When the port opens, the app writes a random key to a file in its config folder (mode 0600). Every request must carry it, so another local user or a web page cannot drive the app. Clients in this repository (`--connect`, `ashlar-debug`) find the key file themselves. Besides commands (`engine.commands`, `engine.execute`, `engine.batch`), the running window answers UI methods: `ui.snapshot` (an outline of the UI with refs), `ui.click`, `ui.fill`, `ui.press`, `ui.wait`, `ui.screenshot`, `app.clock`, `app.stats`, `app.logs` and more. `ashlar-debug` drives a running app from the shell with those methods: ```sh ashlar-debug open ./target/debug/notes ashlar-debug snapshot -i ashlar-debug fill @e2 "Groceries" --submit ashlar-debug screenshot --annotate ashlar-debug close ``` See [Testing and debugging UIs](../docs/debugging-uis.md). ## MCP `notes-cli mcp` is an MCP server on stdio. Its tools are generated from the command registry (`list_commands`, `describe_command`, `execute`, `batch`), plus the UI tools (`ui_snapshot`, `ui_click`, `screenshot`, …) when it is connected to a running window with `--connect PORT`. `--per-command` also makes each command its own tool with its real input schema. A client config entry looks like any stdio server: ```json { "mcpServers": { "notes": { "command": "notes-cli", "args": ["mcp"] } } } ``` `App::instructions("…")` sets what MCP clients are told about the app; write it for an agent that has never seen it. The `ashlar-agents` crate writes these config entries, plus skills and rules, for common coding agents, and removes exactly what it added. `ashlar-mcp-http` serves MCP over HTTP on loopback instead of stdio. ## Agents and undo Agents go through the same chokepoint as users: parameter validation, enabled predicates, undo. A command an agent runs is one undo step the user can take back, and the view can tell it apart (`cx.origin()`) to animate the change instead of jumping. # Testing apps An ashlar app can be tested at three levels: commands on a headless session, the whole UI on a virtual clock with no window, and the real binary from the command line. The full reference is [Testing and debugging UIs](../docs/debugging-uis.md). ## The binary: `--headless` Every binary whose `main` calls `App::run` takes `--headless` (the `headless` feature, on by default). It runs the app with no window, on a virtual clock, in a fresh temporary folder (never your real settings or documents), and writes real pixels when there is a GPU: ```sh notes --headless --size 1024x700 --scale 2 --theme dark --scene two-notes --script add.ui --at 1.5 \ --snap out.png --tree tree.json ``` - `--size` is in points (default 1280x800); the PNG is that times `--scale`. - `--script` runs a `.ui` script first; `--at` lets that many seconds pass before the picture. - `--tree` writes the UI outline (`ui.snapshot`) as JSON. - Exit codes: 0 done, 1 a step failed, 2 bad arguments. ## `.ui` scripts One command per line, with assertions: ```text exec note.add '{"title": "Groceries"}' sleep 500 fill id=notes.title "Groceries and milk" press Mod+S wait --text "Saved" assert exists id=notes.add assert text id=status "Saved" assert count role=listitem >= 1 assert screenshot notes-two ``` Elements are found by automation id (`id=…`), text (`text=…`), role, or a ref from the last snapshot (`@e3`). The same scripts run in process, with `--headless`, or against a running app with `ashlar-debug batch file.ui`. ## In-process tests: `Driver` `App::runtime()` builds the app without a window and `Driver` runs its frames on a virtual clock. Point the app folders at a temporary directory so tests never touch real settings: ```rust,ignore use ashlar::app::Driver; use ashlar::fs::AppDirs; use serde_json::json; #[test] fn adding_a_note_shows_it() { let dir = tempfile::tempdir().unwrap(); let mut d = Driver::new(notes::app().dirs(AppDirs::under(dir.path())).runtime()); d.settle(60); d.call("engine.execute", json!({"command": "note.add", "params": {"title": "Groceries"}})).unwrap(); d.settle(60); let tree = d.call("ui.snapshot", json!({})).unwrap(); assert!(tree["tree"].as_str().unwrap().contains("Groceries")); } ``` | `Driver` call | What it does | | --- | --- | | `frame()` | Run one frame | | `settle(max)` | Run frames until nothing asks for one (at most `max`) | | `advance(secs)` | Let virtual time pass, running every frame that is asked for | | `call(method, params)` | Any control method: `engine.execute`, `ui.click`, `ui.snapshot`… | | `run_ui(script)` | Run a `.ui` script; `sleep` and `wait` use virtual time | | `scene(name)` | Apply a named scene | | `assert_snapshot(name)` | Compare a screenshot with `tests/snapshots/.png` | | `push_event(e)`, `drop_files(paths)` | Inject egui input or dropped files | | `set_screen(w, h)`, `pixels_per_point(s)` | Window size and scale | | `replay(path)` | Play a recorded session | | `record(f)` | Record motion while `f` runs, for the motion gate | For headless tests of commands alone, `App::headless()` gives a `Headless` session with the registry and no UI. ## Scenes A scene is a named starting state, declared on the app: ```rust,ignore App::new("notes", env!("CARGO_PKG_VERSION")) .scene("empty", |_: &mut Session| {}) .scene("two-notes", |s: &mut Session| { s.edit("Two notes", |d| { /* fill the document */ }); }) ``` `notes --list-scenes` prints them, `--scene two-notes` starts in one (with or without a window), `Driver::scene("two-notes")` applies one in a test, and agents use `ui.scene` or the MCP tool `ui_scene`. ## Golden snapshots For real pixels, build the driver with the wgpu renderer: ```rust,ignore use ashlar::app::{Driver, Renderer}; let rt = notes::app().dirs(AppDirs::under(dir.path())).runtime(); let mut d = Driver::with_renderer(rt, Renderer::wgpu()); d.set_screen(640.0, 400.0); d.pixels_per_point(1.0); d.scene("two-notes").unwrap(); d.settle(60); d.assert_snapshot("notes-two").unwrap(); ``` The comparison is exact unless a tolerance is given. On a mismatch it writes `-new.png` and `-diff.png`. `ASHLAR_UPDATE_SNAPSHOTS=1` records new baselines, with a `.meta.json` naming the GPU adapter; a baseline from another adapter is skipped with a note instead of failing. The result type (`SnapshotOutcome`) is in the `ashlar-debug` crate. ## Time: `app.clock` `app.clock` freezes the time the app's frames see, so you can look at an animation at an exact moment: `{"pause": true}`, `{"step_ms": 16}`, `{"resume": true}`. It is also a `.ui` script line (`clock pause`, `clock step 16`), an `ashlar-debug clock` command and the MCP tool `ui_clock`. ## Record and replay `ASHLAR_RECORD=session.jsonl` (or `Runtime::record_to(path)` in a test) records every frame's input and every control request. `Driver::replay` plays it into a fresh driver, which turns a bug report into a test: ```rust,ignore let mut d = Driver::new(notes::app().runtime()); d.replay("session.jsonl")?; ``` ## The motion gate `Driver::record` records values animated through `ashlar::ui::motion` and the position of every element per frame. `check` flags overshoot, reversals, pops, flicker and frames requested after everything settled: ```rust,ignore use ashlar::ui::motion_gate::MotionRules; let rec = d.record(|d| drop(d.run_ui("click overlays.open\nwait 1"))); let report = rec.check(&MotionRules::default()); assert!(report.is_clean(), "{report}"); ``` The notes example has a test for each of these in [examples/notes/tests](https://github.com/Himasnhu-AT/ashlar/tree/main/examples/notes/tests). # Web ashlar's headless core (commands, documents, files, jobs, logging) builds for `wasm32-unknown-unknown`, and CI checks it. Running the windowed app in a browser is experimental: it works, and `examples/web-hello` builds in the gate, but not every feature is there. ## The example ```rust,ignore use ashlar::prelude::*; #[derive(Clone, Default, serde::Serialize, serde::Deserialize)] struct Doc { clicks: u32, } fn main() -> Result<(), ashlar::app::Error> { App::::new("web-hello", env!("CARGO_PKG_VERSION")) .title("Hello, web") .canvas_id("web-hello") .view(|ui, cx: &mut Cx<'_, Doc>| { let t = cx.tokens(); ui.heading("Hello from ashlar on the web"); ui.add_space(t.space.md); if ui.button("Click").clicked() { cx.edit("Click", |d| d.clicks = d.clicks.saturating_add(1)); } ui.label(format!("Clicked {} times (undo with Ctrl/Cmd+Z)", cx.doc().clicks)); }) .run() } ``` The same code opens a window on the desktop. On the web the app runs in the `` named by `App::canvas_id` (default: the app's name, then `ashlar`). If it cannot start (no such canvas, no WebGL), a visible error appears next to the canvas as well as in the console. ## Dependencies Turn off the default features: `headless` has no meaning in a browser. Keep `accesskit` for screen readers. ```toml [dependencies] ashlar = { package = "ashlar-rs", version = "0.2", default-features = false, features = ["accesskit"] } serde = { version = "1", features = ["derive"] } ``` ## Building and serving The example uses [trunk](https://trunkrs.dev), which runs `wasm-bindgen` and serves the page: ```sh rustup target add wasm32-unknown-unknown trunk serve examples/web-hello/index.html ``` The page needs a canvas with the right id and a trunk link to the binary: ```html ``` ## What differs on the web - Jobs run inline, with no threads. Use `spawn_stepped` for long work so it advances a few steps per frame. - The disk-backed settings and files compile but report errors; use the in-memory backends or a browser-storage backend (`ashlar::fs::ConfigStore`). - File dialogs can only open files. - The tray, global shortcuts, notifications, the clipboard, single instance, the control port and record and replay are not available. # Crash safety and telemetry ## What the runtime catches - **A panic in the view** is caught. The window stays open and shows the error in place of the view; the document and undo history are untouched. - **A panic in a command** fails that command with `CommandError::Panicked`. The document is left as it was before the command, and the user sees an error toast. - **A panic in a job** fails the job. Nothing else is affected. - **A panic in a scene or in `setup`** is logged and skipped. Every panic is written to a crash log in the app's `logs` folder by a panic hook that chains to any hook installed before it. ## When the process dies anyway A crash outside these guards, a kill or a power cut ends the process. Two things limit the damage: - **Recovery.** Autosave keeps snapshots of unsaved work. On the next launch a dialog offers to recover them. See [Documents and undo](documents.md#autosave-and-recovery). - **Safe mode.** If the previous launch died before its first frame, the app starts without restoring view settings (theme, window state) and says so. `--safe-mode` does the same by hand. ## Logs The runtime logs to stderr, a rotating file in the `logs` folder, and an in-memory ring that `app.logs` (control port) and the MCP tool `app_logs` read. Filter with `_LOG` or `RUST_LOG` (`NOTES_LOG=warn,notes=debug` for an app named `notes`). Nothing is written to stdout, so a CLI or an MCP server can log freely. ## In your own code The library code follows the rules it enforces on itself, and apps can adopt them: - Deny `unwrap`, `expect`, `panic!`, `unreachable!`, `todo!` and `unimplemented!` outside tests: ```rust,ignore #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] ``` - Treat numbers from input as hostile: `get()` instead of `[i]`, checked or saturating math. - Return `CommandError::failed` or `bad_params` from commands instead of panicking. ## Telemetry With the `telemetry` feature, the app can send crash, performance and usage reports to a server you host (`ashlar-trace-server`): ```sh cargo add ashlar-rs --features telemetry ``` ```rust,ignore App::::new("notes", env!("CARGO_PKG_VERSION")) .telemetry("app-write-key", Some("https://telemetry.example.com")) ``` When an endpoint is set, telemetry is on. The runtime reports: - panics, with recent log lines; - each command run (id, success, duration), from the command chokepoint; - frame times and startup time; - hangs, when a main-loop turn takes too long; - saves. In this integration every category is on and there is no first-run notice; `DO_NOT_TRACK` is ignored. `TRACER_DISABLED` in the environment (meant for developer machines and CI) turns it off. The endpoint must be `https://` (or `http://` to loopback). Command parameters, file names, paths and typed text are never sent. The write key only allows sending data; it is not a secret. Without the feature, every telemetry call compiles to nothing. Because users cannot switch it off from the app, tell them in your privacy policy what is sent. An app that needs consent and per-category switches can use the `ashlar-trace` SDK directly. Before you ship telemetry, read what is collected: - [Privacy and collected fields](../docs/trace/privacy.md) - [Self-hosting the server](../docs/trace/self-hosting.md) - [Integrating the SDK](../docs/trace/integration.md), for apps that use `ashlar-trace` directly # Building with an AI assistant ashlar apps are easy for a coding agent to check: the agent can build the app, run it with no window, look at a PNG of it, read its UI tree, and drive it through the same commands a user runs. This page has a rules file to give the agent, and the steps to connect an agent to your running app. ## Docs for agents - [llms.txt](https://ashlar.himanshuat.com/llms.txt): an index of every page of this site. - [llms-full.txt](https://ashlar.himanshuat.com/llms-full.txt): the whole site as one markdown file. - Each page's markdown is at its URL plus `.md`, for example `https://ashlar.himanshuat.com/guide/commands.md`. - The API reference: [docs.rs/ashlar-rs](https://docs.rs/ashlar-rs). ## A rules file for your project Copy this into `AGENTS.md` or `CLAUDE.md` at the root of your app's repository and adjust the names (`my-app`, `my-app-cli`) to yours. ````markdown # Building this app This is a desktop app built on ashlar (crate `ashlar-rs`, imported as `ashlar`), a Rust framework on egui. Docs: https://ashlar.himanshuat.com/llms-full.txt and https://docs.rs/ashlar-rs. Read the relevant page before using an API you have not used here. ## Never crash - No `unwrap`, `expect`, `panic!`, `unreachable!`, `todo!` or `unimplemented!` outside tests. The crate root has `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. - Numbers from input (files, params, the network) are hostile: `get()` not `[i]`, checked or saturating math, capped allocations. - Commands return `CommandError::bad_params` / `CommandError::failed` instead of panicking. - No blocking I/O on the UI thread: long work is a job (`cx.jobs.spawn`). ## Adding a feature - Every user action is a command: a `fn(&mut Session, &Value) -> Result` registered with `cmd!` or `CommandSpec::new`, with typed `Param`s, a menu path and an enabled predicate. Views call `cx.dispatch("id", json!({…}))`; they do not change the document directly except for live gestures (`cx.edit_merged`). - Each command changes the document with one `s.edit("Label", |d| …)`: one undo step. - Give every interactive widget a stable automation id (`notes.add`, not a generated one), so tests and agents can find it. - Cache derived data by `cx.revision()`; long lists use `ashlar::ui::virtual_list`. ## Checking your work - `cargo clippy --all-targets -- -D warnings` and `cargo test` pass. - Run a command with no window: `cargo run --bin my-app-cli -- exec ''`. - Look at the UI with no window: `cargo run -- --headless --size 1024x700 --scene --snap out.png --tree tree.json`, then read `out.png` and `tree.json`. - Script an interaction in a `.ui` file (`exec`, `click id=…`, `fill id=… "text"`, `press Mod+S`, `wait --text "…"`, `assert exists id=…`) and run it with `--headless --script file.ui` (exit code 0 = passed, 1 = a step failed). - New screens get a scene (`App::scene`) and a golden snapshot test (`Driver::assert_snapshot`). ```` ## Connecting an agent to your app An app with a `-cli` binary is an MCP server: `my-app-cli mcp`. Build the binary and register it with your agent. For Claude Code: ```sh cargo build --bin my-app-cli claude mcp add my-app -- ./target/debug/my-app-cli mcp ``` For agents configured with JSON: ```json { "mcpServers": { "my-app": { "command": "/path/to/my-app-cli", "args": ["mcp"] } } } ``` The agent then lists and runs your commands on a headless session (`list_commands`, `describe_command`, `execute`). To let it see and drive the running window as well, start the app with a control port and connect the server to it: ```sh cargo run -- --control 7700 claude mcp add my-app-live -- ./target/debug/my-app-cli mcp --connect 7700 ``` With `--connect` the agent also gets `screenshot`, `ui_snapshot`, `ui_click`, `ui_fill`, `ui_wait` and the other UI tools. Everything it does goes through the same commands and undo history as the user, so a change it makes can be undone with `Mod+Z`. Write `App::instructions("…")` for the agent: what the app is, what its main commands do, and how to save. MCP clients show it to the model when they connect. See [Agents and automation](agents.md) for the CLI and the control port, and the [control protocol](../docs/control-protocol.md) for every method and tool. # Cookbook Short recipes for common tasks. Each uses `use ashlar::prelude::*;` and a document type `Doc` unless it says otherwise. ## A custom file format, saved and opened from code ```rust,ignore let app = App::::new("sketch", env!("CARGO_PKG_VERSION")) .format(Format::new("acme.sketch", concat!("Sketch ", env!("CARGO_PKG_VERSION"))).migrations(vec![v1_to_v2])) .file_extension("sketch"); ``` The built-in File commands now read and write `.sketch` files. To save or open without a dialog (a script, an agent, a test), pass a path: ```rust,ignore cx.dispatch("file.save_as", json!({"path": "/tmp/drawing.sketch"})); cx.dispatch("file.open", json!({"path": "/tmp/drawing.sketch"})); ``` From the shell: `sketch-cli exec file.save_as '{"path": "out.sketch"}'`. ## A long job with progress and a Cancel button ```rust,ignore use ashlar::ui::status_bar; if ui.button("Export").clicked() { let doc = cx.snapshot(); cx.jobs.spawn("export", "Exporting", doc, |doc, progress| { let total = doc.pages.len() as u64; for (i, page) in doc.pages.iter().enumerate() { if !progress.report(i as f64 / total.max(1) as f64, format!("Page {} of {total}", i + 1)) { return Err("cancelled".into()); } render(page)?; } Ok(apply(|s: &mut Session| { s.toast(ToastLevel::Success, "Export finished"); Ok(Value::Null) })) }); } for job in cx.jobs.list() { status_bar::progress(ui, "export.progress", &job.label, job.fraction.map(|f| f as f32)); if job.cancellable && ui.button("Cancel").clicked() { cx.jobs.cancel(job.id); } } ``` ## A settings page stored in the app settings ```rust,ignore use ashlar::ui::{controls::Switch, form}; let mut autosave: bool = cx.settings.get_or("autosave", true); let out = form::section(ui, "settings.general", "General", |s| { s.row("autosave", "Autosave") .description("Save a recovery copy every minute") .bind(&mut autosave, true) .control(|ui, id, on| Switch::new(id, "", on).show(ui)); }); if autosave != cx.settings.get_or("autosave", true) { let _ = cx.settings.set("autosave", &autosave); } ``` The row shows a "modified" marker and a reset button when the value differs from the default. ## A sortable table of 100,000 rows ```rust,ignore use ashlar::ui::virtual_list::{Column, RowHeights, Sort, VirtualTable}; let columns = [Column::new("Name", 220.0), Column::new("Size", 80.0)]; let out = VirtualTable::new("files", &columns, files.len(), RowHeights::Uniform(22.0)) .selected(selected) .sort(Some(sort)) .show(ui, |ui, row, col| { let f = &files[row]; match col { 0 => { ui.label(&f.name); } _ => { ui.label(format!("{} KB", f.size / 1024)); } } }); if let Some(col) = out.header_clicked { sort = Sort { column: col, ascending: !(sort.column == col && sort.ascending) }; // re-sort `files` by `sort` } if let Some(row) = out.list.select { selected = Some(row); } ``` Only the visible rows are laid out, so the cost per frame does not grow with the row count. ## A context menu that runs commands ```rust,ignore use ashlar::ui::commands::{Item, MenuNode, MenuSpec, Submenu, menu}; let spec = MenuSpec::new(vec![Submenu::new("Note", vec![ MenuNode::Item(Item::new("note.rename", "Rename…")), MenuNode::Item(Item::new("note.delete", "Delete").accelerator("Mod+Backspace")), ])]); let row = ui.label(¬e.title); if let Some(id) = menu::context(&row, &spec) { cx.dispatch(&id, json!({"id": note.id})); } ``` A spec with one menu lists its items directly; with several, each becomes a submenu. Build the spec once and keep it, rather than every frame. ## A toast with an Undo button ```rust,ignore use ashlar::ui::toasts::Toast; // on the App: .run_toast_actions(true) cx.toasts.push(Toast::info("Note deleted").action("Undo", "app.undo")); ``` ## An undoable drag on a numeric field ```rust,ignore use ashlar::ui::edit::EditPhase; use ashlar::ui::value_field::{Decimal, ValueField}; let mut opacity = cx.doc().opacity; let out = ValueField::new("inspector.opacity", "Opacity", &mut opacity, &Decimal { decimals: 0, suffix: " %" }) .range(0.0..=100.0) .show(ui); if let Some(edit) = out.edit && edit.phase != EditPhase::Begin { // Update and Commit carry the new value; Cancel (Escape) carries the value before the drag cx.edit_merged("Opacity", "inspector.opacity", |d| d.opacity = edit.value); } ``` All the edits of one drag share a label and merge key, so the drag is one undo step called "Opacity". ## A golden screenshot test of a scene ```rust,ignore // tests/snapshots.rs use ashlar::app::{Driver, Renderer}; use ashlar::fs::AppDirs; #[test] fn the_empty_scene_looks_as_recorded() { let dir = tempfile::tempdir().unwrap(); let rt = my_app::app().dirs(AppDirs::under(dir.path())).runtime(); let mut d = Driver::with_renderer(rt, Renderer::wgpu()); d.set_screen(640.0, 400.0); d.pixels_per_point(1.0); d.scene("empty").unwrap(); d.settle(60); d.assert_snapshot("empty").unwrap(); } ``` Record the baseline once with `ASHLAR_UPDATE_SNAPSHOTS=1 cargo test`, then commit `tests/snapshots/empty.png` and its `.meta.json`. ## A picture of the app for a bug report or a PR ```sh cargo run -- --headless --scene empty --size 1024x700 --snap app.png --tree tree.json ``` # FAQ and troubleshooting ## The build fails on Linux Install the system libraries (Debian and Ubuntu names): ```sh sudo apt-get install libgtk-3-dev libxdo-dev libxkbcommon-dev libdbus-1-dev ``` Native file dialogs on Linux go through the XDG desktop portal, so the desktop needs a portal service (`xdg-desktop-portal` and a backend for your desktop) at run time. ## Which Rust version do I need? 1.95 or newer, edition 2024. CI tests the latest stable and checks 1.95. ## Which egui version? ashlar 0.2 is built on egui and eframe 0.36 with the wgpu renderer. Use the `egui` that ashlar re-exports (`ashlar::egui`, also in the prelude) rather than adding your own `egui` dependency, so the versions cannot drift apart. If you do depend on egui directly, pin the same minor version. ## Is the API stable? No. ashlar is at 0.2; the API will change between minor versions until 1.0. Each release lists its changes in the [changelog](../CHANGELOG.md). Pin the minor version (`ashlar-rs = "0.2"`). ## The window does not update when my data changes An ashlar app draws a frame only when something asks for one: input, a document change, a finished job, a running animation. If your data changes some other way (a thread of your own, a socket, a timer), ask for a frame: ```rust,ignore use ashlar::ui::wake::{self, Reason}; const FEED: Reason = Reason("feed"); // your own reason, shown in app.stats let waker = wake::waker(cx.ctx(), FEED); // Send + Sync: hand it to the thread std::thread::spawn(move || { // … new data arrived … waker.wake(); }); wake::wake_after(cx.ctx(), Reason::TIMER, std::time::Duration::from_secs(1)); // a timed frame ``` Edits through commands, `cx.edit` and jobs ask for frames themselves. `app.stats` over the control port shows which reasons asked for frames, which also helps find an app that never goes idle. ## The app is slow with a long list Use `virtual_list::VirtualList` or `VirtualTable` instead of a loop over `ui.label`: they lay out only the visible rows. Key derived data (filtered rows, layouts) by `cx.revision()` so it is rebuilt only when the document changes. Wrap a plain list in `list::checked(ui, |ui| …)` and debug builds warn when it builds too many widgets in one frame. ## What works on the web? The headless core (commands, documents, files, jobs, logging) builds for `wasm32-unknown-unknown` and is checked in CI. The windowed runtime runs in a browser canvas but is experimental. There are no threads (jobs run inline; use `spawn_stepped` for long work), no disk (use in-memory or browser-storage backends), file dialogs can only open, and there is no tray, global shortcut, notification, clipboard, single instance or control port. See [Web](web.md). ## Notifications do nothing on macOS macOS delivers notifications only to an app bundle with a bundle identifier. A binary started with `cargo run` gets `NativeError::Unsupported`. Test them from a bundled build. ## The command does not appear in the menu Give it a menu path with `.menu(&["Edit"])` (or the third argument of `cmd!`). Commands made with `query!` are never in menus. A command whose id is already registered is ignored with a warning in the log. ## How do I see what went wrong? - The log: stderr, the rotating file in the app's `logs` folder, or `app.logs` over the control port. Raise the level with `_LOG=debug` (for an app named `notes`, `NOTES_LOG=debug`). - Crash logs are in the same folder. - `--safe-mode` starts without restoring view settings. - `ashlar-debug open ./target/debug/my-app` then `ashlar-debug snapshot -i` shows what the UI contains; see [Testing and debugging UIs](../docs/debugging-uis.md). ## How big is a release binary? About 13.6 MB stripped for the smallest app on macOS arm64, 9.0 MB with a size-optimised profile. See [Installation](installation.md#binary-size). # Contributing to ashlar Thanks for your interest in ashlar. Bug reports, fixes, docs, examples and new building blocks are all welcome. ## Before you start - **Bugs:** search the [issues](https://github.com/Himasnhu-AT/ashlar/issues) first, then open one with the bug report template. - **Features and new crates:** open an issue to discuss the idea before you write a lot of code. ashlar holds only general building blocks that serve more than one kind of app. Widgets for one domain (a timeline, a CAD canvas) belong in the app that needs them. - **Security issues:** don't open a public issue. See [SECURITY.md](SECURITY.md). ## Development setup 1. Install stable Rust (1.95 or newer) with `rustfmt`, `clippy` and the wasm target: ```sh rustup component add rustfmt clippy rustup target add wasm32-unknown-unknown ``` 2. On Linux, install the system libraries: ```sh sudo apt-get install libgtk-3-dev libxdo-dev libxkbcommon-dev libdbus-1-dev ``` 3. Optional: `cargo install cargo-deny --locked` for the dependency checks. 4. Run the gate: ```sh cargo xtask ci ``` ## The rules [AGENTS.md](AGENTS.md) holds the project's rules. They apply to people and AI agents alike, and CI enforces most of them. In short: - **Never crash.** No `unwrap`, `expect`, `panic!`, `unreachable!`, `todo!` or `unimplemented!` outside tests, and no `unsafe`. Treat every input-derived number as hostile. - **Respect the layers.** A crate depends only on crates of a lower rank ([docs/architecture.md](docs/architecture.md)). The headless core never depends on egui. - **Performance is a feature.** Idle apps do no work. Hot paths have benchmarks. - **No app-specific code.** Describe apps by kind ("a video editor"), not by name. The full list of gates, and how to run each one alone, is in [docs/contributing.md](docs/contributing.md). ## Pull requests 1. Fork the repository and create a branch from `main`. 2. Keep each pull request to one change, with tests for new behaviour and bug fixes. 3. Run `cargo xtask ci` and make sure it passes. 4. Update the docs and the crate's README when you change public behaviour, and add a line to [CHANGELOG.md](CHANGELOG.md) under "Unreleased". 5. Write commit subjects in the imperative mood, under about 72 characters. Use the body to explain why. 6. Open the pull request and fill in the template. ## License By contributing, you agree that your contributions are licensed under the [BSD 3-Clause License](LICENSE), the same license as the project. # Contributing Read [AGENTS.md](../AGENTS.md) first; its rules apply to people and agents alike. The crate ranks and design principles are in [architecture.md](architecture.md). ## Gates `cargo xtask ci` must pass before every commit. It runs: | Step | What it checks | |---|---| | `fmt` | `cargo fmt --all --check` | | `names` | no app name in any file of the repository (false positives: `xtask/names-allow.txt`) | | `layers` | each crate depends only on lower ranks; ranks 0–4 never on a UI crate | | `deny-panics` | every crate inherits the workspace lints; no never-crash lint is allowed away outside tests (the OS FFI crate follows docs/adr/0001-native-ffi.md instead) | | deps | `cargo deny check bans sources` (skipped with a note when cargo-deny isn't installed) | | `clippy` | `cargo clippy --workspace --all-targets -- -D warnings` | | `test` | `cargo test --workspace` | | `wasm` | `cargo check --target wasm32-unknown-unknown` on the rank 0–1 crates, `ashlar-ui`, `ashlar-app` and `examples/web-hello` | The quick checks run first and are all reported; then clippy, wasm and the tests run side by side (clippy and wasm in their own target dirs under `target/`, their output shown only on failure). If another checkout is compiling, `ci` waits for it so two gates don't fight over the cores; `--no-wait` skips that. Every step also runs on its own: `cargo xtask names`, `cargo xtask wasm`… Other tasks: - `cargo xtask msrv`: `cargo check` on the workspace `rust-version` (1.95); `rustup toolchain install 1.95 --profile minimal` first. - `cargo xtask advisories`: cargo-deny on advisories, bans and sources (`cargo install cargo-deny --locked`). - `cargo xtask bench [--compare FILE]`: the performance gate, see architecture.md. CI (`.github/workflows/ci.yml`) runs `cargo xtask ci` on Linux, macOS and Windows, `cargo xtask msrv`, and the advisory check. Actions are pinned by commit SHA; update them together. Releases (crates.io and GitHub) are in [releasing.md](releasing.md). Tools needed locally: stable Rust with `rustfmt`, `clippy` and the `wasm32-unknown-unknown` target; cargo-deny is optional locally and installed on CI. ## Commits - One task per commit, with its id: `AS-P1.2: history merge keys`. - Only green states: run `cargo xtask ci` (or, while working on one crate in a parallel worktree, `cargo test -p `, `cargo clippy -p --all-targets -- -D warnings` and `cargo xtask layers`; the full gate runs at integration). - Imperative subject under ~72 characters; the body says why. - AI-assisted commits end with the co-author line, e.g. `Co-Authored-By: Claude Opus 5.5 `. ## Parallel work One git worktree per contributor under `.claude/worktrees/` (ignored by git), branch `wt/`, with its own `CARGO_TARGET_DIR=$PWD/target/agent-`; remove both once merged. Never commit on `main`; the integrator merges. # ashlar: rules for agents and developers ashlar is a Rust library for building fast desktop and web apps: documents and undo, commands, background jobs, files and settings, native OS features, UI components (egui), and a runtime that gives every app crash safety, telemetry and agent control (control port, MCP, CLI) for free. Domain widgets (a timeline, a CAD canvas…) are app-specific and stay in the apps; ashlar only provides the general building blocks they are made from. ## 0. Never crash No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!`/`unimplemented!` outside tests (denied in `[workspace.lints.clippy]`), no `unsafe` (forbidden; only `ashlar-native` may later relax this for FFI, one module at a time, with `// SAFETY:` comments). Every input-derived number is hostile: `get()` not `[i]`, checked/saturating math, capped allocations, bounded recursion. Lock poisoning is not fatal (`lock().unwrap_or_else(PoisonError::into_inner)`). Background work runs under `catch_unwind`. Integration-test files start with `#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]`. ## 1. No app code, no app names A crate here must serve more than one app and never depend on one. Never name the apps that use ashlar, anywhere in this repository (public API, identifiers, docs, agent instructions, commit messages): describe them by kind instead, "a video editor", "a DAW". `cargo xtask names` enforces this over every file in the repository (the names are kept only as hashes; false positives go in `xtask/names-allow.txt`). The one exception is the "Built with ashlar" section of the root README, allowed in that file only. ## 2. Layering (`cargo xtask layers`) Crates have a rank (see `xtask/src/main.rs`); a crate depends only on lower ranks. Ranks 0–4 never depend on egui/eframe/winit: the core is headless and builds for wasm32. ## 3. Performance is a feature Idle apps do zero work (repaint on demand). Caches are keyed by revision numbers, not by diffing. Long lists are virtualized. No blocking I/O on the UI thread. Benches guard every hot path. ## 4. Gates before every commit `cargo xtask ci`: fmt check, names, layers, deny-panics, deps (cargo-deny, when installed), then clippy -D warnings, tests and the wasm32 check (ranks 0–1, `ashlar-ui`, `ashlar-app`) side by side (`--no-wait` skips waiting for other checkouts' builds). Also `cargo xtask msrv`, `advisories` and `bench`; see docs/contributing.md. One task id per commit (`AS-P1.2: history merge keys`), only green states, attribution line at the end. ## 5. Stable contracts Items marked `CONTRACT` in `ashlar-command` are shared across crates built in parallel: change them only additively. ## 6. Working in parallel One git worktree per agent under `.claude/worktrees/` (inside the checkout, kept out of git), branch `wt/`, own `CARGO_TARGET_DIR` (`target/agent-` inside the worktree); remove the worktree and its branch once merged. Never commit on `main`; the integrator merges. Build and test only your crates (`cargo test -p `); the full gate runs at integration. # Architecture ashlar is a workspace of small crates stacked in ranks. An app depends on the umbrella crate `ashlar-rs` (imported as `ashlar`) and turns on the features it needs; each layer below it can also be used on its own. ## Crate ranks A crate depends only on crates of a **lower** rank (dev-dependencies are exempt). Ranks 0–4 never depend on a UI crate (`egui`, `eframe`, `winit`, `egui-wgpu`, `egui_kittest`), and ranks 0–1 build for `wasm32-unknown-unknown`, as do `ashlar-ui` and `ashlar-app` (the web build path). `cargo xtask layers` and `cargo xtask wasm` enforce this; the table below must match `LAYERS` in `xtask/src/main.rs` (a crate missing from `LAYERS` fails the gate). | Rank | Crate | What it is | Headless | wasm32 | |---|---|---|---|---| | 0 | `ashlar-command` | Commands: registry, typed params, one execute chokepoint, discovery | yes | yes | | 0 | `ashlar-fs` | Files and settings: app dirs, durable atomic writes, keyed settings, recent files | yes | yes | | 0 | `ashlar-jobs` | Background jobs: progress, cancel, panic isolation, apply-on-UI-thread | yes | yes | | 0 | `ashlar-trace-proto` | Telemetry wire format: envelopes, items, name rules, limits | yes | yes | | 0 | `ashlar-docs` | Documentation library for agents: folders are categories, markdown files are documents, embedded at build time, a run-time folder on top | yes | yes | | 0 | `ashlar-log` | Logging and crash safety: directive filters, stderr + rotating file + ring, panic hook, launch guard | yes | yes | | 0 | `ashlar-obfuscate` | Compile-time string obfuscation: build-time values kept out of a strings dump of the binary | yes | yes | | 0 | `ashlar-motion` | Motion maths: closed-form springs, curves, animated values that keep their velocity on retarget, reduced-motion rules per value class, motion tokens, OKLab/OKLCH colour, gesture helpers | yes | yes | | 0 | `ashlar-syntax` | Syntax highlighting: linear-time lexers for 18 languages, line states for incremental views, a `Highlighter` trait for bigger engines | yes | yes | | 0 | `ashlar-diff` | Text diffs: Myers with a cost cap, word-level changes, hunks with collapsible context, a unified-patch parser and writer | yes | yes | | 0 | `ashlar-i18n` | Translations: JSON catalogs per locale with fallback chains, `{name}` templates, CLDR plural categories, number formatting, locale negotiation and catalog checks | yes | yes | | 1 | `ashlar-trace` | Telemetry SDK: consent, spool, sending, crash/hang/frame/system signals, event catalog | yes | yes (no-op) | | 1 | `ashlar-store` | Documents: `Arc` snapshots, revisions, undo history, versioned formats, autosave | yes | yes | | 2 | `ashlar-native` | Native OS features behind one API: dialogs, menus, clipboard, power, notifications, secrets | yes | – | | 2 | `ashlar-control` | Agent control port: hardened JSON-lines server, client, standard method set | yes | – | | 2 | `ashlar-trace-server` | Self-hosted telemetry server: ingest, SQLite store, rollups, dashboard | yes | – | | 2 | `ashlar-print` | System PDF print spooler (CUPS, Windows) | yes | – | | 2 | `ashlar-update` | Update checks: latest release from a releases API, semver comparison, background schedule, cached result, "later" per version | yes | – | | 2 | `ashlar-integrations` | Integrations: tool servers installed from a catalog (download with resume and sha256, code-signature check, versions, enable/disable) and run as MCP servers over stdio; their tools for an assistant | yes | – | | 2 | `ashlar-mcp-http` | MCP over streamable HTTP on loopback for an open app: bearer token, Host/Origin checks, a gate, sessions | yes | – | | 2 | `ashlar-agents` | Connect coding agents to an app: its MCP server in each agent's config, skills and rules from a docs category, install and uninstall | yes | – | | 3 | `ashlar-telemetry` | Always-on crash, performance and usage telemetry over ashlar-trace: client, transport, hooks for commands, logs, frames and dialogs | yes | – | | 3 | `ashlar-mcp` | MCP server generated from the command registry (stdio), headless or bridged | yes | – | | 4 | `ashlar-cli` | Standard CLI verbs over the command registry | yes | – | | 4 | `ashlar-assistant` | In-app AI assistant harness, independent of the model: conversation and tool types, providers (an account backend, Anthropic, OpenAI-compatible), the agent loop with approvals, keys in the OS keychain, an MCP server as the tool host | yes | – | | 4 | `ashlar-debug` | `ashlar-debug`: drive a running app's UI from the shell (snapshot, refs, click, wait, screenshot, scripted UI tests) | yes | – | | 5 | `ashlar-ui` | UI components on egui: theme tokens, controls, layout, docking, icons | no | yes | | 6 | `ashlar-app` | Runtime: App builder, frame scheduler, crash guard, logging, telemetry, agent control | no | yes | | 7 | `ashlar-rs` | Umbrella crate (imported as `ashlar`): re-exports the others behind features | no | – | | 8 | `ashlar-example-gallery`, `ashlar-example-hello`, `ashlar-example-notes`, `ashlar-example-trace-demo-cli`, `ashlar-example-trace-loadgen`, `ashlar-example-web-hello` | Example apps | no | – | | 9 | `xtask` | Repo gates and tools (`cargo xtask …`) | – | – | Adding a crate: give it the lowest rank its dependencies allow, add it to `LAYERS` and to this table, add `[lints] workspace = true` to its manifest, and add it to `[workspace.dependencies]` with both `path` and `version`. ## Design principles 1. **Never crash.** No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!`/`unimplemented!` and no `unsafe` outside tests. Every input-derived number is hostile: `get()` instead of indexing, checked or saturating math, capped allocations, bounded recursion. Lock poisoning is not fatal. Background work runs under `catch_unwind`. Enforced by the workspace clippy lints and `cargo xtask deny-panics`. 2. **No app code, no app names.** A crate here serves more than one app and never depends on one. App-specific widgets (a timeline, a CAD canvas) stay in the apps and are built from ashlar's primitives. Docs say "a video editor", "a DAW". Enforced by `cargo xtask names`. 3. **A headless core.** Documents, commands, jobs and files never touch the UI, so they run in tests, in a CLI, behind MCP and in the browser. The UI is openly egui; there is no renderer-agnostic abstraction. 4. **Everything is a command.** Menus, shortcuts, the palette, the CLI and MCP all go through one registry and one execute chokepoint, so anything a user can do an agent can do. 5. **Performance is a feature.** Idle apps do zero work (repaint on demand). Caches are keyed by revision numbers, not by diffing. Long lists are virtualized. No blocking I/O on the UI thread. Edits are copy-on-write with structural sharing. Every hot path has a perf test, and `cargo xtask bench --compare` guards it (see below). 6. **Motion is a policy.** Nothing animates on first show, the user's own hand is never animated, reduced motion is applied per value class (`ashlar-motion`'s `Class`), and settled animations request no frames. 7. **Stable contracts.** Items marked `CONTRACT` in `ashlar-command` are shared across crates built in parallel and change only additively. ## Performance gate Hot paths have perf tests: `#[test] #[ignore]` functions whose names start with `perf`, usually in `tests/perf.rs`. They print their numbers as ```text bench: e.g. bench: edit_100k 812 ns ``` Units ending in `/s` are higher-is-better; all others lower-is-better. A test that prints no `bench:` line is measured by wall time, which is noisy, so prefer explicit metrics. One step is not a test: `perf_hello_size` builds `examples/hello` (an empty document, a heading and a checkbox) in release, strips a copy and reports `hello_stripped_bytes`, the size the framework adds to every app. `cargo build --profile release-small` (fat LTO, one codegen unit, `opt-level = "s"`, stripped) is the workspace's size profile. ```sh cargo xtask bench # writes target/bench/bench.{json,md} cp target/bench/bench.json baseline.json # before a change cargo xtask bench --repeat 3 --compare baseline.json # fails on a >5% regression ``` `--repeat N` keeps the best value of each metric over N rounds; `--threshold PCT` changes the 5%; `--filter TEXT` runs only matching tests. ## Dependencies `cargo xtask ci` checks duplicate versions and sources of every dependency when cargo-deny is installed (`deny.toml`); `cargo xtask advisories` also checks the advisory database. # ADR 0001: `unsafe` OS FFI in `ashlar-native`, and nowhere else - **Status:** accepted (2026-08-18, wave 1) ## Context The workspace sets `unsafe_code = "forbid"` (AGENTS.md §0). Some OS features every desktop app needs have no safe binding: registering Apple event handlers (files opened from Finder), ending an `NSProcessInfo` activity, `SetThreadExecutionState`, `ITaskbarList3`, `ShellExecuteW`, `SystemParametersInfoW`, and attaching a muda menu to a Win32 window. The bindings used (`objc2-*`, the official `windows` crate, muda) are generated declarations. ## Decision Allow `unsafe` in exactly one crate, `ashlar-native`, for OS FFI. Containment rules (the same as the video editor's platform crate these were adapted from): 1. The crate does not use `lints.workspace = true`. Its own `[lints]` table copies the workspace lints with `unsafe_code = "deny"` (not `forbid`) and adds `clippy::undocumented_unsafe_blocks = "deny"`. Only FFI modules carry `#[allow(unsafe_code)]`: - `power` (macOS `endActivity:`, Windows `SetThreadExecutionState`) - `open_files` (macOS: the Apple event handler class and its registration) - `progress` (Windows `ITaskbarList3`) - `system` (Windows `ShellExecuteW`, `SystemParametersInfoW`, `GetUserPreferredUILanguages` (added 2026-11-10 so apps started from the desktop, which have no `LANG`, find the user's languages); and for `bring_to_front`, added 2026-11-10: `IsIconic`, `ShowWindow`, `SetForegroundWindow`, `BringWindowToTop`, `AttachThreadInput`) - `mac_shell` (macOS: replacing the implementation of winit's window delegate `observeValueForKeyPath:ofObject:change:context:` with a checked wrapper that calls the original, `class_addMethod` of `applicationShouldHandleReopen:hasVisibleWindows:` on winit's application delegate, and the `msg_send!`s that inspect the KVO change); added 2026-11-10 so a nil appearance during screen capture doesn't end the app and the Dock icon shows the window - `menu::popup` (muda's `show_context_menu_for_nsview` / `show_context_menu_for_hwnd` on the window's own view or HWND, retaining the macOS view, and posting the macOS menu to the main run loop) and `drag_out` (macOS: an `NSDraggingSource` class, retaining the window's view, an `NSDraggingItem` frame); added 2026-11-10 for context menus and dragging files out - `single_instance` (Windows `AllowSetForegroundWindow`, one block; added 2026-11-10 so the second launch lets the first come to the front) - `menu::native::attach_window` (muda's `init_for_hwnd`) and `menu::native::accelerators` (Windows: `SetWindowSubclass`, `TranslateAcceleratorW`, `DefSubclassProc`; added 2026-11-10 so menu shortcuts work without a hook in winit's message loop) - `terminate` (Unix, through `libc`: `pipe`, `fcntl`, `sigaction`, and in the handler only `write`, `signal` and `raise`, which are async-signal-safe; added 2026-09-10 so a stdio server can end its telemetry session when it is asked to stop) - `memory` (macOS `proc_pid_rusage`, glibc `malloc_trim`, Windows `GetProcessMemoryInfo`; added for idle apps that report their footprint and hand freed memory back; macOS `proc_pidinfo` and Windows `CreateToolhelp32Snapshot`/`Process32FirstW`/`Process32NextW`/ `CloseHandle` added 2026-11-10 for the thread count in `app.stats`) - `display` (Windows `GetProcessWindowStation`, `GetUserObjectInformationW`; added 2026-10-27 so an app started where no window can be shown says so and exits instead of panicking; Windows `EnumDisplayMonitors` with its callback, `GetMonitorInfoW` and `GetDpiForMonitor` added 2026-11-10 to list the monitors before the window exists, so a saved placement is checked at launch; macOS uses objc2's safe `NSScreen` bindings) - `notify::macos` (the `UNUserNotificationCenterDelegate` class, its `init`, and `setUserInfo:`) and `notify::windows` (`SetCurrentProcessExplicitAppUserModelID`, `CoInitializeEx`); added 2026-11-10 so banners show while the app is frontmost, clicks are reported and Windows gets toasts - `session_end` (macOS: `class_addMethod` of `applicationShouldTerminate:` on winit's application delegate class, and reading that method's implementation; Windows: a hidden window's class, window and message loop for `WM_QUERYENDSESSION` / `WM_ENDSESSION`); added 2026-11-10 so Dock Quit, logout and shutdown get the "Save changes?" question and the settings, autosave and telemetry flush Everything else (dialogs, clipboard, secrets, Linux notifications, macOS progress and preferences, single instance off Windows, tray, global shortcuts) uses safe APIs of rfd, arboard, keyring-core, objc2 and zbus. 2. Every `unsafe` block has a `// SAFETY:` comment saying why it is sound. 3. The public API is safe: no `pub unsafe fn`, no raw pointers or FFI types in signatures. Window handles come in through `raw_window_handle::HasWindowHandle`, so a caller can't pass a dangling HWND. Failures are `Result<_, NativeError>`. 4. No panic crosses an FFI boundary: the Apple event handler methods and the notification completion block run under `catch_unwind`; calls into third-party bindings that could panic (rfd, arboard, muda, keyring) go through `guarded`, which turns a panic into `NativeError::Os`. 5. The crate compiles on every target. OS bindings are target-specific optional dependencies enabled per cargo feature; elsewhere a call returns `NativeError::Unsupported`. With default features it passes `cargo check --target wasm32-unknown-unknown`. ## Consequences - Changes to the FFI modules need the same scrutiny as any FFI code. A new use of `unsafe` goes into one of the listed modules with a SAFETY comment, or needs an update to this record. - Main-thread requirements (macOS menus, Dock tile, Apple event handlers) are checked at run time with `MainThreadMarker` and reported as errors instead of crashing. # Benchmarks How fast ashlar is, how that was measured, and how to reproduce it. Machine: Apple M5 Pro (15 cores), 24 GB, macOS. All builds are release builds, run through `memgate`. Two kinds of numbers appear below: - **Quiet measurements** were taken with nothing else heavy running. Comparisons use only these. - **The full-suite snapshot** (`cargo xtask bench`, at the end) ran while other builds shared the machine. Its absolute timings are inflated, often by 2–4×. Allocation counts, sizes and frame counts are not affected by load. ## Running them ```sh cargo xtask bench # every `perf_*` test; writes target/bench/bench.{json,md} cargo xtask bench --filter plain_scroll # one group cargo xtask bench --repeat 3 --compare base.json # fails on a >5 % regression against a saved run cargo xtask launch --runs 9 # launch → window → first present, opens real windows cargo xtask launch --memory # idle footprint and peak after a settle ``` A perf test is a `#[test] #[ignore] fn perf_*` that prints `bench: ` lines (see docs/architecture.md, "Performance gate"). ## Against a comparable native Rust UI framework Measured on 2026-11-10, both frameworks in the same session with the same tools: - a launcher that times spawn → window, plus `ps` and `vmmap` for idle cost - each framework's own benchmark for the same scrolling workload: a plain list of 1,000 rows of 32 pt in a 760×800 pt viewport at 2×, with 120 warm-up frames and then 480 wheel steps of 12 pt at 120 Hz, reversing halfway The other framework draws with its own GPU renderer and a retained element tree. ashlar draws with egui. | Metric | ashlar | other framework | Better | |---|---|---|---| | Scroll 1k plain rows, frame CPU p50 / p95, with `list::Row` | **0.10 / 0.12 ms** | 0.298 / 0.32 ms | ashlar, ~2.8× | | Same, rows drawn as egui selectable labels | 0.65–0.76 / 0.70–0.81 ms | 0.298 / 0.32 ms | other, ~2.3× | | Scroll 10k plain rows, p50, `list::Row` / labels | 0.96–1.15 / 8.6–8.9 ms | not run | — | | Stripped hello binary | 14.0 MB; **9.2 MB** with `--profile release-small` | 21.3 MB | ashlar | | Incremental rebuild of hello | 5.3 s | 14.7–17.7 s | ashlar, ~3× | | Clean release build of hello (loaded machine) | 133 s | 133 s | tie | | Launch → window, warm median of 8 | 106.5 ms | 98 ms | other, ~8 % | | Launch → window, cold first run | 111 ms | 1,360 ms | ashlar | | Idle CPU over 10 s | 0 % | 0 % | tie | | Idle footprint / peak footprint | 40 MB / 94 MB (window never covered); 66 MB / 192 MB after the window was covered | 29 MB / 176 MB | other at idle | | Dependencies of hello (unique packages) | 179 | 202 | ashlar | What each scroll number covers: - ashlar's is the thread CPU of the egui pass plus tessellation. - The other framework's is the thread CPU from wheel dispatch to its draw list. - Neither includes GPU submission. Lists should be virtualized (`VirtualList`). Then the cost does not depend on the row count at all: 0.035 ms at 1k or 100k rows, measured quiet. **Idle memory, explained:** ashlar's idle footprint is about 40 MB (94 MB peak) for a window that has never been covered, and about 66 MB (about 193 MB peak) once it has been covered, minimized or moved to another Space, even briefly right after launch. At that point macOS maps two swapchain images it shares with the window server into the process (about 19 MB), and Metal allocates a second set of blocks. This happens with any present mode or frame latency, so it is not something ashlar's own settings cause. `cargo xtask launch --memory` launches 5 times and reports both groups. The other framework's 29 MB was measured the same way. Whether its windows avoid the effect, or were simply never covered during the measurement, is not known. ## Before and after the 2026-11 improvement work All quiet measurements. "Before" is commit 393afe0. | Metric | Before | After | Change | |---|---|---|---| | Plain 1k-row list, frame CPU p50 | 0.63 ms (labels) | 0.10 ms (`list::Row`) | 6× less | | Typing with a search query, 100k notes, keystroke p95 | 4.6–5.0 ms | 0.53 ms | 9× less | | Selecting the last of 100k notes, frame p95 | 0.106 ms | 0.038 ms | no longer depends on the selected row's position | | Live resize, frame p95 | 0.103 ms | 0.034 ms | 3× less | | Static 52 KB markdown, frame p50 | 0.944 ms | 0.015–0.031 ms | 30–60× less | | Static 52 KB markdown, layout jobs per frame | 115 | 6 | | | Long 50 KB markdown paragraph | quadratic | linear (`md_long_paragraph_parse` 0.4–0.7 ms) | | | Galley cache, worst frame after it fills | 4,160 re-layouts | 0 | | | One row height change in a 1M-row variable list | 1,388 µs | ~1.3 µs | | | Frames drawn per engine command | 3.20 | 1.26 | 61 % fewer | | Frames over 30 s of idle with a focused text field | 194 (all "input") | 21 (caret blinks), then 0 | | | Allocations per idle wake frame | 954 | 272 | 71 % fewer, gated at ≤ 578 | | Allocations per keystroke with a query | 3,716 | 990 | | | Allocations per scroll frame (100k notes) | 472 | 345 | | | Stripped hello binary | 12.76 MB (scratch app) | 9.2 MB with `release-small` | | ## Component costs (quiet) | Metric | Value | |---|---| | `VirtualList` scroll, 1k / 100k rows | 0.035 / 0.033 ms | | Tree, 1M nodes, scroll frame p95 | 0.041 ms | | Code view, 100k lines, scrolled frame | 0.058 ms (first frame about 4.5 ms) | | Diff view, 50k lines, scrolled frame | 0.41 ms (split 0.49 ms) | | Chat transcript, 10k messages, frame | 0.129 ms (1k: 0.092 ms) | | Myers diff, 10k lines | 2.08 ms | | Syntax highlighting, 1k lines (Rust / TypeScript / Python) | 477 / 757 / 280 µs | | Headless wgpu screenshot, 2560×1600 including PNG and base64 | 26 ms | | Spring sample / rect retarget / OKLab mix | 7.9 ns / 185 ns / 36 ns | | Galley cache hit | 20 ns | | Document edit at 100k elements, chunked vs flat | 515 ns vs 12,632 ns | | Undo or redo | 14 ns | ## Full-suite snapshot (loaded machine) This is `cargo xtask bench` at `imp` 30e3264, run while five other builds were compiling. Use it to see which metrics exist, not for absolute timings: run it on a quiet machine before comparing. | Metric | Value | |---|---| | `allocs_per_idle_wake_frame` | 272 (gate ≤ 578) | | `bytes_per_frame_scroll_100k` | 203,865 B | | `notes_first_frame_wall` | 18.3 ms | | `frame_p95_scroll_100k` / `tessellate_p95` / `frame_cpu_p95_x2` | 0.132 / 0.026 / 0.183 ms | | `frames_per_key_command` | 3.0 | | `plain_scroll_1k_rows_widget_p50` | 0.275 ms (0.10 ms quiet) | | `virtual_scroll_100k_p50` | 0.057 ms | | `md_static_frame_p50` / `md_static_layout_jobs` | 0.031 ms / 6 | | `galley_overflow_relayouts` | 0 | | `one_row_height_change_1m` | 1.5 µs | | `transcript_10k_messages_frame` | 0.111 ms | | `code_view_100k_lines_frame` / `diff_50k_lines_frame` | 0.094 / 0.428 ms | | `hello_stripped_bytes` | 14,009,160 B | # Control protocol Every ashlar app can be driven by an agent in three ways, all speaking **one method vocabulary**: | Surface | Transport | Crate | |---|---|---| | Control port | JSON lines over TCP `127.0.0.1:`, keyed | `ashlar-control` (`Server`, `Client`) | | Child process | JSON lines over stdin/stdout, no key (private pipe) | `ashlar-control::serve_stream`, ` serve` | | MCP | JSON-RPC 2.0 over stdio; tools map onto the methods below | `ashlar-mcp` | | CLI | one-shot verbs, one-line replies | `ashlar-cli` | Protocol version: **1** (reported by `app.version`). ## Framing One JSON object per line (`\n`, an optional `\r` is stripped), each way, one reply per request, in order. Request: ```json {"id": 7, "key": "<64 hex digits>", "method": "engine.execute", "params": {"command": "edit.undo"}} ``` - `id` (optional, any JSON): echoed in the reply; omitted from the reply when absent. - `key`: the app's control key (TCP only; see Keys). Not needed after `auth` on the same connection. - `method` (required string). - `params` (optional; `null` or absent means `{}`). Reply: ```json {"id": 7, "ok": true, "result": …} {"id": 7, "ok": false, "error": "human-readable message", "detail": …} ``` `detail` is optional machine-readable context: for a failed command, the command error `{"kind": "unknown"|"disabled"|"bad_params"|"failed"|"panicked", "id": …, …}`; for a failed batch, `{"steps": [...]}`. ## Hardening (TCP) The server is built so that a hostile local process or web page cannot drive or crash the app: - **Loopback only.** Binds `127.0.0.1`; the client only connects there. - **Key.** Once the port is bound the app writes 32 random bytes (64 hex digits) to `/control-key.`, created with mode 0600, where `` is a folder the app chooses (its settings folder) and `` is the Flatpak instance id or `local-`. The file is removed when the server is dropped; a stale one is replaced. Every request must carry the key, as the top-level `key` member or once per connection with `auth`. A missing or wrong key gets `{"ok":false,"error":"unauthorized"}` and the connection is closed. Keys are compared in constant time. `params.key` is an ordinary parameter for every method but `auth` (`ui.key {"key":"Enter"}`). - **Strict lines.** A line that is not a JSON object with a string `method` (invalid UTF-8, bad JSON, an array, a number, an HTTP request line) gets one error reply and the connection is closed, so nothing after it runs. This is what stops a browser's cross-origin `fetch` POST: its request line `POST / HTTP/1.1` is rejected before the JSON body is read. - **Bounded lines.** Longer than `max_line` (4 MiB default): error reply, the rest of the line is drained (at most 64 MiB, 2 s) so the client can read the error, then close. A peer can never make the server buffer more than `max_line`. - **Connection cap.** At most `max_connections` (16) at once; extra ones get `{"ok":false,"error":"too many control connections"}` and are closed. - **First-request deadline.** A connection must send an authorised request within 30 s. It is one overall deadline: blank lines or a slow drip of bytes do not extend it. After that, no idle limit. - **Reply timeout.** The app thread answers within 60 s or the client gets `timeout: …`. - **Never panics.** No `unwrap`/indexing on input; junk never reaches the app thread; a request the app drops unanswered replies with an error by itself. ## Standard methods Answered by `ashlar_control::handle` from any `Dispatch` (headless or windowed): | Method | Params | Result | |---|---|---| | `auth` | `{key}` or top-level `key` | `{}` (transport-level) | | `app.ping` | — | `"pong"` | | `app.version` | — | `{name, version, protocol, methods: [...]}`: `methods` lists every method this app answers, so clients know whether `ui.*` works | | `engine.commands` | `{query?}` (case-insensitive, matches id, label, menu path) | `[CommandInfo]`: `{id, label, menu, shortcut, params_schema, params_text, enabled, disabled_reason, mutates}` | | `engine.execute` | `{command, params?}` | the command's result | | `engine.batch` | `{commands: [{command, params?}], keepGoing?}` | `[{command, ok, result}]`; on failure an error with `detail.steps` (stops at the first failure unless `keepGoing`; at most 10 000 steps) | ## UI methods Routed to the app's `UiHandler`. A handler may keep a request and answer it on a later frame. Headless sessions refuse them with a hint. The windowed runtime answers the app-level ones itself and hands the debugger methods (next section) to `ashlar_ui::devtools`: | Method | Params | Result | |---|---|---| | `ui.inspect` | — | UI state plus every registered element `{id, label, role, rect:[x,y,w,h], enabled, value?, selected?}` | | `ui.menu.list` | — | the menu tree with enablement | | `ui.menu.invoke` | `{id}` | as `engine.execute` | | `ui.set` | app-defined view state (`{theme?, workspace?, …}`) | `{}` | | `app.quit` | `{discard?}` | `{closing}`, then the app quits | | `app.stats` | `{reset?}` | frame times, passes, jobs, `wakes` (frames and requests per reason) | | `app.logs` | `{limit?, level?}` | `{lines: [{unix, level, target, message}]}` | | `app.clock` | one of `{pause: true}`, `{step_ms}`, `{resume: true}`, or `{}` to read | `{paused, time}` | | `ui.scene` | `{name}`, or `{}` to list | `{scene}` / `{scenes: [..]}`; only apps that declare scenes ([`App::scene`](debugging-uis.md#named-scenes)) list it | `app.clock` holds the time the app's frames see, to look at an animation at an exact moment. `pause` freezes it at the current frame's time; `step_ms` (0 < ms ≤ 60000; pauses first) moves the frozen time on and runs one frame there; `resume` lets time run again from the frozen value, so nothing jumps (the app's time then lags the wall clock by the time spent paused). `time` is the egui time in seconds the next frame sees. While paused: - input still runs frames, at the frozen time. Two clicks are then 0 s apart, so egui counts them as a double click; step between clicks that must stay single. - everything timed by egui time stands still: egui's animations, `ashlar_ui::motion`, tooltip delays, toast timeouts, the caret's blink phase and other timed wakes. A timed wake still runs a frame about once per its delay (the frame finds it not yet due), and an egui animation that is mid-flight keeps asking for frames that draw the same picture. - settings and autosave run on the host's clock, so saves are not held up. Background jobs and control requests run as usual. `ashlar-debug clock pause|step |resume` (also a `.ui` script line) and the MCP tool `ui_clock` send it. A headless session refuses it. Apps may add their own methods (`ui.timeline.hit`…); list them in `UiHandler::methods` so they show in `app.version`. ## Debugger methods Drive the UI the way a browser automation tool drives a page: snapshot the element tree with short refs, act on refs, wait, read, screenshot. Implemented by `ashlar_ui::devtools` (an egui plugin), so an app that does not use `ashlar-app` gets the same methods (the `ashlar-debug` CLI and the MCP `ui_*` tools speak them; see [debugging-uis.md](debugging-uis.md)) by passing requests to `devtools::handle(ctx, request)` (see that module's docs). `app.version` lists them when available. ### Elements and refs The element tree is egui's AccessKit output (every egui widget: role, label, value, state, bounds) merged with the ashlar automation registry (dotted `id`s, component roles). An element: ```json {"ref": "e12", "id": "notes.title", "role": "textbox", "name": "Title", "value": "Groceries", "enabled": true, "focused": false, "selected": false, "checked": null, "visible": true, "rect": [x, y, w, h], "parent": "e3"} ``` `id`, `value`, `selected`, `checked` and `parent` are omitted when they do not apply. `rect` is the on-screen part in logical points. Roles: `button checkbox switch radio radiogroup textbox searchbox slider spinbutton combobox link label image colorwell panel window dialog scrollbar separator progressbar menu menubar menuitem tab tablist list listitem tree treeitem table row cell columnheader toolbar status alert tooltip group generic`. A ref stays the same for an element across snapshots while it exists (keyed by its automation id, else by its egui widget id and role); every `ui.snapshot` increments the `generation`. The first debugger request turns egui's AccessKit output on for the rest of the session. ### Targets Every method that acts on an element takes a target, as `target: {...}` or flat in the params: | Target | Matches | |---|---| | `{ref: "e12"}` (also `"@e12"`, `12`, or the bare string `"@e12"`) | the element of a snapshot | | `{id: "file.save"}` | the (first visible) element with that automation id | | `{text: "Save", role?: "button"}` | the first visible element with that name: exact, then ignoring case, then containing it; interactive elements first. Not accepted flat (`text` is the typed text of `ui.fill`/`ui.type`) | | `{x, y}` | that point (logical points); the element there, if any | The error text starts with the kind (`stale_ref: …`, `not_found: …`, `timeout: …`) and `detail.kind` carries it: `stale_ref` (`{kind, ref, generation}`: the element of that ref is gone; take a new snapshot), `not_found` (`{kind, generation}`), `timeout` (`{kind, elapsed_ms}`). ### Methods All answer once the frames they need have run; input methods return `{}` after the injected input has been processed by a frame. | Method | Params | Result | |---|---|---| | `ui.snapshot` | `{interactive?: false, compact?: false, scope?: target, depth?}` | `{generation, tree, elements: [element], focused: ref│null}` | | `ui.click` | target, `button?` (`primary`/`secondary`/`middle`), `count?` (1–3), `modifiers?` | `{}` | | `ui.hover` / `ui.move` | target (`ui.move`: `{x, y}`) | `{}` | | `ui.focus` | target | `{}`: AccessKit focus, or a click for text fields | | `ui.fill` | target?, `text`, `submit?` | `{}`: click the target (not when it is a text field that has the keyboard already), select all, type `text` (or delete when empty), Enter when `submit`; without a target, the focused field | | `ui.type` | target?, `text` | `{}`: typed at the caret of the target (clicked first, unless it is a text field that has the keyboard already, so calls in a row add up) or of the focused widget | | `ui.press` | `keys` (`"Mod+Shift+S"`, sequences `"G G"`; `ashlar_command::shortcut` syntax; `key` is accepted too) | `{}` | | `ui.key` | `{key, modifiers?}` | `{}` (one key; `modifiers`: `{command, shift, alt, ctrl}`, `command` = ⌘ on macOS, Ctrl elsewhere) | | `ui.scroll` | target? (default: the window centre), `dx`, `dy` in points; `dy > 0` scrolls down (towards the end), `dx > 0` right | `{}` | | `ui.scroll_into_view` | target | `{}` | | `ui.drag` | `{from: target, to: target, steps?: 8, modifiers?}` | `{}` | | `ui.get` | target, `what`: `text` (a field's content, else the name), `name`, `value`, `rect`, `enabled`, `visible`, `focused`, `checked`, `selected`, `role`, `element` | `{value}` | | `ui.wait` | `{for: element│gone│text│enabled│idle, target?, text?, timeout_ms?: 5000}` (`for` defaults to `text` when `text` is given, else `element`) | `{elapsed_ms}`; error `timeout` | | `ui.screenshot` | target? (crop; also the legacy `{id}`), `pad?`, `annotate?`, `highlight?: target`, `raise?: true` | `{png: , width, height, annotations?: [{ref, rect}]}` | | `ui.highlight` | target, `ms?: 1500` | `{rect}`: the element is outlined in the live window | | `devtools.toggle` | `{open?}` | the state below | | `devtools.state` | — | `{open, generation, elements, pending: {input_frames, waits, screenshots}, repaints_while_open}` | The text outline of `ui.snapshot`, one element per line, two spaces of indent per level: ```text - button "New Note" [ref=e5] [id=notes.add] - list [ref=e8] [id=notes.list] - listitem "Groceries" [ref=e9] [id=notes.list.row.0] [selected] - textbox "Title" [ref=e14] [id=notes.title] [value="Groceries"] [focused] ``` Flags: `[disabled] [focused] [selected] [checked]│[unchecked] [offscreen]`; names and values are cut at 80 characters. `interactive` lists only visible controls; `compact` leaves out unnamed non-interactive and off-screen elements. Both leave out unnamed `generic` elements (drag areas, click catchers) that have no automation id. Waits are checked on frames the app runs anyway plus one wake at the deadline: a pending wait never makes the app repaint in a loop. `idle` means a frame asked for no repaint within 200 ms, no injected input is pending and the app reports no work of its own (`devtools::set_busy`; the runtime reports its background jobs). Screenshot `annotations[].rect` is in image pixels relative to the returned image; annotate and highlight are painted into the captured frame. Screenshots are rendered off screen when the app provides a renderer (`devtools::set_offscreen`; `ashlar-app` uses eframe's wgpu device), so they work for a window that is covered, in the background, minimized behind others or on a locked screen; `ashlar-app` also runs the UI passes other `ui.*` requests need while such a window draws none (`Runtime::hidden_pass`). Otherwise the backend captures a presented frame: the request is repeated every 0.25 s, the window is brought to the front once (unless `raise: false`), and after 1 s without a frame it fails with an error saying why. Either way it never hangs and never spins: a failed screenshot costs a bounded handful of frames. Pictures travel inline, never through a predictable temp file. ### The inspector F12 (or `devtools.toggle`) opens an inspector window in the app: Pick (hover outlines an element with its role, name, ref, id and rect; a click selects it and copies `@eN`), the element tree, frame and wake counters with a live repaint counter, and panels the app provides (`devtools::section`: the runtime adds its recent logs and the undo history). The inspector is not part of snapshots. Closed, it costs nothing: no frames, no allocations. ## MCP tools `ashlar-mcp` exposes the same vocabulary as tools. Arguments marked `?` are optional; a target is a ref string (`"@e3"`, `"e3"`), `"id=…"`, `"text=…"`, or an object as in the debugger methods. | Tool | Method | |---|---| | `list_commands {query?}` | `engine.commands` (compact: id, label, enabled, mutates) | | `describe_command {command}` | `engine.commands`, one entry in full, with its parameter schema | | `execute {command, params?}` | `engine.execute` | | `batch {commands, keepGoing?}` | `engine.batch` | | `screenshot {target?, annotate?, highlight?, pad?, raise?, id?}` | `ui.screenshot`, returned as an MCP image (plus `{width, height, annotations?}`); `id` is the legacy crop | | `ui_snapshot {interactive?, scope?, depth?, compact?, elements?}` | `ui.snapshot`: the outline as text; `elements: true` adds the element list as JSON | | `ui_inspect` | `ui.inspect` | | `ui_click {target?, id?, x?, y?, button?, count?, modifiers?}` | `ui.click` | | `ui_hover {target}` | `ui.hover` | | `ui_focus {target}` | `ui.focus` | | `ui_fill {target, text, submit?}` | `ui.fill` | | `ui_press {keys}` | `ui.press` | | `ui_drag {from, to, steps?, modifiers?}` | `ui.drag` | | `ui_scroll {dx, dy, target?, x?, y?}` | `ui.scroll` | | `ui_scroll_into_view {target}` | `ui.scroll_into_view` | | `ui_get {target, what}` | `ui.get` | | `ui_wait {for, target?, text?, timeout_ms?}` | `ui.wait` | | `ui_highlight {target, ms?}` | `ui.highlight` | | `ui_key {key, modifiers?}` | `ui.key` | | `ui_type {text}` | `ui.type` | | `ui_menu_list` | `ui.menu.list` | | `ui_menu_invoke {id}` | `ui.menu.invoke` | | `ui_set {…}` | `ui.set` (app-defined keys) | | `app_stats {reset?}` | `app.stats` | | `app_logs {limit?, level?}` | `app.logs` (`level`: error, warn, info, debug or trace) | | `ui_clock {pause?, step_ms?, resume?}` | `app.clock` | | `ui_scene {name?}` | `ui.scene` | | `docs_categories {category?}`, `list_docs {category?, query?, recursive?}`, `read_doc {path}` | the app's documentation library (no control method) | | `save_doc {path, content, replace_builtin?}`, `append_doc {path, text}`, `delete_doc {path}` | the user's own pages, when the server has a user folder | `screenshot`, the `ui_*` tools (`ui_clock` and `ui_scene` included) and `app_stats`/`app_logs` are listed only when the backend's `app.version` reports their method, so an agent never sees a tool the app cannot serve. The documentation tools are listed when the server has a library or a user folder. With `ToolMode::PerCommand` every command is also its own tool (dots become `_`) with its real input schema. `crates/mcp/tests/protocol_doc.rs` checks this table against the code. ## CLI reply contract ` exec [json]`, `describe`, `commands --json`, `run`, `version --json` print one line: `{"ok":true,"result":…}` or `{"ok":false,"error":"…"}` (plus `detail`). Exit codes: 0 ok, 1 the command failed, 2 usage error. # Testing and debugging UIs with ashlar-debug You can drive any ashlar app that serves a control port from outside the process, the way a browser automation tool drives a page. You read the UI as an outline of elements with short refs, act on those refs, wait for the result and check it. Three clients use the same methods: | Who | How | |---|---| | You, in a terminal | `ashlar-debug` (crate `ashlar-debug`) | | A test or CI job | `ashlar-debug batch file.ui`, or `ashlar_debug::run` from a Rust test | | An agent | the `ui_*` tools of `ashlar-mcp` (` mcp --connect PORT`) | The methods (`ui.snapshot`, `ui.click`, `ui.fill`, `ui.wait`, …) are specified in [control-protocol.md](control-protocol.md). ## A session ```sh ashlar-debug open ./target/debug/my-app # free port, --control , waits for app.ping ashlar-debug snapshot -i # - textbox "Title" [ref=e2] # - button "Save" [ref=e3] ashlar-debug fill @e2 "Groceries" --submit ashlar-debug wait --text "Saved" --timeout 3000 ashlar-debug screenshot --annotate # → ~/…/ashlar-debug/screenshots/shot-….png ashlar-debug close ``` Each command runs as its own process. The session file remembers the port and the key folder, so the next command finds the app again. Use `--session NAME` to drive two apps side by side. To reach an app that is already running with `--control `, use `ashlar-debug connect ` or `--port ` (its key is found in the OS settings folders), or `--app `. The output is plain text by default. With `--json`, each command prints one `{"ok":true,"result":…}` or `{"ok":false,"error":…,"detail":…}` line. Exit codes are 0 for success, 1 when the app refused, an assertion failed or nothing answered, and 2 for a usage error. ## Refs and targets A snapshot gives every element a ref (`e3`). Refs are stable: the same element keeps its ref across snapshots for as long as it exists. A ref that no longer exists fails with `stale_ref`, and you take a new snapshot. Instead of a ref you can also write `id=` (the most robust choice in a test script, since it survives layout changes), `text=