MixOS GUI app style guide
1. The three rules
- 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.
- Tokens and widgets, never literals. A length is a token. A colour is a role. A control is a toolkit widget.
- The look is on the Bus. Every app answers
<app>.look, publishes<app>.look.changed, runsview.look.previewandview.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)anddrive::install(ctx, app)are called once in the eframe creator; the app's strings areStrings::newover 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,Changedand a summaryTopichint ask the follower for a read;Disconnectedresets 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;Commandis served (<app>.lookand<app>.executethrough the look layer, then the shipped drive layer'srequest, whose queued answers come back fromdrive::logiceach frame); a read's answer goes tolooked, whose returnednextread is sent exactly as returned; a transient fault arms one bounded retry.install_themeis 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>.lookreports the violation.App::uidraws the title bar on the rootUiwithtoolkit::titlebar::show(no stroke argument; it reads the context) and the content insideegui::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.
| need | read |
|---|---|
| a colour | Chrome::of(ui.ctx()).palette.<role> |
| a chrome length | Chrome::of(ui.ctx()).metrics.<name> |
| a style form (cards or strips, switch or checkbox, bevels) | Chrome::of(ui.ctx()).style.<token> |
| the icon stroke | toolkit::icons::stroke_of(ui.ctx()), or just use IconButton |
| a gap | toolkit::space::of(ui.ctx()).md (xs, sm, md, lg, xl) |
| field radius, padding and focus | toolkit::field::Style::of(ui.ctx()), or just use the field widgets |
| dim or faint text | toolkit::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
| need | use | not |
|---|---|---|
| a text input, one line | field::text(ui, &mut s, hint, width) | egui::TextEdit::singleline |
| a search box | field::search(...) | a text edit with an icon beside it |
| a multi-line editor | field::multiline(...) | egui::TextEdit::multiline |
| a number with a unit, scrubbing, arithmetic | field::ValueField::new(&mut v, range).unit("px") | egui::DragValue, egui::Slider |
| a labelled form | form::Form rows (§5) | egui::Grid |
| a default or secondary action | PushButton::primary(label), PushButton::secondary(label) | ui.button, egui::Button::new |
| an icon button | IconButton::new(Icon::X).tooltip(...) | ImageButton |
| a link with an icon | button::Link | a hyperlink label |
| an on/off | toggle::Toggle | ui.checkbox |
| a list of choices | combo::show | egui::ComboBox |
| a slider or a slider row | slider::Slider, slider::row | egui::Slider |
| a scrolling region | scroll::Area::vertical(id).show(ui, ...) | egui::ScrollArea (any constructor) |
| a table | datatable::DataTable | egui_extras::TableBuilder |
| a dock group with tabs | panel::group, panel::group_with_actions | a Frame with a header |
| a section heading | panel::section_label | a bold label |
| a bar | bars::options, bars::status, bars::tools, bars::rail | a TopBottomPanel with a fill |
| a hairline or divider | bars::hairline, bars::divider | ui.separator() |
| a modal | dialog::Dialog with Choices | egui::Window |
| a tooltip | .tooltip(...), tooltip::text | ad-hoc on_hover_text wording |
| menus, context menus, the Theme menu | the registry's menu_bar, menus, context_menu, theme_menu::register | egui::menu, a theme verb |
| a selectable row | toolkit::selectable | selectable_label |
| dim, faint or coloured text | toolkit::text::{dim, faint, role} | RichText::color, ::font, ::size |
| rendered markdown | markdown::View | your own layout |
| a document canvas with scroll bars | canvas::surround, canvas::scrollbars | your own |
| a prose region that may be light | content::surface(ui, &content, ...) | ui.set_style, style_mut |
| a thumbnail | thumbnail::Decoder | your 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
| token | shipped | use for |
|---|---|---|
xs | 2 | hairline clearance, icon to its text |
sm | 4 | between related controls in a row |
md | 8 | between groups in a row, panel inner padding |
lg | 16 | between sections |
xl | 24 | one 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::surfacetakes the person's content settings (padding, font, light). A region withlight: truedraws the installed window's design, scheme, style and overlays (tunes and preview included) with only the mode forced to light; one withlight: falseinherits 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.colorssettings 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:
| refused | use instead |
|---|---|
egui::TextEdit::singleline, ::multiline | field::text, field::multiline, field::search |
egui::ScrollArea::vertical, ::horizontal, ::both, ::new | toolkit::scroll::Area |
egui::Grid::new | toolkit::form, datatable |
egui::Ui::button, egui::Button::new | PushButton, IconButton, Link |
egui::Ui::checkbox | toggle::Toggle |
egui::ComboBox::from_id_salt, ::from_label | combo::show |
egui::Slider::new, egui::DragValue::new | slider::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_of | toolkit::install; content::surface for a region |
egui::RichText::font, ::size, ::color | toolkit::text::{dim, faint, role}; the heading style |
toolkit::icons::stroke_width, Chrome::for_theme, style::apply, chrome::install | icons::stroke_of(ctx), Chrome::of(ctx), toolkit::install |
ui.add_space(<number>); vec2(<number>, <number>) or Vec2::splat(<number>) as spacing | the space tokens |
any assignment to item_spacing, button_padding, interact_size | nothing: the style owns them |
Color32::from_rgb, from_gray, from_hex, from_rgba_*, WHITE, BLACK | a Chrome role |
a stroke: f32, theme: &Theme, chrome: &Chrome or palette: parameter on a view function | read from the context |
an unlisted const NAME: f32 | list it with a reason, or use a token |
#[allow(...)] or #[expect(...)] on a disallowed_* lint inside an app | there is none; use the replacement |
selectable_label, selectable_value | toolkit::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 lookgeneration, which source was compiled and how it was obtained, the axes with sources (session,app,window), every style token and chrome role withvalue,default,source(design,design-default,legacy,session,app,preview,accessibility) andshadowed, each role's owning egui slots, each colour'scheckedflag, the fonts actually drawn,tune.unknown, the preview state, andregions.<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; refusedstale_previewunless 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>.lookand the<app>.look.changedtopic. - A theme-follow test feeds a look delivery with a
tuneand assertsChrome::of(ctx)changed; one withui.reduced_motionasserts 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::installis 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
constis 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::Areawith 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.previewandview.theme.setare described and registered. -
Content regions use
content::surfaceand are reported underregions. - 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.