Skip to main content

rpi_tui/
settings_list.rs

1//! Settings list component.
2//!
3//! A list of settings with navigation and in-place value cycling. Port of
4//! upstream's `components/settings-list.ts`: Up/Down navigate, Enter (or
5//! Space) activates the row — cycling to the next value, or invoking the
6//! `submenu` callback for rows that open a nested selector — and Esc cancels.
7
8use std::any::Any;
9use std::sync::{Arc, Mutex};
10
11use crossterm::event::{KeyCode, KeyEvent};
12
13use super::component::Component;
14use super::fuzzy::fuzzy_filter;
15use crate::utils::{truncate_to_width, visible_width};
16
17/// A setting item.
18#[derive(Debug, Clone)]
19pub struct SettingItem {
20    /// Setting key.
21    pub key: String,
22    /// Display label.
23    pub label: String,
24    /// Setting value.
25    pub value: String,
26    /// Description.
27    pub description: Option<String>,
28    /// Setting type.
29    pub setting_type: SettingType,
30    /// Values cycled through by [`SettingsList::activate`]. Empty for submenu
31    /// triggers and read-only rows.
32    pub values: Vec<String>,
33    /// Whether activating the row should call `on_select` (open a submenu)
34    /// rather than cycle `values`.
35    pub has_submenu: bool,
36}
37
38impl SettingItem {
39    /// Create a new setting item.
40    pub fn new(key: &str, label: &str, value: &str) -> Self {
41        Self {
42            key: key.to_string(),
43            label: label.to_string(),
44            value: value.to_string(),
45            description: None,
46            setting_type: SettingType::String,
47            values: Vec::new(),
48            has_submenu: false,
49        }
50    }
51
52    /// Add description.
53    pub fn with_description(mut self, description: &str) -> Self {
54        self.description = Some(description.to_string());
55        self
56    }
57
58    /// Set setting type.
59    pub fn with_type(mut self, setting_type: SettingType) -> Self {
60        self.setting_type = setting_type;
61        self
62    }
63
64    /// The cycle order for this row. Activating the row advances to the next
65    /// value and fires `on_change`. Empty ⇒ the row is a submenu trigger or a
66    /// read-only display.
67    pub fn with_values(mut self, values: &[&str]) -> Self {
68        self.values = values.iter().map(|v| v.to_string()).collect();
69        self
70    }
71
72    /// Mark the row as a submenu trigger: activating it calls `on_select`
73    /// instead of cycling values.
74    pub fn with_submenu(mut self) -> Self {
75        self.has_submenu = true;
76        self
77    }
78}
79
80/// Setting type.
81#[derive(Debug, Clone, Copy, PartialEq)]
82pub enum SettingType {
83    /// String setting.
84    String,
85    /// Boolean setting.
86    Boolean,
87    /// Number setting.
88    Number,
89    /// Enum setting.
90    Enum,
91}
92
93/// Settings list theme.
94#[derive(Debug, Clone)]
95pub struct SettingsListTheme {
96    pub key: fn(&str) -> String,
97    pub value: fn(&str) -> String,
98    pub description: fn(&str) -> String,
99    pub selected: fn(&str) -> String,
100    pub selected_prefix: fn(&str) -> String,
101}
102
103impl Default for SettingsListTheme {
104    fn default() -> Self {
105        Self {
106            key: |s| format!("\x1b[1m{}\x1b[0m", s),
107            value: |s| format!("\x1b[36m{}\x1b[0m", s),
108            description: |s| format!("\x1b[90m{}\x1b[0m", s),
109            selected: |s| format!("\x1b[7m{}\x1b[0m", s),
110            selected_prefix: |s| format!("\x1b[32m{}\x1b[0m", s),
111        }
112    }
113}
114
115/// Settings list component.
116pub struct SettingsList {
117    settings: Mutex<Vec<SettingItem>>,
118    filtered: Mutex<Vec<SettingItem>>,
119    selected_index: Mutex<usize>,
120    max_visible: usize,
121    theme: SettingsListTheme,
122    filter: Mutex<String>,
123    on_change: Mutex<Option<Arc<dyn Fn(&str, &str) + Send + Sync>>>,
124    on_select: Mutex<Option<Arc<dyn Fn(&SettingItem) + Send + Sync>>>,
125    on_cancel: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
126}
127
128impl SettingsList {
129    /// Create a new settings list.
130    pub fn new(settings: Vec<SettingItem>) -> Self {
131        Self {
132            filtered: Mutex::new(settings.clone()),
133            settings: Mutex::new(settings),
134            selected_index: Mutex::new(0),
135            max_visible: 10,
136            theme: SettingsListTheme::default(),
137            filter: Mutex::new(String::new()),
138            on_change: Mutex::new(None),
139            on_select: Mutex::new(None),
140            on_cancel: Mutex::new(None),
141        }
142    }
143
144    /// Create with theme.
145    pub fn with_theme(settings: Vec<SettingItem>, theme: SettingsListTheme) -> Self {
146        Self {
147            filtered: Mutex::new(settings.clone()),
148            settings: Mutex::new(settings),
149            selected_index: Mutex::new(0),
150            max_visible: 10,
151            theme,
152            filter: Mutex::new(String::new()),
153            on_change: Mutex::new(None),
154            on_select: Mutex::new(None),
155            on_cancel: Mutex::new(None),
156        }
157    }
158
159    /// Set filter.
160    pub fn set_filter(&self, filter: &str) {
161        if let Ok(mut f) = self.filter.lock() {
162            *f = filter.to_string();
163        }
164        self.refilter();
165        // A changed query resets the cursor to the first match.
166        if let Ok(mut idx) = self.selected_index.lock() {
167            *idx = 0;
168        }
169    }
170
171    /// Rebuild the visible rows from the current filter without moving the
172    /// cursor (a value change must not jump the selection back to the top).
173    fn refilter(&self) {
174        let filter = self.filter.lock().map(|f| f.clone()).unwrap_or_default();
175        if let Ok(settings) = self.settings.lock() {
176            let filtered = fuzzy_filter(&*settings, &filter, |item| item.key.as_str());
177            if let Ok(mut f) = self.filtered.lock() {
178                *f = filtered;
179            }
180        }
181    }
182
183    /// Get selected item.
184    pub fn get_selected(&self) -> Option<SettingItem> {
185        let idx = *self.selected_index.lock().ok()?;
186        let filtered = self.filtered.lock().ok()?;
187        filtered.get(idx).cloned()
188    }
189
190    /// Set selected index.
191    pub fn set_selected(&self, index: usize) {
192        if let Ok(mut idx) = self.selected_index.lock() {
193            if let Ok(filtered) = self.filtered.lock() {
194                *idx = index.min(filtered.len().saturating_sub(1));
195            }
196        }
197    }
198
199    /// Move selection up.
200    pub fn move_up(&self) {
201        if let Ok(mut idx) = self.selected_index.lock() {
202            if let Ok(filtered) = self.filtered.lock() {
203                if !filtered.is_empty() {
204                    if *idx == 0 {
205                        *idx = filtered.len() - 1;
206                    } else {
207                        *idx -= 1;
208                    }
209                }
210            }
211        }
212    }
213
214    /// Move selection down.
215    pub fn move_down(&self) {
216        if let Ok(mut idx) = self.selected_index.lock() {
217            if let Ok(filtered) = self.filtered.lock() {
218                if !filtered.is_empty() {
219                    if *idx >= filtered.len() - 1 {
220                        *idx = 0;
221                    } else {
222                        *idx += 1;
223                    }
224                }
225            }
226        }
227    }
228
229    /// Toggle boolean setting.
230    pub fn toggle(&self) {
231        if let Some(item) = self.get_selected() {
232            if item.setting_type == SettingType::Boolean {
233                let new_value = if item.value == "true" {
234                    "false"
235                } else {
236                    "true"
237                };
238                self.update_value(&item.key, new_value);
239
240                if let Ok(cb) = self.on_change.lock() {
241                    if let Some(callback) = cb.as_ref() {
242                        callback(&item.key, new_value);
243                    }
244                }
245            }
246        }
247    }
248
249    /// Update a setting value.
250    pub fn update_value(&self, key: &str, value: &str) {
251        if let Ok(mut settings) = self.settings.lock() {
252            for setting in settings.iter_mut() {
253                if setting.key == key {
254                    setting.value = value.to_string();
255                    break;
256                }
257            }
258        }
259
260        // Refresh the visible rows but keep the cursor where the user was.
261        self.refilter();
262        if let Ok(mut idx) = self.selected_index.lock() {
263            if let Ok(filtered) = self.filtered.lock() {
264                *idx = (*idx).min(filtered.len().saturating_sub(1));
265            }
266        }
267    }
268
269    /// Set change callback.
270    pub fn on_change(&self, callback: Arc<dyn Fn(&str, &str) + Send + Sync>) {
271        if let Ok(mut cb) = self.on_change.lock() {
272            *cb = Some(callback);
273        }
274    }
275
276    /// Set select callback.
277    pub fn on_select(&self, callback: Arc<dyn Fn(&SettingItem) + Send + Sync>) {
278        if let Ok(mut cb) = self.on_select.lock() {
279            *cb = Some(callback);
280        }
281    }
282
283    /// Set cancel callback (Esc). Upstream's `SettingsList.onCancel`.
284    pub fn on_cancel(&self, callback: Arc<dyn Fn() + Send + Sync>) {
285        if let Ok(mut cb) = self.on_cancel.lock() {
286            *cb = Some(callback);
287        }
288    }
289
290    /// Fire the cancel callback.
291    pub fn cancel(&self) {
292        if let Ok(cb) = self.on_cancel.lock() {
293            if let Some(callback) = cb.as_ref() {
294                callback();
295            }
296        }
297    }
298
299    /// Activate the selected row: open its submenu, or cycle to the next value.
300    /// Mirrors upstream's `SettingsList.activateItem`.
301    ///
302    /// Returns the new value when a value was cycled.
303    pub fn activate(&self) -> Option<(String, String)> {
304        let item = self.get_selected()?;
305        if item.has_submenu {
306            if let Ok(cb) = self.on_select.lock() {
307                if let Some(callback) = cb.as_ref() {
308                    callback(&item);
309                }
310            }
311            return None;
312        }
313        if item.values.is_empty() {
314            return None;
315        }
316        let current = item
317            .values
318            .iter()
319            .position(|value| value == &item.value)
320            .unwrap_or(item.values.len().saturating_sub(1));
321        let next = item.values[(current + 1) % item.values.len()].clone();
322        self.update_value(&item.key, &next);
323        if let Ok(cb) = self.on_change.lock() {
324            if let Some(callback) = cb.as_ref() {
325                callback(&item.key, &next);
326            }
327        }
328        Some((item.key, next))
329    }
330
331    /// Route a key: Up/Down navigate, Enter or Space activates, Esc cancels.
332    /// Mirrors upstream's `SettingsList.handleInput`.
333    pub fn handle_key(&self, key: KeyEvent) {
334        match key.code {
335            KeyCode::Up => self.move_up(),
336            KeyCode::Down => self.move_down(),
337            KeyCode::Enter => {
338                self.activate();
339            }
340            KeyCode::Char(' ') => {
341                self.activate();
342            }
343            KeyCode::Esc => self.cancel(),
344            _ => {}
345        }
346    }
347
348    /// The current value of `key`, if the row exists.
349    pub fn value_of(&self, key: &str) -> Option<String> {
350        let settings = self.settings.lock().ok()?;
351        settings
352            .iter()
353            .find(|item| item.key == key)
354            .map(|item| item.value.clone())
355    }
356
357    /// Replace every row (used to rebuild after an external settings change
358    /// without reopening the selector).
359    pub fn set_items(&self, settings: Vec<SettingItem>) {
360        let filter = self.filter.lock().map(|f| f.clone()).unwrap_or_default();
361        if let Ok(mut guard) = self.settings.lock() {
362            *guard = settings;
363        }
364        self.set_filter(&filter);
365    }
366
367    /// Confirm selection.
368    pub fn confirm(&self) {
369        if let Some(item) = self.get_selected() {
370            if let Ok(cb) = self.on_select.lock() {
371                if let Some(callback) = cb.as_ref() {
372                    callback(&item);
373                }
374            }
375        }
376    }
377
378    /// Render a single setting item.
379    fn render_item(&self, item: &SettingItem, is_selected: bool, width: usize) -> String {
380        let prefix = if is_selected { "→ " } else { "  " };
381
382        let key_width = 20;
383        let value_width = 15;
384
385        let key_display = truncate_to_width(&item.label, key_width, "");
386        let value_display = truncate_to_width(&item.value, value_width, "");
387
388        let main_line = if is_selected {
389            format!(
390                "{}{} {} {}",
391                (self.theme.selected_prefix)(prefix),
392                (self.theme.key)(&key_display),
393                (self.theme.selected)(&value_display),
394                ""
395            )
396        } else {
397            format!(
398                "{}{} {}",
399                prefix,
400                (self.theme.key)(&key_display),
401                (self.theme.value)(&value_display)
402            )
403        };
404
405        // Add description if available
406        if let Some(ref desc) = item.description {
407            let desc_width = width.saturating_sub(visible_width(&main_line) + 2);
408            if desc_width > 10 {
409                let desc_display = truncate_to_width(desc, desc_width, "");
410                format!("{} {}", main_line, (self.theme.description)(&desc_display))
411            } else {
412                main_line
413            }
414        } else {
415            main_line
416        }
417    }
418}
419
420impl Component for SettingsList {
421    fn render(&self, width: usize) -> Vec<String> {
422        let filtered = if let Ok(f) = self.filtered.lock() {
423            f.clone()
424        } else {
425            return Vec::new();
426        };
427
428        let selected = *self.selected_index.lock().unwrap();
429
430        if filtered.is_empty() {
431            return vec![(self.theme.description)("  No settings found")];
432        }
433
434        // Calculate visible range
435        let start = if filtered.len() <= self.max_visible {
436            0
437        } else {
438            selected
439                .saturating_sub(self.max_visible / 2)
440                .min(filtered.len().saturating_sub(self.max_visible))
441        };
442        let end = (start + self.max_visible).min(filtered.len());
443
444        let mut lines = Vec::new();
445
446        for i in start..end {
447            if let Some(item) = filtered.get(i) {
448                lines.push(self.render_item(item, i == selected, width));
449            }
450        }
451
452        // Add scroll indicator
453        if start > 0 || end < filtered.len() {
454            let info = format!("  ({}/{})", selected + 1, filtered.len());
455            lines.push((self.theme.description)(&info));
456        }
457
458        lines
459    }
460
461    fn invalidate(&self) {
462        // No cached state
463    }
464
465    fn as_any(&self) -> &dyn Any {
466        self
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    #[test]
475    fn test_settings_list_new() {
476        let settings = vec![
477            SettingItem::new("theme", "Theme", "dark"),
478            SettingItem::new("font", "Font Size", "14"),
479        ];
480        let list = SettingsList::new(settings);
481
482        let lines = list.render(60);
483        assert!(!lines.is_empty());
484    }
485
486    #[test]
487    fn test_settings_navigation() {
488        let settings = vec![
489            SettingItem::new("a", "A", "1"),
490            SettingItem::new("b", "B", "2"),
491        ];
492        let list = SettingsList::new(settings);
493
494        assert_eq!(list.get_selected().unwrap().key, "a");
495        list.move_down();
496        assert_eq!(list.get_selected().unwrap().key, "b");
497        list.move_down(); // Wrap around
498        assert_eq!(list.get_selected().unwrap().key, "a");
499    }
500
501    #[test]
502    fn test_settings_filter() {
503        let settings = vec![
504            SettingItem::new("theme", "Theme", "dark"),
505            SettingItem::new("font", "Font Size", "14"),
506        ];
507        let list = SettingsList::new(settings);
508
509        list.set_filter("theme");
510        let selected = list.get_selected();
511        assert!(selected.is_some());
512        assert_eq!(selected.unwrap().key, "theme");
513    }
514
515    #[test]
516    fn activate_cycles_values_and_fires_change() {
517        let settings = vec![SettingItem::new("hide-thinking", "Hide thinking", "false")
518            .with_values(&["true", "false"])];
519        let list = SettingsList::new(settings);
520
521        let changes: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
522        let changes_cb = changes.clone();
523        list.on_change(Arc::new(move |key, value| {
524            changes_cb
525                .lock()
526                .unwrap()
527                .push((key.to_string(), value.to_string()));
528        }));
529
530        // current "false" -> next in cycle is "true"
531        assert_eq!(
532            list.activate(),
533            Some(("hide-thinking".to_string(), "true".to_string()))
534        );
535        assert_eq!(list.value_of("hide-thinking").as_deref(), Some("true"));
536        // Cycling wraps back around.
537        list.activate();
538        assert_eq!(list.value_of("hide-thinking").as_deref(), Some("false"));
539        assert_eq!(changes.lock().unwrap().len(), 2);
540    }
541
542    #[test]
543    fn activating_a_value_preserves_the_cursor() {
544        let settings = vec![
545            SettingItem::new("a", "Auto-compact", "true").with_values(&["true", "false"]),
546            SettingItem::new("b", "Steering mode", "all").with_values(&["all", "one-at-a-time"]),
547            SettingItem::new("c", "Quiet startup", "false").with_values(&["true", "false"]),
548        ];
549        let list = SettingsList::new(settings);
550        list.move_down();
551        assert_eq!(list.get_selected().unwrap().key, "b");
552        list.activate();
553        // Upstream keeps the cursor on the row the user just changed.
554        assert_eq!(list.get_selected().unwrap().key, "b");
555        assert_eq!(
556            list.get_selected().unwrap().value,
557            "one-at-a-time",
558            "value should have cycled in place"
559        );
560    }
561
562    #[test]
563    fn submenu_row_calls_select_instead_of_cycling() {
564        let settings = vec![SettingItem::new("theme", "Theme", "dark").with_submenu()];
565        let list = SettingsList::new(settings);
566        let opened: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
567        let opened_cb = opened.clone();
568        list.on_select(Arc::new(move |item| {
569            opened_cb.lock().unwrap().push(item.key.clone());
570        }));
571        assert_eq!(list.activate(), None);
572        assert_eq!(opened.lock().unwrap().as_slice(), ["theme"]);
573        // Value untouched.
574        assert_eq!(list.value_of("theme").as_deref(), Some("dark"));
575    }
576
577    #[test]
578    fn handle_key_routes_navigation_activation_and_cancel() {
579        use crossterm::event::{KeyCode, KeyEvent};
580        let settings = vec![
581            SettingItem::new("a", "Alpha", "1").with_values(&["1", "2"]),
582            SettingItem::new("b", "Beta", "x").with_values(&["x", "y"]),
583        ];
584        let list = SettingsList::new(settings);
585        let cancelled = Arc::new(Mutex::new(false));
586        let cancelled_cb = cancelled.clone();
587        list.on_cancel(Arc::new(move || {
588            *cancelled_cb.lock().unwrap() = true;
589        }));
590
591        list.handle_key(KeyEvent::new(
592            KeyCode::Down,
593            crossterm::event::KeyModifiers::NONE,
594        ));
595        assert_eq!(list.get_selected().unwrap().key, "b");
596        list.handle_key(KeyEvent::new(
597            KeyCode::Char(' '),
598            crossterm::event::KeyModifiers::NONE,
599        ));
600        assert_eq!(list.value_of("b").as_deref(), Some("y"));
601        list.handle_key(KeyEvent::new(
602            KeyCode::Esc,
603            crossterm::event::KeyModifiers::NONE,
604        ));
605        assert!(*cancelled.lock().unwrap());
606    }
607}