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`.
16pub struct ThemeCursor<T> {
17    themes: Vec<T>,
18    index: usize,
19}
20
21impl<T> ThemeCursor<T> {
22    /// Create a cursor from an explicit list. The cursor starts at the first theme.
23    pub fn new(themes: Vec<T>) -> Self {
24        Self { index: 0, themes }
25    }
26
27    /// Returns the current theme without moving the cursor.
28    pub fn peek(&self) -> Option<&T> {
29        self.themes.get(self.index)
30    }
31
32    /// Advances the cursor to the next theme (wrapping) and returns it.
33    ///
34    /// Returns `None` if the cursor is empty.
35    #[allow(clippy::should_implement_trait)]
36    pub fn next(&mut self) -> Option<&T> {
37        if self.is_empty() {
38            return None;
39        }
40        self.index = (self.index + 1) % self.themes.len();
41        self.themes.get(self.index)
42    }
43
44    /// Retreats the cursor to the previous theme (wrapping) and returns it.
45    ///
46    /// Returns `None` if the cursor is empty.
47    pub fn prev(&mut self) -> Option<&T> {
48        if self.is_empty() {
49            return None;
50        }
51        if self.index == 0 {
52            self.index = self.themes.len() - 1;
53        } else {
54            self.index -= 1;
55        }
56        self.themes.get(self.index)
57    }
58
59    /// Returns a slice of all themes in the cursor.
60    pub fn themes(&self) -> &[T] {
61        &self.themes
62    }
63
64    /// Returns the number of themes.
65    pub fn len(&self) -> usize {
66        self.themes.len()
67    }
68
69    /// Returns `true` if the cursor contains no themes.
70    pub fn is_empty(&self) -> bool {
71        self.themes.is_empty()
72    }
73}
74
75/// Convenience constructors for [`ThemeCursor<Theme>`].
76impl ThemeCursor<Theme> {
77    /// All built-in themes.
78    pub fn with_builtins() -> Self {
79        let themes = BuiltinTheme::iter().map(|b| b.theme()).collect();
80        ThemeCursor::new(themes)
81    }
82
83    /// User-installed themes only.
84    #[cfg(feature = "fs")]
85    pub fn with_user_themes() -> Self {
86        use crate::all_user_themes;
87        ThemeCursor::new(all_user_themes())
88    }
89
90    /// Built-ins + user themes. User themes with matching names override builtins.
91    #[cfg(feature = "fs")]
92    pub fn with_all_themes() -> Self {
93        use crate::all_themes;
94        ThemeCursor::new(all_themes())
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::{Ansi, Meta, Semantic, Theme, Ui, UiBg, UiBorder, UiCursor, UiFg, UiSelection};
102
103    fn make_theme(name: &str) -> Theme {
104        Theme {
105            meta: Meta {
106                name: name.to_string(),
107                slug: None,
108                author: None,
109                version: None,
110                description: None,
111                dark: None,
112            },
113            ansi: Ansi {
114                black: "#000000".into(),
115                red: "#cc0000".into(),
116                green: "#4e9a06".into(),
117                yellow: "#c4a000".into(),
118                blue: "#3465a4".into(),
119                magenta: "#75507b".into(),
120                cyan: "#06989a".into(),
121                white: "#d3d7cf".into(),
122                bright_black: "#555753".into(),
123                bright_red: "#ef2929".into(),
124                bright_green: "#8ae234".into(),
125                bright_yellow: "#fce94f".into(),
126                bright_blue: "#729fcf".into(),
127                bright_magenta: "#ad7fa8".into(),
128                bright_cyan: "#34e2e2".into(),
129                bright_white: "#eeeeec".into(),
130            },
131            palette: None,
132            base16: None,
133            semantic: Semantic {
134                error: "#cc0000".into(),
135                warning: "#c4a000".into(),
136                info: "#3465a4".into(),
137                success: "#4e9a06".into(),
138                highlight: "#c4a000".into(),
139                link: "#06989a".into(),
140            },
141            ui: Ui {
142                bg: UiBg {
143                    primary: "#1c1c1c".into(),
144                    secondary: "#2c2c2c".into(),
145                },
146                fg: UiFg {
147                    primary: "#eeeeec".into(),
148                    secondary: "#d3d7cf".into(),
149                    muted: "#888a85".into(),
150                },
151                border: UiBorder {
152                    primary: "#555753".into(),
153                    muted: "#2c2c2c".into(),
154                },
155                cursor: UiCursor {
156                    primary: "#eeeeec".into(),
157                    muted: "#888a85".into(),
158                },
159                selection: UiSelection {
160                    bg: "#3465a4".into(),
161                    fg: "#eeeeec".into(),
162                },
163            },
164        }
165    }
166
167    fn cursor_with_names(names: &[&str]) -> ThemeCursor<Theme> {
168        ThemeCursor::new(names.iter().map(|n| make_theme(n)).collect())
169    }
170
171    fn name(t: Option<&Theme>) -> &str {
172        t.map(|t| t.meta.name.as_str()).unwrap_or("<none>")
173    }
174
175    // --- empty cursor ---
176
177    #[test]
178    fn empty_cursor_peek_is_none() {
179        let c = ThemeCursor::<Theme>::new(vec![]);
180        assert!(c.peek().is_none());
181    }
182
183    #[test]
184    fn empty_cursor_next_is_none() {
185        let mut c = ThemeCursor::<Theme>::new(vec![]);
186        assert!(c.next().is_none());
187    }
188
189    #[test]
190    fn empty_cursor_prev_is_none() {
191        let mut c = ThemeCursor::<Theme>::new(vec![]);
192        assert!(c.prev().is_none());
193    }
194
195    #[test]
196    fn empty_cursor_len_and_is_empty() {
197        let c = ThemeCursor::<Theme>::new(vec![]);
198        assert_eq!(c.len(), 0);
199        assert!(c.is_empty());
200    }
201
202    // --- single-element cursor ---
203
204    #[test]
205    fn single_peek_returns_only_theme() {
206        let c = cursor_with_names(&["Alpha"]);
207        assert_eq!(name(c.peek()), "Alpha");
208    }
209
210    #[test]
211    fn single_next_wraps_to_itself() {
212        let mut c = cursor_with_names(&["Alpha"]);
213        assert_eq!(name(c.next()), "Alpha");
214        assert_eq!(name(c.next()), "Alpha");
215    }
216
217    #[test]
218    fn single_prev_wraps_to_itself() {
219        let mut c = cursor_with_names(&["Alpha"]);
220        assert_eq!(name(c.prev()), "Alpha");
221        assert_eq!(name(c.prev()), "Alpha");
222    }
223
224    // --- multi-element cursor ---
225
226    #[test]
227    fn peek_returns_first_on_creation() {
228        let c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
229        assert_eq!(name(c.peek()), "Alpha");
230    }
231
232    #[test]
233    fn next_advances_through_all_themes() {
234        let mut c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
235        assert_eq!(name(c.next()), "Beta");
236        assert_eq!(name(c.next()), "Gamma");
237    }
238
239    #[test]
240    fn next_wraps_from_last_to_first() {
241        let mut c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
242        c.next(); // Beta
243        c.next(); // Gamma
244        assert_eq!(name(c.next()), "Alpha"); // wraps
245    }
246
247    #[test]
248    fn prev_wraps_from_first_to_last() {
249        let mut c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
250        assert_eq!(name(c.prev()), "Gamma"); // wraps back from first
251    }
252
253    #[test]
254    fn prev_retreats_in_order() {
255        let mut c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
256        c.next(); // Beta
257        c.next(); // Gamma
258        assert_eq!(name(c.prev()), "Beta");
259        assert_eq!(name(c.prev()), "Alpha");
260    }
261
262    #[test]
263    fn next_then_prev_returns_to_start() {
264        let mut c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
265        c.next(); // Beta
266        c.prev(); // back to Alpha
267        assert_eq!(name(c.peek()), "Alpha");
268    }
269
270    #[test]
271    fn full_cycle_forward_returns_to_start() {
272        let mut c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
273        c.next();
274        c.next();
275        c.next(); // wraps back to Alpha
276        assert_eq!(name(c.peek()), "Alpha");
277    }
278
279    #[test]
280    fn full_cycle_backward_returns_to_start() {
281        let mut c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
282        c.prev(); // Gamma
283        c.prev(); // Beta
284        c.prev(); // Alpha
285        assert_eq!(name(c.peek()), "Alpha");
286    }
287
288    // --- themes() accessor ---
289
290    #[test]
291    fn themes_returns_all_in_order() {
292        let c = cursor_with_names(&["Alpha", "Beta", "Gamma"]);
293        let names: Vec<&str> = c.themes().iter().map(|t| t.meta.name.as_str()).collect();
294        assert_eq!(names, vec!["Alpha", "Beta", "Gamma"]);
295    }
296
297    #[test]
298    fn themes_is_empty_for_empty_cursor() {
299        let c = ThemeCursor::<Theme>::new(vec![]);
300        assert!(c.themes().is_empty());
301    }
302
303    // --- with_builtins ---
304
305    #[test]
306    fn with_builtins_is_non_empty() {
307        let c = ThemeCursor::with_builtins();
308        assert!(!c.is_empty());
309        assert!(c.peek().is_some());
310    }
311
312    #[test]
313    fn with_builtins_can_cycle() {
314        let mut c = ThemeCursor::with_builtins();
315        let first = c.peek().unwrap().meta.name.clone();
316        // Cycle forward through all themes and wrap back
317        for _ in 0..c.len() {
318            c.next();
319        }
320        assert_eq!(c.peek().unwrap().meta.name, first);
321    }
322}