Skip to main content

fresh/view/controls/text_list/
mod.rs

1//! Text list control for managing lists of strings
2//!
3//! Renders as a list with add/remove buttons:
4//! ```text
5//! Label:
6//!   [item one                ] [x]
7//!   [item two                ] [x]
8//!   [                        ] [+]
9//! ```
10//!
11//! This module provides a complete text list component with:
12//! - State management (`TextListState`)
13//! - Rendering (`render_text_list`)
14//! - Input handling (`TextListState::handle_mouse`, `handle_key`)
15//! - Layout/hit testing (`TextListLayout`)
16
17mod input;
18mod render;
19
20use ratatui::layout::Rect;
21use ratatui::style::Color;
22
23pub use input::TextListEvent;
24pub use render::render_text_list;
25
26use super::FocusState;
27
28/// State for a text list control
29#[derive(Debug, Clone)]
30pub struct TextListState {
31    /// List of items
32    pub items: Vec<String>,
33    /// Currently focused item index (None = add new item field)
34    pub focused_item: Option<usize>,
35    /// Cursor position within the focused item
36    pub cursor: usize,
37    /// Text in the "add new" field
38    pub new_item_text: String,
39    /// Label displayed above the list
40    pub label: String,
41    /// Focus state
42    pub focus: FocusState,
43    /// Whether items should be treated as integers (for IntegerArray settings)
44    pub is_integer: bool,
45}
46
47impl TextListState {
48    /// Create a new text list state
49    pub fn new(label: impl Into<String>) -> Self {
50        Self {
51            items: Vec::new(),
52            focused_item: None,
53            cursor: 0,
54            new_item_text: String::new(),
55            label: label.into(),
56            focus: FocusState::Normal,
57            is_integer: false,
58        }
59    }
60
61    /// Set the initial items
62    pub fn with_items(mut self, items: Vec<String>) -> Self {
63        self.items = items;
64        self
65    }
66
67    /// Mark this list as containing integer values
68    pub fn with_integer_mode(mut self) -> Self {
69        self.is_integer = true;
70        self
71    }
72
73    /// Set the focus state
74    pub fn with_focus(mut self, focus: FocusState) -> Self {
75        self.focus = focus;
76        self
77    }
78
79    /// Check if the control is enabled
80    pub fn is_enabled(&self) -> bool {
81        self.focus != FocusState::Disabled
82    }
83
84    /// Add a new item from the new_item_text field
85    pub fn add_item(&mut self) {
86        if !self.is_enabled() || self.new_item_text.is_empty() {
87            return;
88        }
89        self.items.push(std::mem::take(&mut self.new_item_text));
90        self.cursor = 0;
91    }
92
93    /// Insert a string at the cursor position
94    pub fn insert_str(&mut self, s: &str) {
95        if !self.is_enabled() {
96            return;
97        }
98        if let Some(index) = self.focused_item {
99            if let Some(item) = self.items.get_mut(index) {
100                if self.cursor <= item.len() {
101                    item.insert_str(self.cursor, s);
102                    self.cursor += s.len();
103                }
104            }
105        } else if self.cursor <= self.new_item_text.len() {
106            self.new_item_text.insert_str(self.cursor, s);
107            self.cursor += s.len();
108        }
109    }
110
111    /// Remove an item by index
112    pub fn remove_item(&mut self, index: usize) {
113        if !self.is_enabled() || index >= self.items.len() {
114            return;
115        }
116        self.items.remove(index);
117        if let Some(focused) = self.focused_item {
118            if focused >= self.items.len() {
119                self.focused_item = if self.items.is_empty() {
120                    None
121                } else {
122                    Some(self.items.len() - 1)
123                };
124            }
125        }
126    }
127
128    /// Focus on an item for editing
129    pub fn focus_item(&mut self, index: usize) {
130        if index < self.items.len() {
131            self.focused_item = Some(index);
132            self.cursor = self.items[index].len();
133        }
134    }
135
136    /// Focus on the new item field
137    pub fn focus_new_item(&mut self) {
138        self.focused_item = None;
139        self.cursor = self.new_item_text.len();
140    }
141
142    /// Insert a character in the focused field
143    pub fn insert(&mut self, c: char) {
144        if !self.is_enabled() {
145            return;
146        }
147        match self.focused_item {
148            Some(idx) if idx < self.items.len() => {
149                self.items[idx].insert(self.cursor, c);
150                self.cursor += 1;
151            }
152            None => {
153                self.new_item_text.insert(self.cursor, c);
154                self.cursor += 1;
155            }
156            _ => {}
157        }
158    }
159
160    /// Backspace in the focused field
161    pub fn backspace(&mut self) {
162        if !self.is_enabled() || self.cursor == 0 {
163            return;
164        }
165        self.cursor -= 1;
166        match self.focused_item {
167            Some(idx) if idx < self.items.len() => {
168                self.items[idx].remove(self.cursor);
169            }
170            None => {
171                self.new_item_text.remove(self.cursor);
172            }
173            _ => {}
174        }
175    }
176
177    /// Move cursor left
178    pub fn move_left(&mut self) {
179        if self.cursor > 0 {
180            self.cursor -= 1;
181        }
182    }
183
184    /// Move cursor right
185    pub fn move_right(&mut self) {
186        let max = match self.focused_item {
187            Some(idx) if idx < self.items.len() => self.items[idx].len(),
188            None => self.new_item_text.len(),
189            _ => 0,
190        };
191        if self.cursor < max {
192            self.cursor += 1;
193        }
194    }
195
196    /// Move cursor to beginning of current field
197    pub fn move_home(&mut self) {
198        self.cursor = 0;
199    }
200
201    /// Move cursor to end of current field
202    pub fn move_end(&mut self) {
203        self.cursor = match self.focused_item {
204            Some(idx) if idx < self.items.len() => self.items[idx].len(),
205            None => self.new_item_text.len(),
206            _ => 0,
207        };
208    }
209
210    /// Delete character at cursor (forward delete)
211    pub fn delete(&mut self) {
212        if !self.is_enabled() {
213            return;
214        }
215        let max = match self.focused_item {
216            Some(idx) if idx < self.items.len() => self.items[idx].len(),
217            None => self.new_item_text.len(),
218            _ => return,
219        };
220        if self.cursor >= max {
221            return;
222        }
223        match self.focused_item {
224            Some(idx) if idx < self.items.len() => {
225                self.items[idx].remove(self.cursor);
226            }
227            None => {
228                self.new_item_text.remove(self.cursor);
229            }
230            _ => {}
231        }
232    }
233
234    /// Move focus to previous item
235    pub fn focus_prev(&mut self) {
236        match self.focused_item {
237            Some(0) => {}
238            Some(idx) => {
239                self.focused_item = Some(idx - 1);
240                self.cursor = self.items[idx - 1].len();
241            }
242            None if !self.items.is_empty() => {
243                self.focused_item = Some(self.items.len() - 1);
244                self.cursor = self.items.last().map(|s| s.len()).unwrap_or(0);
245            }
246            None => {}
247        }
248    }
249
250    /// Move focus to next item
251    pub fn focus_next(&mut self) {
252        match self.focused_item {
253            Some(idx) if idx + 1 < self.items.len() => {
254                self.focused_item = Some(idx + 1);
255                self.cursor = self.items[idx + 1].len();
256            }
257            Some(_) => {
258                self.focused_item = None;
259                self.cursor = self.new_item_text.len();
260            }
261            None => {}
262        }
263    }
264}
265
266/// Colors for the text list control
267#[derive(Debug, Clone, Copy)]
268pub struct TextListColors {
269    /// Label color
270    pub label: Color,
271    /// Item text color
272    pub text: Color,
273    /// Border/bracket color
274    pub border: Color,
275    /// Remove button color
276    pub remove_button: Color,
277    /// Add button color
278    pub add_button: Color,
279    /// Focused item highlight background
280    pub focused: Color,
281    /// Focused item foreground (text on focused background)
282    pub focused_fg: Color,
283    /// Cursor color
284    pub cursor: Color,
285    /// Disabled color
286    pub disabled: Color,
287}
288
289impl Default for TextListColors {
290    fn default() -> Self {
291        Self {
292            label: Color::White,
293            text: Color::White,
294            border: Color::Gray,
295            remove_button: Color::Red,
296            add_button: Color::Green,
297            focused: Color::Cyan,
298            focused_fg: Color::Black,
299            cursor: Color::Yellow,
300            disabled: Color::DarkGray,
301        }
302    }
303}
304
305impl TextListColors {
306    /// Create colors from theme
307    pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
308        Self {
309            label: theme.editor_fg,
310            text: theme.editor_fg,
311            border: theme.line_number_fg,
312            remove_button: theme.diagnostic_error_fg,
313            add_button: theme.diagnostic_info_fg,
314            focused: theme.settings_selected_bg,
315            focused_fg: theme.settings_selected_fg,
316            cursor: theme.cursor,
317            disabled: theme.line_number_fg,
318        }
319    }
320}
321
322/// Hit area for a text list row
323#[derive(Debug, Clone, Copy)]
324pub struct TextListRowLayout {
325    /// The text field area
326    pub text_area: Rect,
327    /// The button area (remove or add)
328    pub button_area: Rect,
329    /// Index of this row (None for add-new row)
330    pub index: Option<usize>,
331}
332
333/// Layout information returned after rendering for hit testing
334#[derive(Debug, Clone, Default)]
335pub struct TextListLayout {
336    /// Layout for each row
337    pub rows: Vec<TextListRowLayout>,
338    /// The full control area
339    pub full_area: Rect,
340}
341
342impl TextListLayout {
343    /// Find which row and component was clicked
344    pub fn hit_test(&self, x: u16, y: u16) -> Option<TextListHit> {
345        for row in &self.rows {
346            if y >= row.text_area.y
347                && y < row.text_area.y + row.text_area.height
348                && x >= row.button_area.x
349                && x < row.button_area.x + row.button_area.width
350            {
351                return Some(TextListHit::Button(row.index));
352            }
353            if y >= row.text_area.y
354                && y < row.text_area.y + row.text_area.height
355                && x >= row.text_area.x
356                && x < row.text_area.x + row.text_area.width
357            {
358                return Some(TextListHit::TextField(row.index));
359            }
360        }
361        None
362    }
363}
364
365/// Result of hit testing on a text list
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367pub enum TextListHit {
368    /// Clicked on a text field (None = add-new field)
369    TextField(Option<usize>),
370    /// Clicked on a button (None = add button, Some = remove button)
371    Button(Option<usize>),
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use ratatui::backend::TestBackend;
378    use ratatui::Terminal;
379
380    fn test_frame<F>(width: u16, height: u16, f: F)
381    where
382        F: FnOnce(&mut ratatui::Frame, Rect),
383    {
384        let backend = TestBackend::new(width, height);
385        let mut terminal = Terminal::new(backend).unwrap();
386        terminal
387            .draw(|frame| {
388                let area = Rect::new(0, 0, width, height);
389                f(frame, area);
390            })
391            .unwrap();
392    }
393
394    #[test]
395    fn test_text_list_empty() {
396        test_frame(40, 5, |frame, area| {
397            let state = TextListState::new("Items");
398            let colors = TextListColors::default();
399            let layout = render_text_list(frame, area, &state, &colors, 20);
400
401            assert_eq!(layout.rows.len(), 1);
402            assert!(layout.rows[0].index.is_none());
403        });
404    }
405
406    #[test]
407    fn test_text_list_with_items() {
408        test_frame(40, 5, |frame, area| {
409            let state =
410                TextListState::new("Items").with_items(vec!["one".to_string(), "two".to_string()]);
411            let colors = TextListColors::default();
412            let layout = render_text_list(frame, area, &state, &colors, 20);
413
414            assert_eq!(layout.rows.len(), 3);
415            assert_eq!(layout.rows[0].index, Some(0));
416            assert_eq!(layout.rows[1].index, Some(1));
417            assert!(layout.rows[2].index.is_none());
418        });
419    }
420
421    #[test]
422    fn test_text_list_add_item() {
423        let mut state = TextListState::new("Items");
424        state.new_item_text = "new item".to_string();
425        state.add_item();
426
427        assert_eq!(state.items.len(), 1);
428        assert_eq!(state.items[0], "new item");
429        assert!(state.new_item_text.is_empty());
430    }
431
432    #[test]
433    fn test_text_list_remove_item() {
434        let mut state =
435            TextListState::new("Items").with_items(vec!["a".to_string(), "b".to_string()]);
436        state.remove_item(0);
437
438        assert_eq!(state.items.len(), 1);
439        assert_eq!(state.items[0], "b");
440    }
441
442    #[test]
443    fn test_text_list_edit_item() {
444        let mut state = TextListState::new("Items").with_items(vec!["hello".to_string()]);
445        state.focus_item(0);
446        state.insert('!');
447
448        assert_eq!(state.items[0], "hello!");
449    }
450
451    #[test]
452    fn test_text_list_navigation() {
453        let mut state = TextListState::new("Items")
454            .with_items(vec!["a".to_string(), "b".to_string()])
455            .with_focus(FocusState::Focused);
456
457        assert!(state.focused_item.is_none());
458
459        state.focus_prev();
460        assert_eq!(state.focused_item, Some(1));
461
462        state.focus_prev();
463        assert_eq!(state.focused_item, Some(0));
464
465        state.focus_prev();
466        assert_eq!(state.focused_item, Some(0));
467
468        state.focus_next();
469        assert_eq!(state.focused_item, Some(1));
470
471        state.focus_next();
472        assert!(state.focused_item.is_none());
473    }
474
475    #[test]
476    fn test_text_list_hit_test() {
477        test_frame(40, 5, |frame, area| {
478            let state = TextListState::new("Items").with_items(vec!["one".to_string()]);
479            let colors = TextListColors::default();
480            let layout = render_text_list(frame, area, &state, &colors, 20);
481
482            let btn = &layout.rows[0].button_area;
483            let hit = layout.hit_test(btn.x, btn.y);
484            assert_eq!(hit, Some(TextListHit::Button(Some(0))));
485
486            let add_btn = &layout.rows[1].button_area;
487            let hit = layout.hit_test(add_btn.x, add_btn.y);
488            assert_eq!(hit, Some(TextListHit::Button(None)));
489        });
490    }
491}