use crossterm::event::{KeyCode, KeyEvent};
use super::app::{Action, App, FocusPane, Mode};
use super::style::ALL_STATUSES;
enum TextInputResult {
Submit(String),
Update(String),
Cancel,
Ignored,
}
fn resolve_text_input(key: KeyEvent, input: &str) -> TextInputResult {
match key.code {
KeyCode::Enter => TextInputResult::Submit(input.trim().to_string()),
KeyCode::Esc => TextInputResult::Cancel,
KeyCode::Backspace => {
let mut s = input.to_string();
s.pop();
TextInputResult::Update(s)
}
KeyCode::Char(c) => {
let mut s = input.to_string();
s.push(c);
TextInputResult::Update(s)
}
_ => TextInputResult::Ignored,
}
}
impl App {
pub fn handle_key(&mut self, key: KeyEvent) -> Action {
let action = match &self.mode {
Mode::Normal => self.handle_normal_key(key),
Mode::StatusSelect { .. } => self.handle_status_select_key(key),
Mode::CreateInput { .. } => self.handle_create_input_key(key),
Mode::FilterInput { .. } => self.handle_filter_input_key(key),
Mode::ConfirmDelete { .. } => self.handle_confirm_delete_key(key),
};
self.check_scroll_reset();
action
}
fn handle_normal_key(&mut self, key: KeyEvent) -> Action {
match key.code {
KeyCode::Esc if self.focus == FocusPane::Detail => {
self.focus = FocusPane::List;
Action::None
}
KeyCode::Char('q') | KeyCode::Esc => Action::Quit,
KeyCode::Tab => {
self.focus = match self.focus {
FocusPane::List => FocusPane::Detail,
FocusPane::Detail => FocusPane::List,
};
Action::None
}
KeyCode::Right if self.focus == FocusPane::List => {
self.focus = FocusPane::Detail;
Action::None
}
KeyCode::Left if self.focus == FocusPane::Detail => {
self.focus = FocusPane::List;
Action::None
}
KeyCode::Char('j') | KeyCode::Down => {
match self.focus {
FocusPane::List => self.select_next(),
FocusPane::Detail => {
self.detail_scroll = self
.detail_scroll
.saturating_add(1)
.min(self.detail_max_scroll());
}
}
Action::None
}
KeyCode::Char('k') | KeyCode::Up => {
match self.focus {
FocusPane::List => self.select_prev(),
FocusPane::Detail => {
self.detail_scroll = self.detail_scroll.saturating_sub(1);
}
}
Action::None
}
KeyCode::Home | KeyCode::Char('g') => {
match self.focus {
FocusPane::List => self.select_first(),
FocusPane::Detail => self.detail_scroll = 0,
}
Action::None
}
KeyCode::End | KeyCode::Char('G') => {
match self.focus {
FocusPane::List => self.select_last(),
FocusPane::Detail => { }
}
Action::None
}
KeyCode::Enter if self.focus == FocusPane::List => {
self.focus = FocusPane::Detail;
Action::None
}
KeyCode::Char('e') => Action::EditSelected,
KeyCode::Char('c') => {
self.mode = Mode::CreateInput {
input: String::new(),
};
Action::None
}
KeyCode::Char('s') => {
if let Some(task) = self.selected_task() {
let current_idx = ALL_STATUSES
.iter()
.position(|s| *s == task.status)
.unwrap_or(0);
self.mode = Mode::StatusSelect {
task_id: task.id.clone(),
selected: current_idx,
};
}
Action::None
}
KeyCode::Char('/') => {
self.mode = Mode::FilterInput {
input: self.filter.query.clone(),
};
Action::None
}
KeyCode::Char('m') => {
self.filter.list_mode = self.filter.list_mode.next();
self.apply_filter();
Action::None
}
KeyCode::Char('d') => {
if let Some(task) = self.selected_task() {
self.mode = Mode::ConfirmDelete {
task_id: task.id.clone(),
title: task.title.clone(),
};
}
Action::None
}
_ => Action::None,
}
}
fn handle_status_select_key(&mut self, key: KeyEvent) -> Action {
let (task_id, selected) = match &self.mode {
Mode::StatusSelect { task_id, selected } => (task_id.clone(), *selected),
_ => return Action::None,
};
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
self.mode = Mode::Normal;
Action::None
}
KeyCode::Char('j') | KeyCode::Down => {
let next = if selected >= ALL_STATUSES.len() - 1 {
0
} else {
selected + 1
};
self.mode = Mode::StatusSelect {
task_id,
selected: next,
};
Action::None
}
KeyCode::Char('k') | KeyCode::Up => {
let prev = if selected == 0 {
ALL_STATUSES.len() - 1
} else {
selected - 1
};
self.mode = Mode::StatusSelect {
task_id,
selected: prev,
};
Action::None
}
KeyCode::Enter => {
let status = ALL_STATUSES[selected];
self.mode = Mode::Normal;
Action::UpdateStatus(task_id, status)
}
_ => Action::None,
}
}
fn handle_create_input_key(&mut self, key: KeyEvent) -> Action {
let input = match &self.mode {
Mode::CreateInput { input } => input.clone(),
_ => return Action::None,
};
match resolve_text_input(key, &input) {
TextInputResult::Submit(title) => {
self.mode = Mode::Normal;
if title.is_empty() {
Action::None
} else {
Action::CreateTask(title)
}
}
TextInputResult::Update(new_input) => {
self.mode = Mode::CreateInput { input: new_input };
Action::None
}
TextInputResult::Cancel => {
self.mode = Mode::Normal;
Action::None
}
TextInputResult::Ignored => Action::None,
}
}
fn handle_filter_input_key(&mut self, key: KeyEvent) -> Action {
let input = match &self.mode {
Mode::FilterInput { input } => input.clone(),
_ => return Action::None,
};
match resolve_text_input(key, &input) {
TextInputResult::Submit(query) => {
self.filter.query = query;
self.mode = Mode::Normal;
self.apply_filter();
Action::None
}
TextInputResult::Update(new_input) => {
self.mode = Mode::FilterInput { input: new_input };
Action::None
}
TextInputResult::Cancel => {
self.mode = Mode::Normal;
Action::None
}
TextInputResult::Ignored => Action::None,
}
}
fn handle_confirm_delete_key(&mut self, key: KeyEvent) -> Action {
let task_id = match &self.mode {
Mode::ConfirmDelete { task_id, .. } => task_id.clone(),
_ => return Action::None,
};
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
self.mode = Mode::Normal;
Action::DeleteTask(task_id)
}
_ => {
self.mode = Mode::Normal;
Action::None
}
}
}
pub(super) fn select_next(&mut self) {
if self.tasks.is_empty() {
return;
}
let i = self.selected_index().unwrap_or(0);
let next = if i >= self.tasks.len() - 1 { 0 } else { i + 1 };
self.list_state.select(Some(next));
}
pub(super) fn select_prev(&mut self) {
if self.tasks.is_empty() {
return;
}
let i = self.selected_index().unwrap_or(0);
let prev = if i == 0 { self.tasks.len() - 1 } else { i - 1 };
self.list_state.select(Some(prev));
}
pub(super) fn select_first(&mut self) {
if !self.tasks.is_empty() {
self.list_state.select(Some(0));
}
}
pub(super) fn select_last(&mut self) {
if !self.tasks.is_empty() {
self.list_state.select(Some(self.tasks.len() - 1));
}
}
}
#[cfg(test)]
mod tests {
use super::super::app::ListMode;
use super::super::app::test_helpers::{make_app, make_key};
use super::*;
use crossterm::event::KeyCode;
#[test]
fn test_initial_selection() {
let app = make_app();
assert_eq!(app.selected_index(), Some(0));
assert!(app.selected_task().is_some());
}
#[test]
fn test_navigate_down_wraps() {
let mut app = make_app();
let visible = app.tasks.len();
for _ in 0..visible {
app.handle_key(make_key(KeyCode::Char('j')));
}
assert_eq!(app.selected_index(), Some(0));
}
#[test]
fn test_navigate_up_wraps() {
let mut app = make_app();
app.handle_key(make_key(KeyCode::Char('k')));
assert_eq!(app.selected_index(), Some(app.tasks.len() - 1));
}
#[test]
fn test_quit_action() {
let mut app = make_app();
let action = app.handle_key(make_key(KeyCode::Char('q')));
assert!(matches!(action, Action::Quit));
}
#[test]
fn test_esc_quits_from_normal() {
let mut app = make_app();
let action = app.handle_key(make_key(KeyCode::Esc));
assert!(matches!(action, Action::Quit));
}
#[test]
fn test_esc_returns_focus_from_detail() {
let mut app = make_app();
app.handle_key(make_key(KeyCode::Tab));
assert_eq!(app.focus, FocusPane::Detail);
let action = app.handle_key(make_key(KeyCode::Esc));
assert!(matches!(action, Action::None));
assert_eq!(app.focus, FocusPane::List);
let action = app.handle_key(make_key(KeyCode::Esc));
assert!(matches!(action, Action::Quit));
}
#[test]
fn test_edit_action() {
let mut app = make_app();
let action = app.handle_key(make_key(KeyCode::Char('e')));
assert!(matches!(action, Action::EditSelected));
}
#[test]
fn test_create_mode() {
let mut app = make_app();
let action = app.handle_key(make_key(KeyCode::Char('c')));
assert!(matches!(action, Action::None));
assert!(matches!(app.mode, Mode::CreateInput { .. }));
app.handle_key(make_key(KeyCode::Char('H')));
app.handle_key(make_key(KeyCode::Char('i')));
let action = app.handle_key(make_key(KeyCode::Enter));
assert!(matches!(action, Action::CreateTask(ref t) if t == "Hi"));
assert_eq!(app.mode, Mode::Normal);
}
#[test]
fn test_create_cancel() {
let mut app = make_app();
app.handle_key(make_key(KeyCode::Char('c')));
app.handle_key(make_key(KeyCode::Char('x')));
let action = app.handle_key(make_key(KeyCode::Esc));
assert!(matches!(action, Action::None));
assert_eq!(app.mode, Mode::Normal);
}
#[test]
fn test_create_empty_title_no_action() {
let mut app = make_app();
app.handle_key(make_key(KeyCode::Char('c')));
let action = app.handle_key(make_key(KeyCode::Enter));
assert!(matches!(action, Action::None));
}
#[test]
fn test_status_select_mode() {
let mut app = make_app();
app.handle_key(make_key(KeyCode::Char('s')));
assert!(matches!(app.mode, Mode::StatusSelect { .. }));
app.handle_key(make_key(KeyCode::Char('j')));
let action = app.handle_key(make_key(KeyCode::Enter));
assert!(matches!(action, Action::UpdateStatus(_, _)));
assert_eq!(app.mode, Mode::Normal);
}
#[test]
fn test_status_cancel() {
let mut app = make_app();
app.handle_key(make_key(KeyCode::Char('s')));
let action = app.handle_key(make_key(KeyCode::Esc));
assert!(matches!(action, Action::None));
assert_eq!(app.mode, Mode::Normal);
}
#[test]
fn test_filter_text() {
let mut app = make_app();
app.handle_key(make_key(KeyCode::Char('/')));
assert!(matches!(app.mode, Mode::FilterInput { .. }));
app.handle_key(make_key(KeyCode::Char('a')));
app.handle_key(make_key(KeyCode::Char('a')));
app.handle_key(make_key(KeyCode::Char('a')));
app.handle_key(make_key(KeyCode::Enter));
assert_eq!(app.mode, Mode::Normal);
assert_eq!(app.tasks.len(), 1);
assert_eq!(app.tasks[0].id, "aaa");
}
#[test]
fn test_filter_mode_cycle() {
let mut app = make_app();
assert_eq!(app.filter.list_mode, ListMode::Open);
app.handle_key(make_key(KeyCode::Char('m')));
assert_eq!(app.filter.list_mode, ListMode::Ready);
app.handle_key(make_key(KeyCode::Char('m')));
assert_eq!(app.filter.list_mode, ListMode::Epics);
app.handle_key(make_key(KeyCode::Char('m')));
assert_eq!(app.filter.list_mode, ListMode::Completed);
app.handle_key(make_key(KeyCode::Char('m')));
assert_eq!(app.filter.list_mode, ListMode::Archive);
app.handle_key(make_key(KeyCode::Char('m')));
assert_eq!(app.filter.list_mode, ListMode::All);
app.handle_key(make_key(KeyCode::Char('m')));
assert_eq!(app.filter.list_mode, ListMode::Open);
}
#[test]
fn test_backspace_in_create() {
let mut app = make_app();
app.handle_key(make_key(KeyCode::Char('c')));
app.handle_key(make_key(KeyCode::Char('A')));
app.handle_key(make_key(KeyCode::Char('B')));
app.handle_key(make_key(KeyCode::Backspace));
let action = app.handle_key(make_key(KeyCode::Enter));
assert!(matches!(action, Action::CreateTask(ref t) if t == "A"));
}
#[test]
fn test_home_end_navigation() {
let mut app = make_app();
app.handle_key(make_key(KeyCode::End));
assert_eq!(app.selected_index(), Some(app.tasks.len() - 1));
app.handle_key(make_key(KeyCode::Home));
assert_eq!(app.selected_index(), Some(0));
}
#[test]
fn test_delete_confirm_and_cancel() {
let mut app = make_app();
app.handle_key(make_key(KeyCode::Char('d')));
assert!(matches!(app.mode, Mode::ConfirmDelete { .. }));
app.handle_key(make_key(KeyCode::Char('n')));
assert_eq!(app.mode, Mode::Normal);
}
#[test]
fn test_delete_confirm_y() {
let mut app = make_app();
app.handle_key(make_key(KeyCode::Char('d')));
let action = app.handle_key(make_key(KeyCode::Char('y')));
assert!(matches!(action, Action::DeleteTask(_)));
assert_eq!(app.mode, Mode::Normal);
}
#[test]
fn test_initial_focus_is_list() {
let app = make_app();
assert_eq!(app.focus, FocusPane::List);
assert_eq!(app.detail_scroll, 0);
}
#[test]
fn test_tab_toggles_focus() {
let mut app = make_app();
assert_eq!(app.focus, FocusPane::List);
app.handle_key(make_key(KeyCode::Tab));
assert_eq!(app.focus, FocusPane::Detail);
app.handle_key(make_key(KeyCode::Tab));
assert_eq!(app.focus, FocusPane::List);
}
#[test]
fn test_jk_navigates_list_when_list_focused() {
let mut app = make_app();
assert_eq!(app.focus, FocusPane::List);
assert_eq!(app.selected_index(), Some(0));
app.handle_key(make_key(KeyCode::Char('j')));
assert_eq!(app.selected_index(), Some(1));
app.handle_key(make_key(KeyCode::Char('k')));
assert_eq!(app.selected_index(), Some(0));
}
#[test]
fn test_jk_scrolls_detail_when_detail_focused() {
let mut app = make_app();
app.detail_content_height = 50;
app.detail_visible_height = 10;
app.handle_key(make_key(KeyCode::Tab));
assert_eq!(app.focus, FocusPane::Detail);
assert_eq!(app.detail_scroll, 0);
app.handle_key(make_key(KeyCode::Char('j')));
assert_eq!(app.detail_scroll, 1);
app.handle_key(make_key(KeyCode::Char('j')));
assert_eq!(app.detail_scroll, 2);
app.handle_key(make_key(KeyCode::Char('k')));
assert_eq!(app.detail_scroll, 1);
}
#[test]
fn test_detail_scroll_does_not_underflow() {
let mut app = make_app();
app.handle_key(make_key(KeyCode::Tab));
app.handle_key(make_key(KeyCode::Char('k')));
assert_eq!(app.detail_scroll, 0);
}
#[test]
fn test_scroll_resets_on_task_change() {
let mut app = make_app();
app.detail_content_height = 50;
app.detail_visible_height = 10;
app.handle_key(make_key(KeyCode::Tab));
app.handle_key(make_key(KeyCode::Char('j')));
app.handle_key(make_key(KeyCode::Char('j')));
assert_eq!(app.detail_scroll, 2);
app.handle_key(make_key(KeyCode::Tab));
app.handle_key(make_key(KeyCode::Char('j')));
assert_eq!(app.detail_scroll, 0);
}
#[test]
fn test_home_resets_detail_scroll() {
let mut app = make_app();
app.detail_content_height = 50;
app.detail_visible_height = 10;
app.handle_key(make_key(KeyCode::Tab));
app.handle_key(make_key(KeyCode::Char('j')));
app.handle_key(make_key(KeyCode::Char('j')));
assert_eq!(app.detail_scroll, 2);
app.handle_key(make_key(KeyCode::Home));
assert_eq!(app.detail_scroll, 0);
}
#[test]
fn test_existing_keys_work_in_detail_focus() {
let mut app = make_app();
app.handle_key(make_key(KeyCode::Tab));
assert_eq!(app.focus, FocusPane::Detail);
let action = app.handle_key(make_key(KeyCode::Char('e')));
assert!(matches!(action, Action::EditSelected));
let action = app.handle_key(make_key(KeyCode::Char('c')));
assert!(matches!(action, Action::None));
assert!(matches!(app.mode, Mode::CreateInput { .. }));
}
#[test]
fn test_enter_moves_focus_to_detail() {
let mut app = make_app();
assert_eq!(app.focus, FocusPane::List);
app.handle_key(make_key(KeyCode::Enter));
assert_eq!(app.focus, FocusPane::Detail);
}
#[test]
fn test_resolve_text_input_submit() {
let result = resolve_text_input(make_key(KeyCode::Enter), " hello ");
assert!(matches!(result, TextInputResult::Submit(s) if s == "hello"));
}
#[test]
fn test_resolve_text_input_cancel() {
let result = resolve_text_input(make_key(KeyCode::Esc), "anything");
assert!(matches!(result, TextInputResult::Cancel));
}
#[test]
fn test_resolve_text_input_backspace() {
let result = resolve_text_input(make_key(KeyCode::Backspace), "abc");
assert!(matches!(result, TextInputResult::Update(s) if s == "ab"));
}
#[test]
fn test_resolve_text_input_char() {
let result = resolve_text_input(make_key(KeyCode::Char('x')), "ab");
assert!(matches!(result, TextInputResult::Update(s) if s == "abx"));
}
}