agent_core/tui/widgets/
input.rs

1//! Text input buffer with cursor management
2//!
3//! A simple text input widget that handles cursor movement, editing operations,
4//! and word wrapping calculations for terminal display.
5
6/// Text input buffer with cursor management
7pub struct TextInput {
8    buffer: String,
9    cursor_pos: usize,
10}
11
12impl TextInput {
13    /// Create a new empty text input
14    pub fn new() -> Self {
15        Self {
16            buffer: String::new(),
17            cursor_pos: 0,
18        }
19    }
20
21    /// Get the current buffer contents
22    pub fn buffer(&self) -> &str {
23        &self.buffer
24    }
25
26    /// Check if the buffer is empty
27    pub fn is_empty(&self) -> bool {
28        self.buffer.is_empty()
29    }
30
31    /// Take ownership of the buffer contents, leaving it empty
32    pub fn take(&mut self) -> String {
33        self.cursor_pos = 0;
34        std::mem::take(&mut self.buffer)
35    }
36
37    /// Clear the buffer
38    pub fn clear(&mut self) {
39        self.buffer.clear();
40        self.cursor_pos = 0;
41    }
42
43    // Editing operations
44
45    /// Insert a character at the cursor position
46    pub fn insert_char(&mut self, c: char) {
47        self.buffer.insert(self.cursor_pos, c);
48        self.cursor_pos += c.len_utf8();
49    }
50
51    /// Delete the character before the cursor (backspace)
52    pub fn delete_char_before(&mut self) {
53        if self.cursor_pos > 0 {
54            let prev_char_boundary = self.buffer[..self.cursor_pos]
55                .char_indices()
56                .last()
57                .map(|(i, _)| i)
58                .unwrap_or(0);
59            self.buffer.remove(prev_char_boundary);
60            self.cursor_pos = prev_char_boundary;
61        }
62    }
63
64    /// Delete the character at the cursor position (delete key)
65    pub fn delete_char_at(&mut self) {
66        if self.cursor_pos < self.buffer.len() {
67            self.buffer.remove(self.cursor_pos);
68        }
69    }
70
71    /// Kill (delete) from cursor to end of line (Ctrl-K)
72    pub fn kill_line(&mut self) {
73        let line_end = self.buffer[self.cursor_pos..]
74            .find('\n')
75            .map(|i| self.cursor_pos + i)
76            .unwrap_or(self.buffer.len());
77
78        if line_end == self.cursor_pos && self.cursor_pos < self.buffer.len() {
79            // At end of line, delete the newline
80            self.buffer.remove(self.cursor_pos);
81        } else {
82            // Delete from cursor to end of line
83            self.buffer.drain(self.cursor_pos..line_end);
84        }
85    }
86
87    // Cursor movement operations
88
89    /// Move cursor left one character
90    pub fn move_left(&mut self) {
91        if self.cursor_pos > 0 {
92            self.cursor_pos = self.buffer[..self.cursor_pos]
93                .char_indices()
94                .last()
95                .map(|(i, _)| i)
96                .unwrap_or(0);
97        }
98    }
99
100    /// Move cursor right one character
101    pub fn move_right(&mut self) {
102        if self.cursor_pos < self.buffer.len() {
103            self.cursor_pos += self.buffer[self.cursor_pos..]
104                .chars()
105                .next()
106                .map(|c| c.len_utf8())
107                .unwrap_or(0);
108        }
109    }
110
111    /// Move cursor up one line
112    pub fn move_up(&mut self) {
113        let (current_line, col) = self.cursor_line_col();
114        if current_line > 0 {
115            let lines: Vec<&str> = self.buffer.split('\n').collect();
116            let prev_line_len = lines[current_line - 1].len();
117            let new_col = col.min(prev_line_len);
118            self.cursor_pos = self.line_col_to_pos(current_line - 1, new_col);
119        }
120    }
121
122    /// Move cursor down one line
123    pub fn move_down(&mut self) {
124        let (current_line, col) = self.cursor_line_col();
125        let lines: Vec<&str> = self.buffer.split('\n').collect();
126        if current_line < lines.len() - 1 {
127            let next_line_len = lines[current_line + 1].len();
128            let new_col = col.min(next_line_len);
129            self.cursor_pos = self.line_col_to_pos(current_line + 1, new_col);
130        }
131    }
132
133    /// Move cursor to the start of the current line
134    pub fn move_to_line_start(&mut self) {
135        let (current_line, _) = self.cursor_line_col();
136        self.cursor_pos = self.line_col_to_pos(current_line, 0);
137    }
138
139    /// Move cursor to the end of the current line
140    pub fn move_to_line_end(&mut self) {
141        let (current_line, _) = self.cursor_line_col();
142        let lines: Vec<&str> = self.buffer.split('\n').collect();
143        let line_len = lines.get(current_line).map(|l| l.len()).unwrap_or(0);
144        self.cursor_pos = self.line_col_to_pos(current_line, line_len);
145    }
146
147    // Utility methods
148
149    /// Get the current cursor position as (line, column)
150    pub fn cursor_line_col(&self) -> (usize, usize) {
151        let before_cursor = &self.buffer[..self.cursor_pos];
152        let line = before_cursor.matches('\n').count();
153        let col = before_cursor
154            .rfind('\n')
155            .map(|i| self.cursor_pos - i - 1)
156            .unwrap_or(self.cursor_pos);
157        (line, col)
158    }
159
160    fn line_col_to_pos(&self, line: usize, col: usize) -> usize {
161        let mut pos = 0;
162        for (i, l) in self.buffer.split('\n').enumerate() {
163            if i == line {
164                return pos + col.min(l.len());
165            }
166            pos += l.len() + 1; // +1 for newline
167        }
168        self.buffer.len()
169    }
170
171    /// Get the number of logical lines in the buffer
172    pub fn line_count(&self) -> usize {
173        self.buffer.split('\n').count().max(1)
174    }
175
176    /// Simulate word wrapping for a line and return visual line break positions
177    /// Returns a vec of (start_char_idx, end_char_idx) for each visual line
178    fn word_wrap_line(
179        &self,
180        line: &str,
181        first_line_width: usize,
182        subsequent_width: usize,
183    ) -> Vec<(usize, usize)> {
184        let chars: Vec<char> = line.chars().collect();
185        let mut breaks = Vec::new();
186
187        if chars.is_empty() {
188            breaks.push((0, 0));
189            return breaks;
190        }
191
192        let mut start = 0;
193        let mut is_first_line = true;
194
195        while start < chars.len() {
196            let width = if is_first_line {
197                first_line_width
198            } else {
199                subsequent_width
200            };
201            let max_end = (start + width).min(chars.len());
202
203            if max_end >= chars.len() {
204                // Rest of line fits
205                breaks.push((start, chars.len()));
206                break;
207            }
208
209            // Look for last space within width to break at (word wrap)
210            // If no space found, break_at stays at max_end (character wrap)
211            let mut break_at = max_end;
212            for i in (start..max_end).rev() {
213                if chars[i] == ' ' {
214                    break_at = i + 1; // Break after the space
215                    break;
216                }
217            }
218
219            breaks.push((start, break_at));
220            start = break_at;
221            is_first_line = false;
222        }
223
224        if breaks.is_empty() {
225            breaks.push((0, 0));
226        }
227
228        breaks
229    }
230
231    /// Calculate visual line count accounting for word wrapping
232    pub fn visual_line_count(&self, width: usize, prompt_len: usize, indent_len: usize) -> usize {
233        if width == 0 {
234            return self.line_count();
235        }
236
237        let mut visual_lines = 0;
238        for (i, line) in self.buffer.split('\n').enumerate() {
239            let prefix_len = if i == 0 { prompt_len } else { indent_len };
240            let first_width = width.saturating_sub(prefix_len);
241            let breaks = self.word_wrap_line(line, first_width, width);
242            visual_lines += breaks.len();
243        }
244        visual_lines.max(1)
245    }
246
247    /// Calculate cursor display position accounting for word wrapping
248    pub fn cursor_display_position_wrapped(
249        &self,
250        width: usize,
251        prompt_len: usize,
252        indent_len: usize,
253    ) -> (u16, u16) {
254        if width == 0 {
255            return self.cursor_display_position(prompt_len, indent_len);
256        }
257
258        let (logical_line, col) = self.cursor_line_col();
259
260        // Convert byte-based col to character count
261        let current_line = self.buffer.split('\n').nth(logical_line).unwrap_or("");
262        let col_chars = current_line[..col.min(current_line.len())]
263            .chars()
264            .count();
265
266        // Count visual lines before cursor's logical line
267        let mut visual_y = 0;
268        for (i, line) in self.buffer.split('\n').enumerate() {
269            if i >= logical_line {
270                break;
271            }
272            let prefix_len = if i == 0 { prompt_len } else { indent_len };
273            let first_width = width.saturating_sub(prefix_len);
274            let breaks = self.word_wrap_line(line, first_width, width);
275            visual_y += breaks.len();
276        }
277
278        // Find cursor position within current logical line
279        let prefix_len = if logical_line == 0 {
280            prompt_len
281        } else {
282            indent_len
283        };
284        let first_width = width.saturating_sub(prefix_len);
285        let breaks = self.word_wrap_line(current_line, first_width, width);
286
287        // Find which visual line contains the cursor.
288        // Breaks are (start, end) where start is inclusive and end is exclusive.
289        // A cursor at position N belongs to the segment where start <= N < end.
290        // Cursor at exactly 'end' belongs to the NEXT segment (or fallback if last).
291        for (i, (start, end)) in breaks.iter().enumerate() {
292            if col_chars >= *start && col_chars < *end {
293                let x = if i == 0 {
294                    prefix_len + (col_chars - start)
295                } else {
296                    col_chars - start
297                };
298                return (x as u16, (visual_y + i) as u16);
299            }
300        }
301
302        // Fallback: cursor at end of last visual line (col_chars == last segment's end)
303        let last_break = breaks.last().unwrap_or(&(0, 0));
304        let x = if breaks.len() == 1 {
305            prefix_len + (col_chars - last_break.0)
306        } else {
307            col_chars - last_break.0
308        };
309        (x as u16, (visual_y + breaks.len() - 1) as u16)
310    }
311
312    /// Calculate cursor display position without word wrapping
313    pub fn cursor_display_position(&self, prompt_len: usize, indent_len: usize) -> (u16, u16) {
314        let (line, col) = self.cursor_line_col();
315        let x = if line == 0 {
316            prompt_len + col
317        } else {
318            indent_len + col
319        };
320        (x as u16, line as u16)
321    }
322}
323
324impl Default for TextInput {
325    fn default() -> Self {
326        Self::new()
327    }
328}
329
330// --- Widget trait implementation ---
331
332use std::any::Any;
333use crossterm::event::KeyEvent;
334use ratatui::{layout::Rect, Frame};
335use crate::tui::themes::Theme;
336use super::{widget_ids, Widget, WidgetKeyContext, WidgetKeyResult};
337
338impl Widget for TextInput {
339    fn id(&self) -> &'static str {
340        widget_ids::TEXT_INPUT
341    }
342
343    fn priority(&self) -> u8 {
344        50 // Low priority - core widget
345    }
346
347    fn is_active(&self) -> bool {
348        true // Always active (unless blocked by modal)
349    }
350
351    fn handle_key(&mut self, _key: KeyEvent, _ctx: &WidgetKeyContext) -> WidgetKeyResult {
352        // TextInput doesn't handle keys via Widget trait
353        // Key handling is done by App directly
354        WidgetKeyResult::NotHandled
355    }
356
357    fn render(&mut self, _frame: &mut Frame, _area: Rect, _theme: &Theme) {
358        // TextInput rendering is handled by App directly
359        // This is a no-op for Widget trait compatibility
360    }
361
362    fn required_height(&self, _available: u16) -> u16 {
363        0 // Height calculated by App based on content
364    }
365
366    fn blocks_input(&self) -> bool {
367        false
368    }
369
370    fn is_overlay(&self) -> bool {
371        false
372    }
373
374    fn as_any(&self) -> &dyn Any {
375        self
376    }
377
378    fn as_any_mut(&mut self) -> &mut dyn Any {
379        self
380    }
381
382    fn into_any(self: Box<Self>) -> Box<dyn Any> {
383        self
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn test_new_input() {
393        let input = TextInput::new();
394        assert!(input.is_empty());
395        assert_eq!(input.buffer(), "");
396    }
397
398    #[test]
399    fn test_insert_char() {
400        let mut input = TextInput::new();
401        input.insert_char('a');
402        input.insert_char('b');
403        input.insert_char('c');
404        assert_eq!(input.buffer(), "abc");
405    }
406
407    #[test]
408    fn test_delete_char_before() {
409        let mut input = TextInput::new();
410        input.insert_char('a');
411        input.insert_char('b');
412        input.delete_char_before();
413        assert_eq!(input.buffer(), "a");
414    }
415
416    #[test]
417    fn test_cursor_movement() {
418        let mut input = TextInput::new();
419        input.insert_char('a');
420        input.insert_char('b');
421        input.insert_char('c');
422
423        input.move_left();
424        input.move_left();
425        input.insert_char('x');
426        assert_eq!(input.buffer(), "axbc");
427    }
428
429    #[test]
430    fn test_cursor_line_col() {
431        let mut input = TextInput::new();
432        input.insert_char('a');
433        input.insert_char('\n');
434        input.insert_char('b');
435
436        let (line, col) = input.cursor_line_col();
437        assert_eq!(line, 1);
438        assert_eq!(col, 1);
439    }
440
441    #[test]
442    fn test_line_count() {
443        let mut input = TextInput::new();
444        assert_eq!(input.line_count(), 1);
445
446        input.insert_char('a');
447        input.insert_char('\n');
448        input.insert_char('b');
449        assert_eq!(input.line_count(), 2);
450
451        input.insert_char('\n');
452        input.insert_char('c');
453        assert_eq!(input.line_count(), 3);
454    }
455
456    #[test]
457    fn test_take() {
458        let mut input = TextInput::new();
459        input.insert_char('a');
460        input.insert_char('b');
461
462        let taken = input.take();
463        assert_eq!(taken, "ab");
464        assert!(input.is_empty());
465    }
466}