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    /// Codepoint count to the left of the cursor. Test-only: callers must use
120    /// [`cursor_display_col`](Self::cursor_display_col) for caret placement,
121    /// since codepoint count differs from display width for CJK / emoji.
122    #[cfg(test)]
123    pub(crate) fn cursor_char_offset(&self) -> usize {
124        self.value[..self.cursor].chars().count()
125    }
126
127    /// Display column to the left of the cursor — accounts for wide (CJK,
128    /// emoji) characters via `unicode-width`. Use this for caret placement.
129    pub fn cursor_display_col(&self) -> usize {
130        self.value[..self.cursor].width()
131    }
132
133    /// Total display width of the value — accounts for wide characters.
134    pub fn display_width(&self) -> usize {
135        self.value.width()
136    }
137
138    pub fn handle_key(&mut self, key: &KeyEvent) -> InputOutcome {
139        match (key.modifiers, key.code) {
140            (_, KeyCode::Enter) => InputOutcome::Submit,
141            (_, KeyCode::Esc) => InputOutcome::Cancel,
142            (_, KeyCode::Backspace) => {
143                if self.cursor == 0 {
144                    return InputOutcome::Consumed;
145                }
146                let prev = prev_char_boundary(&self.value, self.cursor);
147                self.value.drain(prev..self.cursor);
148                self.cursor = prev;
149                InputOutcome::Changed
150            }
151            (_, KeyCode::Delete) => {
152                if self.cursor >= self.value.len() {
153                    return InputOutcome::Consumed;
154                }
155                let next = next_char_boundary(&self.value, self.cursor);
156                self.value.drain(self.cursor..next);
157                InputOutcome::Changed
158            }
159            (_, KeyCode::Left) => {
160                if self.cursor == 0 {
161                    return InputOutcome::Consumed;
162                }
163                self.cursor = prev_char_boundary(&self.value, self.cursor);
164                InputOutcome::Consumed
165            }
166            (_, KeyCode::Right) => {
167                if self.cursor >= self.value.len() {
168                    return InputOutcome::Consumed;
169                }
170                self.cursor = next_char_boundary(&self.value, self.cursor);
171                InputOutcome::Consumed
172            }
173            (_, KeyCode::Home) => {
174                self.cursor = 0;
175                InputOutcome::Consumed
176            }
177            (_, KeyCode::End) => {
178                self.cursor = self.value.len();
179                InputOutcome::Consumed
180            }
181            // Accept only plain or Shift-modified chars; Ctrl/Alt combos are
182            // not text input and must bubble up to the caller for shortcuts.
183            (m, KeyCode::Char(c)) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
184                self.value.insert(self.cursor, c);
185                self.cursor += c.len_utf8();
186                InputOutcome::Changed
187            }
188            _ => InputOutcome::NotConsumed,
189        }
190    }
191
192    /// Render the value text at `rect` using `style`. Caller is responsible for
193    /// any surrounding chrome (borders, prompt prefix, validation glyphs).
194    /// Place the terminal cursor when `focused`. `value_offset_x` is the
195    /// display-column offset within `rect` where the value text starts (e.g.
196    /// when the caller renders a "Find: " prefix separately, pass its
197    /// display width via `UnicodeWidthStr::width`).
198    pub fn render(
199        &mut self,
200        f: &mut Frame,
201        rect: Rect,
202        style: Style,
203        value_offset_x: u16,
204        focused: bool,
205    ) {
206        let inner = Rect {
207            x: rect.x.saturating_add(value_offset_x),
208            width: rect.width.saturating_sub(value_offset_x),
209            ..rect
210        };
211        let scroll_x = self.scroll_into_view(inner.width);
212        f.render_widget(
213            Paragraph::new(self.value.as_str())
214                .style(style)
215                .scroll((0, scroll_x)),
216            inner,
217        );
218        self.place_caret(f, inner, focused);
219    }
220
221    /// Like [`Self::render`], but with a pre-styled line (e.g. the query
222    /// highlighter's output). The line must render the same text as the
223    /// input's value, so caret math stays valid.
224    pub fn render_line(
225        &mut self,
226        f: &mut Frame,
227        rect: Rect,
228        line: ratatui::text::Line<'static>,
229        base_style: Style,
230        value_offset_x: u16,
231        focused: bool,
232    ) {
233        let inner = Rect {
234            x: rect.x.saturating_add(value_offset_x),
235            width: rect.width.saturating_sub(value_offset_x),
236            ..rect
237        };
238        let scroll_x = self.scroll_into_view(inner.width);
239        f.render_widget(
240            Paragraph::new(line).style(base_style).scroll((0, scroll_x)),
241            inner,
242        );
243        self.place_caret(f, inner, focused);
244    }
245
246    /// Adjust the horizontal scroll so the caret falls inside a window of
247    /// `width` display columns, and return the resulting offset. Scrolls only
248    /// when the caret crosses a window edge, so cursor movement inside the
249    /// window leaves the text in place.
250    fn scroll_into_view(&mut self, width: u16) -> u16 {
251        if width == 0 {
252            self.scroll_x = 0;
253            return 0;
254        }
255        // The caret can sit one column past the last char, so the maximum
256        // useful offset keeps that extra cell — not just the last char —
257        // inside the window. Also clamps stale offsets after the value shrank.
258        let total = u16::try_from(self.display_width()).unwrap_or(u16::MAX);
259        let max_scroll = total.saturating_add(1).saturating_sub(width);
260        self.scroll_x = self.scroll_x.min(max_scroll);
261        let cursor_col = u16::try_from(self.cursor_display_col()).unwrap_or(u16::MAX);
262        if cursor_col < self.scroll_x {
263            self.scroll_x = cursor_col;
264        } else if cursor_col >= self.scroll_x.saturating_add(width) {
265            self.scroll_x = cursor_col - (width - 1);
266        }
267        self.scroll_x
268    }
269
270    /// Shared caret placement for both render paths.
271    fn place_caret(&mut self, f: &mut Frame, inner: Rect, focused: bool) {
272        self.last_caret_pos = None;
273        if focused {
274            let caret_x = inner
275                .x
276                .saturating_add((self.cursor_display_col() as u16).saturating_sub(self.scroll_x))
277                .min(inner.x + inner.width.saturating_sub(1));
278            f.set_cursor_position(Position {
279                x: caret_x,
280                y: inner.y,
281            });
282            self.last_caret_pos = Some((caret_x, inner.y));
283        }
284    }
285}
286
287fn prev_char_boundary(s: &str, from: usize) -> usize {
288    s[..from]
289        .char_indices()
290        .next_back()
291        .map(|(i, _)| i)
292        .unwrap_or(0)
293}
294
295fn next_char_boundary(s: &str, from: usize) -> usize {
296    s[from..]
297        .char_indices()
298        .nth(1)
299        .map(|(i, _)| from + i)
300        .unwrap_or(s.len())
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    fn k(code: KeyCode) -> KeyEvent {
308        KeyEvent::new(code, KeyModifiers::NONE)
309    }
310
311    #[test]
312    fn new_is_empty_cursor_zero() {
313        let i = SingleLineInput::new();
314        assert!(i.is_empty());
315        assert_eq!(i.cursor_char_offset(), 0);
316    }
317
318    #[test]
319    fn with_value_places_cursor_at_end() {
320        let i = SingleLineInput::with_value("hello");
321        assert_eq!(i.value(), "hello");
322        assert_eq!(i.cursor_char_offset(), 5);
323    }
324
325    #[test]
326    fn typing_chars_appends_and_advances_cursor() {
327        let mut i = SingleLineInput::new();
328        assert_eq!(i.handle_key(&k(KeyCode::Char('a'))), InputOutcome::Changed);
329        assert_eq!(i.handle_key(&k(KeyCode::Char('b'))), InputOutcome::Changed);
330        assert_eq!(i.value(), "ab");
331        assert_eq!(i.cursor_char_offset(), 2);
332    }
333
334    #[test]
335    fn left_then_insert_inserts_mid_string() {
336        let mut i = SingleLineInput::with_value("ac");
337        i.handle_key(&k(KeyCode::Left));
338        assert_eq!(i.cursor_char_offset(), 1);
339        i.handle_key(&k(KeyCode::Char('b')));
340        assert_eq!(i.value(), "abc");
341        assert_eq!(i.cursor_char_offset(), 2);
342    }
343
344    #[test]
345    fn backspace_at_start_is_noop() {
346        let mut i = SingleLineInput::with_value("abc");
347        i.handle_key(&k(KeyCode::Home));
348        assert_eq!(i.handle_key(&k(KeyCode::Backspace)), InputOutcome::Consumed);
349        assert_eq!(i.value(), "abc");
350    }
351
352    #[test]
353    fn delete_at_end_is_noop() {
354        let mut i = SingleLineInput::with_value("abc");
355        assert_eq!(i.handle_key(&k(KeyCode::Delete)), InputOutcome::Consumed);
356        assert_eq!(i.value(), "abc");
357    }
358
359    #[test]
360    fn home_end_jump_cursor() {
361        let mut i = SingleLineInput::with_value("abc");
362        i.handle_key(&k(KeyCode::Home));
363        assert_eq!(i.cursor_char_offset(), 0);
364        i.handle_key(&k(KeyCode::End));
365        assert_eq!(i.cursor_char_offset(), 3);
366    }
367
368    #[test]
369    fn unicode_chars_count_by_codepoint_not_bytes() {
370        let mut i = SingleLineInput::new();
371        i.handle_key(&k(KeyCode::Char('あ')));
372        i.handle_key(&k(KeyCode::Char('い')));
373        assert_eq!(i.value(), "あい");
374        assert_eq!(i.cursor_char_offset(), 2);
375        i.handle_key(&k(KeyCode::Left));
376        assert_eq!(i.cursor_char_offset(), 1);
377        i.handle_key(&k(KeyCode::Backspace));
378        assert_eq!(i.value(), "い");
379    }
380
381    #[test]
382    fn enter_returns_submit_esc_returns_cancel() {
383        let mut i = SingleLineInput::with_value("x");
384        assert_eq!(i.handle_key(&k(KeyCode::Enter)), InputOutcome::Submit);
385        assert_eq!(i.handle_key(&k(KeyCode::Esc)), InputOutcome::Cancel);
386    }
387
388    #[test]
389    fn ctrl_char_is_not_consumed_as_text() {
390        let mut i = SingleLineInput::new();
391        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL);
392        assert_eq!(i.handle_key(&key), InputOutcome::NotConsumed);
393        assert!(i.is_empty());
394    }
395
396    #[test]
397    fn alt_char_is_not_consumed_as_text() {
398        let mut i = SingleLineInput::new();
399        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT);
400        assert_eq!(i.handle_key(&key), InputOutcome::NotConsumed);
401        assert!(i.is_empty());
402    }
403
404    #[test]
405    fn cjk_chars_count_two_display_cols_per_char() {
406        let mut i = SingleLineInput::new();
407        i.handle_key(&k(KeyCode::Char('あ')));
408        i.handle_key(&k(KeyCode::Char('い')));
409        // 2 codepoints, but each is 2 cells wide.
410        assert_eq!(i.cursor_char_offset(), 2);
411        assert_eq!(i.cursor_display_col(), 4);
412        assert_eq!(i.display_width(), 4);
413    }
414
415    #[test]
416    fn mixed_ascii_and_cjk_caret_column() {
417        let mut i = SingleLineInput::with_value("ab猫");
418        // Caret at end of "ab猫" → 1+1+2 display cols.
419        assert_eq!(i.cursor_display_col(), 4);
420        i.handle_key(&k(KeyCode::Left));
421        // Caret moved before 猫 → 2 cells.
422        assert_eq!(i.cursor_display_col(), 2);
423    }
424
425    #[test]
426    fn shift_char_inserts() {
427        let mut i = SingleLineInput::new();
428        let key = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);
429        assert_eq!(i.handle_key(&key), InputOutcome::Changed);
430        assert_eq!(i.value(), "A");
431    }
432
433    #[test]
434    fn set_value_resets_cursor_to_end() {
435        let mut i = SingleLineInput::with_value("abc");
436        i.handle_key(&k(KeyCode::Home));
437        i.set_value("xyz!");
438        assert_eq!(i.value(), "xyz!");
439        assert_eq!(i.cursor_char_offset(), 4);
440    }
441
442    #[test]
443    fn clear_resets_both() {
444        let mut i = SingleLineInput::with_value("abc");
445        i.clear();
446        assert!(i.is_empty());
447        assert_eq!(i.cursor_char_offset(), 0);
448    }
449
450    mod rendering {
451        use super::*;
452        use ratatui::Terminal;
453        use ratatui::backend::TestBackend;
454
455        fn draw(
456            i: &mut SingleLineInput,
457            width: u16,
458        ) -> (Terminal<TestBackend>, Option<(u16, u16)>) {
459            let mut terminal = Terminal::new(TestBackend::new(width, 1)).unwrap();
460            terminal
461                .draw(|f| {
462                    i.render(f, Rect::new(0, 0, width, 1), Style::default(), 0, true);
463                })
464                .unwrap();
465            let caret = i.last_caret_pos();
466            (terminal, caret)
467        }
468
469        fn row(terminal: &Terminal<TestBackend>, width: u16) -> String {
470            let buf = terminal.backend().buffer();
471            (0..width).map(|x| buf[(x, 0)].symbol()).collect()
472        }
473
474        #[test]
475        fn short_value_renders_from_start() {
476            let mut i = SingleLineInput::with_value("abc");
477            let (t, caret) = draw(&mut i, 10);
478            assert_eq!(row(&t, 10), "abc       ");
479            assert_eq!(caret, Some((3, 0)));
480        }
481
482        #[test]
483        fn long_value_scrolls_to_keep_caret_visible() {
484            // 15 chars in a 10-wide rect, cursor at end: scroll 6 cols so the
485            // caret cell after the last char is the rightmost column.
486            let mut i = SingleLineInput::with_value("abcdefghijklmno");
487            let (t, caret) = draw(&mut i, 10);
488            assert_eq!(row(&t, 10), "ghijklmno ");
489            assert_eq!(caret, Some((9, 0)));
490        }
491
492        #[test]
493        fn cursor_moves_inside_window_without_scrolling() {
494            let mut i = SingleLineInput::with_value("abcdefghijklmno");
495            draw(&mut i, 10); // establishes scroll = 6
496            for _ in 0..3 {
497                i.handle_key(&k(KeyCode::Left));
498            }
499            // Cursor col 12 still inside [6, 16) — window must not move.
500            let (t, caret) = draw(&mut i, 10);
501            assert_eq!(row(&t, 10), "ghijklmno ");
502            assert_eq!(caret, Some((6, 0)));
503        }
504
505        #[test]
506        fn cursor_past_left_edge_scrolls_back() {
507            let mut i = SingleLineInput::with_value("abcdefghijklmno");
508            draw(&mut i, 10); // scroll = 6
509            i.handle_key(&k(KeyCode::Home));
510            let (t, caret) = draw(&mut i, 10);
511            assert_eq!(row(&t, 10), "abcdefghij");
512            assert_eq!(caret, Some((0, 0)));
513        }
514
515        #[test]
516        fn shrinking_value_clamps_scroll() {
517            let mut i = SingleLineInput::with_value("abcdefghijklmno");
518            draw(&mut i, 10); // scroll = 6
519            i.set_value("abc");
520            let (t, caret) = draw(&mut i, 10);
521            assert_eq!(row(&t, 10), "abc       ");
522            assert_eq!(caret, Some((3, 0)));
523        }
524    }
525}