use crate::error::{GwmError, Result};
use crate::tui::keymap::{KeyStroke, Source};
use crossterm::event::{KeyCode, KeyModifiers};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum KeyContext {
Create,
Confirm,
Help,
Detail,
CommandLogs,
Config,
ConfigEdit,
Report,
OpenMenu,
CommandPalette,
LinkChooseTarget,
LinkInputNumber,
ExecPicker,
Clean,
CiChecks,
}
impl KeyContext {
pub fn reserved_typing_stroke(self, stroke: &KeyStroke) -> bool {
use crossterm::event::{KeyCode as KC, KeyModifiers as KM};
if stroke.modifiers.intersects(KM::CONTROL | KM::ALT) {
return false;
}
match (self, stroke.code) {
(KeyContext::CommandPalette | KeyContext::LinkInputNumber | KeyContext::ConfigEdit, KC::Backspace) => true,
(KeyContext::CommandPalette, KC::Char(c)) if stroke.modifiers.contains(KM::SHIFT) => c.is_ascii_digit(),
(KeyContext::CommandPalette, KC::Char(c)) => c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-',
(KeyContext::LinkInputNumber, KC::Char(c)) => c.is_ascii_digit(),
(KeyContext::ConfigEdit, KC::Char(_)) => true,
_ => false,
}
}
pub fn config_path(self) -> &'static str {
match self {
KeyContext::Create => "create",
KeyContext::Confirm => "confirm",
KeyContext::Help => "help",
KeyContext::Detail => "detail",
KeyContext::CommandLogs => "command_logs",
KeyContext::Config => "config",
KeyContext::ConfigEdit => "config.edit",
KeyContext::Report => "report",
KeyContext::OpenMenu => "open_menu",
KeyContext::CommandPalette => "palette",
KeyContext::LinkChooseTarget => "link.choose_target",
KeyContext::LinkInputNumber => "link.input_number",
KeyContext::ExecPicker => "exec",
KeyContext::Clean => "clean",
KeyContext::CiChecks => "ci_checks",
}
}
pub fn from_config_path(path: &str) -> Option<Self> {
Self::all().iter().copied().find(|c| c.config_path() == path)
}
pub fn all() -> &'static [KeyContext] {
use KeyContext::*;
&[
Create,
Confirm,
Help,
Detail,
CommandLogs,
Config,
ConfigEdit,
Report,
OpenMenu,
CommandPalette,
LinkChooseTarget,
LinkInputNumber,
ExecPicker,
Clean,
CiChecks,
]
}
}
macro_rules! define_modal_actions {
( $( $ctx:ident { $( $variant:ident => $verb:literal [ $( $chord:literal ),* $(,)? ] ),* $(,)? } )* ) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ModalAction {
$( $( $variant, )* )*
}
impl ModalAction {
pub fn context(self) -> KeyContext {
match self { $( $( ModalAction::$variant => KeyContext::$ctx, )* )* }
}
pub fn verb(self) -> &'static str {
match self { $( $( ModalAction::$variant => $verb, )* )* }
}
fn default_chord_strs(self) -> &'static [&'static str] {
match self { $( $( ModalAction::$variant => &[ $( $chord, )* ], )* )* }
}
pub fn all() -> impl Iterator<Item = Self> {
[ $( $( ModalAction::$variant, )* )* ].into_iter()
}
}
};
}
define_modal_actions! {
Create {
CreateCancel => "cancel" [ "Esc" ],
CreateNextField => "next_field" [ "Tab" ],
CreatePrevField => "prev_field" [ "BackTab" ],
CreateSubmit => "submit" [ "Enter" ],
CreatePrevType => "prev_type" [ "Up", "Left", "h" ],
CreateNextType => "next_type" [ "Down", "Right", "l" ],
CreateToggleMode => "toggle_mode" [ "Ctrl+t" ],
}
Confirm {
ConfirmConfirm => "confirm" [ "y" ],
ConfirmActivate => "activate" [ "Enter" ],
ConfirmCancel => "cancel" [ "n", "Esc" ],
ConfirmFocusConfirm => "focus_confirm" [ "Left", "h" ],
ConfirmFocusCancel => "focus_cancel" [ "Right", "l" ],
ConfirmToggleFocus => "toggle_focus" [ "Tab" ],
}
Help {
HelpClose => "close" [ "Esc", "q", "?" ],
HelpScrollDown => "scroll_down" [ "Down", "j" ],
HelpScrollUp => "scroll_up" [ "Up", "k" ],
HelpScrollRight => "scroll_right" [ "Right", "l" ],
HelpScrollLeft => "scroll_left" [ "Left", "h" ],
HelpScrollTop => "scroll_top" [ "Home", "g" ],
HelpScrollBottom => "scroll_bottom" [ "End", "G" ],
}
Detail {
DetailClose => "close" [ "Esc", "q" ],
DetailSelectNext => "select_next" [ "Down", "j" ],
DetailSelectPrev => "select_prev" [ "Up", "k" ],
DetailAttach => "attach" [ "a" ],
DetailDetach => "detach" [ "d" ],
DetailInput => "attach_by_id" [ "i" ],
}
CommandLogs {
CommandLogsClose => "close" [ "Esc", "q" ],
CommandLogsCopy => "copy" [ "y" ],
CommandLogsScrollDown => "scroll_down" [ "Down", "j" ],
CommandLogsScrollUp => "scroll_up" [ "Up", "k" ],
CommandLogsScrollRight => "scroll_right" [ "Right", "l" ],
CommandLogsScrollLeft => "scroll_left" [ "Left", "h" ],
CommandLogsScrollTop => "scroll_top" [ "Home", "g" ],
CommandLogsScrollBottom => "scroll_bottom" [ "End", "G" ],
}
Config {
ConfigClose => "close" [ "Esc", "q" ],
ConfigNextTab => "next_tab" [ "Tab" ],
ConfigPrevTab => "prev_tab" [ "BackTab" ],
ConfigToggleLayer => "toggle_layer" [ "L" ],
ConfigActivate => "activate" [ "Space", "Enter" ],
ConfigSelectNext => "select_next" [ "Down", "j" ],
ConfigSelectPrev => "select_prev" [ "Up", "k" ],
ConfigScrollRight => "scroll_right" [ "Right", "l" ],
ConfigScrollLeft => "scroll_left" [ "Left", "h" ],
ConfigScrollTop => "scroll_top" [ "Home", "g" ],
ConfigScrollBottom => "scroll_bottom" [ "End", "G" ],
}
ConfigEdit {
ConfigEditSubmit => "submit" [ "Enter" ],
ConfigEditCancel => "cancel" [ "Esc" ],
}
Report {
ReportClose => "close" [ "Esc", "q", "Enter" ],
}
OpenMenu {
OpenMenuClose => "close" [ "Esc", "q" ],
OpenMenuToggle => "toggle" [ "j", "k", "Down", "Up" ],
OpenMenuAccept => "accept" [ "Enter" ],
OpenMenuIssue => "issue" [ "i" ],
OpenMenuPr => "pr" [ "p" ],
}
CommandPalette {
CommandPaletteClose => "close" [ "Esc" ],
CommandPaletteAccept => "accept" [ "Enter" ],
CommandPalettePrev => "prev" [ "Up" ],
CommandPaletteNext => "next" [ "Down", "Tab" ],
}
LinkChooseTarget {
LinkChooseNext => "next" [ "j", "Down" ],
LinkChoosePrev => "prev" [ "k", "Up" ],
LinkChooseIssue => "issue" [ "i" ],
LinkChoosePr => "pr" [ "p" ],
LinkChooseAccept => "accept" [ "Enter" ],
LinkChooseCancel => "cancel" [ "Esc" ],
}
LinkInputNumber {
LinkInputSubmit => "submit" [ "Enter" ],
LinkInputCancel => "cancel" [ "Esc" ],
}
ExecPicker {
ExecPickerNext => "next" [ "j", "Down" ],
ExecPickerPrev => "prev" [ "k", "Up" ],
ExecPickerAccept => "accept" [ "Enter" ],
ExecPickerCancel => "cancel" [ "Esc" ],
}
Clean {
CleanNext => "next" [ "j", "Down" ],
CleanPrev => "prev" [ "k", "Up" ],
CleanConfirm => "confirm" [ "y", "Enter" ],
CleanCancel => "cancel" [ "n", "Esc" ],
}
CiChecks {
CiChecksClose => "close" [ "Esc", "q" ],
CiChecksNext => "select_next" [ "j", "Down" ],
CiChecksPrev => "select_prev" [ "k", "Up" ],
CiChecksOpen => "open" [ "Enter" ],
CiChecksFilter => "filter" [ "/" ],
CiChecksRefresh => "refresh" [ "f" ],
}
}
impl ModalAction {
pub fn from_context_verb(ctx: KeyContext, verb: &str) -> Option<Self> {
Self::all().find(|a| a.context() == ctx && a.verb() == verb)
}
pub fn reserved_typing_stroke(self, stroke: &KeyStroke) -> bool {
use crossterm::event::{KeyCode as KC, KeyModifiers as KM};
if self.context().reserved_typing_stroke(stroke) {
return true;
}
matches!(
self,
ModalAction::CreateSubmit
| ModalAction::CreateCancel
| ModalAction::CreateNextField
| ModalAction::CreatePrevField
| ModalAction::CreateToggleMode
) && !stroke.modifiers.intersects(KM::CONTROL | KM::ALT)
&& matches!(stroke.code, KC::Char(_) | KC::Backspace)
}
fn default_keys(self) -> Vec<KeyStroke> {
self
.default_chord_strs()
.iter()
.map(|s| {
parse_single(s)
.unwrap_or_else(|e| panic!("default modal binding {:?} for {:?} failed to parse: {}", s, self, e))
})
.collect()
}
}
pub fn parse_single(s: &str) -> Result<KeyStroke> {
let strokes = KeyStroke::parse_chord(s)?;
let stroke = match strokes.into_iter().collect::<Vec<_>>().as_slice() {
[one] => one.clone(),
_ => {
return Err(GwmError::Config(format!(
"modal bindings must be a single keystroke, got chord {:?} (modals have no chord timeout)",
s
)))
}
};
if stroke.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(stroke.code, KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c'))
{
return Err(GwmError::Config(format!(
"modal bindings cannot use {:?}: Ctrl+C is the reserved emergency quit (handled before any modal lookup)",
s
)));
}
Ok(stroke)
}
#[derive(Debug, Clone)]
pub struct ModalBinding {
pub action: ModalAction,
pub keys: Vec<KeyStroke>,
pub source: Source,
}
#[derive(Debug, Clone)]
pub struct ModalKeymap {
entries: Vec<ModalBinding>,
}
impl ModalKeymap {
pub fn defaults() -> Self {
let entries = ModalAction::all()
.map(|action| ModalBinding {
action,
keys: action.default_keys(),
source: Source::Default,
})
.collect();
Self { entries }
}
pub fn apply_override(&mut self, action: ModalAction, keys: Vec<KeyStroke>) -> Result<()> {
let ctx = action.context();
for k in &keys {
if action.reserved_typing_stroke(k) {
return Err(GwmError::Config(format!(
"context {}: key {} is reserved for typing input there and cannot be bound to {} — \
the dispatch routes it into the input before the modal resolution",
ctx.config_path(),
k,
action.verb()
)));
}
}
let claimed: Vec<&KeyStroke> = keys.iter().collect();
let mut map: HashMap<KeyStroke, ModalAction> = HashMap::new();
for b in &self.entries {
if b.action.context() != ctx || b.action == action {
continue;
}
for k in &b.keys {
if b.source == Source::Default && claimed.contains(&k) {
continue;
}
map.insert(k.clone(), b.action);
}
}
for k in &keys {
if let Some(prev) = map.get(k) {
return Err(GwmError::Config(format!(
"context {}: key {} bound to both {:?} and {:?} — conflict",
ctx.config_path(),
k,
prev.verb(),
action.verb()
)));
}
}
let claimed_owned: Vec<KeyStroke> = keys.clone();
for entry in self.entries.iter_mut() {
if entry.action.context() == ctx && entry.action != action && entry.source == Source::Default {
entry.keys.retain(|k| !claimed_owned.contains(k));
}
}
if let Some(entry) = self.entries.iter_mut().find(|b| b.action == action) {
entry.keys = keys;
entry.source = Source::UserConfig;
} else {
self.entries.push(ModalBinding {
action,
keys,
source: Source::UserConfig,
});
}
Ok(())
}
pub fn resolve(&self, ctx: KeyContext, stroke: &KeyStroke) -> Option<ModalAction> {
self
.entries
.iter()
.filter(|b| b.action.context() == ctx)
.find(|b| b.keys.iter().any(|k| k == stroke))
.map(|b| b.action)
}
pub fn bindings_for(&self, ctx: KeyContext) -> Vec<&ModalBinding> {
self.entries.iter().filter(|b| b.action.context() == ctx).collect()
}
pub fn list(&self) -> &[ModalBinding] {
&self.entries
}
pub fn primary_key(&self, action: ModalAction) -> Option<String> {
self
.entries
.iter()
.find(|b| b.action == action)
.and_then(|b| b.keys.first())
.map(|k| k.to_string())
}
pub fn keys_display(&self, action: ModalAction) -> String {
self
.entries
.iter()
.find(|b| b.action == action)
.map(|b| b.keys.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(", "))
.unwrap_or_default()
}
}
impl Default for ModalKeymap {
fn default() -> Self {
Self::defaults()
}
}