Style and Theme
Semantic tokens, typed overrides, local inheritance and safe painters.
Style is the effective value consumed by existing layout and paint. Theme is
an optional source of palette, metrics, typography, density and explicit overrides.
A new Context still uses neutral grey Style::default(); existing examples do not
opt into another palette automatically.
use zaxis::{Color, Density, Theme};
context.set_theme(Theme::light().accent(Color::rgb(35, 85, 155)).density(Density::Compact));
let effective = context.style();Built-ins: Theme::dark(), light(), high_contrast(). Density is independent of
palette. Compact scales geometry, spacing and padding by 0.8; comfortable uses base
metrics. Font sizes do not change with density.
Tokens and actual consumers
Palette exposes background; surface, raised and control surfaces; foreground,
muted and disabled text; accent/on-accent; selected/on-selected; focus; border;
success/warning/error with contrasting foregrounds. accent(...) chooses black
or white for on-accent by measured contrast. Selection is a separate surface token.
Metrics exposes spacing, padding, control height, corner radius, border width,
elevation (Shadow), background backdrop blur, opacity and blur opacity.
Global blur applies to Root, Window and popup backgrounds. Controls and list rows
remain opaque and sharp; a control blur requires an explicit builder or surface override.
Typography supplies small/body/heading/title/code sizes (code is 13 px; the Code role is
set in the monospace family, see Text) and, in Typography::weights, a
FontWeight per role plus control (labels of Button, tabs, menus) and an optional
selected (active tab). Use Text::typography(TypographyRole::Heading); ordinary text
keeps Style::font_size and the body weight. Every weight defaults to Regular, so a theme
looks unchanged until a weight is set. TextStyle also carries family and tabular_numbers
for a subtree:
theme.typography.weights.heading = FontWeight::SEMIBOLD;
theme.typography.weights.control = FontWeight::MEDIUM;Components override their weight through typed styles: TextStyle::weight,
ButtonStyle::font_weight (variants and semantic status styles merge into it),
TextEditStyle::font_weight, WindowStyle::title_font_weight,
TitleBarStyle::font_weight, TableStyle::header_font_weight and
DisclosureStyle::font_weight (tree rows and collapsing headers). See
Font weights for the order. The family itself is
chosen by Context::with_fonts or RunOptions::with_font_family; a theme cannot switch it.
The initial audit found these separate dependencies. Theme now resolves values into the same fields used by the existing public components:
| Component | Previously separate defaults | Effective consumers |
|---|---|---|
| Button | button colors; fixed radius 5/minimum 24 | legacy colors/padding/font, button.surface, minimum geometry |
| Checkbox | button colors, radius 4, text-colored check | accent/on-accent, checkbox.body/indicator, side/gap/stroke |
| Slider | height 28, track 6, thumb 8; accent from text | accent/status colors, slider.track/fill/thumb, metrics |
| TextEdit | own fill/selection/placeholder/cursor | legacy editor fields, text_edit.surface, geometry, caret, selection foreground |
| NumberInput/DragValue | NumberStyle's grey fills | resolved number fields, number.surface; editing still uses TextEdit |
| Window/Root | shared panel/title metrics | window.body/title, padding/title metrics, shadow/blur |
| TitleBar | literal greys/red and fixed font | title_bar.surface/controls, semantic error, caption metrics |
| Popup | padding 2/gap 4/radius 4 | popup.surface/geometry; children inherit creating Ui |
| ComboBox | separate trigger/popup/text/selection palette | resolved combo_box, trigger/option; scrollbar inherits Ui |
| ColorPicker | fixed row/editor geometry, button-like fields | color_picker.body/field and metrics; RGB/HSV colors stay accurate |
| ScrollArea | separate grey thumbs/hints | resolved scroll and scroll.thumb states |
| Grid/Table | separate fills/stripes/selection/cell metrics | resolved grid/table, grid surface, table row/header surfaces |
| Text/Separator | text/border color; fixed separator thickness; Regular weight | text/ separator typed overrides; text weight from TextStyle or typography role |
| Loader/Progress | motion size, text-colored progress | loader, progress.track/fill, accent, existing scheduler |
| Tabs/SelectionIndicator | selected Button, text-colored indicator | Button selected layer, independent accent indicator |
| SplitPane | existing handle palette/panel surfaces | resolved split colors/spacing; explicit SplitSurface remains |
| CollapsingHeader/TreeView | shared compact disclosure row | collapsing.header/tree.row ControlStyle, density geometry, typography, motion; optional guides |
Shapes, renderer clipping, glyph keys, tessellation cache and draw protocol remain the backend. Colors change affected paint descriptions; metrics rerun immediate layout and choose corresponding size/wrap text keys. Style revisions are not global mesh or glyph cache keys.
Explicit overrides and precedence
None inherits. Some(TRANSPARENT), Some(Border::NONE), zero radius, opacity, shadow or blur are explicit values. No comparison with defaults guesses override presence.
use zaxis::{Border, Button, ButtonStyle, ControlStyle, SurfaceStyle, Theme};
let mut theme = Theme::dark();
theme.overrides.button.surface.idle.border = Some(Border::NONE);
theme.overrides.number.height = Some(40.0);
context.set_theme(theme.clone().density(zaxis::Density::Compact));
// Explicit border and numeric height survive accent/density changes.
ui.add(Button::new("Save").style(ButtonStyle {
surface: ControlStyle {
idle: SurfaceStyle::fill(zaxis::Color::rgb(70, 95, 125)),
..Default::default()
},
..Default::default()
}));Order: tokens → theme component overrides → outer/inner Ui overrides → instance
style/builders → state layers. Existing NumberStyle, GridStyle, TableStyle,
ComboBoxStyle, ScrollStyle, SplitStyle and their builders remain. A complete
specialized .style(...) replaces that component's defaults. Their
*StyleOverride variants expose individual optional fields in theme/scoped
patches. Nested surface/state patches merge property by property.
Style::collapsing and Style::tree are optional-field typed styles. Their
StyleOverrides fields merge individual properties, including local with_style /
with_theme and instance .style(...). Shared DisclosureStyle exposes height,
padding, chevron size/stroke, icon size/gap, font size, motion and ControlStyle.
CollapsingStyle adds content padding/spacing and optional height animation;
TreeStyle adds indent, guide Border and scroll style. Explicit transparency,
zero and Border::NONE remain overrides. Fixed tree row height must stay positive.
ControlState has independent enabled, hovered, pressed, selected, focus and
semantic-status flags. Layers: idle, selected, semantic status, then disabled
or pressed or hover, then focus. Disabled never runs hover rules. Focus
border is an immediate layer over selected/pointer surfaces;
focus.border = Some(Border::NONE) explicitly removes it.
SurfaceStyle provides solid fill (SurfaceStyle::fill), gradients, foreground,
border, radius, shadow, opacity and optional blur in each state. Missing state
properties retain the base/selected appearance.
Scalar instance geometry builders (size, width, height, padding, font size,
rounding) stay invariant during interaction. Button/Checkbox .border(...) is
invariant except for focus; .blur(...) fixes the instance blur. Slider
.color(...) fixes its filled track/thumb accent. TextEdit .text_color(...) is
invariant. Typed surface.idle properties set the base and may be changed by state
rules. Layout/hit bounds do not follow animated surface radius/shadow/border.
HoverStyle and Hover use this same resolver. Per-widget hover preset beats wrapper; wrapper beats theme defaults. Explicit presets override their listed properties on a typed hover patch. HoverStyle::NONE suppresses preset effects while retaining explicit typed state rules. Hover::on_hover remains an enabled/unpressed overlay.
Local subtrees and popups
use zaxis::{Color, StyleOverrides, Theme};
ui.with_theme(&Theme::light(), |ui| {
ui.button("Local light button");
ui.with_style(&StyleOverrides {
text_color: Some(Color::rgb(70, 40, 100)), ..Default::default()
}, |ui| { ui.vertical(|ui| { ui.label("Nested override"); }); });
});
ui.button("Original theme again");Scopes restore after normal return, early closure return and unwinding. Styles live on Ui, never in temporarily modified global Context. Horizontal/vertical, sized/fill, Grid/Table cells, ScrollArea rows, Presence, pages, SplitPane panels and nested children inherit. Popup inherits its creating subtree despite drawing above other windows; floating ColorPicker carries the originating style too. with_theme supplies a new subtree base; with_style merges into the current value. Local background never changes Context's native clear color. A scope does not paint a backing panel automatically; supply a container/shape where needed.
Safe control painting
Button, Checkbox, Slider expose .painter(PaintMode, callback). Replace skips
standard part painting including its blur; Before/After explicitly compose.
Checkbox calls for body/indicator; Slider calls for track/thumb, with standard
fill included in its track part. ControlPaint supplies real bounds, resolved
surface, all state flags and a value snapshot.
use zaxis::{Button, PaintMode, Shape};
ui.add(Button::new("Custom").painter(PaintMode::Replace, |p, info| {
p.paint(Shape::rect(info.bounds, info.style.fill.unwrap().start)
.corner_radius(info.style.rounding.unwrap())
.border(info.style.border.unwrap()));
}));Painter::paint/text/clip_rect expose safe commands without Ui, mutable model, layout or hit registration. The component owns capture, focus, Id and mutation. Use size builders for a different allocation. Commands use normal caching and clipping. Callbacks may borrow and are never retained in Context; closure addresses are never keys. They run per UI pass, so do not mutate the model through captured interior mutability.
Scheduling and retained state
Identical set_style/set_theme do not request another redraw. Theme equality is checked before resolution. Local equality preserves stable animation channels. Default theme application snaps and preserves focus, capture, popup, scroll, editor selection/undo, panel geometry and bound application values. set_theme_animated(theme, TweenOptions::new(duration)) explicitly uses the existing scheduler. Forward finite tweens only; reduced motion snaps. All metrics and geometry use the target immediately. Colors interpolate in linear-light sRGB. Optional surface colors interpolate when both endpoints explicitly contain them; a change in inheritance of that property applies immediately. Completed palette transitions stop repaint.
Contrast and limits
Tests check ordinary/muted text on background/surface/raised/control/hover/pressed at 4.5:1; on-accent/on-selected and semantic foregrounds at 4.5:1; focus on surface/control/hover/selected at 3:1. Dark Theme uses brighter muted text than legacy Style to pass the hover-surface threshold. Disabled text is intentionally dim and excluded from the normal text criterion. Picker markers intentionally use black/white against arbitrary actual RGB/HSV colors. Custom alpha, gradients, opacity and blur may reduce contrast. Composite over the actual backdrop and test worst-case endpoints with contrast_ratio. Choosing on-accent alone cannot guarantee accent/focus contrast against all backgrounds. Local themes need appropriate backing surfaces; shadows stay inside active clips.
Migration
Style::default, existing public fields, Context::style/set_style, Ui::style,
struct updates (..Style::default()) and old builders remain. set_style installs
exactly its supplied Style and clears the Theme source; it does not rederive
component palettes. Use Theme for changes that should propagate across defaults.
Editing resolved legacy fields is not a token edit. Explicit typed component
patches take priority over their legacy fallback fields.
Source incompatibility: exhaustive Style/specialized-style literals need the new
fields; prefer ..Default::default(). Button's default generic parameter preserves
ordinary Button type annotations; a painted button carries its callback type.
Checkbox/Slider retain their borrowed lifetimes. Compatibility exports remain.
Run cargo run --example themes to compare actual controls, density, overrides,
paint hook, selection, focus, overlays and the font weight scale (SemiBold headings,
Medium captions, Cyrillic and emoji in every weight).
Glass
An iOS-style glass look for interactive controls: a translucent body over a backdrop that is
blurred, made more vivid and bent inside a narrow edge band like a convex lens, with a thin
specular rim lit from the top left. It is one backdrop effect (BackdropEffect::glass), so
it is drawn by the renderer from the control's real rounded shape, not painted from layers.
use zaxis::{Button, ButtonVariant, Theme};
ctx.set_theme(Theme::dark().glass(1.0)); // every glass-capable control
ui.add(Button::new("Play").variant(ButtonVariant::Glass));
ui.add(Button::new("Buy").variant(ButtonVariant::Solid).glass(1.0)); // tinted glass
ui.add(Button::new("Flat").glass(0.0)); // opt out of a glass themeTheme::glass(strength) (Metrics::glass) sets SurfaceStyle::glass on buttons, checkboxes,
switch and slider thumbs, the segmented plate and its selection, and combo box triggers; tracks
and wells become faint veils so the backdrop shows through the whole control. Ghost buttons
stay flat. SurfaceStyle::glass sets it for one surface of any ControlStyle. A glass surface
blurs by 10 logical pixels unless blur is set, and its fill only tints the glass.
Glass shows only over something drawn behind it (an image, a gradient, other content); over a flat window background it is a subtle tinted rim. Limits: radio buttons, text fields, number inputs, tab bars and menus stay flat; the lens bends toward content inside the control's own bounds and never reads from outside it; there is no chromatic dispersion.
Dock tokens
Style::dock and ThemeOverrides::dock contain DockStyle; local/builder
patches merge optional values and nested SurfaceStyle / TabsStyle fields.
Unspecified colors inherit the active palette. Density changes the inherited
spacing/padding and tab metrics.
| Field | Default Tiled / Flat |
|---|---|
preset, motion | Tiled, Snappy |
gap | theme spacing (8 px comfortable) / 1 px |
panel_radius | 10 px / 0 px; arbitrary per-corner radii supported |
surface, inactive_border | theme window surface and border |
padding, minimum | theme spacing; 120 × 80 logical px |
tabs | inherited tab strip controls and header metrics |
ring_width, ring_gap | 2 px and 1 px / 1 px and 0 px |
ring_active, ring_inactive | accent; muted foreground at 45% alpha when inactive |
ring_glow | 5 px / 0 px; faint external contour strokes |
ring_gradient | false; optional six-second rotating two-color contour |
preview_color | accent; translucent fill and border |
spring | motion preset; critical damping, mass 1, 0.2 px / 0.8 px/s thresholds |
DockMotion::Snappy is 6 Hz; Smooth is 4.5 Hz; Off skips all Dock motion.
Global reduced motion also disables motion and the rotating contour. A custom
spring overrides the motion preset's spring, with invalid options falling back
to the preset. surface.rounding supplies the radius unless panel_radius is set.
Surface gradients, borders, opacity, shadows and backdrop blur use the existing
Appearance painter. The contour uses ordinary gradient-border geometry; no
custom shader/material is necessary.