Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Styling System

Use theme tokens for application-wide values, variants for standard appearances, and style traits for custom widget chrome.

Tier 0:  Tokens          (colors, shapes, motion, typography, layout)
Tier 1:  Variants         (per-widget closed enums: Filled / Plain / …)
Tier 2:  Recipes          (paint vocabulary, shape, fill, border, shadow)
Tier 3:  Style protocols  (`trait FooStyle { fn make_body(...) -> WidgetId }`)

The default implementations of Tier 3 (the Recipe*Style types shipped in teksilo-widgets/src/styles/) read Tier 2 recipes; the default recipes read Tier 0 tokens. So Tier 3 contains Tiers 0-2 for the IntUI preset, but the trait protocol at Tier 3 is the escape hatch that lets apps replace the entire chrome of any widget without touching the widget source.

Mental model: which tier do I use for X?

TaskTierAPI
Tweak a color across the whole app0theme.colors.accent = …
Make a single Button's label redn/aButton::text_role(Color::RED) (always-allowed prop override; a per-instance fill goes through .style(...))
Pick "outlined" instead of "filled" on a Button1Button::variant(ButtonVariant::Outlined)
Make every Outlined Button thicker2Modify a BorderRecipe in the IntUI preset, OR ship a new preset
Replace Button chrome entirely (glassmorphism / brutalist / Material-3)3impl ButtonStyle for MyGlassButton then theme.style_slots.button = Some(Rc::new(MyGlassButton))
Reskin from designer-exported SVGs3An image-backed impl ButtonStyle (a built-in ImageBackedButtonStyle + manifest loader is planned, not shipped)

Implement and install a style trait when token and variant changes are insufficient.

Tier 0: Tokens

The five token groups (ColorTokens, ShapeTokens, LayoutTokens, TypographyTokens, MotionTokens) live in teksilo-tokens/src/. They're pure data structs with no widget knowledge.

Theme aggregates the five token groups plus an id, appearance, the input/density tokens, and the typed style-slot bag:

#![allow(unused)]
fn main() {
pub struct Theme {
    pub id: ThemeId,                        // "intui.light", "custom", …
    pub appearance: ThemeAppearance,        // Light | Dark, required
    pub colors: ColorTokens,
    pub layout: LayoutTokens,
    pub typography: TypographyTokens,
    pub shape: ShapeTokens,
    pub motion: MotionTokens,
    pub input: InputTokens,                  // density, target sizes, gesture slop
    pub style_slots: ComponentStyleSlots,    // typed Rc<dyn FooStyle> slots
    pub extensions: ThemeExtensions,
}
}

There is no Theme::default(). Apps explicitly pick a preset:

#![allow(unused)]
fn main() {
use teksilo::prelude::intui;
let theme = intui::light();   // or intui::dark()
}

Other presets ship as opt-in Cargo features (Material 3, macOS, Fluent, KDE), only IntUI is bundled by default.

Reactive. Theme lives behind a Signal<Theme> on WidgetTree, set_theme(...) dirty-marks every widget for relayout and repaint without rebuilding the tree. Focus, scroll offsets, and animation state survive theme swaps. See docs/reactive-theme.md.

Extensions. theme.with_extension::<MyPalette>(...) / theme.extension::<MyPalette>() attach app-specific extras that don't fit any of the five token groups. Cheap (TypeId lookup), arbitrary type.

How a widget goes grey when disabled

A widget is disabled when its own enabled prop is false or any ancestor's is, a control inside a disabled form is disabled. Its chrome greys by one of two routes, and there is a trap in each.

Role-driven chrome dims for free. ColorProp::resolve(theme, effective_enabled), which every role-driven leaf (TextWidget, IconWidget, RectWidget) calls at paint time, substitutes the disabled counterpart of a role in a disabled subtree: any TextRole → TextRole::Disabled; the accent family (SurfaceRole::Accent / AccentHover / AccentPressed, BorderRole::Accent) → their AccentDisabled counterpart; and the neutral interactive SurfaceRole::Field / BorderRole::Field → their Disabled counterpart. This is why most recipes never mention is_disabled.

Note:️ The substitution only reaches roles. ColorProp::Bound(Signal<Color>) resolves to s.get() and ignores enabled entirely, so a recipe that folds per-state colours into a flat reactive colour (as RecipeButtonStyle does via PerStateRecipe + bind_fill) gets no paint-time safety net, and must select its WidgetState::Disabled from cfg.is_disabled.

Neutral controls must opt into a Field role. The substitution deliberately leaves passive surfaces alone, a disabled Panel keeps its surface. It has to: a text field's frame and a passive Panel both painted SurfaceRole::Content, so the hook could not tell them apart, and dimmed neither. That is what Field is for. It resolves identically to Content while enabled and substitutes to Disabled when not, so a field dims and a panel does not:

#![allow(unused)]
fn main() {
// A field's frame. No `is_disabled` needed for the resting case, the role
// dims itself at paint, from the live arena.
let bg = RectWidget::new().background(SurfaceRole::Field);

// The border still consults `is_disabled`, so disabled outranks *focus*.
let border_role = cfg.is_focused.zip(&cfg.is_disabled).map(|(f, d)| {
    if *d { BorderRole::Disabled }
    else if *f { BorderRole::Focused }
    else { BorderRole::Field }
});
}

SurfaceRole::Disabled / BorderRole::Disabled resolve to the neutral surface_disabled / border_disabled tokens. Do not reach for AccentDisabled here: it is a washed-out accent (pale cyan in IntUI), right for an accent-filled Button and wrong for a grey field.

Getting the signal. ctx.effective_enabled_signal(self_id).map(|on| !*on) ANDs the widget's own enabled prop with every ancestor's. It is a node-resident signal that the framework refreshes from the live arena each state-change pass, so it may be bound to a prop and passed to ctx.effect.

It is deliberately not a signal derived by walking ancestors at call time. A widget's parent is still None while its own build() runs, insert_widget inserts the node parentless and wires the parent only after build() returns, so such a walk sees an empty chain and captures the widget's own enabled prop as the whole answer, permanently. Prefer a Field role over the signal where you can: the paint-time route reads the live tree and cannot go stale.

Raw-colour widgets bypass all of this. Anything shaping through a RichTextEngine (e.g. TextInputField) hands GPU colours straight to the engine, so the substitution above never reaches its glyphs on its own. Resolve against ctx.effective_enabled in paint instead, including any ColorProp the app set (TextInputField::text_color resolves one there every paint).

Color::mix and non-finite factors

darken, lighten, desaturated, and ColorTokens::for_inactive_window (the window-deactivation accent projection, see window-activation.md) all bottom out in Color::mix(other, t). t is clamped to [0, 1] before use, but a bare t.clamp(0.0, 1.0) is not enough on its own: f32::clamp returns NaN for a NaN input rather than saturating it, so a NaN factor used to poison every channel and produce an unrenderable colour. Color::mix now maps a NaN factor to 0.0 (returning self unchanged) before clamping, the safest reading of an undefined mix. ±inf needs no such special case: the clamp already maps them to 1.0 / 0.0 correctly. A caller deriving t from a ratio that can legitimately divide by zero no longer needs to guard it before calling mix.

Tier 1: Variants

Each themable widget exposes a closed *Variant enum naming its design-language presentations. The variant is a hint: the active Tier-3 style decides what it means.

#![allow(unused)]
fn main() {
ButtonVariant    { Filled, Tinted, Outlined, Plain, Ghost, Link, Destructive }
ToggleVariant    { Switch, Pill, Square, Inset }
CheckboxVariant  { Square, Rounded, Circle }
RadioVariant     { Circle, Square, Rounded }
IconButtonSize   { Compact, Default, Toolbar, Large, Hero }  // size = variant for IconButton
CardVariant      { Plain, Elevated*, Outlined, Filled }       // * = #[default]
PanelVariant     { Plain, Sunken, Raised, Highlighted }
PopoverVariant   { Default, Menu, Tooltip }
SliderVariant    { Continuous, Discrete, Range }
TextInputVariant { Outlined, Filled, Underline, Bare }
ComboBoxVariant  { Outlined, Filled, Underline, Plain }
ScrollBarVariant { Permanent, Overlay, Thin }
AvatarShape      { Circle, RoundedSquare, Square }            // and AvatarSize, AvatarCorner, AvatarPresence
RadioTileVariant { Outlined, Elevated, Filled }
DropTargetVariant { Default, Prominent, Subtle, None }        // border weight
// SplitButton reuses ButtonVariant
}

Card defaults to Elevated (shadow + surface_main), the "just works" Card that matches pre-refactor behaviour. Use .variant(CardVariant::Plain) for a flat surface.

The remaining themable widgets are variant-free: MenuItem, StandardListItem / StandardTreeItem, TabBar, TooltipWidget, Dialog, Snackbar, Banner, SegmentedControl, ProgressBar, Link, Badge, SearchField, SpinBox, DateEdit, ColorPicker, Calendar, RichTextEditor, ListView / TreeView (via ListContainerStyle), TableView / TreeTableView (via TableStyle). Their style traits take a *StyleConfig with no variant field, the design language has a single canonical shape, or the variant distinction lives elsewhere (e.g. ProgressBarKind for determinate vs indeterminate).

Slider and ScrollBar additionally carry an orientation enum (SliderOrientation, ScrollBarOrientation) alongside the variant, since orientation changes layout, not just paint.

Several widgets have multi-method style traits where chrome decomposes into named slots (e.g. TabStyle::make_body + make_bar). See Multi-method styles below for the full list.

Set per-call: Button::new(lit!("Save")).variant(ButtonVariant::Outlined). Set per-app via a custom Tier-3 style that defaults a variant for unspecified callers.

IntUI variant policy. Int UI is intentionally minimalist about button styling, destructive actions live in confirmation dialogs where the body carries the warning, not the button. So the IntUI RecipeButtonStyle collapses several variants: Destructive → Filled, Tinted/Outlined → Plain, Link → Ghost. Other design languages (Material 3, Fluent, macOS, KDE) honour them distinctly.

Tier 2: Recipes

Recipes are pure data describing paint vocabulary. They live in teksilo-core/src/styles/recipe.rs. Primitive recipe types:

#![allow(unused)]
fn main() {
pub enum ShapeRecipe {
    Rect { corner_radius: CornerRadius },
    Pill,                          // corner = min(w,h)/2
    Circle,
}

pub enum FillRecipe {
    Solid(RecipeColor),
    // overlay composited over base at `alpha` → flat color. The M3 /
    // Fluent "state layer" (hover = 8 %, pressed = 12 % on-color).
    StateLayer { base: RecipeColor, overlay: RecipeColor, alpha: f32 },
    LinearGradient { stops: Vec<GradientStop>, angle_deg: f32 },
    RadialGradient { stops: Vec<GradientStop>, center: (f32, f32), radius: f32 },
    None,
}

pub struct BorderRecipe {
    pub width: f32,
    pub color: RecipeColor,
    pub style: BorderStyle,           // Solid | Dashed { dash, gap } | Dotted { gap }
    pub position: BorderPosition,     // Inside | Center | Outside (now honoured)
    pub sides: Option<BorderSides>,   // None = uniform; Some = per-side widths
}
// BorderSides { top, trailing, bottom, leading: f32 }, e.g.
// BorderRecipe::underline(w, color) for an M3/Fluent filled-field underline.

pub struct ShadowRecipe {
    pub offset: Vec2,
    pub blur: f32,
    pub spread: f32,
    pub color: RecipeColor,
}
}

Per-state cascades. Most widgets need different recipes for hover / pressed / focused / disabled. The answer is PerStateRecipe<T> with an explicit fallback chain, Teksilo's take on Flutter's WidgetStateProperty<T>:

#![allow(unused)]
fn main() {
pub struct PerStateRecipe<T> {
    pub idle:     T,
    pub hover:    Option<T>,    // falls back to idle
    pub pressed:  Option<T>,    // falls back to hover, then idle
    pub focused:  Option<T>,    // falls back to hover, then idle
    pub disabled: Option<T>,    // falls back to idle
}
}

PerStateRecipe::resolve(WidgetState) -> &T walks the chain. No closures, fully Serde-serialisable, theme-file-friendly.

Colors in recipes. Recipes hold a RecipeColor enum (not ColorProp), Static | Surface(SurfaceRole) | Border(BorderRole) | Text(TextRole), so the full theme cascade still applies but the recipe stays plain data (serializes cleanly for inspector JSON Export and TOML image-theme manifests).

Gradients are rendered. FillRecipe::LinearGradient / RadialGradient paint through the SDF gradient pipeline (via PaintProp, the gradient-or-solid fill prop RectWidget accepts). Anything Into<ColorProp> is also Into<PaintProp> as a solid, so existing fills are unchanged.

Configurable dimensions per widget. Every themable widget now surfaces a public FooRecipe dimension struct, and its RecipeFooStyle carries recipe: FooRecipe with a RecipeFooStyle::new(recipe) constructor. Default fills the recipe from the IntUI pub const dimension block (kept as the default source), so a theme can tweak just the dimensions without writing a new Tier-3 impl:

#![allow(unused)]
fn main() {
let toggle = RecipeToggleStyle::new(ToggleRecipe {
    track_width: 52.0, track_height: 32.0, thumb_diameter: 24.0, thumb_inset: 4.0,
});
theme.style_slots.toggle = Some(Rc::new(toggle));
}

The four multi-method widgets (Tab, Dialog, Table, Calendar) expose a flat recipe each (TabRecipe, DialogRecipe, TableRecipe, CalendarRecipe). A handful with no tunable dimensions (SpinBox, SplitButton, GridView, ListContainer, RichTextEditor) stay unit structs.

Tier 3: Style protocols

The escape hatch. Each themable widget exposes a trait:

#![allow(unused)]
fn main() {
pub trait ButtonStyle: 'static {
    fn make_body(&self, cfg: &ButtonStyleConfig, ctx: &mut BuildContext) -> WidgetId;
}

pub struct ButtonStyleConfig {
    pub label:       WidgetId,           // pre-built label subtree
    pub is_pressed:  Signal<bool>,
    pub is_hovered:  Signal<bool>,
    pub is_focused:  Signal<bool>,
    pub is_disabled: Signal<bool>,
    pub variant:     ButtonVariant,      // a hint; impl may ignore
}
}

The widget builds the parts (label, optional icon, four state signals), hands the bag to the active style, and uses the returned WidgetId as its root child. Everything else, background, border, focus ring, padding, min size, is the style's responsibility.

ButtonStyle also has two defaulted hooks for the label colour, which the Button resolves before make_body runs: label_text_role(variant) answers once per variant, and label_text_role_for(variant, state) is asked again each time the interaction state changes (idle, hovered, pressed, focused), so a style can brighten the label on hover. The second forwards to the first by default and a None keeps the built-in mapping; the change is a repaint, not a rebuild. A per-call Button::text_role(...) wins over both.

Checkbox and RadioButton keep their label and caption text on the widget rather than in the style: .label_style(..) / .label_color(..) and .caption_style(..) / .caption_color(..) override the defaults (Body / Primary, Small / Secondary), and a role colour still dims when the control is disabled. MessageBox takes .title_alignment(..) and .button_alignment(..) for the platform's alert layout (macOS centres both).

The trait is 'static only (not Send + Sync) because all Teksilo trees are single-threaded by construction; Rc<dyn FooStyle> is the public alias (SharedButtonStyle and friends).

Same shape across widgets. The style traits live in teksilo-core/src/styles/, one per ComponentStyleSlots slot. All but three return WidgetId from their make_* methods and take a *StyleConfig describing the inputs that vary by widget; the exceptions are all-recipe traits that hand back paint data instead, ChartStyle (bar_fill / area_fill / donut_fill / gridline), GridViewStyle (focus_ring / marquee / insertion / pinned_header_surface) and TextSelectionStyle (handle / magnifier). The trait is the public API; everything below it is implementation. The full list lives in the migration status table.

Worked example: a Material-3-flavoured Button

#![allow(unused)]
fn main() {
use std::rc::Rc;

use teksilo_core::build_context::BuildContext;
use teksilo_core::styles::{ButtonStyle, ButtonStyleConfig};
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::{CornerRadius, SurfaceRole};
use teksilo_widgets::primitives::{Padding, RectWidget, ZStack};

struct MaterialFilledButton;

impl ButtonStyle for MaterialFilledButton {
    fn make_body(&self, cfg: &ButtonStyleConfig, ctx: &mut BuildContext) -> WidgetId {
        // Material 3 filled buttons are tall (40 dp), pill-shaped, with
        // a small elevation that lifts on hover. State-driven `Accent` /
        // `AccentHover` / `AccentPressed` cover the surface; disabled
        // collapses to a flat translucent grey.
        let bg = cfg.is_pressed
            .zip3(&cfg.is_hovered, &cfg.is_disabled)
            .map(|(pressed, hovered, disabled)| {
                if *disabled { SurfaceRole::AccentDisabled }
                else if *pressed { SurfaceRole::AccentPressed }
                else if *hovered { SurfaceRole::AccentHover }
                else { SurfaceRole::Accent }
            });

        let rect = ctx.add(
            RectWidget::new()
                .background(bg)
                .corner_radius(CornerRadius::uniform(20.0)),
        );

        let padded_label = ctx.add(
            Padding::symmetric(10.0, 24.0)     // M3 spec: 10×24
                .child(cfg.label),
        );

        ctx.add(ZStack::new().child(rect).child(padded_label))
    }
}
}

Install per-call: Button::new(lit!("Save")).style(MaterialFilledButton). Install theme-wide:

#![allow(unused)]
fn main() {
let mut theme = intui::light();
theme.style_slots.button = Some(Rc::new(MaterialFilledButton));
}

The widget honours this precedence at every build():

per-call .style(...)  >  theme.style_slots.button  >  RecipeButtonStyle::default()

Tested end-to-end in teksilo-widgets/src/button.rs under theme_slot_supplies_button_style_when_no_override / per_call_style_override_wins_over_theme_slot.

Built-in presets

PresetWhereStatus
intui::light / intui::darkteksilo_core::presets::intuishipped, the default look
material3::light / material3::darkteksilo-theme-material3 crateshipped, Material 3
fluent::light / fluent::darkteksilo-theme-fluent crateshipped, Windows 11 / WinUI 3
macos::light / macos::darkteksilo-theme-macos crateshipped, macOS Aqua / Dark Aqua
kde::light / kde::darkteksilo-theme-kde crateshipped, KDE Plasma's Breeze
Image-backed themesteksilo-image-theme cratenot yet shipped

The four sibling presets are behind the umbrella features theme-material3, theme-fluent, theme-macos and theme-kde, and the prelude re-exports each crate under its short name (material3, fluent, macos, kde).

KDE Plasma's Breeze (kde, teksilo-theme-kde) differs from the others in where its colours come from. They are a KDE colour scheme, read and resolved by teksilo-kde-scheme exactly as KDE resolves it: kde::light() and kde::dark() are the stock Breeze Light and Dark, kde::from_scheme(&scheme) the theme of any scheme (light or dark as KDE judges it), kde::light_with_accent(PlasmaAccent::Purple.color()) the stock scheme with Plasma's accent applied, and kde::from_desktop(&desktop, density) the theme of a KDE desktop as it is configured (its scheme with the user's accent, its fonts, its animation speed, its breezerc). The scheme's own colours stay on the KdePalette theme extension. Where the scheme falls short of WCAG, the colour tokens are lifted by rule and the extension keeps the vendor colour. The geometry is Breeze's (a 5 px radius, text-driven control heights, a 2 px focus ring half inside the control), the motion Breeze's 100 ms linear, and the text sizes and line boxes Noto Sans's and Hack's at Plasma's defaults. The default build draws them in Teksilo's bundled faces; the font features bundled-fonts (embeds Noto Sans and Hack) and system-fonts (names them) are the crate's own and are not forwarded by the umbrella, so an app that wants Plasma's faces depends on teksilo-theme-kde directly and passes kde::font_registrar() to TeksiloAppBuilder::register_fonts.

It uses three hooks the other presets do not need. Its PaintProjection paints the scheme's own Inactive and high-contrast colours instead of the generic desaturation (see window-activation.md), its list rows return SelectionEmphasis::WindowActive from StandardItemStyle::selection_emphasis, because Qt mutes a selection per window and not per focused view, and its menu rows return a label role from MenuItemStyle::pressed_label_role (a held-down menu row is filled with the accent, so its label takes the on-accent text role). Which widgets carry Breeze's own chrome and which keep Teksilo's recipe styles built with Breeze's metrics is listed under "Known limitations" in the crate documentation.

The colours of the desktop a program is running on, as opposed to a preset that reproduces a design language, are the job of Native mode; see native-theme.md.

Each preset is just a function returning Theme. Apps can write their own without depending on any sibling crate:

#![allow(unused)]
fn main() {
pub fn brutalist_light() -> Theme {
    let mut theme = intui::light();
    theme.colors.accent     = Color::new(1.0, 0.0, 0.4, 1.0);   // hot pink
    theme.shape.radius_control = 0.0;                           // sharp control corners
    theme.style_slots.button   = Some(Rc::new(MyBrutalistButton));
    theme.style_slots.checkbox = Some(Rc::new(MyBrutalistCheckbox));
    theme
}
}

Custom widgets and the styling system

Writing your own composing widget? Three steps to make it themable:

  1. Declare a closed MyWidgetVariant enum for the design-language presentations users can pick (mirror ButtonVariant's shape).
  2. Define a MyWidgetStyle trait in your own crate with a make_body(cfg, ctx) -> WidgetId signature. The cfg struct exposes the inputs that vary by interaction state (Signal<bool>s for hover/pressed/etc.), the variant, and pre-built child subtrees.
  3. Ship a RecipeMyWidgetStyle as the default impl. Add a slot to your own slot-bag struct (or attach via theme.extensions if you only need app-internal use).

The trait pattern doesn't require buying into Teksilo's slot bag, you can ship the trait + default impl and let users override via MyWidget::style(...) per call. The slot bag is for theme-wide installation; it's optional, but it's how the framework's themable widgets get reskinned across an app.

See also