Skip to main content

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