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