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.
StyleIdis a thin wrapper overString. Names like"error"are the identifier — not integers, not opaque handles. Ergonomic and debuggable. - Interior mutability.
definetakes&self, not&mut self. OneRwLockguards reads and writes; multiple readers run concurrently. - Optional defaults.
with_defaultspreloads 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:
| Name | Visual |
|---|---|
error | fg #FF5555, bold |
warning | fg #FFAA00 |
info | fg #55AAFF |
success | fg #55FF55 |
muted | fg #808080, dim |
highlight | bg #FFFF00, fg #000000 (yellow cell) |
link | fg #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
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.