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}
44
45impl SingleLineInput {
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    pub fn with_value(value: impl Into<String>) -> Self {
51        let value = value.into();
52        let cursor = value.len();
53        Self {
54            value,
55            cursor,
56            last_caret_pos: None,
57        }
58    }
59
60    pub fn value(&self) -> &str {
61        &self.value
62    }
63
64    pub fn is_empty(&self) -> bool {
65        self.value.is_empty()
66    }
67
68    /// Current cursor position as a byte offset.
69    pub fn cursor_byte(&self) -> usize {
70        self.cursor
71    }
72
73    /// Caret screen position from the last `render` call, or `None` if
74    /// the widget has not been rendered yet (or was rendered unfocused).
75    pub fn last_caret_pos(&self) -> Option<(u16, u16)> {
76        self.last_caret_pos
77    }
78
79    /// Overwrite a byte `range` of the value with `new_text`, then place
80    /// the cursor at byte offset `new_cursor_byte` in the updated value.
81    /// Used by the hashtag autocomplete to apply an `AcceptAction`. All
82    /// three positions must be on char boundaries; the controller
83    /// computes them off `value` so this holds in practice — checked
84    /// via debug_assert.
85    pub fn replace_range_bytes(
86        &mut self,
87        range: std::ops::Range<usize>,
88        new_text: &str,
89        new_cursor_byte: usize,
90    ) {
91        debug_assert!(self.value.is_char_boundary(range.start));
92        debug_assert!(self.value.is_char_boundary(range.end));
93        self.value.replace_range(range, new_text);
94        let clamped = new_cursor_byte.min(self.value.len());
95        debug_assert!(
96            self.value.is_char_boundary(clamped),
97            "new_cursor_byte must land on a char boundary"
98        );
99        self.cursor = clamped;
100    }
101
102    /// Replace the value; cursor jumps to end.
103    pub fn set_value(&mut self, value: impl Into<String>) {
104        self.value = value.into();
105        self.cursor = self.value.len();
106    }
107
108    pub fn clear(&mut self) {
109        self.value.clear();
110        self.cursor = 0;
111    }
112
113    /// Codepoint count to the left of the cursor. Test-only: callers must use
114    /// [`cursor_display_col`](Self::cursor_display_col) for caret placement,
115    /// since codepoint count differs from display width for CJK / emoji.
116    #[cfg(test)]
117    pub(crate) fn cursor_char_offset(&self) -> usize {
118        self.value[..self.cursor].chars().count()
119    }
120
121    /// Display column to the left of the cursor — accounts for wide (CJK,
122    /// emoji) characters via `unicode-width`. Use this for caret placement.
123    pub fn cursor_display_col(&self) -> usize {
124        self.value[..self.cursor].width()
125    }
126
127    /// Total display width of the value — accounts for wide characters.
128    pub fn display_width(&self) -> usize {
129        self.value.width()
130    }
131
132    pub fn handle_key(&mut self, key: &KeyEvent) -> InputOutcome {
133        match (key.modifiers, key.code) {
134            (_, KeyCode::Enter) => InputOutcome::Submit,
135            (_, KeyCode::Esc) => InputOutcome::Cancel,
136            (_, KeyCode::Backspace) => {
137                if self.cursor == 0 {
138                    return InputOutcome::Consumed;
139                }
140                let prev = prev_char_boundary(&self.value, self.cursor);
141                self.value.drain(prev..self.cursor);
142                self.cursor = prev;
143                InputOutcome::Changed
144            }
145            (_, KeyCode::Delete) => {
146                if self.cursor >= self.value.len() {
147                    return InputOutcome::Consumed;
148                }
149                let next = next_char_boundary(&self.value, self.cursor);
150                self.value.drain(self.cursor..next);
151                InputOutcome::Changed
152            }
153            (_, KeyCode::Left) => {
154                if self.cursor == 0 {
155                    return InputOutcome::Consumed;
156                }
157                self.cursor = prev_char_boundary(&self.value, self.cursor);
158                InputOutcome::Consumed
159            }
160            (_, KeyCode::Right) => {
161                if self.cursor >= self.value.len() {
162                    return InputOutcome::Consumed;
163                }
164                self.cursor = next_char_boundary(&self.value, self.cursor);
165                InputOutcome::Consumed
166            }
167            (_, KeyCode::Home) => {
168                self.cursor = 0;
169                InputOutcome::Consumed
170            }
171            (_, KeyCode::End) => {
172                self.cursor = self.value.len();
173                InputOutcome::Consumed
174            }
175            // Accept only plain or Shift-modified chars; Ctrl/Alt combos are
176            // not text input and must bubble up to the caller for shortcuts.
177            (m, KeyCode::Char(c)) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
178                self.value.insert(self.cursor, c);
179                self.cursor += c.len_utf8();
180                InputOutcome::Changed
181            }
182            _ => InputOutcome::NotConsumed,
183        }
184    }
185
186    /// Render the value text at `rect` using `style`. Caller is responsible for
187    /// any surrounding chrome (borders, prompt prefix, validation glyphs).
188    /// Place the terminal cursor when `focused`. `value_offset_x` is the
189    /// display-column offset within `rect` where the value text starts (e.g.
190    /// when the caller renders a "Find: " prefix separately, pass its
191    /// display width via `UnicodeWidthStr::width`).
192    pub fn render(
193        &mut self,
194        f: &mut Frame,
195        rect: Rect,
196        style: Style,
197        value_offset_x: u16,
198        focused: bool,
199    ) {
200        let inner = Rect {
201            x: rect.x.saturating_add(value_offset_x),
202            width: rect.width.saturating_sub(value_offset_x),
203            ..rect
204        };
205        f.render_widget(Paragraph::new(self.value.as_str()).style(style), inner);
206        self.place_caret(f, inner, focused);
207    }
208
209    /// Like [`Self::render`], but with a pre-styled line (e.g. the query
210    /// highlighter's output). The line must render the same text as the
211    /// input's value, so caret math stays valid.
212    pub fn render_line(
213        &mut self,
214        f: &mut Frame,
215        rect: Rect,
216        line: ratatui::text::Line<'static>,
217        base_style: Style,
218        value_offset_x: u16,
219        focused: bool,
220    ) {
221        let inner = Rect {
222            x: rect.x.saturating_add(value_offset_x),
223            width: rect.width.saturating_sub(value_offset_x),
224            ..rect
225        };
226        f.render_widget(Paragraph::new(line).style(base_style), inner);
227        self.place_caret(f, inner, focused);
228    }
229
230    /// Shared caret placement for both render paths.
231    fn place_caret(&mut self, f: &mut Frame, inner: Rect, focused: bool) {
232        self.last_caret_pos = None;
233        if focused {
234            let caret_x = inner
235                .x
236                .saturating_add(self.cursor_display_col() as u16)
237                .min(inner.x + inner.width.saturating_sub(1));
238            f.set_cursor_position(Position {
239                x: caret_x,
240                y: inner.y,
241            });
242            self.last_caret_pos = Some((caret_x, inner.y));
243        }
244    }
245}
246
247fn prev_char_boundary(s: &str, from: usize) -> usize {
248    s[..from]
249        .char_indices()
250        .next_back()
251        .map(|(i, _)| i)
252        .unwrap_or(0)
253}
254
255fn next_char_boundary(s: &str, from: usize) -> usize {
256    s[from..]
257        .char_indices()
258        .nth(1)
259        .map(|(i, _)| from + i)
260        .unwrap_or(s.len())
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    fn k(code: KeyCode) -> KeyEvent {
268        KeyEvent::new(code, KeyModifiers::NONE)
269    }
270
271    #[test]
272    fn new_is_empty_cursor_zero() {
273        let i = SingleLineInput::new();
274        assert!(i.is_empty());
275        assert_eq!(i.cursor_char_offset(), 0);
276    }
277
278    #[test]
279    fn with_value_places_cursor_at_end() {
280        let i = SingleLineInput::with_value("hello");
281        assert_eq!(i.value(), "hello");
282        assert_eq!(i.cursor_char_offset(), 5);
283    }
284
285    #[test]
286    fn typing_chars_appends_and_advances_cursor() {
287        let mut i = SingleLineInput::new();
288        assert_eq!(i.handle_key(&k(KeyCode::Char('a'))), InputOutcome::Changed);
289        assert_eq!(i.handle_key(&k(KeyCode::Char('b'))), InputOutcome::Changed);
290        assert_eq!(i.value(), "ab");
291        assert_eq!(i.cursor_char_offset(), 2);
292    }
293
294    #[test]
295    fn left_then_insert_inserts_mid_string() {
296        let mut i = SingleLineInput::with_value("ac");
297        i.handle_key(&k(KeyCode::Left));
298        assert_eq!(i.cursor_char_offset(), 1);
299        i.handle_key(&k(KeyCode::Char('b')));
300        assert_eq!(i.value(), "abc");
301        assert_eq!(i.cursor_char_offset(), 2);
302    }
303
304    #[test]
305    fn backspace_at_start_is_noop() {
306        let mut i = SingleLineInput::with_value("abc");
307        i.handle_key(&k(KeyCode::Home));
308        assert_eq!(i.handle_key(&k(KeyCode::Backspace)), InputOutcome::Consumed);
309        assert_eq!(i.value(), "abc");
310    }
311
312    #[test]
313    fn delete_at_end_is_noop() {
314        let mut i = SingleLineInput::with_value("abc");
315        assert_eq!(i.handle_key(&k(KeyCode::Delete)), InputOutcome::Consumed);
316        assert_eq!(i.value(), "abc");
317    }
318
319    #[test]
320    fn home_end_jump_cursor() {
321        let mut i = SingleLineInput::with_value("abc");
322        i.handle_key(&k(KeyCode::Home));
323        assert_eq!(i.cursor_char_offset(), 0);
324        i.handle_key(&k(KeyCode::End));
325        assert_eq!(i.cursor_char_offset(), 3);
326    }
327
328    #[test]
329    fn unicode_chars_count_by_codepoint_not_bytes() {
330        let mut i = SingleLineInput::new();
331        i.handle_key(&k(KeyCode::Char('あ')));
332        i.handle_key(&k(KeyCode::Char('い')));
333        assert_eq!(i.value(), "あい");
334        assert_eq!(i.cursor_char_offset(), 2);
335        i.handle_key(&k(KeyCode::Left));
336        assert_eq!(i.cursor_char_offset(), 1);
337        i.handle_key(&k(KeyCode::Backspace));
338        assert_eq!(i.value(), "い");
339    }
340
341    #[test]
342    fn enter_returns_submit_esc_returns_cancel() {
343        let mut i = SingleLineInput::with_value("x");
344        assert_eq!(i.handle_key(&k(KeyCode::Enter)), InputOutcome::Submit);
345        assert_eq!(i.handle_key(&k(KeyCode::Esc)), InputOutcome::Cancel);
346    }
347
348    #[test]
349    fn ctrl_char_is_not_consumed_as_text() {
350        let mut i = SingleLineInput::new();
351        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL);
352        assert_eq!(i.handle_key(&key), InputOutcome::NotConsumed);
353        assert!(i.is_empty());
354    }
355
356    #[test]
357    fn alt_char_is_not_consumed_as_text() {
358        let mut i = SingleLineInput::new();
359        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT);
360        assert_eq!(i.handle_key(&key), InputOutcome::NotConsumed);
361        assert!(i.is_empty());
362    }
363
364    #[test]
365    fn cjk_chars_count_two_display_cols_per_char() {
366        let mut i = SingleLineInput::new();
367        i.handle_key(&k(KeyCode::Char('あ')));
368        i.handle_key(&k(KeyCode::Char('い')));
369        // 2 codepoints, but each is 2 cells wide.
370        assert_eq!(i.cursor_char_offset(), 2);
371        assert_eq!(i.cursor_display_col(), 4);
372        assert_eq!(i.display_width(), 4);
373    }
374
375    #[test]
376    fn mixed_ascii_and_cjk_caret_column() {
377        let mut i = SingleLineInput::with_value("ab猫");
378        // Caret at end of "ab猫" → 1+1+2 display cols.
379        assert_eq!(i.cursor_display_col(), 4);
380        i.handle_key(&k(KeyCode::Left));
381        // Caret moved before 猫 → 2 cells.
382        assert_eq!(i.cursor_display_col(), 2);
383    }
384
385    #[test]
386    fn shift_char_inserts() {
387        let mut i = SingleLineInput::new();
388        let key = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);
389        assert_eq!(i.handle_key(&key), InputOutcome::Changed);
390        assert_eq!(i.value(), "A");
391    }
392
393    #[test]
394    fn set_value_resets_cursor_to_end() {
395        let mut i = SingleLineInput::with_value("abc");
396        i.handle_key(&k(KeyCode::Home));
397        i.set_value("xyz!");
398        assert_eq!(i.value(), "xyz!");
399        assert_eq!(i.cursor_char_offset(), 4);
400    }
401
402    #[test]
403    fn clear_resets_both() {
404        let mut i = SingleLineInput::with_value("abc");
405        i.clear();
406        assert!(i.is_empty());
407        assert_eq!(i.cursor_char_offset(), 0);
408    }
409}