Skip to main content

rab/tui/components/
settings_list.rs

1#![allow(clippy::type_complexity)]
2
3use crate::tui::component::Component;
4use crate::tui::components::input::Input;
5use crate::tui::fuzzy::fuzzy_filter;
6use crate::tui::keybindings::{
7    ACTION_SELECT_CANCEL, ACTION_SELECT_CONFIRM, ACTION_SELECT_DOWN, ACTION_SELECT_UP,
8    get_keybindings,
9};
10use crate::tui::util::{truncate_to_width, visible_width, wrap_text_with_ansi};
11use crossterm::event::KeyEvent;
12
13/// A setting item that can be toggled or expanded into a submenu.
14pub struct SettingItem {
15    pub id: String,
16    pub label: String,
17    pub description: Option<String>,
18    pub current_value: String,
19    pub values: Option<Vec<String>>,
20    /// Optional submenu: takes current value and a done callback.
21    /// When provided, Enter/Space opens this submenu instead of cycling values.
22    pub submenu: Option<Box<dyn Fn(String, Box<dyn Fn(Option<String>)>) -> Box<dyn Component>>>,
23}
24
25impl SettingItem {
26    pub fn new(
27        id: impl Into<String>,
28        label: impl Into<String>,
29        current_value: impl Into<String>,
30    ) -> Self {
31        Self {
32            id: id.into(),
33            label: label.into(),
34            description: None,
35            current_value: current_value.into(),
36            values: None,
37            submenu: None,
38        }
39    }
40
41    pub fn with_description(mut self, description: impl Into<String>) -> Self {
42        self.description = Some(description.into());
43        self
44    }
45
46    pub fn with_values(mut self, values: Vec<String>) -> Self {
47        self.values = Some(values);
48        self
49    }
50
51    pub fn with_submenu(
52        mut self,
53        submenu: Box<dyn Fn(String, Box<dyn Fn(Option<String>)>) -> Box<dyn Component>>,
54    ) -> Self {
55        self.submenu = Some(submenu);
56        self
57    }
58}
59
60/// Theme for SettingsList.
61pub struct SettingsListTheme {
62    pub selected_prefix: Box<dyn Fn(&str) -> String>,
63    pub selected_label: Box<dyn Fn(&str) -> String>,
64    pub normal_label: Box<dyn Fn(&str) -> String>,
65    pub value_text: Box<dyn Fn(&str) -> String>,
66    pub description: Box<dyn Fn(&str) -> String>,
67    pub scroll_info: Box<dyn Fn(&str) -> String>,
68    pub hint: Box<dyn Fn(&str) -> String>,
69}
70
71impl Default for SettingsListTheme {
72    fn default() -> Self {
73        Self {
74            selected_prefix: Box::new(|s| format!("\x1b[1m> {}\x1b[0m", s)),
75            selected_label: Box::new(|s| format!("\x1b[1m{}\x1b[0m", s)),
76            normal_label: Box::new(|s| format!("  {}", s)),
77            value_text: Box::new(|s| s.to_string()),
78            description: Box::new(|s| format!("  {}", s)),
79            scroll_info: Box::new(|s| s.to_string()),
80            hint: Box::new(|s| s.to_string()),
81        }
82    }
83}
84
85/// Options for SettingsList.
86#[derive(Default)]
87pub struct SettingsListOptions {
88    pub enable_search: bool,
89}
90
91/// Scrollable settings list where items can toggle values or open submenus.
92/// Supports two-column layout (label + value) and submenu components (matching pi).
93pub struct SettingsList {
94    items: Vec<SettingItem>,
95    selected_index: usize,
96    max_visible: usize,
97    scroll_offset: usize,
98    search_input: Input,
99    search_active: bool,
100    enable_search: bool,
101    filtered_indices: Vec<usize>,
102    theme: SettingsListTheme,
103    on_change: Option<Box<dyn FnMut(&str, &str)>>,
104    on_cancel: Option<Box<dyn FnMut()>>,
105    // Submenu state
106    submenu_component: Option<Box<dyn Component>>,
107    submenu_item_index: Option<usize>,
108}
109
110impl SettingsList {
111    pub fn new(
112        items: Vec<SettingItem>,
113        max_visible: usize,
114        theme: SettingsListTheme,
115        on_change: Box<dyn FnMut(&str, &str)>,
116        on_cancel: Box<dyn FnMut()>,
117        options: SettingsListOptions,
118    ) -> Self {
119        let filtered_indices: Vec<usize> = (0..items.len()).collect();
120        Self {
121            items,
122            selected_index: 0,
123            max_visible: max_visible.max(1),
124            scroll_offset: 0,
125            search_input: Input::new().with_prompt("> "),
126            search_active: options.enable_search,
127            enable_search: options.enable_search,
128            filtered_indices,
129            theme,
130            on_change: Some(on_change),
131            on_cancel: Some(on_cancel),
132            submenu_component: None,
133            submenu_item_index: None,
134        }
135    }
136
137    pub fn update_value(&mut self, id: &str, new_value: &str) {
138        for item in &mut self.items {
139            if item.id == id {
140                item.current_value = new_value.to_string();
141                break;
142            }
143        }
144    }
145
146    fn apply_search(&mut self) {
147        let query = self.search_input.get_value();
148        if query.trim().is_empty() {
149            self.filtered_indices = (0..self.items.len()).collect();
150        } else {
151            self.filtered_indices = fuzzy_filter(&self.items, query, |item| &item.label);
152        }
153        self.selected_index = 0;
154        self.scroll_offset = 0;
155    }
156
157    fn move_up(&mut self) {
158        if self.selected_index > 0 {
159            self.selected_index -= 1;
160        }
161        self.adjust_scroll();
162    }
163
164    fn move_down(&mut self) {
165        if self.selected_index + 1 < self.filtered_indices.len() {
166            self.selected_index += 1;
167        }
168        self.adjust_scroll();
169    }
170
171    fn adjust_scroll(&mut self) {
172        if self.selected_index < self.scroll_offset {
173            self.scroll_offset = self.selected_index;
174        } else if self.selected_index >= self.scroll_offset + self.max_visible {
175            self.scroll_offset = self.selected_index - self.max_visible + 1;
176        }
177    }
178
179    fn activate_item(&mut self) {
180        let item_idx = *self.filtered_indices.get(self.selected_index).unwrap_or(&0);
181        let item = &mut self.items[item_idx];
182
183        // Submenu takes priority
184        if let Some(ref submenu_fn) = item.submenu {
185            let current_value = item.current_value.clone();
186            let item_index = self.selected_index;
187
188            // Temporarily take the on_change to move into the closure
189            let mut saved_on_change = None;
190            std::mem::swap(&mut self.on_change, &mut saved_on_change);
191
192            let done_cb: Box<dyn Fn(Option<String>)> =
193                Box::new(move |selected_value: Option<String>| {
194                    if let Some(_val) = selected_value {
195                        // Update the item's value — need a way to reach back
196                        // This is a simplified version; a full implementation would use
197                        // channels or shared state. For now, the submenu caller handles persistence.
198                    }
199                });
200
201            self.submenu_component = Some(submenu_fn(current_value, done_cb));
202            self.submenu_item_index = Some(item_index);
203
204            // Restore on_change
205            std::mem::swap(&mut self.on_change, &mut saved_on_change);
206        } else if let Some(ref values) = item.values.clone()
207            && !values.is_empty()
208        {
209            let current_pos = values
210                .iter()
211                .position(|v| v == &item.current_value)
212                .unwrap_or(0);
213            let next_pos = (current_pos + 1) % values.len();
214            item.current_value = values[next_pos].clone();
215            let id = item.id.clone();
216            let val = item.current_value.clone();
217            if let Some(ref mut cb) = self.on_change {
218                cb(&id, &val);
219            }
220        }
221    }
222
223    fn close_submenu(&mut self) {
224        self.submenu_component = None;
225        if let Some(idx) = self.submenu_item_index {
226            self.selected_index = idx;
227            self.submenu_item_index = None;
228        }
229    }
230
231    fn add_hint_line(&self, lines: &mut Vec<String>, width: usize) {
232        lines.push(String::new());
233        lines.push(truncate_to_width(
234            &(self.theme.hint)(if self.enable_search {
235                "  Type to search · Enter/Space to change · Esc to cancel"
236            } else {
237                "  Enter/Space to change · Esc to cancel"
238            }),
239            width,
240            "",
241            false,
242        ));
243    }
244}
245
246impl Component for SettingsList {
247    fn render(&self, width: usize) -> Vec<String> {
248        // If submenu is active, render it instead
249        if let Some(ref sub) = self.submenu_component {
250            return sub.render(width);
251        }
252
253        let mut lines = Vec::new();
254
255        // Search box
256        if self.enable_search {
257            lines.extend(self.search_input.render(width));
258            lines.push(String::new());
259        }
260
261        if self.filtered_indices.is_empty() {
262            if !self.search_input.get_value().is_empty() {
263                lines.push((self.theme.hint)("No matching settings"));
264            }
265            self.add_hint_line(&mut lines, width);
266            return lines;
267        }
268
269        let end = (self.scroll_offset + self.max_visible).min(self.filtered_indices.len());
270        let visible_slice = &self.filtered_indices[self.scroll_offset..end];
271
272        // Calculate max label width for alignment (pi-style: max 30)
273        let max_label_width = self
274            .filtered_indices
275            .iter()
276            .map(|&i| visible_width(&self.items[i].label))
277            .max()
278            .unwrap_or(0)
279            .min(30);
280
281        for (i, &item_idx) in visible_slice.iter().enumerate() {
282            let actual_idx = self.scroll_offset + i;
283            let is_selected = actual_idx == self.selected_index;
284            let item = &self.items[item_idx];
285
286            let prefix = if is_selected {
287                (self.theme.selected_prefix)("")
288            } else {
289                "  ".to_string()
290            };
291            let prefix_width = visible_width(&prefix);
292
293            // Pad label for alignment
294            let label_padded = format!(
295                "{}{}",
296                item.label,
297                " ".repeat(max_label_width.saturating_sub(visible_width(&item.label)))
298            );
299            let label = if is_selected {
300                (self.theme.selected_label)(&label_padded)
301            } else {
302                (self.theme.normal_label)(&label_padded)
303            };
304
305            // Value with remaining space
306            let separator = "  ";
307            let used = prefix_width + max_label_width + visible_width(separator);
308            let value_max = width.saturating_sub(used + 2);
309            let value = (self.theme.value_text)(&truncate_to_width(
310                &item.current_value,
311                value_max,
312                "",
313                false,
314            ));
315
316            let line = format!("{}{}{}{}", prefix, label, separator, value);
317            lines.push(truncate_to_width(&line, width, "", false));
318        }
319
320        // Scroll indicator
321        if self.filtered_indices.len() > self.max_visible {
322            let indicator = format!(
323                "({}/{})",
324                self.selected_index + 1,
325                self.filtered_indices.len()
326            );
327            lines.push((self.theme.scroll_info)(&indicator));
328        }
329
330        // Description of selected item
331        if let Some(item_idx) = self.filtered_indices.get(self.selected_index).copied()
332            && let Some(ref desc) = self.items[item_idx].description
333        {
334            lines.push(String::new());
335            for desc_line in wrap_text_with_ansi(desc, width.saturating_sub(2)) {
336                lines.push((self.theme.description)(&desc_line));
337            }
338        }
339
340        self.add_hint_line(&mut lines, width);
341        lines
342    }
343
344    fn handle_input(&mut self, key: &KeyEvent) -> bool {
345        // If submenu is active, delegate all input to it
346        if let Some(ref mut sub) = self.submenu_component {
347            let consumed = sub.handle_input(key);
348            if consumed {
349                return true;
350            }
351            // Submenu didn't consume — it may have closed via done()
352            self.close_submenu();
353            return true;
354        }
355
356        let kb = get_keybindings();
357
358        // Search input handling
359        if self.search_active {
360            if kb.matches(key, ACTION_SELECT_DOWN) || kb.matches(key, ACTION_SELECT_UP) {
361                self.search_active = false;
362                return self.handle_input(key);
363            }
364            self.search_input.handle_input(key);
365            self.apply_search();
366            return true;
367        }
368
369        if kb.matches(key, ACTION_SELECT_UP) {
370            self.move_up();
371            return true;
372        }
373
374        if kb.matches(key, ACTION_SELECT_DOWN) {
375            self.move_down();
376            return true;
377        }
378
379        if kb.matches(key, ACTION_SELECT_CONFIRM)
380            || matches!(key.code, crossterm::event::KeyCode::Char(' '))
381        {
382            self.activate_item();
383            return true;
384        }
385
386        if kb.matches(key, ACTION_SELECT_CANCEL) {
387            if let Some(ref mut cb) = self.on_cancel {
388                cb();
389            }
390            return true;
391        }
392
393        // If search is enabled, any printable char activates search
394        if self.enable_search
395            && let crossterm::event::KeyCode::Char(_) = key.code
396            && !key
397                .modifiers
398                .contains(crossterm::event::KeyModifiers::CONTROL)
399            && !key.modifiers.contains(crossterm::event::KeyModifiers::ALT)
400        {
401            self.search_active = true;
402            self.search_input.handle_input(key);
403            self.apply_search();
404            return true;
405        }
406
407        false
408    }
409
410    fn invalidate(&mut self) {
411        self.search_input.invalidate();
412        if let Some(ref mut sub) = self.submenu_component {
413            sub.invalidate();
414        }
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    fn make_items() -> Vec<SettingItem> {
423        vec![
424            SettingItem::new("verbose", "Verbose mode", "off")
425                .with_values(vec!["on".to_string(), "off".to_string()])
426                .with_description("Enable verbose logging"),
427            SettingItem::new("color", "Color output", "on")
428                .with_values(vec!["on".to_string(), "off".to_string()]),
429        ]
430    }
431
432    #[test]
433    fn test_cycle_value() {
434        let mut list = SettingsList::new(
435            make_items(),
436            10,
437            SettingsListTheme::default(),
438            Box::new(|_, _| {}),
439            Box::new(|| {}),
440            SettingsListOptions::default(),
441        );
442
443        let item = &list.items[0];
444        assert_eq!(item.current_value, "off");
445
446        list.activate_item();
447
448        let item = &list.items[0];
449        assert_eq!(item.current_value, "on");
450    }
451
452    #[test]
453    fn test_render() {
454        let list = SettingsList::new(
455            make_items(),
456            10,
457            SettingsListTheme::default(),
458            Box::new(|_, _| {}),
459            Box::new(|| {}),
460            SettingsListOptions::default(),
461        );
462        let lines = list.render(60);
463        assert!(lines.len() >= 2);
464    }
465
466    #[test]
467    fn test_hint_line_shown() {
468        let list = SettingsList::new(
469            make_items(),
470            10,
471            SettingsListTheme::default(),
472            Box::new(|_, _| {}),
473            Box::new(|| {}),
474            SettingsListOptions::default(),
475        );
476        let lines = list.render(60);
477        let has_hint = lines.iter().any(|l| l.contains("Esc to cancel"));
478        assert!(has_hint, "Hint should be visible");
479    }
480}