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 let Err(e) = self.load_history(cache) {
292                    eprintln!("Warning: Could not load history: {}", e);
293                    return;
294                }
295            } else {
296                return;
297            }
298        }
299
300        if self.history.is_empty() {
301            return;
302        }
303
304        // Save current value to temp if we're starting history navigation
305        if self.history_index.is_none() {
306            self.sync_from_textarea(); // Ensure value is up to date
307            self.history_temp = Some(self.value.clone());
308        }
309
310        // Move to previous (older) entry
311        let new_index = if let Some(current_idx) = self.history_index {
312            if current_idx > 0 {
313                current_idx - 1
314            } else {
315                current_idx // Already at oldest
316            }
317        } else {
318            self.history.len() - 1 // Start from most recent
319        };
320
321        self.history_index = Some(new_index);
322        if let Some(entry) = self.history.get(new_index) {
323            self.value = entry.clone();
324            self.cursor = self.value.chars().count();
325            self.sync_to_textarea();
326        }
327    }
328
329    /// Navigate history down (newer entries)
330    pub fn navigate_history_down(&mut self) {
331        if self.history_id.is_none() || self.history_index.is_none() {
332            return;
333        }
334
335        let current_idx = self.history_index.unwrap();
336        if current_idx >= self.history.len() - 1 {
337            // Restore temp value
338            if let Some(ref temp) = self.history_temp {
339                self.value = temp.clone();
340                self.cursor = self.value.chars().count();
341                self.sync_to_textarea();
342            }
343            self.history_index = None;
344            self.history_temp = None;
345        } else {
346            // Move to next (newer) entry
347            let new_index = current_idx + 1;
348            self.history_index = Some(new_index);
349            if let Some(entry) = self.history.get(new_index) {
350                self.value = entry.clone();
351                self.cursor = self.value.chars().count();
352                self.sync_to_textarea();
353            }
354        }
355    }
356
357    /// Handle a key event
358    pub fn handle_key(&mut self, event: &KeyEvent, cache: Option<&CacheManager>) -> TextInputEvent {
359        // Convert KeyEvent to tui_textarea::Input
360        let input = self.key_event_to_input(event);
361
362        match event.code {
363            KeyCode::Enter => {
364                // For single-line, Enter means submit (don't insert newline)
365                // Save to history before submitting
366                if let Some(cache) = cache {
367                    let _ = self.save_to_history(cache);
368                }
369                return TextInputEvent::Submit;
370            }
371            KeyCode::Esc => {
372                return TextInputEvent::Cancel;
373            }
374            KeyCode::Up if self.history_id.is_some() => {
375                self.navigate_history_up(cache);
376                return TextInputEvent::HistoryChanged;
377            }
378            KeyCode::Down if self.history_id.is_some() => {
379                self.navigate_history_down();
380                return TextInputEvent::HistoryChanged;
381            }
382            _ => {
383                // For single-line input, ignore newline insertion
384                if matches!(input.key, Key::Char('\n') | Key::Char('\r')) {
385                    return TextInputEvent::None;
386                }
387                // Handle the input
388                self.textarea.input(input);
389                // Sync value and cursor from textarea
390                self.sync_from_textarea();
391                // Clear history navigation state when user types
392                if self.history_index.is_some() {
393                    self.history_index = None;
394                    self.history_temp = None;
395                }
396            }
397        }
398        TextInputEvent::None
399    }
400
401    /// Convert crossterm KeyEvent to tui_textarea::Input
402    fn key_event_to_input(&self, event: &KeyEvent) -> Input {
403        let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
404        let alt = event.modifiers.contains(KeyModifiers::ALT);
405        let shift = event.modifiers.contains(KeyModifiers::SHIFT);
406
407        let key = match event.code {
408            KeyCode::Char(c) => Key::Char(c),
409            KeyCode::Backspace => Key::Backspace,
410            KeyCode::Enter => Key::Enter,
411            KeyCode::Left => Key::Left,
412            KeyCode::Right => Key::Right,
413            KeyCode::Up => Key::Up,
414            KeyCode::Down => Key::Down,
415            KeyCode::Home => Key::Home,
416            KeyCode::End => Key::End,
417            KeyCode::PageUp => Key::PageUp,
418            KeyCode::PageDown => Key::PageDown,
419            KeyCode::Tab => Key::Tab,
420            KeyCode::BackTab => Key::Tab, // BackTab as Tab
421            KeyCode::Delete => Key::Delete,
422            KeyCode::Insert => Key::Null, // Insert not supported
423            KeyCode::F(_) => Key::Null,
424            KeyCode::Null => Key::Null,
425            KeyCode::Esc => Key::Esc,
426            KeyCode::CapsLock
427            | KeyCode::ScrollLock
428            | KeyCode::NumLock
429            | KeyCode::PrintScreen
430            | KeyCode::Pause
431            | KeyCode::Menu
432            | KeyCode::Media(_)
433            | KeyCode::Modifier(_)
434            | KeyCode::KeypadBegin => Key::Null,
435        };
436
437        Input {
438            key,
439            ctrl,
440            alt,
441            shift,
442        }
443    }
444}
445
446impl Default for TextInput {
447    fn default() -> Self {
448        Self::new()
449    }
450}
451
452impl Widget for &TextInput {
453    fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) {
454        // Render the textarea - it handles all text rendering and styling
455        self.textarea.render(area, buf);
456
457        // Remove underline modifier from all cells (tui-textarea handles cursor visibility via set_cursor_style)
458        for y in area.y..area.bottom() {
459            for x in area.x..area.right() {
460                let cell = &mut buf[(x, y)];
461                let mut style = cell.style();
462                style = style.remove_modifier(Modifier::UNDERLINED);
463                cell.set_style(style);
464            }
465        }
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    #[test]
474    fn test_text_input_new() {
475        let input = TextInput::new();
476        assert_eq!(input.value(), "");
477        assert_eq!(input.cursor(), 0);
478        assert_eq!(input.history_id, None);
479        assert_eq!(input.history_limit, 1000);
480        assert!(!input.focused);
481    }
482
483    #[test]
484    fn test_set_value() {
485        let mut input = TextInput::new();
486        input.set_value("hello".to_string());
487        assert_eq!(input.value(), "hello");
488    }
489
490    #[test]
491    fn test_clear() {
492        let mut input = TextInput::new();
493        input.set_value("hello".to_string());
494        input.clear();
495        assert_eq!(input.value(), "");
496    }
497
498    #[test]
499    fn test_is_empty() {
500        let mut input = TextInput::new();
501        assert!(input.is_empty());
502        input.set_value("hello".to_string());
503        assert!(!input.is_empty());
504    }
505}