Introduction
ashlar is a Rust library for building desktop apps (and, for its headless core, web apps) on egui. It gives an app the parts most apps rebuild by hand:
- Documents with undo: the document is an
Arcsnapshot 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 (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!orunsafeoutside 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.uiscripts 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 |
| 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. The source is on GitHub.
Where to start
- Install the crate.
- Build your first app.
- Read the
notesexample in the repository (examples/notes): a complete app with typed commands, undo, autosave, a virtualized list, scenes and anotes-clibinary.
Installation
cargo add ashlar-rs
The package is published as ashlar-rs; the library is imported as ashlar:
use ashlar::prelude::*;
Documents are serialized with serde, so most apps also add it:
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 |
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 FileDiffs 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:
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:
[profile.release-small]
inherits = "release"
lto = "fat"
codegen-units = 1
opt-level = "s"
strip = true
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.
cargo new jot && cd jot
cargo add ashlar-rs
cargo add serde --features derive
use ashlar::prelude::*;
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
struct Doc {
notes: Vec<String>,
}
fn add(s: &mut Session<Doc>, p: &Value) -> Result<Value, CommandError> {
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::<Doc>::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
}
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<Doc> and gives each state a Revision.
The command. add runs on the Session<Doc>. 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.
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 <name>.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.
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.
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 <port>for agents, and--headlessfor tests (see Testing apps).
Smaller still
The smallest app has an empty document and no commands:
use ashlar::prelude::*;
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
struct Doc {}
fn main() -> Result<(), ashlar::app::Error> {
let mut on = false;
App::<Doc>::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.
Documents and undo
The session
A command runs on a Session<D>, 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<D>, 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<Note>, so editing one note copies one note, not all of them.
fn delete(s: &mut Session<Notes>, p: &Value) -> Result<Value, CommandError> {
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:
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.
s.set_view("selected", &id);
let selected: Option<u64> = 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.
fn v1_to_v2(mut file: Value) -> Result<Value, String> {
// rewrite file["document"] from schema 1 to schema 2
Ok(file)
}
let format = Format::<Notes>::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.
App::<Doc>::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:
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.
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:
fn add(s: &mut Session<Notes>, p: &Value) -> Result<Value, CommandError> {
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:
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<CommandSpec<Session<Notes>>> {
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:
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.
fn has_target(s: &Session<Notes>) -> Result<(), String> {
match s.view::<u64>("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
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:
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<D>, 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:
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.
Sidebar
use ashlar::ui::sidebar::{Sidebar, SidebarItem, SidebarSection};
let items: Vec<SidebarItem> = 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.
Tabs
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.
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.
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.
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.
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));
});
});
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:
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
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
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::FileDiffs with word-level emphasis.
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.
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`
}
Motion
ashlar::ui::motion animates values with springs that keep their velocity when the target
changes, and asks for frames only while something moves:
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:
cargo run -p ashlar-example-gallery
The full list is on 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
Arcof 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
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<Notes>| {
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 undercatch_unwind..spawn_streaming(input, work)also gets anUpdateshandle, 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.
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).
App::<Doc>::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:
use ashlar::fs::AppDirs;
App::<Doc>::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.
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:
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:
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.
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.
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:
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.
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::<Notes>::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.
App::<Notes>::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:
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
App::<Notes>::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:
cargo add ashlar-native --features notify
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.
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 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:
// src/bin/notes-cli.rs
fn main() -> std::process::ExitCode {
ashlar::app::cli_main(notes::app())
}
notes-cli commands [--json] [--query Q] list commands
notes-cli describe <id> one command in full (parameter schema)
notes-cli docs [category|path.md] [--query Q] the documentation
notes-cli exec <id> [json|-] run one command
notes-cli run <file.jsonl|-> [--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]
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:
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:
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.
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:
{
"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.
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:
notes --headless --size 1024x700 --scale 2 --theme dark --scene two-notes --script add.ui --at 1.5 \
--snap out.png --tree tree.json
--sizeis in points (default 1280x800); the PNG is that times--scale.--scriptruns a.uiscript first;--atlets that many seconds pass before the picture.--treewrites 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:
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:
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/<name>.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:
App::new("notes", env!("CARGO_PKG_VERSION"))
.scene("empty", |_: &mut Session<Notes>| {})
.scene("two-notes", |s: &mut Session<Notes>| {
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:
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 <name>-new.png and
<name>-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:
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:
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.
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
use ashlar::prelude::*;
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
struct Doc {
clicks: u32,
}
fn main() -> Result<(), ashlar::app::Error> {
App::<Doc>::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 <canvas> 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.
[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, which runs wasm-bindgen and serves the page:
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:
<link data-trunk rel="rust" data-bin="ashlar-example-web-hello">
<canvas id="web-hello"></canvas>
What differs on the web
- Jobs run inline, with no threads. Use
spawn_steppedfor 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
setupis 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.
- 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-modedoes 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 <APP>_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!andunimplemented!outside tests:#![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::failedorbad_paramsfrom 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):
cargo add ashlar-rs --features telemetry
App::<Doc>::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
- Self-hosting the server
- Integrating the SDK, for apps that use
ashlar-tracedirectly
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: an index of every page of this site.
- llms-full.txt: the whole site as one markdown file.
- Each page’s markdown is at its URL plus
.md, for examplehttps://ashlar.himanshuat.com/guide/commands.md. - The API reference: 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.
# 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<Doc>, &Value) -> Result<Value, CommandError>`
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 <id> '<json>'`.
- Look at the UI with no window:
`cargo run -- --headless --size 1024x700 --scene <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:
cargo build --bin my-app-cli
claude mcp add my-app -- ./target/debug/my-app-cli mcp
For agents configured with 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:
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 for the CLI and the control port, and the control protocol 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
let app = App::<Doc>::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:
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
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<Doc>| {
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
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
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
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
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
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
// 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
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):
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. 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:
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.
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
logsfolder, orapp.logsover the control port. Raise the level with<APP>_LOG=debug(for an app namednotes,NOTES_LOG=debug). - Crash logs are in the same folder.
--safe-modestarts without restoring view settings.ashlar-debug open ./target/debug/my-appthenashlar-debug snapshot -ishows what the UI contains; see Testing and debugging UIs.
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.
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 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.
Development setup
- Install stable Rust (1.95 or newer) with
rustfmt,clippyand the wasm target:rustup component add rustfmt clippy rustup target add wasm32-unknown-unknown - On Linux, install the system libraries:
sudo apt-get install libgtk-3-dev libxdo-dev libxkbcommon-dev libdbus-1-dev - Optional:
cargo install cargo-deny --lockedfor the dependency checks. - Run the gate:
cargo xtask ci
The rules
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!orunimplemented!outside tests, and nounsafe. Treat every input-derived number as hostile. - Respect the layers. A crate depends only on crates of a lower rank (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.
Pull requests
- Fork the repository and create a branch from
main. - Keep each pull request to one change, with tests for new behaviour and bug fixes.
- Run
cargo xtask ciand make sure it passes. - Update the docs and the crate’s README when you change public behaviour, and add a line to CHANGELOG.md under “Unreleased”.
- Write commit subjects in the imperative mood, under about 72 characters. Use the body to explain why.
- 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, the same license as the project.
Contributing
Read AGENTS.md first; its rules apply to people and agents alike. The crate ranks and design principles are in 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 checkon the workspacerust-version(1.95);rustup toolchain install 1.95 --profile minimalfirst.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.
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 <crate>,cargo clippy -p <crate> --all-targets -- -D warningsandcargo 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 <noreply@anthropic.com>.
Parallel work
One git worktree per contributor under .claude/worktrees/<name> (ignored by git), branch
wt/<name>, with its own CARGO_TARGET_DIR=$PWD/target/agent-<name>; 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/<name> (inside the checkout, kept out of git),
branch wt/<name>, own CARGO_TARGET_DIR (target/agent-<name> 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 <crate>); 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
- Never crash. No
unwrap/expect/panic!/unreachable!/todo!/unimplemented!and nounsafeoutside 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 undercatch_unwind. Enforced by the workspace clippy lints andcargo xtask deny-panics. - 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. - 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.
- 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.
- 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 --compareguards it (see below). - 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’sClass), and settled animations request no frames. - Stable contracts. Items marked
CONTRACTinashlar-commandare 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
bench: <metric> <value> <unit> 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.
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):
-
The crate does not use
lints.workspace = true. Its own[lints]table copies the workspace lints withunsafe_code = "deny"(notforbid) and addsclippy::undocumented_unsafe_blocks = "deny". Only FFI modules carry#[allow(unsafe_code)]:power(macOSendActivity:, WindowsSetThreadExecutionState)open_files(macOS: the Apple event handler class and its registration)progress(WindowsITaskbarList3)system(WindowsShellExecuteW,SystemParametersInfoW,GetUserPreferredUILanguages(added 2026-11-10 so apps started from the desktop, which have noLANG, find the user’s languages); and forbring_to_front, added 2026-11-10:IsIconic,ShowWindow,SetForegroundWindow,BringWindowToTop,AttachThreadInput)mac_shell(macOS: replacing the implementation of winit’s window delegateobserveValueForKeyPath:ofObject:change:context:with a checked wrapper that calls the original,class_addMethodofapplicationShouldHandleReopen:hasVisibleWindows:on winit’s application delegate, and themsg_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 windowmenu::popup(muda’sshow_context_menu_for_nsview/show_context_menu_for_hwndon the window’s own view or HWND, retaining the macOS view, and posting the macOS menu to the main run loop) anddrag_out(macOS: anNSDraggingSourceclass, retaining the window’s view, anNSDraggingItemframe); added 2026-11-10 for context menus and dragging files outsingle_instance(WindowsAllowSetForegroundWindow, one block; added 2026-11-10 so the second launch lets the first come to the front)menu::native::attach_window(muda’sinit_for_hwnd) andmenu::native::accelerators(Windows:SetWindowSubclass,TranslateAcceleratorW,DefSubclassProc; added 2026-11-10 so menu shortcuts work without a hook in winit’s message loop)terminate(Unix, throughlibc:pipe,fcntl,sigaction, and in the handler onlywrite,signalandraise, 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(macOSproc_pid_rusage, glibcmalloc_trim, WindowsGetProcessMemoryInfo; added for idle apps that report their footprint and hand freed memory back; macOSproc_pidinfoand WindowsCreateToolhelp32Snapshot/Process32FirstW/Process32NextW/CloseHandleadded 2026-11-10 for the thread count inapp.stats)display(WindowsGetProcessWindowStation,GetUserObjectInformationW; added 2026-10-27 so an app started where no window can be shown says so and exits instead of panicking; WindowsEnumDisplayMonitorswith its callback,GetMonitorInfoWandGetDpiForMonitoradded 2026-11-10 to list the monitors before the window exists, so a saved placement is checked at launch; macOS uses objc2’s safeNSScreenbindings)notify::macos(theUNUserNotificationCenterDelegateclass, itsinit, andsetUserInfo:) andnotify::windows(SetCurrentProcessExplicitAppUserModelID,CoInitializeEx); added 2026-11-10 so banners show while the app is frontmost, clicks are reported and Windows gets toastssession_end(macOS:class_addMethodofapplicationShouldTerminate:on winit’s application delegate class, and reading that method’s implementation; Windows: a hidden window’s class, window and message loop forWM_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.
-
Every
unsafeblock has a// SAFETY:comment saying why it is sound. -
The public API is safe: no
pub unsafe fn, no raw pointers or FFI types in signatures. Window handles come in throughraw_window_handle::HasWindowHandle, so a caller can’t pass a dangling HWND. Failures areResult<_, NativeError>. -
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 throughguarded, which turns a panic intoNativeError::Os. -
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 passescargo check --target wasm32-unknown-unknown.
Consequences
- Changes to the FFI modules need the same scrutiny as any FFI code. A new use of
unsafegoes 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
MainThreadMarkerand 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
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: <metric> <value> <unit> 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
psandvmmapfor 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:<port>, keyed | ashlar-control (Server, Client) |
| Child process | JSON lines over stdin/stdout, no key (private pipe) | ashlar-control::serve_stream, <tool> 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:
{"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 afterauthon the same connection.method(required string).params(optional;nullor absent means{}).
Reply:
{"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
<dir>/control-key.<instance>, created with mode 0600, where<dir>is a folder the app chooses (its settings folder) and<instance>is the Flatpak instance id orlocal-<port>. The file is removed when the server is dropped; a stale one is replaced. Every request must carry the key, as the top-levelkeymember or once per connection withauth. A missing or wrong key gets{"ok":false,"error":"unauthorized"}and the connection is closed. Keys are compared in constant time.params.keyis an ordinary parameter for every method butauth(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-originfetchPOST: its request linePOST / HTTP/1.1is 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 thanmax_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) 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 <ms>|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) 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 ids, component roles). An element:
{"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: <base64>, 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:
- 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
<tool> exec <id> [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 (<tool> mcp --connect PORT) |
The methods (ui.snapshot, ui.click, ui.fill, ui.wait, …) are specified in
control-protocol.md.
A session
ashlar-debug open ./target/debug/my-app # free port, --control <port>, 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 <port>, use ashlar-debug connect <port> or
--port <port> (its key is found in the OS settings folders), or --app <name>.
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=<automation id> (the most robust
choice in a test script, since it survives layout changes), text=<label>, <role>=<label>
(button=Save), or a point x,y.
If you write an app and want stable test targets, give the important widgets automation ids. Use
the ids of the commands they run (file.save), as the menus already do.
UI tests
Write a script with one command per line, plus assertions:
# tests/ui/save.ui
open ./target/debug/my-app
fill id=title "Groceries"
press Mod+S
wait --text "Saved"
assert text id=status "Saved: Groceries"
assert enabled id=edit.delete
assert count role=listitem >= 1
close
ashlar-debug batch tests/ui/save.ui stops at the first failure:
FAIL tests/ui/save.ui:7: assert enabled id=edit.delete
expected id=edit.delete to be enabled, but enabled is false
error: tests/ui/save.ui:7: expected id=edit.delete to be enabled, but enabled is false
hint: 5 step(s) passed before line 7
Each step’s result is in the --json output (detail.steps). If you add trace start <dir> at the
top, every action leaves a screenshot and a snapshot in <dir>, numbered in order
(001-click.png, 001-click.txt, one line per step in steps.jsonl). That is the first place to
look when a script fails on a CI machine. With --port or --app (no session) the trace lasts for
that one batch run; outside a batch they refuse trace with a usage error, since there is no session
to keep it in.
Running a script from Rust gives you the same thing in-process. ashlar_debug::run(args, &env, &mut io)
takes an Env that points the state and key search at a temporary folder:
let env = ashlar_debug::Env::under(&tmp); // state in tmp/home, keys looked for in tmp/config
let code = ashlar_debug::run(vec!["--port".into(), port.to_string(), "--key-dir".into(), key_dir, "batch".into(), "-".into()], &env, &mut io);
examples/notes/tests/debug.rs does this against the windowed runtime on a headless event loop,
so it needs no display. It also has a test that opens a real window, which runs only when
ASHLAR_DEBUG_REAL_WINDOW=1 is set.
Scripts in process, on virtual time
Driver::run_ui(script) runs the same script language on a Driver, with no port and no thread.
sleep N and wait N let virtual time pass instead of the wall clock, and waits such as
wait --text run frames on the virtual clock, so a script gives the same steps on every run and
takes milliseconds:
let mut d = Driver::new(notes::app().runtime());
let report = d.run_ui("exec note.add '{\"title\": \"x\"}'\nsleep 2000\nassert exists id=notes.add\n")?;
assert_eq!(report.passed, 3); // report.steps: one {"line", "command", "ok", "result"} per step
A failure is a ScriptError naming the line, with the same detail as batch --json. Verbs about
sessions, processes and traces (open, connect, close, sessions, trace, demo) fail in
process. To run scripts against something else, implement ashlar_debug::Transport (call, and
pause for time) and call ashlar_debug::run_script; the TCP Client is one such transport.
One command: --headless
Every binary whose main calls App::run takes --headless: no window, the app on a virtual
clock in a fresh temporary folder (never your settings or documents), real pixels when there is
a GPU (grey otherwise, with a note on stderr):
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) and the PNG is that times --scale. --script runs a
.ui script first, --at lets that many seconds pass before the picture, and --tree writes the
ui.snapshot JSON. Exit codes match ashlar-debug: 0 done, 1 a step failed, 2 bad arguments.
examples/notes/tests/headless.rs checks the PNG size and the tree.
Real pixels with no window
A Driver gives flat grey screenshots by default, which keeps tests that need no GPU fast. For
real pixels, build it with Driver::with_renderer(runtime, Renderer::wgpu()): frames render on a
wgpu device with no window or surface, so ui.screenshot, annotate and trace show what a window
would, at the scale set with Driver::pixels_per_point. A software adapter is preferred when
there is one. On a machine with no adapter, Renderer::wgpu() falls back to grey and
Renderer::reason() says why. Every screenshot reply says which was used: "renderer": "wgpu" or
"renderer": "none". Renders of the same state are byte-identical on the same machine, but can
differ between GPUs, so compare pixels across machines only with a tolerance.
crates/app/tests/headless_render.rs shows the pattern, including how to skip without a GPU.
Named scenes
A scene is a fixed starting state: an empty document, a long list, an error banner, a fake service. Declare them on the app:
App::<Notes>::new("notes", "0.1.0")
.scene("empty", |_: &mut Session<Notes>| {})
.scene("two-notes", |s: &mut Session<Notes>| { /* add two notes */ })
notes --list-scenesprints the names, one per line.notes --scene two-notesstarts the window in it;notes --headless --scene two-notes --snap x.pngpictures it (an unknown name exits 2). The scene runs before--themeand--script.Driver::scene("two-notes")applies it in a test.- Agents use the
ui.scene {name}control method or the MCP toolui_scene({}lists the scenes). Both are listed only when the app declares scenes.
Golden snapshots
Driver::assert_snapshot(name) and the script line assert screenshot <name> [target] [--tolerance channel=2,pixels=4] compare a screenshot with tests/snapshots/<name>.png
(relative to the working directory, the package folder under cargo test; ASHLAR_SNAPSHOT_DIR
overrides it):
- The comparison is exact by default. A tolerance allows pixels whose channels are off by at
most
channel, and up topixelspixels beyond that. A tolerance given when recording is kept in the baseline’s meta and used when a check names none. - On a mismatch it writes
<name>-new.pngand<name>-diff.png(differing pixels magenta), and fails with both paths. ASHLAR_UPDATE_SNAPSHOTS=1records the baselines instead, with<name>.meta.jsonsaying what rendered them (adapter,backend,scale, size).- A baseline from another adapter or backend is skipped with a note on stderr, not failed: GPUs differ by a few pixels. With no GPU renderer (grey screenshots) it is skipped too.
examples/notes/tests/snapshots.rs checks the notes app’s empty and two-notes scenes this way.
Egui’s own test harness has a snapshot feature, but it uses its own renderer and a perceptual
compare with no adapter metadata, so ashlar keeps a small module (ashlar_debug::snapshot) that
works on any ui.screenshot reply, from a Driver or a live app.
Without an app: the demo
ashlar-debug demo --control <port> serves a small fake app with a text field, buttons, a
checkbox, a list and a delayed “Load”. It answers every debugger method deterministically. The
crate’s own tests drive it, and you can use it to try the CLI or to check an MCP client:
ashlar-debug open ashlar-debug -- demo.
Debugging a live app
snapshot(full tree) orsnapshot --scope @e8shows what is on screen and why a target does not match: a disabled element shows[disabled]and a focused one[focused].highlight @e3flashes an outline in the app window, which helps when you are pairing with a human.screenshot --annotatedraws each interactive element’s ref on the picture, next to the element rather than over its text. A covered or minimized window, or one on a locked screen, presents no frames;ashlar-appthen runs a UI pass of its own while debugger work waits and renders screenshots off screen, so snapshots, input and screenshots work without bringing it to the front. An app without an off-screen renderer has its window raised once for a screenshot, and the request fails after a few seconds if that does not help. Usescreenshot --no-raise, a session opened withopen --no-raise(orconnect --no-raise), orASHLAR_DEBUG_NO_RAISE=1for CI and background runs that must never steal focus. The MCPscreenshottool takesraise: falsefor the same purpose.logs -n 50,statsanddevtools openshow the app’s recent log lines, its frame and job statistics, and the in-app inspector.clock pause,clock step 16.7andclock resumehold the app’s animation clock: pause, take screenshots orget rectat an exact moment, step one frame at a time, then resume with no jump. Input still works while paused. The same lines work in.uiscripts. See control-protocol.md for what else stands still.call <method> '{json}'sends any control method as is.
Launch time and memory
stats(app.stats) reportslaunch:to_window_ms(from the first line ofrun()until eframe has built the window and its wgpu surface),to_first_frame_ms(the end of the first frame) andlaunch_to_present_ms(that frame painted and presented). The app also logs them once atinfo. Headless runs report none.statsalso reportsprocess:footprint_bytes(macOS physical footprint, Linux private resident + swap, Windows private commit),peak_bytes(the highest footprint seen, sampled at most once a second while frames run and at every reading),cpu_s(CPU seconds of every thread) andthreads(Linux only for now). A field the platform cannot report isnull.<PREFIX>_READY_FILE=path(the prefix is the app name in capitals, like<PREFIX>_CONTROL_PORT) makes the app write{"pid": …, "ready_ms": …}topath, atomically, once its first frame is presented: a launcher or a test waits for the file instead of sleeping.<PREFIX>_QUIET=1opens the window without taking the focus (on macOS the app doesn’t activate either), so an agent that rebuilds and relaunches the app in a loop doesn’t pull the user out of the app they are typing in.- A document named on the command line opens on the frame after the first one, so the window shows before a slow load runs. eframe keeps the window hidden until that first frame is painted, so there is no white flash.
cargo xtask launchbuildsexamples/helloin release, launches it 9 times (--runs N) and printsbench: launch_to_present_p50/p95andspawn_to_ready_p50/p95(from spawning the process).cargo xtask launch --memorylaunches it 5 times (--runs N), lets each idle 10 s (--settle SECS) and prints the lowest and highest RSS, physical footprint and peak (vmmap, macOS), with how many runs fell in the high group. Both open real windows, so they are manual and never run in CI; results swing with machine load, so compare runs made back to back.- macOS idle memory comes in two groups (IMP-2.19). A window whose occlusion state never changed
idles at about 40 MB (94 MB peak). Once it changes (another window covers it, it is minimized,
a Space switch, also by chance right after launch), the system maps two swapchain drawables
(
CAMetalLayer Display Drawable, shared with WindowServer, 9.5 MB each at 960×640 points 2x) into the process for good, and Metal allocates a second set of about 13 × 4 MB purgeable blocks (the ~95 MB peak jump): about 66 MB idle, 193 MB peak. It persists after 40 s idle and does not depend on present mode or frame latency; nothing in ashlar’s eframe/wgpu options changes it. Compare_maxfigures, which are what users see.
Frame performance
Debug builds watch their own frames and print one line to stderr, once per site, when:
- a frame waits: it took ≥ 8 ms of wall time and at least 4× the UI thread’s CPU time, so the thread was blocked (file or network I/O, a lock, a sleep). The line names the command the frame ran, or else the wake reasons it was drawn for. Move that work to a job.
- the same frame is drawn over and over: 60 frames in a row with identical shapes, drawn only
for non-input wake reasons (a timer or an animation that never settles). The line names the
reasons;
app.stats→wakescounts them.
ASHLAR_FRAME_BUDGET_MS=N (debug or release) prints at most one line per second for frames over
N ms: the pass time, the runtime’s own UI time, the UI thread’s CPU time, the command or wake
reasons, and how many more frames went over since the previous line. ASHLAR_PERF_WARN=0
silences all of these. A release build with neither variable set installs nothing.
ASHLAR_FRAME_LOG=FILE (any build; Runtime::log_frames(path) in tests) writes one JSON line
per frame, for comparing runs:
{"frame":12,"passes":1,"t":0.204,"wall_ms":3.1,"cpu_ms":2.9,"phases":{"pass_ms":3.1,"ui_ms":2.4},
"reasons":["animation"],"commands":[],"input":false,"dropped":0}
passes is the pass number within the frame (egui sometimes runs a frame twice; each pass gets a
line). cpu_ms is the UI thread’s CPU time, phases.ui_ms the runtime’s own part (menus, view,
dialogs), reasons the wake reasons and commands the commands the frame ran. A background
thread writes the file; it stops at 64 MiB with a last {"end": "size limit reached", …} line, and
dropped counts lines lost if the writer falls behind. Not available on the web.
ASHLAR_RECORD=FILE (any build; Runtime::record_to(path) in tests) records a session for
replay: one JSON line per frame with its time, screen size and input events (pointer, keys, text,
wheel, clipboard, focus), and one line per control request served. A background thread writes it;
when the file passes 32 MiB it becomes FILE.old and a new one starts, so the pair holds at most
64 MiB. Driver::replay(FILE) plays it into a fresh driver of the same app, frame by frame at the
recorded times, which turns a bug report into a test:
let mut d = Driver::new(my_app().runtime());
d.replay("session.jsonl")?;
assert_eq!(d.call("ui.snapshot", json!({}))?, expected);
Revision numbers come from one process-wide counter, so compare how far a session moved them, not their values. Not available on the web.
Agents (MCP)
ashlar-mcp lists ui_snapshot, ui_click, ui_fill, ui_hover, ui_focus, ui_press,
ui_get, ui_wait, ui_scroll_into_view, ui_highlight and screenshot (with target,
annotate, highlight, pad and raise) when the app reports these methods in app.version,
along with ui_inspect, ui_drag, ui_scroll, ui_key, ui_type, ui_menu_list,
ui_menu_invoke and ui_set. app_stats {reset?}, app_logs {limit?, level?} and
ui_clock {pause?, step_ms?, resume?} are the CLI’s stats, logs and clock. The full table with every argument is in
control-protocol.md. Targets are ref strings ("@e3",
"id=file.save") or objects. ui_snapshot returns the outline as text and adds the element list
as JSON when you pass elements: true. A typical loop is: snapshot, act on a ref, wait, then
snapshot or ui_get to check.
The control key
The control port accepts only clients that hold the app’s key. The app writes it to
<key folder>/control-key.local-<port> with mode 0600. The key folder is the app’s settings
folder, unless whoever started the app set ASHLAR_CONTROL_KEY_DIR; ashlar-debug open sets it to
a private folder inside its session. The CLI never sends the key anywhere but 127.0.0.1.
Panel docking
ashlar_ui::docking arranges an app’s panels: it moves one panel at a time between docked tab
groups, side-by-side splits and floating groups. The library owns placement and the gestures that
change it. The app owns everything inside a panel, its commands, its documents, and the policy for
saving and loading layouts.
The model
A Layout<P> is an optional docked tree (root) plus a list of floating groups, back to front.
The tree has three node kinds:
Node::Tabs { panels, active }: a tab group, its panels in order and the index of the one shown.Node::Split { axis, size, first, second }: two children side by side (Horizontal) or stacked (Vertical).sizeis aSplitSize: a ratio of the space, a fixed first size, or a fixed second size.Node::Stack { entries }: an accordion. EachStackEntryhas a panel, anopenflag and an optional preferred body height.
A Floating<P> is a tab group with a rect [x, y, width, height].
P is the app’s panel id. It must be stable across sessions and languages (an enum or a
string key, never a translated title) and implement Clone + Eq + Hash; the renderer also
needs Debug + Send + Sync + 'static. A panel appears at most once. Per-document tabs (one tab per
open file, say) belong inside a panel, not in the layout.
use ashlar_ui::docking::{Action, DockError, Floating, Layout, Node, Placement};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum Panel { Outline, Inspector, History }
fn arrange() -> Result<Layout<Panel>, DockError> {
let mut layout = Layout {
root: Some(Node::Tabs { panels: vec![Panel::Outline, Panel::Inspector], active: 0 }),
floating: vec![Floating { panels: vec![Panel::History], active: 0, rect: [40.0, 60.0, 260.0, 300.0] }],
};
// Dock History into the main group, in front of Inspector.
layout.apply(Action::Move {
panel: Panel::History,
anchor: Panel::Outline,
placement: Placement::Tab { before: Some(Panel::Inspector) },
})?;
Ok(layout)
}
Transactions
Every change is an Action, applied with Layout::apply or Layout::apply_with_permissions.
A transaction checks the current layout, makes the change on a copy, checks the copy, and only
then replaces the layout. When it fails, the layout is exactly as before and a DockError says
why: a missing panel or target, a panel moved onto itself, a duplicate id, an active index out of
range, too deep or too many (MAX_DEPTH, MAX_PANELS), or geometry that is not finite or out of
bounds.
What a successful move guarantees:
- the moved panel is never lost on the way;
- each group keeps its selected panel by identity, not by index;
- groups left empty disappear, and a split left with one child is replaced by that child;
- an accordion entry keeps its open state and height when it moves to another accordion;
- sizes of splits the move did not touch stay as they were.
Open adds a panel that is not in the layout yet (or activates it if it is). OpenAt places a
new panel at an exact spot, and moves it there if it already exists. Whether a panel id is
known to the app at all is for the app’s panel registry to check before it asks.
Permissions and fixed panels
apply_with_permissions takes a function from panel to Permissions (movable, floatable,
closable, accepts_tabs). A document canvas is typically Permissions::PROTECTED: it cannot be
moved, closed or floated, and nothing can join it as a tab, but other panels can still be split
off beside it. A panel that must never leave the docked tree is the app’s rule to enforce.
An empty root (root: None, or a Tabs node with no panels) is valid, for example when every
panel floats.
Rendering
Apps that draw their own chrome can describe their groups as DropTargets and call
resolve_drop with the pointer. Targets are listed back to front, floating groups in their
current stacking order after the docked ones. The frontmost group under the pointer decides
alone: if it refuses the drop, groups behind it are not considered. drop_zone and zone_rect
give the zone rules on their own.
DockArea is the optional built-in renderer. It fits tabs into the strip and moves the rest into
an overflow menu, previews drops, cancels drags on Escape or when the window loses focus, and
draws split and accordion dividers and the floating groups’ resize corners. It never changes the
layout: DockOutput::actions lists what the user asked for, in order, for the app to send
through its own command path. With the serde feature Action serializes, so pointer gestures,
keyboard commands and remote control can all go through the same path. Panel bodies get an egui
id derived from the dock area and the panel only, so their widget state follows a panel when it
moves; durable data should still live in the app, not in egui memory.
DockStyle holds the colours, font, metrics and the localized labels of the stock controls
(DockStyle::from_ui gives defaults). show_with_limits takes per-panel PanelLimits. The
renderer fits the view into the viewport: when there is not enough room, minimums shrink in
proportion, and the preferred sizes stored in the layout are never overwritten by that. Divider
gestures produce explicit ResizeSplit or ResizeStack actions.
A custom header that starts dragging a panel can hand the gesture to the renderer with
begin_drag, using the same area id.
Floating groups are egui areas inside one viewport. Native windows, moving panels between viewports, and monitor coordinates are for the host to build.
Persistence
The serde feature derives serialization for the model; apps can also write their own adapter.
The library picks no file location. Before trusting a loaded layout, validate panel ids against the
app’s registry, cap the payload size, version the schema, and bound the depth of the
deserializer: validate rejects deep trees, but it cannot protect a parser that has already
recursed without limit.
Hiding and restoring panels
To hide a panel, capture layout.location(&panel), apply Close, and keep the Location
alongside the app’s own visibility state. restore(panel, &location) puts it back using its
neighbours’ ids, fixed split sizes, the floating rect, and recreates a closed accordion entry.
If the neighbour is gone, the layout is left unchanged and MissingTarget is returned; the app
then picks a fallback such as Open. Panels hidden from the same group come back correctly when
restored in reverse order of closing.
What the tests cover
The module’s unit tests cover moves, floats and merges, rejected transactions, selection by
identity, reordering, accordion state, permissions, location and restore for splits, floating
groups and accordions, validation limits, drop resolution, 800 random moves, serde round trips,
and the renderer’s gestures: drags with Escape and focus loss, size limits, the resize corner,
accordion dividers, custom content, blockers, cancel_drag ownership, chrome_has_focus, and
accessible names of the stock menu actions.
crates/ui/tests/docking_workflows.rs drives a document canvas and three populated panels with
real pointer and keyboard input: it edits a value, merges a floating panel into a tab group,
detaches it again and redocks it, at two widths, two scales and both themes, round-tripping every
action through JSON. Set ASHLAR_UI_DOCK_VISUAL_DIR to an absolute path to keep a PNG per stage:
ASHLAR_UI_DOCK_VISUAL_DIR=/abs/path cargo test -p ashlar-ui --all-features --test docking_workflows
These checks run offscreen. They do not replace an app’s own tests of its panels and commands.
Custom chrome and content
show_customized takes one DockContent adapter instead of closures: body draws a panel,
panel_menu adds entries at the top of a tab’s context menu, and group_header_width /
group_header reserve and draw a strip at the right end of a group’s tab bar (clipped to it).
Queue commands from these callbacks rather than changing the layout inside them. Collapsed
accordion entries still have their context menu. Collapsing whole tab groups is a host policy.
label returns WidgetText, so a tab can show a shaped galley (icons, mixed styles); the same
text is used to measure the tab, to paint it and in menus. The plain string from the label
closure stays the accessible name. action_label shapes the text of the stock Float, Move and
Close entries; it gets no panel id. Visible text never replaces an accessible name. show and
show_with_limits remain for apps without an adapter.
Activation and focus
To bring a panel forward, apply Activate and pass raise_panel(panel) on the next frame: a
floating group is raised and the panel’s tab takes keyboard focus. Stacking order is not stored
in the layout. A floating group is identified by its first panel.
The 12-point grip at the left of a floating group’s tab strip moves the whole group with
MoveFloating; dragging a tab detaches just that panel. DockOutput::group_drag and
group_drop report which group is being dragged and where it was released, for hosts that turn a
drop outside the window into a native window, say. Escape or focus loss puts the group back where
it started.
blocked_rects marks screen areas (menus, dialogs, overlays) where previews, drops and the float
fallback are blocked, including overlays that appear in the middle of a drag. Hosts pass the
current overlay geometry every frame, or call cancel_drag while something modal is up.
DockArea::chrome_has_focus says whether keyboard focus is on the dock’s own controls: tabs,
collapse and overflow buttons, dividers, resize corners and grips. Panel content and custom
header or menu widgets do not count, and an invalid layout gives false. A host can use it to let
global shortcuts through while Enter, Space, Tab, Escape and the arrow keys stay with the focused
control.
Visual regressions
crates/ui/tests/visual_regression.rs renders real components and independent reference
painters with the same offscreen wgpu renderer and compares the results. The reference painters
never call the components: they draw literal geometry with plain egui painter calls. Together they
define the visual contract of each control; they are not screenshots of an app.
Coverage
| Suite | Pairs | What is compared |
|---|---|---|
| Controls | 372 | icon button, single-shape button, tab, horizontal and vertical split × light/dark × scales 1, 1.5, 2 × normal and compact sizes × the states normal, hover, press, disabled, focus, plus selected and selected-press for buttons and tabs |
| Controls and content kit | 282 | controls::Button (primary, secondary, ghost, destructive), Switch, a RadioGroup option, Progress::bar (25 %, 100 %, the reduced-motion indeterminate pose), the content::Collapsible header and a dismissible content::Banner (info, warning, danger) × light/dark × scales 1, 1.5, 2 × the states normal, hover, press, disabled, focus, plus selected (on, chosen, open) and selected-press for the switch, radio and collapsible; drawn with the built-in token sets and no labels |
| Toolbar overflow | 18 | three widths × two themes × three scales against literal rects |
| Widget palette | 12 | cells painted from WidgetPalette::apply_to against literal painting |
| SVG icons | 12 | catalog output rasterized by the SVG loader against literal, pre-substituted SVGs |
All 696 positive pairs must give identical RGBA pixels. The 684 pairs rendered with wgpu must also give identical meshes: vertices (position, UV, colour), indices, texture ids and clip rects. The SVG pairs compare the loader’s rasters and must contain visible pixels.
Each state is reached with real input, and the test asserts it was reached: the control is hovered, held down (and dragging, for split handles), or focused. Disabled controls must stay unhovered and unfocused under the same input. Selection wins over press. Split handles are driven in both orientations. The compact fixtures repeat every contract at a smaller size.
48 negative comparisons change one thing in the reference (fill colour, spacing, stroke width;
the fill of a kit button, switch, radio, bar and banner, the collapsible’s chevron) and must differ in both pixels and meshes, which shows the comparison can fail. There is no
tolerance, mask, crop, perceptual metric or automatic update. The toolbar fitting itself
(malformed input, reachable overflow, groups kept in order) is covered by unit tests in the
layout module.
Running
cargo test -p ashlar-ui --test visual_regression
With ASHLAR_UI_VISUAL_DIR=/absolute/path, each suite writes into its own subdirectory:
<case>-before.png (reference), <case>-after.png (component), <case>-difference.png (changed
pixels in magenta, 255,0,128) and comparison.tsv with the columns case, width, height,
changed_pixels, mesh_equal and expected_change. Rows are appended as each pair finishes, so
the file is useful even when a later pair fails. Images are at most 256×144 pixels. A renderer
failure fails the test.
Limits
The comparison is same-machine only: both sides go through the same driver in the same run, so it says nothing about pixel identity across GPUs or drivers. It does not replace app screenshots, complete themes, text layout checks, command dispatch, window resizing, tests against real accessibility services, or cross-platform runtime tests. Apps that keep their own baseline images keep their own record of where those came from.
ashlar-trace: telemetry
Crash, performance and usage telemetry for Rust desktop apps, with a backend you host yourself.
ashlar-trace— a small client you embed. Safe Rust only, no async runtime, no UI toolkit. It never blocks your frame loop and never prints.ashlar-trace-server— one binary and one data folder: ingest API, SQLite storage, and a dashboard for crashes, performance and usage.ashlar-trace-proto— the wire types both share.
Status: release candidate, load-tested; not yet published. See roadmap.md. Apps built on ashlar use it through
ashlar-telemetry, which wires it to the command chokepoint, logging, the frame loop and native dialogs.
What it collects
| Signal | What you get |
|---|---|
| Crashes | Panics with backtrace, handled errors, unclean exits found on next launch, abnormal child-process exits, each with recent log lines and device details. |
| Hangs | A report when the main loop stops responding. |
| Performance | Frame-time histogram, slow and frozen frames, startup time, process CPU and memory, plus your own counters and gauges. |
| Usage | Sessions, daily opens, focused / active / idle time, and the events you declare. |
The host app decides what is tracked. Events are declared as enums and checked at build time; anything not declared and listened to is never recorded. End users can switch each category off, and DO_NOT_TRACK=1 is honoured. docs/privacy.md lists every field.
Sketch
ashlar_trace::events! {
pub enum Category { File = "file", Edit = "edit" }
pub enum AppEvent {
FileOpen = "file.open" in File,
Undo = "edit.undo" in Edit,
}
}
let tracer = ashlar_trace::Tracer::builder("app-write-key", "my-app", env!("CARGO_PKG_VERSION"))
.data_dir(my_data_dir)
.endpoint("https://telemetry.example.com")
.listen(Category::File)
.init();
// Nothing is sent until the user has seen your first-run notice.
tracer.notice_shown();
// Once per turn of the main loop:
let _turn = tracer.turn(); // a turn that takes too long is reported as a hang
tracer.track(AppEvent::FileOpen);
tracer.frame(frame_duration); // once per frame
Documentation
- Integrating the SDK into a host app
- Self-hosting the server
- Architecture
- Wire format
- Privacy and collected fields
Development
The crates are part of the ashlar workspace and its gate (cargo xtask ci). Heavier runs:
cargo xtask trace-stress (the SDK’s benchmarks and stress tests, release build),
cargo xtask trace-load (a short load profile against a spawned release server) and
cargo xtask trace-demo (checks the windowed demo, which is its own workspace).
Integrating the SDK into a host app
This guide walks through adding ashlar-trace to an application. Every snippet is taken from, or was checked against, one of the two demo hosts in this repository:
examples/trace-demo-cli: a scriptable command-line process. It is the one the end-to-end tests drive, so everything it does is tested against a real server.examples/trace-demo-gui: a smalleframe/eguiwindow that wires the SDK the way a real GUI host would. It compiles and passes clippy (cargo xtask trace-demo); it has not been run, because it needs a display.
What the SDK may collect is listed in privacy.md. How the pieces fit together is in architecture.md.
1. Add the dependency
[dependencies]
ashlar-trace = "0.2"
Default features are http (a blocking HTTPS client on the SDK’s own thread) and sysinfo (process CPU and memory, device details). Without http, nothing leaves the machine unless you supply a Transport through Builder::transport. Without sysinfo, the CPU, memory and device fields are simply absent.
The SDK needs Rust 1.90, has no async runtime, forbids unsafe, and never writes to stdout.
2. Declare the catalog
Events are declared once, as two enums. A name that is not lower-case snake_case (dots allowed, at most 64 bytes), or that is used twice, fails the build.
ashlar_trace::events! {
pub enum Category {
File = "file",
Edit = "edit",
View = "view",
}
pub enum DemoEvent {
FileOpen = "file.open" in File,
FileSave = "file.save" in File,
EditUndo = "edit.undo" in Edit,
ViewZoom = "view.zoom" in View,
}
}
Only what you declare and listen to is ever recorded. Tracer::track takes the enum, so a misspelled event does not compile.
3. Build the tracer
Do it once, early in main, and keep the returned Tracer alive for the whole process. It is cheap to clone and safe to share across threads. Dropping the last clone ends the session and sends what is waiting.
let mut builder = Tracer::builder(&key, "demo", env!("CARGO_PKG_VERSION"))
.data_dir(&data_dir)
.listen_all::<DemoEvent>()
.logger(LoggerMode::Install)
.process(ProcessKind::Gui);
if let Some(endpoint) = &endpoint {
builder = builder.endpoint(endpoint);
}
let tracer = builder.init();
What each setting means:
- Key, name, version.
Tracer::builder(write_key, app_name, app_version). The write key comes fromashlar-trace-server app create(see self-hosting.md). It is not a secret: it ships inside the app and only allows ingest. data_dir. The host’s own data directory. The SDK keeps its files in atracerfolder inside it (see section 3.2 of the architecture). Each host picks its own location, so this is required. If it is empty or the folder cannot be created,initreturns a disabled tracer rather than failing.endpoint. The server’s base URL, such ashttps://telemetry.example.com, with no path. Without one the SDK records into its spool and sends nothing.build. An optional build identifier such as a commit hash, shown next to the version.process.Gui(the default),Cli,ServiceorChild. The dashboard counts “daily opens” fromGuisessions only, so give tools and helper processes their own kind.listen.listen(category),listen_events(&[...])andlisten_all::<E>()choose what is recorded in this run. An event that is not listened to is dropped before it reaches the queue. Each of them also tells the server what the app declares (so it can show events that were never fired and set apart names that were never declared), so they can come in any order and nothing else is needed.catalog::<E>()exists for the rare build that declares events without listening to any.send_before_notice. By default nothing is sent until you callnotice_shown()(section 9). Set this totrueonly if the user sees your notice somewhere else, such as in an installer.gpu. Graphics adapter details are only known to the host. If you have them, passGpu { name, vendor, backend, driver }toBuilder::gpu. They go into the device details on every envelope.signals/without. All built-in signals are on by default;without(Signal::Hang)switches one off.configure. Adjusts limits and intervals (flush_interval,heartbeat_interval,perf_window,hang_threshold,spool_max_bytesand so on). The demo-cli uses it to shrink every interval to milliseconds for tests:
builder = builder.configure(|config| {
config.tick_interval = Duration::from_millis(20);
config.heartbeat_interval = Duration::from_millis(100);
config.hang_threshold = Duration::from_millis(400);
});
GPU details
In the GUI demo the tracer is built inside eframe’s app-creator callback, because that is where the toolkit’s creation context exists. With the wgpu renderer the adapter is in cc.wgpu_render_state (adapter.get_info() gives the name, vendor and backend). With the glow renderer used by the demo, the renderer string can only be read through an unsafe GL call, which the demo’s workspace forbids, so demo-gui passes no GPU. The SDK does not need one.
4. Feed it from the main loop
The performance and hang signals need a few calls from the loop that draws the UI. All of them are an atomic operation or a short lock; none blocks on disk or network.
| Call | Where | Why |
|---|---|---|
turn(), or begin_turn() / end_turn() | Around each piece of work the main loop does | Hang watchdog: a turn that takes longer than hang_threshold is a hang |
frame(duration) | Around one UI pass | Frame-time histogram, slow and frozen frames |
first_frame() | After the first frame is on screen | Startup time since init (recorded once) |
set_focused(bool) | When the window gains or loses focus, or every turn | Focused versus unfocused time |
note_input() | When the user does something | Active versus idle time (idle after 120 s of no input by default) |
Turns and the hang watchdog
A hang is the main loop being stuck in one piece of work. So tell the SDK when a piece of work starts and ends:
let _turn = tracer.turn(); // ends when the guard is dropped
handle_the_event();
A turn that stays open longer than hang_threshold (2 s by default) is reported once, with how long it has been blocked. The time between turns, while the loop waits for the next event, is never a hang, however long it lasts. That makes this work for hosts that repaint only on input, and for hosts whose event loop belongs to a GUI framework: you do not need to see the wait, only your own callbacks. No timed repaint or wake-up is needed.
begin_turn() and end_turn() are the same thing without the guard. Each is one atomic update. A second begin_turn while a turn is open keeps the original start, and end_turn without an open turn does nothing, so nesting and early returns are harmless.
An immediate-mode GUI
This is the wiring from demo-gui. eframe owns the event loop and runs it only on input. It calls logic once per loop turn (including turns where nothing is drawn) and ui for each frame, and each is marked as a turn:
impl eframe::App for DemoApp {
fn logic(&mut self, ctx: &eframe::egui::Context, _frame: &mut eframe::Frame) {
let _turn = self.tracer.turn();
let (focused, active) = ctx.input(|input| (input.focused, !input.events.is_empty()));
self.tracer.set_focused(focused);
if active {
self.tracer.note_input();
}
}
fn ui(&mut self, ui: &mut eframe::egui::Ui, _frame: &mut eframe::Frame) {
let _turn = self.tracer.turn();
let started = Instant::now();
self.contents(ui);
self.tracer.frame(started.elapsed());
if !self.first_frame_done {
self.first_frame_done = true;
self.tracer.first_frame();
}
}
}
frame measures the cost of producing a frame (here: building the UI), not the time between frames. That is what matters for hosts that repaint only on input.
A loop that runs continuously
A game-style loop that never waits can call heartbeat() once per iteration instead: a last beat older than the threshold is a hang. Do not use it for a loop that waits for input, because every wait longer than the threshold would look like a hang. Once a host has begun a turn, heartbeats no longer count, so the two are not mixed.
Hosts that call neither get no hang reports at all, never false ones. .without(Signal::Hang) switches the watchdog off.
Pausing hang detection
When the main loop is legitimately blocked, for example by a native modal dialog, pause detection around it. Pauses nest, and resuming restarts the measurement, so the paused time is not a hang:
self.tracer.pause_hang_detection();
std::thread::sleep(Duration::from_secs(3));
self.tracer.resume_hang_detection();
The GUI demo has two buttons that block the UI thread for 3 s: one leaves detection on (and produces a hang issue with blocked_ms of about the threshold or more), one pauses it (and produces nothing).
5. Track usage
track records a declared event. track_with adds up to 16 properties, each a bool, integer, float or &'static str of at most 64 bytes. There is deliberately no way to pass a runtime string, so file names and typed text cannot get in by accident.
tracer.track(DemoEvent::FileOpen);
tracer.track_with(
DemoEvent::FileSave,
&[("count", Prop::Int(3)), ("format", Prop::Str("native")), ("ratio", Prop::Float(0.5)), ("recent", Prop::Bool(true))],
);
A central command dispatcher
Hosts that run every user action through one function keyed by a command id can record the id there, in one place:
tracer.track_id(Category::Edit, "edit.palette_command");
track_id records an id that is not in the catalog, marked dynamic, under a category you listen to. Rules:
- Pass ids only, and only fixed program strings (
&'static str). Never pass parameters, arguments, paths or anything the user typed. - The id must follow the same naming rule as event names. An invalid one is dropped and counted.
- Dynamic ids are exempt from the server’s catalog check, so a command registered at run time does not have to be in the enum.
Declare an event in the catalog when you want it to appear in the “declared but never fired” list; use track_id for the long tail.
6. Logs, handled errors and child processes
Log lines as breadcrumbs
The SDK keeps the last 100 log lines at Info and above, scrubbed, and attaches them to a crash. They leave the device only inside a crash report.
- The host has no logger.
.logger(LoggerMode::Install)installs the SDK’s logger globally. This is whatdemo-clidoes by default. - The host already installs its own logger. Wrap it and install the wrapper in its place. Nothing is lost: every record still reaches the wrapped logger. This is
demo-cli --wrap-logger, tested end to end:
let tracer = builder.init(); // LoggerMode::None, the default
let wrapped = tracer.logger(Some(Box::new(MyLogger)));
if log::set_boxed_logger(wrapped).is_ok() {
log::set_max_level(log::LevelFilter::Info);
}
Only one global logger can exist, so the host must not also install its own. Set the maximum level yourself: log ignores every record until you do. With a disabled tracer, Tracer::logger(Some(inner)) simply returns inner.
Handled errors
For an error you caught and survived. context is a fixed label such as a command id, never the message’s variable parts:
let failure = std::io::Error::other("could not write the preview");
tracer.report_handled(&failure, Some("preview.write"));
It becomes an issue of kind handled, counts towards the session’s errors, and carries the breadcrumbs. The message is scrubbed (home directory, account name) and truncated, but it is still text your code wrote: do not put user content in error messages.
Child processes
If the host spawns helpers that can abort, report how they ended. The SDK records it only when the status is not success:
let status = command.status()?;
tracer.report_child_exit("demo-child", status);
program is a fixed label. The report carries the exit code or the signal (on Unix). A child that links the SDK itself, with ProcessKind::Child and the same data directory, also gets its own session. If it dies without ending it, the next process that starts reports an unclean_exit for it.
7. Counters and gauges on a realtime thread
Register once, on a normal thread (registration locks and allocates). Record from the realtime thread: each call is one atomic operation, with no lock, allocation or system call.
let saves = tracer.counter("saves"); // a Counter, Arc-backed and cheap to clone
saves.add(7);
let layers = tracer.gauge("layers"); // a Gauge
layers.set(4.0);
Counters are reported per performance window as the increase since the previous window (zero increases are left out). A gauge is reported when it was set during the window. There is a cap on how many one process can register; handles over the cap work but are never reported. On a disabled tracer, both return detached handles that do nothing.
8. Short-lived tools
A command-line tool that runs for a moment never reaches the periodic send, and one that ends with std::process::exit never drops its Tracer, so dropping cannot end its session. Use ProcessKind::Cli and end the session yourself on every exit path:
let clean = tracer.shutdown_within(Duration::from_millis(25));
std::process::exit(code);
shutdown_within does what dropping the last handle does: it closes the session as exited, on disk, before it waits; then it gives the worker at most the time you pass for one last send. Later calls, and the final drop, do nothing more, and nothing is recorded after it. It returns true when nothing is left that could be sent. shutdown is the same with the configured shutdown_timeout. What could not be sent is kept (within the spool caps) and goes out with the next process of the same host. flush_blocking still works for a tool that wants to send before it ends.
ProcessKind::Cli also leaves out what only makes sense for an app with a main loop: the hang watchdog, performance windows (frames) and CPU and memory sampling (CLI_OFF), whatever signals says. And it writes events and signal reports to the spool on the calling thread (a file append) instead of queueing them for the worker: a tool may exit while the worker waits on a slow request, and what only the queue held would be lost with it.
A send costs at least one round trip to the server, often several (a new connection each run), which is far more than a tool that agents call many times in a row should add. Keep the wait at exit short and hand what is left to a courier: a background process, started from the same program, built with Builder::courier(true). A courier only delivers what other processes of the host left in the spool, reporting the ones that ended uncleanly as any process does; it opens no session of its own (no start, end, heartbeat or marker) and starts no panic hook, watchdog or logger. ashlar-telemetry packages this (Telemetry::end, Telemetry::hand_off, serve_courier).
A process that records neither sessions nor crashes leaves no session marker, so however it ends it is never reported as an unclean exit.
A stdio protocol server can use the SDK freely: nothing it does touches stdout.
9. Consent, the notice, and “forget me”
Recording is on by default (opt-out), with three independent categories: Crash, Performance and Usage. Nothing is sent until the user has seen your first-run notice. The SDK records from the first moment and keeps the data on disk, but it makes no request until you call notice_shown() once. That call is remembered, so later runs send from the start. The host owns the user interface:
if self.tracer.notice_pending() {
// Show your first-run notice, with the three switches if you like. When the user closes it:
self.tracer.notice_shown();
}
// Settings page: one switch per category.
let mut allowed = self.tracer.consent(ConsentCategory::Usage);
if ui.checkbox(&mut allowed, "Usage").changed() {
self.tracer.set_consent(ConsentCategory::Usage, allowed);
}
// "Forget me".
self.tracer.forget();
- If you never call
notice_shown(), no data ever arrives. The SDK says so once through thelogfacade (a warning with targetashlar_trace), andflush_blockingreturns false while data is waiting for the notice. A tool without a user interface of its own that shares the data directory with your app needs nothing: it sends once the app has shown the notice. - A category the user switches off before closing the notice is deleted from disk and never sent.
- If your notice is shown elsewhere, for example by an installer, build with
.send_before_notice(true). set_consent(category, false)takes effect at once and deletes that category’s unsent items from the spool. The choice is saved and survives restarts. Other running processes of the same host pick it up within a tick.forget()deletes everything stored locally, sends a request asking the server to delete the old install’s data (retried on the next launch if the server is unreachable), and issues a new install id.DO_NOT_TRACKset to anything but empty,0orfalse, orTRACER_DISABLEDset the same way, turns everything off for that process: nothing is written to disk, no folder is created, no request is made, and the consent file is not changed. Document this for your users.- A host whose telemetry is not optional builds with
.force_consent(true): every category is allowed and the notice counts as shown frominit, whatever an earlier run saved inconsent.json(the file is left as it is), andset_consentandnotice_showndo nothing. Nothing in the session is held back, the session start included. - Such a host can also build with
.honor_do_not_track(false):DO_NOT_TRACKis then ignored.TRACER_DISABLEDis always honoured, so developers and CI can still switch the SDK off. If you do this, say so plainly in your own privacy text: users who setDO_NOT_TRACKexpect it to work.
10. Inspect mode and the local report
.inspect(true) mirrors every item that is accepted for sending into outbox.jsonl in the SDK’s folder (capped in size), so a user can read exactly what leaves the machine. A “show what is sent” menu item can simply open that file.
write_report(path) writes a plain-text report the user can attach to a bug report: app and version, device, session ids and totals, the last performance window and the recent log lines. It works without a server and without sending anything:
match self.tracer.write_report(&path) {
Ok(()) => format!("wrote {}", path.display()),
Err(error) => format!("could not write the report: {error}"),
}
11. Tests and builds without telemetry
Tracer::disabled() is a tracer whose every method does nothing and touches no files or threads. Pass it into code under test, or use it in builds that ship without telemetry:
let tracer = Tracer::disabled();
assert!(!tracer.is_enabled());
tracer.track(DemoEvent::FileOpen); // nothing happens
assert!(tracer.flush_blocking(Duration::from_millis(1))); // nothing to send
assert_eq!(tracer.install_id(), "");
demo-cli runs its whole normal scenario against a disabled tracer in a unit test.
To test against a real server, run ashlar-trace-server in-process on 127.0.0.1:0 with a temporary data directory, as examples/trace-demo-cli/tests/e2e.rs does. Set .configure(|c| ...) intervals to milliseconds so tests need no long sleeps.
12. The web build
On wasm32 the SDK compiles but does nothing: Builder::init always returns a disabled tracer, so the same code path works unchanged and costs nothing. There is no data directory, no thread and no request on the web target. (cargo xtask wasm keeps ashlar-trace-proto and ashlar-trace building for it.)
13. Cost and limits
Measured with cargo xtask trace-stress (release build, a 15-core laptop, a real Tracer on a temporary folder and a transport that accepts at once). Run it on your own hardware; the tables it prints are the source of these numbers.
Per call on a host thread, one thread (median, 99th percentile):
| Call | Median | p99 | Allocations |
|---|---|---|---|
track, listened to | 35 ns | 42 ns | 0 |
track_with, 4 properties | 44 ns | 60 ns | 1 |
track_id | 38 ns | 42 ns | 0 |
track, not listened to | 10 ns | 15 ns | 0 |
frame | 5 ns | 10 ns | 0 |
turn() or begin_turn + end_turn | 25 ns | 38 ns | 0 |
heartbeat, note_input | 20 ns | 48 ns | 0 |
set_focused, Counter::inc, Gauge::set | 1–2 ns | 2 ns | 0 |
| a log line kept as a breadcrumb | 88 ns | 187 ns | 4 |
| a log line below the kept level | 3 ns | 7 ns | 0 |
report_handled | 8 µs | 26 µs | many; it is an error path |
| anything on a disabled tracer | 1–2 ns | 2 ns | 0 |
The allocation counts are from reading the code, not from a counting allocator (that needs unsafe, which this workspace forbids).
What a host thread can wait for. Nothing that touches the disk or the network. track is an atomic check, a set lookup and a try_send into a queue that was allocated at startup; when the queue is full the event is dropped and counted with one atomic add. frame, the turn calls, note_input, counters and gauges are atomic operations only. Over ten million calls of each, with the worker flushing every 20 ms, the longest single call was a few milliseconds, and so was the longest gap in an empty loop that only reads the clock: on a desktop operating system the scheduler can take a thread away at any point, and no library can promise otherwise. What the SDK promises is that it adds no wait of its own.
Calls that do touch the disk, briefly, on the calling thread: init, set_consent, notice_shown, forget, and the panic hook; in a ProcessKind::Cli process also track, track_with, track_id and report_handled. flush_blocking, shutdown_within and dropping the last Tracer wait for the worker, up to the timeout you give and shutdown_timeout.
With many threads at once the calls stay lock-free but share memory, so they slow each other down: with 8 threads calling the same thing as fast as they can, track is about 55 ns, frame about 200 ns, a kept log line about 2 µs. Counters and gauges on a realtime thread are one atomic operation on memory nothing else writes to, if you give that thread its own counter.
Startup and shutdown. init returns in under 1 ms, whether the folder is empty or holds 1,000 session folders left by earlier runs or a full 8 MiB spool; dealing with those is the worker’s first job (about 0.4 s of its time for the 1,000 folders). Dropping the last Tracer takes about 1 ms when the server answers at once or refuses at once, as long as one request when the server is slow, and never longer than shutdown_timeout (3 s by default).
In the background. An idle app costs about 0.02% of one core for the worker and the watchdog together. At 120 frames a second with 50 events a second the whole process, pacing loop included, used 0.3% of one core. The worker writes about 500,000 items a second to the spool. With the server unreachable for five minutes at that load, resident memory stayed flat and the spool stayed at its cap.
Limits, all enforced, all counted in the drops item when they throw something away:
| Limit | Default | When it is reached |
|---|---|---|
| Queue between host threads and the worker | 1,024 items (queue_capacity) | The new item is dropped: queue_full |
| Spool on disk, all sessions together | 8 MiB (spool_max_bytes) | The oldest segments go: spool_evicted |
| Age of spooled data | 30 days (spool_max_age) | Older segments go: spool_evicted |
| One request | 1 MiB before compression | A larger backlog is sent as several requests |
| One item | 256 KiB | Dropped: item_too_large; an unclean-exit report that big goes without its log lines |
| Properties on one event | 16 | The rest are left out: prop_dropped |
| Event, category and property names | 64 bytes, lower-case snake_case, dots allowed | Build error for declared names; invalid_id or prop_dropped at run time |
| String property values | 64 bytes | Left out: prop_dropped |
| Panic and error messages | 2 KiB | Cut, on a character boundary |
| Backtraces | 32 KiB | Cut |
| Log lines kept as breadcrumbs | 100 lines of 512 bytes (breadcrumb_capacity) | Oldest lines go; long lines are cut |
| Counters and gauges | 256 names in all | Further names get a handle that records nothing |
| Forget requests still owed | 16 | The oldest is given up |
Inspect mirror outbox.jsonl | 1 MiB | The older half is discarded |
What a power loss can cost. Spooled items are not flushed to the disk one by one, so the last seconds before a power loss can be lost; a crash of the app alone loses nothing that was already written. A consent change or a “forget me” is made durable by the worker within a tick (one second by default).
14. A short checklist
- Add the dependency; declare the catalog with
events!. - Build the tracer once at startup, with data dir, endpoint, key, version, process kind.
- Mark each turn of the main loop with
turn(), callframearound each UI pass,first_frameonce, andset_focused/note_inputfrom your input state. - Call
track/track_idwhere user actions are dispatched. - Install the SDK’s logger or wrap yours.
- Report handled errors and abnormal child exits.
- Show the first-run notice and call
notice_shown()when the user closes it (nothing is sent before that); add the three switches and a “forget me” action. - In short-lived tools, end with
shutdown_withinbefore every exit. - Update the host’s own privacy text from privacy.md.
Privacy and collected fields
This page lists everything the SDK can send. If a field is not here, it is not collected. Change this page in the same commit as any change to what is collected.
Principles
- The host app chooses. It declares its events at build time and picks which to listen to at startup. Undeclared events cannot be recorded.
- Nothing is sent before the notice. Telemetry is on by default with a first-run notice. Until the host app reports that the user has seen that notice, data is recorded on the device but nothing leaves it. A category the user switches off in the notice is deleted and never sent. (A host app that shows its notice elsewhere, such as in an installer, can lift this.)
- The user can switch it off. Each category — crash reports, performance, usage — has its own switch.
DO_NOT_TRACK=1orTRACER_DISABLED=1in the environment turns everything off and nothing is written or sent. A host can choose to ignoreDO_NOT_TRACK(honor_do_not_track(false)) and to make telemetry not optional (force_consent(true): no switches, no notice); it must then say so in its own privacy text.TRACER_DISABLEDcannot be ignored. - No user content. Event names and property text are fixed strings compiled into the app. There is no API that accepts a file name, project name, document text or typed input.
- No stable identity. The install id is random, is not derived from hardware, and can be reset. The server never stores the IP address.
- Visible. With inspect mode on, everything that would be sent is also written to
outbox.jsonlin the SDK’s folder. - Forgettable. “Forget me” deletes local data, including recent log lines and the last crash held in memory, asks the server to delete everything for the install, and issues a new id.
Fields
| Field | Example | Category |
|---|---|---|
| App name, version, build id, process kind | my-app, 1.2.3, abc1234, gui | all |
| Install id | random 32 hex characters | all |
| Session id | random 32 hex characters | all |
| Operating system name and version, CPU architecture | macos, 26.0, aarch64 | all |
| CPU model and core count, RAM size | Example CPU, 10, 32768 MB | all |
| Graphics adapter name, vendor, backend, driver version | supplied by the host app | all |
| Locale | en-US | all |
| Session start, end, how it ended | exited, crashed, abnormal | usage |
| Session duration; focused, active and idle seconds | 62, 50, 40, 22 | usage |
| Declared event name and category | file.open, file | usage |
| Event properties: booleans, numbers, fixed short strings | format = "native" | usage |
| The list of declared events and which are listened to | usage | |
| Frame-time histogram, slow and frozen frame counts | performance | |
| Startup time | 850 ms | performance |
| Process CPU percentage and memory use | 42.5, 512 MB | performance |
| Host-defined counters and gauges | audio.xruns = 2 | performance |
| Panic or error message, source location, thread name, backtrace | scrubbed, see below | crash |
| Hang duration | 2400 ms | crash |
| Child process label, exit code, signal | exporter, 134 | crash |
| Recent log lines (level, target, message) — only attached to a crash | scrubbed, see below | crash |
| Counts of data the client discarded | queue_full = 4 | all |
Scrubbing
Applied on the device, before anything is queued or written, to messages, backtraces and log lines:
- The user’s home directory becomes
~. - The user’s account name is removed where it appears in a path.
- Long values are truncated.
Log lines and error messages are written by the host app, so the host is responsible for not logging user content.
Retention
The server keeps raw data for 90 days by default and daily totals indefinitely. Both are configurable.
Self-hosting the server
ashlar-trace-server is one binary and one data folder. It serves the ingest API, stores everything in a SQLite file, computes daily rollups, and renders the dashboard. There is no other service to run.
1. Run it
The binary
cargo build --release -p ashlar-trace-server
./target/release/ashlar-trace-server serve
With no settings it listens on 127.0.0.1:8090 and keeps its database in ./data. It logs to stderr at info level and stops on ctrl-c, letting in-flight requests finish.
The container
deploy/trace/Dockerfile builds a small image (build from the repository root) and deploy/trace/compose.yml runs it with its data in a named volume:
docker compose -f deploy/trace/compose.yml up -d
The image tags (rust:1-slim-bookworm, debian:bookworm-slim) are left as they are so that security updates arrive on rebuild. If you need reproducible builds, pin the base images by digest (FROM debian:bookworm-slim@sha256:...) and update the digests on a schedule.
The image runs as an unprivileged user (uid 10001), listens on 0.0.0.0:8090 inside the container, and keeps its data in /data. The compose file publishes the port on 127.0.0.1:8090 only, on purpose: put a TLS proxy in front of it (section 5) instead of exposing it directly.
Check that it is up:
curl http://127.0.0.1:8090/v1/health
2. Settings
All settings are environment variables and all are optional.
| Variable | Default | Meaning |
|---|---|---|
TRACER_BIND | 127.0.0.1:8090 | Listen address |
TRACER_DATA_DIR | ./data | Folder for the database |
TRACER_RETENTION_DAYS | 90 | Age at which raw rows are pruned |
TRACER_RATE_PER_MIN | 120 | Requests per minute per client address on the public routes (ingest, forget and health, unknown keys included); 0 switches the limit off. Dashboard sign-in has its own limit (10 a minute per address) that stays on |
TRACER_KEY_RATE_PER_MIN | 6000 | Ingest requests per minute for one write key over all addresses. A capacity guard, far above the per-address limit, so that one client cannot use up a key’s allowance for everyone; 0 switches it off |
TRACER_MAX_DB_MB | 10240 | Ingest answers 507 Insufficient Storage once the database is larger than this many MiB; clients keep their data and retry. The dashboard and sign-in keep working so you can fix it (lower the retention, then prune); 0 switches the ceiling off |
TRACER_TRUST_PROXY | unset | When 1, take the client address from the last entry of the last X-Forwarded-For header line |
TRACER_ADMIN_PASSWORD | unset | Password for admin create. Read by that command only; the server never reads it |
TRACER_SECURE_COOKIES | on | Dashboard cookies are Secure, and the session cookie is named __Host-tracer_session. Set 0 to sign in over plain HTTP, for example on localhost without a proxy |
A value that cannot be parsed stops the command with a message naming the variable. An empty value counts as unset. The repository’s .env.example lists the same names with placeholders; copy it, fill it in, and keep the real file out of version control.
With compose, TRACER_RETENTION_DAYS, TRACER_RATE_PER_MIN, TRACER_KEY_RATE_PER_MIN, TRACER_MAX_DB_MB, TRACER_TRUST_PROXY and TRACER_SECURE_COOKIES are passed through from the shell or from a .env file next to the compose file. The bind address and data folder are fixed by the image.
3. First setup: an app, a key and an admin
Every command below works on the same data folder as serve. Run them with the same TRACER_DATA_DIR. They can run while the server is running.
Create an app. The slug is lower-case letters, digits, - or _. The command prints the app’s first write key:
ashlar-trace-server app create my-app "My App"
created app my-app (My App)
write key: tk_...
Put that key into the host app’s Tracer::builder. Write keys are public by nature (they ship inside the app) and only allow sending data. More keys:
ashlar-trace-server key create my-app
ashlar-trace-server key revoke tk_... # stops it working at once
ashlar-trace-server app list
Revoking a key makes the server answer 401 to it. The SDK keeps its data and stops sending until the app restarts, so rotate keys by shipping a new one first, then revoking the old one when most installs have updated.
Create a dashboard login. The password is read from TRACER_ADMIN_PASSWORD or, if that is unset, from one line of standard input. It must be at least 8 characters:
TRACER_ADMIN_PASSWORD='a long passphrase' ashlar-trace-server admin create alice
Then open the server’s address in a browser and sign in. The dashboard has an overview, crashes, performance, usage, release reports (downloadable as HTML, Markdown or JSON) and a settings page for keys.
With compose, the container’s entrypoint is the binary, so:
docker compose -f deploy/trace/compose.yml exec tracer ashlar-trace-server app create my-app "My App"
docker compose -f deploy/trace/compose.yml exec -e TRACER_ADMIN_PASSWORD='a long passphrase' tracer ashlar-trace-server admin create alice
4. Point a host app at it
In the host app:
Tracer::builder("tk_...", "my-app", env!("CARGO_PKG_VERSION")).endpoint("https://telemetry.example.com")
The endpoint is the base URL with no path; the SDK appends /v1/ingest and /v1/forget. See integration.md for everything else.
5. TLS and a reverse proxy
The server speaks plain HTTP. Terminate TLS in front of it with any proxy. The SDK uses https and verifies certificates, so use a real certificate.
Caddy, which obtains certificates itself:
telemetry.example.com {
reverse_proxy 127.0.0.1:8090
}
nginx:
server {
listen 443 ssl;
server_name telemetry.example.com;
# ssl_certificate and ssl_certificate_key as usual
location / {
proxy_pass http://127.0.0.1:8090;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Then set:
TRACER_TRUST_PROXY=1, so the rate limiter sees each client’s address instead of the proxy’s. Without it every request appears to come from the proxy and shares one bucket, and busy installs will see 429. The server uses the lastX-Forwarded-Forentry, the one the proxy itself appended, so earlier entries a client forges are ignored. Only turn this on when a proxy you control is the only way to reach the server.- Leave
TRACER_SECURE_COOKIESunset (cookies areSecureby default, which needs HTTPS). Only set it to0for plain-HTTP use on a local machine.
The server never stores client addresses; it uses them for the rate limit and nothing else.
Do not expose the port directly. Bind to 127.0.0.1 (the default) or to a private interface, and let the proxy be the public side. The server logs a warning at startup when it is bound beyond loopback without TRACER_TRUST_PROXY=1: behind a proxy that would make every client look like the proxy, so the per-address and sign-in limits would be shared by all of them.
The server itself cuts off clients that take longer than 15 seconds to send request headers (this also closes idle keep-alive connections), and serves at most 1,024 connections at once, waiting for a slot beyond that. Request bodies must arrive within 30 seconds. You can still add stricter limits in the proxy (client_header_timeout, limit_conn, client_max_body_size 1m in nginx), but the server no longer depends on it for slow clients.
6. Backups
Everything is in TRACER_DATA_DIR: the database tracer.db, and while the server runs, two companion files tracer.db-wal and tracer.db-shm. SQLite runs in WAL mode, so recent writes may live only in the -wal file. Copying tracer.db alone while the server is running can give you a stale or inconsistent copy. Use one of these:
-
Ask SQLite for a snapshot. Safe while the server runs:
sqlite3 /path/to/data/tracer.db ".backup '/backups/tracer-$(date +%F).db'"or
VACUUM INTO '/backups/tracer-2026-01-01.db'for a compacted copy. The result is a single, self-contained file. -
Stop the server, then copy the folder. After a clean stop the
-walfile is empty or gone, and copying the whole folder is fine. If you copy while stopped, copy all three files together. -
Snapshot the volume (LVM, ZFS, a cloud disk snapshot). These capture the three files at one instant and are safe.
For compose, option 3 or a stopped copy is simplest:
docker compose -f deploy/trace/compose.yml stop
docker run --rm -v tracer_tracer-data:/data -v "$PWD":/backup debian:bookworm-slim tar czf /backup/tracer-data.tgz -C /data .
docker compose -f deploy/trace/compose.yml start
(The volume is named <project>_tracer-data; docker volume ls shows the real name.) Test a restore now and then: stop the server, put the files back into the data folder, start it.
What is lost if you lose the database: history. Clients keep unsent data on their own disks and resend it, and the server ignores anything it has already stored, so a restore plus a few days of resends is not harmful.
7. Retention and housekeeping
-
Raw rows (events, performance windows, crashes, sessions) are deleted when they are older than
TRACER_RETENTION_DAYS(default 90). The ids used to ignore duplicates are kept for 35 days. After pruning, SQLite reclaims the space. -
Daily totals (opens, active installs, event counts, frame histograms, drops) are kept indefinitely, so long-term charts survive pruning. Lowering the retention period does not remove them.
-
Timers.
serverecomputes the rollups for the last three days every five minutes and runs retention hourly. A session that is still open a day after its last item is closed asabnormal. -
By hand, for example from cron on a machine that runs the binary directly:
ashlar-trace-server rollup --days 7 # recompute recent rollups now ashlar-trace-server prune # run retention once
8. Upgrading
- Take a backup (section 6).
- Build or pull the new version and restart. Database migrations are applied at startup and tracked in the file (
PRAGMA user_version). They only ever add; a shipped migration is never edited. - Check
/v1/health, which reports the server version.
Run one server process per data folder. Do not point an older binary at a database that a newer one has migrated. The wire format only grows, so older apps keep working against a newer server, and an app newer than the server may send fields the old server ignores. If a client sends a schema higher than the server understands it gets 422 and drops that batch, so upgrade the server before shipping an app that depends on a new schema.
9. Troubleshooting
The SDK stops sending, the server answers 401. The key is missing, mistyped or revoked. The SDK keeps its data and stops sending until the host app restarts. Check the key on the dashboard’s settings page, or create a new one (key create). The key header is x-tracer-key.
429 Too Many Requests. Requests are limited per client address (TRACER_RATE_PER_MIN, default 120 a minute), and each write key also has a much larger overall allowance (TRACER_KEY_RATE_PER_MIN, default 6000). Behind a proxy, set TRACER_TRUST_PROXY=1, otherwise all clients share the proxy’s address. The SDK honours Retry-After and keeps its data until the server accepts it, so an occasional 429 loses nothing. Sustained 429 usually means the proxy setting above, or a test loop. Raise the limit, or set 0 to switch it off.
507 Insufficient Storage. The database passed TRACER_MAX_DB_MB. The SDK keeps its data and retries. Lower TRACER_RETENTION_DAYS and run ashlar-trace-server prune, or raise the ceiling.
400, 413 or 422. The batch was malformed, over 1 MiB after decompression, or from a newer schema. The SDK deletes that batch and counts a server_rejected drop, shown on the dashboard. A proxy that rewrites or truncates bodies (or a body-size limit smaller than 1 MiB) causes this too.
Times look wrong, or events land on the wrong day. The server compares the client’s sent_at with its own clock. If they differ by more than five minutes, item times are shifted by the difference, and no time is accepted more than an hour in the future or older than the retention period. Make sure the server’s clock is right (use NTP); the client’s can be wrong without harm.
Nothing arrives.
curl https://your-host/v1/healthfrom outside. If that fails, it is the network, DNS, TLS or the proxy.- In the host app: is
endpointset (the SDK records locally and sends nothing without it), and is it the base URL with no path? - Is the user’s
DO_NOT_TRACKorTRACER_DISABLEDset? Then nothing is written or sent, by design. Are all three consent categories switched off? - The SDK does not send at once: it sends when 50 items are waiting, every minute while items are waiting, and at the end of the session. A short run that was killed is sent by the next launch.
- Look at the
tracerfolder inside the host’s data directory.spool/holds what has not been delivered;outbox.jsonl(inspect mode) shows what would be sent. - The dashboard has a version filter and a date range; check them. Events that are not in the app’s declared catalog are kept, but listed on the usage page under “Undeclared events” rather than among the declared ones.
- Watch the server’s stderr while the host app starts.
Sessions show up as “abnormal”. A process ended without closing its session (killed, crashed natively, power loss). The next launch of the app reports it. A session that never ends and never reports again is closed as abnormal after a day.
The container cannot write its data. The image runs as uid 10001. A bind-mounted data folder must be writable by that user; the named volume in compose.yml already is.
10. Capacity and tuning
Numbers below were measured with examples/trace-loadgen on one machine (15 cores, an NVMe disk, release build, client and server on the same host over loopback, other jobs running on the machine). Treat them as an order of magnitude for your own hardware and re-measure with the commands at the end. A “typical” envelope in the generator carries about 40 items (a session start with a catalog hash, heartbeats, 20 to 50 events, a performance window with a real histogram, now and then a crash with a 30-frame backtrace, a session end) and is about 1.2 KB gzipped.
Ingest.
| Load (64 connections) | Result |
|---|---|
| Flat out | about 4,300 envelopes a second (170,000 items a second), p50 13 ms, p99 about 100 ms |
| Fixed 2,000 a second | p50 8 ms, p99 about 60 ms, no errors |
| Fixed 500 a second | p50 7 ms, p99 about 14 ms |
| 1,000 a second for 5 minutes | no errors; p50 steady at 9 ms; p99 drifts from 70 to 230 ms as the database grows to 2.7 GB; server memory 28 to 38 MiB |
| 2,000 a second for 5 minutes | held for about 3 minutes (a 3 GB database), then latency climbs: see “When the database outgrows memory” |
| 9,000 a second for 15 seconds (over twice the capacity) | the server answers 503 with Retry-After: 1 to what it cannot take, keeps accepting the rest at its normal rate, answers /v1/health and the dashboard in under 5 ms, and is back to no errors within a second of the load dropping |
Why it is fast: ingest requests are queued to one writer thread that stores up to 64 envelopes per transaction and commits once, a separate thread checkpoints the write-ahead log, statements are prepared once, and duplicate detection is one statement per item. The queue holds 256 envelopes; when it is full the server answers 503 and the SDK retries later, so memory stays bounded however hard clients push.
Durability. The database runs in WAL mode with synchronous = NORMAL. A request is acknowledged only after its data is committed to the log, so stopping the process, even with kill -9, loses nothing that was acknowledged (a test does exactly this under load). What NORMAL does not do is fsync each commit, so a power failure or operating-system crash can lose the last moments of acknowledged data. The database is never left corrupt. If you cannot accept that, run the database on a volume with a battery-backed cache or a replicated disk.
The dashboard on a large database. On a database with 1,000,000 sessions, 10,000,000 events, 2,000,000 performance windows, 100,000 crashes in 5,000 issues, 50 versions, 200,000 installs and 90 days (2.5 GB):
| Before | After | |
|---|---|---|
| Slowest page, warm | 2.1 s (reports), 1.4 s (overview) | 18 ms |
| Slowest page, first request after start | 2.2 s | 290 ms |
| Rollup of 3 days (writer held) | 2.3 s | 3.5 s (a few ms of it holding the writer) |
| Full 90-day recompute | 60 s | 64 s |
Ingest at 300 envelopes a second while the 3-day rollup runs as another process: p99 58 ms against 47 ms without it. While retention deletes half the data it never stops ingest, but it does hurt the tail (p99 about 300 ms against about 40 ms).
When the database outgrows memory. Each stored item touches several B-tree indexes in random order, so once the database is much larger than the memory the operating system can use for caching, every commit reads and writes random pages and throughput falls. On the test machine that started at roughly 3 GB at 2,000 envelopes a second. The remedies are the usual ones: more memory, a shorter TRACER_RETENTION_DAYS, and a fast local disk. Dropping the four per-install indexes delayed the problem a little but did not remove it, so they are kept. TRACER_MAX_DB_MB stops ingest with 507 before the disk fills.
Settings that matter for capacity. TRACER_RETENTION_DAYS (database size is about 220 bytes per stored item including its indexes and dedup record: 12 million items took 2.7 GB); TRACER_RATE_PER_MIN (per client address) and TRACER_KEY_RATE_PER_MIN (per key, a capacity guard); TRACER_MAX_DB_MB. Retention runs hourly in small chunks (100 rows) that yield the writer, so the first run on a very large database can take a long while; that is harmless, and later runs only remove a day’s worth.
Measuring your own server.
cargo xtask trace-load # a short standard profile, prints a table
cargo build --release -p ashlar-trace-server -p loadgen
target/release/loadgen run --spawn --concurrency 64 --duration 30 # flat out
target/release/loadgen run --spawn --rate 500 --duration 60 # fixed rate, open loop
target/release/loadgen run --url http://host:8090 --key tk_... --db /data/tracer.db --rate 200 --duration 60
target/release/loadgen run --spawn --phases 1500:10,9000:15,1500:10 --concurrency 600 --probe # overload
target/release/loadgen seed --db /tmp/big/tracer.db --scale 1.0 # 10M events, 1M sessions, ... (2 min, 2.5 GB)
target/release/loadgen read --dir /tmp/big --mode rollup # rollup timings
target/release/loadgen read --dir /tmp/big --mode pages # every page, cold and warm
The generator is open loop with --rate: requests leave on a clock and latency is measured from when they were due, so a slow server shows up as latency. It ends with a check that the number of items the server stored equals the number it acknowledged. Do not point it at a server you care about: it writes a lot.
Architecture
This is the spec. Code follows it; when they disagree, fix one of them in the same change.
host app ──► ashlar-trace ──► bounded queue ──► worker thread ──► disk spool ──► HTTPS batch (gzip)
│
ashlar-trace-server: ingest ► SQLite ► rollups ► dashboard + reports
1. What the SDK must fit
The host apps set these constraints:
- Rust edition 2024, minimum Rust 1.90,
unsafeforbidden, nounwrap/expect/panicoutside tests. - Immediate-mode GUI with a per-frame callback. Some hosts repaint only on input, so frame cost matters more than frame rate.
- The
logfacade only. A host may have no logger, or its own. Only one global logger can exist, so the SDK must be able to wrap one. - No async runtime in the GUI process. Background work is a plain thread; HTTP is blocking.
- Each host also ships a short-lived command-line binary, a stdio protocol server (stdout must stay clean), a web build, and sometimes child processes that can abort.
- One host has a realtime audio thread, where the SDK must never lock, allocate or make a system call.
- Each host chooses its own data directory, so the SDK takes it as a parameter.
2. Crates
| Crate | Role | May depend on |
|---|---|---|
ashlar-trace-proto | Wire types, constants, name validation. No I/O. | serde |
ashlar-trace | The client. | ashlar-trace-proto, serde_json, log, thiserror, flate2, getrandom, optional ureq, optional sysinfo |
ashlar-trace-server | The backend binary. | ashlar-trace-proto, axum, tokio, rusqlite, maud, argon2, and small helpers |
3. SDK
3.1 Shape
client.rs holds the public Tracer handle and Builder. Behind it are two halves that meet only at the Sink trait (sink.rs):
pipelineimplementsSink. It owns consent, identity, the session, the queue, the spool, the worker thread and sending.signalsproduces data: crash, hang, perf, sys, logs, realtime counters, scrubbing, the local report. It callsSinkand nothing else.
The public API in client.rs and the Sink trait are frozen. Changing either needs a note in PROGRESS.md.
Builder::init never fails. On the web target, with no data directory, or when the SDK’s folder cannot be created, it returns a disabled tracer whose methods do nothing.
3.2 Files on disk
Everything lives in <host data dir>/tracer/:
| Path | Owner | Content |
|---|---|---|
install.json | pipeline | {"install_id": "…", "created": ms, "pending_forget": ["…"]}. pending_forget lists earlier install ids whose forget request has not been delivered yet (at most 16); it is absent when empty. |
install.lock | pipeline | Empty. Locked for the moment it takes to change install.json. |
consent.json | pipeline | {"crash": bool, "performance": bool, "usage": bool, "notice_shown": bool} |
selection.json | pipeline | Catalog hash, listened categories and events, enabled signals for the last run. Also the catalog hashes the server has acknowledged, per app version. |
spool/<session_id>/lock | pipeline | Empty. The process that runs the session holds an exclusive advisory lock on it for its whole life. |
spool/<session_id>/header.json | pipeline | The envelope header for that session (app, install id, device), plus created, which orders sessions. |
spool/<session_id>/session.json | pipeline | Marker for the running session: envelope header, start time, time of the last rewrite, last heartbeat totals, error count, panicked flag. Removed on clean shutdown. |
spool/<session_id>/<seq>.jsonl | pipeline | Items in segments, one per line: a consent-category byte (c, p, u, or - for none) followed by the item as JSON. |
spool/<session_id>/breadcrumbs.jsonl | signals | Recent log lines of that session, rewritten periodically. |
outbox.jsonl | pipeline | Inspect mode only: a capped mirror of every item that was accepted for sending. |
Files are written to a temporary name and renamed. A file that fails to parse is treated as absent, never as an error.
Nothing in the SDK’s folder is trusted. Every file is read as possibly damaged or planted: a state file that does not parse is treated as absent and replaced; a file is read only if it is a regular file of plausible size (state files up to 1 MiB, segments up to 8 MiB), so a symbolic link is never followed out of the folder and an oversized file is never loaded into memory; a session folder is only ever a real folder, never a link; a segment far larger than the SDK writes is deleted unread when its folder is adopted and counted as spool_damaged. If the SDK’s folder, or the running session’s folder, is deleted under a running session, the worker puts back the folder, lock, header, marker and the install and consent files on its next pass; what was recorded in between is lost uncounted.
Durability. Nothing waits for the disk on a host thread. Spool segments are appended with one write per item and are never flushed with fsync: a crash of the process loses nothing that was written, but a power loss or a crash of the operating system can lose the last seconds of items, or leave a cut-off last line, which readers skip. consent.json and install.json are written by temporary file and rename on the calling thread and then flushed to the disk, with their folder, by the worker within a tick; until then a power loss can bring back the previous consent choice or install id, or an empty file, which reads as absent (consent then starts from the defaults). The marker and selection.json are not flushed.
Several processes, one directory. A host’s GUI, command-line tool and child processes may all run on the same data directory at once, so every session folder has exactly one owner:
- A running process owns
spool/<its session id>/. It creates the folder atinit, takes the lock, and appends only there. The operating system releases the lock when the process ends, however it ends. - A folder whose lock is held belongs to a live process and is left completely alone by everyone else: not sent, not evicted, not purged, not deleted.
- A folder whose lock can be taken is an orphan. The process that takes the lock adopts it and keeps the lock until the folder is gone, so no two processes work on the same orphan. Orphans are looked for at startup, before every send, and every
flush_interval. - An orphan recorded under an install id other than the current one was forgotten by the user and is deleted unsent. A folder with neither header nor marker is deleted once it is a minute old (younger, it may belong to a process that is still starting). A scan never creates a lock file in a folder younger than that, since the folder may be one its owner is deleting at that moment.
install.json,consent.json,selection.jsonandoutbox.jsonlare shared. Processes that start together on a fresh directory agree on the install id: the file is created with create-if-absent, and the losers read what the winner wrote. The worker re-readsconsent.jsonandinstall.jsonon every tick, so a consent change or aforgetin one process reaches the others within a tick. Changes toinstall.jsonare read-modify-write underinstall.lock; changes toconsent.jsonstart from the file’s current content, without a lock.- Where the filesystem does not support locks (
try_lockfails with an error other than “would block”), a folder counts as an orphan only when nothing in it has been written for 24 hours, and no unclean exit is ever reported from it: its marker is discarded and its items are sent. A running session rewrites its marker everyheartbeat_interval, which keeps its folder fresh.
3.3 Consent and kill switches
- Default is on for all three categories.
Signal::consent()maps each signal to its category. DO_NOT_TRACK(any value other than empty,0orfalse) orTRACER_DISABLEDset the same way disables everything for the process:allowsis false for every signal, nothing is written to the spool, and no request is made. The consent file is not changed.Sink::allows(signal)is true only when the host enabled the signal, the kill switches are off, and the category is allowed.set_consent(category, false)takes effect at once and also deletes that category’s unsent items from the spool.- A choice made in this process is the authority in memory until it has been saved. If
consent.jsoncannot be written, the worker tries again on every tick and does not read the file in the meantime, so a file that could not be updated never switches an opt-out back on. notice_pending()is true untilnotice_shown(). Until then the SDK records and spools as usual (opt-out model) but sends nothing: no ingest request and no forget request leaves the machine before the user has seen the host’s notice.notice_shown()is saved inconsent.json, so later runs send from the start, and other running processes of the host see it within a tick. A category the user switches off before the notice is acknowledged is deleted from the spool and so is never sent.flush_blockingreturns false while sending is held and data is waiting. The first time that happens the worker logs onelog::warn!with targetashlar_trace, so a host that forgot to callnotice_shown()can find out why nothing arrives; the SDK’s own logger forwards lines with that target but never keeps them as breadcrumbs.Builder::send_before_notice(true)lifts the hold, for hosts that show their notice somewhere else, such as an installer. It does not mark the notice as shown.- Under a kill switch
set_consentandnotice_shownchange nothing on disk. forget()deletes this process’s unsent items, every orphan, its breadcrumbs and the outbox, sends aForgetRequestfor the old install id, and creates a new install id. It also callsSignals::forget(), which empties what the signal modules hold in memory from before: the breadcrumb ring, the last crash and the last performance window. Other running processes of the host see the new id on their next tick and do the same to their own session folder and memory; items they had queued under the old id are discarded, which is decided under the spool lock so that none can land under the new header.- The rotated install record is the authority in memory. If
install.jsoncannot be written, the new id and the pending forget stay in memory, the worker tries the save again on every tick, and a file that still names an id this process has rotated away from is treated as behind and overwritten, never taken back. - The forget request is best effort and never holds up ingest. A network error, a 5xx or a 429 is retried later with its own backoff, in this run and after later launches. A 401 or 403 stops all sending, as for ingest, and keeps the request. Any other 4xx is final: the request is given up and counted as
forget_rejected.
3.4 Identity and ids
Install and session ids are 16 random bytes from getrandom, as 32 lower-case hex characters. If the random source fails, the SDK falls back to a mix of time, process id and a counter rather than failing.
Item ids have the same form but are not drawn from the system each time, because that is a system call per item: the first half is random and fixed for the process, the second half is a per-process counter passed through a mixing function that gives every count a different value. They are unique, which is all the server’s duplicate check needs. The two items that report an adopted session’s unclean end are the exception: their ids are derived from the session id (§3.5).
3.5 Session
- One session per process run.
session_startis submitted duringinit. - The worker emits
session_heartbeateveryheartbeat_intervalwith running totals, and rewrites the marker,spool/<session_id>/session.json. A heartbeat is never written after the session’s end. - Time accounting uses a monotonic clock, sampled once a second by the worker: a second counts as focused if the host last said
set_focused(true)(hosts that never call it count as focused), as active if focused andnote_input()was called withinidle_after, otherwise idle.note_inputandset_focusedare single atomic stores. - Clean shutdown (last
Tracerclone dropped) writessession_endon the calling thread, removes the marker, tries one final send, and releases the session folder. If everything was sent the folder is deleted; otherwise it stays, without a marker, as an orphan that the next process to look simply sends. Status iscrashedif a panic was seen and the thread dropping the tracer is panicking, otherwiseexited. errorscounts handled errors and survived panics. The panic hook counts every panic when it happens, because it cannot know then whether the process will survive it. When a session endscrashed, by clean shutdown while unwinding or as reported from a marker withpanickedset, the panic that ended it is taken off again:crashedalready says so, and it is not counted twice.- The time one tick may add to the session’s totals is capped at five tick intervals, and at no less than a minute. A longer gap means the machine was asleep (some monotonic clocks keep counting through sleep), and that time is not credited as focused, active or idle.
- An orphan (§3.2) that still has its marker belonged to a process that ended uncleanly. The process that adopts it writes into that folder, under that session’s header: a
session_endwith statuscrashedif the marker’spanickedflag was set, elseabnormal, with the last saved totals; and, when not panicked, acrashof kindunclean_exitcarryingsignals::saved_breadcrumbs(<that folder>). Both carry the time of the marker’s last rewrite, or the present if the marker claims a later time. It then removes the marker, then the saved breadcrumbs, and sends the folder’s items like any other. The breadcrumbs are deleted last on purpose: if the adopter dies after reading them and before the report is on disk, the next adopter still finds them. - An unclean end is reported once: only the lock holder reports, and the marker is removed after the report is on disk. The ids of the two items are derived from the session id, so if the adopter itself dies between those two steps and the report is written again, the server stores it once.
- Short-lived processes (
ProcessKind::Cli,Child) behave the same; they should callflush_blockingbefore exit. Sink::session_summary()gives the local report the install id, session id, start time, running totals and error count.
3.6 Event catalog
The host declares events once:
ashlar_trace::events! {
pub enum Category { File = "file", Edit = "edit" }
pub enum AppEvent {
FileOpen = "file.open" in File,
Undo = "edit.undo" in Edit,
}
}
- A declarative macro; no procedural macro crate.
- It generates both enums (
Copy,Clone,Debug,PartialEq,Eq,Hash), and implementsEventCategoryandEventwithALLlists. - At build time, through
constassertions usingashlar_trace_proto::validate_nameandconst_str_eq, it rejects an invalid name and a duplicate name within either enum.trybuildcompile-fail tests cover both. listen(category),listen_events(&[..])andlisten_all::<AppEvent>()choose what is recorded, and each declares the catalog itself, so their order does not matter and no separate call is needed.listen(category)reaches the events throughEventCategory::EVENTS, which the macro fills in.Builder::catalog::<AppEvent>()declares the events without listening to any. A hand-writtenEventCategorythat leavesEVENTSempty needs that call;initlogs one warning when categories are listened to and no event is declared.trackdrops an event that is not listened to before it reaches the queue. The check must be cheap: a lookup in a small set built at startup.track_id(category, id)records an id that is not in the catalog, markeddynamic, when its category is listened to. The id is validated withvalidate_nameat run time; an invalid one is dropped and counted.- Properties: at most
MAX_PROPS, keys validated like names, string values&'static strof at mostMAX_PROP_STR_LENbytes. Anything over a limit is dropped and counted. session_startalways carriescatalog_hash(a stable hash of the sorted catalog),listeningandsignals. It carries the fullcataloguntil the server has acknowledged that hash for this app version; an ingest reply withneed_catalog: trueclears the acknowledgement so the next session resends it.
3.7 Queue, spool and sending
submitdoestry_sendon a bounded channel ofqueue_capacity. When full it drops the item and countsqueue_fullwith one atomic add. It never blocks and takes no lock.trackhands the worker exactly what it was given: the two&'static strnames, a timestamp, and the properties copied into one boxed slice (no allocation at all without properties). Checking the limits, building the wire event, assigning the id, serialising and writing are done by the worker.frameis three atomic operations on a histogram of atomics. No call on a host thread takes a lock the worker holds across disk or network I/O.Builder::initdoes only what must precede the first call: creates the folders, reads the three small state files, takes the session folder’s lock, writes header and marker and thesession_start, and starts the worker. Looking through what earlier runs left behind, however much, is the worker’s first job.- When the worker drains the queue for a flush or at shutdown it takes at most one queue’s worth, so host threads that keep the queue full cannot keep it from sending, ticking or answering the flush. At shutdown the drop counts are written to the spool whether or not anything can be sent, so they are not lost with the process.
- One worker thread drains the queue, appends items to its own session’s spool segment, and ticks every
tick_interval(1 s by default): session accounting, re-reading the shared consent and install files,Signals::tick. Every other interval is effectively rounded up to a multiple of the tick. submit_nowserialises the item and appends it to the spool on the calling thread, without going through the queue. It is used from the panic hook, so it must not panic, must tolerate a poisoned lock, and gives up (countingspool_busy) rather than wait on a lock the panicking thread may itself hold.- The spool is capped by
spool_max_bytesandspool_max_age, across all session folders: the folders of other live processes count towards the size, as of the last look at them. To make room a process evicts the oldest segments it may touch (adopted orphans first, oldest session first, then its own oldest) and never anything of another live process; if that is not enough, the new item is dropped. Evicted and expired items are counted asspool_evicted. - The worker sends when
batch_itemsare waiting orflush_intervalhas passed, and once at startup for anything earlier runs left. It sends the orphans it has adopted, oldest session first, then its own session. One request per session folder, at mostMAX_ENVELOPE_BYTESafter serialisation; larger backlogs are split. What a request carried is deleted when the server has answered for it, and an orphan’s folder is deleted when it is empty. - Bodies are gzip-compressed JSON, at the fastest level: on a 1 MiB batch of events it takes an eighth of the time of the default level for 5% more bytes.
- Reply handling follows the table in
docs/wire-format.md. Retries use exponential backoff with jitter, fromretry_min(5 s) doubling up toretry_max(15 min), each wait a random point in the upper half of its step. ARetry-Aftergiven in seconds is honoured, never for less thanretry_min(soRetry-After: 0does not mean “on every tick”) and up to a day; an explicit flush skips the backoff but never the server’sRetry-After. - Only an ingest reply that parses says anything about the catalog. A 2xx with an empty or foreign body counts as delivered but neither acknowledges the catalog nor asks for it.
- A segment is read as bytes and each line is judged on its own. A tail without a newline (a write cut short by a crash) and a line that is not valid UTF-8 are skipped and counted as
spool_damaged; the lines around them are sent. A segment that cannot be read at all is left out of the batch and stays on disk; it does not hold up the sessions after it. - No interval can overflow the clock:
tick_intervalis clamped to at most an hour and the retry waits to at most a week when the worker starts, and aflush_blockingtimeout orshutdown_timeouttoo long to add to the clock means “no deadline”. - Drop counts are sent as a
dropsitem with the next batch, while at least one consent category is allowed. The reasons are:queue_full,spool_evicted,spool_busy,item_too_large,io_error,server_rejected(a batch the server refused with 400, 413 or 422),spool_damaged(a stored line that was cut short or is not valid UTF-8),forget_rejected,invalid_id,prop_dropped,tick_panicked, and from the signal modulesbreadcrumbs_write. - With no endpoint configured the SDK still spools (within the caps), still adopts orphans, and inspect mode still works, but nothing is sent. This holds even when the host supplies its own
Transport. flush_blocking(timeout)asks the worker to drain and send now and waits for it. It is true when this process has nothing left that it could send.shutdowndoes the same withshutdown_timeout, then stops the worker.- The built-in transport is
ureq3 with rustls, behind thehttpfeature, with connect and overall timeouts and redirects off. Hosts and tests can supply their ownTransport.
3.8 Signals
| Module | Behaviour |
|---|---|
crash | A panic hook that chains to the previous hook. It captures message, location, thread name and Backtrace::force_capture, scrubs them, attaches breadcrumbs and the last perf window, calls submit_now with a crash of kind panic, then count_error and mark_panicked. It must never panic itself and must guard against re-entry. report_handled submits kind handled and calls count_error. report_child_exit submits kind child_exit with exit code or signal, only for an unsuccessful status. fatal is set where it can be known: true on a child_exit and on the unclean_exit the pipeline writes for a dead session; false on handled and hang; and false on a panic at capture, because the hook cannot know whether the host catches it. The server marks a panic fatal when its session ends crashed. |
hang | A watchdog thread, driven in one of two ways. Turns (preferred): begin_turn() when the main loop starts handling something and end_turn() when it is done, or turn() for a guard that does both. A turn open for longer than hang_threshold is a hang; the time between turns, when the loop waits for work, never is. This is the only way that works for a loop that runs only on input or that a GUI framework owns. Heartbeats: heartbeat() once per iteration of a loop that runs continuously; a last beat older than the threshold is a hang. Once a host has begun a turn, heartbeats no longer count for that process. Each call is one atomic update. A second begin_turn while a turn is open keeps the original start; end_turn without an open turn does nothing. Either way one crash of kind hang is submitted per hang, with blocked_ms as observed so far, and nothing more until the next turn or beat. Watching starts with the first turn or beat, so hosts that call neither get no reports. pause/resume nest; resuming restarts the open turn or the last beat, and stores that before lifting the pause. If the watchdog finds far more time has passed than its own poll interval, it was suspended with the rest of the machine: it restarts the measurement instead of reporting. |
perf | frame(duration) records into a FrameStats histogram behind a lock that is held only for the update. first_frame() records startup time since init once. Every perf_window the tick submits one perf item if it has anything to report, and keeps a copy as “last perf” for crash reports. |
sys | With the sysinfo feature: this process’s CPU and memory every sys_interval, folded into the perf window (mean CPU, last and peak memory), and static device details once for the envelope header. Without the feature, those fields are absent. |
logs | A log::Log that keeps the last breadcrumb_capacity lines at or above breadcrumb_level in a ring, scrubbed, and forwards every record to the wrapped logger if there is one. LoggerMode::Install installs it globally; otherwise the host wraps its own logger with Tracer::logger. The ring is saved to breadcrumbs.jsonl in the session folder on the tick when it changed. Log lines leave the device only inside a crash. Lines with the SDK’s own target, ashlar_trace, are forwarded but not kept. |
rt | Counter and Gauge are Arc<AtomicU64>. Registration allocates and locks; recording is one atomic operation. The tick reads them into the perf window (counters as the increase since the last window). |
scrub | Replaces the home directory with ~, removes the account name from paths, truncates long values (messages 2 KiB, backtraces 32 KiB, log lines 512 bytes). |
report | write_report(path) writes a plain-text report: app, version, device, session ids and totals, last perf window, recent breadcrumbs. |
Known limit for 0.1: a backtrace from a release build stripped of symbols contains unresolved frames. Symbolication arrives with native crash support.
3.9 Threads
| Thread | Does |
|---|---|
| Host threads | Call Tracer methods; each is an atomic store, a short lock, or a try_send. |
tracer-worker | Drains the queue, writes the spool, ticks, adopts orphaned session folders, sends. The only thread that touches the network. |
tracer-watchdog | Hang detection only. Started when the Hang signal is enabled. |
4. Server
4.1 Shape
One binary, ashlar-trace-server, with subcommands:
| Command | Does |
|---|---|
serve | Runs the HTTP server, the rollup timer and the retention timer. |
app create <slug> <name> | Creates an app and prints its first write key. |
app list | Lists apps. |
key create <slug> / key revoke <key> | Manages write keys. |
admin create <username> | Creates a dashboard login; reads the password from TRACER_ADMIN_PASSWORD or stdin. |
prune | Runs retention once. |
rollup [--days N] | Recomputes rollups once. |
Settings come from the environment (all optional):
| Variable | Default | Meaning |
|---|---|---|
TRACER_BIND | 127.0.0.1:8090 | Listen address |
TRACER_DATA_DIR | ./data | Folder for the database |
TRACER_RETENTION_DAYS | 90 | Age at which raw rows are pruned |
TRACER_RATE_PER_MIN | 120 | Requests per minute per client address on the public routes (ingest, forget, health), unknown keys included; 0 switches the limit off. Dashboard sign-in has its own limit, which stays on |
TRACER_KEY_RATE_PER_MIN | 6000 | Ingest requests per minute for one write key over all addresses: a capacity guard far above the per-address limit; 0 switches it off |
TRACER_MAX_DB_MB | 10240 | Ingest answers 507 once the database is larger than this many MiB; the dashboard and sign-in keep working; 0 switches the ceiling off |
TRACER_TRUST_PROXY | unset | When 1, take the client address from the last X-Forwarded-For entry of the last such header line |
TRACER_ADMIN_PASSWORD | unset | Password for admin create; read by that command only |
TRACER_SECURE_COOKIES | on | Cookies are Secure and the session cookie uses the __Host- prefix. Set 0 to allow plain-HTTP use on a local machine |
4.2 Storage
- SQLite in WAL mode, schema in
migrations/, applied at startup and tracked withPRAGMA user_version.0001_initis the schema;0002_dashboardadds indexes for the dashboard queries;0003_hardeningmakes catalogs additive (catalog_events.hash), addsevents.declared,daily_events.declared,sessions.catalog_hashandissues.created_day;0004_scaleadds the indexes the large-database measurements called for (sessions_by_version, the partialperf_with_metrics),daily_active(daily, weekly and monthly active installs) anddirty_days. - All access goes through a
Storetrait so a second backend can be added. - Writes: group commit. Ingest requests go through a bounded queue (256 envelopes) to one
tracer-writerthread. It takes whatever has queued up (at most 64 envelopes), stores them in one transaction, commits once and only then answers each caller, so a request is acknowledged only after its data is committed. When the queue is full the answer is503withRetry-After: 1: memory cannot grow, and the client keeps its data. If a group fails, its envelopes are stored one by one, so a bad envelope cannot take the others down; a failure that concerns the whole database (locked past the busy timeout, disk full, read-only) is reported at once instead (503for a lock,500otherwise). - Durability. WAL with
synchronous = NORMAL: a commit is in the log file but not fsynced. A crash orkill -9of the process loses nothing that was acknowledged; a power failure or operating-system crash can lose the last moments of acknowledged data (the database stays consistent, never half-written). A separatetracer-checkpointthread folds the log into the database file every second, so the writer never pays for that fsync inside a commit; the log is allowed to grow to about 200 MiB before the writer would checkpoint it itself. - Reads use a small pool of
query_onlyconnections and never wait for the writer (WAL). - Signals.
SIGINTandSIGTERMboth stop the server gracefully: it stops accepting, lets in-flight requests finish (up to 10 s) and exits with status 0. - Conventions are in the header of
0001_init.sql.
4.3 Ingest
POST /v1/ingest, in order:
- Take one request from the client address’s token bucket (in memory, bounded, IPv6 by /64) before anything else, so unknown keys and the health route are limited too; over the limit is 429 with
Retry-After. The address is used here, for the sign-in limit and nowhere else. A request the address bucket refuses does not touch the key bucket. - Look up
x-tracer-key; unknown or revoked is 401. Then take one request from the key’s own bucket (TRACER_KEY_RATE_PER_MIN, a capacity guard); over it is 429. - When the database is over
TRACER_MAX_DB_MB, answer 507. The client keeps its data and retries. - Read at most
MAX_ENVELOPE_BYTESafter gunzip; larger is 413. Parse; failure is 400. Aschemaabove the server’s is 422. - In one transaction:
- Upsert the install (first/last seen, versions, device).
- Upsert the session only when the envelope carries a session item (
session_start,session_heartbeatorsession_end). Crashes, performance windows and events keep theirsession_idas plain text on their own rows, so a user who switched session reporting off never creates a session that the stale closer would turnabnormal. - For each item, skip it if
(app_id, id)is initems_seen(a duplicate), else store the item by type and record the id:session_start: remember the namedcatalog_hashon the session. A carried catalog is stored next to the others, keyed by(app, version, hash), never in place of one (at most 8 per version and 200 per app); a hash the server does not hold and did not receive setsneed_catalog.session_heartbeat: raise the session totals (never lower them) andlast_seen.session_end: raise the totals and set the status, unless the stored status is already terminal. When the status iscrashed, the session’s most recentpaniccrash is markedfatal.event: validate the name. An event is never refused for being undeclared: the catalog is client input, and a refused event is deleted by the client. A non-dynamic name is stored withdeclared = 1when it is in any catalog held for that app version (which includes the one the session’ssession_startnamed), elsedeclared = 0. With no catalog held it isdeclared = 1andneed_catalogis set.perf: store.crash: compute the fingerprint, upsert the issue, store. At most 20 crashes are taken per envelope (the rest count as rejected). An app creates at most 200 new issues per day; further new fingerprints share one “Too many distinct crashes” issue for that day, so nothing is dropped silently. Apanicis markedfatalwhen its session has endedcrashed(in either arrival order; the SDK cannot know at capture time whether the host survives). A fatal panic from the client also marks anoksessioncrashed.drops: add todaily_drops. Each count is clamped to 1,000,000, reasons must be valid names, at most 64 distinct reasons are kept per app per day (the rest count asother) and a stored total is capped.- unknown types: skip, count as rejected.
- Item times are corrected for clock drift by
received_at - sent_atwhen the difference is over five minutes, then clamped to the retention window in the past and to one hour in the future. Item ids longer than 64 bytes are rejected.
Fingerprint: kind, plus the first frames of the backtrace that belong to the app (skipping std, core, alloc, panic and backtrace machinery, and with hex addresses and hash suffixes removed); when there is no usable backtrace, the location; when there is neither, the message with digits removed. The issue title is the message’s first line, or the kind.
POST /v1/forget deletes the install’s rows from installs, sessions, events, perf, crashes and daily_installs, and removes issues left with no crashes. It answers 200 {"ok":true} whether or not the install existed, so it cannot be used to probe for install ids.
Connections. The server runs hyper directly. A client has 15 seconds to send a request’s headers, which also bounds an idle keep-alive connection, and at most 1,024 connections are served at once; further clients wait in the accept queue. The body of a request must arrive within 30 seconds.
4.4 Rollups and retention
- Ingest marks every (app, day) it stores data for in
dirty_days. A timer (every five minutes) recomputes thedaily_*tables for exactly those days, oldest first, and clears each mark unless new data arrived meanwhile;rollup --days Nrecomputes a fixed window by hand. Late data for an old day (a client replaying a spool) is therefore rolled up too. - One day of one app is built on a private connection that only reads the raw tables and collects the results in an in-memory scratch database; the write is then one short transaction that replaces that day’s rows (for the install list only the difference is applied). Ingest is never held up by the aggregation itself.
daily_activeholds the daily, weekly and monthly active installs per day. The weekly and monthly figures cost a distinct count over up to 30 days, so they are computed for the newest two days only (older days store-1, and readers fall back to counting).- Per app, only the 50 busiest versions, 20 busiest operating systems and 500 busiest event names keep their own rollup rows; the long tail is folded into
other, so made-up values cannot grow the rollups. daily_eventsis keyed bydeclaredas well: declared and undeclared uses of one name are separate rows.- Retention deletes raw rows older than
TRACER_RETENTION_DAYS,daily_dropsand catalogs not seen within that time, anditems_seenolder than 35 days, then lets SQLite reclaim space. Rollups other than drops are kept. Big tables are deleted in chunks of 2,000 rows, each its own short transaction with a pause of three times the chunk’s duration, so ingest keeps getting the writer; space is returned 2,000 pages at a time. - Sessions still
okwhoselast_seenis over a day old are closed asabnormal.
4.5 Dashboard
Server-rendered HTML with maud, charts as inline SVG generated on the server, one small embedded stylesheet, no script build. Login is a username and password (argon2), with a random session token in an HttpOnly, SameSite=Lax cookie; only its hash is stored. Every state-changing form carries a CSRF token. The login cookie lasts 14 days. The login form itself is protected by a short-lived SameSite=Strict cookie paired with a hidden field, and by a limit of 10 attempts a minute per address. Dashboard responses carry Content-Security-Policy: default-src 'self' (no inline script or style), X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer and Cache-Control: no-store.
| Page | Shows |
|---|---|
| Overview | Per app: crash-free sessions and installs, daily opens, daily / weekly / monthly active installs, newest issues. |
| Crashes | Issue list with counts, affected installs and versions; filter by kind, version, status. Issue page with backtrace, breadcrumbs, device, last perf window, and resolve / ignore. |
| Performance | Frame-time percentiles (from merged histograms), slow and frozen frame rates, startup time, CPU and memory, by version and by operating system. |
| Usage | Active installs, sessions per day, session length, focused / active / idle time, events ranked by use, and declared events that were never fired. |
| Reports | A stability and usage report for one release: summary, top issues, performance, feature use. Download as HTML, Markdown or JSON. |
| Settings | Apps and write keys. |
Definitions:
- Crash-free sessions = 1 − (crashed + abnormal) ÷ sessions that have ended.
- Crash-free installs = 1 − installs with a crashed or abnormal session ÷ active installs. Counted per day and summed over the selected range (install-days), because rollups cannot give distinct installs across a range.
- Daily opens = sessions started that day with process kind
gui. Kept in the rollup asdaily_sessions.opens, since raw sessions are pruned.
5. Later phases
See ROADMAP.md. The designs above leave room for them: the Store trait for Postgres, the Transport trait for the web target, and an attachment route for minidumps.
Wire format
Schema version 1. The types live in crates/trace-proto; the fixtures in its tests/fixtures/ are the reference examples.
Transport
| Ingest | POST /v1/ingest, body is one envelope as JSON, usually Content-Encoding: gzip |
| Forget | POST /v1/forget, body {"schema":1,"install_id":"…"} |
| Health | GET /v1/health |
| Auth | x-tracer-key: <app write key> on ingest and forget |
| Limits | 1 MiB per body after decompression |
Replies to ingest are JSON: {"accepted":n,"duplicates":n,"rejected":n,"need_catalog":bool}. rejected counts items the server refused as malformed (a bad name, an unknown type, an over-long id, more than 20 crashes in one envelope); an event is never refused for not being in the catalog. POST /v1/forget replies 200 {"ok":true} whether or not the install existed.
The server limits requests per client address (429), and the address is used for that and nothing else.
| Status | Meaning | Client does |
|---|---|---|
| 200 | Stored (see counts) | Deletes the batch |
| 400, 413, 422 | Malformed or too large | Deletes the batch and counts a drop |
| 401, 403 | Unknown or revoked key | Keeps data, stops sending until restart |
| 429 | Rate limited | Waits for Retry-After, then retries |
| 507 | Server storage is full | Keeps data, retries with backoff (like any 5xx) |
| other 5xx, network error | Server trouble | Retries with backoff |
Envelope
One envelope carries items from one session of one install. Data from an earlier session, such as a crash found on the next launch, goes in its own envelope with that session’s header.
| Field | Type | Notes |
|---|---|---|
schema | integer | 1 |
app | object | name, version, optional build, process (gui, cli, service, child) |
install_id | string | Random, resettable. Never derived from hardware. |
session_id | string | One per process run |
device | object, optional | os_name, os_version, arch, cpu_brand, cpu_cores, ram_mb, gpu, locale |
sent_at | integer | Client clock in Unix milliseconds, for drift correction |
items | array | See below |
Every item has id (unique, client-generated, at most 64 bytes; the server ignores an id it has already stored), ts (Unix milliseconds) and type. The server moves ts onto its own clock when the client clock is more than five minutes off, and clamps it to the server’s retention window in the past and one hour ahead.
The server creates or updates the session record only for envelopes that carry a session_start, session_heartbeat or session_end. Events, perf, crash and drops in an envelope without one are stored with their session_id as plain text, so a client that has switched session reporting off creates no session.
Item types
type | Fields |
|---|---|
session_start | catalog_hash, catalog (events[] of name, category), listening[], signals[] |
session_heartbeat | seq, duration_s, focused_s, active_s, idle_s — running totals |
session_end | status (exited, crashed, abnormal), the same totals, errors |
event | name, category, dynamic, props (bool, integer, float or short string values) |
perf | window_s, frames, cpu_pct, rss_mb, rss_peak_mb, startup_ms, counters, gauges, incidents[] |
crash | kind, fatal, message, location, thread, backtrace, blocked_ms, exit_code, signal, context, breadcrumbs[], perf |
drops | reasons — what the client discarded, by reason |
crash.fatal. A client cannot know at capture time whether a panic ends the host, so it sends fatal: false for panic, handled and hang, and fatal: true for unclean_exit and child_exit (which are found after the fact). The server decides for panics: when a session ends with status: "crashed" (a session_end, in the same or a later envelope, before or after the crash item arrives), that session’s most recent panic is stored as fatal. A fatal: true panic from a client also marks its ok session crashed.
Catalog. session_start.catalog is client input and is treated as unverified: catalogs are stored next to each other by catalog_hash, never replacing one. Each stored event carries a declared flag, set when its name is in a catalog held for the app version. declared = false events are kept and shown separately; they are not an error.
crash.kind is one of panic, handled, hang, unclean_exit, child_exit.
crash.fatal says the process could not continue. The client sets it where it can know: true for unclean_exit and child_exit, false for handled and hang. A panic is sent with fatal: false, because at capture the client cannot know whether the host catches it; the server marks it fatal when the session it belongs to ends crashed.
session_end.errors counts handled errors and panics the process survived. The panic that ends a crashed session is not included.
perf.frames is a histogram: count, sum_us, max_us, slow (over 16.667 ms, the 60 Hz frame budget; slow includes frozen), frozen (over 700 ms), buckets, one count per upper bound in FRAME_BUCKET_BOUNDS_US plus an overflow bucket, and the optional over_budget (over 8.333 ms, DISPLAY_BUDGET_US, the 120 Hz frame budget; it includes slow). Histograms merge by adding buckets.
over_budget was added within schema 1. Clients that predate it leave it out, and older servers ignore it. Because 8.333 ms is one of the bucket bounds, a reader counts it from the buckets when it is missing (FrameStats::over_budget_frames), and the server always counts it from the buckets it stores, so old and new clients report the same figure.
perf.incidents lists the incident rules that fired in the window, at most one entry per rule: rule (long_frames: 3 frames over 8.333 ms within 1 s; stall: one frame of 250 ms or more; memory_growth: 5 rising per-minute memory floors, the last at least 64 MiB and 20% above the first), at_ms (when it first fired, in milliseconds since process start), count (how often it fired in the window) and value (the worst figure: the slowest frame in microseconds for long_frames and stall, the growth in MiB for memory_growth). It was added within schema 1: clients that predate it leave it out, older servers ignore it, and readers treat an unknown rule as other.
Names
Event and category names are lower-case snake_case, optionally dotted (file.open), start with a letter, and are at most 64 bytes. The same rule is checked at build time in the SDK and again by the server.
Compatibility
- Fields are only added, and new fields are optional.
- Readers ignore unknown fields, and treat an unknown item
typeas something to skip. - Unknown enum values map to
otherwhere the enum has one. - A breaking change increases
schema.
Telemetry (trace) roadmap
Status, 2026-09-09: release candidate, load-tested. The SDK (ashlar-trace), server
(ashlar-trace-server), dashboard, demo hosts and guides are built; the SDK is tested end to end
against the real server; a bug review of the SDK and a security review of the server are done and
their findings fixed. Moved into ashlar with its history; apps use it through ashlar-telemetry.
Not yet published.
Version 0.1
| Id | Milestone | State |
|---|---|---|
| M0 | Foundation: workspace, gate, wire format with fixtures, SDK and server skeletons, schema, docs | Done |
| M1 | SDK pipeline: consent, identity, session, queue, spool, transport, event catalog, usage | Done |
| M2 | SDK signals: panic hook, handled errors, unclean exit, child exit, hang watchdog, frame and system metrics, breadcrumbs, local report | Done |
| M3 | Server core: ingest, write keys, rate limits, dedup, SQLite store, rollups, retention, admin commands | Done |
| M4 | Dashboard and reports: overview, crashes, performance, usage, per-release report export | Done |
| M5 | Demo hosts and end-to-end tests | Done |
| M6 | Guides and packaging: integration guide, self-hosting guide, container image | Done |
| M7 | Hardening and 0.1.0 release | Release candidate |
| M8 | Tests, stress and load: edge-case and fuzz suites, SDK hot-path and server ingest optimisation, large-database read path | Done; two load targets missed (below) |
Still open
- Find why ingest latency collapses at 2,000 envelopes a second once the database passes about 3 GB. Suspected: index inserts outgrowing the cache; not proven.
- Cut ingest tail latency while retention deletes a large backlog (p99 about 300 ms against about 50 ms).
- Repeat the load runs on Linux and on a quiet machine; all numbers so far are from one busy macOS machine.
- Build and run the container image once (
deploy/trace/Dockerfile,deploy/trace/compose.yml). Written but never built. - Run the windowed demo (
examples/trace-demo-gui). It compiles and passes lints but has never been opened. - Check the dashboard on a narrow (360 px) screen.
- Check the login cookie (
Secure,__Host-prefix) in a browser over real HTTPS. - Decide whether to prove “no allocation” for the realtime counters, which needs an
unsafeexemption for one test file.
Later
| Milestone | Notes |
|---|---|
| Native crashes | Out-of-process monitor, minidumps, symbol upload and server-side symbolication, in an isolated crate that is the only one allowed unsafe. |
| Postgres | A second Store implementation for larger deployments. |
| Web target | A transport for wasm32, where the SDK is currently a no-op. |
| Alerts | Email or webhook on a new issue or a crash-rate spike. |
| OTLP export | Forward stored data to OpenTelemetry collectors. |
Releasing
Every crate in crates/ is published to crates.io with the same version (the workspace
version in the root Cargo.toml). The umbrella crate is published as ashlar-rs, because
the ashlar name on crates.io belongs to an unrelated project; its library name is still ashlar,
so apps write use ashlar::prelude::*. Examples and xtask are publish = false.
One-time setup
- On crates.io, create an API token with the
publish-newandpublish-updatescopes. Pick no expiry (or a long one), so it is set once. - Store it as the repository secret
CARGO_REGISTRY_TOKEN:
(paste the token when asked). The token never needs to be on a developer machine.gh secret set CARGO_REGISTRY_TOKEN -R Himasnhu-AT/ashlar - Optional, later: switch to crates.io trusted publishing and delete the token.
What triggers a release
Only pushing a tag of the form vMAJOR.MINOR.PATCH (for example v0.2.0). Nothing else
publishes: pushes to main, pull requests and Dependabot never do, and running the release
workflow by hand (Actions → release → Run workflow) is always a dry run.
Before anything is uploaded, the workflow refuses to go on unless:
| Guard | Why |
|---|---|
the tag equals v + the workspace version | no mismatched or mistyped tags |
the tagged commit is on main | nothing from an unmerged branch |
| the full CI gate passes on that exact commit (all three OSes, MSRV) | no broken or half-done state |
CHANGELOG.md has a ## [x.y.z] - YYYY-MM-DD section | every release is documented |
that version of ashlar-rs is not on crates.io yet | no accidental re-release |
Then it runs cargo publish --workspace --locked (every crate in dependency order), skipping the
crates that already have this version on crates.io, and creates the GitHub release with that
version’s changelog section as the text. crates.io lets an account create only a few new crates at
once and then about one every 10 minutes, so a release that adds new crates (the first one adds
all of them) waits and tries again until every crate is there; that can take a few hours. Any
other publish error stops the job.
Cutting a release
- Make sure
mainis green on CI and everything for the release is merged. - Run
cargo xtask release x.y.z(add--date YYYY-MM-DDto date it other than today, UTC). It bumpsversionin[workspace.package]and every internal crate’sversion = "…"in[workspace.dependencies]together, renames “Unreleased” inCHANGELOG.mdto## [x.y.z] - YYYY-MM-DDunder a fresh empty “Unreleased”, updates the compare links at the bottom, and runscargo checkto updateCargo.lock. It refuses when the versions disagree, the section already exists or “Unreleased” is empty, and it never commits, tags or publishes. - Read the diff; bump any version shown in docs snippets (for example
docs/trace/integration.md). - Check that everything packages:
cargo publish --workspace --locked --dry-run. - Commit to
mainand push. Wait for CI to pass. - Tag and push the tag:
git tag -a v0.2.0 -m "ashlar 0.2.0" git push origin v0.2.0 - Watch the
releaseworkflow. If a guard fails, nothing was published: fix the cause, delete the tag (git push origin :refs/tags/v0.2.0,git tag -d v0.2.0) and tag again.
If publishing stops halfway (a network error, a timeout), crates.io keeps the crates that made it.
Fix the cause and run the failed publish job again from the workflow run: it publishes only what
is missing. If the fix changes the workflow itself, a re-run still uses the old one; push the fix
to main and move the tag to that commit (git tag -fa vX.Y.Z -m "ashlar X.Y.Z",
git push -f origin vX.Y.Z). That is safe only while the crates’ own files are unchanged, since
the published crates came from the old commit.
Versioning
ashlar follows Semantic Versioning. Before 1.0, a minor version (0.x) may
break the API; patch versions never do. The items marked CONTRACT in ashlar-command change only
additively.
Security policy
Supported versions
ashlar is in early development. Security fixes go into the latest released minor version and
into main.
| Version | Supported |
|---|---|
| 0.1.x (latest) | yes |
| older | no |
Reporting a vulnerability
Please don’t report security vulnerabilities in public issues, discussions or pull requests.
Report them privately through GitHub: open a private security advisory.
Please include:
- the affected crate(s) and version or commit
- a description of the issue and its impact
- steps to reproduce, or a proof of concept
- any suggested fix
You can expect an acknowledgement within a few days. We’ll keep you updated while we work on a fix, and credit you in the advisory unless you’d rather stay anonymous. Please give us reasonable time to release a fix before you disclose the issue publicly.
Scope
Areas where security matters most in ashlar:
- the agent control port (
ashlar-control) and MCP over HTTP (ashlar-mcp-http): authentication, Host and Origin checks, input limits - downloading and verifying integrations (
ashlar-integrations) and update checks (ashlar-update) - telemetry (
ashlar-trace,ashlar-trace-server): data scrubbing, ingest limits, the dashboard - parsing of untrusted documents and files
Dependencies are checked against the RustSec advisory database on every CI run.
Changelog
All notable changes to ashlar are documented in this file.
The format is based on Keep a Changelog, and this project follows Semantic Versioning. Before 1.0, a minor version (0.x) may contain breaking changes.
Unreleased
Added
- Documentation site (
site/, mdBook, deployed at https://ashlar.himanshuat.com): a user guide (first app, documents, commands, views, jobs, native features, agents, testing, web, a cookbook, an FAQ, building with an AI assistant), the developer docs and this changelog, plusllms.txt,llms-full.txtand each page’s markdown for AI agents.
0.2.0 - 2026-11-11
Added
-
cargo xtask release <x.y.z> [--date YYYY-MM-DD]: bumps the workspace and internal crate versions together, dates the changelog’s “Unreleased” section, updates the compare links and refreshes Cargo.lock (docs/releasing.md step 2). It never commits, tags or publishes. -
ashlar_ui::sidebar(IMP-5.14): a source list of folding sections with counts and rows with an icon, detail and badge, built onlist::Rowand virtualized. One Tab stop: arrows, Home/End, Left/Right to fold, Enter to activate, F2 to rename in place, type-ahead; click, double click and context clicks reported inSidebarResponse. Automation ids<id>,<id>.section.<s>,<id>.section.<s>.item.<i>,<id>.rename; labelsUiLabels::sidebar(ui.sidebar.rename,ui.sidebar.items, English and German).list::Rowgainsicon,detail,badgeandfocusable. The gallery has a Sidebar page. -
CodeView and DiffView follow-ups (IMP-5.20): unwrapped long lines scroll sideways (horizontal wheel or Shift+wheel, and a draggable bar along the bottom; a split diff’s sides move together); both views register the new automation role
Role::Document("document"inui.inspect). The gallery has a Code page. An open dialog no longer copies its shapes for the exit animation on frames where they did not change, and reuses the copy’s buffer when they did (perf_dialog_open_frame_allocs: per-frame overhead over a plain area 173 → 21 allocations, 24.9 → 5.5 KB). -
Controls follow-ups (IMP-5.19):
controls::Skeletonplaceholder rows (pulse while visible, static and frame-free under reduced motion, a busy progress node named by the newUiLabels::controls.loading, English and German);content::Accordionkeeps at most one section open;Segmented::disabledandRadioGroup::disableddim options that cannot be picked and that the keys skip; the⇧keycap is drawn as an outlined arrow (the default fonts lack the glyph). Visual regression adds reference-painter pairs for the progress circle and the spinner’s static poses. -
Toasts (IMP-5.17): F6 moves keyboard focus into the newest toast (its first action, else its close button) and back to where it was;
Toasts::take_actions_wheredrains only the actions a caller accepts;App::run_toast_actions(true)makes the runtime run a clicked toast action whose command is registered (other actions stay for the view). The gallery’s Overlays page demos action and progress toasts. -
ashlar-rs: thetrayandhotkeyfeatures pass through toashlar-app, so an app on the umbrella crate can useApp::trayandApp::global_shortcut. -
Windows menu shortcuts (IMP-6.17):
NativeMenuBar::attach_windowsubclasses the window and runsTranslateAcceleratorWwith the bar’s table on key-downs, so a native menu’s key equivalents fire as item activations (once; the key press doesn’t also reach the window). Compile-checked forx86_64-pc-windows-msvc; not run on Windows yet. -
Context menus and drag-out (IMP-6.15):
ashlar_native::menu::popup::show(window, &spec, at, waker)opens aMenuSpecas a native context menu (macOS: posted to the run loop so it never opens inside the UI pass; Windows: opens at once, call it between frames) and reportsSome(id)orNoneon a receiver.ashlar_native::drag_out::start(window, DragItem { files, png, text }, on_end)(featuredrag-out, infull) drags files, a PNG or text into other apps on macOS;Unsupportedelsewhere. -
ashlar_native::mac_shell(default featuremac-shell, IMP-6.14):guard_appearance()checks the window delegate’seffectiveAppearancechanges before winit casts them, so a nil appearance while the screen is captured no longer ends the app;listen_reopen(waker)reports Dock reopen clicks;quiet_requested(prefix)reads<PREFIX>_QUIET. An ashlar app installs both after its first frame, shows and focuses its window on reopen, and with<PREFIX>_QUIET=1opens without taking the focus. -
ashlar_native::hotkey(featurehotkey, IMP-6.16): system-wide shortcuts on global-hotkey (macOS, Windows, Linux on X11).GlobalShortcuts::new(waker),register(id, &chord)with the keymap’s chords (a chord without Ctrl, Alt or ⌘ is refused),unregister,drain(); dropping it releases every chord.system::bring_to_front(window)restores and focuses a window through the OS (Windows borrows the foreground thread’s input when the foreground lock refuses), and a second launch on Windows now callsAllowSetForegroundWindowfor the first (its pid travels in the endpoint file).App::global_shortcut(command, chord)(featurehotkeyofashlar-app) runs the command and brings the window forward; forwarded launches and tray clicks also bring it forward through the OS. -
ashlar_native::tray(featuretray, IMP-6.11): a tray / status-bar icon with a menu built from aMenuSpec(macOS status bar, Windows notification area, Linux StatusNotifierItem over D-Bus with no GTK).Tray::new(TraySpec { id, tooltip, icon, template, menu }, waker),events()(Activate,Menu(id)),set_tooltip/set_icon/set_menu; dropping it removes the icon. The menu bar, trays and later popups now share one process-wide menu-event router, so a tray’s items never reach the menu bar.App::tray(spec)(featuretrayofashlar-app) runs the chosen item’s command and shows the window on a click. -
cargo xtask launch --memorylaunches 5 times (--runs N) and prints the min and max idle and peak footprint and how many runs fell in the high group; one launch landed in either of the two macOS groups at random (IMP-2.19). -
ashlar_native::memoryreports the OS’s lifetime peak footprint (ProcessMemory::peak: macOS lifetime max phys footprint, Windows peak private commit) andthread_count()(macOS, Windows, Linux);app.stats→process.peak_bytesuses the OS peak, so spikes between samples count, andprocess.threadsis filled on macOS and Windows too (IMP-6.18). -
ashlar_native::display::monitors()lists the monitors before any window exists (macOS, Windows); a restored window placement is checked against them at launch instead of only on the first frame (IMP-6.18). -
Web (IMP-6.8):
App::canvas_id; when a web app cannot start (no such canvas, no WebGL) a visible error (role="alert") appears next to the canvas or at the top of the page;examples/web-hello(with anindex.htmlfor trunk) is checked for wasm32 bycargo xtask wasm. -
i18n follow-ups (IMP-6.19): plural forms for the toast “+N more” line and the keymap editor’s “N commands share a shortcut” (new key
ui.keymap.shared), throughlabels::Plurals,labels::install_i18n,labels::count_textandlabels::PLURAL_KEYS(the label structs are unchanged);ashlar_native::system::preferred_languages()(macOSNSLocale.preferredLanguages, WindowsGetUserPreferredUILanguages), whichashlar_i18n::system_locales()now appends after the environment’s languages in apps (set_native_languages,with_native). -
Record and replay (IMP-4.11):
ASHLAR_RECORD=path(orRuntime::record_to) records each frame’s time, screen and input events plus the control requests served to a JSONL ring of at most 64 MiB (written off the UI thread), andDriver::replay(path)plays it into a fresh driver; see docs/debugging-uis.md. -
Cxpolish (IMP-3.8):cx.tokens();cx.motion(id, target, curve), which animates unless the document just changed by the user’s own hand;Origin(User,Drag,Undo,Agent,Job) recorded per change (Session::origin,Session::with_origin,Cx::origin), with control requests markedAgentand job appliesJob;ashlar_ui::layout::{row, column, inset}taking atokens::Space,SpaceScale::get;Radii::{sm, md, lg}()asf32. -
Telemetry incident rules (IMP-2.13):
ashlar-traceflags 3 frames over the 120 Hz budget within 1 s (long_frames), a frame of 250 ms or more (stall) and 5 rising per-minute memory floors that grow by at least 64 MiB and 20% (memory_growth), once per rule and window, in the new optionalperf.incidentslist (Incident,IncidentRuleinashlar-trace-proto; older readers ignore it). The trace server counts them asincidents.<rule>metrics and lists them on a crash’s performance panel. -
ashlar_ui::markdownv2 (IMP-5.4): links take keyboard focus (Tab, then Enter or Space opens them) with a focus ring, have the AccessKitLinkrole and automation roleLink(new), and answer the pointer on every row they wrap onto; tables size columns to their content and wrap them down to their longest word, with a “N more rows” footer (<id>.table.<n>.more) pastMAX_TABLE_ROWS(500) instead of dropping rows silently; theMarkdownbuilder adds.on_task(|index, checked| ..)(clickable task checkboxes,<id>.task.<n>),.find(query)(highlighted matches, counted inMarkdownResponse::matches;find_rangesgives the byte ranges),.streaming(true)(an unclosed**,*,~~,`or link at the end is drawn as already styled, so markers never flash; alsoparse_streaming) and.labels(&MarkdownLabels)for “Copy”, “Copied”, “code” and the table footer, by default from the installedUiLabels(ui.markdown.copy,.copied,.code,.more_rows; English and German).MarkdownStylegainsfind_bg. -
ashlar_ui::markdown::show_with_revisionandMarkdown::revision: a caller that keeps a revision of the text skips hashing it each frame. Inline parsing is linear in the paragraph’s length (a 50 KB paragraph:bench: md_long_paragraph_parse, about 0.4 ms), and a drawing snapshot (tests/markdown_snapshot.rs) checks what markdown paints (IMP-5.18). -
Rich clipboard:
clipboard::set_rich/rich(text, HTML, PNG, file lists; read HTML is sanitized byclipboard::sanitize_html, every form size-capped),clipboard::set_secret(text, clear_after)(kept out of clipboard history where supported, cleared only if still ours) and aBackendtrait withMemoryClipboardfor tests. -
Dock ▸ Quit, logout, restart and shutdown go through the “Save changes?” check and flush settings, autosave and telemetry (
ashlar_native::session_end, featuresession-end, on by default): macOS answersapplicationShouldTerminate:later until the user chooses; Windows handlesWM_QUERYENDSESSION/WM_ENDSESSIONand, since it can’t wait for a dialog, keeps unsaved changes for recovery. The flow is the puresession_end::step. -
Gesture handoff: value fields, sliders and knobs keep going when a drag is let go while moving and ease to rest (clamped, snapped; the commit carries the resting value, so one drag is still one undo step);
.fling(false)turns it off.VirtualList::fling(true)drags the list to scroll it, with fling and rubber band past either end. A pause of 40 ms before release, or reduced motion, means no fling. -
ashlar_ui::rolling::RollingText: counters and readouts that roll instead of jumping. Only the changed characters move (prefix/suffix, then LCS for short text), up when.value(..)rises and down when it falls; the width follows;.tabular(true)gives digits one width. Reduced motion crossfades. The accessible value is the whole new text at once; nothing animates once settled. -
Windows reopen where they were:
App::remember_window(bool)(default on) saves position, size, maximized and full screen state in the settings under"window"when they change, and the next launch opens the window there, sizes bounded and a window left on no monitor moved back (ashlar_app::window_state::clamp).App::viewport(|builder| …)changes the window builder (minimum size, decorations…) andApp::icon(png_bytes)sets the window icon. -
ashlar-i18n(new crate, rank 0, builds for wasm32): translations without a full Unicode library. Flat JSON catalogs per locale, parsed on first use and layered (later catalogs override key by key); a bad catalog is reported and acts as empty, never a panic.I18n::{text, format, plural, integer, decimal, set_locale, negotiate}:{name}templates, CLDR plural categories for about 50 languages (key.one,key.few, …), per-locale group and decimal separators (including Indian grouping), fallback locale → language → source (zh-TW→zh-Hant), POSIX and BCP 47 tags (de_DE.UTF-8→de-DE),system_locales()fromLANGUAGE/LC_ALL/LANG, andI18n::check()for missing keys and mismatched placeholders. -
ashlar_ui::labels: oneUiLabelsinstalled on the context holds every built-in component string (toasts, form, chat, diff view, keymap editor, tab strip, print panel). Components use it unless the app passes its own labels, which still win. English and German catalogs ship for theui.*keys;UiLabels::from_i18nbuilds the labels for any locale. -
Perf warnings in debug builds (
ashlar_app::perf_warn): one stderr line per site when a frame waits (≥ 8 ms wall at ≥ 4× its CPU time; names the command or wake reason) or when 60 identical frames in a row are drawn for non-input wakes.ASHLAR_FRAME_BUDGET_MS=N(any build) prints at most one line a second for frames overNms with the phase split;ASHLAR_PERF_WARN=0silences. -
ASHLAR_FRAME_LOG=FILEandRuntime::log_frames(path): one JSON line per frame (wall and UI-thread CPU time, phases, wake reasons, commands run, pass number), written by a background thread and capped at 64 MiB with a final"end"line (ashlar_app::frame_log). -
app.stats(and the MCPapp_statstool) reportsprocess:footprint_bytes,peak_bytes,cpu_sandthreads(ProcessStats;nullwhere the platform cannot say). -
ashlar-nativenotifications: stable ids (Notification::id; the same id replaces a shown notification),notify::show(returns the id),notify::remove,notify::on_click(aReceiver<Click>with the notification’sinfomap),silent, andnotify::set_app_identity. Windows now shows toasts (AppUserModelID registered under HKCU for unpackaged apps, escaped XML, tags over 64 characters hashed); macOS installs a notification-center delegate so banners show while the app is frontmost and clicks are reported; Linux sends thedesktop-entryhint and reports clicks fromActionInvoked. -
ashlar_ui::chat: a chat kit over plain data.Transcriptis a virtualized, variable-height message list (heights kept per message key, revision and width; a frame costs O(visible)) that follows the newest message while it streams, stays put once the user scrolls up and then offers “Jump to latest”; it is a polite AccessKit log.Messagedraws a role and a markdown body with a streaming state,ToolCarda tool call with folded details and keyboard-reachable Approve/Deny (<id>.approve,<id>.deny), andComposera text field where Enter sends, Shift+Enter adds a line, input-method composition never sends, and@//open aPopupListof suggestions. Strings come fromChatLabels. The gallery has a Chat page. -
ashlar-diff(new crate, rank 0, no dependencies, builds for wasm32): line diffs with Myers’ algorithm under a cost cap (past the cap, a split at lines unique to both sides, then a coarse delete/insert, flaggedcoarse), word-level emphasis for paired changed lines, hunks with context and the unchanged ranges between them (FileDiff::gaps, expandable when the new text is known), andparse_patch/write_patchfor unified andgit diffpatches (renames, copies, modes, binary markers, quoted paths,\ No newline at end of file); malformed patches are an error, never a panic. About 2 ms for a 10k-line file. -
ashlar_ui::diff_view::DiffView: unified and split diffs, virtualized (about 0.4 ms per scrolled frame at 50k lines), syntax-coloured per file language, word-level emphasis, “expand N lines” on collapsed ranges, foldable files, a sticky file header, line selection,N/P(orF7) between hunks. Automation<id>.file.<f>.hunk.<h>and friends for visible rows; AccessKit document, headings and labelled lines. -
app.statsreportslaunch:to_window_ms,to_first_frame_msandlaunch_to_present_ms, counted from the first line ofrun()(FrameStats::launch,LaunchTimes). Native windows only. -
<PREFIX>_READY_FILE=path: the app writes{"pid", "ready_ms"}there atomically once its first frame is presented. -
cargo xtask launchmeasuresexamples/hellolaunch time over 9 launches (p50/p95), andcargo xtask launch --memoryits idle RSS and physical footprint (macOS). Manual, opens windows. -
App::frame_latency(frames): how many frames the GPU may queue ahead of the screen (1 to 3). -
Motion and jank gate for tests:
ashlar_ui::motion_gate::MotionRecorderrecords values animated throughashlar_ui::motionand per-frame automation rects (Driver::recorddoes both for an app), andcheck(&MotionRules)flags overshoot of opacity and colour, reversals without bounce, pops, flicker and frames requested after everything settled, with a per-id allow-list. The gallery’s dialog, toast, theme crossfade and tab switches pass it. -
ashlar_ui::overlay:Select(a button with a list of options),Combobox(a text field whose options are filtered with the command palette’s fuzzy matcher; input-method composition never chooses), andPopover(anchored, flips above at the window edge, Escape or an outside click closes, focus moves in and returns). Option lists are virtualized, fade with theenter_exitmotion token and register<id>.option.<n>with ListBox/option roles. -
ashlar_ui::help(response, text): a tooltip after the newmotion.tooltip_delaytoken, also given to screen readers as the control’s description. -
ashlar_ui::commands::menu::context(response, &spec): a context menu built from aMenuSpec. -
Automation roles
ComboBox,ListBox,ListOptionandTooltip. -
ashlar_ui::form: the settings form pattern. Sections of rows (label, description, control) with a modified marker and reset button, a filter that hides non-matching rows and empty sections, highlights matches and shows “No settings match”, andFieldwith a required marker and an announced error. Row controls get automation ids<section>.<row>; strings come fromFormLabels. The gallery has a Settings page. -
Golden snapshots:
Driver::assert_snapshot(name)and the.uilineassert screenshot <name> [target] [--tolerance channel=N,pixels=N]compare withtests/snapshots/<name>.png, exact by default; a mismatch writes-new.pngand a magenta-diff.png;ASHLAR_UPDATE_SNAPSHOTS=1records baselines with a.meta.json(adapter, backend, scale); a baseline from another adapter is skipped with a note. Screenshot replies from aDrivernow carryadapter,backendandscale(IMP-4.8). -
Named scenes:
App::scene(name, |s| ..),--scene NAME(in a window or--headless),--list-scenes,Driver::scene, theui.scenecontrol method and the MCP toolui_scene(listed only for apps with scenes) (IMP-4.9). -
App::single_instance(true): a second launch hands its arguments to the running app and exits 0; the running app opens the first file withfile.open(a dirty document asks to save first) and comes to the front. Desktop only. -
Files from Finder (macOS
odoc/GURLApple events: a double-click, a drop on the Dock icon) open withfile.openwhile the app runs and at launch. -
App::accept_drops(bool)(on by default when the app sets a format or a file extension): the first dropped file with a known extension opens withfile.open; the others reach the view asCx::dropped_files().Driver::drop_filesandDriver::forward_launchtest both paths. -
app.clockcontrol method:{pause: true},{step_ms}(one frame that much later) and{resume: true}hold the time anashlar-appwindow’s frames see, so an animation can be screenshotted or measured at an exact moment; input still runs while paused and resume does not jump.ashlar-debug clock pause|step <ms>|resume(also in.uiscripts) and the MCP toolui_clocksend it;ashlar_control::{APP_CLOCK, ClockOp}parse it. -
examples/hello, the smallest ashlar app, and acargo xtask benchstep that reports its stripped release size ashello_stripped_bytes. -
Features
accesskitandheadlessonashlar-rsandashlar-app, both on by default (no change for existing apps).default-features = falsedrops AccessKit and--headlessfor a binary about 1 MB smaller. -
A
release-smallprofile in the workspace (fat LTO, one codegen unit,opt-level = "s", stripped): the hello app goes from 13.6 MB to 9.0 MB stripped on macOS arm64. -
ashlar-ui: screen readers see every visible row ofVirtualList,VirtualTableandTreeView: list items, table rows and tree items named byrow_label(the tree’s labels), with selected state, position and set size, and level and expanded state for tree items. Only visible rows get nodes, and only while AccessKit is on. -
ashlar-ui:selection::Selection, an app-owned, serializable multi-selection (anchor, cursor, ranges).VirtualList::selection(&mut sel)andVirtualTable::selectionhandle Shift-click, Shift+arrows, ⌘/Ctrl-click and ⌘A;selectedandis_selectedkeep working without it. -
ashlar-ui:VirtualList::type_ahead(&text)andVirtualTable::type_ahead: letters typed within a second of each other jump to the next row starting with them. -
ashlar-app:--headlessfor everyApp::runbinary:--size WxH --scale S --theme light|dark --script file.ui --at SECONDS --snap out.png --tree tree.jsonwrites a screenshot (real pixels when there is a GPU) and the UI tree with no window, in a temporary folder. Exit codes 0/1/2 as inashlar-debug.ashlar-debugexportssave_png. -
ashlar-app:Driver::run_ui(script)runs a.uiscript (theashlar-debug batchlanguage) in process, with no port or thread.sleepandwait Nlet virtual time pass, so two runs give the same steps.ashlar-debuggains theTransporttrait andrun_scriptto run scripts against any target. -
ashlar-ui:cache::label(aui.labelwith its galley cached under a key: no layout and noStringon steady frames),cache::row_label(the same, not selectable, for text inside a clickable row) andcache::button_text(a button’s text as a cached galley). -
ashlar_ui::tokens::set_mode_animated: switches theme with an OKLab crossfade, surfaces over 0.4 s and text leading at half that; primary and secondary text keep 3:1 on the panel at every frame, and once settled the theme’s own visuals are back and nothing more runs.Tokens::mixblends two token sets. -
ashlar_ui::tabs::indicator: where to paint a tab strip’s selection underline or pill, sliding by its edges to the newly selected tab (paint only; tabs keep their rects for hit testing). -
ashlar_ui::tabs::TabStrip: a standalone tab strip with one Tab stop (arrows, Home and End move the selection), close by button, middle-click or ⌘W, an overflow menu for tabs that do not fit, drag or ⌘⇧ + arrow reordering (tabs::reorderapplies a move), the animated selection indicator, an AccessKit tab list with positioned tabs, and automation ids<id>.tab.<n>. The gallery has a Tabs page. -
ashlar_ui::tabs::Tab::automation: registers a tab under a dotted automation id. -
ashlar_ui::motion::presence: keeps an item drawn while it leaves (Some(progress)until its exit settles, thenNone), entering and leaving along a token pair as an opacity. -
ashlar_ui::code_view::CodeView: a read-only code view with virtualized lines (O(visible) per frame), a line-number gutter, optional wrapping, mouse selection, select all and copy, search marks and syntax colours fromSyntaxTheme::from_tokens. Line galleys are cached per line and revision; lexer states are computed lazily. Automation<id>.line.<n>for visible lines only; AccessKit document and paragraphs. About 0.06 ms per scrolled frame at 100k lines. -
ashlar_ui::markdown: fenced code blocks in a known language are syntax-coloured. -
ashlar-syntax(new crate, rank 0, no dependencies, builds for wasm32): syntax highlighting with hand-written, linear-time lexers for Rust, TypeScript/JavaScript, Go, C, C++, Java, C#, Python, shell, SQL, JSON, TOML, YAML, HTML (with script and style), CSS, Markdown (fenced code in its language) and diffs.highlightreturns ordered byte ranges that cover the input;highlight_linecarries aStatefrom line to line for incremental views;lang_for_path; aHighlightertrait so a bigger engine can plug in. About 0.5 ms per 1,000 lines of Rust. -
ashlar-app:Driver::with_renderer(runtime, Renderer::wgpu())renders real screenshots with no window, on a wgpu device with no surface, at the driver’s display scale. Without an adapter it falls back to the grey image. Screenshot replies from aDriversay which was used ("renderer": "wgpu"or"none"), and grey screenshots now have the screen’s size in pixels instead of points. -
ashlar_ui::list::Row: a cheap selectable list row for plain (non-virtual) lists: one allocation, one interact, and a cached galley only while on screen. AccessKit list item with label and selection; focusable, Space and Enter click; opt-in automation id. A 1k-row plain list scrolls in about 0.10 ms per frame with it, against about 0.7 ms withButton::selectablerows. -
ashlar_ui::list::checked: wraps a list’s content and, in debug builds, warns once per call site when it builds more than 2,000 interactive widgets in a frame, pointing toVirtualList(ASHLAR_QUIET_LISTSsilences it). -
ashlar-motion(new crate, rank 0, no dependencies, builds for wasm32): closed-form springs set by duration and bounce (Spring, never earlysettle_time),Curve(spring, cubic-bezier tween, decay, instant),Animated<T>values whose retargets keep position and velocity and that land exactly on their target,Classsettle thresholds and reduced-motion rules (opacity and colour never overshoot),MotionPrefs, motiontokens(exits shorter than enters), an OKLab/OKLCHcolormodule with gamut mapping, premultiplied mixing and WCAG contrast, andgesturehelpers (velocity tracker, fling projection, rubber band). -
ashlar-app: frame stats split the frame into phases:app.statskeeps the UI time asp50_ms/p95_ms/max_msand addspass,tessellateandframe_cpu(the whole frame on the CPU: eframe’s own figure in a window).Drivertessellates every frame like a window and takes the display scale (Driver::pixels_per_point). -
ashlar_ui::motion: animated values for egui in one call per frame (value,f32,opacity,colorin OKLab,rectby edges,set,release), kept in the context and forgotten when unused. Nothing animates on first show, frames are requested (Reason::ANIMATION) only while a value moves, and settled values are exactly their target.prefs/set_prefsset reduced motion (also turning off egui’s widget animations) and a time scale;activecounts moving values. -
ashlar_ui::tokens:motion(spring per kind of change),elevation(popup and window shadows, no longer hard-coded invisuals()) andstroke(hairline, focus) tokens;Tokens::high_contrast();contrast_ratio;TOKEN_SPEC, the OKLCH source of every built-in colour, checked against the hex values by a test. -
ashlar-appfollows the OS preferences: reduced motion (throughashlar_ui::motion::set_prefs), the accent colour (Tokens::with_accent) and increased contrast (Tokens::high_contrast), read at startup and again when the window gains focus (on Linux off the UI thread). Tokens an app installs itself win.App::follow_system_accent(bool)(default on) andApp::motion(MotionPrefs)override;Runtime::set_preferencesinjects preferences in tests, andApp::runtime()ignores the OS so test runs match on every machine.tokens::ofandtokens::apply_colorsare new.
Changed
-
File dialogs open over the app window (a sheet on macOS) and no longer block a frame: the frame that asks for one finishes, the next shows the dialog and acts on the pick. Saving an untitled document from the “Save changes?” dialog now goes on to the held-back open or quit after Save As.
ashlar_native::dialogs::FileDialog::parent(&window)parents any picker. -
ashlar-nativemacOS notifications play the default sound unlesssilentis set, and ask for sound permission only then. -
A document named on the command line opens on the frame after the first, so the window shows before the load runs (it used to load before the first paint).
-
Windows queue at most one frame ahead of the screen by default (
App::frame_latency(1)): lower input latency and one swapchain image fewer (about 10 MB less idle footprint for a 960×640 window on a Retina screen). Apps with heavy per-frame GPU work can ask for 2. -
ashlar_ui::list::Rowgainsindent,text_styleandweak. The devtools Elements list draws its rows with it (about 6.7× cheaper per row than its old selectable labels: 0.10 ms against 0.67 ms for 1k rows), and the tab strip’s overflow menu runs insidelist::checked. -
ashlar_ui::docking: group tab strips use the shared tab strip: one Tab stop per group with arrow keys, an AccessKit tab list, and the animated selection underline. -
ashlar_ui::controls: button, switch, segmented and radio hover and press fills ease over about 0.1 s, like egui’s own widgets (tokens::state_color,Tokens::interact_fill_animated); they change at once when egui’sanimation_timeis 0, which reduced motion sets. Visual regression tests now run with reduced motion. -
ashlar_ui::dialog: on open the backdrop fades in and the panel fades in while growing from 97 %; when the app stops showing a dialog, its last frame fades out a little faster without taking input. No growing under reduced motion; hit testing uses the resting layout. -
ashlar_ui::docking: the drop preview moves quickly (0.1 s) from one drop target to the next while a tab is dragged, and appears in place when a drag first shows it. -
ashlar_ui::toasts: a new toast fades and slides 8 pt in, a dismissed one slides back out and is painted until its exit settles, and the toasts behind it glide into the gap. Toasts queued before the stack is first shown appear at once; hit testing and automation rects use the resting layout; reduced motion keeps only the fade. -
Tokens::with_accentderives hover, focus, selection and selection text in OKLCH (same hue, lightness steps, capped chroma) instead of sRGB mixing, flipstext.on_accentdark when white falls below 4.5:1 and pushes the focus ring to 3:1 on every surface. The dark theme’s accent is darker (#276ee0, was#3b82f6) so white text on it reaches 4.5:1; its derived states follow. -
The status bar’s indeterminate progress holds still (and asks for no frames) under reduced motion, and its sweep follows the motion time scale.
-
Fewer allocations per frame (IMP-2.16): a frame woken only by a caret blink in the notes app makes 550 allocations instead of 958, a scroll frame 345 instead of 472, a keystroke 1,935 instead of 3,720. The menu bar and the notes list draw cached galleys, and an edit that changes no command’s enabled state no longer rebuilds every command’s info and the menus.
-
ashlar-app: a command run from the keyboard (or anything else before the view draws) no longer asks for adocumentframe: the frame that ran it already shows the change. The runtime also stops copying and comparing the view state and formatting the window title on frames that did not change them. -
ashlar-ui: variable-heightVirtualListandVirtualTablekeep row heights in a Fenwick tree, andchanged_rows(&[row])says which rows changed height: those are measured again in O(log rows) each instead of re-summing every row (1M rows: about 2 ms → 1 µs). A full rebuild (new revision or row count) costs about a third more than before. -
ashlar-ui:markdown::showkeeps its parse while the text is the same and parses streamed text again only from its last open block; each block’s layout is kept per width and style, and blocks outside the clip rect only reserve their space (a 50 KB document: about 1.9 ms → 0.02 ms a frame). Automation ids (<id>.link.<n>,<id>.code.<n>) now exist only for blocks drawn, with the same numbering.MdBlock,SpanandMarkerimplementHash. -
ashlar-ui: the galley cache (cache::galley,RevisionCache,MeshCache) hashes with a fast fixed-seed hasher (cache::hash_ofvalues differ from before), reads the shared cache under the context’s read lock, and when full drops its least recently used half instead of everything, so labels in use are not laid out again. -
ashlar-ui: a controls kit (controls):Button(primary, secondary, ghost and destructive variants, three sizes, an icon, a loading state that shows a spinner and reports busy),Switch,SegmentedandRadioGroup(one Tab stop; arrows, Home and End move the choice),Badgewith tones,Keycap(a cap per key of a shortcut),Progress::barandProgress::circle(determinate or indeterminate) andSpinner. Each registers a dotted automation id and fills its AccessKit node; indeterminate indicators ask for frames (wake::Reason::ANIMATION) only while on screen and hold a static pose withreduced_motion(true). New automation roles:switch,radio,radio_group,progress,label,group,disclosure,alert. The gallery has a Controls page, and the visual regression suite compares the kit against reference painters (222 pairs). -
ashlar-ui: dialogs move the keyboard focus inside when they open (the body’s first field, else the default button, else the cancel button;Dialog::initial_focuschooses) and give it back to the widget that had it when they close, however they close (Dialog::return_focus_tonames it for a dialog opened by a click). Screen readers get a modal dialog node named by the title, withDialog::description(also shown under the title) as its description. -
ashlar-ui: content components (content):EmptyState(icon, title, text, action),Banner(a tone, title, action and close button; a live region that screen readers announce, assertively for errors),Card(a titled group) andCollapsible(a header that shows or hides its content; reports expanded to AccessKit, arrows open and close it).IconButton::tooltipgives icon-only buttons a visible name on hover, andcontrols::Button::partregisters a component’s button as<id>.<part>. The gallery has a Content page; the visual regression suite covers the collapsible header and the banner.
Changed
-
ashlar-ui: dialog buttons arecontrols::Buttons (primary for the default, destructive for a destructive one); a disabled one is drawn at the disabled opacity instead of in the sunken colours, and its automation entry no longer reports the default button asselected. -
ashlar-ui:wake::wake,Waker::wakeand a zero-delaywake_after/rescheduleask egui for one frame instead of two (egui adds a second frame to every zero-delay repaint request). A command sent through the control port now costs 2.2 frames instead of 3.2. -
ashlar-app: the control port no longer asks for a frame after answering a request; the frame that serves it runs before the view draws and already shows the change. An engine command sent through the port now costs about 1.3 frames (was 2.2).Driver::requestwakes for thecontrolreason as the port does. -
ashlar-update: the check asks for this platform’s build (?platform=macos|windows-x64|linux-x86_64|linux-aarch64,PLATFORM,latest_url_for), so a Windows or Linux app is never offered a Mac download.
Fixed
-
Linux: copied text and images no longer vanish when the copy call returns; one clipboard lives on a worker thread for the life of the process.
-
ashlar-uilists:scroll_toread the scroll offset under the wrong id, so it never scrolled up to a row above the view and moved the list for a row already in view. It now reads the scroll area’s own id, as the fling does. -
ashlar-uiaccessibility: lists, tables and trees take a.label(text)that screen readers announce; table cells areCellnodes and the header row holdsColumnHeadernodes (with sort direction); resize handles, tree disclosure arrows and the fling drag surface no longer show up as unnamed nodes. The keymap editor’s table, its actions column and its shortcut and clear buttons have names (newKeymapLabels::tableandactions, English and German). -
ashlar-uiaccessibility: search fields and the command palette input have a name, not only a placeholder (SearchField::label, newUiLabels::searchwith English and German); banners announce their text once instead of as both name and value. -
ashlar-uidevtools: AccessKit that the debugger turned on to answer a request (ui.click,ui.snapshot, a script) goes back off once nothing needs the tree, so a driven app no longer builds a screen reader’s tree on every later frame (a caret-blink frame in the notes app: 272 allocations instead of 652). This happens only where no screen reader can exist (aDriveror--headlessrun, or a build without theaccesskitfeature); in a window it stays on, anddevtools::set_platform_accesskitsets which applies. -
Curve::Tweenstarted from rest follows its easing exactly: a linear tween is linear (it used to start at zero velocity, so a 2 s linear 0→100 tween read ≈12 at 0.525 s instead of 26.25). Retargets mid-motion still keep velocity continuous. -
Telemetry: a run whose shutdown wait ran out no longer takes back its own unsent session folder (and other leftover ones) as its worker finishes, so a courier delivers them instead of seeing them as in use until the process exits.
-
notesexample: a click on a note’s title in the list selects the note (the title was selectable text that took the click). -
native-menu: picks in the OS menu bar no longer run while a dialog (such as “Save changes?”) or the command palette is open. -
⌘Z / Ctrl+Z in a text field undoes the text, not the document. Text-editing shortcuts (undo, redo, select all, copy, cut, paste) are left to a focused text field (
works_while_typing); withnative-menuthey have no OS key equivalent, and menu picks of commands bound to them are skipped while typing. -
native-menuon Windows: the menu bar is attached to the window; until it is (or if that fails) the in-window bar shows, so the app never ends up without a menu. -
native-menuon macOS: Quit is in the application menu, and a Window menu has Minimize, Zoom and Bring All to Front. -
ashlar-debug:trace start <dir>inside--port P batch -(or--app NAME) recorded nothing, since the trace was kept only in a session file. The trace now lives in that batch run; outside a batch,tracewith--port/--appis a usage error instead of doing nothing. -
docs/control-protocol.md: the MCP tools table listedscreenshot {id?}and left out the debugger tools; it now lists every tool with its arguments, and a test keeps it in step with the code. -
ashlar-app: a focused text field no longer keeps an idle app drawing: egui’s caret blink cost about 6 frames a second for as long as the field had focus. The runtime blinks the caret itself, one frame per phase change credited to the newcaretwake reason, and leaves it solid after 10 s without input (ashlar_app::caret); input starts it again. 30 s idle with a focused field: 194 frames before, 21 after. -
ashlar-ui: a value field’s typed entry lost what was typed in the frame its editor opened (a value typed straight after the click, as the control channel does), and while its window did not have the operating system’s focus (an app driven without being activated) the editor selected all its text again every frame, so each entry replaced the last. The editor takes the keyboard before it is drawn, and keyboard handling here asks egui’s focus alone (edit::has_keyboard): value fields, sliders, trees, swatches, resize handles, virtual lists and the search field. A person typing in a focused window was not affected, except that an editor left open while they switched windows had its text selected again when they came back. -
ashlar-ui:ui.type/ui.fillwith a target no longer click a text field that has the keyboard already: two requests in a row made a double click, which selected a word for the next text to replace. -
ashlar-assistant: a saved chat reopened after a relaunch could be refused (“Invalidsignatureinthinkingblock … bound to a different conversation”): its thinking blocks were replayed even when the system prompt, the tools or the saved messages (results cut, pictures left out) differed from the request that produced them. Each Anthropic reply now records a fingerprint of its prefix (Native::binding,anthropic::request_parts,anthropic::bind), and thinking goes back only while it matches; chats saved before this replay none. A refusal anyway drops all earlier thinking (Conversation::forget_thinking) and the step is asked once more. -
ashlar-assistant: OpenAI-format requests carry at mostanthropic::MAX_IMAGESpictures (the newest), like Anthropic ones; they carried every picture of the chat. -
ashlar-ui:idle::FrameRate::fpscounts the frames of the last second (two frames 11 ms apart on an idle app read 91 fps);idle::Activity::ofdocuments that an unfocused window is the background signal that arrives (winit rarely reports a covered one). -
ashlar-native:memoryis listed as an FFI module (the deny-panics check refused it); it no longer calls macOS’smalloc_zone_pressure_relief, which released next to nothing on macOS 26, sorelease_free_memoryis a no-op there;memory::space_efficient_malloc()tells whether the process runs withMallocSpaceEfficient=1, which hands freed memory back at once.
Added
ashlar-ui: toasts v2. Each toast is an AccessKit live region (polite; assertive for errors), so screen readers announce it. New onToast:title,action(label, command)(up to three buttons, reachable astoast.<id>.action.<n>; clicks arrive fromToasts::take_actionsorshow_actions),key(pushing a toast with a queued toast’s key updates it in place), andprogress;Toasts::progress(key, text, fraction)keeps a progress toast up to date anddismiss_keyremoves it. Escape dismisses the focused or newest toast (not while a field or a popup has it), keyboard focus pauses a toast’s timer, close and action buttons show a focus ring, the close target is a full control high, toasts pastmax_visibleshow as “+N more”, and the words come fromToastLabels(English by default).Toastgained public fields, so code that builds one with a struct literal must add them (Toast::newis unchanged).ashlar-trace-proto,ashlar-trace,ashlar-trace-server: telemetry counts frames over the 120 Hz budget.DISPLAY_BUDGET_US(8.333 ms, already a bucket bound) and the optionalFrameStats::over_budgetare new within schema 1: older clients leave the field out, older servers ignore it, andFrameStats::over_budget_framescounts it from the buckets when it is missing. The SDK sends it and shows it in its local report; the server counts it from the stored buckets (no new column) and shows “Over 120 Hz budget” on the performance page and “Frames over 8.3 ms” on issue pages.ashlar-mcp:app_stats {reset?}andapp_logs {limit?, level?}tools over the runtime’sapp.statsandapp.logs, listed only when the app reports those methods.ashlar-assistant:HistoryStore::offload_imageskeeps only a chat’s newest pictures in memory (older ones stay in its history folder as file references, which saving keeps, loading reads back and requests leave out);HistoryStore::read_imageandImage::is_offloadedfor showing them.ashlar-telemetry:TelemetryConfig::build: a build identifier (such as the commit a release was packaged from) sent with every report, so a server can tell release sessions from developer ones.ashlar-assistant:Conversation::memory(): the bytes a chat holds (pictures dominate) and how many pictures, for memory readouts.ashlar-ui:idle:IdleTrimfires once per quiet spell (30 s idle, 10 s in the background) so an app can trim its caches,MemoryReport(named cache sizes plus the process footprint forperf.stats),texture_memory, andFrameRate, a frame rate that reads 0 while no frames run;wake::Reason::IDLE.ashlar-native:display(feature, on by default):check()says when this process cannot show a window at all (Windows: a service’s window station, as in session 0), where wgpu would panic configuring the window’s surface.ashlar-appchecks it before opening its window and returnsError::NoDesktopinstead of crashing.ashlar-native:memory(feature, on by default):process_memory()reads the process’s footprint and resident size the way the OS’s tools do;release_free_memory()returns the allocator’s free pages to the system (macOS keeps freed regions dirty otherwise).ashlar-assistant: saved chats (history:HistoryStore,HistorySaver; versioned, crash-safe, pictures in their own files),@mentions (mention,Block::Mention),/commands with saved prompts (slash), and/compactsummaries and chat titles (compact).ashlar-assistant:skills(Skills: skills listed by name and description in the system prompt, loaded with theload_skill/read_skill_filetools, picked as/name; from docs categories, skill folders and integrations),SlashAction::SkillandSlashAction::External, and featureintegrations(integration: an integration’s skills and MCP prompts as commands).ashlar-agents:skills::parseandskills::load_dirreadSKILL.mdskill folders back from disk.ashlar-integrations: MCP prompts (Integrations::prompts,get_prompt) and resources (skill_resources,read_resource), and skill folders in a bundle (manifestskills,skill_dirs).ashlar-ui:markdown, Markdown for chat replies (headings, lists, tables, code blocks with Copy, inline styles and links), safe for text that is still streaming in.
Security
ashlar-trace-serverand the trace examples use rusqlite 0.41 (bundled SQLite from libsqlite3-sys 0.39), fixing RUSTSEC-2026-0459 (CVE-2026-119375, stale memory read through a malicious database and-walfile pair); the yanked async-executor 1.14.0 is replaced by 1.14.1.
0.1.0 - 2026-10-27
First public release, on crates.io.
Added
- The umbrella crate, published as
ashlar-rs(imported asashlar), and theashlar-*crates: app runtime, UI components on egui, documents with undo and autosave, commands, background jobs, files and settings, logging and crash safety, native OS features, the agent control port, CLI, MCP (stdio and HTTP), agent setup, an in-app assistant harness, integrations, UI debugging, agent docs, update checks, printing, string obfuscation, and self-hosted telemetry. - Examples:
notes,gallery, and the telemetry demos. - A release workflow that publishes to crates.io only from a version tag on
main, after the full CI gate passes on that commit.
Changed
- HTTPS in
ashlar-telemetry,ashlar-update,ashlar-assistantandashlar-integrationsuses rustls with theringprovider, replacing the unmaintainedrustls-rustcrypto.
Fixed
ashlar-trace-serversendsConnection: closewith a refusal made before the request body is read (bad key, rate limit, storage full) and with every dashboard form response, so a client never reuses a connection the server is about to drop.ashlar-traceon Windows reads each spooled file’s size from the file itself, not the folder listing, which can show a stale size for a file still being written; a segment that looked empty was deleted.ashlar-tracekeeps the count of a batch the server refuses while the tracer shuts down, so the next process reports it instead of the count being lost.