use crate::config::TuiConfig;
use crate::frame_snapshot::FrameSnapshot;
use crate::input::{AppState, ArgValue, Focus};
use crate::query::{
form::FieldWidget,
selectors::{self, active_form_field},
};
use crate::runtime::{AppKeyCode, AppKeyEvent};
use crate::update::Action;
pub(crate) fn handle_key_event(
key: AppKeyEvent,
state: &AppState,
_frame_snapshot: &FrameSnapshot,
config: &TuiConfig,
) -> Option<Action> {
if key.code == AppKeyCode::Char('c') && key.modifiers.control {
return Some(Action::Exit);
}
if key.code == AppKeyCode::Char('r') && key.modifiers.control {
return Some(Action::Run);
}
if key.code == AppKeyCode::Char('y') && key.modifiers.control {
return Some(Action::CopyPreview);
}
if key.code == AppKeyCode::Enter && key.modifiers.control {
return Some(Action::Run);
}
if key.code == AppKeyCode::Esc {
return Some(Action::Escape);
}
if state.ui.help_open {
return handle_help_key_event(key, config);
}
if matches!(state.ui.focus, Focus::Search) {
return Some(Action::SearchInput(key));
}
if let Some(active_choice) = state.ui.dropdown_open.as_ref()
&& matches!(
key.code,
AppKeyCode::Up
| AppKeyCode::Down
| AppKeyCode::Esc
| AppKeyCode::Enter
| AppKeyCode::Char(' ')
)
{
return Some(Action::ChoiceInput {
arg_id: active_choice.clone(),
key,
});
}
if matches!(state.ui.focus, Focus::Form)
&& let Some(action) = handle_form_widget_key_event(key, state, config)
{
return Some(action);
}
if matches!(state.ui.focus, Focus::Form) && is_form_text_input(key, state) {
return Some(Action::FormTextInput(key));
}
handle_focused_key_event(key, state, config)
}
fn handle_form_widget_key_event(
key: AppKeyEvent,
state: &AppState,
config: &TuiConfig,
) -> Option<Action> {
let root = state.domain.root.clone();
let selected_path = state.domain.selected_path().clone();
let args = selectors::visible_form_args(&root, &selected_path, state.ui.active_tab);
let item = active_form_field(&args, state.ui.selected_arg_index)?;
match item.widget {
FieldWidget::Counter => match key.code {
AppKeyCode::Right
| AppKeyCode::Char('+' | '=' | '-')
| AppKeyCode::Left
| AppKeyCode::Backspace => Some(Action::FormWidgetInput(key)),
_ => None,
},
FieldWidget::RepeatedText => match key.code {
AppKeyCode::Enter | AppKeyCode::Up | AppKeyCode::Down => {
Some(Action::FormWidgetInput(key))
}
_ if repeated_row_shortcut(key) => Some(Action::FormWidgetInput(key)),
_ => None,
},
FieldWidget::OptionalValue => match key.code {
AppKeyCode::Left | AppKeyCode::Delete | AppKeyCode::Backspace | AppKeyCode::Right => {
Some(Action::FormWidgetInput(key))
}
AppKeyCode::Char(c) if c == config.keymap.search => None,
_ => None,
},
_ => None,
}
}
fn repeated_row_shortcut(key: AppKeyEvent) -> bool {
matches!(key.code, AppKeyCode::Up | AppKeyCode::Down) && key.modifiers.alt
|| matches!(key.code, AppKeyCode::Delete) && key.modifiers.control
|| matches!(key.code, AppKeyCode::Backspace) && key.modifiers.control
}
fn handle_help_key_event(key: AppKeyEvent, config: &TuiConfig) -> Option<Action> {
if matches!(key.code, AppKeyCode::F(1))
|| matches!(key.code, AppKeyCode::Char(c) if c == config.keymap.help)
{
return Some(Action::ToggleHelp);
}
match key.code {
AppKeyCode::Up => Some(Action::ScrollForm(-1)),
AppKeyCode::Down => Some(Action::ScrollForm(1)),
AppKeyCode::PageUp => Some(Action::ScrollForm(-10)),
AppKeyCode::PageDown => Some(Action::ScrollForm(10)),
AppKeyCode::Tab => Some(Action::ToggleFocus),
_ => None,
}
}
fn handle_focused_key_event(
key: AppKeyEvent,
state: &AppState,
config: &TuiConfig,
) -> Option<Action> {
match key.code {
AppKeyCode::Tab => Some(Action::ToggleFocus),
AppKeyCode::Char(c) if c == config.keymap.help => Some(Action::ToggleHelp),
AppKeyCode::F(1) => Some(Action::ToggleHelp),
AppKeyCode::BackTab => Some(Action::ReverseFocus),
AppKeyCode::Char(c) if c == config.keymap.search => Some(Action::FocusSearch),
AppKeyCode::Up => match state.ui.focus {
Focus::Sidebar => Some(Action::MoveSidebarSelection(-1)),
Focus::Form => Some(Action::MoveFormSelection(-1)),
Focus::Search => None,
},
AppKeyCode::Down => match state.ui.focus {
Focus::Sidebar => Some(Action::MoveSidebarSelection(1)),
Focus::Form => Some(Action::MoveFormSelection(1)),
Focus::Search => None,
},
AppKeyCode::Left => {
if matches!(state.ui.focus, Focus::Sidebar) {
Some(Action::CollapseSelected)
} else {
None
}
}
AppKeyCode::Right => {
if matches!(state.ui.focus, Focus::Sidebar) {
Some(Action::SidebarRight)
} else {
None
}
}
AppKeyCode::Enter => {
if matches!(state.ui.focus, Focus::Sidebar) {
Some(Action::SelectSidebar)
} else if matches!(state.ui.focus, Focus::Form) {
Some(Action::ActivateFormField)
} else {
None
}
}
AppKeyCode::Char(' ') => {
if matches!(state.ui.focus, Focus::Form) {
Some(Action::ActivateFormField)
} else {
None
}
}
_ => None,
}
}
fn is_form_text_input(key: AppKeyEvent, state: &AppState) -> bool {
let root = state.domain.root.clone();
let selected_path = state.domain.selected_path().clone();
let args = selectors::visible_form_args(&root, &selected_path, state.ui.active_tab);
let Some(item) = active_form_field(&args, state.ui.selected_arg_index) else {
return false;
};
if !item.widget.accepts_text_input() {
return false;
}
if key.code == AppKeyCode::Enter {
return false;
}
if matches!(item.widget, FieldWidget::OptionalValue) && key.code == AppKeyCode::Char(' ') {
return state
.domain
.current_form()
.filter(|_| state.domain.is_touched(&item.arg.id))
.and_then(|form| form.compatibility_value(item.arg))
.is_some_and(|value| matches!(value, ArgValue::Text(_)));
}
!matches!(
key.code,
AppKeyCode::Tab | AppKeyCode::Up | AppKeyCode::Down | AppKeyCode::Esc
)
}
#[cfg(test)]
mod tests {
use clap::{Arg, ArgAction};
use crate::config::TuiConfig;
use crate::frame_snapshot::FrameSnapshot;
use crate::input::{AppState, Focus};
use crate::runtime::{AppKeyCode, AppKeyEvent, AppKeyModifiers};
use crate::spec::CommandSpec;
use crate::update::Action;
use super::handle_key_event;
fn command() -> CommandSpec {
CommandSpec {
name: "tool".to_string(),
version: None,
about: None,
help: String::new(),
args: Vec::new(),
subcommands: Vec::new(),
..CommandSpec::default()
}
}
fn key(code: AppKeyCode) -> AppKeyEvent {
AppKeyEvent::new(code, AppKeyModifiers::default())
}
#[test]
fn right_in_sidebar_emits_sidebar_right_action() {
let state = AppState::new(command());
let action = handle_key_event(
key(AppKeyCode::Right),
&state,
&FrameSnapshot::default(),
&TuiConfig::default(),
);
assert_eq!(action, Some(Action::SidebarRight));
}
#[test]
fn right_outside_sidebar_preserves_existing_behavior() {
let mut state = AppState::new(command());
state.ui.focus = Focus::Form;
let action = handle_key_event(
key(AppKeyCode::Right),
&state,
&FrameSnapshot::default(),
&TuiConfig::default(),
);
assert_eq!(action, None);
}
#[test]
fn escape_emits_centralized_escape_action() {
let mut state = AppState::new(command());
state.ui.focus = Focus::Search;
let action = handle_key_event(
key(AppKeyCode::Esc),
&state,
&FrameSnapshot::default(),
&TuiConfig::default(),
);
assert_eq!(action, Some(Action::Escape));
}
#[test]
fn ctrl_r_emits_run_action() {
let state = AppState::new(command());
let action = handle_key_event(
AppKeyEvent::new(
AppKeyCode::Char('r'),
AppKeyModifiers {
control: true,
alt: false,
shift: false,
},
),
&state,
&FrameSnapshot::default(),
&TuiConfig::default(),
);
assert_eq!(action, Some(Action::Run));
}
#[test]
fn ctrl_y_emits_copy_preview_action() {
let state = AppState::new(command());
let action = handle_key_event(
AppKeyEvent::new(
AppKeyCode::Char('y'),
AppKeyModifiers {
control: true,
alt: false,
shift: false,
},
),
&state,
&FrameSnapshot::default(),
&TuiConfig::default(),
);
assert_eq!(action, Some(Action::CopyPreview));
}
#[test]
fn counter_fields_route_left_and_right_to_widget_input() {
let mut state = AppState::from_command(
&clap::Command::new("tool")
.arg(Arg::new("verbose").short('v').action(ArgAction::Count)),
);
state.ui.focus = Focus::Form;
let right = handle_key_event(
key(AppKeyCode::Right),
&state,
&FrameSnapshot::default(),
&TuiConfig::default(),
);
let left = handle_key_event(
key(AppKeyCode::Left),
&state,
&FrameSnapshot::default(),
&TuiConfig::default(),
);
assert_eq!(right, Some(Action::FormWidgetInput(key(AppKeyCode::Right))));
assert_eq!(left, Some(Action::FormWidgetInput(key(AppKeyCode::Left))));
}
#[test]
fn repeated_text_fields_route_enter_to_widget_input() {
let mut state = AppState::from_command(
&clap::Command::new("tool").arg(
Arg::new("include")
.long("include")
.action(ArgAction::Append)
.num_args(1),
),
);
state.ui.focus = Focus::Form;
let action = handle_key_event(
key(AppKeyCode::Enter),
&state,
&FrameSnapshot::default(),
&TuiConfig::default(),
);
assert_eq!(
action,
Some(Action::FormWidgetInput(key(AppKeyCode::Enter)))
);
}
#[test]
fn repeated_text_fields_route_row_shortcuts_to_widget_input() {
let mut state = AppState::from_command(
&clap::Command::new("tool").arg(
Arg::new("include")
.long("include")
.action(ArgAction::Append)
.num_args(1),
),
);
state.ui.focus = Focus::Form;
let alt_up = AppKeyEvent::new(
AppKeyCode::Up,
AppKeyModifiers {
alt: true,
..AppKeyModifiers::default()
},
);
let ctrl_delete = AppKeyEvent::new(
AppKeyCode::Delete,
AppKeyModifiers {
control: true,
..AppKeyModifiers::default()
},
);
let move_action = handle_key_event(
alt_up,
&state,
&FrameSnapshot::default(),
&TuiConfig::default(),
);
let remove_action = handle_key_event(
ctrl_delete,
&state,
&FrameSnapshot::default(),
&TuiConfig::default(),
);
assert_eq!(move_action, Some(Action::FormWidgetInput(alt_up)));
assert_eq!(remove_action, Some(Action::FormWidgetInput(ctrl_delete)));
}
#[test]
fn optional_value_space_activates_when_no_explicit_text() {
let mut state = AppState::from_command(
&clap::Command::new("tool").arg(
Arg::new("color")
.long("color")
.action(ArgAction::Set)
.num_args(0..=1),
),
);
state.ui.focus = Focus::Form;
let action = handle_key_event(
key(AppKeyCode::Char(' ')),
&state,
&FrameSnapshot::default(),
&TuiConfig::default(),
);
assert_eq!(action, Some(Action::ActivateFormField));
}
#[test]
fn optional_value_space_activates_when_value_is_only_defaulted() {
let mut state = AppState::from_command(
&clap::Command::new("tool").arg(
Arg::new("color")
.long("color")
.action(ArgAction::Set)
.default_value("auto")
.num_args(0..=1),
),
);
state.ui.focus = Focus::Form;
let action = handle_key_event(
key(AppKeyCode::Char(' ')),
&state,
&FrameSnapshot::default(),
&TuiConfig::default(),
);
assert_eq!(action, Some(Action::ActivateFormField));
}
#[test]
fn search_key_is_treated_as_text_input_in_form_fields() {
let mut state =
AppState::from_command(&clap::Command::new("tool").arg(Arg::new("path").long("path")));
state.ui.focus = Focus::Form;
let action = handle_key_event(
key(AppKeyCode::Char('/')),
&state,
&FrameSnapshot::default(),
&TuiConfig::default(),
);
assert_eq!(
action,
Some(Action::FormTextInput(key(AppKeyCode::Char('/'))))
);
}
#[test]
fn search_key_still_focuses_search_outside_text_input() {
let state = AppState::new(command());
let action = handle_key_event(
key(AppKeyCode::Char('/')),
&state,
&FrameSnapshot::default(),
&TuiConfig::default(),
);
assert_eq!(action, Some(Action::FocusSearch));
}
#[test]
fn backtab_emits_reverse_focus_action() {
let state = AppState::new(command());
let action = handle_key_event(
key(AppKeyCode::BackTab),
&state,
&FrameSnapshot::default(),
&TuiConfig::default(),
);
assert_eq!(action, Some(Action::ReverseFocus));
}
}