Skip to main content

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}