gravityfile_tui/app/
input.rs1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4
5#[derive(Debug, Clone, Default)]
7pub struct InputState {
8 buffer: String,
10 cursor: usize,
12 original: Option<String>,
14 error: Option<String>,
16 pub context_path: Option<std::path::PathBuf>,
18}
19
20impl InputState {
21 pub fn new() -> Self {
23 Self::default()
24 }
25
26 pub fn with_initial(value: &str) -> Self {
28 Self {
29 buffer: value.to_string(),
30 cursor: value.len(),
31 original: Some(value.to_string()),
32 error: None,
33 context_path: None,
34 }
35 }
36
37 pub fn with_context(context_path: std::path::PathBuf) -> Self {
39 Self {
40 context_path: Some(context_path),
41 ..Default::default()
42 }
43 }
44
45 pub fn buffer(&self) -> &str {
47 &self.buffer
48 }
49
50 pub fn cursor(&self) -> usize {
52 self.cursor
53 }
54
55 pub fn original(&self) -> Option<&str> {
57 self.original.as_deref()
58 }
59
60 pub fn error(&self) -> Option<&str> {
62 self.error.as_deref()
63 }
64
65 pub fn set_error(&mut self, error: impl Into<String>) {
67 self.error = Some(error.into());
68 }
69
70 pub fn clear_error(&mut self) {
72 self.error = None;
73 }
74
75 pub fn handle_key(&mut self, key: KeyEvent) -> InputResult {
79 self.clear_error();
80
81 match (key.code, key.modifiers) {
82 (KeyCode::Enter, _) => {
84 let value = self.buffer.clone();
85 InputResult::Submit(value)
86 }
87
88 (KeyCode::Esc, _) => InputResult::Cancel,
90
91 (KeyCode::Backspace, _) => {
93 if self.cursor > 0 {
94 self.cursor -= 1;
95 self.buffer.remove(self.cursor);
96 }
97 InputResult::Continue
98 }
99
100 (KeyCode::Delete, _) => {
102 if self.cursor < self.buffer.len() {
103 self.buffer.remove(self.cursor);
104 }
105 InputResult::Continue
106 }
107
108 (KeyCode::Left, _) => {
110 self.cursor = self.cursor.saturating_sub(1);
111 InputResult::Continue
112 }
113
114 (KeyCode::Right, _) => {
116 self.cursor = (self.cursor + 1).min(self.buffer.len());
117 InputResult::Continue
118 }
119
120 (KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => {
122 self.cursor = 0;
123 InputResult::Continue
124 }
125
126 (KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
128 self.cursor = self.buffer.len();
129 InputResult::Continue
130 }
131
132 (KeyCode::Char('u'), KeyModifiers::CONTROL) => {
134 self.buffer.clear();
135 self.cursor = 0;
136 InputResult::Continue
137 }
138
139 (KeyCode::Char('k'), KeyModifiers::CONTROL) => {
141 self.buffer.truncate(self.cursor);
142 InputResult::Continue
143 }
144
145 (KeyCode::Char('w'), KeyModifiers::CONTROL) => {
147 if self.cursor > 0 {
148 let before = &self.buffer[..self.cursor];
150 let word_start = before
151 .rfind(|c: char| c.is_whitespace())
152 .map(|i| i + 1)
153 .unwrap_or(0);
154 self.buffer.replace_range(word_start..self.cursor, "");
155 self.cursor = word_start;
156 }
157 InputResult::Continue
158 }
159
160 (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
162 self.buffer.insert(self.cursor, c);
163 self.cursor += 1;
164 InputResult::Continue
165 }
166
167 _ => InputResult::Continue,
169 }
170 }
171
172 pub fn validate_filename(&self) -> Result<(), String> {
176 let name = &self.buffer;
177
178 if name.is_empty() {
179 return Err("Name cannot be empty".into());
180 }
181
182 if name.len() > 255 {
183 return Err("Name is too long (max 255 characters)".into());
184 }
185
186 if name.contains('/') {
188 return Err("Name cannot contain '/'".into());
189 }
190
191 if name.contains('\0') {
192 return Err("Name cannot contain null character".into());
193 }
194
195 if name == "." || name == ".." {
197 return Err("'.' and '..' are reserved names".into());
198 }
199
200 if name.starts_with(' ') || name.ends_with(' ') {
202 return Err("Name cannot start or end with spaces".into());
203 }
204
205 if name.ends_with('.') && name != "." {
207 return Err("Name cannot end with a dot".into());
208 }
209
210 Ok(())
211 }
212
213 pub fn has_changed(&self) -> bool {
215 self.original.as_deref() != Some(&self.buffer)
216 }
217}
218
219#[derive(Debug, Clone)]
221pub enum InputResult {
222 Continue,
224 Cancel,
226 Submit(String),
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
234
235 fn key_event(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
236 KeyEvent {
237 code,
238 modifiers,
239 kind: KeyEventKind::Press,
240 state: KeyEventState::NONE,
241 }
242 }
243
244 #[test]
245 fn test_input_basic() {
246 let mut input = InputState::new();
247
248 input.handle_key(key_event(KeyCode::Char('t'), KeyModifiers::NONE));
250 input.handle_key(key_event(KeyCode::Char('e'), KeyModifiers::NONE));
251 input.handle_key(key_event(KeyCode::Char('s'), KeyModifiers::NONE));
252 input.handle_key(key_event(KeyCode::Char('t'), KeyModifiers::NONE));
253
254 assert_eq!(input.buffer(), "test");
255 assert_eq!(input.cursor(), 4);
256 }
257
258 #[test]
259 fn test_input_backspace() {
260 let mut input = InputState::with_initial("test");
261
262 input.handle_key(key_event(KeyCode::Backspace, KeyModifiers::NONE));
263
264 assert_eq!(input.buffer(), "tes");
265 assert_eq!(input.cursor(), 3);
266 }
267
268 #[test]
269 fn test_input_cursor_movement() {
270 let mut input = InputState::with_initial("test");
271
272 input.handle_key(key_event(KeyCode::Home, KeyModifiers::NONE));
274 assert_eq!(input.cursor(), 0);
275
276 input.handle_key(key_event(KeyCode::End, KeyModifiers::NONE));
278 assert_eq!(input.cursor(), 4);
279
280 input.handle_key(key_event(KeyCode::Left, KeyModifiers::NONE));
282 assert_eq!(input.cursor(), 3);
283
284 input.handle_key(key_event(KeyCode::Right, KeyModifiers::NONE));
286 assert_eq!(input.cursor(), 4);
287 }
288
289 #[test]
290 fn test_validate_filename() {
291 let mut input = InputState::new();
292
293 input.buffer = "test.txt".to_string();
295 assert!(input.validate_filename().is_ok());
296
297 input.buffer = ".hidden".to_string();
298 assert!(input.validate_filename().is_ok());
299
300 input.buffer = "my-file_v2".to_string();
301 assert!(input.validate_filename().is_ok());
302
303 input.buffer = "".to_string();
305 assert!(input.validate_filename().is_err());
306
307 input.buffer = "test/file".to_string();
308 assert!(input.validate_filename().is_err());
309
310 input.buffer = ".".to_string();
311 assert!(input.validate_filename().is_err());
312
313 input.buffer = "..".to_string();
314 assert!(input.validate_filename().is_err());
315 }
316
317 #[test]
318 fn test_submit_and_cancel() {
319 let mut input = InputState::with_initial("test");
320
321 let result = input.handle_key(key_event(KeyCode::Enter, KeyModifiers::NONE));
322 assert!(matches!(result, InputResult::Submit(s) if s == "test"));
323
324 let result = input.handle_key(key_event(KeyCode::Esc, KeyModifiers::NONE));
325 assert!(matches!(result, InputResult::Cancel));
326 }
327}