Skip to main content

kimun_notes/components/
single_line_input.rs

1//! Reusable single-line text input.
2//!
3//! Used by the editor find bar, dialogs (rename, move, quick-note), the sidebar
4//! / note-browser search boxes, and the settings workspace name field. The
5//! widget owns its value and char cursor; callers add titles, hints, validation
6//! visuals, and submit/cancel semantics on top.
7//!
8//! `handle_key` returns [`InputOutcome`] so callers can branch on Submit /
9//! Cancel / textual mutation without re-matching the raw key.
10
11use ratatui::Frame;
12use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
13use ratatui::layout::{Position, Rect};
14use ratatui::style::Style;
15use ratatui::widgets::Paragraph;
16use unicode_width::UnicodeWidthStr;
17
18/// Outcome of [`SingleLineInput::handle_key`] — lets callers branch without
19/// re-parsing the key event.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum InputOutcome {
22    /// Key was consumed but the value did not change (cursor move, no-op).
23    Consumed,
24    /// Value (and possibly cursor) changed.
25    Changed,
26    /// User pressed Enter.
27    Submit,
28    /// User pressed Esc.
29    Cancel,
30    /// Key was not recognised by the widget.
31    NotConsumed,
32}
33
34#[derive(Default)]
35pub struct SingleLineInput {
36    value: String,
37    /// Byte offset into `value`.
38    cursor: usize,
39    /// Caret screen position (col, row), cached after the most recent
40    /// `render` call. Used by overlays anchored on the caret (e.g. the
41    /// hashtag autocomplete popup). `None` until the first render.
42    last_caret_pos: Option<(u16, u16)>,
43    /// Horizontal scroll offset in display columns, updated on render so the
44    /// caret stays inside the visible window when the value overflows the
45    /// rect. Kept across renders so the cursor can move within the window
46    /// without the text shifting under it.
47    scroll_x: u16,
48}
49
50impl SingleLineInput {
51    pub fn new() -> Self {
52        Self::default()
53    }
54
55    pub fn with_value(value: impl Into<String>) -> Self {
56        let value = value.into();
57        let cursor = value.len();
58        Self {
59            value,
60            cursor,
61            last_caret_pos: None,
62            scroll_x: 0,
63        }
64    }
65
66    pub fn value(&self) -> &str {
67        &self.value
68    }
69
70    pub fn is_empty(&self) -> bool {
71        self.value.is_empty()
72    }
73
74    /// Current cursor position as a byte offset.
75    pub fn cursor_byte(&self) -> usize {
76        self.cursor
77    }
78
79    /// Caret screen position from the last `render` call, or `None` if
80    /// the widget has not been rendered yet (or was rendered unfocused).
81    pub fn last_caret_pos(&self) -> Option<(u16, u16)> {
82        self.last_caret_pos
83    }
84
85    /// Overwrite a byte `range` of the value with `new_text`, then place
86    /// the cursor at byte offset `new_cursor_byte` in the updated value.
87    /// Used by the hashtag autocomplete to apply an `AcceptAction`. All
88    /// three positions must be on char boundaries; the controller
89    /// computes them off `value` so this holds in practice — checked
90    /// via debug_assert.
91    pub fn replace_range_bytes(
92        &mut self,
93        range: std::ops::Range<usize>,
94        new_text: &str,
95        new_cursor_byte: usize,
96    ) {
97        debug_assert!(self.value.is_char_boundary(range.start));
98        debug_assert!(self.value.is_char_boundary(range.end));
99        self.value.replace_range(range, new_text);
100        let clamped = new_cursor_byte.min(self.value.len());
101        debug_assert!(
102            self.value.is_char_boundary(clamped),
103            "new_cursor_byte must land on a char boundary"
104        );
105        self.cursor = clamped;
106    }
107
108    /// Replace the value; cursor jumps to end.
109    pub fn set_value(&mut self, value: impl Into<String>) {
110        self.value = value.into();
111        self.cursor = self.value.len();
112    }
113
114    pub fn clear(&mut self) {
115        self.value.clear();
116        self.cursor = 0;
117    }
118
119    /// Take the current value, leaving the input empty — a submit path that
120    /// consumes the text without a separate `clear()` call.
121    pub fn take_text(&mut self) -> String {
122        self.cursor = 0;
123        std::mem::take(&mut self.value)
124    }
125
126    /// Codepoint count to the left of the cursor. Test-only: callers must use
127    /// [`cursor_display_col`](Self::cursor_display_col) for caret placement,
128    /// since codepoint count differs from display width for CJK / emoji.
129    #[cfg(test)]
130    pub(crate) fn cursor_char_offset(&self) -> usize {
131        self.value[..self.cursor].chars().count()
132    }
133
134    /// Display column to the left of the cursor — accounts for wide (CJK,
135    /// emoji) characters via `unicode-width`. Use this for caret placement.
136    pub fn cursor_display_col(&self) -> usize {
137        self.value[..self.cursor].width()
138    }
139
140    /// Total display width of the value — accounts for wide characters.
141    pub fn display_width(&self) -> usize {
142        self.value.width()
143    }
144
145    pub fn handle_key(&mut self, key: &KeyEvent) -> InputOutcome {
146        match (key.modifiers, key.code) {
147            (_, KeyCode::Enter) => InputOutcome::Submit,
148            (_, KeyCode::Esc) => InputOutcome::Cancel,
149            (_, KeyCode::Backspace) => {
150                if self.cursor == 0 {
151                    return InputOutcome::Consumed;
152                }
153                let prev = prev_char_boundary(&self.value, self.cursor);
154                self.value.drain(prev..self.cursor);
155                self.cursor = prev;
156                InputOutcome::Changed
157            }
158            (_, KeyCode::Delete) => {
159                if self.cursor >= self.value.len() {
160                    return InputOutcome::Consumed;
161                }
162                let next = next_char_boundary(&self.value, self.cursor);
163                self.value.drain(self.cursor..next);
164                InputOutcome::Changed
165            }
166            (_, KeyCode::Left) => {
167                if self.cursor == 0 {
168                    return InputOutcome::Consumed;
169                }
170                self.cursor = prev_char_boundary(&self.value, self.cursor);
171                InputOutcome::Consumed
172            }
173            (_, KeyCode::Right) => {
174                if self.cursor >= self.value.len() {
175                    return InputOutcome::Consumed;
176                }
177                self.cursor = next_char_boundary(&self.value, self.cursor);
178                InputOutcome::Consumed
179            }
180            (_, KeyCode::Home) => {
181                self.cursor = 0;
182                InputOutcome::Consumed
183            }
184            (_, KeyCode::End) => {
185                self.cursor = self.value.len();
186                InputOutcome::Consumed
187            }
188            // Accept only plain or Shift-modified chars; Ctrl/Alt combos are
189            // not text input and must bubble up to the caller for shortcuts.
190            (m, KeyCode::Char(c)) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
191                self.value.insert(self.cursor, c);
192                self.cursor += c.len_utf8();
193                InputOutcome::Changed
194            }
195            _ => InputOutcome::NotConsumed,
196        }
197    }
198
199    /// Render the value text at `rect` using `style`. Caller is responsible for
200    /// any surrounding chrome (borders, prompt prefix, validation glyphs).
201    /// Place the terminal cursor when `focused`. `value_offset_x` is the
202    /// display-column offset within `rect` where the value text starts (e.g.
203    /// when the caller renders a "Find: " prefix separately, pass its
204    /// display width via `UnicodeWidthStr::width`).
205    pub fn render(
206        &mut self,
207        f: &mut Frame,
208        rect: Rect,
209        style: Style,
210        value_offset_x: u16,
211        focused: bool,
212    ) {
213        let inner = Rect {
214            x: rect.x.saturating_add(value_offset_x),
215            width: rect.width.saturating_sub(value_offset_x),
216            ..rect
217        };
218        let scroll_x = self.scroll_into_view(inner.width);
219        f.render_widget(
220            Paragraph::new(self.value.as_str())
221                .style(style)
222                .scroll((0, scroll_x)),
223            inner,
224        );
225        self.place_caret(f, inner, focused);
226    }
227
228    /// Like [`Self::render`], but with a pre-styled line (e.g. the query
229    /// highlighter's output). The line must render the same text as the
230    /// input's value, so caret math stays valid.
231    pub fn render_line(
232        &mut self,
233        f: &mut Frame,
234        rect: Rect,
235        line: ratatui::text::Line<'static>,
236        base_style: Style,
237        value_offset_x: u16,
238        focused: bool,
239    ) {
240        let inner = Rect {
241            x: rect.x.saturating_add(value_offset_x),
242            width: rect.width.saturating_sub(value_offset_x),
243            ..rect
244        };
245        let scroll_x = self.scroll_into_view(inner.width);
246        f.render_widget(
247            Paragraph::new(line).style(base_style).scroll((0, scroll_x)),
248            inner,
249        );
250        self.place_caret(f, inner, focused);
251    }
252
253    /// Adjust the horizontal scroll so the caret falls inside a window of
254    /// `width` display columns, and return the resulting offset. Scrolls only
255    /// when the caret crosses a window edge, so cursor movement inside the
256    /// window leaves the text in place.
257    fn scroll_into_view(&mut self, width: u16) -> u16 {
258        if width == 0 {
259            self.scroll_x = 0;
260            return 0;
261        }
262        // The caret can sit one column past the last char, so the maximum
263        // useful offset keeps that extra cell — not just the last char —
264        // inside the window. Also clamps stale offsets after the value shrank.
265        let total = u16::try_from(self.display_width()).unwrap_or(u16::MAX);
266        let max_scroll = total.saturating_add(1).saturating_sub(width);
267        self.scroll_x = self.scroll_x.min(max_scroll);
268        let cursor_col = u16::try_from(self.cursor_display_col()).unwrap_or(u16::MAX);
269        if cursor_col < self.scroll_x {
270            self.scroll_x = cursor_col;
271        } else if cursor_col >= self.scroll_x.saturating_add(width) {
272            self.scroll_x = cursor_col - (width - 1);
273        }
274        self.scroll_x
275    }
276
277    /// Shared caret placement for both render paths.
278    fn place_caret(&mut self, f: &mut Frame, inner: Rect, focused: bool) {
279        self.last_caret_pos = None;
280        if focused {
281            let caret_x = inner
282                .x
283                .saturating_add((self.cursor_display_col() as u16).saturating_sub(self.scroll_x))
284                .min(inner.x + inner.width.saturating_sub(1));
285            f.set_cursor_position(Position {
286                x: caret_x,
287                y: inner.y,
288            });
289            self.last_caret_pos = Some((caret_x, inner.y));
290        }
291    }
292}
293
294fn prev_char_boundary(s: &str, from: usize) -> usize {
295    s[..from]
296        .char_indices()
297        .next_back()
298        .map(|(i, _)| i)
299        .unwrap_or(0)
300}
301
302fn next_char_boundary(s: &str, from: usize) -> usize {
303    s[from..]
304        .char_indices()
305        .nth(1)
306        .map(|(i, _)| from + i)
307        .unwrap_or(s.len())
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    fn k(code: KeyCode) -> KeyEvent {
315        KeyEvent::new(code, KeyModifiers::NONE)
316    }
317
318    #[test]
319    fn new_is_empty_cursor_zero() {
320        let i = SingleLineInput::new();
321        assert!(i.is_empty());
322        assert_eq!(i.cursor_char_offset(), 0);
323    }
324
325    #[test]
326    fn with_value_places_cursor_at_end() {
327        let i = SingleLineInput::with_value("hello");
328        assert_eq!(i.value(), "hello");
329        assert_eq!(i.cursor_char_offset(), 5);
330    }
331
332    #[test]
333    fn typing_chars_appends_and_advances_cursor() {
334        let mut i = SingleLineInput::new();
335        assert_eq!(i.handle_key(&k(KeyCode::Char('a'))), InputOutcome::Changed);
336        assert_eq!(i.handle_key(&k(KeyCode::Char('b'))), InputOutcome::Changed);
337        assert_eq!(i.value(), "ab");
338        assert_eq!(i.cursor_char_offset(), 2);
339    }
340
341    #[test]
342    fn left_then_insert_inserts_mid_string() {
343        let mut i = SingleLineInput::with_value("ac");
344        i.handle_key(&k(KeyCode::Left));
345        assert_eq!(i.cursor_char_offset(), 1);
346        i.handle_key(&k(KeyCode::Char('b')));
347        assert_eq!(i.value(), "abc");
348        assert_eq!(i.cursor_char_offset(), 2);
349    }
350
351    #[test]
352    fn backspace_at_start_is_noop() {
353        let mut i = SingleLineInput::with_value("abc");
354        i.handle_key(&k(KeyCode::Home));
355        assert_eq!(i.handle_key(&k(KeyCode::Backspace)), InputOutcome::Consumed);
356        assert_eq!(i.value(), "abc");
357    }
358
359    #[test]
360    fn delete_at_end_is_noop() {
361        let mut i = SingleLineInput::with_value("abc");
362        assert_eq!(i.handle_key(&k(KeyCode::Delete)), InputOutcome::Consumed);
363        assert_eq!(i.value(), "abc");
364    }
365
366    #[test]
367    fn home_end_jump_cursor() {
368        let mut i = SingleLineInput::with_value("abc");
369        i.handle_key(&k(KeyCode::Home));
370        assert_eq!(i.cursor_char_offset(), 0);
371        i.handle_key(&k(KeyCode::End));
372        assert_eq!(i.cursor_char_offset(), 3);
373    }
374
375    #[test]
376    fn unicode_chars_count_by_codepoint_not_bytes() {
377        let mut i = SingleLineInput::new();
378        i.handle_key(&k(KeyCode::Char('あ')));
379        i.handle_key(&k(KeyCode::Char('い')));
380        assert_eq!(i.value(), "あい");
381        assert_eq!(i.cursor_char_offset(), 2);
382        i.handle_key(&k(KeyCode::Left));
383        assert_eq!(i.cursor_char_offset(), 1);
384        i.handle_key(&k(KeyCode::Backspace));
385        assert_eq!(i.value(), "い");
386    }
387
388    #[test]
389    fn enter_returns_submit_esc_returns_cancel() {
390        let mut i = SingleLineInput::with_value("x");
391        assert_eq!(i.handle_key(&k(KeyCode::Enter)), InputOutcome::Submit);
392        assert_eq!(i.handle_key(&k(KeyCode::Esc)), InputOutcome::Cancel);
393    }
394
395    #[test]
396    fn ctrl_char_is_not_consumed_as_text() {
397        let mut i = SingleLineInput::new();
398        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL);
399        assert_eq!(i.handle_key(&key), InputOutcome::NotConsumed);
400        assert!(i.is_empty());
401    }
402
403    #[test]
404    fn alt_char_is_not_consumed_as_text() {
405        let mut i = SingleLineInput::new();
406        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT);
407        assert_eq!(i.handle_key(&key), InputOutcome::NotConsumed);
408        assert!(i.is_empty());
409    }
410
411    #[test]
412    fn cjk_chars_count_two_display_cols_per_char() {
413        let mut i = SingleLineInput::new();
414        i.handle_key(&k(KeyCode::Char('あ')));
415        i.handle_key(&k(KeyCode::Char('い')));
416        // 2 codepoints, but each is 2 cells wide.
417        assert_eq!(i.cursor_char_offset(), 2);
418        assert_eq!(i.cursor_display_col(), 4);
419        assert_eq!(i.display_width(), 4);
420    }
421
422    #[test]
423    fn mixed_ascii_and_cjk_caret_column() {
424        let mut i = SingleLineInput::with_value("ab猫");
425        // Caret at end of "ab猫" → 1+1+2 display cols.
426        assert_eq!(i.cursor_display_col(), 4);
427        i.handle_key(&k(KeyCode::Left));
428        // Caret moved before 猫 → 2 cells.
429        assert_eq!(i.cursor_display_col(), 2);
430    }
431
432    #[test]
433    fn shift_char_inserts() {
434        let mut i = SingleLineInput::new();
435        let key = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);
436        assert_eq!(i.handle_key(&key), InputOutcome::Changed);
437        assert_eq!(i.value(), "A");
438    }
439
440    #[test]
441    fn set_value_resets_cursor_to_end() {
442        let mut i = SingleLineInput::with_value("abc");
443        i.handle_key(&k(KeyCode::Home));
444        i.set_value("xyz!");
445        assert_eq!(i.value(), "xyz!");
446        assert_eq!(i.cursor_char_offset(), 4);
447    }
448
449    #[test]
450    fn clear_resets_both() {
451        let mut i = SingleLineInput::with_value("abc");
452        i.clear();
453        assert!(i.is_empty());
454        assert_eq!(i.cursor_char_offset(), 0);
455    }
456
457    #[test]
458    fn take_text_returns_value_and_empties_the_input() {
459        let mut i = SingleLineInput::with_value("abc");
460        assert_eq!(i.take_text(), "abc");
461        assert!(i.is_empty());
462        assert_eq!(i.cursor_char_offset(), 0);
463    }
464
465    mod rendering {
466        use super::*;
467        use ratatui::Terminal;
468        use ratatui::backend::TestBackend;
469
470        fn draw(
471            i: &mut SingleLineInput,
472            width: u16,
473        ) -> (Terminal<TestBackend>, Option<(u16, u16)>) {
474            let mut terminal = Terminal::new(TestBackend::new(width, 1)).unwrap();
475            terminal
476                .draw(|f| {
477                    i.render(f, Rect::new(0, 0, width, 1), Style::default(), 0, true);
478                })
479                .unwrap();
480            let caret = i.last_caret_pos();
481            (terminal, caret)
482        }
483
484        fn row(terminal: &Terminal<TestBackend>, width: u16) -> String {
485            let buf = terminal.backend().buffer();
486            (0..width).map(|x| buf[(x, 0)].symbol()).collect()
487        }
488
489        #[test]
490        fn short_value_renders_from_start() {
491            let mut i = SingleLineInput::with_value("abc");
492            let (t, caret) = draw(&mut i, 10);
493            assert_eq!(row(&t, 10), "abc       ");
494            assert_eq!(caret, Some((3, 0)));
495        }
496
497        #[test]
498        fn long_value_scrolls_to_keep_caret_visible() {
499            // 15 chars in a 10-wide rect, cursor at end: scroll 6 cols so the
500            // caret cell after the last char is the rightmost column.
501            let mut i = SingleLineInput::with_value("abcdefghijklmno");
502            let (t, caret) = draw(&mut i, 10);
503            assert_eq!(row(&t, 10), "ghijklmno ");
504            assert_eq!(caret, Some((9, 0)));
505        }
506
507        #[test]
508        fn cursor_moves_inside_window_without_scrolling() {
509            let mut i = SingleLineInput::with_value("abcdefghijklmno");
510            draw(&mut i, 10); // establishes scroll = 6
511            for _ in 0..3 {
512                i.handle_key(&k(KeyCode::Left));
513            }
514            // Cursor col 12 still inside [6, 16) — window must not move.
515            let (t, caret) = draw(&mut i, 10);
516            assert_eq!(row(&t, 10), "ghijklmno ");
517            assert_eq!(caret, Some((6, 0)));
518        }
519
520        #[test]
521        fn cursor_past_left_edge_scrolls_back() {
522            let mut i = SingleLineInput::with_value("abcdefghijklmno");
523            draw(&mut i, 10); // scroll = 6
524            i.handle_key(&k(KeyCode::Home));
525            let (t, caret) = draw(&mut i, 10);
526            assert_eq!(row(&t, 10), "abcdefghij");
527            assert_eq!(caret, Some((0, 0)));
528        }
529
530        #[test]
531        fn shrinking_value_clamps_scroll() {
532            let mut i = SingleLineInput::with_value("abcdefghijklmno");
533            draw(&mut i, 10); // scroll = 6
534            i.set_value("abc");
535            let (t, caret) = draw(&mut i, 10);
536            assert_eq!(row(&t, 10), "abc       ");
537            assert_eq!(caret, Some((3, 0)));
538        }
539    }
540}