Skip to main content

tca_types/
cursor.rs

1use crate::{BuiltinTheme, Theme};
2
3/// A cycling cursor over a list of themes of type `T`.
4///
5/// `peek()` returns the current theme without moving.
6/// `next()` and `prev()` advance or retreat the cursor and return the new current theme.
7/// Both wrap around at the ends.
8///
9/// To iterate over all themes without cycling, use [`ThemeCursor::themes`].
10///
11/// # Type parameters
12///
13/// - [`ThemeCursor<tca_types::Theme>`] — raw themes; see [`ThemeCursor::with_builtins`] etc.
14/// - [`tca_ratatui::TcaThemeCursor`] — resolved Ratatui themes; convenience constructors
15///   are available via `tca_ratatui`.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ThemeCursor<T> {
18    themes: Vec<T>,
19    index: usize,
20}
21
22impl<T> ThemeCursor<T> {
23    /// Create a cursor from any iterable of themes. The cursor starts at the first theme.
24    pub fn new(themes: impl IntoIterator<Item = T>) -> Self {
25        let themes = themes.into_iter().collect();
26        Self { index: 0, themes }
27    }
28
29    /// Returns the current theme without moving the cursor.
30    pub fn peek(&self) -> Option<&T> {
31        self.themes.get(self.index)
32    }
33
34    /// Advances the cursor to the next theme (wrapping) and returns it.
35    ///
36    /// Returns `None` if the cursor is empty.
37    #[allow(clippy::should_implement_trait)]
38    pub fn next(&mut self) -> Option<&T> {
39        if self.is_empty() {
40            return None;
41        }
42        self.index = (self.index + 1) % self.themes.len();
43        self.themes.get(self.index)
44    }
45
46    /// Retreats the cursor to the previous theme (wrapping) and returns it.
47    ///
48    /// Returns `None` if the cursor is empty.
49    pub fn prev(&mut self) -> Option<&T> {
50        if self.is_empty() {
51            return None;
52        }
53        if self.index == 0 {
54            self.index = self.themes.len() - 1;
55        } else {
56            self.index -= 1;
57        }
58        self.themes.get(self.index)
59    }
60
61    /// Returns a slice of all themes in the cursor.
62    pub fn themes(&self) -> &[T] {
63        &self.themes
64    }
65
66    /// Returns the number of themes.
67    pub fn len(&self) -> usize {
68        self.themes.len()
69    }
70
71    /// Returns `true` if the cursor contains no themes.
72    pub fn is_empty(&self) -> bool {
73        self.themes.is_empty()
74    }
75
76    /// Moves the cursor to `index` and returns the theme at that position.
77    ///
78    /// Returns `None` if `index` is out of bounds.
79    pub fn set_index(&mut self, index: usize) -> Option<&T> {
80        if index < self.themes.len() {
81            self.index = index;
82            self.themes.get(index)
83        } else {
84            None
85        }
86    }
87}
88
89/// Convenience constructors for [`ThemeCursor<Theme>`].
90impl ThemeCursor<Theme> {
91    /// All built-in themes.
92    pub fn with_builtins() -> Self {
93        let mut themes: Vec<Theme> = BuiltinTheme::iter().map(|b| b.theme()).collect();
94        themes.sort();
95        ThemeCursor::new(themes)
96    }
97
98    /// User-installed themes only.
99    #[cfg(feature = "fs")]
100    pub fn with_user_themes() -> Self {
101        use crate::all_user_themes;
102        let mut themes = all_user_themes();
103        themes.sort();
104        ThemeCursor::new(themes)
105    }
106
107    /// Built-ins + user themes. User themes with matching names override builtins.
108    #[cfg(feature = "fs")]
109    pub fn with_all_themes() -> Self {
110        use crate::all_themes;
111        let mut themes = all_themes();
112        themes.sort();
113        ThemeCursor::new(themes)
114    }
115
116    /// Moves the cursor to the theme matching `name` (slug-insensitive) and returns it.
117    ///
118    /// Accepts fuzzy names: "Nord Dark", "nord-dark", and "nordDark" all match the same theme.
119    /// Returns `None` if no matching theme is found.
120    pub fn set_current(&mut self, name: &str) -> Option<&Theme> {
121        let slug = heck::AsKebabCase(name).to_string();
122        let idx = self.themes.iter().position(|t| t.name_slug() == slug)?;
123        self.set_index(idx)
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::Theme;
131
132    fn make_theme(name: &str) -> Theme {
133        // Build a minimal base24 YAML string so tests don't depend on struct internals.
134        let yaml = format!(
135            r#"scheme: "{name}"
136author: "test"
137base00: "1c1c1c"
138base01: "2c2c2c"
139base02: "3c3c3c"
140base03: "555753"
141base04: "888a85"
142base05: "eeeeec"
143base06: "d3d7cf"
144base07: "eeeeec"
145base08: "cc0000"
146base09: "c4a000"
147base0A: "c4a000"
148base0B: "4e9a06"
149base0C: "06989a"
150base0D: "3465a4"
151base0E: "75507b"
152base0F: "cc0000"
153base10: "1c1c1c"
154base11: "000000"
155base12: "ef2929"
156base13: "fce94f"
157base14: "8ae234"
158base15: "34e2e2"
159base16: "729fcf"
160base17: "ad7fa8"
161"#
162        );
163        Theme::from_base24_str(&yaml).expect("test theme YAML is invalid")
164    }
165
166    fn cursor_with_names(names: &[&str]) -> ThemeCursor<Theme> {
167        ThemeCursor::new(names.iter().map(|n| make_theme(n)))
168    }
169
170    fn name(t: Option<&Theme>) -> &str {
171        t.map(|t| t.meta.name.as_str()).unwrap_or("<none>")
172    }
173
174    // --- empty cursor ---
175
176    #[test]
177    fn empty_cursor() {
178        let mut c = ThemeCursor::<Theme>::new(vec![]);
179        assert_eq!(c.len(), 0);
180        assert!(c.is_empty());
181        assert!(c.peek().is_none());
182        assert!(c.next().is_none());
183        assert!(c.prev().is_none());
184        assert!(c.themes().is_empty());
185    }
186
187    // --- single-element cursor ---
188
189    #[test]
190    fn single_peek_returns_only_theme() {
191        let c = cursor_with_names(&["Alpha"]);
192        assert_eq!(name(c.peek()), "Alpha");
193    }
194
195    #[test]
196    fn single_next_wraps_to_itself() {
197        let mut c = cursor_with_names(&["Alpha"]);
198        assert_eq!(name(c.next()), "Alpha");
199        assert_eq!(name(c.next()), "Alpha");
200    }
201
202    #[test]
203    fn single_prev_wraps_to_itself() {
204        let mut c = cursor_with_names(&["Alpha"]);
205        assert_eq!(name(c.prev()), "Alpha");
206        assert_eq!(name(c.prev()), "Alpha");
207    }
208
209    // --- multi-element cursor ---
210
211    #[test]
212    fn peek_returns_first_on_creation() {
213        let c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
214        assert_eq!(name(c.peek()), "Alpha");
215    }
216
217    #[test]
218    fn next_advances_through_all_themes() {
219        let mut c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
220        assert_eq!(name(c.next()), "Beta");
221        assert_eq!(name(c.next()), "Gamma");
222    }
223
224    #[test]
225    fn next_wraps_from_last_to_first() {
226        let mut c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
227        c.next(); // Beta
228        c.next(); // Gamma
229        assert_eq!(name(c.next()), "Alpha"); // wraps
230    }
231
232    #[test]
233    fn prev_wraps_from_first_to_last() {
234        let mut c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
235        assert_eq!(name(c.prev()), "Gamma"); // wraps back from first
236    }
237
238    #[test]
239    fn prev_retreats_in_order() {
240        let mut c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
241        c.next(); // Beta
242        c.next(); // Gamma
243        assert_eq!(name(c.prev()), "Beta");
244        assert_eq!(name(c.prev()), "Alpha");
245    }
246
247    #[test]
248    fn next_then_prev_returns_to_start() {
249        let mut c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
250        c.next(); // Beta
251        c.prev(); // back to Alpha
252        assert_eq!(name(c.peek()), "Alpha");
253    }
254
255    #[test]
256    fn full_cycle_forward_returns_to_start() {
257        let mut c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
258        c.next();
259        c.next();
260        c.next(); // wraps back to Alpha
261        assert_eq!(name(c.peek()), "Alpha");
262    }
263
264    #[test]
265    fn full_cycle_backward_returns_to_start() {
266        let mut c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
267        c.prev(); // Gamma
268        c.prev(); // Beta
269        c.prev(); // Alpha
270        assert_eq!(name(c.peek()), "Alpha");
271    }
272
273    // --- themes() accessor ---
274
275    #[test]
276    fn themes_returns_all_in_order() {
277        let c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
278        let names: Vec<&str> = c.themes().iter().map(|t| t.meta.name.as_str()).collect();
279        assert_eq!(names, vec!["Alpha", "Beta", "Gamma"]);
280    }
281
282    // --- with_builtins ---
283
284    #[test]
285    fn with_builtins_is_non_empty() {
286        let c = ThemeCursor::with_builtins();
287        assert!(!c.is_empty());
288        assert!(c.peek().is_some());
289    }
290
291    #[test]
292    fn with_builtins_is_sorted() {
293        let c = ThemeCursor::with_builtins();
294        let names: Vec<&str> = c.themes().iter().map(|t| t.meta.name.as_str()).collect();
295        let mut sorted = names.clone();
296        sorted.sort();
297        assert_eq!(names, sorted);
298    }
299
300    #[test]
301    fn with_builtins_can_cycle() {
302        let mut c = ThemeCursor::with_builtins();
303        let first = c.peek().unwrap().meta.name.clone();
304        // Cycle forward through all themes and wrap back
305        for _ in 0..c.len() {
306            c.next();
307        }
308        assert_eq!(c.peek().unwrap().meta.name, first);
309    }
310}