ratatui_style_presets/lib.rs
1//! # ratatui-style-presets
2//!
3//! Ready-to-use CSS themes & utilities for [`ratatui-style`] — the styling
4//! layer pre-filled, so third-party apps get a sensible look **out of the
5//! box** and can swap looks by changing one stylesheet.
6//!
7//! Each preset is embedded at compile time and exposed as a `&'static
8//! Stylesheet` parsed with [`Origin::Theme`](ratatui_style::Origin::Theme), so
9//! a downstream app overrides any of it with its own `Origin::User` rules at
10//! equal specificity — no merge plumbing required.
11//!
12//! ## Themes & swappability
13//!
14//! Every theme fills the **same** canonical semantic tokens (see
15//! [`SEMANTIC_TOKENS`]): `--bg`, `--text`, `--accent`, `--success`, …
16//! [`default_theme()`] additionally ships base component classes (`Button`,
17//! `Panel`, `Text`, `List`, …) that reference those tokens through `var()`.
18//! The `widgets` preset does the same for ratatui widget type names. So:
19//!
20//! - pick a palette (default / catppuccin / nord / dracula) → restyle everything,
21//! - layer `widgets` / `tailwind` on top → styled widgets / atomic utilities,
22//! - override anything with your own `Origin::User` rules.
23//!
24//! ## Feature flags
25//!
26//! | Feature | Preset |
27//! |-----------|---------------------------------------------------|
28//! | _(none)_ | [`default_theme()`] — always available |
29//! | `tailwind`| [`tailwind()`] — atomic utility classes |
30//! | `widgets` | [`widgets()`] — ratatui widget type defaults |
31//! | `catppuccin` | [`catppuccin()`] — Catppuccin palette |
32//! | `nord` | [`nord()`] — Nord palette |
33//! | `dracula` | [`dracula()`] — Dracula palette |
34//!
35//! ```toml
36//! [dependencies]
37//! ratatui-style-presets = { version = "0.1", features = ["widgets", "catppuccin"] }
38//! ```
39//!
40//! ## Combine presets
41//!
42//! [`merge()`] stacks presets into one owned stylesheet (later ones override
43//! earlier at equal specificity), and [`PresetBuilder`] does it fluently:
44//!
45//! ```no_run
46//! use ratatui_style_presets::{merge, Preset, PresetBuilder};
47//!
48//! // Stack presets into one sheet (later ones override earlier at equal
49//! // specificity). Add `Widgets` / `Catppuccin` / … when those features are on:
50//! let sheet = merge(&[Preset::Default]);
51//!
52//! // Same thing, fluently:
53//! let sheet = PresetBuilder::new()
54//! .with(Preset::Default)
55//! .build();
56//! ```
57//!
58//! [`ratatui-style`]: https://docs.rs/ratatui-style
59
60#![forbid(unsafe_code)]
61
62use ratatui_style::Stylesheet;
63
64/// The canonical, theme-agnostic semantic color-token names every shipped
65/// theme reproduces. A theme that sets exactly these tokens can be dropped in
66/// as the base stylesheet and the rest of a UI restyles for free.
67///
68/// `var()` currently supports **color** tokens (not length/padding), so this
69/// vocabulary is intentionally color-only.
70pub const SEMANTIC_TOKENS: &[&str] = &[
71 "--bg",
72 "--surface",
73 "--surface-2",
74 "--border",
75 "--text",
76 "--text-muted",
77 "--accent",
78 "--accent-fg",
79 "--success",
80 "--warning",
81 "--danger",
82 "--info",
83];
84
85/// Parse an embedded preset CSS file once, lazily, as `Origin::Theme`.
86///
87/// Uses absolute paths so it expands correctly inside any `cfg`-gated module.
88macro_rules! preset_static {
89 ($name:ident, $path:literal) => {
90 static $name: ::std::sync::LazyLock<::ratatui_style::Stylesheet> =
91 ::std::sync::LazyLock::new(|| {
92 ::ratatui_style::Stylesheet::parse_with_origin(
93 ::std::include_str!($path),
94 ::ratatui_style::Origin::Theme,
95 )
96 .expect(::std::concat!("embedded preset CSS must parse: ", $path))
97 });
98 };
99}
100
101preset_static!(DEFAULT, "../css/default.css");
102
103/// The default theme + base component classes (`Button`, `Panel`, `Text`,
104/// `List`, `Badge`, …). Always available — no feature flag needed.
105///
106/// Defines the canonical [`SEMANTIC_TOKENS`] that the other themes reproduce.
107pub fn default_theme() -> &'static Stylesheet {
108 &DEFAULT
109}
110
111// ---------------------------------------------------------------------------
112// Feature-gated presets. The `static` (and its `include_str!`) are themselves
113// `#[cfg]`-gated, so an unused preset is never parsed and its CSS file need
114// not exist unless the feature is enabled.
115// ---------------------------------------------------------------------------
116
117#[cfg(feature = "tailwind")]
118preset_static!(TAILWIND, "../css/tailwind.css");
119/// Tailwind-style atomic utility classes (`.bg-*`, `.text-*`, `.p-*`,
120/// `.rounded` …). Requires feature `tailwind`.
121#[cfg(feature = "tailwind")]
122pub fn tailwind() -> &'static Stylesheet {
123 &TAILWIND
124}
125
126#[cfg(feature = "widgets")]
127preset_static!(WIDGETS, "../css/widgets.css");
128/// Default styles for ratatui widget type names (`Table`, `List`, `Tabs`,
129/// `Gauge`, …), referencing the canonical semantic tokens. Requires feature
130/// `widgets`.
131#[cfg(feature = "widgets")]
132pub fn widgets() -> &'static Stylesheet {
133 &WIDGETS
134}
135
136#[cfg(feature = "catppuccin")]
137preset_static!(CATPPUCCIN, "../css/catppuccin.css");
138/// The Catppuccin (Mocha) palette, filling the canonical semantic tokens.
139/// Requires feature `catppuccin`.
140#[cfg(feature = "catppuccin")]
141pub fn catppuccin() -> &'static Stylesheet {
142 &CATPPUCCIN
143}
144
145#[cfg(feature = "nord")]
146preset_static!(NORD, "../css/nord.css");
147/// The Nord palette, filling the canonical semantic tokens. Requires feature
148/// `nord`.
149#[cfg(feature = "nord")]
150pub fn nord() -> &'static Stylesheet {
151 &NORD
152}
153
154#[cfg(feature = "dracula")]
155preset_static!(DRACULA, "../css/dracula.css");
156/// The Dracula palette, filling the canonical semantic tokens. Requires
157/// feature `dracula`.
158#[cfg(feature = "dracula")]
159pub fn dracula() -> &'static Stylesheet {
160 &DRACULA
161}
162
163// ---------------------------------------------------------------------------
164// Selection + composition API.
165// ---------------------------------------------------------------------------
166
167/// A selectable preset stylesheet.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
169pub enum Preset {
170 /// The default theme + base component classes (always available).
171 Default,
172 /// Tailwind-style atomic utilities.
173 #[cfg(feature = "tailwind")]
174 Tailwind,
175 /// ratatui widget type defaults.
176 #[cfg(feature = "widgets")]
177 Widgets,
178 /// Catppuccin (Mocha) palette.
179 #[cfg(feature = "catppuccin")]
180 Catppuccin,
181 /// Nord palette.
182 #[cfg(feature = "nord")]
183 Nord,
184 /// Dracula palette.
185 #[cfg(feature = "dracula")]
186 Dracula,
187}
188
189impl Preset {
190 /// The `&'static Stylesheet` for this preset (parsed as `Origin::Theme`).
191 pub fn stylesheet(&self) -> &'static Stylesheet {
192 match self {
193 Preset::Default => default_theme(),
194 #[cfg(feature = "tailwind")]
195 Preset::Tailwind => tailwind(),
196 #[cfg(feature = "widgets")]
197 Preset::Widgets => widgets(),
198 #[cfg(feature = "catppuccin")]
199 Preset::Catppuccin => catppuccin(),
200 #[cfg(feature = "nord")]
201 Preset::Nord => nord(),
202 #[cfg(feature = "dracula")]
203 Preset::Dracula => dracula(),
204 }
205 }
206}
207
208/// Stack several presets into one owned stylesheet, in order.
209///
210/// Later presets override earlier ones at equal specificity (all rules are
211/// `Origin::Theme`). The result is a fresh owned [`Stylesheet`] you can pass to
212/// `RuntimeStyle::from_owned` or compute against directly.
213///
214/// Note: presets can't add a `.merge()` method onto [`Stylesheet`] directly
215/// (orphan rule — `Stylesheet` is foreign), so this free function + the
216/// [`PresetBuilder`] are the composition entry points.
217pub fn merge(presets: &[Preset]) -> Stylesheet {
218 let mut sheet = Stylesheet::new();
219 for p in presets {
220 sheet.extend(p.stylesheet());
221 }
222 sheet
223}
224
225/// A fluent builder over [`merge()`]: stack presets, then [`build()`](Self::build)
226/// into one owned stylesheet.
227#[derive(Debug, Default)]
228pub struct PresetBuilder {
229 presets: Vec<Preset>,
230}
231
232impl PresetBuilder {
233 /// Start an empty builder.
234 pub fn new() -> Self {
235 Self::default()
236 }
237
238 /// Push a preset; it layers on top of whatever was added before.
239 #[must_use]
240 pub fn with(mut self, preset: Preset) -> Self {
241 self.presets.push(preset);
242 self
243 }
244
245 /// Materialize the stacked presets into one owned stylesheet.
246 pub fn build(self) -> Stylesheet {
247 merge(&self.presets)
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use ratatui_style::{NodeRef, OwnedNode, State};
255
256 #[test]
257 fn default_theme_styles_root() {
258 // Root pulls --bg as background.
259 let c = DEFAULT.compute(&NodeRef::new("Root"), None);
260 assert!(
261 c.style.background.is_some(),
262 "Root should have a background from --bg"
263 );
264 }
265
266 #[test]
267 fn default_theme_styles_primary_button() {
268 let node = NodeRef::new("Button").classes(&["primary"]);
269 let c = DEFAULT.compute(&node, None);
270 // primary sets background to --accent and color to --accent-fg.
271 assert!(c.style.background.is_some(), "primary button needs a background");
272 assert!(c.style.color.is_some(), "primary button needs a text color");
273 }
274
275 #[test]
276 fn default_theme_text_variants() {
277 for cls in ["title", "success", "warning", "danger", "info", "muted"] {
278 // Bind the slice to a local so the NodeRef borrow outlives the call.
279 let classes = [cls];
280 let node = NodeRef::new("Text").classes(&classes);
281 let c = DEFAULT.compute(&node, None);
282 assert!(c.style.color.is_some(), "Text.{cls} should set a color");
283 }
284 }
285
286 #[test]
287 fn pseudo_state_disabled_applies() {
288 // :disabled should kick in when the disabled state is set.
289 let node = NodeRef::new("Button").classes(&["primary"]).state(State {
290 disabled: true,
291 ..State::empty()
292 });
293 let c = DEFAULT.compute(&node, None);
294 assert!(c.style.color.is_some(), "disabled button should still have a color");
295 }
296
297 #[test]
298 fn merge_reproduces_a_preset() {
299 // Merging just Default should compute identically to the Default sheet.
300 let merged = merge(&[Preset::Default]);
301 let node = NodeRef::new("Button").classes(&["primary"]);
302 let a = DEFAULT.compute(&node, None);
303 let b = merged.compute(&node, None);
304 assert_eq!(a.style.color, b.style.color);
305 assert_eq!(a.style.background, b.style.background);
306 }
307
308 #[test]
309 fn builder_equivalent_to_merge() {
310 let node = NodeRef::new("Root");
311 let via_merge = merge(&[Preset::Default]).compute(&node, None);
312 let via_build = PresetBuilder::new().with(Preset::Default).build().compute(&node, None);
313 assert_eq!(via_merge.style.background, via_build.style.background);
314 }
315
316 #[test]
317 fn semantic_token_vocabulary_is_stable() {
318 // Guard against accidentally renaming the shared tokens — every theme
319 // depends on exactly these names.
320 assert_eq!(SEMANTIC_TOKENS.len(), 12);
321 assert!(SEMANTIC_TOKENS.contains(&"--accent"));
322 assert!(SEMANTIC_TOKENS.contains(&"--text-muted"));
323 }
324
325 #[cfg(feature = "tailwind")]
326 #[test]
327 fn tailwind_utilities_match() {
328 // Atomic utilities compose: each writes a different CSS field, so all
329 // three apply. (OwnedNode lets us use runtime class strings.)
330 let node = OwnedNode::new("Div").with_classes(["bg-blue-500", "text-white", "p-1"]);
331 let c = TAILWIND.compute(&node, None);
332 assert!(c.style.background.is_some(), "bg-blue-500 must set background");
333 assert!(c.style.color.is_some(), "text-white must set color");
334 assert!(c.style.padding.is_some(), "p-1 must set padding");
335
336 // The .btn component + variant layer.
337 let btn = OwnedNode::new("Div").with_classes(["btn", "primary"]);
338 assert!(
339 TAILWIND.compute(&btn, None).style.background.is_some(),
340 ".btn.primary must set background"
341 );
342 }
343
344 #[cfg(feature = "widgets")]
345 #[test]
346 fn widget_types_match() {
347 // Type-keyed rules must apply — proves the selectors (and var() tokens)
348 // aren't silently dropped by the lenient parser.
349 let table = WIDGETS.compute(&OwnedNode::new("Table"), None);
350 assert!(table.style.border.is_some(), "Table must have a border");
351
352 let gauge = WIDGETS.compute(&OwnedNode::new("Gauge"), None);
353 assert!(gauge.style.background.is_some(), "Gauge must set accent background");
354
355 let tab_active = WIDGETS.compute(&OwnedNode::new("Tab").with_classes(["active"]), None);
356 assert!(tab_active.style.color.is_some(), "Tab.active must set color");
357 }
358
359 /// Each theme fills the SAME canonical `--accent` token, so pairing a theme
360 /// with the widget defaults must resolve `Gauge`'s `var(--accent)` background
361 /// to that theme's accent color — and the four shipped themes must differ
362 /// (they're distinct palettes, not duplicates).
363 #[cfg(all(
364 feature = "widgets",
365 feature = "catppuccin",
366 feature = "nord",
367 feature = "dracula"
368 ))]
369 #[test]
370 fn themes_resolve_distinct_accents() {
371 fn accent(theme: Preset) -> ratatui_style::Color {
372 let sheet = merge(&[theme, Preset::Widgets]);
373 // Gauge { background: var(--accent); } in widgets.css.
374 sheet
375 .compute(&OwnedNode::new("Gauge"), None)
376 .style
377 .background
378 .expect("theme must resolve --accent for Gauge")
379 }
380
381 let all = [
382 accent(Preset::Default),
383 accent(Preset::Catppuccin),
384 accent(Preset::Nord),
385 accent(Preset::Dracula),
386 ];
387 for i in 0..all.len() {
388 for j in (i + 1)..all.len() {
389 assert_ne!(
390 all[i], all[j],
391 "themes {i} and {j} share an accent color — expected distinct palettes"
392 );
393 }
394 }
395 }
396}