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 pub fn take_text(&mut self) -> String {
122 self.cursor = 0;
123 std::mem::take(&mut self.value)
124 }
125
126 #[cfg(test)]
130 pub(crate) fn cursor_char_offset(&self) -> usize {
131 self.value[..self.cursor].chars().count()
132 }
133
134 pub fn cursor_display_col(&self) -> usize {
137 self.value[..self.cursor].width()
138 }
139
140 pub fn display_width(&self) -> usize {
142 self.value.width()
143 }
144
145 pub fn handle_key(&mut self, key: &KeyEvent) -> InputOutcome {
146 match (key.modifiers, key.code) {
147 (_, KeyCode::Enter) => InputOutcome::Submit,
148 (_, KeyCode::Esc) => InputOutcome::Cancel,
149 (_, KeyCode::Backspace) => {
150 if self.cursor == 0 {
151 return InputOutcome::Consumed;
152 }
153 let prev = prev_char_boundary(&self.value, self.cursor);
154 self.value.drain(prev..self.cursor);
155 self.cursor = prev;
156 InputOutcome::Changed
157 }
158 (_, KeyCode::Delete) => {
159 if self.cursor >= self.value.len() {
160 return InputOutcome::Consumed;
161 }
162 let next = next_char_boundary(&self.value, self.cursor);
163 self.value.drain(self.cursor..next);
164 InputOutcome::Changed
165 }
166 (_, KeyCode::Left) => {
167 if self.cursor == 0 {
168 return InputOutcome::Consumed;
169 }
170 self.cursor = prev_char_boundary(&self.value, self.cursor);
171 InputOutcome::Consumed
172 }
173 (_, KeyCode::Right) => {
174 if self.cursor >= self.value.len() {
175 return InputOutcome::Consumed;
176 }
177 self.cursor = next_char_boundary(&self.value, self.cursor);
178 InputOutcome::Consumed
179 }
180 (_, KeyCode::Home) => {
181 self.cursor = 0;
182 InputOutcome::Consumed
183 }
184 (_, KeyCode::End) => {
185 self.cursor = self.value.len();
186 InputOutcome::Consumed
187 }
188 (m, KeyCode::Char(c)) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
191 self.value.insert(self.cursor, c);
192 self.cursor += c.len_utf8();
193 InputOutcome::Changed
194 }
195 _ => InputOutcome::NotConsumed,
196 }
197 }
198
199 pub fn render(
206 &mut self,
207 f: &mut Frame,
208 rect: Rect,
209 style: Style,
210 value_offset_x: u16,
211 focused: bool,
212 ) {
213 let inner = Rect {
214 x: rect.x.saturating_add(value_offset_x),
215 width: rect.width.saturating_sub(value_offset_x),
216 ..rect
217 };
218 let scroll_x = self.scroll_into_view(inner.width);
219 f.render_widget(
220 Paragraph::new(self.value.as_str())
221 .style(style)
222 .scroll((0, scroll_x)),
223 inner,
224 );
225 self.place_caret(f, inner, focused);
226 }
227
228 pub fn render_line(
232 &mut self,
233 f: &mut Frame,
234 rect: Rect,
235 line: ratatui::text::Line<'static>,
236 base_style: Style,
237 value_offset_x: u16,
238 focused: bool,
239 ) {
240 let inner = Rect {
241 x: rect.x.saturating_add(value_offset_x),
242 width: rect.width.saturating_sub(value_offset_x),
243 ..rect
244 };
245 let scroll_x = self.scroll_into_view(inner.width);
246 f.render_widget(
247 Paragraph::new(line).style(base_style).scroll((0, scroll_x)),
248 inner,
249 );
250 self.place_caret(f, inner, focused);
251 }
252
253 fn scroll_into_view(&mut self, width: u16) -> u16 {
258 if width == 0 {
259 self.scroll_x = 0;
260 return 0;
261 }
262 let total = u16::try_from(self.display_width()).unwrap_or(u16::MAX);
266 let max_scroll = total.saturating_add(1).saturating_sub(width);
267 self.scroll_x = self.scroll_x.min(max_scroll);
268 let cursor_col = u16::try_from(self.cursor_display_col()).unwrap_or(u16::MAX);
269 if cursor_col < self.scroll_x {
270 self.scroll_x = cursor_col;
271 } else if cursor_col >= self.scroll_x.saturating_add(width) {
272 self.scroll_x = cursor_col - (width - 1);
273 }
274 self.scroll_x
275 }
276
277 fn place_caret(&mut self, f: &mut Frame, inner: Rect, focused: bool) {
279 self.last_caret_pos = None;
280 if focused {
281 let caret_x = inner
282 .x
283 .saturating_add((self.cursor_display_col() as u16).saturating_sub(self.scroll_x))
284 .min(inner.x + inner.width.saturating_sub(1));
285 f.set_cursor_position(Position {
286 x: caret_x,
287 y: inner.y,
288 });
289 self.last_caret_pos = Some((caret_x, inner.y));
290 }
291 }
292}
293
294fn prev_char_boundary(s: &str, from: usize) -> usize {
295 s[..from]
296 .char_indices()
297 .next_back()
298 .map(|(i, _)| i)
299 .unwrap_or(0)
300}
301
302fn next_char_boundary(s: &str, from: usize) -> usize {
303 s[from..]
304 .char_indices()
305 .nth(1)
306 .map(|(i, _)| from + i)
307 .unwrap_or(s.len())
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 fn k(code: KeyCode) -> KeyEvent {
315 KeyEvent::new(code, KeyModifiers::NONE)
316 }
317
318 #[test]
319 fn new_is_empty_cursor_zero() {
320 let i = SingleLineInput::new();
321 assert!(i.is_empty());
322 assert_eq!(i.cursor_char_offset(), 0);
323 }
324
325 #[test]
326 fn with_value_places_cursor_at_end() {
327 let i = SingleLineInput::with_value("hello");
328 assert_eq!(i.value(), "hello");
329 assert_eq!(i.cursor_char_offset(), 5);
330 }
331
332 #[test]
333 fn typing_chars_appends_and_advances_cursor() {
334 let mut i = SingleLineInput::new();
335 assert_eq!(i.handle_key(&k(KeyCode::Char('a'))), InputOutcome::Changed);
336 assert_eq!(i.handle_key(&k(KeyCode::Char('b'))), InputOutcome::Changed);
337 assert_eq!(i.value(), "ab");
338 assert_eq!(i.cursor_char_offset(), 2);
339 }
340
341 #[test]
342 fn left_then_insert_inserts_mid_string() {
343 let mut i = SingleLineInput::with_value("ac");
344 i.handle_key(&k(KeyCode::Left));
345 assert_eq!(i.cursor_char_offset(), 1);
346 i.handle_key(&k(KeyCode::Char('b')));
347 assert_eq!(i.value(), "abc");
348 assert_eq!(i.cursor_char_offset(), 2);
349 }
350
351 #[test]
352 fn backspace_at_start_is_noop() {
353 let mut i = SingleLineInput::with_value("abc");
354 i.handle_key(&k(KeyCode::Home));
355 assert_eq!(i.handle_key(&k(KeyCode::Backspace)), InputOutcome::Consumed);
356 assert_eq!(i.value(), "abc");
357 }
358
359 #[test]
360 fn delete_at_end_is_noop() {
361 let mut i = SingleLineInput::with_value("abc");
362 assert_eq!(i.handle_key(&k(KeyCode::Delete)), InputOutcome::Consumed);
363 assert_eq!(i.value(), "abc");
364 }
365
366 #[test]
367 fn home_end_jump_cursor() {
368 let mut i = SingleLineInput::with_value("abc");
369 i.handle_key(&k(KeyCode::Home));
370 assert_eq!(i.cursor_char_offset(), 0);
371 i.handle_key(&k(KeyCode::End));
372 assert_eq!(i.cursor_char_offset(), 3);
373 }
374
375 #[test]
376 fn unicode_chars_count_by_codepoint_not_bytes() {
377 let mut i = SingleLineInput::new();
378 i.handle_key(&k(KeyCode::Char('あ')));
379 i.handle_key(&k(KeyCode::Char('い')));
380 assert_eq!(i.value(), "あい");
381 assert_eq!(i.cursor_char_offset(), 2);
382 i.handle_key(&k(KeyCode::Left));
383 assert_eq!(i.cursor_char_offset(), 1);
384 i.handle_key(&k(KeyCode::Backspace));
385 assert_eq!(i.value(), "い");
386 }
387
388 #[test]
389 fn enter_returns_submit_esc_returns_cancel() {
390 let mut i = SingleLineInput::with_value("x");
391 assert_eq!(i.handle_key(&k(KeyCode::Enter)), InputOutcome::Submit);
392 assert_eq!(i.handle_key(&k(KeyCode::Esc)), InputOutcome::Cancel);
393 }
394
395 #[test]
396 fn ctrl_char_is_not_consumed_as_text() {
397 let mut i = SingleLineInput::new();
398 let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL);
399 assert_eq!(i.handle_key(&key), InputOutcome::NotConsumed);
400 assert!(i.is_empty());
401 }
402
403 #[test]
404 fn alt_char_is_not_consumed_as_text() {
405 let mut i = SingleLineInput::new();
406 let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT);
407 assert_eq!(i.handle_key(&key), InputOutcome::NotConsumed);
408 assert!(i.is_empty());
409 }
410
411 #[test]
412 fn cjk_chars_count_two_display_cols_per_char() {
413 let mut i = SingleLineInput::new();
414 i.handle_key(&k(KeyCode::Char('あ')));
415 i.handle_key(&k(KeyCode::Char('い')));
416 assert_eq!(i.cursor_char_offset(), 2);
418 assert_eq!(i.cursor_display_col(), 4);
419 assert_eq!(i.display_width(), 4);
420 }
421
422 #[test]
423 fn mixed_ascii_and_cjk_caret_column() {
424 let mut i = SingleLineInput::with_value("ab猫");
425 assert_eq!(i.cursor_display_col(), 4);
427 i.handle_key(&k(KeyCode::Left));
428 assert_eq!(i.cursor_display_col(), 2);
430 }
431
432 #[test]
433 fn shift_char_inserts() {
434 let mut i = SingleLineInput::new();
435 let key = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);
436 assert_eq!(i.handle_key(&key), InputOutcome::Changed);
437 assert_eq!(i.value(), "A");
438 }
439
440 #[test]
441 fn set_value_resets_cursor_to_end() {
442 let mut i = SingleLineInput::with_value("abc");
443 i.handle_key(&k(KeyCode::Home));
444 i.set_value("xyz!");
445 assert_eq!(i.value(), "xyz!");
446 assert_eq!(i.cursor_char_offset(), 4);
447 }
448
449 #[test]
450 fn clear_resets_both() {
451 let mut i = SingleLineInput::with_value("abc");
452 i.clear();
453 assert!(i.is_empty());
454 assert_eq!(i.cursor_char_offset(), 0);
455 }
456
457 #[test]
458 fn take_text_returns_value_and_empties_the_input() {
459 let mut i = SingleLineInput::with_value("abc");
460 assert_eq!(i.take_text(), "abc");
461 assert!(i.is_empty());
462 assert_eq!(i.cursor_char_offset(), 0);
463 }
464
465 mod rendering {
466 use super::*;
467 use ratatui::Terminal;
468 use ratatui::backend::TestBackend;
469
470 fn draw(
471 i: &mut SingleLineInput,
472 width: u16,
473 ) -> (Terminal<TestBackend>, Option<(u16, u16)>) {
474 let mut terminal = Terminal::new(TestBackend::new(width, 1)).unwrap();
475 terminal
476 .draw(|f| {
477 i.render(f, Rect::new(0, 0, width, 1), Style::default(), 0, true);
478 })
479 .unwrap();
480 let caret = i.last_caret_pos();
481 (terminal, caret)
482 }
483
484 fn row(terminal: &Terminal<TestBackend>, width: u16) -> String {
485 let buf = terminal.backend().buffer();
486 (0..width).map(|x| buf[(x, 0)].symbol()).collect()
487 }
488
489 #[test]
490 fn short_value_renders_from_start() {
491 let mut i = SingleLineInput::with_value("abc");
492 let (t, caret) = draw(&mut i, 10);
493 assert_eq!(row(&t, 10), "abc ");
494 assert_eq!(caret, Some((3, 0)));
495 }
496
497 #[test]
498 fn long_value_scrolls_to_keep_caret_visible() {
499 let mut i = SingleLineInput::with_value("abcdefghijklmno");
502 let (t, caret) = draw(&mut i, 10);
503 assert_eq!(row(&t, 10), "ghijklmno ");
504 assert_eq!(caret, Some((9, 0)));
505 }
506
507 #[test]
508 fn cursor_moves_inside_window_without_scrolling() {
509 let mut i = SingleLineInput::with_value("abcdefghijklmno");
510 draw(&mut i, 10); for _ in 0..3 {
512 i.handle_key(&k(KeyCode::Left));
513 }
514 let (t, caret) = draw(&mut i, 10);
516 assert_eq!(row(&t, 10), "ghijklmno ");
517 assert_eq!(caret, Some((6, 0)));
518 }
519
520 #[test]
521 fn cursor_past_left_edge_scrolls_back() {
522 let mut i = SingleLineInput::with_value("abcdefghijklmno");
523 draw(&mut i, 10); i.handle_key(&k(KeyCode::Home));
525 let (t, caret) = draw(&mut i, 10);
526 assert_eq!(row(&t, 10), "abcdefghij");
527 assert_eq!(caret, Some((0, 0)));
528 }
529
530 #[test]
531 fn shrinking_value_clamps_scroll() {
532 let mut i = SingleLineInput::with_value("abcdefghijklmno");
533 draw(&mut i, 10); i.set_value("abc");
535 let (t, caret) = draw(&mut i, 10);
536 assert_eq!(row(&t, 10), "abc ");
537 assert_eq!(caret, Some((3, 0)));
538 }
539 }
540}