use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::time::{Duration, Instant};
use crate::sync::UiCommand;
use super::app::{App, EngineStatus};
#[derive(Default)]
pub struct InputState {
pub pending_confirm: Option<(char, Instant)>,
help_open: bool,
}
#[derive(Debug)]
pub enum InputOutcome {
None,
Command(UiCommand),
Quit,
}
impl InputState {
pub fn help_open(&self) -> bool {
self.help_open
}
pub fn on_key(&mut self, key: KeyEvent, app: &mut App) -> InputOutcome {
let is_shift = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code {
KeyCode::Char('q') => return InputOutcome::Quit,
KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => {
return InputOutcome::Quit;
}
KeyCode::Down => {
app.move_selection_down();
}
KeyCode::Up => {
app.move_selection_up();
}
KeyCode::Left => {
app.move_selection_left();
}
KeyCode::Right => {
app.move_selection_right();
}
KeyCode::Char(' ') => {
app.toggle_selected_collapsed();
}
KeyCode::Enter => {
if app.focused_subsource_name().is_some() {
app.toggle_drilldown();
} else {
app.toggle_selected_collapsed();
}
}
KeyCode::Esc => {
if self.help_open {
self.help_open = false;
} else if app.drilldown_open {
app.toggle_drilldown();
}
}
KeyCode::Char('?') => {
self.help_open = !self.help_open;
}
KeyCode::Char('s') => {
app.prefs.log_view_on = !app.prefs.log_view_on;
}
KeyCode::Char('p') => match app.status {
EngineStatus::Paused => {
app.status = EngineStatus::Idle;
return InputOutcome::Command(UiCommand::Resume);
}
_ => {
app.status = EngineStatus::Paused;
return InputOutcome::Command(UiCommand::Pause);
}
},
KeyCode::Char('r') if !is_shift => {
return InputOutcome::Command(UiCommand::SyncNow);
}
KeyCode::Char('R') => {
if self.armed('R') {
if let Some(src) = app.focused_source_name() {
return InputOutcome::Command(UiCommand::ResetCursor {
source: src.to_string(),
subsource: app.focused_subsource_name().map(str::to_string),
});
}
} else {
self.arm('R');
app.footer = "press R again within 2s to reset".into();
}
}
KeyCode::Char('P') => {
if self.armed('P') {
if let Some(src) = app.focused_source_name() {
return InputOutcome::Command(UiCommand::PurgeNow {
source: src.to_string(),
});
}
} else {
self.arm('P');
app.footer = "press P again within 2s to purge".into();
}
}
KeyCode::Char('c') => {
app.footer.clear();
}
_ => {}
}
self.prune_expired();
InputOutcome::None
}
fn arm(&mut self, key: char) {
self.pending_confirm = Some((key, Instant::now()));
}
fn armed(&mut self, key: char) -> bool {
let now = Instant::now();
match self.pending_confirm {
Some((k, t)) if k == key && now.duration_since(t) <= Duration::from_secs(2) => {
self.pending_confirm = None;
true
}
_ => false,
}
}
fn prune_expired(&mut self) {
if let Some((_, t)) = self.pending_confirm
&& Instant::now().duration_since(t) > Duration::from_secs(2)
{
self.pending_confirm = None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{
AuthConfig, AzureConfig, Config, JiraSourceConfig, SourceConfig, SyncConfig,
};
use crate::tui::app::App;
use crate::tui::prefs::Prefs;
fn make_app() -> App {
let cfg = Config {
azure: AzureConfig {
endpoint: "x".into(),
api_key: "k".into(),
},
sources: vec![SourceConfig::Jira(JiraSourceConfig {
name: "j".into(),
url: "x".into(),
auth: AuthConfig::DataCenter { pat: "p".into() },
projects: vec!["DO".into()],
index: "i".into(),
})],
sync: SyncConfig::default(),
};
App::new(&cfg, Prefs::default())
}
#[test]
fn space_toggles_source_collapsed() {
let mut state = InputState::default();
let mut app = make_app();
let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
state.on_key(key, &mut app);
assert!(app.prefs.is_source_collapsed("j"));
state.on_key(key, &mut app);
assert!(!app.prefs.is_source_collapsed("j"));
}
#[test]
fn s_toggles_log_view() {
let mut state = InputState::default();
let mut app = make_app();
let key = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE);
state.on_key(key, &mut app);
assert!(app.prefs.log_view_on);
}
#[test]
fn shift_r_requires_second_press() {
let mut state = InputState::default();
let mut app = make_app();
let shift_r = KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT);
match state.on_key(shift_r, &mut app) {
InputOutcome::None => {}
other => panic!("expected arm first, got {other:?}"),
}
match state.on_key(shift_r, &mut app) {
InputOutcome::Command(UiCommand::ResetCursor { source, .. }) => {
assert_eq!(source, "j");
}
other => panic!("expected ResetCursor, got {other:?}"),
}
}
#[test]
fn arrows_move_selection() {
let mut state = InputState::default();
let mut app = make_app();
state.on_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), &mut app);
assert_eq!(app.focused_subsource_name(), Some("DO"));
state.on_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &mut app);
assert_eq!(app.focused_subsource_name(), None);
}
#[test]
fn enter_on_focused_subsource_opens_drilldown() {
let mut state = InputState::default();
let mut app = make_app();
state.on_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), &mut app);
assert_eq!(app.focused_subsource_name(), Some("DO"));
state.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &mut app);
assert!(app.drilldown_open);
state.on_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &mut app);
assert!(!app.drilldown_open);
}
#[test]
fn question_mark_toggles_help_overlay() {
let mut state = InputState::default();
let mut app = make_app();
assert!(!state.help_open());
state.on_key(
KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE),
&mut app,
);
assert!(state.help_open());
state.on_key(
KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE),
&mut app,
);
assert!(!state.help_open());
}
#[test]
fn esc_closes_help_overlay() {
let mut state = InputState::default();
let mut app = make_app();
state.on_key(
KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE),
&mut app,
);
assert!(state.help_open());
state.on_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &mut app);
assert!(!state.help_open());
}
}