Skip to main content

netget/cli/
input_state.rs

1//! Simple multi-line input state management
2//!
3//! Replaces tui-textarea with a lightweight implementation
4//! for the rolling terminal interface.
5//!
6//! # Cursor indexing model (read this before editing)
7//!
8//! `cursor_col` is a **character index** — a count of Unicode scalar values into the
9//! current line — and never a byte offset. Every method in this file must agree on
10//! that, and the renderer counts characters too.
11//!
12//! This file previously mixed the two: `insert_char`, `insert_newline`, `delete_char`,
13//! `delete_char_forward` and `delete_to_end_of_line` treated `cursor_col` as a byte
14//! offset while the word-movement and word-delete methods treated it as a character
15//! index. That is a crash, not a style problem: typing `я` once leaves `cursor_col = 1`
16//! on a line that is 2 bytes long, and the *second* keystroke calls
17//! `String::insert(1, …)` in the middle of a UTF-8 sequence, which panics. Any accented
18//! Latin letter, any CJK character and any emoji did it, in the box the user types into.
19//!
20//! Character index rather than byte index because `cursor_col` is what Left/Right move
21//! by: one keypress should traverse one thing the user sees, and a byte is not one.
22//!
23//! **Known limitation of character (rather than grapheme cluster) semantics:** a
24//! multi-codepoint grapheme — a flag (`🇺🇸` = 2 scalars), a family emoji joined with
25//! ZWJ, a skin-tone modifier, or combining accents — takes one keypress *per scalar* to
26//! cross or erase. That is a cosmetic annoyance; it cannot panic, because every scalar
27//! boundary is also a valid UTF-8 boundary. Moving to grapheme semantics means adding a
28//! segmentation dependency and changing only these helpers, not the call sites.
29
30use crossterm::event::{KeyCode, KeyModifiers};
31
32/// Number of characters in a line — the unit `cursor_col` is measured in.
33fn char_len(line: &str) -> usize {
34    line.chars().count()
35}
36
37/// Convert a character index into the byte offset `String` methods need.
38///
39/// Saturates at the end of the line, so an out-of-range `cursor_col` truncates or
40/// appends rather than panicking.
41fn byte_offset(line: &str, char_idx: usize) -> usize {
42    line.char_indices()
43        .nth(char_idx)
44        .map(|(offset, _)| offset)
45        .unwrap_or(line.len())
46}
47
48/// Direction for cursor movement
49#[derive(Debug, Clone, Copy)]
50pub enum Direction {
51    Up,
52    Down,
53    Left,
54    Right,
55}
56
57/// Multi-line input state with cursor management
58#[derive(Debug, Clone)]
59pub struct InputState {
60    /// Lines of text
61    lines: Vec<String>,
62    /// Current cursor row (0-indexed)
63    cursor_row: usize,
64    /// Current cursor column (0-indexed, **character** offset within the line — see the
65    /// module docs; this is deliberately not a byte offset)
66    cursor_col: usize,
67}
68
69impl Default for InputState {
70    fn default() -> Self {
71        Self {
72            lines: vec![String::new()],
73            cursor_row: 0,
74            cursor_col: 0,
75        }
76    }
77}
78
79impl InputState {
80    /// Create a new empty input state
81    pub fn new() -> Self {
82        Self::default()
83    }
84
85    /// Create from existing lines
86    pub fn from_lines(lines: Vec<String>) -> Self {
87        if lines.is_empty() {
88            Self::default()
89        } else {
90            let cursor_row = lines.len() - 1;
91            let cursor_col = char_len(&lines[cursor_row]);
92            Self {
93                lines,
94                cursor_row,
95                cursor_col,
96            }
97        }
98    }
99
100    /// Get all lines as a Vec<String>
101    pub fn lines(&self) -> &[String] {
102        &self.lines
103    }
104
105    /// Get the full text as a single string with newlines
106    pub fn text(&self) -> String {
107        self.lines.join("\n")
108    }
109
110    /// Get current cursor position as (row, character column)
111    pub fn cursor_position(&self) -> (usize, usize) {
112        (self.cursor_row, self.cursor_col)
113    }
114
115    /// Clear all input
116    pub fn clear(&mut self) {
117        self.lines = vec![String::new()];
118        self.cursor_row = 0;
119        self.cursor_col = 0;
120    }
121
122    /// Insert a character at the cursor position
123    pub fn insert_char(&mut self, c: char) {
124        let offset = byte_offset(&self.lines[self.cursor_row], self.cursor_col);
125        self.lines[self.cursor_row].insert(offset, c);
126        self.cursor_col += 1;
127    }
128
129    /// Insert a newline at the cursor position
130    pub fn insert_newline(&mut self) {
131        let offset = byte_offset(&self.lines[self.cursor_row], self.cursor_col);
132        let rest = self.lines[self.cursor_row][offset..].to_string();
133        self.lines[self.cursor_row].truncate(offset);
134
135        self.cursor_row += 1;
136        self.lines.insert(self.cursor_row, rest);
137        self.cursor_col = 0;
138    }
139
140    /// Delete character before cursor (backspace)
141    pub fn delete_char(&mut self) {
142        if self.cursor_col > 0 {
143            // Delete within current line
144            let offset = byte_offset(&self.lines[self.cursor_row], self.cursor_col - 1);
145            self.lines[self.cursor_row].remove(offset);
146            self.cursor_col -= 1;
147        } else if self.cursor_row > 0 {
148            // Join with previous line
149            let current_line = self.lines.remove(self.cursor_row);
150            self.cursor_row -= 1;
151            self.cursor_col = char_len(&self.lines[self.cursor_row]);
152            self.lines[self.cursor_row].push_str(&current_line);
153        }
154    }
155
156    /// Delete character at cursor (delete key)
157    pub fn delete_char_forward(&mut self) {
158        if self.cursor_col < char_len(&self.lines[self.cursor_row]) {
159            // Delete within current line
160            let offset = byte_offset(&self.lines[self.cursor_row], self.cursor_col);
161            self.lines[self.cursor_row].remove(offset);
162        } else if self.cursor_row < self.lines.len() - 1 {
163            // Join with next line
164            let next_line = self.lines.remove(self.cursor_row + 1);
165            self.lines[self.cursor_row].push_str(&next_line);
166        }
167    }
168
169    /// Delete from cursor to end of line (Ctrl+K)
170    pub fn delete_to_end_of_line(&mut self) {
171        let offset = byte_offset(&self.lines[self.cursor_row], self.cursor_col);
172        self.lines[self.cursor_row].truncate(offset);
173    }
174
175    /// Delete entire line content (Ctrl+U)
176    pub fn delete_line(&mut self) {
177        self.lines[self.cursor_row].clear();
178        self.cursor_col = 0;
179    }
180
181    /// Delete word before cursor (Ctrl+W, Alt+Backspace)
182    pub fn delete_word(&mut self) {
183        if self.cursor_col == 0 {
184            return;
185        }
186
187        let line = &mut self.lines[self.cursor_row];
188        let chars: Vec<char> = line.chars().collect();
189        let end_char = self.cursor_col.min(chars.len());
190
191        // Find start of word
192        let mut new_col = end_char;
193
194        // Skip trailing whitespace
195        while new_col > 0 && chars[new_col - 1].is_whitespace() {
196            new_col -= 1;
197        }
198
199        // Delete word characters
200        while new_col > 0 && !chars[new_col - 1].is_whitespace() {
201            new_col -= 1;
202        }
203
204        // Remove the range
205        let byte_start = byte_offset(line, new_col);
206        let byte_end = byte_offset(line, end_char);
207        line.replace_range(byte_start..byte_end, "");
208
209        self.cursor_col = new_col;
210    }
211
212    /// Delete word after cursor (Alt+Delete, Ctrl+Delete)
213    pub fn delete_word_forward(&mut self) {
214        let line = &mut self.lines[self.cursor_row];
215        let chars: Vec<char> = line.chars().collect();
216        if self.cursor_col >= chars.len() {
217            return;
218        }
219
220        // Find end of word
221        let mut end_col = self.cursor_col;
222
223        // Skip leading whitespace
224        while end_col < chars.len() && chars[end_col].is_whitespace() {
225            end_col += 1;
226        }
227
228        // Delete word characters
229        while end_col < chars.len() && !chars[end_col].is_whitespace() {
230            end_col += 1;
231        }
232
233        // Remove the range
234        let byte_start = byte_offset(line, self.cursor_col);
235        let byte_end = byte_offset(line, end_col);
236        line.replace_range(byte_start..byte_end, "");
237    }
238
239    /// Move cursor to start of current or previous word (Alt+Left, Ctrl+Left)
240    pub fn move_cursor_word_left(&mut self) {
241        let line = &self.lines[self.cursor_row];
242        if self.cursor_col == 0 {
243            // Move to end of previous line
244            if self.cursor_row > 0 {
245                self.cursor_row -= 1;
246                self.cursor_col = char_len(&self.lines[self.cursor_row]);
247            }
248            return;
249        }
250
251        let chars: Vec<char> = line.chars().collect();
252        let mut new_col = self.cursor_col.min(chars.len());
253
254        // Skip trailing whitespace
255        while new_col > 0 && chars[new_col - 1].is_whitespace() {
256            new_col -= 1;
257        }
258
259        // Skip word characters
260        while new_col > 0 && !chars[new_col - 1].is_whitespace() {
261            new_col -= 1;
262        }
263
264        self.cursor_col = new_col;
265    }
266
267    /// Move cursor to beginning of next word (Alt+Right, Ctrl+Right)
268    pub fn move_cursor_word_right(&mut self) {
269        let line = &self.lines[self.cursor_row];
270        let chars: Vec<char> = line.chars().collect();
271        if self.cursor_col >= chars.len() {
272            // Move to start of next line
273            if self.cursor_row < self.lines.len() - 1 {
274                self.cursor_row += 1;
275                self.cursor_col = 0;
276            }
277            return;
278        }
279
280        let mut new_col = self.cursor_col;
281
282        // Skip current word if we're in one
283        if new_col < chars.len() && !chars[new_col].is_whitespace() {
284            while new_col < chars.len() && !chars[new_col].is_whitespace() {
285                new_col += 1;
286            }
287        }
288
289        // Skip whitespace to reach beginning of next word
290        while new_col < chars.len() && chars[new_col].is_whitespace() {
291            new_col += 1;
292        }
293
294        self.cursor_col = new_col;
295    }
296
297    /// Move cursor in the specified direction
298    pub fn move_cursor(&mut self, direction: Direction) {
299        match direction {
300            Direction::Up => {
301                if self.cursor_row > 0 {
302                    self.cursor_row -= 1;
303                    // Clamp column to line length
304                    let line_len = char_len(&self.lines[self.cursor_row]);
305                    self.cursor_col = self.cursor_col.min(line_len);
306                }
307            }
308            Direction::Down => {
309                if self.cursor_row < self.lines.len() - 1 {
310                    self.cursor_row += 1;
311                    // Clamp column to line length
312                    let line_len = char_len(&self.lines[self.cursor_row]);
313                    self.cursor_col = self.cursor_col.min(line_len);
314                }
315            }
316            Direction::Left => {
317                if self.cursor_col > 0 {
318                    self.cursor_col -= 1;
319                } else if self.cursor_row > 0 {
320                    // Move to end of previous line
321                    self.cursor_row -= 1;
322                    self.cursor_col = char_len(&self.lines[self.cursor_row]);
323                }
324            }
325            Direction::Right => {
326                let line_len = char_len(&self.lines[self.cursor_row]);
327                if self.cursor_col < line_len {
328                    self.cursor_col += 1;
329                } else if self.cursor_row < self.lines.len() - 1 {
330                    // Move to start of next line
331                    self.cursor_row += 1;
332                    self.cursor_col = 0;
333                }
334            }
335        }
336    }
337
338    /// Move cursor to start of line (Ctrl+A, Home)
339    pub fn move_to_start_of_line(&mut self) {
340        self.cursor_col = 0;
341    }
342
343    /// Move cursor to end of line (Ctrl+E, End)
344    pub fn move_to_end_of_line(&mut self) {
345        self.cursor_col = char_len(&self.lines[self.cursor_row]);
346    }
347
348    /// Move cursor to start of input (top-left)
349    pub fn move_to_top(&mut self) {
350        self.cursor_row = 0;
351        self.cursor_col = 0;
352    }
353
354    /// Move cursor to end of input (bottom-right)
355    pub fn move_to_bottom(&mut self) {
356        self.cursor_row = self.lines.len() - 1;
357        self.cursor_col = char_len(&self.lines[self.cursor_row]);
358    }
359
360    /// Check if cursor is on the first line
361    pub fn is_on_first_line(&self) -> bool {
362        self.cursor_row == 0
363    }
364
365    /// Check if cursor is on the last line
366    pub fn is_on_last_line(&self) -> bool {
367        self.cursor_row == self.lines.len() - 1
368    }
369
370    /// Handle a key event and return true if the key was handled
371    pub fn handle_key(&mut self, key_code: KeyCode, modifiers: KeyModifiers) -> bool {
372        match key_code {
373            KeyCode::Char(c) => {
374                // Check for special modifiers (not Shift which is normal)
375                if modifiers.contains(KeyModifiers::CONTROL)
376                    || modifiers.contains(KeyModifiers::ALT)
377                {
378                    // Let caller handle Ctrl+C, Ctrl+N, etc.
379                    return false;
380                }
381                self.insert_char(c);
382                true
383            }
384            KeyCode::Backspace => {
385                self.delete_char();
386                true
387            }
388            KeyCode::Delete => {
389                self.delete_char_forward();
390                true
391            }
392            KeyCode::Left => {
393                self.move_cursor(Direction::Left);
394                true
395            }
396            KeyCode::Right => {
397                self.move_cursor(Direction::Right);
398                true
399            }
400            KeyCode::Up => {
401                self.move_cursor(Direction::Up);
402                true
403            }
404            KeyCode::Down => {
405                self.move_cursor(Direction::Down);
406                true
407            }
408            KeyCode::Home => {
409                self.move_to_start_of_line();
410                true
411            }
412            KeyCode::End => {
413                self.move_to_end_of_line();
414                true
415            }
416            _ => false,
417        }
418    }
419}