photon_ui/components/
settings_list.rs1use 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
20pub 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 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 pub fn on_change(mut self, cb: fn(usize, bool)) -> Self {
47 self.on_change = Some(cb);
48 self
49 }
50
51 pub fn values(&self) -> Vec<bool> {
53 self.items.iter().map(|(_, v)| *v).collect()
54 }
55
56 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}