use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::queue::Queue;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InputAction {
Nothing,
ForwardToChild(Vec<u8>),
EnqueueCurrent {
command: String,
conditional: bool,
},
CommitEdit {
index: usize,
command: String,
conditional: bool,
},
CancelEdit,
DeleteEdited,
MoveEditedUp,
MoveEditedDown,
TogglePause,
ClearQueue,
ToggleForceQueue,
ToggleHelp,
ToggleChain {
now_on: bool,
},
}
#[derive(Debug, Default)]
pub struct LineEditor {
pub buffer: String,
pub cursor: usize,
pub editing_index: Option<usize>,
pub conditional: bool,
}
impl LineEditor {
pub fn new() -> Self {
Self::default()
}
pub fn reset(&mut self) {
self.buffer.clear();
self.cursor = 0;
self.editing_index = None;
self.conditional = false;
}
pub fn load_for_edit(&mut self, index: usize, item_command: &str, conditional: bool) {
self.buffer = item_command.to_string();
self.cursor = self.buffer.len();
self.editing_index = Some(index);
self.conditional = conditional;
}
fn prev_boundary(&self) -> usize {
self.buffer[..self.cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0)
}
fn next_boundary(&self) -> usize {
self.buffer[self.cursor..]
.char_indices()
.nth(1)
.map(|(i, _)| self.cursor + i)
.unwrap_or(self.buffer.len())
}
fn insert_char(&mut self, c: char) {
self.buffer.insert(self.cursor, c);
self.cursor += c.len_utf8();
}
pub fn insert_str(&mut self, s: &str) {
self.buffer.insert_str(self.cursor, s);
self.cursor += s.len();
}
fn backspace(&mut self) {
if self.cursor == 0 {
return;
}
let prev = self.prev_boundary();
self.buffer.replace_range(prev..self.cursor, "");
self.cursor = prev;
}
fn delete_at_cursor(&mut self) {
if self.cursor >= self.buffer.len() {
return;
}
let next = self.next_boundary();
self.buffer.replace_range(self.cursor..next, "");
}
fn move_left(&mut self) {
if self.cursor == 0 {
return;
}
self.cursor = self.prev_boundary();
}
fn move_right(&mut self) {
if self.cursor >= self.buffer.len() {
return;
}
self.cursor = self.next_boundary();
}
pub fn handle_key(&mut self, key: KeyEvent, queue: &Queue) -> InputAction {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
match key.code {
KeyCode::Char(c) if ctrl => match c {
'c' | 'C' => {
if !self.buffer.is_empty() {
self.reset();
InputAction::Nothing
} else {
InputAction::ForwardToChild(vec![0x03])
}
}
'd' | 'D' => {
if self.editing_index.is_some() {
InputAction::DeleteEdited
} else {
InputAction::Nothing
}
}
'k' | 'K' => InputAction::ClearQueue,
'x' | 'X' => InputAction::TogglePause,
'q' | 'Q' => InputAction::ToggleForceQueue,
'a' | 'A' => {
self.cursor = 0;
InputAction::Nothing
}
'e' | 'E' => {
self.cursor = self.buffer.len();
InputAction::Nothing
}
'u' | 'U' => {
self.buffer.replace_range(..self.cursor, "");
self.cursor = 0;
InputAction::Nothing
}
_ => InputAction::Nothing,
},
KeyCode::Char(_) if alt => InputAction::Nothing,
KeyCode::Char('?')
if !ctrl && self.buffer.is_empty() && self.editing_index.is_none() =>
{
InputAction::ToggleHelp
}
KeyCode::Char(c) => {
self.insert_char(c);
InputAction::Nothing
}
KeyCode::Backspace => {
self.backspace();
InputAction::Nothing
}
KeyCode::Delete => {
self.delete_at_cursor();
InputAction::Nothing
}
KeyCode::Left => {
self.move_left();
InputAction::Nothing
}
KeyCode::Right => {
self.move_right();
InputAction::Nothing
}
KeyCode::Home => {
self.cursor = 0;
InputAction::Nothing
}
KeyCode::End => {
self.cursor = self.buffer.len();
InputAction::Nothing
}
KeyCode::Up if alt && self.editing_index.is_some() => InputAction::MoveEditedUp,
KeyCode::Up if alt => InputAction::Nothing,
KeyCode::Up => {
self.navigate_up(queue);
InputAction::Nothing
}
KeyCode::Down if alt && self.editing_index.is_some() => InputAction::MoveEditedDown,
KeyCode::Down if alt => InputAction::Nothing,
KeyCode::Down => {
self.navigate_down(queue);
InputAction::Nothing
}
KeyCode::Tab => {
self.conditional = !self.conditional;
InputAction::ToggleChain {
now_on: self.conditional,
}
}
KeyCode::Enter => {
let cmd = self.buffer.trim().to_string();
if cmd.is_empty() {
InputAction::Nothing
} else if let Some(idx) = self.editing_index {
let cond = self.conditional;
self.reset();
InputAction::CommitEdit {
index: idx,
command: cmd,
conditional: cond,
}
} else {
let cond = self.conditional;
self.reset();
InputAction::EnqueueCurrent {
command: cmd,
conditional: cond,
}
}
}
KeyCode::Esc => {
if self.editing_index.is_some() {
self.reset();
InputAction::CancelEdit
} else {
self.reset();
InputAction::Nothing
}
}
_ => InputAction::Nothing,
}
}
fn navigate_up(&mut self, queue: &Queue) {
if queue.is_empty() {
return;
}
let target = match self.editing_index {
None => queue.len() - 1,
Some(i) => i.saturating_sub(1),
};
if let Some(item) = queue.items().get(target) {
self.load_for_edit(target, &item.command, item.conditional);
}
}
fn navigate_down(&mut self, queue: &Queue) {
if queue.is_empty() {
return;
}
match self.editing_index {
None => {}
Some(i) => {
if i + 1 >= queue.len() {
self.reset();
} else if let Some(item) = queue.items().get(i + 1) {
self.load_for_edit(i + 1, &item.command, item.conditional);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyEventKind;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
}
}
fn ctrl(c: char) -> KeyEvent {
KeyEvent {
code: KeyCode::Char(c),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
}
}
#[test]
fn typing_appends_and_enter_enqueues() {
let mut ed = LineEditor::new();
let q = Queue::new();
ed.handle_key(key(KeyCode::Char('l')), &q);
ed.handle_key(key(KeyCode::Char('s')), &q);
let action = ed.handle_key(key(KeyCode::Enter), &q);
match action {
InputAction::EnqueueCurrent { command, .. } => assert_eq!(command, "ls"),
other => panic!("got {:?}", other),
}
assert!(ed.buffer.is_empty());
}
#[test]
fn backspace_removes_char() {
let mut ed = LineEditor::new();
let q = Queue::new();
for c in "abc".chars() {
ed.handle_key(key(KeyCode::Char(c)), &q);
}
ed.handle_key(key(KeyCode::Backspace), &q);
assert_eq!(ed.buffer, "ab");
}
#[test]
fn navigate_up_loads_last_queue_item_for_edit() {
let mut q = Queue::new();
q.push("first", false);
q.push("second", false);
let mut ed = LineEditor::new();
ed.handle_key(key(KeyCode::Up), &q);
assert_eq!(ed.editing_index, Some(1));
assert_eq!(ed.buffer, "second");
ed.handle_key(key(KeyCode::Up), &q);
assert_eq!(ed.editing_index, Some(0));
assert_eq!(ed.buffer, "first");
}
#[test]
fn ctrl_c_clears_input_when_non_empty() {
let mut ed = LineEditor::new();
let q = Queue::new();
ed.handle_key(key(KeyCode::Char('x')), &q);
let action = ed.handle_key(ctrl('c'), &q);
assert_eq!(action, InputAction::Nothing);
assert!(ed.buffer.is_empty());
}
#[test]
fn ctrl_c_forwards_when_empty() {
let mut ed = LineEditor::new();
let q = Queue::new();
let action = ed.handle_key(ctrl('c'), &q);
assert_eq!(action, InputAction::ForwardToChild(vec![0x03]));
}
#[test]
fn esc_during_edit_cancels() {
let mut q = Queue::new();
q.push("x", false);
let mut ed = LineEditor::new();
ed.handle_key(key(KeyCode::Up), &q);
assert!(ed.editing_index.is_some());
let action = ed.handle_key(key(KeyCode::Esc), &q);
assert_eq!(action, InputAction::CancelEdit);
assert!(ed.editing_index.is_none());
}
#[test]
fn enter_during_edit_commits() {
let mut q = Queue::new();
q.push("orig", false);
let mut ed = LineEditor::new();
ed.handle_key(key(KeyCode::Up), &q);
ed.handle_key(key(KeyCode::Backspace), &q);
ed.handle_key(key(KeyCode::Char('!')), &q);
let action = ed.handle_key(key(KeyCode::Enter), &q);
match action {
InputAction::CommitEdit { index, command, .. } => {
assert_eq!(index, 0);
assert_eq!(command, "ori!");
}
other => panic!("got {:?}", other),
}
}
#[test]
fn ctrl_d_deletes_edited_item_with_text_in_buffer() {
let mut q = Queue::new();
q.push("orig", false);
let mut ed = LineEditor::new();
ed.handle_key(key(KeyCode::Up), &q);
assert_eq!(ed.buffer, "orig");
let action = ed.handle_key(ctrl('d'), &q);
assert_eq!(action, InputAction::DeleteEdited);
}
#[test]
fn ctrl_d_outside_edit_is_noop_in_editor() {
let mut ed = LineEditor::new();
let q = Queue::new();
ed.handle_key(key(KeyCode::Char('x')), &q);
let action = ed.handle_key(ctrl('d'), &q);
assert_eq!(action, InputAction::Nothing);
}
#[test]
fn ctrl_x_toggles_pause() {
let mut ed = LineEditor::new();
let q = Queue::new();
let action = ed.handle_key(ctrl('x'), &q);
assert_eq!(action, InputAction::TogglePause);
}
#[test]
fn ctrl_k_clears_queue() {
let mut ed = LineEditor::new();
let q = Queue::new();
let action = ed.handle_key(ctrl('k'), &q);
assert_eq!(action, InputAction::ClearQueue);
}
#[test]
fn empty_enter_is_noop() {
let mut ed = LineEditor::new();
let q = Queue::new();
let action = ed.handle_key(key(KeyCode::Enter), &q);
assert_eq!(action, InputAction::Nothing);
}
#[test]
fn question_mark_with_empty_buffer_opens_help() {
let mut ed = LineEditor::new();
let q = Queue::new();
let action = ed.handle_key(key(KeyCode::Char('?')), &q);
assert_eq!(action, InputAction::ToggleHelp);
}
#[test]
fn question_mark_with_text_in_buffer_inserts_normally() {
let mut ed = LineEditor::new();
let q = Queue::new();
ed.handle_key(key(KeyCode::Char('e')), &q);
ed.handle_key(key(KeyCode::Char('c')), &q);
ed.handle_key(key(KeyCode::Char('h')), &q);
let action = ed.handle_key(key(KeyCode::Char('?')), &q);
assert_eq!(action, InputAction::Nothing);
assert_eq!(ed.buffer, "ech?");
}
#[test]
fn question_mark_during_edit_inserts_normally() {
let mut q = Queue::new();
q.push("orig", false);
let mut ed = LineEditor::new();
ed.handle_key(key(KeyCode::Up), &q); let action = ed.handle_key(key(KeyCode::Char('?')), &q);
assert_eq!(action, InputAction::Nothing);
assert!(ed.buffer.ends_with('?'));
}
#[test]
fn tab_toggles_conditional() {
let mut ed = LineEditor::new();
let q = Queue::new();
assert!(!ed.conditional);
let a = ed.handle_key(key(KeyCode::Tab), &q);
assert_eq!(a, InputAction::ToggleChain { now_on: true });
assert!(ed.conditional);
let a = ed.handle_key(key(KeyCode::Tab), &q);
assert_eq!(a, InputAction::ToggleChain { now_on: false });
assert!(!ed.conditional);
}
}