MixOS GUI app style guide


1. The three rules

  1. One look per window context. Install the theme once per egui context (one for a single-window app). Read everything back from the context. Thread nothing.
  2. Tokens and widgets, never literals. A length is a token. A colour is a role. A control is a toolkit widget.
  3. The look is on the Bus. Every app answers <app>.look, publishes <app>.look.changed, runs view.look.preview and view.theme.set, and follows the session's tunables through settingsd without a restart and without polling.

2. Crates and dependencies

An app is two crates: an engine crate under apps/<app>/crates/<name> that knows nothing about egui, and the egui shell crate apps/<app>. The shell crate's Cargo.toml lists, as busviewer's does:

[dependencies]
eframe.workspace = true      # the window
egui.workspace = true        # labels and layouts the toolkit does not wrap
toolkit.workspace = true     # theme, chrome, widgets, commands, drive verbs
settings.workspace = true    # the session look follower
citizen.workspace = true     # the Bus connection
bus = { workspace = true, features = ["native"] }   # the native client citizen is built on
futures.workspace = true     # the connection's delivery stream
serde_json.workspace = true  # verb bodies

[dev-dependencies]
egui_kittest.workspace = true  # offscreen snapshots and accessibility queries

The toolkit does not re-export egui or eframe. The engine crate depends on none of these; the layering table (apps/layers.conf.mix) refuses egui, eframe, egui-wgpu, winit and rfd in its closure. A nested GUI crate (a renderer that draws into a texture) is held to this guide and its gate like the shell.

3. The minimal app

The full example is libs/toolkit/examples/guide_app.rs. Its shape, which every shipped app shares:

  • Start: citizen::start_with_topics(app, service, url, topics) with the settings summary topic gives the Bus handle and a stream of deliveries; Theme::load() gives the theme file or the embedded design; toolkit::install(ctx, &theme) and drive::install(ctx, app) are called once in the eframe creator; the app's strings are Strings::new over its embedded Fluent catalogue.
  • Forwarder: one thread drains the delivery stream into the shell's inbox and calls ctx.request_repaint() for every event, so an idle window wakes for a settings hint, a Bus command or a disconnection.
  • App::logic (eframe's per-frame hook before drawing) drains the inbox: Connected, Changed and a summary Topic hint ask the follower for a read; Disconnected resets the follower (so the next connection reads afresh and gets an identity-bearing look even at an unchanged revision), voids retries and clears the authority and any preview; Command is served (<app>.look and <app>.execute through the look layer, then the shipped drive layer's request, whose queued answers come back from drive::logic each frame); a read's answer goes to looked, whose returned next read is sent exactly as returned; a transient fault arms one bounded retry.
  • install_theme is the only place the look changes: the follower's inputs for this app (or the theme file) with the window's own choice laid over, installed on the context; every call bumps the window's look generation and publishes <app>.look.changed; it clears a preview, except when it is the arrival of the window's pinned source, which keeps the preview. Before installing, it checks the window's final token pair against the design's constraints; a violating pair is not installed, the last valid look stays, and <app>.look reports the violation.
  • App::ui draws the title bar on the root Ui with toolkit::titlebar::show (no stroke argument; it reads the context) and the content inside egui::Frame::central_panel.

What the lifecycle guarantees. The first read happens at start; every later read is caused by a summary delivery, a connection event or a retry; a delivery is never trusted on its own; an older revision never replaces a newer one (the follower checks); without settingsd the theme file stands. There is no timer.

Which design the app draws. The profile's pinned design source. When the summary's source digest (BLAKE3 of the source bytes, settings::source_digest) equals that of the design the binary carries, the app compiles that; otherwise it fetches the pinned source once with settings.source, verifies it with the same digest, caches it per user in a private directory it has checked it owns, and compiles it for the window's own axes, drawing the embedded design until it arrives. A reply activates only for a window that still wants that digest, resolving the window's current inputs and keeping its current preview; a late or superseded one only fills the cache. One fetched source serves every window and light region of the app without another fetch. <app>.look reports which was compiled and the acquisition state. A profile with its own design source is therefore an ordinary profile: the session look, tunes and previews apply. An app connects to the broker over the local Unix endpoint when one is named, which is what lets the compositor attribute its windows; over TCP it works the same but its windows draw the session look under server decorations.

Reading the look in view code.

needread
a colourChrome::of(ui.ctx()).palette.<role>
a chrome lengthChrome::of(ui.ctx()).metrics.<name>
a style form (cards or strips, switch or checkbox, bevels)Chrome::of(ui.ctx()).style.<token>
the icon stroketoolkit::icons::stroke_of(ui.ctx()), or just use IconButton
a gaptoolkit::space::of(ui.ctx()).md (xs, sm, md, lg, xl)
field radius, padding and focustoolkit::field::Style::of(ui.ctx()), or just use the field widgets
dim or faint texttoolkit::text::dim(s), toolkit::text::faint(s), toolkit::text::role(s, role)
a behaviour value (double-click time, scrub rate, hit tolerance)Chrome::of(ui.ctx()).style.<token>

Never pass any of these into a function as a parameter.

Desktop icons share the context's canonical 1.5 stroke on the 24-unit icon grid. Use icons::image_of or the toolkit icon widgets. Older test fixtures that passed 2.0 explicitly use the canonical default too. The existing appearance.icon_stroke session choice remains supported.

Local style overlays are fallible: handle Theme::with_overlay and Theme::with_choice errors before installing a look. A refused overlay must leave the installed look intact. canvas_dot_pitch accepts 4 to 1000 points, preventing zero or impractically dense drawing loops. For a colour tunable with a numeric range, the bounds apply to each normalised colour component. The value itself remains a hexadecimal colour; icon_mask requires every channel to be at least 0.1.

fn view(ui: &mut egui::Ui, engine: &Engine) {
    let space = toolkit::space::of(ui.ctx());
    let p = Chrome::of(ui.ctx()).palette;
    ui.label(format!("Count: {}", engine.count));
    ui.add_space(space.md);
    ui.add(toolkit::button::IconButton::new(Icon::Settings).tooltip("Settings"));
    ui.add(toolkit::text::dim("dim text"));
    let _ = p.accent;                               // read where you paint
}

4. Which toolkit widget for each need

needusenot
a text input, one linefield::text(ui, &mut s, hint, width)egui::TextEdit::singleline
a search boxfield::search(...)a text edit with an icon beside it
a multi-line editorfield::multiline(...)egui::TextEdit::multiline
a number with a unit, scrubbing, arithmeticfield::ValueField::new(&mut v, range).unit("px")egui::DragValue, egui::Slider
a labelled formform::Form rows (§5)egui::Grid
a default or secondary actionPushButton::primary(label), PushButton::secondary(label)ui.button, egui::Button::new
an icon buttonIconButton::new(Icon::X).tooltip(...)ImageButton
a link with an iconbutton::Linka hyperlink label
an on/offtoggle::Toggleui.checkbox
a list of choicescombo::showegui::ComboBox
a slider or a slider rowslider::Slider, slider::rowegui::Slider
a scrolling regionscroll::Area::vertical(id).show(ui, ...)egui::ScrollArea (any constructor)
a tabledatatable::DataTableegui_extras::TableBuilder
a dock group with tabspanel::group, panel::group_with_actionsa Frame with a header
a section headingpanel::section_labela bold label
a barbars::options, bars::status, bars::tools, bars::raila TopBottomPanel with a fill
a hairline or dividerbars::hairline, bars::dividerui.separator()
a modaldialog::Dialog with Choicesegui::Window
a tooltip.tooltip(...), tooltip::textad-hoc on_hover_text wording
menus, context menus, the Theme menuthe registry's menu_bar, menus, context_menu, theme_menu::registeregui::menu, a theme verb
a selectable rowtoolkit::selectableselectable_label
dim, faint or coloured texttoolkit::text::{dim, faint, role}RichText::color, ::font, ::size
rendered markdownmarkdown::Viewyour own layout
a document canvas with scroll barscanvas::surround, canvas::scrollbarsyour own
a prose region that may be lightcontent::surface(ui, &content, ...)ui.set_style, style_mut
a thumbnailthumbnail::Decoderyour own decoder

A plain ui.label is fine. When no widget fits, write it in the toolkit, not the app: read its colours and lengths from Chrome, name the spec section, snapshot it, then use it.

5. The form row

Labels outside the field in one right-aligned column as wide as the widest label the form can show; form_label_gap to the field; trailing controls right to left form_trailing_gap apart; the field takes the remainder; rows form_row_gap apart; the label's id is the field's accessible name.

let form = toolkit::form::Form::new(ui, &["From", "To", "Cc", "Subject"]);
form.row(ui, "To", |ui, label_id| {
    ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
        if ui.add(IconButton::new(Icon::Copy).tooltip(cc_hint)).clicked() { /* ... */ }
        field::text(ui, &mut to, "", ui.available_width()).labelled_by(label_id)
    })
});

A row taller than a field uses row_of with its height.

6. Spacing

tokenshippeduse for
xs2hairline clearance, icon to its text
sm4between related controls in a row
md8between groups in a row, panel inner padding
lg16between sections
xl24one control height; dialog outer margin

ui.add_space(space.md) is the approved form; the gate refuses a literal argument, not the method. An app never sets item_spacing, button_padding or interact_size, and never calls spacing_mut, visuals_mut, style_mut or any Context style setter.

A const is allowed only for content geometry (a column width, a thumbnail edge, a glyph size in a custom painter), named in capitals, commented, and listed in the app's style-allow.conf.mix with the reason.

7. Scroll areas

toolkit::scroll::Area::vertical("messages")      // the id salt: stable, unique in the window
    .max_height(h)
    .stick_to_bottom(true)
    .show(ui, |ui| { /* rows */ });

Also Area::horizontal, Area::both, .min_scrolled_height, .scroll_to(rect). It wraps egui's scroll area, so drag, page click, wheel, keyboard, nesting and two-axis behaviour are egui's; the toolkit supplies the defaults (no auto-shrink) and the style's bar: thin floating bars 4 pt at rest and 10 pt when hot, or solid 6 pt in a bevelled style, the handle in the state's text colour at the resting opacity, the track only while hot, all from tokens. Clippy refuses every egui::ScrollArea constructor by path.

8. Icons

Lucide, from the toolkit's Icon enum. A new icon is added to the toolkit (copy its SVG from the pinned Lucide release into libs/toolkit/assets/icons/, list it in icons.rs), never embedded in the app. Icons take the stroke installed on the context and the colour of the text around them. Sizes: IconButton is the icon_button token (28) with the icon at 52 %; an inline icon uses space.lg or an allowlisted content constant through paint_icon.

9. Menus, context menus, the palette, the Theme menu

Every action is a registry command with a stable id, a label key, a menu path, an optional default chord and typed parameters. The toolkit generates the menu bar, menus, context menus and palette from the registry. Right-click rows are 24 pt and flush. The View › Theme submenu is toolkit::theme_menu::register: its rows are commands, and view.theme.set {scheme?, style?, mode?, decorations?, captions?} is the same choice for agents. An app writes no theme verb of its own. The window's choice reaches the compositor too: a server-decorated window's title bar is drawn from the window's own look, attributed through the broker's record of the app's connection. A multi-window app's extra windows are matched by their toplevel tag once the toolkit sets one; until then they draw the session look under server decorations.

10. Dialogs

dialog::Dialog::new(id, title) with Choices carrying a Role so button order, Enter and Esc follow the platform rule. Wide dialogs use .width; resizable ones .resizable(default, min). A destructive prompt opens with its default focused and ringed.

11. Content regions

Chrome is what the toolkit paints from Chrome. Content is what the person reads or edits. Content keeps its own colours on purpose, but it is on the look:

  • a prose region drawn with content::surface takes the person's content settings (padding, font, light). A region with light: true draws the installed window's design, scheme, style and overlays (tunes and preview included) with only the mode forced to light; one with light: false inherits the window as it is;
  • an editor palette is built from the region's theme (editor::Palette::from_theme), never from literals;
  • a terminal grid draws the terminal.colors settings over the design's colour table for the session's scheme and mode, with the running program's palette changes over them until it resets them, and explicit RGB cells as sent.

<app>.look reports every content region under regions: a prose or editor region with the palette and font it draws with; a terminal region with its design defaults, the settings entries, the runtime overrides in force, the effective palette and its cursor rule.

12. What is forbidden in an app (and what checks it)

Clippy, by resolved path (an alias or a use cannot dodge it), and tools/style_gate.mix, for literals and shapes, refuse these in every egui-dependent crate under apps/, tests excluded:

refuseduse instead
egui::TextEdit::singleline, ::multilinefield::text, field::multiline, field::search
egui::ScrollArea::vertical, ::horizontal, ::both, ::newtoolkit::scroll::Area
egui::Grid::newtoolkit::form, datatable
egui::Ui::button, egui::Button::newPushButton, IconButton, Link
egui::Ui::checkboxtoggle::Toggle
egui::ComboBox::from_id_salt, ::from_labelcombo::show
egui::Slider::new, egui::DragValue::newslider::Slider, field::ValueField
egui::Ui::style_mut, set_style, spacing_mut, visuals_mut; egui::Context::set_style, set_global_style, set_visuals, set_fonts, global_style_mut, all_styles_mut, style_mut_of, set_style_oftoolkit::install; content::surface for a region
egui::RichText::font, ::size, ::colortoolkit::text::{dim, faint, role}; the heading style
toolkit::icons::stroke_width, Chrome::for_theme, style::apply, chrome::installicons::stroke_of(ctx), Chrome::of(ctx), toolkit::install
ui.add_space(<number>); vec2(<number>, <number>) or Vec2::splat(<number>) as spacingthe space tokens
any assignment to item_spacing, button_padding, interact_sizenothing: the style owns them
Color32::from_rgb, from_gray, from_hex, from_rgba_*, WHITE, BLACKa Chrome role
a stroke: f32, theme: &Theme, chrome: &Chrome or palette: parameter on a view functionread from the context
an unlisted const NAME: f32list it with a reason, or use a token
#[allow(...)] or #[expect(...)] on a disallowed_* lint inside an appthere is none; use the replacement
selectable_label, selectable_valuetoolkit::selectable

Each refusal prints the file, the line and the replacement. The example app and the snippets in §3 to §7 are the gate's passing fixture; one adversarial fixture exists per refusal. The gate runs with the other repository gates and in CI.

13. What the app exposes on the Bus for its look

The toolkit provides these; the app lists them in app.describe with its drive verbs and registers them in the verb registry:

  • <app>.look (query; {context?} for apps with several contexts): the authority identity (binding, incarnation, revision, source digest, or null while settingsd is missing), the window's look generation, which source was compiled and how it was obtained, the axes with sources (session, app, window), every style token and chrome role with value, default, source (design, design-default, legacy, session, app, preview, accessibility) and shadowed, each role's owning egui slots, each colour's checked flag, the fonts actually drawn, tune.unknown, the preview state, and regions.
  • <app>.look.changed (topic): published on every install with the window's context and generation. The compositor follows it for server-decorated title bars.
  • view.look.preview (command, intent): params {tune: {"style.field_radius": 3}, expect: {incarnation, revision, generation}} overlay this window only; {tune: {}} clears; refused stale_preview unless all three fence values match what the window draws now; at most 4096 bytes; cleared by every look change except the arrival of the window's pinned design source, which keeps the preview and re-applies it over the refined look. Run through <app>.execute, journaled like any command.
  • view.theme.set (command, intent): the window's own axis choice.

To change the session for every app, a caller writes settingsd: settings.validate to try, settings.apply with "tune.style.field_radius": 4 (fenced on the current revision; null resets; "tune": null resets all), settings.reset, or Prefs' prefs.appearance.set. Per app: "apps.<id>.tune.<family>.<name>". Terminal colours, session scope only: "terminal.colors.ansi.1": "#ff0000", "terminal.colors.foreground", .background, .cursor. Style and chrome tunes have session, app and window scope; terminal colours have session scope. A relation between two tokens (a resize corner at least as large as the edge) is a constraint checked on the final layered values and refused with both sides named. Every running app follows within one summary delivery, under any design source. Undo is the caller's held before-values applied as an inverse fenced on the accepted receipt's incarnation and revision; a conflict means someone else wrote in between.

14. Snapshots and tests

  • Every view has an offscreen snapshot in the app's tests/, rendered in at least Studio dark and one light scheme. Look at the image.
  • Rows are found by accessible name, never by position.
  • A registry test checks the verbs the app describes are the ones it registers, including <app>.look and the <app>.look.changed topic.
  • A theme-follow test feeds a look delivery with a tune and asserts Chrome::of(ctx) changed; one with ui.reduced_motion asserts the animation time is zero; one with a stale preview fence asserts refusal; one with a pinned source digest that differs asserts the embedded design first and the acquired source after; the lifecycle tests the example app carries run against the shell.

15. Review checklist

  • toolkit::install is the only look installer; no view function takes a stroke, theme, chrome or palette parameter.
  • The shell follows the example app's lifecycle: forwarder wakes on every event, the follower's returned read is the one sent, a disconnect resets the follower and clears authority and preview, a fault retries once, a differing source digest is acquired.
  • No raw egui widget or style setter from §12; clippy and the gate are green with no lint exception inside the app.
  • No spacing literal; every const is allowlisted with a reason.
  • No colour literal; coloured text goes through toolkit::text.
  • Forms use toolkit::form; labels are the fields' accessible names.
  • Scrolling uses toolkit::scroll::Area with a stable id salt.
  • Icons come from Icon; new ones were added to the toolkit.
  • Every action is a registry command; menus and the Theme menu come from the registry; no app theme verb.
  • <app>.look, <app>.look.changed, view.look.preview and view.theme.set are described and registered.
  • Content regions use content::surface and are reported under regions.
  • Snapshots exist for the changed view in a dark and a light scheme, and were looked at.
  • Nothing names a host, an operator path or a reference application.

This page as Markdown · This page describes the current main branch of MixOS.