Skip to main content

datui_lib/widgets/
text_input.rs

1use color_eyre::Result;
2use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3use ratatui::{
4    layout::Rect,
5    style::{Color, Modifier, Style},
6    widgets::Widget,
7};
8use tui_textarea::{Input, Key, TextArea};
9
10use crate::cache::CacheManager;
11use crate::config::Theme;
12
13use super::text_input_common::{add_to_history, load_history_impl, save_history_impl};
14
15/// Event emitted by TextInput widget
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum TextInputEvent {
18    None,
19    Submit,         // Enter pressed
20    Cancel,         // Esc pressed
21    HistoryChanged, // History navigation occurred
22}
23
24/// Single-line text input widget wrapping tui-textarea with history support
25pub struct TextInput {
26    textarea: TextArea<'static>,
27    // Public fields for backward compatibility (kept in sync with textarea)
28    pub value: String,
29    pub cursor: usize,
30    pub history_id: Option<String>, // None = no history, Some(id) = use history with this ID
31    pub history: Vec<String>,       // Loaded history entries (lazy-loaded)
32    pub history_index: Option<usize>, // Current position in history (None = editing new value)
33    pub history_temp: Option<String>, // Temporary storage when navigating history
34    pub history_limit: usize,       // Maximum number of history entries to keep
35    pub history_loaded: bool,       // Track if history has been loaded (for lazy loading)
36    // Styling (internal, set via builder methods)
37    text_color: Option<Color>,
38    cursor_bg: Option<Color>,
39    cursor_fg: Option<Color>,
40    background_color: Option<Color>,
41    cursor_focused: Option<Color>, // Cursor color when focused (from theme)
42    focused: bool,                 // Whether the widget is currently focused
43}
44
45impl TextInput {
46    /// Create a new TextInput widget
47    pub fn new() -> Self {
48        let mut textarea = TextArea::default();
49        // Configure for single-line: disable cursor line underline
50        // Don't set line_number_style - leaving it unset means no line numbers (default behavior)
51        textarea.set_cursor_line_style(Style::default()); // No underline
52
53        let mut widget = Self {
54            textarea,
55            value: String::new(),
56            cursor: 0,
57            history_id: None,
58            history: Vec::new(),
59            history_index: None,
60            history_temp: None,
61            history_limit: 1000,
62            history_loaded: false,
63            text_color: None,
64            cursor_bg: None,
65            cursor_fg: None,
66            background_color: None,
67            cursor_focused: None,
68            focused: false,
69        };
70        // Apply any colors that were set (none initially, but this ensures consistency)
71        widget.apply_colors_to_textarea();
72        widget
73    }
74
75    /// Sync value and cursor from textarea
76    fn sync_from_textarea(&mut self) {
77        self.value = self.textarea.lines().first().cloned().unwrap_or_default();
78        self.cursor = self.textarea.cursor().1;
79    }
80
81    /// Apply text and background colors to the textarea
82    fn apply_colors_to_textarea(&mut self) {
83        // Build style from text_color and background_color
84        // Ensure no modifiers (like underline) are applied
85        let mut style = Style::default();
86        if let Some(text_color) = self.text_color {
87            style = style.fg(text_color);
88        }
89        if let Some(bg_color) = self.background_color {
90            style = style.bg(bg_color);
91        }
92        // Apply style to textarea - this sets the text style (no underline by default)
93        self.textarea.set_style(style);
94        // Disable cursor line underline (this is separate from text underline)
95        self.textarea.set_cursor_line_style(Style::default());
96    }
97
98    /// Sync textarea from value and cursor
99    fn sync_to_textarea(&mut self) {
100        let single_line = self.value.replace(['\n', '\r'], " ");
101        self.textarea = TextArea::new(vec![single_line]);
102        // Re-apply colors and cursor line style configuration
103        // This is necessary because creating a new TextArea resets the style
104        self.apply_colors_to_textarea();
105        // Re-apply cursor style based on focus state (since textarea was recreated)
106        let was_focused = self.focused;
107        self.focused = false; // Temporarily set to false so set_focused will update
108        self.set_focused(was_focused);
109        use tui_textarea::CursorMove;
110        self.textarea.move_cursor(CursorMove::Jump(
111            0,
112            self.cursor.min(u16::MAX as usize) as u16,
113        ));
114    }
115
116    /// Set all colors at once
117    /// Note: cursor_bg and cursor_fg are deprecated - cursor colors are now automatically reversed
118    #[allow(deprecated)]
119    pub fn with_style(mut self, text_color: Color, cursor_bg: Color, cursor_fg: Color) -> Self {
120        self.text_color = Some(text_color);
121        self.cursor_bg = Some(cursor_bg);
122        self.cursor_fg = Some(cursor_fg);
123        self.apply_colors_to_textarea();
124        self
125    }
126
127    /// Set text color only
128    pub fn with_text_color(mut self, color: Color) -> Self {
129        self.text_color = Some(color);
130        self.apply_colors_to_textarea();
131        self
132    }
133
134    /// Set cursor colors only (deprecated: cursor colors are now automatically reversed)
135    #[deprecated(note = "Cursor colors are now automatically reversed from text/background colors")]
136    pub fn with_cursor_colors(mut self, bg: Color, fg: Color) -> Self {
137        self.cursor_bg = Some(bg);
138        self.cursor_fg = Some(fg);
139        self
140    }
141
142    /// Set optional background color for input area
143    pub fn with_background(mut self, color: Color) -> Self {
144        self.background_color = Some(color);
145        self.apply_colors_to_textarea();
146        self
147    }
148
149    /// Convenience method to set colors from theme
150    /// Cursor colors come from theme cursor_focused setting
151    /// If cursor_focused is "default" (Color::Reset), uses REVERSED modifier (tui-textarea default)
152    pub fn with_theme(mut self, theme: &Theme) -> Self {
153        let text_primary = theme.get("text_primary");
154        // Set text color from theme
155        self.text_color = Some(text_primary);
156        // Set cursor color from theme (defaults to Color::Reset which uses REVERSED)
157        self.cursor_focused = Some(theme.get("cursor_focused"));
158        self.apply_colors_to_textarea();
159        self
160    }
161
162    /// Enable history with the given ID
163    pub fn with_history(mut self, history_id: String) -> Self {
164        self.history_id = Some(history_id);
165        self
166    }
167
168    /// Set history limit
169    pub fn with_history_limit(mut self, limit: usize) -> Self {
170        self.history_limit = limit;
171        self
172    }
173
174    /// Set focused state
175    pub fn set_focused(&mut self, focused: bool) {
176        self.focused = focused;
177        // Use tui-textarea's set_cursor_style to hide/show cursor
178        // Setting the same style as text hides the cursor (per tui-textarea docs)
179        if focused {
180            // When focused, always use a visible cursor style
181            // Default to REVERSED modifier (tui-textarea's default) for maximum compatibility
182            let cursor_color = self.cursor_focused.unwrap_or(Color::Reset);
183            let cursor_style = if cursor_color == Color::Reset {
184                // Use reversed modifier (tui-textarea default behavior)
185                Style::default().add_modifier(Modifier::REVERSED)
186            } else {
187                // Use theme-based cursor color with explicit background
188                let text_color = match cursor_color {
189                    Color::White => Color::Black,
190                    Color::Black => Color::White,
191                    Color::Red => Color::White,
192                    Color::Green => Color::Black,
193                    Color::Yellow => Color::Black,
194                    Color::Blue => Color::White,
195                    Color::Magenta => Color::White,
196                    Color::Cyan => Color::Black,
197                    Color::Gray => Color::Black,
198                    Color::DarkGray => Color::White,
199                    Color::LightRed => Color::Black,
200                    Color::LightGreen => Color::Black,
201                    Color::LightYellow => Color::Black,
202                    Color::LightBlue => Color::Black,
203                    Color::LightMagenta => Color::Black,
204                    Color::LightCyan => Color::Black,
205                    _ => Color::Black,
206                };
207                Style::default().bg(cursor_color).fg(text_color)
208            };
209            self.textarea.set_cursor_style(cursor_style);
210        } else {
211            // When unfocused, set cursor style to exactly match textarea's text style (hides cursor)
212            // Get the actual textarea style to match it exactly
213            let textarea_style = self.textarea.style();
214            self.textarea.set_cursor_style(textarea_style);
215        }
216    }
217
218    /// Get the current value (single line)
219    pub fn value(&self) -> &str {
220        &self.value
221    }
222
223    /// Set the value
224    pub fn set_value(&mut self, value: String) {
225        self.value = value;
226        self.sync_to_textarea();
227    }
228
229    /// Get cursor position
230    pub fn cursor(&self) -> usize {
231        self.cursor
232    }
233
234    /// Set cursor position
235    pub fn set_cursor(&mut self, cursor: usize) {
236        self.cursor = cursor;
237        use tui_textarea::CursorMove;
238        self.textarea
239            .move_cursor(CursorMove::Jump(0, cursor.min(u16::MAX as usize) as u16));
240    }
241
242    /// Load history from cache (lazy loading)
243    pub fn load_history(&mut self, cache: &CacheManager) -> Result<()> {
244        if self.history_loaded {
245            return Ok(());
246        }
247        if let Some(ref history_id) = self.history_id {
248            self.history = load_history_impl(cache, history_id)?;
249            self.history_loaded = true;
250        }
251        Ok(())
252    }
253
254    /// Save current value to history
255    pub fn save_to_history(&mut self, cache: &CacheManager) -> Result<()> {
256        if let Some(history_id) = self.history_id.clone() {
257            self.sync_from_textarea(); // Ensure value is up to date
258            if !self.value.is_empty() {
259                // Add to history with deduplication
260                add_to_history(&mut self.history, self.value.clone());
261                // Save to cache
262                save_history_impl(cache, &history_id, &self.history, self.history_limit)?;
263            }
264        }
265        Ok(())
266    }
267
268    /// Clear the input
269    pub fn clear(&mut self) {
270        self.textarea = TextArea::default();
271        self.value.clear();
272        self.cursor = 0;
273        self.history_index = None;
274        self.history_temp = None;
275    }
276
277    /// Check if input is empty
278    pub fn is_empty(&self) -> bool {
279        self.value.is_empty()
280    }
281
282    /// Navigate history up (older entries)
283    pub fn navigate_history_up(&mut self, cache: Option<&CacheManager>) {
284        if self.history_id.is_none() {
285            return;
286        }
287
288        // Lazy load history if needed
289        if !self.history_loaded {
290            if let Some(cache) = cache {
291                if self.load_history(cache).is_err() {
292                    return;
293                }
294            } else {
295                return;
296            }
297        }
298
299        if self.history.is_empty() {
300            return;
301        }
302
303        // Save current value to temp if we're starting history navigation
304        if self.history_index.is_none() {
305            self.sync_from_textarea(); // Ensure value is up to date
306            self.history_temp = Some(self.value.clone());
307        }
308
309        // Move to previous (older) entry
310        let new_index = if let Some(current_idx) = self.history_index {
311            if current_idx > 0 {
312                current_idx - 1
313            } else {
314                current_idx // Already at oldest
315            }
316        } else {
317            self.history.len() - 1 // Start from most recent
318        };
319
320        self.history_index = Some(new_index);
321        if let Some(entry) = self.history.get(new_index) {
322            self.value = entry.clone();
323            self.cursor = self.value.chars().count();
324            self.sync_to_textarea();
325        }
326    }
327
328    /// Navigate history down (newer entries)
329    pub fn navigate_history_down(&mut self) {
330        if self.history_id.is_none() || self.history_index.is_none() {
331            return;
332        }
333
334        let current_idx = self.history_index.unwrap();
335        if current_idx >= self.history.len() - 1 {
336            // Restore temp value
337            if let Some(ref temp) = self.history_temp {
338                self.value = temp.clone();
339                self.cursor = self.value.chars().count();
340                self.sync_to_textarea();
341            }
342            self.history_index = None;
343            self.history_temp = None;
344        } else {
345            // Move to next (newer) entry
346            let new_index = current_idx + 1;
347            self.history_index = Some(new_index);
348            if let Some(entry) = self.history.get(new_index) {
349                self.value = entry.clone();
350                self.cursor = self.value.chars().count();
351                self.sync_to_textarea();
352            }
353        }
354    }
355
356    /// Handle a key event
357    pub fn handle_key(&mut self, event: &KeyEvent, cache: Option<&CacheManager>) -> TextInputEvent {
358        // Convert KeyEvent to tui_textarea::Input
359        let input = self.key_event_to_input(event);
360
361        match event.code {
362            KeyCode::Enter => {
363                // For single-line, Enter means submit (don't insert newline)
364                // Save to history before submitting
365                if let Some(cache) = cache {
366                    let _ = self.save_to_history(cache);
367                }
368                return TextInputEvent::Submit;
369            }
370            KeyCode::Esc => {
371                return TextInputEvent::Cancel;
372            }
373            KeyCode::Up if self.history_id.is_some() => {
374                self.navigate_history_up(cache);
375                return TextInputEvent::HistoryChanged;
376            }
377            KeyCode::Down if self.history_id.is_some() => {
378                self.navigate_history_down();
379                return TextInputEvent::HistoryChanged;
380            }
381            _ => {
382                // For single-line input, ignore newline insertion
383                if matches!(input.key, Key::Char('\n') | Key::Char('\r')) {
384                    return TextInputEvent::None;
385                }
386                // Handle the input
387                self.textarea.input(input);
388                // Sync value and cursor from textarea
389                self.sync_from_textarea();
390                // Clear history navigation state when user types
391                if self.history_index.is_some() {
392                    self.history_index = None;
393                    self.history_temp = None;
394                }
395            }
396        }
397        TextInputEvent::None
398    }
399
400    /// Convert crossterm KeyEvent to tui_textarea::Input
401    fn key_event_to_input(&self, event: &KeyEvent) -> Input {
402        let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
403        let alt = event.modifiers.contains(KeyModifiers::ALT);
404        let shift = event.modifiers.contains(KeyModifiers::SHIFT);
405
406        let key = match event.code {
407            KeyCode::Char(c) => Key::Char(c),
408            KeyCode::Backspace => Key::Backspace,
409            KeyCode::Enter => Key::Enter,
410            KeyCode::Left => Key::Left,
411            KeyCode::Right => Key::Right,
412            KeyCode::Up => Key::Up,
413            KeyCode::Down => Key::Down,
414            KeyCode::Home => Key::Home,
415            KeyCode::End => Key::End,
416            KeyCode::PageUp => Key::PageUp,
417            KeyCode::PageDown => Key::PageDown,
418            KeyCode::Tab => Key::Tab,
419            KeyCode::BackTab => Key::Tab, // BackTab as Tab
420            KeyCode::Delete => Key::Delete,
421            KeyCode::Insert => Key::Null, // Insert not supported
422            KeyCode::F(_) => Key::Null,
423            KeyCode::Null => Key::Null,
424            KeyCode::Esc => Key::Esc,
425            KeyCode::CapsLock
426            | KeyCode::ScrollLock
427            | KeyCode::NumLock
428            | KeyCode::PrintScreen
429            | KeyCode::Pause
430            | KeyCode::Menu
431            | KeyCode::Media(_)
432            | KeyCode::Modifier(_)
433            | KeyCode::KeypadBegin => Key::Null,
434        };
435
436        Input {
437            key,
438            ctrl,
439            alt,
440            shift,
441        }
442    }
443}
444
445impl Default for TextInput {
446    fn default() -> Self {
447        Self::new()
448    }
449}
450
451impl Widget for &TextInput {
452    fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) {
453        // Render the textarea - it handles all text rendering and styling
454        self.textarea.render(area, buf);
455
456        // Remove underline modifier from all cells (tui-textarea handles cursor visibility via set_cursor_style)
457        for y in area.y..area.bottom() {
458            for x in area.x..area.right() {
459                let cell = &mut buf[(x, y)];
460                let mut style = cell.style();
461                style = style.remove_modifier(Modifier::UNDERLINED);
462                cell.set_style(style);
463            }
464        }
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn test_text_input_new() {
474        let input = TextInput::new();
475        assert_eq!(input.value(), "");
476        assert_eq!(input.cursor(), 0);
477        assert_eq!(input.history_id, None);
478        assert_eq!(input.history_limit, 1000);
479        assert!(!input.focused);
480    }
481
482    #[test]
483    fn test_set_value() {
484        let mut input = TextInput::new();
485        input.set_value("hello".to_string());
486        assert_eq!(input.value(), "hello");
487    }
488
489    #[test]
490    fn test_clear() {
491        let mut input = TextInput::new();
492        input.set_value("hello".to_string());
493        input.clear();
494        assert_eq!(input.value(), "");
495    }
496
497    #[test]
498    fn test_is_empty() {
499        let mut input = TextInput::new();
500        assert!(input.is_empty());
501        input.set_value("hello".to_string());
502        assert!(!input.is_empty());
503    }
504}