use std::time::Instant;
pub const DOUBLE_PRESS_WINDOW: std::time::Duration = std::time::Duration::from_millis(2000);
pub fn double_press_window() -> std::time::Duration {
std::env::var("RECURSIVE_TUI_DOUBLE_MS")
.ok()
.and_then(|raw| raw.parse::<u64>().ok())
.map(std::time::Duration::from_millis)
.unwrap_or(DOUBLE_PRESS_WINDOW)
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DoublePressTracker {
pub last_esc_at: Option<Instant>,
pub last_ctrl_c_at: Option<Instant>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum InputMode {
Prompt,
Bash,
Note,
Command,
AtFile,
HistorySearch,
}
impl InputMode {
pub fn indicator(self) -> char {
match self {
InputMode::Prompt | InputMode::AtFile | InputMode::HistorySearch => '❯',
InputMode::Bash => '!',
InputMode::Note => '#',
InputMode::Command => '/',
}
}
pub fn history_prefix(self) -> &'static str {
match self {
InputMode::Prompt | InputMode::AtFile | InputMode::HistorySearch => "",
InputMode::Bash => "!",
InputMode::Note => "#",
InputMode::Command => "/",
}
}
pub fn cycle_next(self) -> InputMode {
match self {
InputMode::Prompt => InputMode::Bash,
InputMode::Bash => InputMode::Note,
InputMode::Note => InputMode::Prompt,
InputMode::Command | InputMode::AtFile | InputMode::HistorySearch => InputMode::Prompt,
}
}
}
pub const HISTORY_CAPACITY: usize = 200;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PromptInputState {
pub mode: InputMode,
pub buffer: String,
pub cursor: usize,
pub history: Vec<String>,
pub history_idx: Option<usize>,
pub draft: String,
pub draft_mode: InputMode,
}
impl Default for PromptInputState {
fn default() -> Self {
Self {
mode: InputMode::Prompt,
buffer: String::new(),
cursor: 0,
history: Vec::new(),
history_idx: None,
draft: String::new(),
draft_mode: InputMode::Prompt,
}
}
}
impl PromptInputState {
pub fn new() -> Self {
Self::default()
}
pub fn insert_char(&mut self, ch: char) {
self.buffer.insert(self.cursor, ch);
self.cursor += ch.len_utf8();
self.history_idx = None;
}
pub fn backspace(&mut self) -> bool {
if self.cursor == 0 {
return false;
}
let prev = self.buffer[..self.cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
self.buffer.drain(prev..self.cursor);
self.cursor = prev;
self.history_idx = None;
true
}
pub fn delete_forward(&mut self) {
if self.cursor >= self.buffer.len() {
return;
}
let after = self.buffer[self.cursor..]
.char_indices()
.nth(1)
.map(|(i, _)| self.cursor + i)
.unwrap_or(self.buffer.len());
self.buffer.drain(self.cursor..after);
self.history_idx = None;
}
pub fn move_left(&mut self) {
if self.cursor == 0 {
return;
}
self.cursor = self.buffer[..self.cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
}
pub fn move_right(&mut self) {
if self.cursor >= self.buffer.len() {
return;
}
let step = self.buffer[self.cursor..]
.chars()
.next()
.map(|c| c.len_utf8())
.unwrap_or(0);
self.cursor = (self.cursor + step).min(self.buffer.len());
}
pub fn move_home(&mut self) {
self.cursor = self.buffer[..self.cursor]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0);
}
pub fn move_end(&mut self) {
self.cursor = self.buffer[self.cursor..]
.find('\n')
.map(|i| self.cursor + i)
.unwrap_or(self.buffer.len());
}
pub fn move_prev_line(&mut self) {
if self.cursor_on_first_line() {
return;
}
let cur_line_start = self.buffer[..self.cursor]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0);
let col = self.cursor - cur_line_start;
let prev_line_end = cur_line_start - 1;
let prev_line_start = self.buffer[..prev_line_end]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0);
let prev_line_len = prev_line_end - prev_line_start;
self.cursor = prev_line_start + col.min(prev_line_len);
}
pub fn move_next_line(&mut self) {
if self.cursor_on_last_line() {
return;
}
let cur_line_start = self.buffer[..self.cursor]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0);
let col = self.cursor - cur_line_start;
let cur_line_end = self.buffer[self.cursor..]
.find('\n')
.map(|i| self.cursor + i)
.unwrap_or(self.buffer.len());
let next_line_start = cur_line_end + 1;
if next_line_start > self.buffer.len() {
return;
}
let next_line_end = self.buffer[next_line_start..]
.find('\n')
.map(|i| next_line_start + i)
.unwrap_or(self.buffer.len());
let next_line_len = next_line_end - next_line_start;
self.cursor = next_line_start + col.min(next_line_len);
}
pub fn cursor_on_first_line(&self) -> bool {
!self.buffer[..self.cursor].contains('\n')
}
pub fn cursor_on_last_line(&self) -> bool {
!self.buffer[self.cursor..].contains('\n')
}
fn enter_history_walk(&mut self) {
if self.history_idx.is_none() {
self.draft = self.buffer.clone();
self.draft_mode = self.mode;
self.history_idx = Some(self.history.len());
}
}
pub fn history_prev(&mut self) -> bool {
if self.history.is_empty() {
return false;
}
self.enter_history_walk();
let idx = self.history_idx.unwrap_or(self.history.len());
if idx == 0 {
return false;
}
let new_idx = idx - 1;
self.load_history(new_idx);
true
}
pub fn history_next(&mut self) -> bool {
let Some(idx) = self.history_idx else {
return false;
};
let next = idx + 1;
if next >= self.history.len() {
self.buffer = std::mem::take(&mut self.draft);
self.cursor = self.buffer.len();
self.mode = self.draft_mode;
self.history_idx = None;
} else {
self.load_history(next);
}
true
}
fn load_history(&mut self, idx: usize) {
let raw = &self.history[idx];
let (mode, body) = strip_history_prefix(raw);
self.mode = mode;
self.buffer = body.to_string();
self.cursor = self.buffer.len();
self.history_idx = Some(idx);
}
pub fn record_submission(&mut self, prefixed: String) {
if !prefixed.is_empty() {
self.history.push(prefixed);
if self.history.len() > HISTORY_CAPACITY {
let overflow = self.history.len() - HISTORY_CAPACITY;
self.history.drain(0..overflow);
}
}
self.buffer.clear();
self.cursor = 0;
self.mode = InputMode::Prompt;
self.history_idx = None;
self.draft.clear();
self.draft_mode = InputMode::Prompt;
}
}
pub fn strip_history_prefix(raw: &str) -> (InputMode, &str) {
if let Some(rest) = raw.strip_prefix('!') {
(InputMode::Bash, rest)
} else if let Some(rest) = raw.strip_prefix('#') {
(InputMode::Note, rest)
} else if let Some(rest) = raw.strip_prefix('/') {
(InputMode::Command, rest)
} else {
(InputMode::Prompt, raw)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn s(buf: &str, cursor: usize) -> PromptInputState {
PromptInputState {
buffer: buf.to_string(),
cursor,
..PromptInputState::default()
}
}
#[test]
fn prev_line_moves_to_same_column() {
let mut p = s("abc\ndef\nghi", 6); p.move_prev_line();
assert_eq!(p.cursor, 2, "should land on 'ab|c' of the first line");
}
#[test]
fn next_line_moves_to_same_column() {
let mut p = s("abc\ndef\nghi", 2); p.move_next_line();
assert_eq!(p.cursor, 6, "should land on 'de|f' of the second line");
}
#[test]
fn prev_line_handles_short_target_line() {
let mut p = s("ab\ndef\nghi", 8);
p.move_prev_line();
assert_eq!(p.cursor, 4, "should land on 'd|ef' of the second line");
}
#[test]
fn next_line_clamps_to_shorter_line() {
let mut p = s("abc\nde\nghi", 3);
p.move_next_line();
assert_eq!(p.cursor, 6, "clamped to end of 'de|'");
}
#[test]
fn prev_line_noop_on_first_line() {
let mut p = s("hello", 3);
p.move_prev_line();
assert_eq!(p.cursor, 3, "first line is a no-op");
}
#[test]
fn next_line_noop_on_last_line() {
let mut p = s("hello", 3);
p.move_next_line();
assert_eq!(p.cursor, 3, "last line is a no-op");
}
#[test]
fn prev_line_three_lines_walks_back_step_by_step() {
let mut p = s("first\nsecond\nthird", 14);
p.move_prev_line();
assert_eq!(p.cursor, 7, "second line, col 1 ('s|econd')");
p.move_prev_line();
assert_eq!(p.cursor, 1, "first line, col 1 ('f|irst')");
p.move_prev_line();
assert_eq!(p.cursor, 1, "first line is a no-op");
}
#[test]
fn next_line_three_lines_walks_forward_step_by_step() {
let mut p = s("first\nsecond\nthird", 2);
p.move_next_line();
assert_eq!(p.cursor, 8, "second line, col 2 ('seco|nd')");
p.move_next_line();
assert_eq!(p.cursor, 15, "third line, col 2 ('thi|rd')");
p.move_next_line();
assert_eq!(p.cursor, 15, "last line is a no-op");
}
#[test]
fn prev_line_handles_empty_intermediate_line() {
let mut p = s("abc\n\ndef", 7);
p.move_prev_line();
assert_eq!(p.cursor, 4, "empty line, col 0 (just past '\\n')");
}
#[test]
fn next_line_handles_empty_intermediate_line() {
let mut p = s("abc\n\ndef", 1);
p.move_next_line();
assert_eq!(p.cursor, 4, "empty line, col 0");
}
}