Skip to main content

photon_ui/components/
settings_list.rs

1use crossterm::event::{
2    KeyCode,
3    KeyModifiers,
4};
5
6use crate::{
7    Component,
8    Event,
9    Focusable,
10    InputResult,
11    RenderError,
12    Rendered,
13    theme::{
14        Style,
15        Theme,
16        stylize,
17    },
18};
19
20/// A scrollable list of toggleable settings rendered as checkboxes.
21///
22/// Each item is a `(name, value)` pair where `value` is `true` for checked
23/// `[x]` and `false` for unchecked `[ ]`. Items can be toggled with Enter
24/// or Space, and an optional `on_change` callback is fired on every toggle.
25pub struct SettingsList {
26    items: Vec<(String, bool)>,
27    selected: usize,
28    focused: bool,
29    on_change: Option<fn(usize, bool)>,
30}
31
32impl SettingsList {
33    /// Create a new settings list from `(name, value)` pairs.
34    pub fn new(items: Vec<(String, bool)>) -> Self {
35        Self {
36            items,
37            selected: 0,
38            focused: false,
39            on_change: None,
40        }
41    }
42
43    /// Attach a callback invoked when an item is toggled.
44    ///
45    /// The callback receives `(index, new_value)`.
46    pub fn on_change(mut self, cb: fn(usize, bool)) -> Self {
47        self.on_change = Some(cb);
48        self
49    }
50
51    /// Return the current values of all items.
52    pub fn values(&self) -> Vec<bool> {
53        self.items.iter().map(|(_, v)| *v).collect()
54    }
55
56    /// Set the selected item index (clamped to valid range).
57    pub fn set_selected(&mut self, index: usize) {
58        self.selected = index.min(self.items.len().saturating_sub(1));
59    }
60}
61
62impl Focusable for SettingsList {
63    fn focused(&self) -> bool {
64        self.focused
65    }
66
67    fn set_focused(&mut self, focused: bool) {
68        self.focused = focused;
69    }
70}
71
72impl Component for SettingsList {
73    fn render(&self, width: u16) -> Result<Rendered, RenderError> {
74        let theme = Theme::palette();
75        let accent_style = Style::new().fg(theme.accent()).bold();
76        let primary_style = Style::new().fg(theme.text());
77        let dim_style = Style::new().fg(theme.text_muted());
78
79        let mut lines = Vec::new();
80        for (i, (name, value)) in self.items.iter().enumerate() {
81            let is_selected = i == self.selected;
82            let style = if is_selected && self.focused {
83                &accent_style
84            } else if is_selected {
85                &primary_style
86            } else {
87                &dim_style
88            };
89
90            let prefix = if is_selected { "> " } else { "  " };
91            let check = if *value { "[x]" } else { "[ ]" };
92            let line = stylize(&format!("{}{} {}", prefix, check, name), style);
93            lines.push(crate::utils::truncate_to_width(&line, width, "…"));
94        }
95        Ok(Rendered {
96            lines,
97            cursor: None,
98            images: Vec::new(),
99        })
100    }
101
102    fn handle_input(&mut self, event: &Event) -> InputResult {
103        if let Event::Key(key) = event {
104            match key.code {
105                | KeyCode::Down => {
106                    if self.selected + 1 < self.items.len() {
107                        self.selected += 1;
108                    }
109                    InputResult::Handled
110                },
111                | KeyCode::Up => {
112                    if self.selected > 0 {
113                        self.selected -= 1;
114                    }
115                    InputResult::Handled
116                },
117                | KeyCode::Char('j') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
118                    if self.selected + 1 < self.items.len() {
119                        self.selected += 1;
120                    }
121                    InputResult::Handled
122                },
123                | KeyCode::Char('k') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
124                    if self.selected > 0 {
125                        self.selected -= 1;
126                    }
127                    InputResult::Handled
128                },
129                | KeyCode::Enter | KeyCode::Char(' ') => {
130                    if let Some((_, value)) = self.items.get_mut(self.selected) {
131                        *value = !*value;
132                        if let Some(cb) = self.on_change {
133                            cb(self.selected, *value);
134                        }
135                    }
136                    InputResult::Handled
137                },
138                | _ => InputResult::Ignored,
139            }
140        } else {
141            InputResult::Ignored
142        }
143    }
144
145    fn as_focusable(&self) -> Option<&dyn Focusable> {
146        Some(self)
147    }
148
149    fn as_focusable_mut(&mut self) -> Option<&mut dyn Focusable> {
150        Some(self)
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn renders_items() {
160        let list = SettingsList::new(vec![("A".into(), true), ("B".into(), false)]);
161        let rendered = list.render(80).unwrap();
162        assert_eq!(rendered.lines.len(), 2);
163        assert!(rendered.lines[0].contains("[x]"));
164        assert!(rendered.lines[1].contains("[ ]"));
165    }
166
167    #[test]
168    fn navigation_down_and_up() {
169        let mut list = SettingsList::new(vec![("A".into(), false), ("B".into(), false)]);
170        list.set_focused(true);
171        list.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
172            KeyCode::Down,
173            crossterm::event::KeyModifiers::empty(),
174        )));
175        let rendered = list.render(80).unwrap();
176        assert!(rendered.lines[1].contains("> "));
177
178        list.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
179            KeyCode::Up,
180            crossterm::event::KeyModifiers::empty(),
181        )));
182        let rendered = list.render(80).unwrap();
183        assert!(rendered.lines[0].contains("> "));
184    }
185
186    #[test]
187    fn toggle_with_enter() {
188        let mut list = SettingsList::new(vec![("A".into(), false)]);
189        list.set_focused(true);
190        list.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
191            KeyCode::Enter,
192            crossterm::event::KeyModifiers::empty(),
193        )));
194        let rendered = list.render(80).unwrap();
195        assert!(rendered.lines[0].contains("[x]"));
196        assert_eq!(list.values(), vec![true]);
197    }
198
199    #[test]
200    fn ignored_input() {
201        let mut list = SettingsList::new(vec![("A".into(), false)]);
202        let result = list.handle_input(&Event::Resize(80, 24));
203        assert!(matches!(result, InputResult::Ignored));
204    }
205}