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
13pub 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 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
60pub 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#[derive(Default)]
87pub struct SettingsListOptions {
88 pub enable_search: bool,
89}
90
91pub 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_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 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 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 }
199 });
200
201 self.submenu_component = Some(submenu_fn(current_value, done_cb));
202 self.submenu_item_index = Some(item_index);
203
204 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 let Some(ref sub) = self.submenu_component {
250 return sub.render(width);
251 }
252
253 let mut lines = Vec::new();
254
255 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 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 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 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 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 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 let Some(ref mut sub) = self.submenu_component {
347 let consumed = sub.handle_input(key);
348 if consumed {
349 return true;
350 }
351 self.close_submenu();
353 return true;
354 }
355
356 let kb = get_keybindings();
357
358 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 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}