Skip to main content

gravityfile_tui/app/
input.rs

1//! Input state for text input modes (rename, create).
2
3use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4use unicode_segmentation::UnicodeSegmentation;
5
6/// State for text input operations.
7#[derive(Debug, Clone, Default)]
8pub struct InputState {
9    /// The current input buffer.
10    buffer: String,
11    /// UTF-8 byte offset at a grapheme boundary within the buffer.
12    cursor: usize,
13    /// Validation error message.
14    error: Option<String>,
15    /// Path context (parent directory for create, full path for rename).
16    pub context_path: Option<std::path::PathBuf>,
17}
18
19impl InputState {
20    /// Create a new empty input state.
21    pub fn new() -> Self {
22        Self::default()
23    }
24
25    /// Create an input state with an initial value (for rename).
26    pub fn with_initial(value: &str) -> Self {
27        Self {
28            buffer: value.to_string(),
29            cursor: value.len(),
30            error: None,
31            context_path: None,
32        }
33    }
34
35    /// Get the current buffer contents.
36    pub fn buffer(&self) -> &str {
37        &self.buffer
38    }
39
40    /// Get the cursor position.
41    pub fn cursor(&self) -> usize {
42        self.cursor
43    }
44
45    /// Get the current error message (if any).
46    pub fn error(&self) -> Option<&str> {
47        self.error.as_deref()
48    }
49
50    /// Set an error message.
51    pub fn set_error(&mut self, error: impl Into<String>) {
52        self.error = Some(error.into());
53    }
54
55    /// Clear the error message.
56    pub fn clear_error(&mut self) {
57        self.error = None;
58    }
59
60    /// Handle a key event.
61    ///
62    /// Returns the result of handling the key.
63    pub fn handle_key(&mut self, key: KeyEvent) -> InputResult {
64        self.clear_error();
65
66        match (key.code, key.modifiers) {
67            // Submit
68            (KeyCode::Enter, _) => {
69                let value = self.buffer.clone();
70                InputResult::Submit(value)
71            }
72
73            // Cancel
74            (KeyCode::Esc, _) => InputResult::Cancel,
75
76            // Backspace - delete character before cursor
77            (KeyCode::Backspace, _) => {
78                if self.cursor > 0 {
79                    let start = self.previous_boundary();
80                    self.buffer.replace_range(start..self.cursor, "");
81                    self.cursor = start;
82                    self.snap_cursor_forward();
83                }
84                InputResult::Continue
85            }
86
87            // Delete - delete character at cursor
88            (KeyCode::Delete, _) => {
89                if self.cursor < self.buffer.len() {
90                    let end = self.next_boundary();
91                    self.buffer.replace_range(self.cursor..end, "");
92                    self.snap_cursor_forward();
93                }
94                InputResult::Continue
95            }
96
97            // Left arrow - move cursor left
98            (KeyCode::Left, _) => {
99                self.cursor = self.previous_boundary();
100                InputResult::Continue
101            }
102
103            // Right arrow - move cursor right
104            (KeyCode::Right, _) => {
105                self.cursor = self.next_boundary();
106                InputResult::Continue
107            }
108
109            // Home or Ctrl-A - move to start
110            (KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => {
111                self.cursor = 0;
112                InputResult::Continue
113            }
114
115            // End or Ctrl-E - move to end
116            (KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
117                self.cursor = self.buffer.len();
118                InputResult::Continue
119            }
120
121            // Ctrl-U - clear line
122            (KeyCode::Char('u'), KeyModifiers::CONTROL) => {
123                self.buffer.clear();
124                self.cursor = 0;
125                InputResult::Continue
126            }
127
128            // Ctrl-K - delete from cursor to end
129            (KeyCode::Char('k'), KeyModifiers::CONTROL) => {
130                self.buffer.truncate(self.cursor);
131                InputResult::Continue
132            }
133
134            // Ctrl-W - delete word before cursor
135            (KeyCode::Char('w'), KeyModifiers::CONTROL) => {
136                if self.cursor > 0 {
137                    let mut start = self.cursor;
138                    let mut found_word = false;
139                    for (index, grapheme) in self.buffer[..self.cursor].grapheme_indices(true).rev()
140                    {
141                        let whitespace = grapheme.chars().all(char::is_whitespace);
142                        if found_word && whitespace {
143                            break;
144                        }
145                        found_word |= !whitespace;
146                        start = index;
147                    }
148                    self.buffer.replace_range(start..self.cursor, "");
149                    self.cursor = start;
150                    self.snap_cursor_forward();
151                }
152                InputResult::Continue
153            }
154
155            // Regular character input
156            (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
157                self.buffer.insert(self.cursor, c);
158                self.cursor += c.len_utf8();
159                self.snap_cursor_forward();
160                InputResult::Continue
161            }
162
163            // Ignore other keys
164            _ => InputResult::Continue,
165        }
166    }
167
168    /// Validate the buffer as a filename.
169    ///
170    /// Returns Ok(()) if valid, Err with message if invalid.
171    pub fn validate_filename(&self) -> Result<(), String> {
172        gravityfile_ops::validate_filename(&self.buffer)
173    }
174
175    fn previous_boundary(&self) -> usize {
176        self.buffer
177            .grapheme_indices(true)
178            .map(|(index, _)| index)
179            .take_while(|&index| index < self.cursor)
180            .last()
181            .unwrap_or(0)
182    }
183
184    fn next_boundary(&self) -> usize {
185        self.buffer
186            .grapheme_indices(true)
187            .map(|(index, _)| index)
188            .find(|&index| index > self.cursor)
189            .unwrap_or(self.buffer.len())
190    }
191
192    fn snap_cursor_forward(&mut self) {
193        self.cursor = self
194            .buffer
195            .grapheme_indices(true)
196            .map(|(index, _)| index)
197            .find(|&index| index >= self.cursor)
198            .unwrap_or(self.buffer.len());
199    }
200}
201
202/// Result of handling input.
203#[derive(Debug, Clone)]
204pub enum InputResult {
205    /// Continue accepting input.
206    Continue,
207    /// User cancelled the input.
208    Cancel,
209    /// User submitted the input with this value.
210    Submit(String),
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
217
218    fn key_event(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
219        KeyEvent {
220            code,
221            modifiers,
222            kind: KeyEventKind::Press,
223            state: KeyEventState::NONE,
224        }
225    }
226
227    #[test]
228    fn unicode_editing_preserves_grapheme_boundaries() {
229        use ratatui::{buffer::Buffer, layout::Rect, style::Modifier, widgets::Widget};
230        for value in ["é日", "e\u{301}x", "👩‍💻🇺🇸1️⃣", "a\u{2003}word  "] {
231            let mut input = InputState::new();
232            for character in value.chars() {
233                input.handle_key(key_event(KeyCode::Char(character), KeyModifiers::NONE));
234                assert!(input.buffer.is_char_boundary(input.cursor));
235                for width in [0, 1, 6, 20, 80] {
236                    let area = Rect::new(0, 0, width, 24);
237                    let mut buffer = Buffer::empty(area);
238                    crate::ui::modals::InputModal::new(
239                        &crate::theme::Theme::default(),
240                        &input,
241                        "Rename",
242                        "Name",
243                    )
244                    .render(area, &mut buffer);
245                }
246            }
247            assert_eq!(input.buffer(), value);
248            let boundaries: Vec<_> = value
249                .grapheme_indices(true)
250                .map(|(index, _)| index)
251                .chain(std::iter::once(value.len()))
252                .collect();
253            for &boundary in boundaries.iter().rev().skip(1) {
254                input.handle_key(key_event(KeyCode::Left, KeyModifiers::NONE));
255                assert_eq!(input.cursor(), boundary);
256            }
257            for &boundary in boundaries.iter().skip(1) {
258                input.handle_key(key_event(KeyCode::Right, KeyModifiers::NONE));
259                assert_eq!(input.cursor(), boundary);
260            }
261            for remaining in (0..value.graphemes(true).count()).rev() {
262                input.handle_key(key_event(KeyCode::Backspace, KeyModifiers::NONE));
263                assert_eq!(input.buffer.graphemes(true).count(), remaining);
264            }
265            let mut input = InputState::with_initial(value);
266            input.handle_key(key_event(KeyCode::Home, KeyModifiers::NONE));
267            for remaining in (0..value.graphemes(true).count()).rev() {
268                input.handle_key(key_event(KeyCode::Delete, KeyModifiers::NONE));
269                assert_eq!(input.buffer.graphemes(true).count(), remaining);
270            }
271        }
272        let mut input = InputState::with_initial("é\u{2003}word  ");
273        input.handle_key(key_event(KeyCode::Char('w'), KeyModifiers::CONTROL));
274        assert_eq!(input.buffer(), "é\u{2003}");
275        input.handle_key(key_event(KeyCode::Char('w'), KeyModifiers::CONTROL));
276        assert!(input.buffer().is_empty());
277        let mut input = InputState::with_initial("🇺x🇸");
278        input.handle_key(key_event(KeyCode::Left, KeyModifiers::NONE));
279        input.handle_key(key_event(KeyCode::Backspace, KeyModifiers::NONE));
280        assert_eq!(input.buffer(), "🇺🇸");
281        assert_eq!(input.cursor(), input.buffer().len());
282        let mut input = InputState::with_initial("é日");
283        input.handle_key(key_event(KeyCode::Left, KeyModifiers::NONE));
284        let area = Rect::new(0, 0, 80, 24);
285        let mut buffer = Buffer::empty(area);
286        crate::ui::modals::InputModal::new(
287            &crate::theme::Theme::default(),
288            &input,
289            "Rename",
290            "Name",
291        )
292        .render(area, &mut buffer);
293        assert!(
294            buffer
295                .content()
296                .iter()
297                .any(|cell| cell.symbol() == "日" && cell.modifier.contains(Modifier::REVERSED))
298        );
299    }
300
301    #[test]
302    fn test_input_basic() {
303        let mut input = InputState::new();
304
305        // Type "test"
306        input.handle_key(key_event(KeyCode::Char('t'), KeyModifiers::NONE));
307        input.handle_key(key_event(KeyCode::Char('e'), KeyModifiers::NONE));
308        input.handle_key(key_event(KeyCode::Char('s'), KeyModifiers::NONE));
309        input.handle_key(key_event(KeyCode::Char('t'), KeyModifiers::NONE));
310
311        assert_eq!(input.buffer(), "test");
312        assert_eq!(input.cursor(), 4);
313    }
314
315    #[test]
316    fn test_input_backspace() {
317        let mut input = InputState::with_initial("test");
318
319        input.handle_key(key_event(KeyCode::Backspace, KeyModifiers::NONE));
320
321        assert_eq!(input.buffer(), "tes");
322        assert_eq!(input.cursor(), 3);
323    }
324
325    #[test]
326    fn test_input_cursor_movement() {
327        let mut input = InputState::with_initial("test");
328
329        // Move to start
330        input.handle_key(key_event(KeyCode::Home, KeyModifiers::NONE));
331        assert_eq!(input.cursor(), 0);
332
333        // Move to end
334        input.handle_key(key_event(KeyCode::End, KeyModifiers::NONE));
335        assert_eq!(input.cursor(), 4);
336
337        // Move left
338        input.handle_key(key_event(KeyCode::Left, KeyModifiers::NONE));
339        assert_eq!(input.cursor(), 3);
340
341        // Move right
342        input.handle_key(key_event(KeyCode::Right, KeyModifiers::NONE));
343        assert_eq!(input.cursor(), 4);
344    }
345
346    #[test]
347    fn test_validate_filename() {
348        let mut input = InputState::new();
349
350        // Valid names
351        input.buffer = "test.txt".to_string();
352        assert!(input.validate_filename().is_ok());
353
354        input.buffer = ".hidden".to_string();
355        assert!(input.validate_filename().is_ok());
356
357        input.buffer = "my-file_v2".to_string();
358        assert!(input.validate_filename().is_ok());
359
360        // Invalid names
361        input.buffer = "".to_string();
362        assert!(input.validate_filename().is_err());
363
364        input.buffer = "test/file".to_string();
365        assert!(input.validate_filename().is_err());
366
367        input.buffer = ".".to_string();
368        assert!(input.validate_filename().is_err());
369
370        input.buffer = "..".to_string();
371        assert!(input.validate_filename().is_err());
372    }
373
374    #[test]
375    fn test_submit_and_cancel() {
376        let mut input = InputState::with_initial("test");
377
378        let result = input.handle_key(key_event(KeyCode::Enter, KeyModifiers::NONE));
379        assert!(matches!(result, InputResult::Submit(s) if s == "test"));
380
381        let result = input.handle_key(key_event(KeyCode::Esc, KeyModifiers::NONE));
382        assert!(matches!(result, InputResult::Cancel));
383    }
384}