Skip to main content

mecomp_tui/ui/widgets/
input_box.rs

1//! Implementation of a search bar input box widget
2
3use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, MouseEvent};
4use ratatui::{
5    buffer::Buffer,
6    layout::{Offset, Position, Rect},
7    style::{Color, Style},
8    widgets::{Block, Paragraph, StatefulWidget, Widget},
9};
10use unicode_width::UnicodeWidthChar;
11
12/// State for the input box widget containing all mutable data
13#[derive(Debug, Default)]
14pub struct InputBoxState {
15    /// Current value of the input box
16    text: String,
17    /// Index of cursor in the text.
18    /// This is in *characters*, not bytes.
19    cursor_position: usize,
20    /// length of the text in characters
21    text_length: usize,
22    /// prefix sum array of the text width in columns
23    ps_columns: util::PrefixSumVec,
24    /// The offset of where the cursor is in the currently displayed area
25    cursor_offset: u16,
26    /// Horizontal scroll offset in columns (maintains smooth scrolling)
27    horizontal_scroll: u16,
28}
29
30impl InputBoxState {
31    #[must_use]
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    #[must_use]
37    pub const fn text(&self) -> &str {
38        self.text.as_str()
39    }
40
41    pub fn set_text(&mut self, new_text: &str) {
42        self.text = String::from(new_text);
43        self.text_length = self.text.chars().count();
44        self.cursor_position = self.text_length;
45        self.ps_columns.clear();
46        for c in self.text.chars() {
47            self.ps_columns
48                .push(UnicodeWidthChar::width(c).unwrap_or_default());
49        }
50        // Reset scroll when setting new text
51        self.horizontal_scroll = 0;
52    }
53
54    pub fn clear(&mut self) {
55        self.text.clear();
56        self.cursor_position = 0;
57        self.text_length = 0;
58        self.ps_columns.clear();
59        self.horizontal_scroll = 0;
60    }
61
62    #[must_use]
63    pub const fn is_empty(&self) -> bool {
64        self.text.is_empty()
65    }
66
67    const fn move_cursor_left(&mut self) {
68        let mut min = self.cursor_position.saturating_sub(1);
69        if min > self.text_length {
70            min = self.text_length;
71        }
72        self.cursor_position = min;
73    }
74
75    const fn move_cursor_right(&mut self) {
76        let mut min = self.cursor_position.saturating_add(1);
77        if min > self.text_length {
78            min = self.text_length;
79        }
80        self.cursor_position = min;
81    }
82
83    const fn move_cursor_to_start(&mut self) {
84        self.cursor_position = 0;
85    }
86
87    const fn move_cursor_to_end(&mut self) {
88        self.cursor_position = self.text_length;
89    }
90
91    const fn update_cursor_offset(&mut self, new_offset: u16) {
92        self.cursor_offset = new_offset;
93    }
94
95    #[must_use]
96    pub const fn cursor_offset(&self) -> Offset {
97        Offset::new(self.cursor_offset as i32, 0)
98    }
99
100    fn enter_char(&mut self, new_char: char) {
101        // we need to convert the cursor position (which is in characters) to the byte index
102        // of the cursor position in the string
103        let cursor_byte_index = self
104            .text
105            .chars()
106            .take(self.cursor_position)
107            .map(char::len_utf8)
108            .sum();
109
110        self.text.insert(cursor_byte_index, new_char);
111        self.text_length += 1;
112        self.ps_columns.insert(
113            self.cursor_position,
114            UnicodeWidthChar::width(new_char).unwrap_or_default(),
115        );
116
117        self.move_cursor_right();
118    }
119
120    // Delete the character before the cursor (backspace)
121    fn delete_char(&mut self) {
122        if self.cursor_position == 0 {
123            return;
124        }
125
126        // Method "remove" is not used on the saved text for deleting the selected char.
127        // Reason: Using remove on String works on bytes instead of the chars.
128        // Using remove would require special care because of char boundaries.
129        let mut chars = self.text.chars();
130
131        // Getting all characters before the selected character.
132        let mut new = chars
133            .by_ref()
134            .take(self.cursor_position - 1)
135            .collect::<String>();
136        // the character being removed
137        chars.next();
138        // Getting all characters after selected character.
139        new.extend(chars);
140
141        self.text = new;
142        self.text_length = self.text_length.saturating_sub(1);
143        self.ps_columns.remove(self.cursor_position - 1);
144        self.move_cursor_left();
145    }
146
147    // delete the character under the cursor (delete)
148    fn delete_next_char(&mut self) {
149        // same procedure as with `self.delete_char()`, but we don't need to
150        // decrement the cursor position
151        let mut chars = self.text.chars();
152        let mut new = chars
153            .by_ref()
154            .take(self.cursor_position)
155            .collect::<String>();
156        chars.next();
157        new.extend(chars);
158
159        self.text = new;
160        self.text_length = self.text_length.saturating_sub(1);
161        self.ps_columns.remove(self.cursor_position);
162    }
163
164    /// Update the horizontal scroll offset to keep the cursor visible
165    ///
166    /// Only scrolls when the cursor moves outside the visible area.
167    /// This maintains scroll position when cursor is visible, creating smooth scrolling.
168    const fn update_scroll(&mut self, view_width: u16) {
169        let cursor_column = self.ps_columns.get(self.cursor_position);
170        let scroll = self.horizontal_scroll as usize;
171        let view_end = scroll + view_width as usize;
172
173        #[allow(clippy::cast_possible_truncation)]
174        if cursor_column < scroll {
175            // Cursor moved left past visible area - scroll left to make it visible
176            self.horizontal_scroll = cursor_column as u16;
177        } else if cursor_column > view_end {
178            // Cursor moved right past visible area - scroll right to make it visible
179            // Note: cursor_column == view_end is OK (cursor at right edge)
180            self.horizontal_scroll = (cursor_column.saturating_sub(view_width as usize)) as u16;
181        }
182        // else: cursor is within visible area, keep current scroll
183    }
184
185    /// Convert a column position to a character index
186    ///
187    /// Finds the character index where the cumulative width is closest to the target column.
188    /// For wide characters, snaps to the nearest character boundary.
189    const fn column_to_char_index(&self, column: usize) -> usize {
190        // Binary search to find the character index
191        // We want to find the largest index where ps_columns.get(index) <= column
192        let mut left = 0;
193        let mut right = self.text_length;
194
195        while left < right {
196            let mid = (left + right).div_ceil(2);
197            if self.ps_columns.get(mid) <= column {
198                left = mid;
199            } else {
200                right = mid - 1;
201            }
202        }
203
204        left
205    }
206
207    pub fn handle_key_event(&mut self, key: KeyEvent) {
208        if key.kind != KeyEventKind::Press {
209            return;
210        }
211
212        match key.code {
213            KeyCode::Char(to_insert) => {
214                self.enter_char(to_insert);
215            }
216            KeyCode::Backspace => {
217                self.delete_char();
218            }
219            KeyCode::Delete => {
220                self.delete_next_char();
221            }
222            KeyCode::Left => {
223                self.move_cursor_left();
224            }
225            KeyCode::Right => {
226                self.move_cursor_right();
227            }
228            KeyCode::Home => {
229                self.move_cursor_to_start();
230            }
231            KeyCode::End => {
232                self.move_cursor_to_end();
233            }
234            _ => {}
235        }
236    }
237
238    /// Handle mouse events
239    ///
240    /// moves the cursor to the clicked position
241    pub fn handle_mouse_event(&mut self, mouse: MouseEvent, area: Rect) {
242        let MouseEvent {
243            kind, column, row, ..
244        } = mouse;
245        let mouse_position = Position::new(column, row);
246
247        if !area.contains(mouse_position) {
248            return;
249        }
250
251        if kind == crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) {
252            // NOTE: this assumes that the border is 1 character wide, which may not necessarily be true
253            let mouse_x = mouse_position.x.saturating_sub(area.x + 1);
254
255            // Add scroll offset to get the actual column position in the text
256            let actual_column = (mouse_x + self.horizontal_scroll) as usize;
257
258            // Convert column position to character index
259            self.cursor_position = self.column_to_char_index(actual_column);
260        }
261    }
262}
263
264/// Input box widget for text input
265///
266/// This is a stateful widget - use with `InputBoxState` to maintain the input state.
267#[derive(Debug, Clone)]
268pub struct InputBox<'a> {
269    border: Option<Block<'a>>,
270    text_color: Color,
271}
272
273impl<'a> InputBox<'a> {
274    #[must_use]
275    pub const fn new() -> Self {
276        Self {
277            border: None,
278            text_color: Color::Reset,
279        }
280    }
281
282    #[must_use]
283    pub fn border(mut self, border: Block<'a>) -> Self {
284        self.border.replace(border);
285        self
286    }
287
288    #[must_use]
289    pub const fn text_color(mut self, color: Color) -> Self {
290        self.text_color = color;
291        self
292    }
293}
294
295impl Default for InputBox<'_> {
296    fn default() -> Self {
297        Self::new()
298    }
299}
300
301impl StatefulWidget for InputBox<'_> {
302    type State = InputBoxState;
303
304    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
305        // Get the inner area inside a possible border
306        let inner_area = self.border.map_or(area, |border| {
307            let inner = border.inner(area);
308            border.render(area, buf);
309            inner
310        });
311
312        // Update scroll to keep cursor visible
313        state.update_scroll(inner_area.width);
314
315        let cursor_column = state.ps_columns.get(state.cursor_position);
316
317        #[allow(clippy::cast_possible_truncation)]
318        state.update_cursor_offset(cursor_column as u16 - state.horizontal_scroll);
319        let input = Paragraph::new(state.text.as_str())
320            .style(Style::default().fg(self.text_color))
321            .scroll((0, state.horizontal_scroll));
322
323        input.render(inner_area, buf);
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use crate::test_utils::{assert_buffer_eq, setup_test_terminal};
330
331    use super::*;
332    use anyhow::Result;
333    use pretty_assertions::assert_eq;
334    use rstest::rstest;
335
336    #[test]
337    fn test_enter_delete() {
338        let mut input_box = InputBoxState::default();
339
340        input_box.enter_char('a');
341        assert_eq!(input_box.text, "a");
342        assert_eq!(input_box.cursor_position, 1);
343
344        input_box.enter_char('b');
345        assert_eq!(input_box.text, "ab");
346        assert_eq!(input_box.cursor_position, 2);
347
348        input_box.enter_char('c');
349        assert_eq!(input_box.text, "abc");
350        assert_eq!(input_box.cursor_position, 3);
351
352        input_box.move_cursor_left();
353        assert_eq!(input_box.cursor_position, 2);
354
355        input_box.delete_char();
356        assert_eq!(input_box.text, "ac");
357        assert_eq!(input_box.cursor_position, 1);
358
359        input_box.enter_char('d');
360        assert_eq!(input_box.text, "adc");
361        assert_eq!(input_box.cursor_position, 2);
362
363        input_box.move_cursor_right();
364        assert_eq!(input_box.cursor_position, 3);
365
366        input_box.clear();
367        assert_eq!(input_box.text, "");
368        assert_eq!(input_box.cursor_position, 0);
369
370        input_box.delete_char();
371        assert_eq!(input_box.text, "");
372        assert_eq!(input_box.cursor_position, 0);
373
374        input_box.delete_char();
375        assert_eq!(input_box.text, "");
376        assert_eq!(input_box.cursor_position, 0);
377    }
378
379    #[test]
380    fn test_enter_delete_non_ascii_char() {
381        let mut input_box = InputBoxState::default();
382
383        input_box.enter_char('a');
384        assert_eq!(input_box.text, "a");
385        assert_eq!(input_box.cursor_position, 1);
386        assert_eq!(input_box.text_length, 1);
387        assert_eq!(input_box.ps_columns.last(), 1);
388
389        input_box.enter_char('m');
390        assert_eq!(input_box.text, "am");
391        assert_eq!(input_box.cursor_position, 2);
392        assert_eq!(input_box.text_length, 2);
393        assert_eq!(input_box.ps_columns.last(), 2);
394
395        input_box.enter_char('é');
396        assert_eq!(input_box.text, "amé");
397        assert_eq!(input_box.cursor_position, 3);
398        assert_eq!(input_box.text_length, 3);
399        assert_eq!(input_box.ps_columns.last(), 3);
400
401        input_box.enter_char('l');
402        assert_eq!(input_box.text, "amél");
403        assert_eq!(input_box.cursor_position, 4);
404        assert_eq!(input_box.text_length, 4);
405        assert_eq!(input_box.ps_columns.last(), 4);
406
407        input_box.delete_char();
408        assert_eq!(input_box.text, "amé");
409        assert_eq!(input_box.cursor_position, 3);
410        assert_eq!(input_box.text_length, 3);
411        assert_eq!(input_box.ps_columns.last(), 3);
412
413        input_box.delete_char();
414        assert_eq!(input_box.text, "am");
415        assert_eq!(input_box.cursor_position, 2);
416        assert_eq!(input_box.text_length, 2);
417        assert_eq!(input_box.ps_columns.last(), 2);
418    }
419
420    #[test]
421    fn test_enter_delete_wide_characters() {
422        let mut input_box = InputBoxState::default();
423
424        input_box.enter_char('こ');
425        assert_eq!(input_box.text, "こ");
426        assert_eq!(input_box.cursor_position, 1);
427        assert_eq!(input_box.text_length, 1);
428        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 2);
429        assert_eq!(input_box.ps_columns.last(), 2);
430
431        input_box.enter_char('ん');
432        assert_eq!(input_box.text, "こん");
433        assert_eq!(input_box.cursor_position, 2);
434        assert_eq!(input_box.text_length, 2);
435        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 4);
436        assert_eq!(input_box.ps_columns.last(), 4);
437
438        input_box.enter_char('に');
439        assert_eq!(input_box.text, "こんに");
440        assert_eq!(input_box.cursor_position, 3);
441        assert_eq!(input_box.text_length, 3);
442        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 6);
443        assert_eq!(input_box.ps_columns.last(), 6);
444
445        input_box.enter_char('ち');
446        assert_eq!(input_box.text, "こんにち");
447        assert_eq!(input_box.cursor_position, 4);
448        assert_eq!(input_box.text_length, 4);
449        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 8);
450        assert_eq!(input_box.ps_columns.last(), 8);
451
452        input_box.enter_char('は');
453        assert_eq!(input_box.text, "こんにちは");
454        assert_eq!(input_box.cursor_position, 5);
455        assert_eq!(input_box.text_length, 5);
456        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 10);
457        assert_eq!(input_box.ps_columns.last(), 10);
458
459        input_box.delete_char();
460        assert_eq!(input_box.text, "こんにち");
461        assert_eq!(input_box.cursor_position, 4);
462        assert_eq!(input_box.text_length, 4);
463        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 8);
464        assert_eq!(input_box.ps_columns.last(), 8);
465
466        input_box.delete_char();
467        assert_eq!(input_box.text, "こんに");
468        assert_eq!(input_box.cursor_position, 3);
469        assert_eq!(input_box.text_length, 3);
470        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 6);
471        assert_eq!(input_box.ps_columns.last(), 6);
472    }
473
474    #[test]
475    fn test_move_left_right() {
476        let mut input_box = InputBoxState::default();
477
478        // string with:
479        // - normal ascii
480        // - accented character (1 column)
481        // - wide character (2 columns)
482        // - zero-width character
483        input_box.set_text("héこ👨\u{200B}");
484        assert_eq!(input_box.text, "héこ👨​");
485        assert_eq!(input_box.cursor_position, 5);
486        assert_eq!(input_box.text_length, 5);
487        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 6);
488        assert_eq!(input_box.ps_columns.last(), 6);
489
490        input_box.move_cursor_left();
491        assert_eq!(input_box.cursor_position, 4);
492        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 6);
493
494        input_box.move_cursor_left();
495        assert_eq!(input_box.cursor_position, 3);
496        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 4);
497        input_box.move_cursor_left();
498        assert_eq!(input_box.cursor_position, 2);
499        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 2);
500        input_box.move_cursor_left();
501        assert_eq!(input_box.cursor_position, 1);
502        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 1);
503        input_box.move_cursor_left();
504        assert_eq!(input_box.cursor_position, 0);
505        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 0);
506        input_box.move_cursor_left();
507        assert_eq!(input_box.cursor_position, 0);
508        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 0);
509        input_box.move_cursor_right();
510        assert_eq!(input_box.cursor_position, 1);
511        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 1);
512        input_box.move_cursor_right();
513        assert_eq!(input_box.cursor_position, 2);
514        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 2);
515        input_box.move_cursor_right();
516        assert_eq!(input_box.cursor_position, 3);
517        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 4);
518        input_box.move_cursor_right();
519        assert_eq!(input_box.cursor_position, 4);
520        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 6);
521        input_box.move_cursor_right();
522        assert_eq!(input_box.cursor_position, 5);
523        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 6);
524        input_box.move_cursor_right();
525        assert_eq!(input_box.cursor_position, 5);
526        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 6);
527    }
528
529    #[test]
530    fn test_enter_delete_middle() {
531        let mut input_box = InputBoxState::default();
532
533        input_box.set_text("ace");
534        assert_eq!(input_box.text, "ace");
535        assert_eq!(input_box.cursor_position, 3);
536
537        input_box.move_cursor_left();
538        input_box.enter_char('Ü');
539        assert_eq!(input_box.text, "acÜe");
540        assert_eq!(input_box.cursor_position, 3);
541        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 3);
542        assert_eq!(input_box.text_length, 4);
543        assert_eq!(input_box.ps_columns.last(), 4);
544
545        input_box.move_cursor_left();
546        input_box.move_cursor_left();
547        input_box.enter_char('X');
548        assert_eq!(input_box.text, "aXcÜe");
549        assert_eq!(input_box.cursor_position, 2);
550        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 2);
551        assert_eq!(input_box.text_length, 5);
552        assert_eq!(input_box.ps_columns.last(), 5);
553
554        // add two wide characters
555        input_box.enter_char('こ');
556        input_box.enter_char('い');
557        assert_eq!(input_box.text, "aXこいcÜe");
558        assert_eq!(input_box.cursor_position, 4);
559        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 6);
560        assert_eq!(input_box.text_length, 7);
561        assert_eq!(input_box.ps_columns.last(), 9);
562
563        input_box.move_cursor_left();
564        input_box.delete_char();
565        assert_eq!(input_box.text, "aXいcÜe");
566        assert_eq!(input_box.cursor_position, 2);
567        assert_eq!(input_box.ps_columns.get(input_box.cursor_position), 2);
568        assert_eq!(input_box.text_length, 6);
569        assert_eq!(input_box.ps_columns.last(), 7);
570
571        input_box.delete_next_char();
572        assert_eq!(input_box.text, "aXcÜe");
573        assert_eq!(input_box.cursor_position, 2);
574        assert_eq!(input_box.text_length, 5);
575        assert_eq!(input_box.ps_columns.last(), 5);
576
577        input_box.delete_char();
578        assert_eq!(input_box.text, "acÜe");
579        assert_eq!(input_box.cursor_position, 1);
580        assert_eq!(input_box.text_length, 4);
581        assert_eq!(input_box.ps_columns.last(), 4);
582
583        input_box.move_cursor_right();
584        input_box.delete_next_char();
585        assert_eq!(input_box.text, "ace");
586        assert_eq!(input_box.cursor_position, 2);
587    }
588
589    #[test]
590    fn test_input_box_is_empty() {
591        let input_box = InputBoxState::default();
592        assert!(input_box.is_empty());
593
594        let mut input_box = InputBoxState::default();
595        input_box.set_text("abc");
596
597        assert!(!input_box.is_empty());
598    }
599
600    #[test]
601    fn test_input_box_text() {
602        let mut input_box = InputBoxState::default();
603        input_box.set_text("abc");
604
605        assert_eq!(input_box.text(), "abc");
606    }
607
608    #[rstest]
609    fn test_input_box_render(
610        #[values(10, 20)] width: u16,
611        #[values(1, 2, 3, 4, 5, 6)] height: u16,
612    ) -> Result<()> {
613        use ratatui::{buffer::Buffer, text::Line};
614
615        let (mut terminal, _) = setup_test_terminal(width, height);
616        let mut state = InputBoxState::default();
617        state.set_text("Hello, World!");
618        let area = Rect::new(0, 0, width, height);
619
620        let buffer = terminal
621            .draw(|frame| {
622                frame.render_stateful_widget(
623                    InputBox::new().border(Block::bordered()),
624                    area,
625                    &mut state,
626                )
627            })?
628            .buffer
629            .clone();
630
631        let line_top = Line::raw(String::from("┌") + &"─".repeat((width - 2).into()) + "┐");
632        let line_text = if width > 15 {
633            Line::raw(String::from("│Hello, World!") + &" ".repeat((width - 15).into()) + "│")
634        } else {
635            Line::raw(
636                String::from("│")
637                    + &"Hello, World!"
638                        .chars()
639                        .skip(state.text().len() - (width - 2) as usize)
640                        .collect::<String>()
641                    + "│",
642            )
643        };
644        let line_empty = Line::raw(String::from("│") + &" ".repeat((width - 2).into()) + "│");
645        let line_bottom = Line::raw(String::from("└") + &"─".repeat((width - 2).into()) + "┘");
646
647        let expected = Buffer::with_lines(match height {
648            0 => unreachable!(),
649            1 => vec![line_top].into_iter(),
650            2 => vec![line_top, line_bottom].into_iter(),
651            3 => vec![line_top, line_text, line_bottom].into_iter(),
652            other => vec![line_top, line_text]
653                .into_iter()
654                .chain(
655                    std::iter::repeat_n(line_empty, (other - 3).into())
656                        .chain(std::iter::once(line_bottom)),
657                )
658                .collect::<Vec<_>>()
659                .into_iter(),
660        });
661
662        assert_eq!(buffer, expected);
663
664        Ok(())
665    }
666
667    #[rstest]
668    #[case::fits("Hello", 10, "Hello     ")]
669    #[case::exact_fit("Hello, World!", 13, "Hello, World!")]
670    #[case::too_small("Hello, World!", 6, "World!")]
671    #[case::too_small_wide("こんにちは世界", 10, "にちは世界")]
672    fn test_keeps_cursor_visible_right(
673        #[case] new_text: &str,
674        #[case] view_width: u16,
675        #[case] expected_visible_text: &str,
676    ) -> Result<()> {
677        use ratatui::{buffer::Buffer, text::Line};
678
679        let (mut terminal, _) = setup_test_terminal(view_width, 1);
680        let mut state = InputBoxState::default();
681        state.set_text(new_text);
682
683        let area = Rect::new(0, 0, view_width, 1);
684        let buffer = terminal
685            .draw(|frame| frame.render_stateful_widget(InputBox::new(), area, &mut state))?
686            .buffer
687            .clone();
688        let line = Line::raw(expected_visible_text.to_string());
689        let expected = Buffer::with_lines(std::iter::once(line));
690        assert_buffer_eq(&buffer, &expected);
691        Ok(())
692    }
693
694    #[test]
695    fn test_column_to_char_index() {
696        let mut input_box = InputBoxState::default();
697
698        // Test with ASCII text
699        input_box.set_text("Hello, World!");
700        assert_eq!(input_box.column_to_char_index(0), 0);
701        assert_eq!(input_box.column_to_char_index(1), 1);
702        assert_eq!(input_box.column_to_char_index(5), 5);
703        assert_eq!(input_box.column_to_char_index(13), 13);
704        assert_eq!(input_box.column_to_char_index(100), 13); // Beyond end
705
706        // Test with wide characters
707        input_box.set_text("こんにち");
708        // Each character is 2 columns wide
709        assert_eq!(input_box.column_to_char_index(0), 0);
710        assert_eq!(input_box.column_to_char_index(1), 0); // In middle of first char
711        assert_eq!(input_box.column_to_char_index(2), 1); // Start of second char
712        assert_eq!(input_box.column_to_char_index(3), 1); // In middle of second char
713        assert_eq!(input_box.column_to_char_index(4), 2); // Start of third char
714        assert_eq!(input_box.column_to_char_index(6), 3); // Start of fourth char
715        assert_eq!(input_box.column_to_char_index(8), 4); // Beyond end
716
717        // Test with mixed width characters
718        input_box.set_text("aこbに");
719        // a=1, こ=2, b=1, に=2 total=6 columns
720        assert_eq!(input_box.column_to_char_index(0), 0); // 'a'
721        assert_eq!(input_box.column_to_char_index(1), 1); // 'こ' start
722        assert_eq!(input_box.column_to_char_index(2), 1); // 'こ' middle
723        assert_eq!(input_box.column_to_char_index(3), 2); // 'b'
724        assert_eq!(input_box.column_to_char_index(4), 3); // 'に' start
725        assert_eq!(input_box.column_to_char_index(5), 3); // 'に' middle
726        assert_eq!(input_box.column_to_char_index(6), 4); // Beyond end
727    }
728
729    #[test]
730    fn test_smooth_scrolling_maintains_position() {
731        let mut input_box = InputBoxState::default();
732        input_box.set_text("Hello, World! This is long text!"); // 32 chars
733
734        // Text is 32 chars, cursor at end after set_text
735        assert_eq!(input_box.text_length, 32);
736        assert_eq!(input_box.cursor_position, 32);
737
738        // Update scroll with view width of 10
739        input_box.update_scroll(10);
740        assert_eq!(input_box.horizontal_scroll, 22); // 32 - 10 = 22
741
742        // Move cursor left a few characters (but still visible)
743        for _ in 0..3 {
744            input_box.move_cursor_left();
745        }
746        input_box.update_scroll(10);
747        // Scroll should NOT change because cursor is still visible (column 29, view is [22, 32])
748        assert_eq!(input_box.cursor_position, 29);
749        assert_eq!(input_box.horizontal_scroll, 22);
750
751        // Move cursor even more left (still visible)
752        for _ in 0..5 {
753            input_box.move_cursor_left();
754        }
755        input_box.update_scroll(10);
756        // Still visible (column 24, view is [22, 32])
757        assert_eq!(input_box.cursor_position, 24);
758        assert_eq!(input_box.horizontal_scroll, 22);
759
760        // Now move cursor left past the visible area
761        for _ in 0..5 {
762            input_box.move_cursor_left();
763        }
764        input_box.update_scroll(10);
765        // Should scroll left to make cursor visible at left edge
766        assert_eq!(input_box.cursor_position, 19);
767        assert_eq!(input_box.horizontal_scroll, 19);
768    }
769
770    #[test]
771    fn test_smooth_scrolling_right_movement() {
772        let mut input_box = InputBoxState::default();
773        input_box.set_text("Hello, World! This is long text!"); // 32 chars
774
775        // Start with cursor at beginning (set_text leaves cursor at end, move it back)
776        input_box.move_cursor_to_start();
777        assert_eq!(input_box.cursor_position, 0);
778        assert_eq!(input_box.horizontal_scroll, 0);
779
780        // Move cursor right within visible area
781        for _ in 0..5 {
782            input_box.move_cursor_right();
783        }
784        input_box.update_scroll(10);
785        // Should not scroll (column 5 is visible in [0, 10])
786        assert_eq!(input_box.cursor_position, 5);
787        assert_eq!(input_box.horizontal_scroll, 0);
788
789        // Move cursor to exactly at the right edge
790        for _ in 0..4 {
791            input_box.move_cursor_right();
792        }
793        input_box.update_scroll(10);
794        // Still visible (column 9 is in [0, 10])
795        assert_eq!(input_box.cursor_position, 9);
796        assert_eq!(input_box.horizontal_scroll, 0);
797
798        // Move cursor to position that equals view_end
799        input_box.move_cursor_right();
800        input_box.update_scroll(10);
801        // Cursor at right edge (column 10 == view_end) - should be visible without scroll
802        assert_eq!(input_box.cursor_position, 10);
803        assert_eq!(input_box.horizontal_scroll, 0);
804
805        // Move cursor one more past the right edge
806        input_box.move_cursor_right();
807        input_box.update_scroll(10);
808        // Now cursor is not visible (column 11 > view_end 10), should scroll
809        assert_eq!(input_box.cursor_position, 11);
810        assert_eq!(input_box.horizontal_scroll, 1); // 11 - 10 = 1
811    }
812
813    #[test]
814    fn test_smooth_scrolling_with_wide_chars() {
815        let mut input_box = InputBoxState::default();
816        input_box.set_text("こんにちは世界です。"); // 10 chars, 20 columns
817
818        // Cursor at end after set_text (column 20)
819        assert_eq!(input_box.cursor_position, 10);
820        input_box.update_scroll(10);
821        // Cursor at column 20 > view_end 10, need to scroll
822        assert_eq!(input_box.horizontal_scroll, 10); // 20 - 10 = 10
823
824        // Move cursor left (still visible with current scroll)
825        for _ in 0..3 {
826            input_box.move_cursor_left();
827        }
828        input_box.update_scroll(10);
829        // Cursor at position 7, column 14, view is [10, 20], still visible
830        assert_eq!(input_box.cursor_position, 7);
831        assert_eq!(input_box.horizontal_scroll, 10);
832
833        // Move cursor further left past visible area
834        for _ in 0..3 {
835            input_box.move_cursor_left();
836        }
837        input_box.update_scroll(10);
838        // Cursor at position 4, column 8 < scroll 10, so scroll to 8
839        assert_eq!(input_box.cursor_position, 4);
840        assert_eq!(input_box.horizontal_scroll, 8);
841    }
842
843    #[test]
844    fn test_smooth_scrolling_edge_cases() {
845        let mut input_box = InputBoxState::default();
846
847        // Empty text
848        input_box.set_text("");
849        input_box.update_scroll(10);
850        assert_eq!(input_box.horizontal_scroll, 0);
851
852        // Text shorter than view
853        input_box.set_text("Hi");
854        input_box.cursor_position = 2;
855        input_box.update_scroll(10);
856        assert_eq!(input_box.horizontal_scroll, 0);
857
858        // Clear should reset scroll
859        input_box.set_text("Long text here");
860        input_box.cursor_position = 14;
861        input_box.update_scroll(5);
862        assert!(input_box.horizontal_scroll > 0);
863        input_box.clear();
864        assert_eq!(input_box.horizontal_scroll, 0);
865    }
866
867    #[test]
868    fn test_mouse_click_no_scroll() {
869        let mut input_box = InputBoxState::default();
870        input_box.set_text("Hello");
871
872        // Click at position 2 (on 'l')
873        let mouse_event = MouseEvent {
874            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
875            column: 3, // area.x=1, border=1, so mouse_x = 3-1-1 = 1, but we want position 2
876            row: 1,
877            modifiers: crossterm::event::KeyModifiers::empty(),
878        };
879        let area = Rect::new(1, 1, 10, 1);
880        input_box.handle_mouse_event(mouse_event, area);
881        assert_eq!(input_box.cursor_position, 1);
882    }
883
884    #[test]
885    fn test_mouse_click_with_scroll_ascii() {
886        let mut input_box = InputBoxState::default();
887        input_box.set_text("Hello, World!");
888
889        // Move cursor to end using cursor movement
890        for _ in 0..13 {
891            input_box.move_cursor_right();
892        }
893        input_box.update_scroll(10);
894
895        // View width is 10, cursor at column 13, so scroll = 13 - 10 = 3
896        assert_eq!(input_box.horizontal_scroll, 3);
897
898        // Click at mouse position 5 (relative to content area)
899        // actual_column = 5 + 3 = 8, which should be position 8
900        let mouse_event = MouseEvent {
901            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
902            column: 6, // area.x=0, border=1, mouse_x = 6-0-1 = 5
903            row: 0,
904            modifiers: crossterm::event::KeyModifiers::empty(),
905        };
906        let area = Rect::new(0, 0, 12, 1);
907        input_box.handle_mouse_event(mouse_event, area);
908        assert_eq!(input_box.cursor_position, 8);
909    }
910
911    #[test]
912    fn test_mouse_click_with_scroll_wide_chars() {
913        let mut input_box = InputBoxState::default();
914        input_box.set_text("こんにちは世界"); // 7 chars, 14 columns
915
916        // Move cursor to end using cursor movement
917        for _ in 0..7 {
918            input_box.move_cursor_right();
919        }
920        input_box.update_scroll(10);
921
922        // View width is 10, cursor at column 14, so scroll = 14 - 10 = 4
923        assert_eq!(input_box.horizontal_scroll, 4);
924
925        // Click at mouse position 6 (relative to content area)
926        // actual_column = 6 + 4 = 10
927        // Column 10 corresponds to character index 5 (10/2 = 5)
928        let mouse_event = MouseEvent {
929            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
930            column: 7, // area.x=0, border=1, mouse_x = 7-0-1 = 6
931            row: 0,
932            modifiers: crossterm::event::KeyModifiers::empty(),
933        };
934        let area = Rect::new(0, 0, 12, 1);
935        input_box.handle_mouse_event(mouse_event, area);
936        assert_eq!(input_box.cursor_position, 5);
937    }
938
939    #[test]
940    fn test_mouse_click_beyond_text_end() {
941        let mut input_box = InputBoxState::default();
942        input_box.set_text("Hi");
943
944        // Click far beyond the text
945        let mouse_event = MouseEvent {
946            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
947            column: 20,
948            row: 0,
949            modifiers: crossterm::event::KeyModifiers::empty(),
950        };
951        let area = Rect::new(0, 0, 30, 1);
952        input_box.handle_mouse_event(mouse_event, area);
953        assert_eq!(input_box.cursor_position, 2); // Should clamp to text length
954    }
955
956    #[test]
957    fn test_mouse_click_on_wide_char_boundary() {
958        let mut input_box = InputBoxState::default();
959        input_box.set_text("aこb");
960
961        // Click on column 2 (middle of 'こ' which spans columns 1-2)
962        // Should snap to character index 1
963        let mouse_event = MouseEvent {
964            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
965            column: 3, // area.x=0, border=1, mouse_x = 3-0-1 = 2
966            row: 0,
967            modifiers: crossterm::event::KeyModifiers::empty(),
968        };
969        let area = Rect::new(0, 0, 10, 1);
970        input_box.handle_mouse_event(mouse_event, area);
971        assert_eq!(input_box.cursor_position, 1);
972    }
973}
974
975mod util {
976    /// A helper struct that maintains a prefix sum array of usize values.
977    ///
978    /// This is used to efficiently calculate the (column) width of substrings in the input box.
979    ///
980    /// This implementation optimizes the most common operations (reading and adding/removing from the end) to O(1), at the cost of
981    /// making insertions and deletions anywhere else take O(n).
982    #[derive(Debug)]
983    pub struct PrefixSumVec {
984        data: Vec<usize>,
985    }
986    impl PrefixSumVec {
987        pub fn new() -> Self {
988            Self { data: vec![0] }
989        }
990
991        pub const fn last(&self) -> usize {
992            if let [.., last] = self.data.as_slice() {
993                *last
994            } else {
995                unreachable!() // there is always at least one element (0)
996            }
997        }
998
999        pub fn push(&mut self, value: usize) {
1000            let last = self.last();
1001            self.data.push(last + value);
1002        }
1003
1004        pub fn insert(&mut self, index: usize, value: usize) {
1005            // adjust index to account for the leading zero
1006            let index = index + 1;
1007
1008            // if trying to insert at the end, just push
1009            if index >= self.data.len() {
1010                self.push(value);
1011                return;
1012            }
1013
1014            // adjust all subsequent values, then push to the end.
1015            // idea is to "add" the value at the index and adjust everything after it in place,
1016            // without needing to actually perform a full insertion shift
1017            let mut prev = self.data[index - 1];
1018            for i in index..self.data.len() {
1019                let current = self.data[i];
1020                self.data[i] = prev + value;
1021                prev = current;
1022            }
1023            self.data.push(prev + value);
1024        }
1025
1026        pub fn remove(&mut self, index: usize) {
1027            if self.data.len() <= 1 {
1028                // nothing to remove
1029                return;
1030            }
1031
1032            // adjust index to account for the leading zero
1033            let index = index + 1;
1034
1035            // if trying to remove at the end, just pop instead
1036            if index >= self.data.len() {
1037                self.data.pop();
1038                return;
1039            }
1040
1041            // adjust all subsequent values, then pop
1042            for i in index..self.data.len() - 1 {
1043                let prev = self.data[i - 1];
1044                let next = self.data[i + 1];
1045                self.data[i] = prev + (next - self.data[i]);
1046            }
1047            self.data.pop();
1048        }
1049
1050        pub const fn get(&self, index: usize) -> usize {
1051            self.data.as_slice()[index]
1052        }
1053
1054        pub fn clear(&mut self) {
1055            self.data.clear();
1056            self.data.push(0);
1057        }
1058    }
1059    impl Default for PrefixSumVec {
1060        fn default() -> Self {
1061            Self::new()
1062        }
1063    }
1064    #[cfg(test)]
1065    mod tests {
1066        use super::PrefixSumVec;
1067        use pretty_assertions::assert_eq;
1068
1069        #[test]
1070        fn test_prefix_sum_vec_basic_operations() {
1071            let mut psv = PrefixSumVec::new();
1072            assert_eq!(psv.last(), 0);
1073            assert_eq!(psv.data, vec![0]);
1074            assert_eq!(psv.last(), 0);
1075            psv.remove(0); // removing from empty should do nothing
1076            assert_eq!(psv.data, vec![0]);
1077            assert_eq!(psv.last(), 0);
1078            psv.push(3);
1079            assert_eq!(psv.data, vec![0, 3]);
1080            assert_eq!(psv.last(), 3);
1081            psv.push(5);
1082            assert_eq!(psv.data, vec![0, 3, 8]);
1083            assert_eq!(psv.last(), 8);
1084            psv.insert(1, 2); // insert 2 at index 1
1085            assert_eq!(psv.data, vec![0, 3, 5, 10]);
1086            assert_eq!(psv.last(), 10);
1087            psv.insert(0, 7); // insert 7 at index 0
1088            assert_eq!(psv.data, vec![0, 7, 10, 12, 17]);
1089            assert_eq!(psv.last(), 17);
1090            psv.remove(2); // remove value at index 2
1091            assert_eq!(psv.data, vec![0, 7, 10, 15]);
1092            assert_eq!(psv.last(), 15);
1093            psv.remove(0); // remove value at index 0
1094            assert_eq!(psv.data, vec![0, 3, 8]);
1095            psv.remove(1); // remove the last element
1096            assert_eq!(psv.data, vec![0, 3]);
1097            assert_eq!(psv.get(0), 0);
1098            assert_eq!(psv.get(1), 3);
1099            psv.insert(1, 4); // insert to the end
1100            assert_eq!(psv.data, vec![0, 3, 7]);
1101            psv.clear();
1102            assert_eq!(psv.data, vec![0]);
1103        }
1104    }
1105}