1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum InputOutcome {
22 Consumed,
24 Changed,
26 Submit,
28 Cancel,
30 NotConsumed,
32}
33
34#[derive(Default)]
35pub struct SingleLineInput {
36 value: String,
37 cursor: usize,
39 last_caret_pos: Option<(u16, u16)>,
43 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 pub fn cursor_byte(&self) -> usize {
76 self.cursor
77 }
78
79 pub fn last_caret_pos(&self) -> Option<(u16, u16)> {
82 self.last_caret_pos
83 }
84
85 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 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 #[cfg(test)]
123 pub(crate) fn cursor_char_offset(&self) -> usize {
124 self.value[..self.cursor].chars().count()
125 }
126
127 pub fn cursor_display_col(&self) -> usize {
130 self.value[..self.cursor].width()
131 }
132
133 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 (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 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 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 fn scroll_into_view(&mut self, width: u16) -> u16 {
251 if width == 0 {
252 self.scroll_x = 0;
253 return 0;
254 }
255 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 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 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 assert_eq!(i.cursor_display_col(), 4);
420 i.handle_key(&k(KeyCode::Left));
421 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 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); for _ in 0..3 {
497 i.handle_key(&k(KeyCode::Left));
498 }
499 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); 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); 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}