Skip to Content
ftui-styleStylesheet

StyleSheet

Hardcoding colors across every widget is the old mistake. StyleSheet is the registry that lets you name styles once and refer to them everywhere — so when “error” means red-bold in one place, it means red-bold everywhere, and changing the theme changes every reference.

The contract

pub struct StyleId(pub String); pub struct StyleSheet { styles: RwLock<AHashMap<String, Style>>, } impl StyleSheet { pub fn new() -> Self; pub fn with_defaults() -> Self; pub fn define(&self, name: impl Into<String>, style: Style); pub fn remove(&self, name: &str) -> Option<Style>; pub fn get(&self, name: &str) -> Option<Style>; pub fn get_or_default(&self, name: &str) -> Style; pub fn contains(&self, name: &str) -> bool; pub fn compose(&self, names: &[&str]) -> Style; }

Four important things at once:

  • String-keyed. StyleId is a thin wrapper over String. Names like "error" are the identifier — not integers, not opaque handles. Ergonomic and debuggable.
  • Interior mutability. define takes &self, not &mut self. One RwLock guards reads and writes; multiple readers run concurrently.
  • Optional defaults. with_defaults preloads seven semantic styles.
  • Composition built in. compose(names) merges multiple styles in order so “muted + error” is a thing.

The default semantic styles

StyleSheet::with_defaults() registers:

NameVisual
errorfg #FF5555, bold
warningfg #FFAA00
infofg #55AAFF
successfg #55FF55
mutedfg #808080, dim
highlightbg #FFFF00, fg #000000 (yellow cell)
linkfg #55AAFF, underline

These exist so a fresh runtime is usable immediately. You can redefine any of them:

sheet.define("error", Style::new().fg(PackedRgba::rgb(240, 80, 80)).bold());

Worked example — a themeable widget

themed_badge.rs
use ftui_style::{Style, StyleSheet}; use ftui_text::Span; fn render_badge<'a>(sheet: &StyleSheet, level: &str, text: &'a str) -> Span<'a> { let style = sheet.get(level).unwrap_or_default(); Span::styled(text, style) } let sheet = StyleSheet::with_defaults(); let badge = render_badge(&sheet, "error", "FAILED");

The widget never mentions a color. Swap the sheet, swap every badge.

compose — layer two or more

let emphasized = sheet.compose(&["muted", "error"]); // "error" is applied on top of "muted": colors from error (red + bold), // dim attribute from muted is preserved by the attribute-union cascade.

This is CSS class="muted error" — with the precedence defined by the slice order.

Thread safety

The internal RwLock makes the sheet safe to share across threads:

  • Multiple readers (widget render passes) run in parallel.
  • A writer (theme swap) takes exclusive access briefly.

The common usage pattern is Arc<StyleSheet> passed down the render tree. Writing is rare; reading is every frame.

Lookup ergonomics

Prefer get_or_default for non-critical styles — if a widget asks for a name that doesn’t exist, returning Style::default() (= inherit everything) is almost always the right behavior. Reserve get + unwrap for places where a missing style is a bug.

sheet.get("critical-section").expect("must be registered at startup");

Pitfalls

Lock poisoning is recoverable. get / define panic if the lock is poisoned. In practice this only happens if a panic occurred while the writer held the lock. Prefer registering styles in one place at startup so the writer-held path is narrow.

compose allocates a new Style every call. Style is 28 bytes, so this is cheap — but if you’re composing the same chain on every frame, cache the result.

Don’t register styles from inside render. Registration takes a write lock that blocks readers. Do setup at construction time, render calls at frame time.

Where to go next