Skip to main content

tca_ratatui/
theme.rs

1#[cfg(feature = "fs")]
2use anyhow::{Context, Result};
3use ratatui::style::Color;
4use std::collections::HashMap;
5#[cfg(feature = "fs")]
6use std::path::PathBuf;
7
8#[cfg(feature = "fs")]
9use tca_types::BuiltinTheme;
10
11/// Theme metadata.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Meta {
14    /// Human-readable theme name.
15    pub name: String,
16    /// Theme author name or contact.
17    pub author: Option<String>,
18    /// Semantic version string (e.g. `"1.0.0"`).
19    pub version: Option<String>,
20    /// Short description of the theme.
21    pub description: Option<String>,
22    /// `true` for dark themes, `false` for light themes.
23    pub dark: Option<bool>,
24}
25
26impl Default for Meta {
27    fn default() -> Self {
28        Self {
29            name: "Unnamed Theme".to_string(),
30            author: None,
31            version: None,
32            description: None,
33            dark: None,
34        }
35    }
36}
37
38/// ANSI 16-color definitions mapped to Ratatui colors.
39///
40/// All colors are resolved to concrete [`Color::Rgb`] values when loaded
41/// from a theme file. The [`Default`] impl maps to Ratatui's named colors
42/// as a fallback when no theme file is present.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Ansi {
45    /// ANSI color 0 — black.
46    pub black: Color,
47    /// ANSI color 1 — red.
48    pub red: Color,
49    /// ANSI color 2 — green.
50    pub green: Color,
51    /// ANSI color 3 — yellow.
52    pub yellow: Color,
53    /// ANSI color 4 — blue.
54    pub blue: Color,
55    /// ANSI color 5 — magenta.
56    pub magenta: Color,
57    /// ANSI color 6 — cyan.
58    pub cyan: Color,
59    /// ANSI color 7 — white.
60    pub white: Color,
61    /// ANSI color 8 — bright black (dark gray).
62    pub bright_black: Color,
63    /// ANSI color 9 — bright red.
64    pub bright_red: Color,
65    /// ANSI color 10 — bright green.
66    pub bright_green: Color,
67    /// ANSI color 11 — bright yellow.
68    pub bright_yellow: Color,
69    /// ANSI color 12 — bright blue.
70    pub bright_blue: Color,
71    /// ANSI color 13 — bright magenta.
72    pub bright_magenta: Color,
73    /// ANSI color 14 — bright cyan.
74    pub bright_cyan: Color,
75    /// ANSI color 15 — bright white.
76    pub bright_white: Color,
77}
78
79impl Ansi {
80    /// Return the resolved color for the given ANSI key name (e.g. `"red"`, `"bright_black"`).
81    ///
82    /// Returns `None` for unknown key names.
83    pub fn get(&self, key: &str) -> Option<Color> {
84        match key {
85            "black" => Some(self.black),
86            "red" => Some(self.red),
87            "green" => Some(self.green),
88            "yellow" => Some(self.yellow),
89            "blue" => Some(self.blue),
90            "magenta" => Some(self.magenta),
91            "cyan" => Some(self.cyan),
92            "white" => Some(self.white),
93            "bright_black" => Some(self.bright_black),
94            "bright_red" => Some(self.bright_red),
95            "bright_green" => Some(self.bright_green),
96            "bright_yellow" => Some(self.bright_yellow),
97            "bright_blue" => Some(self.bright_blue),
98            "bright_magenta" => Some(self.bright_magenta),
99            "bright_cyan" => Some(self.bright_cyan),
100            "bright_white" => Some(self.bright_white),
101            _ => None,
102        }
103    }
104}
105
106impl Default for Ansi {
107    fn default() -> Self {
108        Self {
109            black: Color::Black,
110            red: Color::Red,
111            green: Color::Green,
112            yellow: Color::Yellow,
113            blue: Color::Blue,
114            magenta: Color::Magenta,
115            cyan: Color::Cyan,
116            white: Color::Gray,
117            bright_black: Color::DarkGray,
118            bright_red: Color::LightRed,
119            bright_green: Color::LightGreen,
120            bright_yellow: Color::LightYellow,
121            bright_blue: Color::LightBlue,
122            bright_magenta: Color::LightMagenta,
123            bright_cyan: Color::LightCyan,
124            bright_white: Color::White,
125        }
126    }
127}
128
129/// A named color ramp, 0-indexed from darkest (index 0) to lightest.
130#[derive(Debug, Clone, PartialEq, Eq, Default)]
131pub struct ColorRamp {
132    /// The resolved colors in this ramp, ordered darkest (index 0) to lightest.
133    pub colors: Vec<Color>,
134}
135
136impl ColorRamp {
137    /// Returns the color at the given 0-based index, or `None` if out of range.
138    pub fn get(&self, idx: usize) -> Option<Color> {
139        self.colors.get(idx).copied()
140    }
141
142    /// Returns the number of colors in the ramp.
143    pub fn len(&self) -> usize {
144        self.colors.len()
145    }
146
147    /// Returns `true` if the ramp contains no colors.
148    pub fn is_empty(&self) -> bool {
149        self.colors.is_empty()
150    }
151}
152
153/// Color palette of named hue ramps (`[palette]` section).
154///
155/// All ramps are 0-indexed and ordered darkest → lightest.
156/// An absent `[palette]` section deserializes to an empty palette.
157#[derive(Debug, Clone, PartialEq, Eq, Default)]
158pub struct Palette(pub(crate) HashMap<String, ColorRamp>);
159
160impl Palette {
161    /// Returns the named ramp, or `None` if it doesn't exist.
162    pub fn get_ramp(&self, name: &str) -> Option<&ColorRamp> {
163        self.0.get(name)
164    }
165
166    /// Returns all ramp names in sorted order.
167    pub fn ramp_names(&self) -> Vec<&str> {
168        let mut names: Vec<&str> = self.0.keys().map(String::as_str).collect();
169        names.sort();
170        names
171    }
172}
173
174/// Base16 color mappings (`base00`–`base0F`).
175///
176/// An absent `[base16]` section deserializes to an empty map.
177#[derive(Debug, Clone, PartialEq, Eq, Default)]
178pub struct Base16(pub(crate) HashMap<String, Color>);
179
180impl Base16 {
181    /// Returns the resolved color for the given Base16 key, or `None`.
182    pub fn get(&self, key: &str) -> Option<Color> {
183        self.0.get(key).copied()
184    }
185
186    /// Iterates over all `(key, color)` pairs in sorted key order.
187    pub fn entries(&self) -> impl Iterator<Item = (&str, Color)> {
188        let mut pairs: Vec<(&str, Color)> = self.0.iter().map(|(k, &v)| (k.as_str(), v)).collect();
189        pairs.sort_by_key(|(k, _)| *k);
190        pairs.into_iter()
191    }
192}
193
194/// Semantic color roles.
195///
196/// The [`Default`] impl maps to Ratatui's named colors as a fallback.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct Semantic {
199    /// Color for error states.
200    pub error: Color,
201    /// Color for warning states.
202    pub warning: Color,
203    /// Color for informational states.
204    pub info: Color,
205    /// Color for success states.
206    pub success: Color,
207    /// Color for highlighted text.
208    pub highlight: Color,
209    /// Color for hyperlinks.
210    pub link: Color,
211}
212
213impl Default for Semantic {
214    fn default() -> Self {
215        Self {
216            error: Color::Red,
217            warning: Color::Yellow,
218            info: Color::Blue,
219            success: Color::Green,
220            highlight: Color::Cyan,
221            link: Color::Blue,
222        }
223    }
224}
225
226/// UI element colors for application chrome.
227///
228/// Field names flatten the nested TOML sub-tables (`[ui.bg]`, `[ui.fg]`, etc.)
229/// into a single struct for convenient access.
230///
231/// The [`Default`] impl provides a minimal dark-theme fallback.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct Ui {
234    /// Primary application background.
235    pub bg_primary: Color,
236    /// Secondary / sidebar background.
237    pub bg_secondary: Color,
238    /// Primary text color.
239    pub fg_primary: Color,
240    /// Secondary text color.
241    pub fg_secondary: Color,
242    /// De-emphasized / placeholder text color.
243    pub fg_muted: Color,
244    /// Active / focused border color.
245    pub border_primary: Color,
246    /// Inactive / de-emphasized border color.
247    pub border_muted: Color,
248    /// Active cursor color.
249    pub cursor_primary: Color,
250    /// Inactive cursor color.
251    pub cursor_muted: Color,
252    /// Selection background.
253    pub selection_bg: Color,
254    /// Selection foreground.
255    pub selection_fg: Color,
256}
257
258impl Default for Ui {
259    fn default() -> Self {
260        Self {
261            bg_primary: Color::Black,
262            bg_secondary: Color::Black,
263            fg_primary: Color::White,
264            fg_secondary: Color::Gray,
265            fg_muted: Color::DarkGray,
266            border_primary: Color::White,
267            border_muted: Color::DarkGray,
268            cursor_primary: Color::White,
269            cursor_muted: Color::Gray,
270            selection_bg: Color::DarkGray,
271            selection_fg: Color::White,
272        }
273    }
274}
275
276/// A fully resolved TCA theme with Ratatui-compatible colors.
277///
278/// All color references have been resolved to concrete [`Color`] values.
279/// Construct via [`TcaThemeBuilder`] or the `from_file`/`from_toml` methods.
280#[derive(Debug, Clone)]
281pub struct TcaTheme {
282    /// Theme metadata (name, author, version, etc.).
283    pub meta: Meta,
284    /// Resolved ANSI 16-color definitions.
285    pub ansi: Ansi,
286    /// Resolved palette ramps. Empty if the theme has no `[palette]` section.
287    pub palette: Palette,
288    /// Resolved Base16 mappings. Empty if the theme has no `[base16]` section.
289    pub base16: Base16,
290    /// Resolved semantic color roles.
291    pub semantic: Semantic,
292    /// Resolved UI element colors.
293    pub ui: Ui,
294}
295
296impl TcaTheme {
297    /// Creates a new theme, trying to match the passed name to a known
298    /// theme name or path and a reasonable default otherwise. The name is internally
299    /// converted, so e.g. any of the following would get the same theme:
300    /// NordDark
301    /// Nord Dark
302    /// Nord-Dark
303    /// nord-dark
304    /// nordDark
305    /// etc...
306    ///
307    /// The search fallback order is:
308    /// 1. Local theme files.
309    /// 2. Built in themes.
310    /// 3. Built in default light/dark mode theme based on current terminal mode.
311    #[cfg(feature = "fs")]
312    pub fn new(name: Option<&str>) -> Self {
313        TcaTheme::try_from(tca_types::Theme::from_name(name)).unwrap_or_else(|_| {
314            use terminal_colorsaurus::{theme_mode, QueryOptions, ThemeMode};
315            let builtin = match theme_mode(QueryOptions::default()).ok() {
316                Some(ThemeMode::Light) => BuiltinTheme::default_light(),
317                _ => BuiltinTheme::default(),
318            };
319            TcaTheme::try_from(builtin.theme()).expect("hardcoded default must be valid")
320        })
321    }
322    /// Returns the canonical name slug for the theme.
323    ///
324    /// This is the kebab-case version of the theme name.
325    /// e.g. "Tokyo Night" => "tokyo-night"
326    pub fn name_slug(&self) -> String {
327        heck::AsKebabCase(&self.meta.name).to_string()
328    }
329
330    /// Returns the canonical file name for the theme.
331    ///
332    /// This is the kebab-case name + '.toml'
333    /// e.g. "Tokyo Night" => "tokyo-night.toml"
334    pub fn to_filename(&self) -> String {
335        let mut theme_name = self.name_slug();
336        if !theme_name.ends_with(".toml") {
337            theme_name.push_str(".toml");
338        }
339        theme_name
340    }
341
342    /// Returns the canonical file path for the theme.
343    ///
344    /// Note that this is not necessarily the current location of the theme, nor
345    /// does it tell you if the theme is locally installed. It just tells you
346    /// where it should be installed to.
347    #[cfg(feature = "fs")]
348    pub fn to_pathbuf(&self) -> Result<PathBuf> {
349        use tca_types::user_themes_path;
350
351        let mut path = user_themes_path()?;
352        path.push(self.to_filename());
353        Ok(path)
354    }
355}
356
357#[cfg(feature = "fs")]
358impl Default for TcaTheme {
359    fn default() -> Self {
360        TcaTheme::new(None)
361    }
362}
363
364#[cfg(feature = "fs")]
365/// Load a TcaTheme from a TOML string.
366impl TryFrom<&str> for TcaTheme {
367    type Error = anyhow::Error;
368    fn try_from(value: &str) -> Result<TcaTheme, Self::Error> {
369        let raw: tca_types::Theme = toml::from_str(value)?;
370        TcaTheme::try_from(raw)
371    }
372}
373
374/// Converts a raw [`tca_types::Theme`] into a fully resolved [`TcaTheme`].
375///
376/// ANSI hex values are parsed strictly — malformed hex returns an error.
377/// Semantic and UI color references that cannot be resolved fall back to
378/// the named-color defaults from [`Semantic::default`] and [`Ui::default`].
379#[cfg(feature = "fs")]
380impl TryFrom<tca_types::Theme> for TcaTheme {
381    type Error = anyhow::Error;
382    fn try_from(raw: tca_types::Theme) -> Result<TcaTheme, Self::Error> {
383        // ANSI is required and hex-only; hard error on bad hex.
384        let ansi = parse_ansi(&raw.ansi)?;
385
386        // Palette and Base16 are optional; absent sections -> empty defaults.
387        let palette = parse_palette(raw.palette.as_ref(), &raw.ansi);
388        let base16 = parse_base16(raw.base16.as_ref(), &raw.ansi, &palette);
389
390        let resolve = |r: &str| resolve_ref(r, &ansi, &palette, &base16);
391
392        let defaults = Semantic::default();
393        let semantic = Semantic {
394            error: resolve(&raw.semantic.error).unwrap_or(defaults.error),
395            warning: resolve(&raw.semantic.warning).unwrap_or(defaults.warning),
396            info: resolve(&raw.semantic.info).unwrap_or(defaults.info),
397            success: resolve(&raw.semantic.success).unwrap_or(defaults.success),
398            highlight: resolve(&raw.semantic.highlight).unwrap_or(defaults.highlight),
399            link: resolve(&raw.semantic.link).unwrap_or(defaults.link),
400        };
401
402        let defaults = Ui::default();
403        let ui = Ui {
404            bg_primary: resolve(&raw.ui.bg.primary).unwrap_or(defaults.bg_primary),
405            bg_secondary: resolve(&raw.ui.bg.secondary).unwrap_or(defaults.bg_secondary),
406            fg_primary: resolve(&raw.ui.fg.primary).unwrap_or(defaults.fg_primary),
407            fg_secondary: resolve(&raw.ui.fg.secondary).unwrap_or(defaults.fg_secondary),
408            fg_muted: resolve(&raw.ui.fg.muted).unwrap_or(defaults.fg_muted),
409            border_primary: resolve(&raw.ui.border.primary).unwrap_or(defaults.border_primary),
410            border_muted: resolve(&raw.ui.border.muted).unwrap_or(defaults.border_muted),
411            cursor_primary: resolve(&raw.ui.cursor.primary).unwrap_or(defaults.cursor_primary),
412            cursor_muted: resolve(&raw.ui.cursor.muted).unwrap_or(defaults.cursor_muted),
413            selection_bg: resolve(&raw.ui.selection.bg).unwrap_or(defaults.selection_bg),
414            selection_fg: resolve(&raw.ui.selection.fg).unwrap_or(defaults.selection_fg),
415        };
416
417        let meta = Meta {
418            name: raw.meta.name,
419            author: raw.meta.author,
420            version: raw.meta.version,
421            description: raw.meta.description,
422            dark: raw.meta.dark,
423        };
424
425        Ok(TcaTheme {
426            meta,
427            ansi,
428            palette,
429            base16,
430            semantic,
431            ui,
432        })
433    }
434}
435
436impl PartialEq for TcaTheme {
437    fn eq(&self, other: &Self) -> bool {
438        self.name_slug() == other.name_slug()
439    }
440}
441impl Eq for TcaTheme {}
442
443impl PartialOrd for TcaTheme {
444    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
445        Some(self.cmp(other))
446    }
447}
448
449impl Ord for TcaTheme {
450    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
451        self.name_slug().cmp(&other.name_slug())
452    }
453}
454
455/// Builder for constructing a [`TcaTheme`] programmatically.
456///
457/// All sections default to sensible fallback values so you only need to
458/// supply what differs from the defaults.
459///
460/// # Example
461///
462/// ```rust
463/// use tca_ratatui::{TcaThemeBuilder, Semantic};
464/// use ratatui::style::Color;
465///
466/// let theme = TcaThemeBuilder::new()
467///     .semantic(Semantic {
468///         error: Color::Rgb(255, 80, 80),
469///         ..Default::default()
470///     })
471///     .build();
472///
473/// assert_eq!(theme.semantic.error, Color::Rgb(255, 80, 80));
474/// assert_eq!(theme.semantic.warning, Color::Yellow); // default
475/// ```
476#[derive(Debug, Clone, Default)]
477pub struct TcaThemeBuilder {
478    meta: Meta,
479    ansi: Ansi,
480    palette: Palette,
481    base16: Base16,
482    semantic: Semantic,
483    ui: Ui,
484}
485
486impl TcaThemeBuilder {
487    /// Create a new builder with all sections set to their defaults.
488    pub fn new() -> Self {
489        Self::default()
490    }
491
492    /// Set the theme metadata.
493    pub fn meta(mut self, meta: Meta) -> Self {
494        self.meta = meta;
495        self
496    }
497
498    /// Set the ANSI 16-color definitions.
499    pub fn ansi(mut self, ansi: Ansi) -> Self {
500        self.ansi = ansi;
501        self
502    }
503
504    /// Set the color palette ramps.
505    pub fn palette(mut self, palette: Palette) -> Self {
506        self.palette = palette;
507        self
508    }
509
510    /// Set the Base16 color mappings.
511    pub fn base16(mut self, base16: Base16) -> Self {
512        self.base16 = base16;
513        self
514    }
515
516    /// Set the semantic color roles.
517    pub fn semantic(mut self, semantic: Semantic) -> Self {
518        self.semantic = semantic;
519        self
520    }
521
522    /// Set the UI element colors.
523    pub fn ui(mut self, ui: Ui) -> Self {
524        self.ui = ui;
525        self
526    }
527
528    /// Consume the builder and return the constructed [`TcaTheme`].
529    pub fn build(self) -> TcaTheme {
530        TcaTheme {
531            meta: self.meta,
532            ansi: self.ansi,
533            palette: self.palette,
534            base16: self.base16,
535            semantic: self.semantic,
536            ui: self.ui,
537        }
538    }
539}
540
541/// Parse `#RRGGBB` hex into [`Color::Rgb`]. Returns `None` on malformed input.
542#[cfg(feature = "fs")]
543fn hex_to_color(hex: &str) -> Option<Color> {
544    let (r, g, b) = tca_types::hex_to_rgb(hex).ok()?;
545    Some(Color::Rgb(r, g, b))
546}
547
548/// Resolve a color reference string to a [`Color`].
549///
550/// Supported formats: `#RRGGBB`, `ansi.<key>`, `palette.<ramp>.<index>`, `base16.<key>`.
551/// Returns `None` if the reference cannot be resolved.
552#[cfg(feature = "fs")]
553fn resolve_ref(r: &str, ansi: &Ansi, palette: &Palette, base16: &Base16) -> Option<Color> {
554    if r.starts_with('#') {
555        return hex_to_color(r);
556    }
557
558    let parts: Vec<&str> = r.splitn(3, '.').collect();
559    match parts.as_slice() {
560        ["ansi", key] => ansi.get(key),
561        ["palette", ramp, idx_str] => {
562            let idx: usize = idx_str.parse().ok()?;
563            palette.get_ramp(ramp)?.get(idx)
564        }
565        ["base16", key] => base16.get(key),
566        _ => None,
567    }
568}
569
570/// Parse a raw [`tca_types::Ansi`] into a resolved [`Ansi`].
571/// Returns an error if any hex color is malformed (spec requires hex-only in `[ansi]`).
572#[cfg(feature = "fs")]
573fn parse_ansi(raw: &tca_types::Ansi) -> Result<Ansi> {
574    let p = |hex: &str| -> Result<Color> {
575        hex_to_color(hex).with_context(|| format!("Invalid hex color in [ansi]: {:?}", hex))
576    };
577    Ok(Ansi {
578        black: p(&raw.black)?,
579        red: p(&raw.red)?,
580        green: p(&raw.green)?,
581        yellow: p(&raw.yellow)?,
582        blue: p(&raw.blue)?,
583        magenta: p(&raw.magenta)?,
584        cyan: p(&raw.cyan)?,
585        white: p(&raw.white)?,
586        bright_black: p(&raw.bright_black)?,
587        bright_red: p(&raw.bright_red)?,
588        bright_green: p(&raw.bright_green)?,
589        bright_yellow: p(&raw.bright_yellow)?,
590        bright_blue: p(&raw.bright_blue)?,
591        bright_magenta: p(&raw.bright_magenta)?,
592        bright_cyan: p(&raw.bright_cyan)?,
593        bright_white: p(&raw.bright_white)?,
594    })
595}
596
597/// Parse a raw [`tca_types::Palette`] into a resolved [`Palette`].
598/// Palette values may be `#RRGGBB` hex or `ansi.<key>` references.
599/// Values that cannot be resolved are silently skipped.
600#[cfg(feature = "fs")]
601fn parse_palette(raw: Option<&tca_types::Palette>, raw_ansi: &tca_types::Ansi) -> Palette {
602    let Some(raw_palette) = raw else {
603        return Palette::default();
604    };
605
606    let ramps = raw_palette
607        .entries()
608        .map(|(name, values)| {
609            let colors = values
610                .iter()
611                .filter_map(|v| {
612                    if v.starts_with('#') {
613                        hex_to_color(v)
614                    } else if let Some(key) = v.strip_prefix("ansi.") {
615                        hex_to_color(raw_ansi.get(key)?)
616                    } else {
617                        None
618                    }
619                })
620                .collect();
621            (name.to_string(), ColorRamp { colors })
622        })
623        .collect();
624
625    Palette(ramps)
626}
627
628/// Parse a raw [`tca_types::Base16`] into a resolved [`Base16`].
629/// Values may be `#RRGGBB`, `ansi.<key>`, or `palette.<ramp>.<index>`.
630/// Values that cannot be resolved are silently skipped.
631#[cfg(feature = "fs")]
632fn parse_base16(
633    raw: Option<&tca_types::Base16>,
634    raw_ansi: &tca_types::Ansi,
635    palette: &Palette,
636) -> Base16 {
637    let Some(raw_b16) = raw else {
638        return Base16::default();
639    };
640
641    let map = raw_b16
642        .entries()
643        .filter_map(|(key, value)| {
644            let color = if value.starts_with('#') {
645                hex_to_color(value)?
646            } else if let Some(k) = value.strip_prefix("ansi.") {
647                hex_to_color(raw_ansi.get(k)?)?
648            } else {
649                let parts: Vec<&str> = value.splitn(3, '.').collect();
650                match parts.as_slice() {
651                    ["palette", ramp, idx_str] => {
652                        let idx: usize = idx_str.parse().ok()?;
653                        palette.get_ramp(ramp)?.get(idx)?
654                    }
655                    _ => return None,
656                }
657            };
658            Some((key.to_string(), color))
659        })
660        .collect();
661
662    Base16(map)
663}
664
665/// A cycling cursor over resolved [`TcaTheme`] values.
666///
667/// Wraps [`tca_types::ThemeCursor<TcaTheme>`] and adds convenience constructors
668/// for building a cursor from built-in or user-installed themes.
669///
670/// All cursor methods (`peek`, `next`, `prev`, `themes`, `len`, `is_empty`)
671/// delegate to the inner [`tca_types::ThemeCursor`].
672///
673/// # Example
674///
675/// ```rust,no_run
676/// use tca_ratatui::TcaThemeCursor;
677///
678/// let mut cursor = TcaThemeCursor::with_builtins();
679/// let first = cursor.peek().unwrap();
680/// let second = cursor.next().unwrap();
681/// ```
682#[cfg(feature = "fs")]
683pub struct TcaThemeCursor(tca_types::ThemeCursor<TcaTheme>);
684
685#[cfg(feature = "fs")]
686impl TcaThemeCursor {
687    /// Create a cursor from any iterable of resolved themes. The cursor starts at the first theme.
688    pub fn new(themes: impl IntoIterator<Item = TcaTheme>) -> Self {
689        Self(tca_types::ThemeCursor::new(themes))
690    }
691
692    /// All built-in themes, resolved to [`TcaTheme`].
693    /// Themes that fail to resolve are silently skipped.
694    pub fn with_builtins() -> Self {
695        Self::new(
696            tca_types::BuiltinTheme::iter().filter_map(|b| TcaTheme::try_from(b.theme()).ok()),
697        )
698    }
699
700    /// User-installed themes only, resolved to [`TcaTheme`].
701    pub fn with_user_themes() -> Self {
702        Self::new(
703            tca_types::all_user_themes()
704                .into_iter()
705                .filter_map(|t| TcaTheme::try_from(t).ok()),
706        )
707    }
708
709    /// Built-ins + user themes, resolved to [`TcaTheme`].
710    /// User themes with matching names override builtins.
711    pub fn with_all_themes() -> Self {
712        Self::new(
713            tca_types::all_themes()
714                .into_iter()
715                .filter_map(|t| TcaTheme::try_from(t).ok()),
716        )
717    }
718
719    /// Returns the current theme without moving the cursor.
720    pub fn peek(&self) -> Option<&TcaTheme> {
721        self.0.peek()
722    }
723
724    /// Advances the cursor to the next theme (wrapping) and returns it.
725    #[allow(clippy::should_implement_trait)]
726    pub fn next(&mut self) -> Option<&TcaTheme> {
727        self.0.next()
728    }
729
730    /// Retreats the cursor to the previous theme (wrapping) and returns it.
731    pub fn prev(&mut self) -> Option<&TcaTheme> {
732        self.0.prev()
733    }
734
735    /// Returns a slice of all themes in the cursor.
736    pub fn themes(&self) -> &[TcaTheme] {
737        self.0.themes()
738    }
739
740    /// Returns the number of themes.
741    pub fn len(&self) -> usize {
742        self.0.len()
743    }
744
745    /// Returns `true` if the cursor contains no themes.
746    pub fn is_empty(&self) -> bool {
747        self.0.is_empty()
748    }
749
750    /// Moves the cursor to the theme matching `name` (slug-insensitive) and returns it.
751    ///
752    /// Accepts fuzzy names: "Nord Dark", "nord-dark", and "nordDark" all match the same theme.
753    /// Returns `None` if no matching theme is found.
754    pub fn set_current(&mut self, name: &str) -> Option<&TcaTheme> {
755        let slug = heck::AsKebabCase(name).to_string();
756        let idx = self.0.themes().iter().position(|t| t.name_slug() == slug)?;
757        self.0.set_index(idx)
758    }
759}