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