Skip to main content

fresh/view/controls/text_input/
mod.rs

1//! Single-line text input control
2//!
3//! Renders as: `Label: [text content     ]`
4//!
5//! This module provides a complete text input component with:
6//! - State management (`TextInputState`)
7//! - Rendering (`render_text_input`, `render_text_input_aligned`)
8//! - Input handling (`TextInputState::handle_mouse`, `handle_key`)
9//! - Layout/hit testing (`TextInputLayout`)
10
11mod input;
12mod render;
13
14use crate::primitives::grapheme;
15use ratatui::layout::Rect;
16use ratatui::style::Color;
17
18pub use input::TextInputEvent;
19pub use render::{render_text_input, render_text_input_aligned};
20
21use super::FocusState;
22
23/// State for a text input control
24#[derive(Debug, Clone)]
25pub struct TextInputState {
26    /// Current text value
27    pub value: String,
28    /// Cursor position (character index)
29    pub cursor: usize,
30    /// Label displayed before the input
31    pub label: String,
32    /// Placeholder text when empty
33    pub placeholder: String,
34    /// Focus state
35    pub focus: FocusState,
36    /// If true, the user is actively editing (Enter was pressed). When
37    /// the control is merely selected/highlighted via navigation this
38    /// stays `false`, which suppresses the cursor block so the caret
39    /// only appears once the user asks to type.
40    pub editing: bool,
41    /// If true, validate that value is valid JSON before allowing exit
42    pub validate_json: bool,
43    /// "Select-all" affordance: when the input gains focus the whole
44    /// value is conceptually selected, so the next printable keystroke
45    /// replaces it (matching the spinner UX from `NumberInputState`).
46    /// Any cursor movement, deletion, or explicit `insert` cancels the
47    /// flag and the input behaves normally from then on.
48    pub pending_replace_on_type: bool,
49}
50
51impl TextInputState {
52    /// Create a new text input state
53    pub fn new(label: impl Into<String>) -> Self {
54        Self {
55            value: String::new(),
56            cursor: 0,
57            label: label.into(),
58            placeholder: String::new(),
59            focus: FocusState::Normal,
60            editing: false,
61            validate_json: false,
62            pending_replace_on_type: false,
63        }
64    }
65
66    /// Arm the "next-keystroke-replaces-value" affordance. Call when
67    /// the input first gains focus from a normal/hovered state.
68    pub fn arm_replace_on_type(&mut self) {
69        self.pending_replace_on_type = !self.value.is_empty();
70    }
71
72    /// Set JSON validation mode
73    pub fn with_json_validation(mut self) -> Self {
74        self.validate_json = true;
75        self
76    }
77
78    /// Check if the current value is valid (valid JSON if validate_json is set)
79    pub fn is_valid(&self) -> bool {
80        if self.validate_json {
81            serde_json::from_str::<serde_json::Value>(&self.value).is_ok()
82        } else {
83            true
84        }
85    }
86
87    /// Set the initial value
88    pub fn with_value(mut self, value: impl Into<String>) -> Self {
89        self.value = value.into();
90        self.cursor = self.value.len();
91        self
92    }
93
94    /// Set the placeholder text
95    pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
96        self.placeholder = placeholder.into();
97        self
98    }
99
100    /// Set the focus state
101    pub fn with_focus(mut self, focus: FocusState) -> Self {
102        self.focus = focus;
103        self
104    }
105
106    /// Check if the control is enabled
107    pub fn is_enabled(&self) -> bool {
108        self.focus != FocusState::Disabled
109    }
110
111    /// Insert a character at the cursor position
112    pub fn insert(&mut self, c: char) {
113        if !self.is_enabled() {
114            return;
115        }
116        self.consume_pending_replace();
117        self.value.insert(self.cursor, c);
118        self.cursor += c.len_utf8();
119    }
120
121    /// Insert a string at the cursor position
122    pub fn insert_str(&mut self, s: &str) {
123        if !self.is_enabled() {
124            return;
125        }
126        self.consume_pending_replace();
127        self.value.insert_str(self.cursor, s);
128        self.cursor += s.len();
129    }
130
131    /// Delete the character before the cursor (backspace)
132    pub fn backspace(&mut self) {
133        if !self.is_enabled() || self.cursor == 0 {
134            return;
135        }
136        if self.consume_pending_replace() {
137            // The "selected" value is cleared; nothing left to backspace.
138            return;
139        }
140        // Find the previous character boundary
141        let prev_boundary = self.value[..self.cursor]
142            .char_indices()
143            .next_back()
144            .map(|(i, _)| i)
145            .unwrap_or(0);
146        self.value.remove(prev_boundary);
147        self.cursor = prev_boundary;
148    }
149
150    /// If a pending replace-on-type is armed, clear the value and the
151    /// flag. Returns whether the pending state was consumed.
152    fn consume_pending_replace(&mut self) -> bool {
153        if self.pending_replace_on_type {
154            self.value.clear();
155            self.cursor = 0;
156            self.pending_replace_on_type = false;
157            true
158        } else {
159            false
160        }
161    }
162
163    /// Delete the grapheme cluster at the cursor (delete key)
164    ///
165    /// Deletes the entire grapheme cluster, handling combining characters properly.
166    pub fn delete(&mut self) {
167        if !self.is_enabled() || self.cursor >= self.value.len() {
168            return;
169        }
170        if self.consume_pending_replace() {
171            return;
172        }
173        let next_boundary = grapheme::next_grapheme_boundary(&self.value, self.cursor);
174        self.value.drain(self.cursor..next_boundary);
175    }
176
177    /// Move cursor left (to previous grapheme cluster boundary)
178    ///
179    /// Uses grapheme cluster boundaries for proper handling of combining characters
180    /// like Thai diacritics, emoji with modifiers, etc.
181    pub fn move_left(&mut self) {
182        self.pending_replace_on_type = false;
183        if self.cursor > 0 {
184            self.cursor = grapheme::prev_grapheme_boundary(&self.value, self.cursor);
185        }
186    }
187
188    /// Move cursor right (to next grapheme cluster boundary)
189    ///
190    /// Uses grapheme cluster boundaries for proper handling of combining characters
191    /// like Thai diacritics, emoji with modifiers, etc.
192    pub fn move_right(&mut self) {
193        self.pending_replace_on_type = false;
194        if self.cursor < self.value.len() {
195            self.cursor = grapheme::next_grapheme_boundary(&self.value, self.cursor);
196        }
197    }
198
199    /// Move cursor to start
200    pub fn move_home(&mut self) {
201        self.pending_replace_on_type = false;
202        self.cursor = 0;
203    }
204
205    /// Move cursor to end
206    pub fn move_end(&mut self) {
207        self.pending_replace_on_type = false;
208        self.cursor = self.value.len();
209    }
210
211    /// Clear the input
212    pub fn clear(&mut self) {
213        if self.is_enabled() {
214            self.value.clear();
215            self.cursor = 0;
216        }
217    }
218
219    /// Set the value directly
220    pub fn set_value(&mut self, value: impl Into<String>) {
221        if self.is_enabled() {
222            self.value = value.into();
223            self.cursor = self.value.len();
224        }
225    }
226}
227
228/// Colors for the text input control
229#[derive(Debug, Clone, Copy)]
230pub struct TextInputColors {
231    /// Label color
232    pub label: Color,
233    /// Input text color
234    pub text: Color,
235    /// Border/bracket color
236    pub border: Color,
237    /// Placeholder text color
238    pub placeholder: Color,
239    /// Cursor color
240    pub cursor: Color,
241    /// Focused highlight color
242    pub focused: Color,
243    /// Disabled color
244    pub disabled: Color,
245    /// Background colour used when the field is actively being edited
246    /// (state.focus == Focused && state.editing). Gives the user a
247    /// clear "keystrokes go here" signal.
248    pub editing_bg: Color,
249}
250
251impl Default for TextInputColors {
252    fn default() -> Self {
253        Self {
254            label: Color::White,
255            text: Color::White,
256            border: Color::Gray,
257            placeholder: Color::DarkGray,
258            cursor: Color::Yellow,
259            focused: Color::Cyan,
260            disabled: Color::DarkGray,
261            editing_bg: Color::DarkGray,
262        }
263    }
264}
265
266impl TextInputColors {
267    /// Create colors from theme
268    pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
269        Self {
270            label: theme.editor_fg,
271            text: theme.editor_fg,
272            border: theme.line_number_fg,
273            placeholder: theme.line_number_fg,
274            cursor: theme.cursor,
275            // Use a fg-family colour for the focused/editing accent so
276            // the label and bracket highlighting remain readable against
277            // dark row backgrounds. `selection_bg` is a background colour
278            // and renders as dark-on-dark on high-contrast themes.
279            focused: theme.settings_selected_fg,
280            disabled: theme.line_number_fg,
281            // Reuse the popup-selection bg (`ui.popup_selection_bg`) —
282            // the same key the plugin widget framework's Toggle /
283            // Button use for focused chrome. Guaranteed to contrast
284            // with popup_bg across all bundled themes.
285            editing_bg: theme.popup_selection_bg,
286        }
287    }
288
289    /// Create dimmed colors for read-only/inherited text inputs.
290    /// Shows brackets but with muted styling to indicate the field exists
291    /// but is not currently editable.
292    pub fn from_theme_disabled(theme: &crate::view::theme::Theme) -> Self {
293        Self {
294            label: theme.editor_fg,
295            text: theme.line_number_fg,
296            border: theme.line_number_fg,
297            placeholder: theme.line_number_fg,
298            cursor: theme.cursor,
299            focused: theme.settings_selected_fg,
300            disabled: theme.line_number_fg,
301            editing_bg: theme.popup_selection_bg,
302        }
303    }
304}
305
306/// Layout information returned after rendering for hit testing
307#[derive(Debug, Clone, Copy, Default)]
308pub struct TextInputLayout {
309    /// The text input field area
310    pub input_area: Rect,
311    /// The full control area including label
312    pub full_area: Rect,
313    /// Cursor position in screen coordinates (if focused)
314    pub cursor_pos: Option<(u16, u16)>,
315}
316
317impl TextInputLayout {
318    /// Check if a point is within the input area
319    pub fn is_input(&self, x: u16, y: u16) -> bool {
320        x >= self.input_area.x
321            && x < self.input_area.x + self.input_area.width
322            && y >= self.input_area.y
323            && y < self.input_area.y + self.input_area.height
324    }
325
326    /// Check if a point is within the full control area
327    pub fn contains(&self, x: u16, y: u16) -> bool {
328        x >= self.full_area.x
329            && x < self.full_area.x + self.full_area.width
330            && y >= self.full_area.y
331            && y < self.full_area.y + self.full_area.height
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use ratatui::backend::TestBackend;
339    use ratatui::Terminal;
340
341    fn test_frame<F>(width: u16, height: u16, f: F)
342    where
343        F: FnOnce(&mut ratatui::Frame, Rect),
344    {
345        let backend = TestBackend::new(width, height);
346        let mut terminal = Terminal::new(backend).unwrap();
347        terminal
348            .draw(|frame| {
349                let area = Rect::new(0, 0, width, height);
350                f(frame, area);
351            })
352            .unwrap();
353    }
354
355    #[test]
356    fn test_arm_replace_on_type_replaces_value_on_first_char() {
357        let mut state = TextInputState::new("Width").with_value("30%");
358        state.arm_replace_on_type();
359        assert!(state.pending_replace_on_type);
360        state.insert('2');
361        assert_eq!(state.value, "2");
362        assert!(!state.pending_replace_on_type);
363        state.insert('4');
364        assert_eq!(state.value, "24");
365    }
366
367    #[test]
368    fn test_arm_replace_on_type_is_cancelled_by_cursor_movement() {
369        let mut state = TextInputState::new("Width").with_value("30%");
370        state.arm_replace_on_type();
371        state.move_left();
372        assert!(!state.pending_replace_on_type);
373        state.insert('x');
374        assert_eq!(state.value, "30x%");
375    }
376
377    #[test]
378    fn test_arm_replace_on_type_skips_when_empty() {
379        let mut state = TextInputState::new("Width");
380        state.arm_replace_on_type();
381        assert!(!state.pending_replace_on_type);
382    }
383
384    #[test]
385    fn test_arm_replace_on_type_backspace_clears_whole_value() {
386        let mut state = TextInputState::new("Width").with_value("30%");
387        state.arm_replace_on_type();
388        state.backspace();
389        assert_eq!(state.value, "");
390        assert!(!state.pending_replace_on_type);
391    }
392
393    #[test]
394    fn test_text_input_renders() {
395        test_frame(40, 1, |frame, area| {
396            let state = TextInputState::new("Name").with_value("John");
397            let colors = TextInputColors::default();
398            let layout = render_text_input(frame, area, &state, &colors, 20);
399
400            assert!(layout.input_area.width > 0);
401        });
402    }
403
404    #[test]
405    fn test_text_input_insert() {
406        let mut state = TextInputState::new("Test");
407        state.insert('a');
408        state.insert('b');
409        state.insert('c');
410        assert_eq!(state.value, "abc");
411        assert_eq!(state.cursor, 3);
412    }
413
414    #[test]
415    fn test_text_input_backspace() {
416        let mut state = TextInputState::new("Test").with_value("abc");
417        state.backspace();
418        assert_eq!(state.value, "ab");
419        assert_eq!(state.cursor, 2);
420    }
421
422    #[test]
423    fn test_text_input_cursor_movement() {
424        let mut state = TextInputState::new("Test").with_value("hello");
425        assert_eq!(state.cursor, 5);
426
427        state.move_left();
428        assert_eq!(state.cursor, 4);
429
430        state.move_home();
431        assert_eq!(state.cursor, 0);
432
433        state.move_right();
434        assert_eq!(state.cursor, 1);
435
436        state.move_end();
437        assert_eq!(state.cursor, 5);
438    }
439
440    #[test]
441    fn test_text_input_delete() {
442        let mut state = TextInputState::new("Test").with_value("abc");
443        state.move_home();
444        state.delete();
445        assert_eq!(state.value, "bc");
446        assert_eq!(state.cursor, 0);
447    }
448
449    #[test]
450    fn test_text_input_disabled() {
451        let mut state = TextInputState::new("Test").with_focus(FocusState::Disabled);
452        state.insert('a');
453        assert_eq!(state.value, "");
454    }
455
456    #[test]
457    fn test_text_input_clear() {
458        let mut state = TextInputState::new("Test").with_value("hello");
459        state.clear();
460        assert_eq!(state.value, "");
461        assert_eq!(state.cursor, 0);
462    }
463
464    #[test]
465    fn test_text_input_multibyte_insert_and_backspace() {
466        // Regression test for issue #466: panic when backspacing multi-byte chars
467        let mut state = TextInputState::new("Test");
468        // © is 2 bytes in UTF-8
469        state.insert('©');
470        assert_eq!(state.value, "©");
471        assert_eq!(state.cursor, 2); // byte position, not char position
472
473        // Backspace should delete the whole character, not cause a panic
474        state.backspace();
475        assert_eq!(state.value, "");
476        assert_eq!(state.cursor, 0);
477    }
478
479    #[test]
480    fn test_text_input_multibyte_cursor_movement() {
481        let mut state = TextInputState::new("Test").with_value("日本語");
482        // Each Japanese character is 3 bytes
483        assert_eq!(state.cursor, 9);
484
485        state.move_left();
486        assert_eq!(state.cursor, 6); // moved back by one character (3 bytes)
487
488        state.move_left();
489        assert_eq!(state.cursor, 3);
490
491        state.move_right();
492        assert_eq!(state.cursor, 6);
493
494        state.move_home();
495        assert_eq!(state.cursor, 0);
496
497        state.move_right();
498        assert_eq!(state.cursor, 3); // moved forward by one character (3 bytes)
499    }
500
501    #[test]
502    fn test_text_input_multibyte_delete() {
503        let mut state = TextInputState::new("Test").with_value("a日b");
504        // 'a' is 1 byte, '日' is 3 bytes, 'b' is 1 byte = 5 bytes total
505        assert_eq!(state.cursor, 5);
506
507        state.move_home();
508        state.move_right(); // cursor now at byte 1 (after 'a', before '日')
509        assert_eq!(state.cursor, 1);
510
511        state.delete(); // delete '日'
512        assert_eq!(state.value, "ab");
513        assert_eq!(state.cursor, 1);
514    }
515
516    #[test]
517    fn test_text_input_insert_between_multibyte() {
518        let mut state = TextInputState::new("Test").with_value("日語");
519        state.move_home();
520        state.move_right(); // cursor after first character
521        assert_eq!(state.cursor, 3);
522
523        state.insert('本');
524        assert_eq!(state.value, "日本語");
525        assert_eq!(state.cursor, 6);
526    }
527}