use std::collections::HashMap;
use std::fmt;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::error::{Result, TixError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum InputContext {
Board,
BulkModal,
Triage,
Rollover,
Detail,
Form,
FormPicker,
ConfirmDelete,
MoveIntoDone,
ConfirmBlockedMove,
Help,
HelpSearch,
ContextMenu,
Filter,
FilterModal,
Settings,
SettingsPrompt,
PlanningManagement,
PlanningConfirm,
PlanningForm,
DatePicker,
Comment,
Asset,
EditWarning,
LinkPrompt,
}
impl InputContext {
pub const ALL: &'static [Self] = &[
Self::Board,
Self::Rollover,
Self::Detail,
Self::Form,
Self::FormPicker,
Self::ConfirmDelete,
Self::MoveIntoDone,
Self::ConfirmBlockedMove,
Self::Help,
Self::HelpSearch,
Self::ContextMenu,
Self::Filter,
Self::FilterModal,
Self::BulkModal,
Self::Triage,
Self::Settings,
Self::SettingsPrompt,
Self::PlanningManagement,
Self::PlanningConfirm,
Self::PlanningForm,
Self::DatePicker,
Self::Comment,
Self::Asset,
Self::EditWarning,
Self::LinkPrompt,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::Board => "board",
Self::Rollover => "rollover",
Self::Detail => "detail",
Self::Form => "form",
Self::FormPicker => "form_picker",
Self::ConfirmDelete => "confirm_delete",
Self::ConfirmBlockedMove => "confirm_blocked_move",
Self::MoveIntoDone => "move_into_done",
Self::Help => "help",
Self::HelpSearch => "help_search",
Self::ContextMenu => "context_menu",
Self::Filter => "filter",
Self::FilterModal => "filter_modal",
Self::BulkModal => "bulk_modal",
Self::Settings => "settings",
Self::SettingsPrompt => "settings_prompt",
Self::PlanningManagement => "planning_management",
Self::PlanningConfirm => "planning_confirm",
Self::PlanningForm => "planning_form",
Self::DatePicker => "date_picker",
Self::Comment => "comment",
Self::Asset => "asset",
Self::EditWarning => "edit_warning",
Self::LinkPrompt => "link_prompt",
Self::Triage => "triage",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TuiAction {
MoveUp,
MoveDown,
MoveLeft,
MoveRight,
NextBoard,
PreviousBoard,
Open,
Edit,
Agent,
ToggleColumn,
FilterModal,
BulkModal,
Triage,
Mark,
Unmark,
MovePrevious,
MoveNext,
NewIssue,
CloneIssue,
ToggleArchived,
Delete,
Refresh,
Settings,
Planning,
NewBoard,
Help,
Quit,
TextFilter,
ReorderUp,
ReorderDown,
Close,
ScrollUp,
ScrollDown,
Comment,
Asset,
NewRelationship,
EditRelationship,
RemoveRelationship,
NextRelationship,
PreviousRelationship,
Submit,
Cancel,
Next,
Previous,
OpenPicker,
Reset,
Toggle,
AddColumn,
NewPlanningItem,
EditPlanningItem,
DeletePlanningItem,
Confirm,
Create,
PreviousYear,
NextYear,
PreviousMonth,
NextMonth,
Reedit,
Ignore,
}
impl TuiAction {
pub const fn as_str(self) -> &'static str {
match self {
Self::MoveUp => "move_up",
Self::MoveDown => "move_down",
Self::MoveLeft => "move_left",
Self::MoveRight => "move_right",
Self::NextBoard => "next_board",
Self::PreviousBoard => "previous_board",
Self::Open => "open",
Self::Edit => "edit",
Self::Agent => "agent",
Self::ToggleColumn => "toggle_column",
Self::FilterModal => "filter_modal",
Self::BulkModal => "bulk_modal",
Self::Triage => "triage",
Self::Mark => "mark",
Self::Unmark => "unmark",
Self::MovePrevious => "move_previous",
Self::MoveNext => "move_next",
Self::NewIssue => "new_issue",
Self::CloneIssue => "clone_issue",
Self::ToggleArchived => "toggle_archived",
Self::Delete => "delete",
Self::Refresh => "refresh",
Self::Settings => "settings",
Self::Planning => "planning",
Self::NewBoard => "new_board",
Self::Help => "help",
Self::Quit => "quit",
Self::TextFilter => "text_filter",
Self::ReorderUp => "reorder_up",
Self::ReorderDown => "reorder_down",
Self::Close => "close",
Self::ScrollUp => "scroll_up",
Self::ScrollDown => "scroll_down",
Self::Comment => "comment",
Self::Asset => "asset",
Self::NewRelationship => "new_relationship",
Self::EditRelationship => "edit_relationship",
Self::RemoveRelationship => "remove_relationship",
Self::NextRelationship => "next_relationship",
Self::PreviousRelationship => "previous_relationship",
Self::Submit => "submit",
Self::Cancel => "cancel",
Self::Next => "next",
Self::Previous => "previous",
Self::OpenPicker => "open_picker",
Self::Reset => "reset",
Self::Toggle => "toggle",
Self::AddColumn => "add_column",
Self::NewPlanningItem => "new_planning_item",
Self::EditPlanningItem => "edit_planning_item",
Self::DeletePlanningItem => "delete_planning_item",
Self::Confirm => "confirm",
Self::Create => "create",
Self::PreviousYear => "previous_year",
Self::NextYear => "next_year",
Self::PreviousMonth => "previous_month",
Self::NextMonth => "next_month",
Self::Reedit => "reedit",
Self::Ignore => "ignore",
}
}
pub const fn description(self) -> &'static str {
match self {
Self::MoveUp => "move up",
Self::MoveDown => "move down",
Self::MoveLeft => "previous column",
Self::MoveRight => "next column",
Self::NextBoard => "next board",
Self::PreviousBoard => "previous board",
Self::Open => "open detail",
Self::Edit => "edit",
Self::Agent => "open agent",
Self::ToggleColumn => "toggle column",
Self::FilterModal => "filter criteria",
Self::BulkModal => "batch select",
Self::Triage => "triage inbox",
Self::Mark => "mark for batch",
Self::Unmark => "unmark for batch",
Self::MovePrevious => "move to previous column",
Self::MoveNext => "move to next column",
Self::NewIssue => "new issue",
Self::CloneIssue => "clone issue",
Self::ToggleArchived => "show archived issues",
Self::Delete => "delete",
Self::Refresh => "refresh",
Self::Settings => "settings",
Self::Planning => "planning",
Self::NewBoard => "new board",
Self::Help => "help",
Self::Quit => "quit",
Self::TextFilter => "filter",
Self::ReorderUp => "reorder up",
Self::ReorderDown => "reorder down",
Self::Close => "close",
Self::ScrollUp => "scroll up one page",
Self::ScrollDown => "scroll down one page",
Self::Comment => "comment",
Self::Asset => "asset",
Self::NewRelationship => "new relationship",
Self::EditRelationship => "edit relationship",
Self::RemoveRelationship => "remove relationship",
Self::NextRelationship => "next relationship",
Self::PreviousRelationship => "previous relationship",
Self::Submit => "submit",
Self::Cancel => "cancel",
Self::Next => "next",
Self::Previous => "previous",
Self::OpenPicker => "open picker",
Self::Reset => "reset",
Self::Toggle => "toggle",
Self::AddColumn => "add column",
Self::NewPlanningItem => "new",
Self::EditPlanningItem => "edit",
Self::DeletePlanningItem => "delete",
Self::Confirm => "confirm",
Self::Create => "create",
Self::PreviousYear => "previous year",
Self::NextYear => "next year",
Self::PreviousMonth => "previous month",
Self::NextMonth => "next month",
Self::Reedit => "re-edit",
Self::Ignore => "ignore",
}
}
}
pub type Binding = Vec<String>;
macro_rules! partial_bindings {
($($field:ident),+ $(,)?) => {
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ActionBindings {
$(
#[serde(skip_serializing_if = "Option::is_none")]
pub $field: Option<Binding>,
)+
}
};
}
partial_bindings!(
move_up,
move_down,
move_left,
move_right,
next_board,
previous_board,
open,
edit,
agent,
toggle_column,
filter_modal,
bulk_modal,
triage,
mark,
unmark,
move_previous,
move_next,
new_issue,
clone_issue,
toggle_archived,
delete,
refresh,
settings,
planning,
new_board,
help,
quit,
text_filter,
reorder_up,
reorder_down,
close,
scroll_up,
scroll_down,
comment,
asset,
submit,
cancel,
next,
previous,
open_picker,
reset,
toggle,
add_column,
new_planning_item,
edit_planning_item,
delete_planning_item,
confirm,
create,
previous_year,
next_year,
previous_month,
next_month,
reedit,
ignore,
);
macro_rules! keybindings_config {
($($field:ident => $context:ident),+ $(,)?) => {
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct KeyBindings {
$(
#[serde(skip_serializing_if = "ActionBindings::is_empty")]
pub $field: ActionBindings,
)+
}
impl KeyBindings {
pub fn is_empty(&self) -> bool {
self == &Self::default()
}
fn overrides(&self, context: InputContext) -> &ActionBindings {
match context {
$(InputContext::$context => &self.$field,)+
}
}
}
};
}
keybindings_config!(
board => Board,
rollover => Rollover,
detail => Detail,
form => Form,
form_picker => FormPicker,
confirm_delete => ConfirmDelete,
confirm_blocked_move => ConfirmBlockedMove,
help_search => HelpSearch,
move_into_done => MoveIntoDone,
help => Help,
context_menu => ContextMenu,
filter => Filter,
filter_modal => FilterModal,
bulk_modal => BulkModal,
triage => Triage,
settings => Settings,
settings_prompt => SettingsPrompt,
planning_management => PlanningManagement,
planning_confirm => PlanningConfirm,
planning_form => PlanningForm,
date_picker => DatePicker,
comment => Comment,
asset => Asset,
edit_warning => EditWarning,
link_prompt => LinkPrompt,
);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct KeyChord {
pub code: KeyCode,
pub modifiers: KeyModifiers,
}
impl KeyChord {
pub fn parse(value: &str) -> std::result::Result<Self, String> {
let value = value.trim();
if value.is_empty() {
return Err("key chord must not be empty".into());
}
if value.chars().count() == 1 {
let ch = value.chars().next().expect("value has one character");
if ch.is_control() {
return Err(format!("unsupported key name `{value}`"));
}
let modifiers = if ch.is_ascii_uppercase() {
KeyModifiers::SHIFT
} else {
KeyModifiers::NONE
};
return Ok(Self {
code: KeyCode::Char(ch),
modifiers,
});
}
let mut parts = value.split('+');
let key = parts.next_back().ok_or("missing key name")?.trim();
let mut modifiers = KeyModifiers::NONE;
for part in parts {
match part.trim().to_ascii_lowercase().as_str() {
"ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
"alt" | "option" => modifiers |= KeyModifiers::ALT,
"shift" => modifiers |= KeyModifiers::SHIFT,
"super" | "cmd" | "command" => modifiers |= KeyModifiers::SUPER,
other => return Err(format!("unsupported modifier `{other}`")),
}
}
if key.is_empty() {
return Err("missing key name".into());
}
let mut code = match key.to_ascii_lowercase().as_str() {
"backtab" => return Err("use shift+tab instead of `backtab`".into()),
"shift+tab" => KeyCode::BackTab,
"backspace" => KeyCode::Backspace,
"tab" => KeyCode::Tab,
"enter" | "return" => KeyCode::Enter,
"esc" | "escape" => KeyCode::Esc,
"space" => KeyCode::Char(' '),
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"up" => KeyCode::Up,
"down" => KeyCode::Down,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"pageup" | "pgup" => KeyCode::PageUp,
"pagedown" | "pgdn" | "pgdown" => KeyCode::PageDown,
"delete" | "del" => KeyCode::Delete,
"insert" | "ins" => KeyCode::Insert,
_ => {
if let Some(number) = key
.to_ascii_lowercase()
.strip_prefix('f')
.and_then(|n| n.parse::<u8>().ok())
{
if (1..=12).contains(&number) {
KeyCode::F(number)
} else {
return Err(format!("unsupported function key `{key}`"));
}
} else {
let mut chars = key.chars();
let Some(ch) = chars.next().filter(|_| chars.next().is_none()) else {
return Err(format!("unsupported key name `{key}`"));
};
if ch.is_control() {
return Err(format!("unsupported key name `{key}`"));
}
if ch.is_ascii_uppercase() {
modifiers |= KeyModifiers::SHIFT;
}
KeyCode::Char(ch)
}
}
};
if code == KeyCode::Tab && modifiers.contains(KeyModifiers::SHIFT) {
code = KeyCode::BackTab;
modifiers.remove(KeyModifiers::SHIFT);
}
if code == KeyCode::BackTab {
modifiers |= KeyModifiers::SHIFT;
}
Ok(Self { code, modifiers })
}
pub fn matches(&self, event: KeyEvent) -> bool {
if self.code != event.code {
return false;
}
if self.modifiers.is_empty() {
return event.modifiers.is_empty();
}
if self.code == KeyCode::BackTab {
return event.modifiers.contains(KeyModifiers::SHIFT) || event.code == KeyCode::BackTab;
}
if let (KeyCode::Char(expected), KeyCode::Char(actual)) = (self.code, event.code) {
let shift_expected = self.modifiers.contains(KeyModifiers::SHIFT);
let normalized_expected = if shift_expected {
expected.to_ascii_lowercase()
} else {
expected
};
let shift_actual =
actual.is_uppercase() || event.modifiers.contains(KeyModifiers::SHIFT);
let expected_other = self.modifiers.difference(KeyModifiers::SHIFT);
let actual_other = event.modifiers.difference(KeyModifiers::SHIFT);
return expected_other == actual_other
&& normalized_expected == actual.to_ascii_lowercase()
&& shift_expected == shift_actual;
}
event.modifiers == self.modifiers
}
pub fn display(&self) -> String {
let mut out = String::new();
if self.modifiers.contains(KeyModifiers::CONTROL) {
out.push_str("Ctrl+");
}
if self.modifiers.contains(KeyModifiers::ALT) {
out.push_str("Alt+");
}
if self.modifiers.contains(KeyModifiers::SUPER) {
out.push_str("Super+");
}
if self.modifiers.contains(KeyModifiers::SHIFT) && self.code != KeyCode::BackTab {
out.push_str("Shift+");
}
out.push_str(&match self.code {
KeyCode::BackTab => "Shift+Tab".into(),
KeyCode::Char(' ') => "Space".into(),
KeyCode::Char(ch) => ch.to_string(),
KeyCode::Enter => "Enter".into(),
KeyCode::Esc => "Esc".into(),
KeyCode::Left => "Left".into(),
KeyCode::Right => "Right".into(),
KeyCode::Up => "Up".into(),
KeyCode::Down => "Down".into(),
KeyCode::PageUp => "PgUp".into(),
KeyCode::PageDown => "PgDn".into(),
KeyCode::Delete => "Delete".into(),
KeyCode::Insert => "Insert".into(),
KeyCode::Home => "Home".into(),
KeyCode::End => "End".into(),
KeyCode::F(n) => format!("F{n}"),
other => format!("{other:?}"),
});
out
}
}
impl Serialize for KeyChord {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.display())
}
}
impl<'de> Deserialize<'de> for KeyChord {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::parse(&value).map_err(D::Error::custom)
}
}
impl fmt::Display for KeyChord {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.display())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EffectiveBindings {
modes: HashMap<InputContext, HashMap<TuiAction, Vec<KeyChord>>>,
}
fn chord(value: &str) -> KeyChord {
KeyChord::parse(value).expect("built-in key chord must parse")
}
fn set(map: &mut HashMap<TuiAction, Vec<KeyChord>>, action: TuiAction, values: &[&str]) {
map.insert(action, values.iter().map(|value| chord(value)).collect());
}
impl KeyBindings {
pub fn resolve(overrides: &KeyBindings) -> Result<EffectiveBindings> {
let mut effective = EffectiveBindings::default();
for context in InputContext::ALL {
let mut mode = default_mode(*context);
overlay(&mut mode, overrides.overrides(*context), *context)?;
effective.modes.insert(*context, mode);
}
Ok(effective)
}
}
impl EffectiveBindings {
pub fn action(&self, context: InputContext, event: KeyEvent) -> Option<TuiAction> {
self.modes
.get(&context)?
.iter()
.find_map(|(action, chords)| {
chords
.iter()
.any(|key| key.matches(event))
.then_some(*action)
})
}
pub fn bound_actions(&self, context: InputContext) -> Vec<TuiAction> {
let mut actions: Vec<TuiAction> = self
.modes
.get(&context)
.map(|mode| mode.keys().copied().collect())
.unwrap_or_default();
actions.sort_by_key(|action| action.as_str());
actions
}
pub fn chords(&self, context: InputContext, action: TuiAction) -> &[KeyChord] {
self.modes
.get(&context)
.and_then(|mode| mode.get(&action))
.map(Vec::as_slice)
.unwrap_or_default()
}
pub fn label(&self, context: InputContext, action: TuiAction) -> String {
let labels: Vec<String> = self
.chords(context, action)
.iter()
.map(ToString::to_string)
.collect();
if labels.is_empty() {
"—".into()
} else {
labels.join(" / ")
}
}
pub fn canonical_event(&self, context: InputContext, action: TuiAction) -> KeyEvent {
let (code, modifiers) = match (context, action) {
(_, TuiAction::ReorderUp) => (KeyCode::Up, KeyModifiers::CONTROL),
(_, TuiAction::ReorderDown) => (KeyCode::Down, KeyModifiers::CONTROL),
(InputContext::Rollover, TuiAction::Previous) => (KeyCode::Tab, KeyModifiers::NONE),
(InputContext::PlanningManagement, TuiAction::Next) => {
(KeyCode::Tab, KeyModifiers::NONE)
}
(InputContext::FormPicker, TuiAction::Next) => (KeyCode::Up, KeyModifiers::NONE),
(InputContext::FormPicker, TuiAction::Previous) => (KeyCode::Down, KeyModifiers::NONE),
(InputContext::DatePicker, TuiAction::PreviousYear) => {
(KeyCode::Char('['), KeyModifiers::NONE)
}
(InputContext::DatePicker, TuiAction::NextYear) => {
(KeyCode::Char(']'), KeyModifiers::NONE)
}
(InputContext::DatePicker, TuiAction::PreviousMonth) => {
(KeyCode::PageUp, KeyModifiers::NONE)
}
(InputContext::DatePicker, TuiAction::NextMonth) => {
(KeyCode::PageDown, KeyModifiers::NONE)
}
(InputContext::DatePicker, TuiAction::Reset) => (KeyCode::Delete, KeyModifiers::NONE),
(InputContext::Settings, TuiAction::NewBoard) => {
(KeyCode::Char('n'), KeyModifiers::NONE)
}
(InputContext::Settings, TuiAction::Delete) => (KeyCode::Char('x'), KeyModifiers::NONE),
(_, TuiAction::ScrollUp) => (KeyCode::PageUp, KeyModifiers::NONE),
(_, TuiAction::ScrollDown) => (KeyCode::PageDown, KeyModifiers::NONE),
(_, TuiAction::MoveUp | TuiAction::Previous) => (KeyCode::Up, KeyModifiers::NONE),
(_, TuiAction::MoveDown | TuiAction::Next) => (KeyCode::Down, KeyModifiers::NONE),
(_, TuiAction::MoveLeft) => (KeyCode::Left, KeyModifiers::NONE),
(_, TuiAction::MoveRight) => (KeyCode::Right, KeyModifiers::NONE),
(_, TuiAction::PreviousBoard) => (KeyCode::BackTab, KeyModifiers::NONE),
(_, TuiAction::NextBoard) => (KeyCode::Tab, KeyModifiers::NONE),
(_, TuiAction::Submit | TuiAction::Open | TuiAction::Ignore) => {
(KeyCode::Enter, KeyModifiers::NONE)
}
(_, TuiAction::NewIssue) => (KeyCode::Char('n'), KeyModifiers::NONE),
(_, TuiAction::CloneIssue) => (KeyCode::Char('C'), KeyModifiers::NONE),
(_, TuiAction::ToggleArchived) => (KeyCode::Char('a'), KeyModifiers::NONE),
(_, TuiAction::Edit | TuiAction::EditPlanningItem) => {
(KeyCode::Char('e'), KeyModifiers::NONE)
}
(_, TuiAction::Agent) => (KeyCode::Char('A'), KeyModifiers::NONE),
(_, TuiAction::ToggleColumn | TuiAction::Comment) => {
(KeyCode::Char('c'), KeyModifiers::NONE)
}
(_, TuiAction::FilterModal) => (KeyCode::Char('f'), KeyModifiers::NONE),
(_, TuiAction::BulkModal) => (KeyCode::Char('B'), KeyModifiers::NONE),
(_, TuiAction::Triage) => (KeyCode::Char('t'), KeyModifiers::NONE),
(_, TuiAction::Mark) => (KeyCode::Char(' '), KeyModifiers::NONE),
(_, TuiAction::Unmark) => (KeyCode::Char('x'), KeyModifiers::NONE),
(_, TuiAction::MovePrevious) => (KeyCode::Char('<'), KeyModifiers::NONE),
(_, TuiAction::MoveNext) => (KeyCode::Char('>'), KeyModifiers::NONE),
(_, TuiAction::Delete) => (KeyCode::Char('d'), KeyModifiers::NONE),
(_, TuiAction::DeletePlanningItem) => (KeyCode::Char('x'), KeyModifiers::NONE),
(_, TuiAction::Refresh | TuiAction::Reset | TuiAction::Reedit) => {
(KeyCode::Char('r'), KeyModifiers::NONE)
}
(_, TuiAction::Settings) => (KeyCode::Char('s'), KeyModifiers::NONE),
(_, TuiAction::Planning) => (KeyCode::Char('m'), KeyModifiers::NONE),
(_, TuiAction::NewPlanningItem) => (KeyCode::Char('n'), KeyModifiers::NONE),
(_, TuiAction::NewBoard) => (KeyCode::Char('b'), KeyModifiers::NONE),
(_, TuiAction::AddColumn) => (KeyCode::Char('a'), KeyModifiers::NONE),
(_, TuiAction::Help) => (KeyCode::Char('?'), KeyModifiers::NONE),
(_, TuiAction::Quit) => (KeyCode::Char('q'), KeyModifiers::NONE),
(_, TuiAction::TextFilter) => (KeyCode::Char('/'), KeyModifiers::NONE),
(_, TuiAction::Close | TuiAction::Cancel) => (KeyCode::Esc, KeyModifiers::NONE),
(_, TuiAction::Asset) => (KeyCode::Char('a'), KeyModifiers::NONE),
(_, TuiAction::NewRelationship) => (KeyCode::Char('n'), KeyModifiers::NONE),
(_, TuiAction::EditRelationship) => (KeyCode::Char('E'), KeyModifiers::SHIFT),
(_, TuiAction::RemoveRelationship) => (KeyCode::Char('d'), KeyModifiers::NONE),
(_, TuiAction::NextRelationship) => (KeyCode::Char('j'), KeyModifiers::NONE),
(_, TuiAction::PreviousRelationship) => (KeyCode::Char('k'), KeyModifiers::NONE),
(_, TuiAction::OpenPicker) => (KeyCode::F(2), KeyModifiers::NONE),
(_, TuiAction::Toggle) => (KeyCode::Char(' '), KeyModifiers::NONE),
(_, TuiAction::Confirm) => (KeyCode::Char('y'), KeyModifiers::NONE),
(_, TuiAction::Create) => (KeyCode::Char('+'), KeyModifiers::NONE),
_ => (KeyCode::Esc, KeyModifiers::NONE),
};
KeyEvent::new(code, modifiers)
}
}
fn defaults(values: &[(&str, &[&str])]) -> HashMap<TuiAction, Vec<KeyChord>> {
let mut map = HashMap::new();
for (action, keys) in values {
let action = match *action {
"move_up" => TuiAction::MoveUp,
"move_down" => TuiAction::MoveDown,
"move_left" => TuiAction::MoveLeft,
"move_right" => TuiAction::MoveRight,
"next_board" => TuiAction::NextBoard,
"previous_board" => TuiAction::PreviousBoard,
"open" => TuiAction::Open,
"edit" => TuiAction::Edit,
"agent" => TuiAction::Agent,
"toggle_column" => TuiAction::ToggleColumn,
"filter_modal" => TuiAction::FilterModal,
"bulk_modal" => TuiAction::BulkModal,
"triage" => TuiAction::Triage,
"mark" => TuiAction::Mark,
"unmark" => TuiAction::Unmark,
"move_previous" => TuiAction::MovePrevious,
"move_next" => TuiAction::MoveNext,
"new_issue" => TuiAction::NewIssue,
"clone_issue" => TuiAction::CloneIssue,
"toggle_archived" => TuiAction::ToggleArchived,
"delete" => TuiAction::Delete,
"refresh" => TuiAction::Refresh,
"settings" => TuiAction::Settings,
"planning" => TuiAction::Planning,
"new_board" => TuiAction::NewBoard,
"help" => TuiAction::Help,
"quit" => TuiAction::Quit,
"text_filter" => TuiAction::TextFilter,
"reorder_up" => TuiAction::ReorderUp,
"reorder_down" => TuiAction::ReorderDown,
"close" => TuiAction::Close,
"scroll_up" => TuiAction::ScrollUp,
"scroll_down" => TuiAction::ScrollDown,
"comment" => TuiAction::Comment,
"asset" => TuiAction::Asset,
"new_relationship" => TuiAction::NewRelationship,
"edit_relationship" => TuiAction::EditRelationship,
"remove_relationship" => TuiAction::RemoveRelationship,
"next_relationship" => TuiAction::NextRelationship,
"previous_relationship" => TuiAction::PreviousRelationship,
"submit" => TuiAction::Submit,
"cancel" => TuiAction::Cancel,
"next" => TuiAction::Next,
"previous" => TuiAction::Previous,
"open_picker" => TuiAction::OpenPicker,
"reset" => TuiAction::Reset,
"toggle" => TuiAction::Toggle,
"add_column" => TuiAction::AddColumn,
"new_planning_item" => TuiAction::NewPlanningItem,
"edit_planning_item" => TuiAction::EditPlanningItem,
"delete_planning_item" => TuiAction::DeletePlanningItem,
"confirm" => TuiAction::Confirm,
"create" => TuiAction::Create,
"previous_year" => TuiAction::PreviousYear,
"next_year" => TuiAction::NextYear,
"previous_month" => TuiAction::PreviousMonth,
"next_month" => TuiAction::NextMonth,
"reedit" => TuiAction::Reedit,
"ignore" => TuiAction::Ignore,
_ => unreachable!(),
};
set(&mut map, action, keys);
}
map
}
fn default_mode(context: InputContext) -> HashMap<TuiAction, Vec<KeyChord>> {
match context {
InputContext::Board => defaults(&[
("move_up", &["up", "k"]),
("move_down", &["down", "j"]),
("scroll_up", &["pgup"]),
("scroll_down", &["pgdn"]),
("move_left", &["left", "h"]),
("move_right", &["right", "l"]),
("next_board", &["tab"]),
("previous_board", &["shift+tab"]),
("open", &["enter"]),
("edit", &["e"]),
("agent", &["A"]),
("toggle_column", &["c"]),
("filter_modal", &["f"]),
("bulk_modal", &["B"]),
("triage", &["t"]),
("mark", &["space"]),
("unmark", &["x"]),
("move_previous", &["<"]),
("move_next", &[">"]),
("new_issue", &["n"]),
("clone_issue", &["C"]),
("toggle_archived", &["a"]),
("delete", &["d"]),
("refresh", &["r"]),
("settings", &["s"]),
("planning", &["m", "M"]),
("new_board", &["b"]),
("help", &["?"]),
("quit", &["q", "Q", "esc"]),
("text_filter", &["/"]),
("reorder_up", &["ctrl+up"]),
("reorder_down", &["ctrl+down"]),
]),
InputContext::Rollover => defaults(&[
("submit", &["enter"]),
("previous", &["left", "right", "tab"]),
("cancel", &["esc"]),
]),
InputContext::Detail => defaults(&[
("close", &["q", "Q", "esc"]),
("edit", &["e"]),
("scroll_up", &["pgup"]),
("scroll_down", &["pgdn"]),
("help", &["?"]),
("comment", &["c"]),
("asset", &["a"]),
("new_relationship", &["n"]),
("edit_relationship", &["E"]),
("remove_relationship", &["d"]),
("next_relationship", &["j", "down"]),
("previous_relationship", &["k", "up"]),
]),
InputContext::Form | InputContext::PlanningForm => defaults(&[
("cancel", &["esc"]),
("submit", &["enter"]),
("next", &["tab", "down"]),
("previous", &["shift+tab", "up"]),
("open_picker", &["f2"]),
]),
InputContext::FormPicker => defaults(&[
("cancel", &["esc", "f2"]),
("next", &["up"]),
("previous", &["down"]),
("submit", &["enter"]),
("create", &["+", "insert"]),
]),
InputContext::ConfirmDelete => defaults(&[("confirm", &["y", "Y"]), ("cancel", &["esc"])]),
InputContext::ConfirmBlockedMove => defaults(&[
("confirm", &["y", "Y", "enter"]),
("cancel", &["n", "N", "esc"]),
]),
InputContext::MoveIntoDone
| InputContext::Comment
| InputContext::Asset
| InputContext::Filter => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
InputContext::Help => defaults(&[
("cancel", &["esc", "q", "?"]),
("next", &["down", "j"]),
("previous", &["up", "k"]),
("submit", &["enter"]),
("text_filter", &["/"]),
("scroll_up", &["pgup"]),
("scroll_down", &["pgdn"]),
]),
InputContext::ContextMenu => defaults(&[
("cancel", &["esc", "q", "?"]),
("next", &["down", "j"]),
("previous", &["up", "k"]),
("submit", &["enter"]),
]),
InputContext::HelpSearch => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
InputContext::BulkModal => defaults(&[
("cancel", &["esc"]),
("next", &["tab", "down"]),
("previous", &["shift+tab", "up"]),
("submit", &["enter"]),
]),
InputContext::Triage => defaults(&[
("cancel", &["esc"]),
("submit", &["enter"]),
("move_up", &["up"]),
("move_down", &["down"]),
("toggle", &["space"]),
]),
InputContext::FilterModal => defaults(&[
("cancel", &["esc"]),
("next", &["tab", "down"]),
("previous", &["shift+tab", "up"]),
("reset", &["r", "R"]),
("submit", &["enter"]),
("open_picker", &["f2"]),
]),
InputContext::Settings => defaults(&[
("close", &["q", "Q", "esc"]),
("next", &["tab"]),
("previous", &["shift+tab"]),
("move_up", &["up", "k"]),
("move_down", &["down", "j"]),
("toggle", &["space", "enter"]),
("add_column", &["a"]),
("new_board", &["n"]),
("delete", &["x", "X"]),
]),
InputContext::SettingsPrompt => defaults(&[
("cancel", &["esc", "q", "Q"]),
("submit", &["enter", "y", "Y"]),
]),
InputContext::PlanningManagement => defaults(&[
("close", &["q", "Q", "esc"]),
("move_up", &["up", "k"]),
("move_down", &["down", "j"]),
("next", &["tab", "shift+tab"]),
("new_planning_item", &["n"]),
("edit_planning_item", &["e"]),
("delete_planning_item", &["x", "X"]),
]),
InputContext::PlanningConfirm => {
defaults(&[("confirm", &["y", "Y", "enter"]), ("cancel", &["esc"])])
}
InputContext::DatePicker => defaults(&[
("cancel", &["esc", "q"]),
("submit", &["enter"]),
("move_up", &["up", "k"]),
("move_down", &["down", "j"]),
("move_left", &["left", "h"]),
("move_right", &["right", "l"]),
("previous_month", &["pgup"]),
("next_month", &["pgdn"]),
("previous_year", &["["]),
("next_year", &["]"]),
("reset", &["delete", "c", "C"]),
]),
InputContext::EditWarning => defaults(&[
("reedit", &["r", "R"]),
("ignore", &["i", "I", "enter", "esc"]),
]),
InputContext::LinkPrompt => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
}
}
macro_rules! overlay_fields {
($map:ident, $overrides:ident, $context:expr, $($field:ident => $action:ident),+ $(,)?) => {
$(
if let Some(values) = &$overrides.$field {
let mut chords = Vec::with_capacity(values.len());
for value in values {
chords.push(KeyChord::parse(value).map_err(|message| TixError::Config(format!(
"`keybindings.{}.{}`: {message}", $context.as_str(), stringify!($field)
)))?);
}
if chords.is_empty() {
return Err(TixError::Config(format!(
"`keybindings.{}.{}` must not be empty", $context.as_str(), stringify!($field)
)));
}
$map.insert(TuiAction::$action, chords);
}
)+
};
}
fn overlay(
map: &mut HashMap<TuiAction, Vec<KeyChord>>,
overrides: &ActionBindings,
context: InputContext,
) -> Result<()> {
overlay_fields!(map, overrides, context,
move_up => MoveUp, move_down => MoveDown, move_left => MoveLeft, move_right => MoveRight,
next_board => NextBoard, previous_board => PreviousBoard, open => Open, edit => Edit,
agent => Agent, toggle_column => ToggleColumn, filter_modal => FilterModal,
bulk_modal => BulkModal, mark => Mark, unmark => Unmark, triage => Triage,
move_previous => MovePrevious, move_next => MoveNext, new_issue => NewIssue,
clone_issue => CloneIssue,
toggle_archived => ToggleArchived,
delete => Delete, refresh => Refresh, settings => Settings, planning => Planning,
new_board => NewBoard, help => Help, quit => Quit, text_filter => TextFilter, reorder_up => ReorderUp,
reorder_down => ReorderDown, close => Close, scroll_up => ScrollUp, scroll_down => ScrollDown,
comment => Comment, asset => Asset, submit => Submit, cancel => Cancel, next => Next,
previous => Previous, open_picker => OpenPicker, reset => Reset, toggle => Toggle,
add_column => AddColumn, new_planning_item => NewPlanningItem,
edit_planning_item => EditPlanningItem, delete_planning_item => DeletePlanningItem,
confirm => Confirm, create => Create, previous_year => PreviousYear, next_year => NextYear,
previous_month => PreviousMonth, next_month => NextMonth, reedit => Reedit, ignore => Ignore,
);
let mut seen: HashMap<KeyChord, TuiAction> = HashMap::new();
for (action, chords) in map {
for key in chords {
if let Some(previous) = seen.insert(*key, *action) {
return Err(TixError::Config(format!(
"`keybindings.{}`: `{}` is assigned to both `{}` and `{}`",
context.as_str(),
key,
previous.as_str(),
action.as_str()
)));
}
}
}
Ok(())
}
impl ActionBindings {
pub fn is_empty(&self) -> bool {
self == &Self::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_and_normalizes_documented_chords() {
assert_eq!(KeyChord::parse("ESC").unwrap(), chord("esc"));
assert_eq!(KeyChord::parse("Enter").unwrap(), chord("enter"));
assert_eq!(KeyChord::parse("LEFT").unwrap(), chord("left"));
assert_eq!(KeyChord::parse("PgUp").unwrap(), chord("pgup"));
assert_eq!(KeyChord::parse("F2").unwrap(), chord("f2"));
assert_eq!(KeyChord::parse("Ctrl+UP").unwrap(), chord("ctrl+up"));
assert_eq!(KeyChord::parse("A").unwrap().display(), "Shift+A");
assert!(KeyChord::parse("ctrl+wat").is_err());
assert!(KeyChord::parse("f99").is_err());
}
#[test]
fn defaults_validate_and_cross_mode_reuse_is_allowed() {
let defaults = KeyBindings::resolve(&KeyBindings::default()).unwrap();
assert!(
defaults
.action(
InputContext::Board,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)
)
.is_some()
);
let mut overrides = KeyBindings::default();
overrides.board.open = Some(vec!["f4".into()]);
overrides.detail.close = Some(vec!["f4".into()]);
assert!(KeyBindings::resolve(&overrides).is_ok());
}
#[test]
fn conflicts_report_mode_and_actions() {
let mut overrides = KeyBindings::default();
overrides.board.open = Some(vec!["ctrl+n".into()]);
overrides.board.new_issue = Some(vec!["ctrl+n".into()]);
let error = KeyBindings::resolve(&overrides).unwrap_err().to_string();
assert!(error.contains("board") && error.contains("open") && error.contains("new_issue"));
}
}