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}
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 pub fn cursor_byte(&self) -> usize {
70 self.cursor
71 }
72
73 pub fn last_caret_pos(&self) -> Option<(u16, u16)> {
76 self.last_caret_pos
77 }
78
79 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 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 #[cfg(test)]
117 pub(crate) fn cursor_char_offset(&self) -> usize {
118 self.value[..self.cursor].chars().count()
119 }
120
121 pub fn cursor_display_col(&self) -> usize {
124 self.value[..self.cursor].width()
125 }
126
127 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 (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 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 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 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 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 assert_eq!(i.cursor_display_col(), 4);
380 i.handle_key(&k(KeyCode::Left));
381 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}