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, validate that value is valid JSON before allowing exit
37    pub validate_json: bool,
38}
39
40impl TextInputState {
41    /// Create a new text input state
42    pub fn new(label: impl Into<String>) -> Self {
43        Self {
44            value: String::new(),
45            cursor: 0,
46            label: label.into(),
47            placeholder: String::new(),
48            focus: FocusState::Normal,
49            validate_json: false,
50        }
51    }
52
53    /// Set JSON validation mode
54    pub fn with_json_validation(mut self) -> Self {
55        self.validate_json = true;
56        self
57    }
58
59    /// Check if the current value is valid (valid JSON if validate_json is set)
60    pub fn is_valid(&self) -> bool {
61        if self.validate_json {
62            serde_json::from_str::<serde_json::Value>(&self.value).is_ok()
63        } else {
64            true
65        }
66    }
67
68    /// Set the initial value
69    pub fn with_value(mut self, value: impl Into<String>) -> Self {
70        self.value = value.into();
71        self.cursor = self.value.len();
72        self
73    }
74
75    /// Set the placeholder text
76    pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
77        self.placeholder = placeholder.into();
78        self
79    }
80
81    /// Set the focus state
82    pub fn with_focus(mut self, focus: FocusState) -> Self {
83        self.focus = focus;
84        self
85    }
86
87    /// Check if the control is enabled
88    pub fn is_enabled(&self) -> bool {
89        self.focus != FocusState::Disabled
90    }
91
92    /// Insert a character at the cursor position
93    pub fn insert(&mut self, c: char) {
94        if !self.is_enabled() {
95            return;
96        }
97        self.value.insert(self.cursor, c);
98        self.cursor += c.len_utf8();
99    }
100
101    /// Insert a string at the cursor position
102    pub fn insert_str(&mut self, s: &str) {
103        if !self.is_enabled() {
104            return;
105        }
106        self.value.insert_str(self.cursor, s);
107        self.cursor += s.len();
108    }
109
110    /// Delete the character before the cursor (backspace)
111    pub fn backspace(&mut self) {
112        if !self.is_enabled() || self.cursor == 0 {
113            return;
114        }
115        // Find the previous character boundary
116        let prev_boundary = self.value[..self.cursor]
117            .char_indices()
118            .next_back()
119            .map(|(i, _)| i)
120            .unwrap_or(0);
121        self.value.remove(prev_boundary);
122        self.cursor = prev_boundary;
123    }
124
125    /// Delete the grapheme cluster at the cursor (delete key)
126    ///
127    /// Deletes the entire grapheme cluster, handling combining characters properly.
128    pub fn delete(&mut self) {
129        if !self.is_enabled() || self.cursor >= self.value.len() {
130            return;
131        }
132        let next_boundary = grapheme::next_grapheme_boundary(&self.value, self.cursor);
133        self.value.drain(self.cursor..next_boundary);
134    }
135
136    /// Move cursor left (to previous grapheme cluster boundary)
137    ///
138    /// Uses grapheme cluster boundaries for proper handling of combining characters
139    /// like Thai diacritics, emoji with modifiers, etc.
140    pub fn move_left(&mut self) {
141        if self.cursor > 0 {
142            self.cursor = grapheme::prev_grapheme_boundary(&self.value, self.cursor);
143        }
144    }
145
146    /// Move cursor right (to next grapheme cluster boundary)
147    ///
148    /// Uses grapheme cluster boundaries for proper handling of combining characters
149    /// like Thai diacritics, emoji with modifiers, etc.
150    pub fn move_right(&mut self) {
151        if self.cursor < self.value.len() {
152            self.cursor = grapheme::next_grapheme_boundary(&self.value, self.cursor);
153        }
154    }
155
156    /// Move cursor to start
157    pub fn move_home(&mut self) {
158        self.cursor = 0;
159    }
160
161    /// Move cursor to end
162    pub fn move_end(&mut self) {
163        self.cursor = self.value.len();
164    }
165
166    /// Clear the input
167    pub fn clear(&mut self) {
168        if self.is_enabled() {
169            self.value.clear();
170            self.cursor = 0;
171        }
172    }
173
174    /// Set the value directly
175    pub fn set_value(&mut self, value: impl Into<String>) {
176        if self.is_enabled() {
177            self.value = value.into();
178            self.cursor = self.value.len();
179        }
180    }
181}
182
183/// Colors for the text input control
184#[derive(Debug, Clone, Copy)]
185pub struct TextInputColors {
186    /// Label color
187    pub label: Color,
188    /// Input text color
189    pub text: Color,
190    /// Border/bracket color
191    pub border: Color,
192    /// Placeholder text color
193    pub placeholder: Color,
194    /// Cursor color
195    pub cursor: Color,
196    /// Focused highlight color
197    pub focused: Color,
198    /// Disabled color
199    pub disabled: Color,
200}
201
202impl Default for TextInputColors {
203    fn default() -> Self {
204        Self {
205            label: Color::White,
206            text: Color::White,
207            border: Color::Gray,
208            placeholder: Color::DarkGray,
209            cursor: Color::Yellow,
210            focused: Color::Cyan,
211            disabled: Color::DarkGray,
212        }
213    }
214}
215
216impl TextInputColors {
217    /// Create colors from theme
218    pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
219        Self {
220            label: theme.editor_fg,
221            text: theme.editor_fg,
222            border: theme.line_number_fg,
223            placeholder: theme.line_number_fg,
224            cursor: theme.cursor,
225            focused: theme.selection_bg,
226            disabled: theme.line_number_fg,
227        }
228    }
229
230    /// Create dimmed colors for read-only/inherited text inputs.
231    /// Shows brackets but with muted styling to indicate the field exists
232    /// but is not currently editable.
233    pub fn from_theme_disabled(theme: &crate::view::theme::Theme) -> Self {
234        Self {
235            label: theme.editor_fg,
236            text: theme.line_number_fg,
237            border: theme.line_number_fg,
238            placeholder: theme.line_number_fg,
239            cursor: theme.cursor,
240            focused: theme.selection_bg,
241            disabled: theme.line_number_fg,
242        }
243    }
244}
245
246/// Layout information returned after rendering for hit testing
247#[derive(Debug, Clone, Copy, Default)]
248pub struct TextInputLayout {
249    /// The text input field area
250    pub input_area: Rect,
251    /// The full control area including label
252    pub full_area: Rect,
253    /// Cursor position in screen coordinates (if focused)
254    pub cursor_pos: Option<(u16, u16)>,
255}
256
257impl TextInputLayout {
258    /// Check if a point is within the input area
259    pub fn is_input(&self, x: u16, y: u16) -> bool {
260        x >= self.input_area.x
261            && x < self.input_area.x + self.input_area.width
262            && y >= self.input_area.y
263            && y < self.input_area.y + self.input_area.height
264    }
265
266    /// Check if a point is within the full control area
267    pub fn contains(&self, x: u16, y: u16) -> bool {
268        x >= self.full_area.x
269            && x < self.full_area.x + self.full_area.width
270            && y >= self.full_area.y
271            && y < self.full_area.y + self.full_area.height
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use ratatui::backend::TestBackend;
279    use ratatui::Terminal;
280
281    fn test_frame<F>(width: u16, height: u16, f: F)
282    where
283        F: FnOnce(&mut ratatui::Frame, Rect),
284    {
285        let backend = TestBackend::new(width, height);
286        let mut terminal = Terminal::new(backend).unwrap();
287        terminal
288            .draw(|frame| {
289                let area = Rect::new(0, 0, width, height);
290                f(frame, area);
291            })
292            .unwrap();
293    }
294
295    #[test]
296    fn test_text_input_renders() {
297        test_frame(40, 1, |frame, area| {
298            let state = TextInputState::new("Name").with_value("John");
299            let colors = TextInputColors::default();
300            let layout = render_text_input(frame, area, &state, &colors, 20);
301
302            assert!(layout.input_area.width > 0);
303        });
304    }
305
306    #[test]
307    fn test_text_input_insert() {
308        let mut state = TextInputState::new("Test");
309        state.insert('a');
310        state.insert('b');
311        state.insert('c');
312        assert_eq!(state.value, "abc");
313        assert_eq!(state.cursor, 3);
314    }
315
316    #[test]
317    fn test_text_input_backspace() {
318        let mut state = TextInputState::new("Test").with_value("abc");
319        state.backspace();
320        assert_eq!(state.value, "ab");
321        assert_eq!(state.cursor, 2);
322    }
323
324    #[test]
325    fn test_text_input_cursor_movement() {
326        let mut state = TextInputState::new("Test").with_value("hello");
327        assert_eq!(state.cursor, 5);
328
329        state.move_left();
330        assert_eq!(state.cursor, 4);
331
332        state.move_home();
333        assert_eq!(state.cursor, 0);
334
335        state.move_right();
336        assert_eq!(state.cursor, 1);
337
338        state.move_end();
339        assert_eq!(state.cursor, 5);
340    }
341
342    #[test]
343    fn test_text_input_delete() {
344        let mut state = TextInputState::new("Test").with_value("abc");
345        state.move_home();
346        state.delete();
347        assert_eq!(state.value, "bc");
348        assert_eq!(state.cursor, 0);
349    }
350
351    #[test]
352    fn test_text_input_disabled() {
353        let mut state = TextInputState::new("Test").with_focus(FocusState::Disabled);
354        state.insert('a');
355        assert_eq!(state.value, "");
356    }
357
358    #[test]
359    fn test_text_input_clear() {
360        let mut state = TextInputState::new("Test").with_value("hello");
361        state.clear();
362        assert_eq!(state.value, "");
363        assert_eq!(state.cursor, 0);
364    }
365
366    #[test]
367    fn test_text_input_multibyte_insert_and_backspace() {
368        // Regression test for issue #466: panic when backspacing multi-byte chars
369        let mut state = TextInputState::new("Test");
370        // © is 2 bytes in UTF-8
371        state.insert('©');
372        assert_eq!(state.value, "©");
373        assert_eq!(state.cursor, 2); // byte position, not char position
374
375        // Backspace should delete the whole character, not cause a panic
376        state.backspace();
377        assert_eq!(state.value, "");
378        assert_eq!(state.cursor, 0);
379    }
380
381    #[test]
382    fn test_text_input_multibyte_cursor_movement() {
383        let mut state = TextInputState::new("Test").with_value("日本語");
384        // Each Japanese character is 3 bytes
385        assert_eq!(state.cursor, 9);
386
387        state.move_left();
388        assert_eq!(state.cursor, 6); // moved back by one character (3 bytes)
389
390        state.move_left();
391        assert_eq!(state.cursor, 3);
392
393        state.move_right();
394        assert_eq!(state.cursor, 6);
395
396        state.move_home();
397        assert_eq!(state.cursor, 0);
398
399        state.move_right();
400        assert_eq!(state.cursor, 3); // moved forward by one character (3 bytes)
401    }
402
403    #[test]
404    fn test_text_input_multibyte_delete() {
405        let mut state = TextInputState::new("Test").with_value("a日b");
406        // 'a' is 1 byte, '日' is 3 bytes, 'b' is 1 byte = 5 bytes total
407        assert_eq!(state.cursor, 5);
408
409        state.move_home();
410        state.move_right(); // cursor now at byte 1 (after 'a', before '日')
411        assert_eq!(state.cursor, 1);
412
413        state.delete(); // delete '日'
414        assert_eq!(state.value, "ab");
415        assert_eq!(state.cursor, 1);
416    }
417
418    #[test]
419    fn test_text_input_insert_between_multibyte() {
420        let mut state = TextInputState::new("Test").with_value("日語");
421        state.move_home();
422        state.move_right(); // cursor after first character
423        assert_eq!(state.cursor, 3);
424
425        state.insert('本');
426        assert_eq!(state.value, "日本語");
427        assert_eq!(state.cursor, 6);
428    }
429}