ashlar-ui 0.1.0

ashlar UI components: theme tokens, controls, layout, docking, icons
Documentation

ashlar-ui

General-purpose egui components for ashlar apps: design tokens, inputs with undo-friendly edit phases, virtualized lists, dialogs and toasts, the command UI (menu bar, palette, keymap editor) and automation ids so every control can be inspected and clicked by an agent. Domain widgets (timelines, canvases, meters) stay in the apps and are built from these pieces.

Run the gallery to see every component in its states, light and dark:

cargo run -p ashlar-example-gallery
cargo run -p ashlar-example-gallery -- --snapshot /tmp/gallery   # PNGs, no window

Components

Module What it gives you
tokens Design tokens (surfaces, text, accent, selection, focus, borders, status colours, radii, spacing and type scales), apply/install, light/dark/follow-system, interface scale, app token extensions
fonts FontInstall: faces from app-supplied bytes (validated), primary/named families, fallbacks, CJK chain ordered by UI language
edit Edit { phase: Begin/Update/Commit/Cancel, start, value }: one drag or typed entry = one undo step
value_field Numeric drag/scrub field (Shift ×10, Alt ×0.1, click or Enter to type, arrows step, Escape restores); ValueFormat for formatted fields (timecode, units)
slider Slider (track follows the pointer, click jumps) and Knob (vertical drag, double-click resets)
swatches SwatchGrid with selection, checkerboard for transparent colours, arrow-key focus
search SearchField: glyph, clear button, Escape clears then unfocuses, length cap
virtual_list VirtualList and VirtualTable (resizable columns, sort indicator): uniform or variable row heights, keyboard selection, O(visible) per frame
tree TreeView over a TreeModel, serializable TreeState, flattening cached per revision, keyboard expand/collapse
dialog Modal dialog shell: Enter → default button (unless a multi-line editor has focus), Escape → cancel
toasts Bounded toast queue, timed or sticky, hover pauses, one scheduled repaint, zero cost when empty
status_bar Bottom bar with left/right sections: text, buttons, determinate/indeterminate progress
commands menu_bar drawing ashlar_command::menu::MenuSpec (re-exported; from CommandInfo menu paths; serializable; the same spec feeds the native menu bar), CommandPalette (fuzzy, shortcuts, disabled reasons), KeymapEditor (record, conflicts, reset/clear), dispatch (keys → command id)
automation Per-pass registry of id, label, role, rect, value, selection; inspect() for ui.inspect, find/click_point for ui.click
cache RevisionCache, GalleyCache, MeshCache, and a context-wide cache::galley
buttons, tabs, layout, docking, theme, icons, printing The shared components seeded from an earlier UI library: icon buttons, tab fitting, splits and resize handles, toolbar overflow, panel docking, widget-state colouring, SVG catalogs and print settings (see Shared components)

Design tokens

use ashlar_ui::tokens::{self, ThemeMode, Tokens};

fn setup(ctx: &egui::Context) {
    let dark = Tokens::dark().with_accent(egui::Color32::from_rgb(232, 89, 12));
    tokens::install(ctx, &Tokens::light(), &dark, ThemeMode::System);
    tokens::set_interface_scale(ctx, 1.25);
}

fn paint(ui: &egui::Ui) {
    let t = tokens::current(ui.ctx()); // the active theme's set
    ui.painter().rect_filled(ui.max_rect(), t.radius.md, t.surface.sunken);
}

apply also writes egui's own visuals, spacing and text styles, so stock egui widgets match. Apps add their own domain colours with tokens::set_extension(ctx, theme, MyTokens { .. }) and read them with tokens::extension::<MyTokens>(ctx).

Fonts

ashlar ships no font files. The app supplies bytes; a corrupt face is an error at add, not a panic inside egui later:

use ashlar_ui::fonts::{CjkScript, FontInstall, FontError};

fn fonts(ctx: &egui::Context, ui: &'static [u8], mono: &'static [u8], jp: Vec<u8>, sc: Vec<u8>) -> Result<(), FontError> {
    let mut f = FontInstall::new();
    f.add("ui", ui)?.add("mono", mono)?;
    f.primary(egui::FontFamily::Proportional, "ui")?.primary(egui::FontFamily::Monospace, "mono")?;
    f.add_cjk(CjkScript::Japanese, "jp", jp)?.add_cjk(CjkScript::SimplifiedChinese, "sc", sc)?;
    if let Some(s) = CjkScript::for_language("ja-JP") {
        f.prefer(s);
    }
    f.install(ctx);
    Ok(())
}

Edit phases and undo

Value components return Option<Edit>. Map it onto history merge keys: open a group on Begin, apply Updates with the group's merge key, close it on Commit, roll back on Cancel. A Commit with no Begin before it (arrow key, slider click, typed value) is a one-shot change.

use ashlar_ui::edit::EditPhase;
use ashlar_ui::value_field::{Decimal, ValueField};

fn opacity(ui: &mut egui::Ui, value: &mut f64) {
    let fmt = Decimal { decimals: 0, suffix: " %" };
    if let Some(e) = ValueField::new("inspector.opacity", "Opacity", value, &fmt).range(0.0..=100.0).show(ui).edit {
        match e.phase {
            EditPhase::Begin => { /* history.begin_group() */ }
            EditPhase::Update | EditPhase::Commit => { /* apply e.value (merge key = field id) */ }
            EditPhase::Cancel => { /* history.cancel_group() */ }
        }
    }
}

Virtualized lists

Only visible rows are laid out, painted and registered. Uniform heights cost nothing per row; variable heights are summed once into an offset table per revision (O(rows)), then each frame binary-searches the first visible row. The tests drive 100 000 rows and assert that a frame paints only the visible rows, that the height function is not called again for an unchanged revision, and that frame time at 100k rows matches 1k rows (≈0.3 ms per frame in a debug build).

Commands

use ashlar_command::{CommandInfo, Keymap, shortcut::Platform};
use ashlar_ui::commands::{dispatch, menu_bar, CommandPalette, MenuSpec};

fn frame(ui: &mut egui::Ui, infos: &[CommandInfo], keymap: &Keymap, palette: &mut CommandPalette, revision: u64) -> Option<String> {
    let mut run = dispatch(ui.ctx(), keymap);                       // keyboard
    let spec = MenuSpec::from_commands(infos, &["File", "Edit"]);   // also feeds a native menu
    run = run.or(menu_bar(ui, &spec, Platform::current()));        // in-window menu bar
    run.or(palette.show(ui.ctx(), infos, revision, Platform::current()))
}

Every returned id goes through the app's one execute chokepoint.

Automation

Each component registers id → (label, role, rect, value, selected) for the visible part of itself, every pass, without allocating once warm. Ids are the dotted strings the app passes (inspector.opacity); components add sub-ids (files.row.12, dialog.button.0, menu.item.file.save, toast.3.close). Call automation::install(ctx) at startup, then automation::inspect(ctx) serves ui.inspect and automation::click_point(ctx, id) serves ui.click.

Shared components

These modules are the shared building blocks seeded into ashlar from an earlier UI library. They hold no application state: the app passes its data in and reads back what the user asked for.

SVG catalogs

icons::SvgIconSet prepares a fixed set of embedded monochrome SVGs once and hands egui the same shared bytes every time it is asked.

use std::sync::OnceLock;
use ashlar_ui::icons::{SvgIconSet, SvgStyle};

static ICONS: &[(&str, &[u8])] = &[("save", include_bytes!("icons/save.svg"))];

fn icons() -> &'static SvgIconSet {
    static SET: OnceLock<SvgIconSet> = OnceLock::new();
    SET.get_or_init(|| {
        let style = SvgStyle { current_color: "#0f172a", stroke_width: Some("1.75") };
        SvgIconSet::new(ICONS, style, "bytes://my-app/icons/light/", "save")
    })
}

fn toolbar(ui: &mut egui::Ui) {
    ui.add(egui::Image::new(icons().source("save")).fit_to_exact_size(egui::vec2(16.0, 16.0)));
}

The app installs the SVG loader (egui_extras::install_image_loaders with its svg feature) and chooses tint and size; the library installs no loader and no theme. A catalog is immutable after new and every lookup returns the same Arc bytes. An unknown name gets the fallback icon's bytes but keeps the requested name in its URI; with no fallback either, the bytes are empty and the loader reports the error. contains and canonical_name only see real entries, so resolve unknown names to the fallback yourself first if you want a single cache entry per icon. Give each catalog and style its own URI prefix within a context. Lookups never add entries. Only use it for trusted, compiled-in monochrome assets: the only processing is decoding as lossy UTF-8 and two literal substitutions (currentColor, and stroke-width="2" when a width is given).

Icon buttons

buttons::IconButton takes an accessible label, a size and an IconButtonStyle: one ButtonVisuals (fill, stroke, icon colour) for each of normal, hovered, pressed and selected, plus a corner radius and a focus stroke. Selected wins over everything; pressed: None keeps the hovered look while the button is held, Some gives it its own. Press capture and cancelling a press by moving away are egui's.

use ashlar_ui::buttons::{ButtonVisuals, IconButton, IconButtonStyle};
use egui::{Color32, CornerRadius, Stroke, vec2};

let base = ButtonVisuals { fill: Color32::TRANSPARENT, stroke: Stroke::NONE, icon: theme.text };
let style = IconButtonStyle {
    normal: base,
    hovered: ButtonVisuals { fill: theme.hover, ..base },
    pressed: Some(ButtonVisuals { fill: theme.pressed, ..base }),
    selected: ButtonVisuals { fill: theme.accent, icon: Color32::WHITE, ..base },
    corner_radius: CornerRadius::same(4),
    focus_stroke: Stroke::new(1.0, theme.focus),
};
let clicked = ui
    .push_id("brush-tool", |ui| {
        IconButton::new(&t("Brush"), vec2(28.0, 28.0), &style)
            .selected(tool == Tool::Brush)
            .show(ui, |ui, rect, color| paint_brush(ui.painter(), rect, color))
    })
    .inner
    .clicked();

Take the colours from your theme and the label from your translations. A disabled parent UI disables and fades the button. The label and selected state go to AccessKit, keyboard focus draws an outline inset by two points, and Enter, Space and the mouse activate it through egui. The button never runs a command or toggles itself: act on clicked() and pass selected back next frame. Tooltips go on the returned response. single_shape_frame paints fill and border as one painter.rect call, for controls that used to be drawn that way. The id comes from the UI position, so wrap buttons in push_id when they can be reordered. Sizes must be finite, positive and at most 4096 points (otherwise the button is inert), and strokes are limited to half the button's smaller side.

Tabs

tabs::fit(widths, selected, available, minimum, overflow_width) decides which tabs of a strip are shown and how wide. Tabs shrink before any is hidden, the widest first, down to the minimum. When that is not enough, an overflow button is reserved, the selected tab always stays, and other tabs keep their order. A tab can end up zero wide in a very narrow strip. Every input is sanitized, so the result is finite and never wider than available; it runs in linear time with at most 32 bisection passes.

use ashlar_ui::tabs::Tab;

let response = Tab::new(ui.id().with(("doc-tab", doc.id)), &doc.title, doc.id == current)
    .sense(egui::Sense::click_and_drag())
    .show_at(ui, rect, |ui, r| paint_tab(ui.painter(), rect, r.hovered(), doc.id == current));
if response.clicked() {
    current = doc.id;
}

The caller owns ids, the selection, labels, the overflow menu, painting and drags. Tab reports the AccessKit Tab role with its selected state, activates on Enter and Space, and draws the theme's focus outline after your painter. Close buttons and context menus are separate widgets.

Splits and resize handles

layout::split_rect(rect, axis, size, gap, minimum) divides a rect along SplitAxis::Horizontal (first pane left) or Vertical (first pane on top). SplitSize is a ratio of the space, a fixed first size or a fixed second size. The result has first, divider and second rects. The gap and minimum come from the app; in a space too small for two minimums each pane gets half. Non-finite or inverted input gives None.

use ashlar_ui::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, split_rect};

if let Some(split) = split_rect(rect, SplitAxis::Horizontal, prefs.sidebar, 4.0, 120.0) {
    sidebar(ui, split.first);
    editor(ui, split.second);
    let style = ResizeHandleStyle { hovered: theme.hover, dragged: theme.accent, focused: theme.focus };
    let moved = ResizeHandle::new(ui.id().with("sidebar"), SplitAxis::Horizontal, split.divider, &t("Resize sidebar"), style)
        .value(split.first_size(), 120.0..=split.available() - 120.0)
        .show(ui);
    if let Some(size) = split.resized(prefs.sidebar, moved.delta, 120.0) {
        prefs.sidebar = size;
    }
}

ResizeHandle reports one delta per frame from the pointer, from the arrow keys of its axis while focused, and from AccessKit increment and decrement. value adds the current position and its bounds to the accessibility node and enables set-value. Tab still moves focus away. Invalid geometry gives an inert handle. Apply the delta with SplitLayout::resized; nothing is stored by the widget.

Panel docking

docking::Layout<P> arranges panels in tab groups, splits, accordions and floating groups, independently of what the panels contain. Changes are Actions applied as validated, atomic transactions; location and restore hide a panel and put it back where it was. With the serde feature the model and actions serialize. DockArea is an optional renderer that only reports actions; protected panels such as a document canvas render without a tab header, and per-panel size limits shape the view without overwriting saved sizes. Apps with their own chrome use resolve_drop for the same drop rules. See the docking guide.

Toolbar overflow

layout::fit_toolbar(widths, available, spacing, overflow_width) keeps a leading run of toolbar groups at their natural widths and moves the rest to an overflow control. It reserves the whole overflow width plus the gap before it, and never compresses a group or skips one to fit a later one. The app paints the overflow control as an enabled, focusable button whose menu offers every overflowed group. It returns None for malformed input and when not even the overflow control fits; the app needs its own fallback for that case (a menu button only, say).

Theme mechanisms

theme::WidgetPalette sets the background fills (both bg_fill and weak_bg_fill), corner radius and foreground stroke of egui's five widget states, and leaves borders and expansion alone.

use ashlar_ui::theme::WidgetPalette;

let mut style = (*ctx.style()).clone();
WidgetPalette { noninteractive: c.surface, inactive: c.control, hovered: c.hover, active: c.pressed, open: c.open }
    .apply_to(&mut style.visuals.widgets, egui::CornerRadius::same(4), egui::Stroke::new(1.0, c.text));
style.visuals.widgets.open.bg_stroke = egui::Stroke::new(1.0, c.accent); // an app-specific exception afterwards
ctx.set_style(style);

It is the low-level piece that tokens uses when it installs a theme.

Print settings and feedback

printing::State holds the transient part of a print dialog: the chosen printer, copies, duplex, grayscale, and the receivers of the discovery and submission workers. The host supplies those workers (Discover, and a Submission receiver for each job) and maps Settings to the print crate's Job. show draws the controls and needs a stable id and localized Labels. Progress and results come back as typed Feedback, and poll schedules its own repaints while work is pending.

A refresh blocks submission until the list arrives. A selection that is still in the new list is kept; otherwise the default printer is chosen, or the first. An empty discovery clears the selection and a failed one empties the list, so a printer that no longer exists can never look ready, and ready is false while anything is pending. begin refuses to replace a running job, and dropping a receiver does not cancel the job behind it. duplex: None means the queue's default. Receipts and error texts are passed through untouched. The controls work on WASM too.

Compatibility and checks

egui 0.36, Rust 1.95+. The production dependency disables egui's default features: fonts and rendering stay the application's choice.

cargo clippy -p ashlar-ui --all-targets -- -D warnings
cargo test -p ashlar-ui
cargo test -p ashlar-example-gallery
cargo check --target wasm32-unknown-unknown -p ashlar-ui

Visual regressions describes the paired comparisons of the shared controls against reference painters. To keep the rendered images and the comparison table:

ASHLAR_UI_VISUAL_DIR=/absolute/path/to/visuals cargo test -p ashlar-ui --test visual_regression