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.last_caret_pos = None;
207        if focused {
208            let caret_x = inner
209                .x
210                .saturating_add(self.cursor_display_col() as u16)
211                .min(inner.x + inner.width.saturating_sub(1));
212            f.set_cursor_position(Position {
213                x: caret_x,
214                y: inner.y,
215            });
216            self.last_caret_pos = Some((caret_x, inner.y));
217        }
218    }
219}
220
221fn prev_char_boundary(s: &str, from: usize) -> usize {
222    s[..from]
223        .char_indices()
224        .next_back()
225        .map(|(i, _)| i)
226        .unwrap_or(0)
227}
228
229fn next_char_boundary(s: &str, from: usize) -> usize {
230    s[from..]
231        .char_indices()
232        .nth(1)
233        .map(|(i, _)| from + i)
234        .unwrap_or(s.len())
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    fn k(code: KeyCode) -> KeyEvent {
242        KeyEvent::new(code, KeyModifiers::NONE)
243    }
244
245    #[test]
246    fn new_is_empty_cursor_zero() {
247        let i = SingleLineInput::new();
248        assert!(i.is_empty());
249        assert_eq!(i.cursor_char_offset(), 0);
250    }
251
252    #[test]
253    fn with_value_places_cursor_at_end() {
254        let i = SingleLineInput::with_value("hello");
255        assert_eq!(i.value(), "hello");
256        assert_eq!(i.cursor_char_offset(), 5);
257    }
258
259    #[test]
260    fn typing_chars_appends_and_advances_cursor() {
261        let mut i = SingleLineInput::new();
262        assert_eq!(i.handle_key(&k(KeyCode::Char('a'))), InputOutcome::Changed);
263        assert_eq!(i.handle_key(&k(KeyCode::Char('b'))), InputOutcome::Changed);
264        assert_eq!(i.value(), "ab");
265        assert_eq!(i.cursor_char_offset(), 2);
266    }
267
268    #[test]
269    fn left_then_insert_inserts_mid_string() {
270        let mut i = SingleLineInput::with_value("ac");
271        i.handle_key(&k(KeyCode::Left));
272        assert_eq!(i.cursor_char_offset(), 1);
273        i.handle_key(&k(KeyCode::Char('b')));
274        assert_eq!(i.value(), "abc");
275        assert_eq!(i.cursor_char_offset(), 2);
276    }
277
278    #[test]
279    fn backspace_at_start_is_noop() {
280        let mut i = SingleLineInput::with_value("abc");
281        i.handle_key(&k(KeyCode::Home));
282        assert_eq!(i.handle_key(&k(KeyCode::Backspace)), InputOutcome::Consumed);
283        assert_eq!(i.value(), "abc");
284    }
285
286    #[test]
287    fn delete_at_end_is_noop() {
288        let mut i = SingleLineInput::with_value("abc");
289        assert_eq!(i.handle_key(&k(KeyCode::Delete)), InputOutcome::Consumed);
290        assert_eq!(i.value(), "abc");
291    }
292
293    #[test]
294    fn home_end_jump_cursor() {
295        let mut i = SingleLineInput::with_value("abc");
296        i.handle_key(&k(KeyCode::Home));
297        assert_eq!(i.cursor_char_offset(), 0);
298        i.handle_key(&k(KeyCode::End));
299        assert_eq!(i.cursor_char_offset(), 3);
300    }
301
302    #[test]
303    fn unicode_chars_count_by_codepoint_not_bytes() {
304        let mut i = SingleLineInput::new();
305        i.handle_key(&k(KeyCode::Char('あ')));
306        i.handle_key(&k(KeyCode::Char('い')));
307        assert_eq!(i.value(), "あい");
308        assert_eq!(i.cursor_char_offset(), 2);
309        i.handle_key(&k(KeyCode::Left));
310        assert_eq!(i.cursor_char_offset(), 1);
311        i.handle_key(&k(KeyCode::Backspace));
312        assert_eq!(i.value(), "い");
313    }
314
315    #[test]
316    fn enter_returns_submit_esc_returns_cancel() {
317        let mut i = SingleLineInput::with_value("x");
318        assert_eq!(i.handle_key(&k(KeyCode::Enter)), InputOutcome::Submit);
319        assert_eq!(i.handle_key(&k(KeyCode::Esc)), InputOutcome::Cancel);
320    }
321
322    #[test]
323    fn ctrl_char_is_not_consumed_as_text() {
324        let mut i = SingleLineInput::new();
325        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL);
326        assert_eq!(i.handle_key(&key), InputOutcome::NotConsumed);
327        assert!(i.is_empty());
328    }
329
330    #[test]
331    fn alt_char_is_not_consumed_as_text() {
332        let mut i = SingleLineInput::new();
333        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT);
334        assert_eq!(i.handle_key(&key), InputOutcome::NotConsumed);
335        assert!(i.is_empty());
336    }
337
338    #[test]
339    fn cjk_chars_count_two_display_cols_per_char() {
340        let mut i = SingleLineInput::new();
341        i.handle_key(&k(KeyCode::Char('あ')));
342        i.handle_key(&k(KeyCode::Char('い')));
343        // 2 codepoints, but each is 2 cells wide.
344        assert_eq!(i.cursor_char_offset(), 2);
345        assert_eq!(i.cursor_display_col(), 4);
346        assert_eq!(i.display_width(), 4);
347    }
348
349    #[test]
350    fn mixed_ascii_and_cjk_caret_column() {
351        let mut i = SingleLineInput::with_value("ab猫");
352        // Caret at end of "ab猫" → 1+1+2 display cols.
353        assert_eq!(i.cursor_display_col(), 4);
354        i.handle_key(&k(KeyCode::Left));
355        // Caret moved before 猫 → 2 cells.
356        assert_eq!(i.cursor_display_col(), 2);
357    }
358
359    #[test]
360    fn shift_char_inserts() {
361        let mut i = SingleLineInput::new();
362        let key = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);
363        assert_eq!(i.handle_key(&key), InputOutcome::Changed);
364        assert_eq!(i.value(), "A");
365    }
366
367    #[test]
368    fn set_value_resets_cursor_to_end() {
369        let mut i = SingleLineInput::with_value("abc");
370        i.handle_key(&k(KeyCode::Home));
371        i.set_value("xyz!");
372        assert_eq!(i.value(), "xyz!");
373        assert_eq!(i.cursor_char_offset(), 4);
374    }
375
376    #[test]
377    fn clear_resets_both() {
378        let mut i = SingleLineInput::with_value("abc");
379        i.clear();
380        assert!(i.is_empty());
381        assert_eq!(i.cursor_char_offset(), 0);
382    }
383}