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}
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 background
271    pub focused: Color,
272    /// Focused item foreground (text on focused background)
273    pub focused_fg: Color,
274    /// Cursor color
275    pub cursor: Color,
276    /// Disabled color
277    pub disabled: Color,
278}
279
280impl Default for TextListColors {
281    fn default() -> Self {
282        Self {
283            label: Color::White,
284            text: Color::White,
285            border: Color::Gray,
286            remove_button: Color::Red,
287            add_button: Color::Green,
288            focused: Color::Cyan,
289            focused_fg: Color::Black,
290            cursor: Color::Yellow,
291            disabled: Color::DarkGray,
292        }
293    }
294}
295
296impl TextListColors {
297    /// Create colors from theme
298    pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
299        Self {
300            label: theme.editor_fg,
301            text: theme.editor_fg,
302            border: theme.line_number_fg,
303            remove_button: theme.diagnostic_error_fg,
304            add_button: theme.diagnostic_info_fg,
305            focused: theme.settings_selected_bg,
306            focused_fg: theme.settings_selected_fg,
307            cursor: theme.cursor,
308            disabled: theme.line_number_fg,
309        }
310    }
311}
312
313/// Hit area for a text list row
314#[derive(Debug, Clone, Copy)]
315pub struct TextListRowLayout {
316    /// The text field area
317    pub text_area: Rect,
318    /// The button area (remove or add)
319    pub button_area: Rect,
320    /// Index of this row (None for add-new row)
321    pub index: Option<usize>,
322}
323
324/// Layout information returned after rendering for hit testing
325#[derive(Debug, Clone, Default)]
326pub struct TextListLayout {
327    /// Layout for each row
328    pub rows: Vec<TextListRowLayout>,
329    /// The full control area
330    pub full_area: Rect,
331}
332
333impl TextListLayout {
334    /// Find which row and component was clicked
335    pub fn hit_test(&self, x: u16, y: u16) -> Option<TextListHit> {
336        for row in &self.rows {
337            if y >= row.text_area.y
338                && y < row.text_area.y + row.text_area.height
339                && x >= row.button_area.x
340                && x < row.button_area.x + row.button_area.width
341            {
342                return Some(TextListHit::Button(row.index));
343            }
344            if y >= row.text_area.y
345                && y < row.text_area.y + row.text_area.height
346                && x >= row.text_area.x
347                && x < row.text_area.x + row.text_area.width
348            {
349                return Some(TextListHit::TextField(row.index));
350            }
351        }
352        None
353    }
354}
355
356/// Result of hit testing on a text list
357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
358pub enum TextListHit {
359    /// Clicked on a text field (None = add-new field)
360    TextField(Option<usize>),
361    /// Clicked on a button (None = add button, Some = remove button)
362    Button(Option<usize>),
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use ratatui::backend::TestBackend;
369    use ratatui::Terminal;
370
371    fn test_frame<F>(width: u16, height: u16, f: F)
372    where
373        F: FnOnce(&mut ratatui::Frame, Rect),
374    {
375        let backend = TestBackend::new(width, height);
376        let mut terminal = Terminal::new(backend).unwrap();
377        terminal
378            .draw(|frame| {
379                let area = Rect::new(0, 0, width, height);
380                f(frame, area);
381            })
382            .unwrap();
383    }
384
385    #[test]
386    fn test_text_list_empty() {
387        test_frame(40, 5, |frame, area| {
388            let state = TextListState::new("Items");
389            let colors = TextListColors::default();
390            let layout = render_text_list(frame, area, &state, &colors, 20);
391
392            assert_eq!(layout.rows.len(), 1);
393            assert!(layout.rows[0].index.is_none());
394        });
395    }
396
397    #[test]
398    fn test_text_list_with_items() {
399        test_frame(40, 5, |frame, area| {
400            let state =
401                TextListState::new("Items").with_items(vec!["one".to_string(), "two".to_string()]);
402            let colors = TextListColors::default();
403            let layout = render_text_list(frame, area, &state, &colors, 20);
404
405            assert_eq!(layout.rows.len(), 3);
406            assert_eq!(layout.rows[0].index, Some(0));
407            assert_eq!(layout.rows[1].index, Some(1));
408            assert!(layout.rows[2].index.is_none());
409        });
410    }
411
412    #[test]
413    fn test_text_list_add_item() {
414        let mut state = TextListState::new("Items");
415        state.new_item_text = "new item".to_string();
416        state.add_item();
417
418        assert_eq!(state.items.len(), 1);
419        assert_eq!(state.items[0], "new item");
420        assert!(state.new_item_text.is_empty());
421    }
422
423    #[test]
424    fn test_text_list_remove_item() {
425        let mut state =
426            TextListState::new("Items").with_items(vec!["a".to_string(), "b".to_string()]);
427        state.remove_item(0);
428
429        assert_eq!(state.items.len(), 1);
430        assert_eq!(state.items[0], "b");
431    }
432
433    #[test]
434    fn test_text_list_edit_item() {
435        let mut state = TextListState::new("Items").with_items(vec!["hello".to_string()]);
436        state.focus_item(0);
437        state.insert('!');
438
439        assert_eq!(state.items[0], "hello!");
440    }
441
442    #[test]
443    fn test_text_list_navigation() {
444        let mut state = TextListState::new("Items")
445            .with_items(vec!["a".to_string(), "b".to_string()])
446            .with_focus(FocusState::Focused);
447
448        assert!(state.focused_item.is_none());
449
450        state.focus_prev();
451        assert_eq!(state.focused_item, Some(1));
452
453        state.focus_prev();
454        assert_eq!(state.focused_item, Some(0));
455
456        state.focus_prev();
457        assert_eq!(state.focused_item, Some(0));
458
459        state.focus_next();
460        assert_eq!(state.focused_item, Some(1));
461
462        state.focus_next();
463        assert!(state.focused_item.is_none());
464    }
465
466    #[test]
467    fn test_text_list_hit_test() {
468        test_frame(40, 5, |frame, area| {
469            let state = TextListState::new("Items").with_items(vec!["one".to_string()]);
470            let colors = TextListColors::default();
471            let layout = render_text_list(frame, area, &state, &colors, 20);
472
473            let btn = &layout.rows[0].button_area;
474            let hit = layout.hit_test(btn.x, btn.y);
475            assert_eq!(hit, Some(TextListHit::Button(Some(0))));
476
477            let add_btn = &layout.rows[1].button_area;
478            let hit = layout.hit_test(add_btn.x, add_btn.y);
479            assert_eq!(hit, Some(TextListHit::Button(None)));
480        });
481    }
482}