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    /// Returns the current cursor index.
77    pub fn index(&self) -> usize {
78        self.index
79    }
80
81    /// Moves the cursor to `index` and returns the theme at that position.
82    ///
83    /// Returns `None` if `index` is out of bounds.
84    pub fn set_index(&mut self, index: usize) -> Option<&T> {
85        if index < self.themes.len() {
86            self.index = index;
87            self.themes.get(index)
88        } else {
89            None
90        }
91    }
92}
93
94/// Convenience constructors for [`ThemeCursor<Theme>`].
95impl ThemeCursor<Theme> {
96    /// All built-in themes.
97    pub fn with_builtins() -> Self {
98        let mut themes: Vec<Theme> = BuiltinTheme::iter().map(|b| b.theme()).collect();
99        themes.sort();
100        ThemeCursor::new(themes)
101    }
102
103    /// User-installed themes only.
104    #[cfg(feature = "fs")]
105    pub fn with_user_themes() -> Self {
106        use crate::all_user_themes;
107        let mut themes = all_user_themes();
108        themes.sort();
109        ThemeCursor::new(themes)
110    }
111
112    /// Built-ins + user themes. User themes with matching names override builtins.
113    #[cfg(feature = "fs")]
114    pub fn with_all_themes() -> Self {
115        use crate::all_themes;
116        let mut themes = all_themes();
117        themes.sort();
118        ThemeCursor::new(themes)
119    }
120
121    /// Moves the cursor to the theme matching `name` (slug-insensitive) and returns it.
122    ///
123    /// Accepts fuzzy names: "Nord Dark", "nord-dark", and "nordDark" all match the same theme.
124    /// Returns `None` if no matching theme is found.
125    pub fn set_current(&mut self, name: &str) -> Option<&Theme> {
126        let slug = heck::AsKebabCase(name).to_string();
127        let idx = self.themes.iter().position(|t| t.name_slug() == slug)?;
128        self.set_index(idx)
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::Theme;
136
137    fn make_theme(name: &str) -> Theme {
138        // Build a minimal base24 YAML string so tests don't depend on struct internals.
139        let yaml = format!(
140            r#"scheme: "{name}"
141author: "test"
142base00: "1c1c1c"
143base01: "2c2c2c"
144base02: "3c3c3c"
145base03: "555753"
146base04: "888a85"
147base05: "eeeeec"
148base06: "d3d7cf"
149base07: "eeeeec"
150base08: "cc0000"
151base09: "c4a000"
152base0A: "c4a000"
153base0B: "4e9a06"
154base0C: "06989a"
155base0D: "3465a4"
156base0E: "75507b"
157base0F: "cc0000"
158base10: "1c1c1c"
159base11: "000000"
160base12: "ef2929"
161base13: "fce94f"
162base14: "8ae234"
163base15: "34e2e2"
164base16: "729fcf"
165base17: "ad7fa8"
166"#
167        );
168        Theme::from_base24_str(&yaml).expect("test theme YAML is invalid")
169    }
170
171    fn cursor_with_names(names: &[&str]) -> ThemeCursor<Theme> {
172        ThemeCursor::new(names.iter().map(|n| make_theme(n)))
173    }
174
175    fn name(t: Option<&Theme>) -> &str {
176        t.map(|t| t.meta.name.as_str()).unwrap_or("<none>")
177    }
178
179    // --- empty cursor ---
180
181    #[test]
182    fn empty_cursor() {
183        let mut c = ThemeCursor::<Theme>::new(vec![]);
184        assert_eq!(c.len(), 0);
185        assert!(c.is_empty());
186        assert!(c.peek().is_none());
187        assert!(c.next().is_none());
188        assert!(c.prev().is_none());
189        assert!(c.themes().is_empty());
190    }
191
192    // --- single-element cursor ---
193
194    #[test]
195    fn single_peek_returns_only_theme() {
196        let c = cursor_with_names(&["Alpha"]);
197        assert_eq!(name(c.peek()), "Alpha");
198    }
199
200    #[test]
201    fn single_next_wraps_to_itself() {
202        let mut c = cursor_with_names(&["Alpha"]);
203        assert_eq!(name(c.next()), "Alpha");
204        assert_eq!(name(c.next()), "Alpha");
205    }
206
207    #[test]
208    fn single_prev_wraps_to_itself() {
209        let mut c = cursor_with_names(&["Alpha"]);
210        assert_eq!(name(c.prev()), "Alpha");
211        assert_eq!(name(c.prev()), "Alpha");
212    }
213
214    // --- multi-element cursor ---
215
216    #[test]
217    fn peek_returns_first_on_creation() {
218        let c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
219        assert_eq!(name(c.peek()), "Alpha");
220    }
221
222    #[test]
223    fn next_advances_through_all_themes() {
224        let mut c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
225        assert_eq!(name(c.next()), "Beta");
226        assert_eq!(name(c.next()), "Gamma");
227    }
228
229    #[test]
230    fn next_wraps_from_last_to_first() {
231        let mut c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
232        c.next(); // Beta
233        c.next(); // Gamma
234        assert_eq!(name(c.next()), "Alpha"); // wraps
235    }
236
237    #[test]
238    fn prev_wraps_from_first_to_last() {
239        let mut c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
240        assert_eq!(name(c.prev()), "Gamma"); // wraps back from first
241    }
242
243    #[test]
244    fn prev_retreats_in_order() {
245        let mut c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
246        c.next(); // Beta
247        c.next(); // Gamma
248        assert_eq!(name(c.prev()), "Beta");
249        assert_eq!(name(c.prev()), "Alpha");
250    }
251
252    #[test]
253    fn next_then_prev_returns_to_start() {
254        let mut c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
255        c.next(); // Beta
256        c.prev(); // back to Alpha
257        assert_eq!(name(c.peek()), "Alpha");
258    }
259
260    #[test]
261    fn full_cycle_forward_returns_to_start() {
262        let mut c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
263        c.next();
264        c.next();
265        c.next(); // wraps back to Alpha
266        assert_eq!(name(c.peek()), "Alpha");
267    }
268
269    #[test]
270    fn full_cycle_backward_returns_to_start() {
271        let mut c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
272        c.prev(); // Gamma
273        c.prev(); // Beta
274        c.prev(); // Alpha
275        assert_eq!(name(c.peek()), "Alpha");
276    }
277
278    // --- themes() accessor ---
279
280    #[test]
281    fn themes_returns_all_in_order() {
282        let c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
283        let names: Vec<&str> = c.themes().iter().map(|t| t.meta.name.as_str()).collect();
284        assert_eq!(names, vec!["Alpha", "Beta", "Gamma"]);
285    }
286
287    // --- with_builtins ---
288
289    #[test]
290    fn with_builtins_is_non_empty() {
291        let c = ThemeCursor::with_builtins();
292        assert!(!c.is_empty());
293        assert!(c.peek().is_some());
294    }
295
296    #[test]
297    fn with_builtins_is_sorted() {
298        let c = ThemeCursor::with_builtins();
299        let names: Vec<&str> = c.themes().iter().map(|t| t.meta.name.as_str()).collect();
300        let mut sorted = names.clone();
301        sorted.sort();
302        assert_eq!(names, sorted);
303    }
304
305    #[test]
306    fn with_builtins_can_cycle() {
307        let mut c = ThemeCursor::with_builtins();
308        let first = c.peek().unwrap().meta.name.clone();
309        // Cycle forward through all themes and wrap back
310        for _ in 0..c.len() {
311            c.next();
312        }
313        assert_eq!(c.peek().unwrap().meta.name, first);
314    }
315}