Skip to main content

rpi_tui/
input.rs

1//! Input component - single-line text input with horizontal scrolling.
2//!
3//! Provides a text input field with cursor positioning, selection,
4//! undo/redo, and Emacs-style editing.
5
6use std::any::Any;
7use std::sync::{Arc, Mutex};
8
9use super::component::{Component, Focusable};
10use super::keybindings::Keybindings;
11use super::kill_ring::KillRing;
12use super::undo_stack::UndoStack;
13use super::word_navigation::{find_word_backward, find_word_forward};
14use crate::ansi::CURSOR_MARKER;
15use crate::utils::{slice_by_column, visible_width};
16
17/// Byte offset of the `char_index`-th character, clamped to the end of the
18/// string.
19///
20/// The cursor is a *character* index (matching `word_navigation`, which also
21/// indexes by char) but `&str` can only be sliced at byte offsets, so every
22/// splice has to go through this mapping. Indexing a `&str` directly by the
23/// char index both panicked on non-ASCII input and desynced the caret.
24fn byte_offset(value: &str, char_index: usize) -> usize {
25    value
26        .char_indices()
27        .nth(char_index)
28        .map(|(offset, _)| offset)
29        .unwrap_or(value.len())
30}
31
32/// Number of characters in `value`.
33fn char_count(value: &str) -> usize {
34    value.chars().count()
35}
36
37/// Input state for undo.
38#[derive(Debug, Clone)]
39struct InputState {
40    value: String,
41    cursor: usize,
42}
43
44impl Default for InputState {
45    fn default() -> Self {
46        Self {
47            value: String::new(),
48            cursor: 0,
49        }
50    }
51}
52
53/// Last action type for kill ring coalescing.
54#[derive(Debug, Clone, Copy, PartialEq)]
55enum LastAction {
56    None,
57    Kill,
58    Yank,
59    TypeWord,
60}
61
62/// Input component - single-line text input.
63pub struct Input {
64    state: Mutex<InputState>,
65    prompt: String,
66    placeholder: Option<String>,
67    #[allow(dead_code)]
68    keybindings: Arc<Keybindings>,
69    kill_ring: Mutex<KillRing>,
70    undo_stack: Mutex<UndoStack<InputState>>,
71    last_action: Mutex<LastAction>,
72    focused: Mutex<bool>,
73    on_submit: Mutex<Option<Arc<dyn Fn(&str) + Send + Sync>>>,
74    on_escape: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
75    on_change: Mutex<Option<Arc<dyn Fn(&str) + Send + Sync>>>,
76}
77
78impl Input {
79    /// Create a new input component.
80    pub fn new() -> Self {
81        Self {
82            state: Mutex::new(InputState::default()),
83            prompt: "> ".to_string(),
84            placeholder: None,
85            keybindings: Arc::new(Keybindings::new()),
86            kill_ring: Mutex::new(KillRing::new()),
87            undo_stack: Mutex::new(UndoStack::new()),
88            last_action: Mutex::new(LastAction::None),
89            focused: Mutex::new(false),
90            on_submit: Mutex::new(None),
91            on_escape: Mutex::new(None),
92            on_change: Mutex::new(None),
93        }
94    }
95
96    /// Create an input with a custom prompt.
97    pub fn with_prompt(prompt: &str) -> Self {
98        let mut input = Self::new();
99        input.prompt = prompt.to_string();
100        input
101    }
102
103    /// Create an input with a placeholder.
104    pub fn with_placeholder(placeholder: &str) -> Self {
105        let mut input = Self::new();
106        input.placeholder = Some(placeholder.to_string());
107        input
108    }
109
110    /// Get the current value.
111    pub fn get_value(&self) -> String {
112        self.state
113            .lock()
114            .map(|s| s.value.clone())
115            .unwrap_or_default()
116    }
117
118    /// Set the value.
119    pub fn set_value(&self, value: &str) {
120        if let Ok(mut state) = self.state.lock() {
121            state.value = value.to_string();
122            // Character index, not byte length: the cursor is char-counted.
123            state.cursor = char_count(value);
124        }
125    }
126
127    /// Clear the input.
128    pub fn clear(&self) {
129        if let Ok(mut state) = self.state.lock() {
130            state.value.clear();
131            state.cursor = 0;
132        }
133    }
134
135    /// Set callback for when input is submitted (Enter pressed).
136    pub fn on_submit(&self, callback: Arc<dyn Fn(&str) + Send + Sync>) {
137        if let Ok(mut cb) = self.on_submit.lock() {
138            *cb = Some(callback);
139        }
140    }
141
142    /// Set callback for when escape is pressed.
143    pub fn on_escape(&self, callback: Arc<dyn Fn() + Send + Sync>) {
144        if let Ok(mut cb) = self.on_escape.lock() {
145            *cb = Some(callback);
146        }
147    }
148
149    /// Set callback for when value changes.
150    pub fn on_change(&self, callback: Arc<dyn Fn(&str) + Send + Sync>) {
151        if let Ok(mut cb) = self.on_change.lock() {
152            *cb = Some(callback);
153        }
154    }
155
156    /// Handle bracketed paste.
157    pub fn handle_paste(&self, pasted_text: &str) {
158        self.set_last_action(LastAction::None);
159
160        // Clean the pasted text - remove newlines and tabs
161        let clean_text = pasted_text
162            .replace("\r\n", "")
163            .replace("\r", "")
164            .replace("\n", "")
165            .replace("\t", "    ");
166
167        self.edit(false, |state| {
168            if clean_text.is_empty() {
169                return false;
170            }
171            let at = byte_offset(&state.value, state.cursor);
172            state.value.insert_str(at, &clean_text);
173            state.cursor += clean_text.chars().count();
174            true
175        });
176    }
177
178    /// Insert a character at the cursor.
179    fn insert_character(&self, char: &str) {
180        // Undo coalescing: consecutive word chars coalesce into one undo unit
181        let whitespace = char
182            .chars()
183            .next()
184            .map(|c| c.is_whitespace())
185            .unwrap_or(true);
186        let coalesce = !whitespace && self.get_last_action() == LastAction::TypeWord;
187        self.set_last_action(LastAction::TypeWord);
188
189        self.edit(coalesce, |state| {
190            if char.is_empty() {
191                return false;
192            }
193            let at = byte_offset(&state.value, state.cursor);
194            state.value.insert_str(at, char);
195            state.cursor += char.chars().count();
196            true
197        });
198    }
199
200    /// Handle backspace (delete character before cursor).
201    fn handle_backspace(&self) {
202        self.set_last_action(LastAction::None);
203        self.edit(false, |state| {
204            if state.cursor == 0 {
205                return false;
206            }
207            let start = byte_offset(&state.value, state.cursor - 1);
208            let end = byte_offset(&state.value, state.cursor);
209            state.value.replace_range(start..end, "");
210            state.cursor -= 1;
211            true
212        });
213    }
214
215    /// Handle forward delete (delete character after cursor).
216    fn handle_forward_delete(&self) {
217        self.set_last_action(LastAction::None);
218        self.edit(false, |state| {
219            if state.cursor >= char_count(&state.value) {
220                return false;
221            }
222            let start = byte_offset(&state.value, state.cursor);
223            let end = byte_offset(&state.value, state.cursor + 1);
224            state.value.replace_range(start..end, "");
225            true
226        });
227    }
228
229    /// Delete to the start of the line.
230    fn delete_to_line_start(&self) {
231        self.edit(false, |state| {
232            if state.cursor == 0 {
233                return false;
234            }
235            let at = byte_offset(&state.value, state.cursor);
236            let deleted = state.value[..at].to_string();
237            if let Ok(mut kill_ring) = self.kill_ring.lock() {
238                use super::kill_ring::PushOptions;
239                let accumulate = self.get_last_action() == LastAction::Kill;
240                kill_ring.push(
241                    &deleted,
242                    PushOptions {
243                        prepend: true,
244                        accumulate,
245                    },
246                );
247            }
248            self.set_last_action(LastAction::Kill);
249            state.value.replace_range(..at, "");
250            state.cursor = 0;
251            true
252        });
253    }
254
255    /// Delete to the end of the line.
256    fn delete_to_line_end(&self) {
257        self.edit(false, |state| {
258            if state.cursor >= char_count(&state.value) {
259                return false;
260            }
261            let at = byte_offset(&state.value, state.cursor);
262            let deleted = state.value[at..].to_string();
263            if let Ok(mut kill_ring) = self.kill_ring.lock() {
264                use super::kill_ring::PushOptions;
265                let accumulate = self.get_last_action() == LastAction::Kill;
266                kill_ring.push(
267                    &deleted,
268                    PushOptions {
269                        prepend: false,
270                        accumulate,
271                    },
272                );
273            }
274            self.set_last_action(LastAction::Kill);
275            state.value.truncate(at);
276            true
277        });
278    }
279
280    /// Delete word backwards.
281    fn delete_word_backwards(&self) {
282        self.edit(false, |state| {
283            if state.cursor == 0 {
284                return false;
285            }
286            let was_kill = self.get_last_action() == LastAction::Kill;
287            let old_cursor = state.cursor;
288            let new_cursor = find_word_backward(&state.value, old_cursor);
289            let start = byte_offset(&state.value, new_cursor);
290            let end = byte_offset(&state.value, old_cursor);
291            let deleted = state.value[start..end].to_string();
292            if let Ok(mut kill_ring) = self.kill_ring.lock() {
293                use super::kill_ring::PushOptions;
294                kill_ring.push(
295                    &deleted,
296                    PushOptions {
297                        prepend: true,
298                        accumulate: was_kill,
299                    },
300                );
301            }
302            self.set_last_action(LastAction::Kill);
303            state.value.replace_range(start..end, "");
304            state.cursor = new_cursor;
305            true
306        });
307    }
308
309    /// Delete word forward.
310    fn delete_word_forward(&self) {
311        self.edit(false, |state| {
312            if state.cursor >= char_count(&state.value) {
313                return false;
314            }
315            let was_kill = self.get_last_action() == LastAction::Kill;
316            let old_cursor = state.cursor;
317            let new_cursor = find_word_forward(&state.value, old_cursor);
318            let start = byte_offset(&state.value, old_cursor);
319            let end = byte_offset(&state.value, new_cursor);
320            let deleted = state.value[start..end].to_string();
321            if let Ok(mut kill_ring) = self.kill_ring.lock() {
322                use super::kill_ring::PushOptions;
323                kill_ring.push(
324                    &deleted,
325                    PushOptions {
326                        prepend: false,
327                        accumulate: was_kill,
328                    },
329                );
330            }
331            self.set_last_action(LastAction::Kill);
332            state.value.replace_range(start..end, "");
333            state.cursor = old_cursor;
334            true
335        });
336    }
337
338    /// Yank from kill ring.
339    fn yank(&self) {
340        let text = match self.kill_ring.lock() {
341            Ok(kill_ring) => kill_ring.peek().map(|s| s.to_string()),
342            Err(_) => None,
343        };
344
345        if let Some(text) = text {
346            self.set_last_action(LastAction::Yank);
347            self.edit(false, |state| {
348                let at = byte_offset(&state.value, state.cursor);
349                state.value.insert_str(at, &text);
350                state.cursor += text.chars().count();
351                true
352            });
353        }
354    }
355
356    /// Yank pop (cycle through kill ring).
357    #[allow(dead_code)]
358    fn yank_pop(&self) {
359        if self.get_last_action() != LastAction::Yank {
360            return;
361        }
362
363        // Rotate the kill ring before touching the state lock so the ring is
364        // never held across an edit.
365        let (previous, replacement) = {
366            let Ok(mut kill_ring) = self.kill_ring.lock() else {
367                return;
368            };
369            if kill_ring.len() <= 1 {
370                return;
371            }
372            let previous = kill_ring.peek().unwrap_or("").to_string();
373            kill_ring.rotate();
374            let replacement = kill_ring.peek().unwrap_or("").to_string();
375            (previous, replacement)
376        };
377
378        self.edit(false, |state| {
379            let previous_len = previous.chars().count();
380            if state.cursor < previous_len {
381                return false;
382            }
383            // Remove the previously yanked text, then insert the next candidate.
384            let start = byte_offset(&state.value, state.cursor - previous_len);
385            let end = byte_offset(&state.value, state.cursor);
386            state.value.replace_range(start..end, &replacement);
387            state.cursor = state.cursor - previous_len + replacement.chars().count();
388            true
389        });
390        self.set_last_action(LastAction::Yank);
391    }
392
393    /// Move cursor left.
394    fn move_cursor_left(&self) {
395        self.set_last_action(LastAction::None);
396        if let Ok(mut state) = self.state.lock() {
397            if state.cursor > 0 {
398                state.cursor -= 1;
399            }
400        }
401    }
402
403    /// Move cursor right.
404    fn move_cursor_right(&self) {
405        self.set_last_action(LastAction::None);
406        if let Ok(mut state) = self.state.lock() {
407            if state.cursor < char_count(&state.value) {
408                state.cursor += 1;
409            }
410        }
411    }
412
413    /// Move cursor to line start.
414    fn move_to_line_start(&self) {
415        self.set_last_action(LastAction::None);
416        if let Ok(mut state) = self.state.lock() {
417            state.cursor = 0;
418        }
419    }
420
421    /// Move cursor to line end.
422    fn move_to_line_end(&self) {
423        self.set_last_action(LastAction::None);
424        if let Ok(mut state) = self.state.lock() {
425            state.cursor = char_count(&state.value);
426        }
427    }
428
429    /// Move cursor word left.
430    fn move_word_left(&self) {
431        self.set_last_action(LastAction::None);
432        if let Ok(mut state) = self.state.lock() {
433            state.cursor = find_word_backward(&state.value, state.cursor);
434        }
435    }
436
437    /// Move cursor word right.
438    fn move_word_right(&self) {
439        self.set_last_action(LastAction::None);
440        if let Ok(mut state) = self.state.lock() {
441            state.cursor = find_word_forward(&state.value, state.cursor);
442        }
443    }
444
445    /// Undo.
446    fn undo(&self) {
447        if let Ok(mut undo_stack) = self.undo_stack.lock() {
448            if let Some(snapshot) = undo_stack.pop() {
449                drop(undo_stack);
450                if let Ok(mut state) = self.state.lock() {
451                    *state = snapshot;
452                }
453                self.set_last_action(LastAction::None);
454                self.notify_change();
455            }
456        }
457    }
458
459    /// Apply a mutation to the input state, record an undo snapshot, and notify
460    /// listeners.
461    ///
462    /// The undo snapshot is taken while the state lock is held but pushed to the
463    /// undo stack *after* releasing it, and `notify_change` (which calls
464    /// `get_value` ⇒ `state.lock()`) runs last. Both used to run nested inside the
465    /// state lock, and `std::sync::Mutex` is not reentrant — so the first
466    /// backspace (every deletion path, actually) deadlocked the whole TUI and
467    /// wedged the `ask_user` freeform input.
468    ///
469    /// `coalesce` skips the snapshot so consecutive keystrokes of a word merge
470    /// into a single undo unit.
471    fn edit(&self, coalesce: bool, mutate: impl FnOnce(&mut InputState) -> bool) {
472        let snapshot = {
473            let Ok(mut state) = self.state.lock() else {
474                return;
475            };
476            let snapshot = if coalesce {
477                None
478            } else {
479                Some(InputState {
480                    value: state.value.clone(),
481                    cursor: state.cursor,
482                })
483            };
484            if !mutate(&mut state) {
485                return;
486            }
487            snapshot
488        };
489        if let Some(snapshot) = snapshot {
490            if let Ok(mut undo_stack) = self.undo_stack.lock() {
491                undo_stack.push(snapshot);
492            }
493        }
494        self.notify_change();
495    }
496
497    /// Get the last action.
498    fn get_last_action(&self) -> LastAction {
499        self.last_action
500            .lock()
501            .map(|a| *a)
502            .unwrap_or(LastAction::None)
503    }
504
505    /// Set the last action.
506    fn set_last_action(&self, action: LastAction) {
507        if let Ok(mut a) = self.last_action.lock() {
508            *a = action;
509        }
510    }
511
512    /// Notify change callback.
513    fn notify_change(&self) {
514        if let Ok(cb) = self.on_change.lock() {
515            if let Some(callback) = cb.as_ref() {
516                let value = self.get_value();
517                callback(&value);
518            }
519        }
520    }
521
522    /// Handle key input.
523    pub fn handle_key(&self, key: crossterm::event::KeyEvent) {
524        use crossterm::event::{KeyCode, KeyModifiers};
525
526        match (key.code, key.modifiers) {
527            // Submit
528            (KeyCode::Enter, _) | (KeyCode::Char('\n'), _) => {
529                if let Ok(cb) = self.on_submit.lock() {
530                    if let Some(callback) = cb.as_ref() {
531                        let value = self.get_value();
532                        callback(&value);
533                    }
534                }
535            }
536            // Escape
537            (KeyCode::Esc, _) => {
538                if let Ok(cb) = self.on_escape.lock() {
539                    if let Some(callback) = cb.as_ref() {
540                        callback();
541                    }
542                }
543            }
544            // Navigation
545            (KeyCode::Left, KeyModifiers::CONTROL) => self.move_word_left(),
546            (KeyCode::Right, KeyModifiers::CONTROL) => self.move_word_right(),
547            (KeyCode::Left, _) => self.move_cursor_left(),
548            (KeyCode::Right, _) => self.move_cursor_right(),
549            (KeyCode::Home, _) => self.move_to_line_start(),
550            (KeyCode::End, _) => self.move_to_line_end(),
551            // Deletion
552            (KeyCode::Backspace, KeyModifiers::CONTROL) => self.delete_word_backwards(),
553            (KeyCode::Delete, KeyModifiers::CONTROL) => self.delete_word_forward(),
554            (KeyCode::Backspace, _) => self.handle_backspace(),
555            (KeyCode::Delete, _) => self.handle_forward_delete(),
556            // Character input
557            (KeyCode::Char(c), KeyModifiers::CONTROL) => match c {
558                'a' => self.move_to_line_start(),
559                'e' => self.move_to_line_end(),
560                'b' => self.move_cursor_left(),
561                'f' => self.move_cursor_right(),
562                'w' => self.delete_word_backwards(),
563                'k' => self.delete_to_line_end(),
564                'u' => self.delete_to_line_start(),
565                'y' => self.yank(),
566                '_' | '/' => self.undo(),
567                _ => {}
568            },
569            (KeyCode::Char(c), KeyModifiers::ALT) => match c {
570                'b' => self.move_word_left(),
571                'f' => self.move_word_right(),
572                'd' => self.delete_word_forward(),
573                _ => {}
574            },
575            (KeyCode::Char(c), _) => {
576                self.insert_character(&c.to_string());
577            }
578            _ => {}
579        }
580    }
581}
582
583impl Default for Input {
584    fn default() -> Self {
585        Self::new()
586    }
587}
588
589impl Component for Input {
590    fn render(&self, width: usize) -> Vec<String> {
591        let state = self.state.lock().unwrap_or_else(|e| e.into_inner());
592        let focused = *self.focused.lock().unwrap_or_else(|e| e.into_inner());
593
594        let prompt_len = visible_width(&self.prompt);
595        let available_width = width.saturating_sub(prompt_len);
596
597        if available_width == 0 {
598            return vec![self.prompt.clone()];
599        }
600
601        let value = &state.value;
602        let cursor = state.cursor.min(char_count(value));
603
604        // Show placeholder if empty and not focused
605        if value.is_empty() && !focused {
606            if let Some(ref placeholder) = self.placeholder {
607                let dimmed = format!("\x1b[90m{}\x1b[0m", placeholder);
608                return vec![format!("{}{}", self.prompt, dimmed)];
609            }
610        }
611
612        let total_width = visible_width(value);
613        let cursor_byte = byte_offset(value, cursor);
614        let cursor_col = visible_width(&value[..cursor_byte]);
615
616        // Horizontal scrolling. The window is chosen in display columns, but the
617        // caret offset inside it must be counted in *characters* — the previous
618        // implementation reused the byte cursor as an index into the visible
619        // `chars` vector and re-derived it with a quadratic
620        // `chars().position()` scan (which finds the FIRST occurrence of a char
621        // and feeds that index back in as a byte offset). That desynced the
622        // caret as soon as the text scrolled, and panicked on non-ASCII text.
623        let (visible_text, cursor_display) = if total_width < available_width {
624            (value.clone(), cursor)
625        } else {
626            let scroll_width = if cursor == char_count(value) {
627                available_width.saturating_sub(1)
628            } else {
629                available_width
630            };
631
632            if scroll_width == 0 {
633                (String::new(), 0)
634            } else {
635                let half_width = scroll_width / 2;
636                let start_col = if cursor_col < half_width {
637                    0
638                } else if cursor_col > total_width.saturating_sub(half_width) {
639                    total_width.saturating_sub(scroll_width)
640                } else {
641                    cursor_col.saturating_sub(half_width)
642                };
643                let visible = slice_by_column(value, start_col, scroll_width, true);
644                let caret_cols = cursor_col.saturating_sub(start_col).min(scroll_width);
645                let before_caret = slice_by_column(value, start_col, caret_cols, true);
646                let caret = before_caret.chars().count().min(char_count(&visible));
647                (visible, caret)
648            }
649        };
650
651        // Insert cursor marker and reverse video for cursor character
652        let chars: Vec<char> = visible_text.chars().collect();
653        let cursor_display = cursor_display.min(chars.len());
654        let before_cursor: String = chars.iter().take(cursor_display).collect();
655        let at_cursor = chars.get(cursor_display).copied().unwrap_or(' ');
656        let after_cursor: String = chars.iter().skip(cursor_display + 1).collect();
657
658        let marker = if focused { CURSOR_MARKER } else { "" };
659        let cursor_char = if focused {
660            format!("\x1b[7m{}\x1b[27m", at_cursor)
661        } else {
662            at_cursor.to_string()
663        };
664
665        // The marker is zero-width and `extract_cursor_position` derives the
666        // hardware cursor column from the visible text *before* it, so it has to
667        // sit exactly at the caret. Emitting it right after the prompt (as this
668        // used to) pinned the real terminal caret to the start of the input and
669        // it never followed the text being typed.
670        let line = format!(
671            "{}{}{}{}{}{}",
672            self.prompt, before_cursor, marker, cursor_char, after_cursor, "\x1b[0m"
673        );
674
675        vec![line]
676    }
677
678    fn invalidate(&self) {
679        // No cached state to invalidate
680    }
681
682    fn as_any(&self) -> &dyn Any {
683        self
684    }
685}
686
687impl Focusable for Input {
688    fn set_focused(&self, focused: bool) {
689        if let Ok(mut f) = self.focused.lock() {
690            *f = focused;
691        }
692    }
693
694    fn is_focused(&self) -> bool {
695        *self.focused.lock().unwrap_or_else(|e| e.into_inner())
696    }
697}
698#[cfg(test)]
699mod tests {
700    use super::*;
701    use crate::ansi::strip_ansi;
702    use crossterm::event::{KeyCode, KeyModifiers};
703
704    /// Press one key with no modifiers.
705    fn key(input: &Input, code: KeyCode) {
706        input.handle_key(crossterm::event::KeyEvent::new(code, KeyModifiers::NONE));
707    }
708
709    #[test]
710    fn test_input_value() {
711        let input = Input::new();
712        input.set_value("hello");
713        assert_eq!(input.get_value(), "hello");
714    }
715
716    #[test]
717    fn test_input_clear() {
718        let input = Input::new();
719        input.set_value("hello");
720        input.clear();
721        assert_eq!(input.get_value(), "");
722    }
723
724    #[test]
725    fn test_input_render() {
726        let input = Input::new();
727        input.set_value("hello");
728        let lines = input.render(20);
729        assert_eq!(lines.len(), 1);
730        assert!(lines[0].contains("hello"));
731    }
732
733    #[test]
734    fn test_input_placeholder() {
735        let input = Input::with_placeholder("Enter text...");
736        let lines = input.render(20);
737        assert!(lines[0].contains("Enter text..."));
738    }
739
740    /// Regression: every deletion path used to call `push_undo()` and
741    /// `notify_change()` *while already holding* the `state` mutex. Both re-lock
742    /// it and `std::sync::Mutex` is not reentrant, so the first backspace
743    /// deadlocked the whole TUI and wedged the `ask_user` freeform input.
744    #[test]
745    fn deletion_keys_do_not_deadlock() {
746        use std::sync::mpsc;
747        use std::time::Duration;
748
749        let press = |code: KeyCode, mods: KeyModifiers| {
750            let input = Arc::new(Input::new());
751            input.set_value("hello world");
752            let (tx, rx) = mpsc::channel();
753            let handle = input.clone();
754            std::thread::spawn(move || {
755                handle.handle_key(crossterm::event::KeyEvent::new(code, mods));
756                let _ = tx.send(handle.get_value());
757            });
758            match rx.recv_timeout(Duration::from_secs(5)) {
759                Ok(value) => value,
760                Err(_) => panic!("{code:?} deadlocked"),
761            }
762        };
763
764        assert_eq!(press(KeyCode::Backspace, KeyModifiers::NONE), "hello worl");
765        assert_eq!(press(KeyCode::Delete, KeyModifiers::NONE), "hello world");
766        assert_eq!(press(KeyCode::Backspace, KeyModifiers::CONTROL), "hello ");
767        // Ctrl+K sits at the end of the line, so it is a no-op; Ctrl+U kills
768        // back to the start.
769        assert_eq!(
770            press(KeyCode::Char('k'), KeyModifiers::CONTROL),
771            "hello world"
772        );
773        assert_eq!(press(KeyCode::Char('u'), KeyModifiers::CONTROL), "");
774    }
775
776    /// Regression: `render` emitted `CURSOR_MARKER` right after the prompt, but
777    /// `extract_cursor_position` measures the hardware cursor column from the
778    /// text *before* the marker — so the real terminal caret stayed pinned at
779    /// the start of the input instead of following the document.
780    #[test]
781    fn cursor_marker_sits_at_the_caret() {
782        let input = Input::new();
783        input.set_focused(true);
784        input.set_value("hello world");
785        let line = input.render(40).remove(0);
786        let marker_at = line.find(CURSOR_MARKER).expect("marker present");
787        // Caret is at the end of "hello world", not at column 0 / after "> ".
788        assert_eq!(
789            visible_width(&line[..marker_at]),
790            visible_width("> hello world"),
791            "caret not at end: {line:?}"
792        );
793    }
794
795    /// Same, but with a value wider than the field so the input has to scroll,
796    /// and with multibyte text (the old caret math reused a byte cursor as a
797    /// char index plus a quadratic `chars().position()` rescan).
798    #[test]
799    fn cursor_marker_tracks_scrolled_multibyte_text() {
800        let input = Input::new();
801        input.set_focused(true);
802
803        let long: String = "0123456789".repeat(6);
804        input.set_value(&long);
805        let line = input.render(20).remove(0);
806        let marker_at = line.find(CURSOR_MARKER).expect("marker present");
807        let caret_col = visible_width(&line[..marker_at]);
808        let plain = strip_ansi(&line.replace(CURSOR_MARKER, ""));
809        assert!(
810            caret_col > visible_width("> "),
811            "caret stuck at the prompt: col={caret_col}"
812        );
813        assert!(plain.contains("89"), "window lost the tail: {plain:?}");
814
815        // Multibyte value: must not panic and must keep the caret at the end.
816        input.set_value("\u{4e2d}\u{6587}\u{6d4b}\u{8bd5}");
817        let line = input.render(40).remove(0);
818        let marker_at = line.find(CURSOR_MARKER).expect("marker present");
819        assert_eq!(
820            visible_width(&line[..marker_at]),
821            visible_width("> \u{4e2d}\u{6587}\u{6d4b}\u{8bd5}")
822        );
823        assert!(strip_ansi(&line).contains("\u{4e2d}\u{6587}\u{6d4b}\u{8bd5}"));
824    }
825
826    /// Regression: the cursor is a character index while `&str` is sliced by
827    /// byte offset, so `&value[..cursor]` panicked on non-ASCII input and
828    /// `set_value` left the cursor past the end of the text.
829    #[test]
830    fn deletions_are_char_aware() {
831        let input = Input::new();
832
833        // set_value must put the cursor at the end, so Backspace deletes.
834        input.set_value("ab\u{4e2d}");
835        key(&input, KeyCode::Backspace);
836        assert_eq!(input.get_value(), "ab");
837        key(&input, KeyCode::Backspace);
838        assert_eq!(input.get_value(), "a");
839
840        // Forward delete across a multibyte boundary.
841        input.set_value("\u{4e2d}b");
842        key(&input, KeyCode::Left);
843        key(&input, KeyCode::Delete);
844        assert_eq!(input.get_value(), "\u{4e2d}");
845        key(&input, KeyCode::Left);
846        key(&input, KeyCode::Delete);
847        assert_eq!(input.get_value(), "");
848
849        // Insertion at the end must not land mid-character.
850        input.set_value("\u{4e2d}\u{6587}");
851        key(&input, KeyCode::Char('X'));
852        assert_eq!(input.get_value(), "\u{4e2d}\u{6587}X");
853
854        // Nor may moving/inserting inside a multibyte string land mid-char.
855        input.set_value("a\u{4e2d}b");
856        key(&input, KeyCode::Left);
857        key(&input, KeyCode::Char('X'));
858        assert_eq!(input.get_value(), "a\u{4e2d}Xb");
859        key(&input, KeyCode::Backspace);
860        assert_eq!(input.get_value(), "a\u{4e2d}b");
861        key(&input, KeyCode::Backspace);
862        assert_eq!(input.get_value(), "ab");
863    }
864}