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