Skip to main content

flywheel/widget/
text_input.rs

1//! Text Input Widget: Single-line text input with cursor.
2//!
3//! A focused, single-line text input widget with cursor blinking,
4//! character insertion, deletion, and navigation.
5
6use crate::actor::{InputEvent, KeyCode};
7use crate::buffer::{Buffer, Cell, Rgb};
8use crate::layout::Rect;
9use super::traits::Widget;
10
11/// Configuration for the text input widget.
12#[derive(Debug, Clone)]
13pub struct TextInputConfig {
14    /// Foreground color for text.
15    pub fg: Rgb,
16    /// Background color.
17    pub bg: Rgb,
18    /// Cursor color.
19    pub cursor_fg: Rgb,
20    /// Placeholder text shown when empty.
21    pub placeholder: String,
22    /// Placeholder text color.
23    pub placeholder_fg: Rgb,
24    /// Prompt prefix (e.g., "> ").
25    pub prompt: String,
26    /// Prompt color.
27    pub prompt_fg: Rgb,
28}
29
30impl Default for TextInputConfig {
31    fn default() -> Self {
32        Self {
33            fg: Rgb::WHITE,
34            bg: Rgb::new(30, 30, 30),
35            cursor_fg: Rgb::new(0, 255, 255),
36            placeholder: String::new(),
37            placeholder_fg: Rgb::new(100, 100, 100),
38            prompt: String::from("> "),
39            prompt_fg: Rgb::new(0, 255, 255),
40        }
41    }
42}
43
44/// A single-line text input widget with cursor and editing support.
45#[derive(Debug)]
46pub struct TextInput {
47    /// Current text content.
48    content: String,
49    /// Cursor position (byte offset, not char offset for simplicity).
50    cursor: usize,
51    /// Widget bounds.
52    bounds: Rect,
53    /// Whether this widget has focus.
54    focused: bool,
55    /// Configuration.
56    config: TextInputConfig,
57    /// Frame counter for cursor blinking.
58    frame: u64,
59    /// Needs redraw flag.
60    dirty: bool,
61}
62
63impl TextInput {
64    /// Create a new text input widget with the given bounds.
65    pub fn new(bounds: Rect) -> Self {
66        Self {
67            content: String::new(),
68            cursor: 0,
69            bounds,
70            focused: true,
71            config: TextInputConfig::default(),
72            frame: 0,
73            dirty: true,
74        }
75    }
76
77    /// Create a new text input widget with custom configuration.
78    pub const fn with_config(bounds: Rect, config: TextInputConfig) -> Self {
79        Self {
80            content: String::new(),
81            cursor: 0,
82            bounds,
83            focused: true,
84            config,
85            frame: 0,
86            dirty: true,
87        }
88    }
89
90    /// Get the current text content.
91    pub fn content(&self) -> &str {
92        &self.content
93    }
94
95    /// Set the content, moving cursor to end.
96    pub fn set_content(&mut self, content: &str) {
97        self.content = content.to_string();
98        self.cursor = self.content.len();
99        self.dirty = true;
100    }
101
102    /// Clear the content.
103    pub fn clear(&mut self) {
104        self.content.clear();
105        self.cursor = 0;
106        self.dirty = true;
107    }
108
109    /// Check if the input is empty.
110    pub const fn is_empty(&self) -> bool {
111        self.content.is_empty()
112    }
113
114    /// Set focus state.
115    pub const fn set_focused(&mut self, focused: bool) {
116        self.focused = focused;
117        self.dirty = true;
118    }
119
120    /// Check if focused.
121    pub const fn is_focused(&self) -> bool {
122        self.focused
123    }
124
125    /// Advance frame for cursor blink animation.
126    pub const fn tick(&mut self) {
127        self.frame = self.frame.wrapping_add(1);
128        // Only mark dirty if focused (cursor blink matters)
129        if self.focused && self.frame.is_multiple_of(15) {
130            self.dirty = true;
131        }
132    }
133
134    /// Insert a character at the cursor position.
135    fn insert_char(&mut self, c: char) {
136        self.content.insert(self.cursor, c);
137        self.cursor += c.len_utf8();
138        self.dirty = true;
139    }
140
141    /// Delete the character before the cursor.
142    fn backspace(&mut self) {
143        if self.cursor > 0 {
144            // Find the previous char boundary
145            let prev = self.content[..self.cursor]
146                .char_indices()
147                .last()
148                .map_or(0, |(i, _)| i);
149            self.content.remove(prev);
150            self.cursor = prev;
151            self.dirty = true;
152        }
153    }
154
155    /// Delete the character at the cursor.
156    fn delete(&mut self) {
157        if self.cursor < self.content.len() {
158            self.content.remove(self.cursor);
159            self.dirty = true;
160        }
161    }
162
163    /// Move cursor left.
164    fn cursor_left(&mut self) {
165        if self.cursor > 0 {
166            // Find previous char boundary
167            self.cursor = self.content[..self.cursor]
168                .char_indices()
169                .last()
170                .map_or(0, |(i, _)| i);
171            self.dirty = true;
172        }
173    }
174
175    /// Move cursor right.
176    fn cursor_right(&mut self) {
177        if self.cursor < self.content.len() {
178            // Find next char boundary
179            if let Some(c) = self.content[self.cursor..].chars().next() {
180                self.cursor += c.len_utf8();
181                self.dirty = true;
182            }
183        }
184    }
185
186    /// Move cursor to start.
187    const fn cursor_home(&mut self) {
188        if self.cursor != 0 {
189            self.cursor = 0;
190            self.dirty = true;
191        }
192    }
193
194    /// Move cursor to end.
195    const fn cursor_end(&mut self) {
196        let end = self.content.len();
197        if self.cursor != end {
198            self.cursor = end;
199            self.dirty = true;
200        }
201    }
202}
203
204impl Widget for TextInput {
205    fn bounds(&self) -> Rect {
206        self.bounds
207    }
208
209    fn set_bounds(&mut self, bounds: Rect) {
210        self.bounds = bounds;
211        self.dirty = true;
212    }
213
214    fn render(&self, buffer: &mut Buffer) {
215        let x = self.bounds.x;
216        let y = self.bounds.y;
217        let width = self.bounds.width as usize;
218
219        // Clear the line with background
220        for i in 0..self.bounds.width {
221            buffer.set(x + i, y, Cell::new(' ').with_bg(self.config.bg));
222        }
223
224        // Draw prompt
225        let prompt_len = self.config.prompt.chars().count();
226        for (i, c) in self.config.prompt.chars().enumerate() {
227            #[allow(clippy::cast_possible_truncation)]
228            let px = x + i as u16;
229            if (px as usize) < x as usize + width {
230                buffer.set(px, y, Cell::new(c)
231                    .with_fg(self.config.prompt_fg)
232                    .with_bg(self.config.bg));
233            }
234        }
235
236        #[allow(clippy::cast_possible_truncation)]
237        let text_start = x + prompt_len as u16;
238        let text_width = width.saturating_sub(prompt_len);
239
240        if self.content.is_empty() && !self.config.placeholder.is_empty() {
241            // Draw placeholder
242            for (i, c) in self.config.placeholder.chars().take(text_width).enumerate() {
243                #[allow(clippy::cast_possible_truncation)]
244                let px = text_start + i as u16;
245                buffer.set(px, y, Cell::new(c)
246                    .with_fg(self.config.placeholder_fg)
247                    .with_bg(self.config.bg));
248            }
249        } else {
250            // Draw content
251            // Calculate visible window based on cursor position
252            let cursor_char_pos = self.content[..self.cursor].chars().count();
253            let content_chars: Vec<char> = self.content.chars().collect();
254            
255            // Calculate scroll offset to keep cursor visible
256            let scroll_offset = if cursor_char_pos >= text_width {
257                cursor_char_pos - text_width + 1
258            } else {
259                0
260            };
261
262            for (i, &c) in content_chars.iter().skip(scroll_offset).take(text_width).enumerate() {
263                #[allow(clippy::cast_possible_truncation)]
264                let px = text_start + i as u16;
265                let is_cursor = self.focused 
266                    && (i + scroll_offset) == cursor_char_pos
267                    && self.frame % 30 < 15;
268
269                if is_cursor {
270                    buffer.set(px, y, Cell::new(c)
271                        .with_fg(self.config.bg)
272                        .with_bg(self.config.cursor_fg));
273                } else {
274                    buffer.set(px, y, Cell::new(c)
275                        .with_fg(self.config.fg)
276                        .with_bg(self.config.bg));
277                }
278            }
279
280            // Draw cursor at end if needed
281            #[allow(clippy::cast_possible_truncation)]
282            let cursor_visual_pos = cursor_char_pos.saturating_sub(scroll_offset) as u16;
283            #[allow(clippy::cast_possible_truncation)]
284            let text_width_u16 = text_width as u16;
285            if self.focused 
286                && cursor_char_pos == content_chars.len() 
287                && cursor_visual_pos < text_width_u16
288                && self.frame % 30 < 15
289            {
290                let cx = text_start + cursor_visual_pos;
291                buffer.set(cx, y, Cell::new('█')
292                    .with_fg(self.config.cursor_fg)
293                    .with_bg(self.config.bg));
294            }
295        }
296    }
297
298    fn handle_input(&mut self, event: &InputEvent) -> bool {
299        if !self.focused {
300            return false;
301        }
302
303        if let InputEvent::Key { code, modifiers } = event {
304            match code {
305                KeyCode::Char(c) => {
306                    if !modifiers.control && !modifiers.alt {
307                        self.insert_char(*c);
308                        return true;
309                    }
310                }
311                KeyCode::Backspace => {
312                    self.backspace();
313                    return true;
314                }
315                KeyCode::Delete => {
316                    self.delete();
317                    return true;
318                }
319                KeyCode::Left => {
320                    self.cursor_left();
321                    return true;
322                }
323                KeyCode::Right => {
324                    self.cursor_right();
325                    return true;
326                }
327                KeyCode::Home => {
328                    self.cursor_home();
329                    return true;
330                }
331                KeyCode::End => {
332                    self.cursor_end();
333                    return true;
334                }
335                _ => {}
336            }
337        }
338
339        false
340    }
341
342    fn needs_redraw(&self) -> bool {
343        self.dirty
344    }
345
346    fn clear_redraw(&mut self) {
347        self.dirty = false;
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn test_text_input_basic() {
357        let mut input = TextInput::new(Rect::new(0, 0, 80, 1));
358        
359        // Insert characters
360        input.insert_char('H');
361        input.insert_char('i');
362        assert_eq!(input.content(), "Hi");
363        assert_eq!(input.cursor, 2);
364    }
365
366    #[test]
367    fn test_text_input_backspace() {
368        let mut input = TextInput::new(Rect::new(0, 0, 80, 1));
369        input.set_content("Hello");
370        
371        input.backspace();
372        assert_eq!(input.content(), "Hell");
373    }
374
375    #[test]
376    fn test_text_input_cursor_movement() {
377        let mut input = TextInput::new(Rect::new(0, 0, 80, 1));
378        input.set_content("Hello");
379        
380        input.cursor_left();
381        assert_eq!(input.cursor, 4);
382        
383        input.cursor_home();
384        assert_eq!(input.cursor, 0);
385        
386        input.cursor_end();
387        assert_eq!(input.cursor, 5);
388    }
389}