1use std::time::Instant;
8
9pub const DOUBLE_PRESS_WINDOW: std::time::Duration = std::time::Duration::from_millis(2000);
17
18pub fn double_press_window() -> std::time::Duration {
23 std::env::var("RECURSIVE_TUI_DOUBLE_MS")
24 .ok()
25 .and_then(|raw| raw.parse::<u64>().ok())
26 .map(std::time::Duration::from_millis)
27 .unwrap_or(DOUBLE_PRESS_WINDOW)
28}
29
30#[derive(Clone, Debug, Default, PartialEq, Eq)]
35pub struct DoublePressTracker {
36 pub last_esc_at: Option<Instant>,
37 pub last_ctrl_c_at: Option<Instant>,
38}
39
40#[derive(Clone, Copy, PartialEq, Eq, Debug)]
59pub enum InputMode {
60 Prompt,
62 Bash,
65 Note,
68 Command,
71 AtFile,
74 HistorySearch,
77}
78
79impl InputMode {
80 pub fn indicator(self) -> char {
82 match self {
83 InputMode::Prompt | InputMode::AtFile | InputMode::HistorySearch => '❯',
84 InputMode::Bash => '!',
85 InputMode::Note => '#',
86 InputMode::Command => '/',
87 }
88 }
89
90 pub fn history_prefix(self) -> &'static str {
93 match self {
94 InputMode::Prompt | InputMode::AtFile | InputMode::HistorySearch => "",
95 InputMode::Bash => "!",
96 InputMode::Note => "#",
97 InputMode::Command => "/",
98 }
99 }
100
101 pub fn cycle_next(self) -> InputMode {
105 match self {
106 InputMode::Prompt => InputMode::Bash,
107 InputMode::Bash => InputMode::Note,
108 InputMode::Note => InputMode::Prompt,
109 InputMode::Command | InputMode::AtFile | InputMode::HistorySearch => InputMode::Prompt,
110 }
111 }
112}
113
114pub const HISTORY_CAPACITY: usize = 200;
116
117#[derive(Clone, Debug, PartialEq, Eq)]
123pub struct PromptInputState {
124 pub mode: InputMode,
125 pub buffer: String,
126 pub cursor: usize,
128 pub history: Vec<String>,
131 pub history_idx: Option<usize>,
133 pub draft: String,
137 pub draft_mode: InputMode,
140}
141
142impl Default for PromptInputState {
143 fn default() -> Self {
144 Self {
145 mode: InputMode::Prompt,
146 buffer: String::new(),
147 cursor: 0,
148 history: Vec::new(),
149 history_idx: None,
150 draft: String::new(),
151 draft_mode: InputMode::Prompt,
152 }
153 }
154}
155
156impl PromptInputState {
157 pub fn new() -> Self {
158 Self::default()
159 }
160
161 pub fn insert_char(&mut self, ch: char) {
164 self.buffer.insert(self.cursor, ch);
165 self.cursor += ch.len_utf8();
166 self.history_idx = None;
167 }
168
169 pub fn backspace(&mut self) -> bool {
172 if self.cursor == 0 {
173 return false;
174 }
175 let prev = self.buffer[..self.cursor]
176 .char_indices()
177 .next_back()
178 .map(|(i, _)| i)
179 .unwrap_or(0);
180 self.buffer.drain(prev..self.cursor);
181 self.cursor = prev;
182 self.history_idx = None;
183 true
184 }
185
186 pub fn delete_forward(&mut self) {
188 if self.cursor >= self.buffer.len() {
189 return;
190 }
191 let after = self.buffer[self.cursor..]
192 .char_indices()
193 .nth(1)
194 .map(|(i, _)| self.cursor + i)
195 .unwrap_or(self.buffer.len());
196 self.buffer.drain(self.cursor..after);
197 self.history_idx = None;
198 }
199
200 pub fn move_left(&mut self) {
202 if self.cursor == 0 {
203 return;
204 }
205 self.cursor = self.buffer[..self.cursor]
206 .char_indices()
207 .next_back()
208 .map(|(i, _)| i)
209 .unwrap_or(0);
210 }
211
212 pub fn move_right(&mut self) {
214 if self.cursor >= self.buffer.len() {
215 return;
216 }
217 let step = self.buffer[self.cursor..]
218 .chars()
219 .next()
220 .map(|c| c.len_utf8())
221 .unwrap_or(0);
222 self.cursor = (self.cursor + step).min(self.buffer.len());
223 }
224
225 pub fn move_home(&mut self) {
227 self.cursor = self.buffer[..self.cursor]
228 .rfind('\n')
229 .map(|i| i + 1)
230 .unwrap_or(0);
231 }
232
233 pub fn move_end(&mut self) {
235 self.cursor = self.buffer[self.cursor..]
236 .find('\n')
237 .map(|i| self.cursor + i)
238 .unwrap_or(self.buffer.len());
239 }
240
241 pub fn move_prev_line(&mut self) {
247 if self.cursor_on_first_line() {
248 return;
249 }
250 let cur_line_start = self.buffer[..self.cursor]
252 .rfind('\n')
253 .map(|i| i + 1)
254 .unwrap_or(0);
255 let col = self.cursor - cur_line_start;
256 let prev_line_end = cur_line_start - 1;
259 let prev_line_start = self.buffer[..prev_line_end]
260 .rfind('\n')
261 .map(|i| i + 1)
262 .unwrap_or(0);
263 let prev_line_len = prev_line_end - prev_line_start;
264 self.cursor = prev_line_start + col.min(prev_line_len);
265 }
266
267 pub fn move_next_line(&mut self) {
273 if self.cursor_on_last_line() {
274 return;
275 }
276 let cur_line_start = self.buffer[..self.cursor]
277 .rfind('\n')
278 .map(|i| i + 1)
279 .unwrap_or(0);
280 let col = self.cursor - cur_line_start;
281 let cur_line_end = self.buffer[self.cursor..]
283 .find('\n')
284 .map(|i| self.cursor + i)
285 .unwrap_or(self.buffer.len());
286 let next_line_start = cur_line_end + 1;
288 if next_line_start > self.buffer.len() {
289 return;
290 }
291 let next_line_end = self.buffer[next_line_start..]
292 .find('\n')
293 .map(|i| next_line_start + i)
294 .unwrap_or(self.buffer.len());
295 let next_line_len = next_line_end - next_line_start;
296 self.cursor = next_line_start + col.min(next_line_len);
297 }
298
299 pub fn cursor_on_first_line(&self) -> bool {
301 !self.buffer[..self.cursor].contains('\n')
302 }
303
304 pub fn cursor_on_last_line(&self) -> bool {
306 !self.buffer[self.cursor..].contains('\n')
307 }
308
309 fn enter_history_walk(&mut self) {
312 if self.history_idx.is_none() {
313 self.draft = self.buffer.clone();
314 self.draft_mode = self.mode;
315 self.history_idx = Some(self.history.len());
316 }
317 }
318
319 pub fn history_prev(&mut self) -> bool {
322 if self.history.is_empty() {
323 return false;
324 }
325 self.enter_history_walk();
326 let idx = self.history_idx.unwrap_or(self.history.len());
327 if idx == 0 {
328 return false;
329 }
330 let new_idx = idx - 1;
331 self.load_history(new_idx);
332 true
333 }
334
335 pub fn history_next(&mut self) -> bool {
339 let Some(idx) = self.history_idx else {
340 return false;
341 };
342 let next = idx + 1;
343 if next >= self.history.len() {
344 self.buffer = std::mem::take(&mut self.draft);
346 self.cursor = self.buffer.len();
347 self.mode = self.draft_mode;
348 self.history_idx = None;
349 } else {
350 self.load_history(next);
351 }
352 true
353 }
354
355 fn load_history(&mut self, idx: usize) {
356 let raw = &self.history[idx];
357 let (mode, body) = strip_history_prefix(raw);
358 self.mode = mode;
359 self.buffer = body.to_string();
360 self.cursor = self.buffer.len();
361 self.history_idx = Some(idx);
362 }
363
364 pub fn record_submission(&mut self, prefixed: String) {
367 if !prefixed.is_empty() {
368 self.history.push(prefixed);
369 if self.history.len() > HISTORY_CAPACITY {
370 let overflow = self.history.len() - HISTORY_CAPACITY;
371 self.history.drain(0..overflow);
372 }
373 }
374 self.buffer.clear();
375 self.cursor = 0;
376 self.mode = InputMode::Prompt;
377 self.history_idx = None;
378 self.draft.clear();
379 self.draft_mode = InputMode::Prompt;
380 }
381}
382
383pub fn strip_history_prefix(raw: &str) -> (InputMode, &str) {
384 if let Some(rest) = raw.strip_prefix('!') {
385 (InputMode::Bash, rest)
386 } else if let Some(rest) = raw.strip_prefix('#') {
387 (InputMode::Note, rest)
388 } else if let Some(rest) = raw.strip_prefix('/') {
389 (InputMode::Command, rest)
390 } else {
391 (InputMode::Prompt, raw)
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 fn s(buf: &str, cursor: usize) -> PromptInputState {
400 PromptInputState {
401 buffer: buf.to_string(),
402 cursor,
403 ..PromptInputState::default()
404 }
405 }
406
407 #[test]
408 fn prev_line_moves_to_same_column() {
409 let mut p = s("abc\ndef\nghi", 6); p.move_prev_line();
411 assert_eq!(p.cursor, 2, "should land on 'ab|c' of the first line");
412 }
413
414 #[test]
415 fn next_line_moves_to_same_column() {
416 let mut p = s("abc\ndef\nghi", 2); p.move_next_line();
418 assert_eq!(p.cursor, 6, "should land on 'de|f' of the second line");
419 }
420
421 #[test]
422 fn prev_line_handles_short_target_line() {
423 let mut p = s("ab\ndef\nghi", 8);
427 p.move_prev_line();
428 assert_eq!(p.cursor, 4, "should land on 'd|ef' of the second line");
429 }
430
431 #[test]
432 fn next_line_clamps_to_shorter_line() {
433 let mut p = s("abc\nde\nghi", 3);
437 p.move_next_line();
438 assert_eq!(p.cursor, 6, "clamped to end of 'de|'");
439 }
440
441 #[test]
442 fn prev_line_noop_on_first_line() {
443 let mut p = s("hello", 3);
444 p.move_prev_line();
445 assert_eq!(p.cursor, 3, "first line is a no-op");
446 }
447
448 #[test]
449 fn next_line_noop_on_last_line() {
450 let mut p = s("hello", 3);
451 p.move_next_line();
452 assert_eq!(p.cursor, 3, "last line is a no-op");
453 }
454
455 #[test]
456 fn prev_line_three_lines_walks_back_step_by_step() {
457 let mut p = s("first\nsecond\nthird", 14);
461 p.move_prev_line();
462 assert_eq!(p.cursor, 7, "second line, col 1 ('s|econd')");
463 p.move_prev_line();
464 assert_eq!(p.cursor, 1, "first line, col 1 ('f|irst')");
465 p.move_prev_line();
466 assert_eq!(p.cursor, 1, "first line is a no-op");
467 }
468
469 #[test]
470 fn next_line_three_lines_walks_forward_step_by_step() {
471 let mut p = s("first\nsecond\nthird", 2);
473 p.move_next_line();
474 assert_eq!(p.cursor, 8, "second line, col 2 ('seco|nd')");
475 p.move_next_line();
476 assert_eq!(p.cursor, 15, "third line, col 2 ('thi|rd')");
477 p.move_next_line();
478 assert_eq!(p.cursor, 15, "last line is a no-op");
479 }
480
481 #[test]
482 fn prev_line_handles_empty_intermediate_line() {
483 let mut p = s("abc\n\ndef", 7);
486 p.move_prev_line();
487 assert_eq!(p.cursor, 4, "empty line, col 0 (just past '\\n')");
489 }
490
491 #[test]
492 fn next_line_handles_empty_intermediate_line() {
493 let mut p = s("abc\n\ndef", 1);
496 p.move_next_line();
497 assert_eq!(p.cursor, 4, "empty line, col 0");
499 }
500}