1use std::collections::HashMap;
2use std::fmt;
3
4use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5use serde::de::Error as _;
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8use crate::error::{Result, TixError};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum InputContext {
12 Board,
13 BulkModal,
17 Triage,
26 Rollover,
27 Detail,
28 Form,
29 FormPicker,
30 ConfirmDelete,
31 MoveIntoDone,
32 ConfirmBlockedMove,
35 Help,
36 HelpSearch,
43 ContextMenu,
44 Filter,
45 FilterModal,
46 Settings,
47 SettingsPrompt,
48 PlanningManagement,
49 PlanningConfirm,
50 PlanningForm,
51 DatePicker,
52 Comment,
53 Asset,
54 EditWarning,
55 LinkPrompt,
61}
62
63impl InputContext {
64 pub const ALL: &'static [Self] = &[
65 Self::Board,
66 Self::Rollover,
67 Self::Detail,
68 Self::Form,
69 Self::FormPicker,
70 Self::ConfirmDelete,
71 Self::MoveIntoDone,
72 Self::ConfirmBlockedMove,
73 Self::Help,
74 Self::HelpSearch,
75 Self::ContextMenu,
76 Self::Filter,
77 Self::FilterModal,
78 Self::BulkModal,
79 Self::Triage,
80 Self::Settings,
81 Self::SettingsPrompt,
82 Self::PlanningManagement,
83 Self::PlanningConfirm,
84 Self::PlanningForm,
85 Self::DatePicker,
86 Self::Comment,
87 Self::Asset,
88 Self::EditWarning,
89 Self::LinkPrompt,
93 ];
94
95 pub const fn as_str(self) -> &'static str {
96 match self {
97 Self::Board => "board",
98 Self::Rollover => "rollover",
99 Self::Detail => "detail",
100 Self::Form => "form",
101 Self::FormPicker => "form_picker",
102 Self::ConfirmDelete => "confirm_delete",
103 Self::ConfirmBlockedMove => "confirm_blocked_move",
104 Self::MoveIntoDone => "move_into_done",
105 Self::Help => "help",
106 Self::HelpSearch => "help_search",
107 Self::ContextMenu => "context_menu",
108 Self::Filter => "filter",
109 Self::FilterModal => "filter_modal",
110 Self::BulkModal => "bulk_modal",
111 Self::Settings => "settings",
112 Self::SettingsPrompt => "settings_prompt",
113 Self::PlanningManagement => "planning_management",
114 Self::PlanningConfirm => "planning_confirm",
115 Self::PlanningForm => "planning_form",
116 Self::DatePicker => "date_picker",
117 Self::Comment => "comment",
118 Self::Asset => "asset",
119 Self::EditWarning => "edit_warning",
120 Self::LinkPrompt => "link_prompt",
121 Self::Triage => "triage",
122 }
123 }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
127#[serde(rename_all = "snake_case")]
128pub enum TuiAction {
129 MoveUp,
130 MoveDown,
131 MoveLeft,
132 MoveRight,
133 NextBoard,
134 PreviousBoard,
135 Open,
136 Edit,
137 Agent,
138 ToggleColumn,
139 FilterModal,
140 BulkModal,
141 Triage,
145 Mark,
146 Unmark,
147 MovePrevious,
148 MoveNext,
149 NewIssue,
150 CloneIssue,
156 ToggleArchived,
161 Delete,
162 Refresh,
163 Settings,
164 Planning,
165 NewBoard,
166 Help,
167 Quit,
168 TextFilter,
169 ReorderUp,
170 ReorderDown,
171 Close,
172 ScrollUp,
173 ScrollDown,
174 Comment,
175 Asset,
176 NewRelationship,
178 EditRelationship,
180 RemoveRelationship,
182 NextRelationship,
184 PreviousRelationship,
185 Submit,
186 Cancel,
187 Next,
188 Previous,
189 OpenPicker,
190 Reset,
191 Toggle,
192 AddColumn,
193 NewPlanningItem,
194 EditPlanningItem,
195 DeletePlanningItem,
196 Confirm,
197 Create,
198 PreviousYear,
199 NextYear,
200 PreviousMonth,
201 NextMonth,
202 Reedit,
203 Ignore,
204}
205
206impl TuiAction {
207 pub const fn as_str(self) -> &'static str {
208 match self {
209 Self::MoveUp => "move_up",
210 Self::MoveDown => "move_down",
211 Self::MoveLeft => "move_left",
212 Self::MoveRight => "move_right",
213 Self::NextBoard => "next_board",
214 Self::PreviousBoard => "previous_board",
215 Self::Open => "open",
216 Self::Edit => "edit",
217 Self::Agent => "agent",
218 Self::ToggleColumn => "toggle_column",
219 Self::FilterModal => "filter_modal",
220 Self::BulkModal => "bulk_modal",
221 Self::Triage => "triage",
222 Self::Mark => "mark",
223 Self::Unmark => "unmark",
224 Self::MovePrevious => "move_previous",
225 Self::MoveNext => "move_next",
226 Self::NewIssue => "new_issue",
227 Self::CloneIssue => "clone_issue",
228 Self::ToggleArchived => "toggle_archived",
229 Self::Delete => "delete",
230 Self::Refresh => "refresh",
231 Self::Settings => "settings",
232 Self::Planning => "planning",
233 Self::NewBoard => "new_board",
234 Self::Help => "help",
235 Self::Quit => "quit",
236 Self::TextFilter => "text_filter",
237 Self::ReorderUp => "reorder_up",
238 Self::ReorderDown => "reorder_down",
239 Self::Close => "close",
240 Self::ScrollUp => "scroll_up",
241 Self::ScrollDown => "scroll_down",
242 Self::Comment => "comment",
243 Self::Asset => "asset",
244 Self::NewRelationship => "new_relationship",
245 Self::EditRelationship => "edit_relationship",
246 Self::RemoveRelationship => "remove_relationship",
247 Self::NextRelationship => "next_relationship",
248 Self::PreviousRelationship => "previous_relationship",
249 Self::Submit => "submit",
250 Self::Cancel => "cancel",
251 Self::Next => "next",
252 Self::Previous => "previous",
253 Self::OpenPicker => "open_picker",
254 Self::Reset => "reset",
255 Self::Toggle => "toggle",
256 Self::AddColumn => "add_column",
257 Self::NewPlanningItem => "new_planning_item",
258 Self::EditPlanningItem => "edit_planning_item",
259 Self::DeletePlanningItem => "delete_planning_item",
260 Self::Confirm => "confirm",
261 Self::Create => "create",
262 Self::PreviousYear => "previous_year",
263 Self::NextYear => "next_year",
264 Self::PreviousMonth => "previous_month",
265 Self::NextMonth => "next_month",
266 Self::Reedit => "reedit",
267 Self::Ignore => "ignore",
268 }
269 }
270
271 pub const fn description(self) -> &'static str {
272 match self {
273 Self::MoveUp => "move up",
274 Self::MoveDown => "move down",
275 Self::MoveLeft => "previous column",
276 Self::MoveRight => "next column",
277 Self::NextBoard => "next board",
278 Self::PreviousBoard => "previous board",
279 Self::Open => "open detail",
280 Self::Edit => "edit",
281 Self::Agent => "open agent",
282 Self::ToggleColumn => "toggle column",
283 Self::FilterModal => "filter criteria",
284 Self::BulkModal => "batch select",
285 Self::Triage => "triage inbox",
286 Self::Mark => "mark for batch",
287 Self::Unmark => "unmark for batch",
288 Self::MovePrevious => "move to previous column",
289 Self::MoveNext => "move to next column",
290 Self::NewIssue => "new issue",
291 Self::CloneIssue => "clone issue",
292 Self::ToggleArchived => "show archived issues",
293 Self::Delete => "delete",
294 Self::Refresh => "refresh",
295 Self::Settings => "settings",
296 Self::Planning => "planning",
297 Self::NewBoard => "new board",
298 Self::Help => "help",
299 Self::Quit => "quit",
300 Self::TextFilter => "filter",
301 Self::ReorderUp => "reorder up",
302 Self::ReorderDown => "reorder down",
303 Self::Close => "close",
304 Self::ScrollUp => "scroll up one page",
305 Self::ScrollDown => "scroll down one page",
306 Self::Comment => "comment",
307 Self::Asset => "asset",
308 Self::NewRelationship => "new relationship",
309 Self::EditRelationship => "edit relationship",
310 Self::RemoveRelationship => "remove relationship",
311 Self::NextRelationship => "next relationship",
312 Self::PreviousRelationship => "previous relationship",
313 Self::Submit => "submit",
314 Self::Cancel => "cancel",
315 Self::Next => "next",
316 Self::Previous => "previous",
317 Self::OpenPicker => "open picker",
318 Self::Reset => "reset",
319 Self::Toggle => "toggle",
320 Self::AddColumn => "add column",
321 Self::NewPlanningItem => "new",
322 Self::EditPlanningItem => "edit",
323 Self::DeletePlanningItem => "delete",
324 Self::Confirm => "confirm",
325 Self::Create => "create",
326 Self::PreviousYear => "previous year",
327 Self::NextYear => "next year",
328 Self::PreviousMonth => "previous month",
329 Self::NextMonth => "next month",
330 Self::Reedit => "re-edit",
331 Self::Ignore => "ignore",
332 }
333 }
334}
335
336pub type Binding = Vec<String>;
337
338macro_rules! partial_bindings {
339 ($($field:ident),+ $(,)?) => {
340 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
341 #[serde(default, deny_unknown_fields)]
342 pub struct ActionBindings {
343 $(
344 #[serde(skip_serializing_if = "Option::is_none")]
345 pub $field: Option<Binding>,
346 )+
347 }
348 };
349}
350
351partial_bindings!(
352 move_up,
353 move_down,
354 move_left,
355 move_right,
356 next_board,
357 previous_board,
358 open,
359 edit,
360 agent,
361 toggle_column,
362 filter_modal,
363 bulk_modal,
364 triage,
365 mark,
366 unmark,
367 move_previous,
368 move_next,
369 new_issue,
370 clone_issue,
371 toggle_archived,
372 delete,
373 refresh,
374 settings,
375 planning,
376 new_board,
377 help,
378 quit,
379 text_filter,
380 reorder_up,
381 reorder_down,
382 close,
383 scroll_up,
384 scroll_down,
385 comment,
386 asset,
387 submit,
388 cancel,
389 next,
390 previous,
391 open_picker,
392 reset,
393 toggle,
394 add_column,
395 new_planning_item,
396 edit_planning_item,
397 delete_planning_item,
398 confirm,
399 create,
400 previous_year,
401 next_year,
402 previous_month,
403 next_month,
404 reedit,
405 ignore,
406);
407
408macro_rules! keybindings_config {
409 ($($field:ident => $context:ident),+ $(,)?) => {
410 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
411 #[serde(default, deny_unknown_fields)]
412 pub struct KeyBindings {
413 $(
414 #[serde(skip_serializing_if = "ActionBindings::is_empty")]
415 pub $field: ActionBindings,
416 )+
417 }
418
419 impl KeyBindings {
420 pub fn is_empty(&self) -> bool {
421 self == &Self::default()
422 }
423
424 fn overrides(&self, context: InputContext) -> &ActionBindings {
425 match context {
426 $(InputContext::$context => &self.$field,)+
427 }
428 }
429 }
430 };
431}
432
433keybindings_config!(
434 board => Board,
435 rollover => Rollover,
436 detail => Detail,
437 form => Form,
438 form_picker => FormPicker,
439 confirm_delete => ConfirmDelete,
440 confirm_blocked_move => ConfirmBlockedMove,
441 help_search => HelpSearch,
442 move_into_done => MoveIntoDone,
443 help => Help,
444 context_menu => ContextMenu,
445 filter => Filter,
446 filter_modal => FilterModal,
447 bulk_modal => BulkModal,
448 triage => Triage,
449 settings => Settings,
450 settings_prompt => SettingsPrompt,
451 planning_management => PlanningManagement,
452 planning_confirm => PlanningConfirm,
453 planning_form => PlanningForm,
454 date_picker => DatePicker,
455 comment => Comment,
456 asset => Asset,
457 edit_warning => EditWarning,
458 link_prompt => LinkPrompt,
459);
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
462pub struct KeyChord {
463 pub code: KeyCode,
464 pub modifiers: KeyModifiers,
465}
466
467impl KeyChord {
468 pub fn parse(value: &str) -> std::result::Result<Self, String> {
469 let value = value.trim();
470 if value.is_empty() {
471 return Err("key chord must not be empty".into());
472 }
473 if value.chars().count() == 1 {
474 let ch = value.chars().next().expect("value has one character");
475 if ch.is_control() {
476 return Err(format!("unsupported key name `{value}`"));
477 }
478 let modifiers = if ch.is_ascii_uppercase() {
479 KeyModifiers::SHIFT
480 } else {
481 KeyModifiers::NONE
482 };
483 return Ok(Self {
484 code: KeyCode::Char(ch),
485 modifiers,
486 });
487 }
488 let mut parts = value.split('+');
489 let key = parts.next_back().ok_or("missing key name")?.trim();
490 let mut modifiers = KeyModifiers::NONE;
491 for part in parts {
492 match part.trim().to_ascii_lowercase().as_str() {
493 "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
494 "alt" | "option" => modifiers |= KeyModifiers::ALT,
495 "shift" => modifiers |= KeyModifiers::SHIFT,
496 "super" | "cmd" | "command" => modifiers |= KeyModifiers::SUPER,
497 other => return Err(format!("unsupported modifier `{other}`")),
498 }
499 }
500 if key.is_empty() {
501 return Err("missing key name".into());
502 }
503 let mut code = match key.to_ascii_lowercase().as_str() {
504 "backtab" => return Err("use shift+tab instead of `backtab`".into()),
505 "shift+tab" => KeyCode::BackTab,
506 "backspace" => KeyCode::Backspace,
507 "tab" => KeyCode::Tab,
508 "enter" | "return" => KeyCode::Enter,
509 "esc" | "escape" => KeyCode::Esc,
510 "space" => KeyCode::Char(' '),
511 "left" => KeyCode::Left,
512 "right" => KeyCode::Right,
513 "up" => KeyCode::Up,
514 "down" => KeyCode::Down,
515 "home" => KeyCode::Home,
516 "end" => KeyCode::End,
517 "pageup" | "pgup" => KeyCode::PageUp,
518 "pagedown" | "pgdn" | "pgdown" => KeyCode::PageDown,
519 "delete" | "del" => KeyCode::Delete,
520 "insert" | "ins" => KeyCode::Insert,
521 _ => {
522 if let Some(number) = key
523 .to_ascii_lowercase()
524 .strip_prefix('f')
525 .and_then(|n| n.parse::<u8>().ok())
526 {
527 if (1..=12).contains(&number) {
528 KeyCode::F(number)
529 } else {
530 return Err(format!("unsupported function key `{key}`"));
531 }
532 } else {
533 let mut chars = key.chars();
534 let Some(ch) = chars.next().filter(|_| chars.next().is_none()) else {
535 return Err(format!("unsupported key name `{key}`"));
536 };
537 if ch.is_control() {
538 return Err(format!("unsupported key name `{key}`"));
539 }
540 if ch.is_ascii_uppercase() {
541 modifiers |= KeyModifiers::SHIFT;
542 }
543 KeyCode::Char(ch)
544 }
545 }
546 };
547 if code == KeyCode::Tab && modifiers.contains(KeyModifiers::SHIFT) {
548 code = KeyCode::BackTab;
549 modifiers.remove(KeyModifiers::SHIFT);
550 }
551 if code == KeyCode::BackTab {
552 modifiers |= KeyModifiers::SHIFT;
553 }
554 Ok(Self { code, modifiers })
555 }
556
557 pub fn matches(&self, event: KeyEvent) -> bool {
558 if self.code != event.code {
559 return false;
560 }
561 if self.modifiers.is_empty() {
562 return event.modifiers.is_empty();
563 }
564 if self.code == KeyCode::BackTab {
565 return event.modifiers.contains(KeyModifiers::SHIFT) || event.code == KeyCode::BackTab;
566 }
567 if let (KeyCode::Char(expected), KeyCode::Char(actual)) = (self.code, event.code) {
568 let shift_expected = self.modifiers.contains(KeyModifiers::SHIFT);
569 let normalized_expected = if shift_expected {
570 expected.to_ascii_lowercase()
571 } else {
572 expected
573 };
574 let shift_actual =
575 actual.is_uppercase() || event.modifiers.contains(KeyModifiers::SHIFT);
576 let expected_other = self.modifiers.difference(KeyModifiers::SHIFT);
577 let actual_other = event.modifiers.difference(KeyModifiers::SHIFT);
578 return expected_other == actual_other
579 && normalized_expected == actual.to_ascii_lowercase()
580 && shift_expected == shift_actual;
581 }
582 event.modifiers == self.modifiers
583 }
584
585 pub fn display(&self) -> String {
586 let mut out = String::new();
587 if self.modifiers.contains(KeyModifiers::CONTROL) {
588 out.push_str("Ctrl+");
589 }
590 if self.modifiers.contains(KeyModifiers::ALT) {
591 out.push_str("Alt+");
592 }
593 if self.modifiers.contains(KeyModifiers::SUPER) {
594 out.push_str("Super+");
595 }
596 if self.modifiers.contains(KeyModifiers::SHIFT) && self.code != KeyCode::BackTab {
597 out.push_str("Shift+");
598 }
599 out.push_str(&match self.code {
600 KeyCode::BackTab => "Shift+Tab".into(),
601 KeyCode::Char(' ') => "Space".into(),
602 KeyCode::Char(ch) => ch.to_string(),
603 KeyCode::Enter => "Enter".into(),
604 KeyCode::Esc => "Esc".into(),
605 KeyCode::Left => "Left".into(),
606 KeyCode::Right => "Right".into(),
607 KeyCode::Up => "Up".into(),
608 KeyCode::Down => "Down".into(),
609 KeyCode::PageUp => "PgUp".into(),
610 KeyCode::PageDown => "PgDn".into(),
611 KeyCode::Delete => "Delete".into(),
612 KeyCode::Insert => "Insert".into(),
613 KeyCode::Home => "Home".into(),
614 KeyCode::End => "End".into(),
615 KeyCode::F(n) => format!("F{n}"),
616 other => format!("{other:?}"),
617 });
618 out
619 }
620}
621
622impl Serialize for KeyChord {
623 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
624 where
625 S: Serializer,
626 {
627 serializer.serialize_str(&self.display())
628 }
629}
630
631impl<'de> Deserialize<'de> for KeyChord {
632 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
633 where
634 D: Deserializer<'de>,
635 {
636 let value = String::deserialize(deserializer)?;
637 Self::parse(&value).map_err(D::Error::custom)
638 }
639}
640
641impl fmt::Display for KeyChord {
642 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
643 formatter.write_str(&self.display())
644 }
645}
646
647#[derive(Debug, Clone, Default, PartialEq, Eq)]
648pub struct EffectiveBindings {
649 modes: HashMap<InputContext, HashMap<TuiAction, Vec<KeyChord>>>,
650}
651
652fn chord(value: &str) -> KeyChord {
653 KeyChord::parse(value).expect("built-in key chord must parse")
654}
655
656fn set(map: &mut HashMap<TuiAction, Vec<KeyChord>>, action: TuiAction, values: &[&str]) {
657 map.insert(action, values.iter().map(|value| chord(value)).collect());
658}
659
660impl KeyBindings {
661 pub fn resolve(overrides: &KeyBindings) -> Result<EffectiveBindings> {
662 let mut effective = EffectiveBindings::default();
663 for context in InputContext::ALL {
664 let mut mode = default_mode(*context);
665 overlay(&mut mode, overrides.overrides(*context), *context)?;
666 effective.modes.insert(*context, mode);
667 }
668 Ok(effective)
669 }
670}
671
672impl EffectiveBindings {
673 pub fn action(&self, context: InputContext, event: KeyEvent) -> Option<TuiAction> {
674 self.modes
675 .get(&context)?
676 .iter()
677 .find_map(|(action, chords)| {
678 chords
679 .iter()
680 .any(|key| key.matches(event))
681 .then_some(*action)
682 })
683 }
684
685 pub fn bound_actions(&self, context: InputContext) -> Vec<TuiAction> {
691 let mut actions: Vec<TuiAction> = self
692 .modes
693 .get(&context)
694 .map(|mode| mode.keys().copied().collect())
695 .unwrap_or_default();
696 actions.sort_by_key(|action| action.as_str());
697 actions
698 }
699
700 pub fn chords(&self, context: InputContext, action: TuiAction) -> &[KeyChord] {
701 self.modes
702 .get(&context)
703 .and_then(|mode| mode.get(&action))
704 .map(Vec::as_slice)
705 .unwrap_or_default()
706 }
707
708 pub fn label(&self, context: InputContext, action: TuiAction) -> String {
709 let labels: Vec<String> = self
710 .chords(context, action)
711 .iter()
712 .map(ToString::to_string)
713 .collect();
714 if labels.is_empty() {
715 "—".into()
716 } else {
717 labels.join(" / ")
718 }
719 }
720
721 pub fn canonical_event(&self, context: InputContext, action: TuiAction) -> KeyEvent {
722 let (code, modifiers) = match (context, action) {
723 (_, TuiAction::ReorderUp) => (KeyCode::Up, KeyModifiers::CONTROL),
724 (_, TuiAction::ReorderDown) => (KeyCode::Down, KeyModifiers::CONTROL),
725 (InputContext::Rollover, TuiAction::Previous) => (KeyCode::Tab, KeyModifiers::NONE),
726 (InputContext::PlanningManagement, TuiAction::Next) => {
727 (KeyCode::Tab, KeyModifiers::NONE)
728 }
729 (InputContext::FormPicker, TuiAction::Next) => (KeyCode::Up, KeyModifiers::NONE),
730 (InputContext::FormPicker, TuiAction::Previous) => (KeyCode::Down, KeyModifiers::NONE),
731 (InputContext::DatePicker, TuiAction::PreviousYear) => {
732 (KeyCode::Char('['), KeyModifiers::NONE)
733 }
734 (InputContext::DatePicker, TuiAction::NextYear) => {
735 (KeyCode::Char(']'), KeyModifiers::NONE)
736 }
737 (InputContext::DatePicker, TuiAction::PreviousMonth) => {
738 (KeyCode::PageUp, KeyModifiers::NONE)
739 }
740 (InputContext::DatePicker, TuiAction::NextMonth) => {
741 (KeyCode::PageDown, KeyModifiers::NONE)
742 }
743 (InputContext::DatePicker, TuiAction::Reset) => (KeyCode::Delete, KeyModifiers::NONE),
744 (InputContext::Settings, TuiAction::NewBoard) => {
745 (KeyCode::Char('n'), KeyModifiers::NONE)
746 }
747 (InputContext::Settings, TuiAction::Delete) => (KeyCode::Char('x'), KeyModifiers::NONE),
748 (_, TuiAction::ScrollUp) => (KeyCode::PageUp, KeyModifiers::NONE),
756 (_, TuiAction::ScrollDown) => (KeyCode::PageDown, KeyModifiers::NONE),
757 (_, TuiAction::MoveUp | TuiAction::Previous) => (KeyCode::Up, KeyModifiers::NONE),
758 (_, TuiAction::MoveDown | TuiAction::Next) => (KeyCode::Down, KeyModifiers::NONE),
759 (_, TuiAction::MoveLeft) => (KeyCode::Left, KeyModifiers::NONE),
760 (_, TuiAction::MoveRight) => (KeyCode::Right, KeyModifiers::NONE),
761 (_, TuiAction::PreviousBoard) => (KeyCode::BackTab, KeyModifiers::NONE),
762 (_, TuiAction::NextBoard) => (KeyCode::Tab, KeyModifiers::NONE),
763 (_, TuiAction::Submit | TuiAction::Open | TuiAction::Ignore) => {
764 (KeyCode::Enter, KeyModifiers::NONE)
765 }
766 (_, TuiAction::NewIssue) => (KeyCode::Char('n'), KeyModifiers::NONE),
767 (_, TuiAction::CloneIssue) => (KeyCode::Char('C'), KeyModifiers::NONE),
768 (_, TuiAction::ToggleArchived) => (KeyCode::Char('a'), KeyModifiers::NONE),
769 (_, TuiAction::Edit | TuiAction::EditPlanningItem) => {
770 (KeyCode::Char('e'), KeyModifiers::NONE)
771 }
772 (_, TuiAction::Agent) => (KeyCode::Char('A'), KeyModifiers::NONE),
773 (_, TuiAction::ToggleColumn | TuiAction::Comment) => {
774 (KeyCode::Char('c'), KeyModifiers::NONE)
775 }
776 (_, TuiAction::FilterModal) => (KeyCode::Char('f'), KeyModifiers::NONE),
777 (_, TuiAction::BulkModal) => (KeyCode::Char('B'), KeyModifiers::NONE),
778 (_, TuiAction::Triage) => (KeyCode::Char('t'), KeyModifiers::NONE),
779 (_, TuiAction::Mark) => (KeyCode::Char(' '), KeyModifiers::NONE),
780 (_, TuiAction::Unmark) => (KeyCode::Char('x'), KeyModifiers::NONE),
781 (_, TuiAction::MovePrevious) => (KeyCode::Char('<'), KeyModifiers::NONE),
782 (_, TuiAction::MoveNext) => (KeyCode::Char('>'), KeyModifiers::NONE),
783 (_, TuiAction::Delete) => (KeyCode::Char('d'), KeyModifiers::NONE),
784 (_, TuiAction::DeletePlanningItem) => (KeyCode::Char('x'), KeyModifiers::NONE),
785 (_, TuiAction::Refresh | TuiAction::Reset | TuiAction::Reedit) => {
786 (KeyCode::Char('r'), KeyModifiers::NONE)
787 }
788 (_, TuiAction::Settings) => (KeyCode::Char('s'), KeyModifiers::NONE),
789 (_, TuiAction::Planning) => (KeyCode::Char('m'), KeyModifiers::NONE),
790 (_, TuiAction::NewPlanningItem) => (KeyCode::Char('n'), KeyModifiers::NONE),
791 (_, TuiAction::NewBoard) => (KeyCode::Char('b'), KeyModifiers::NONE),
792 (_, TuiAction::AddColumn) => (KeyCode::Char('a'), KeyModifiers::NONE),
793 (_, TuiAction::Help) => (KeyCode::Char('?'), KeyModifiers::NONE),
794 (_, TuiAction::Quit) => (KeyCode::Char('q'), KeyModifiers::NONE),
795 (_, TuiAction::TextFilter) => (KeyCode::Char('/'), KeyModifiers::NONE),
796 (_, TuiAction::Close | TuiAction::Cancel) => (KeyCode::Esc, KeyModifiers::NONE),
797 (_, TuiAction::Asset) => (KeyCode::Char('a'), KeyModifiers::NONE),
798 (_, TuiAction::NewRelationship) => (KeyCode::Char('n'), KeyModifiers::NONE),
801 (_, TuiAction::EditRelationship) => (KeyCode::Char('E'), KeyModifiers::SHIFT),
802 (_, TuiAction::RemoveRelationship) => (KeyCode::Char('d'), KeyModifiers::NONE),
803 (_, TuiAction::NextRelationship) => (KeyCode::Char('j'), KeyModifiers::NONE),
804 (_, TuiAction::PreviousRelationship) => (KeyCode::Char('k'), KeyModifiers::NONE),
805 (_, TuiAction::OpenPicker) => (KeyCode::F(2), KeyModifiers::NONE),
806 (_, TuiAction::Toggle) => (KeyCode::Char(' '), KeyModifiers::NONE),
807 (_, TuiAction::Confirm) => (KeyCode::Char('y'), KeyModifiers::NONE),
808 (_, TuiAction::Create) => (KeyCode::Char('+'), KeyModifiers::NONE),
809 _ => (KeyCode::Esc, KeyModifiers::NONE),
810 };
811 KeyEvent::new(code, modifiers)
812 }
813}
814
815fn defaults(values: &[(&str, &[&str])]) -> HashMap<TuiAction, Vec<KeyChord>> {
816 let mut map = HashMap::new();
817 for (action, keys) in values {
818 let action = match *action {
819 "move_up" => TuiAction::MoveUp,
820 "move_down" => TuiAction::MoveDown,
821 "move_left" => TuiAction::MoveLeft,
822 "move_right" => TuiAction::MoveRight,
823 "next_board" => TuiAction::NextBoard,
824 "previous_board" => TuiAction::PreviousBoard,
825 "open" => TuiAction::Open,
826 "edit" => TuiAction::Edit,
827 "agent" => TuiAction::Agent,
828 "toggle_column" => TuiAction::ToggleColumn,
829 "filter_modal" => TuiAction::FilterModal,
830 "bulk_modal" => TuiAction::BulkModal,
831 "triage" => TuiAction::Triage,
832 "mark" => TuiAction::Mark,
833 "unmark" => TuiAction::Unmark,
834 "move_previous" => TuiAction::MovePrevious,
835 "move_next" => TuiAction::MoveNext,
836 "new_issue" => TuiAction::NewIssue,
837 "clone_issue" => TuiAction::CloneIssue,
838 "toggle_archived" => TuiAction::ToggleArchived,
839 "delete" => TuiAction::Delete,
840 "refresh" => TuiAction::Refresh,
841 "settings" => TuiAction::Settings,
842 "planning" => TuiAction::Planning,
843 "new_board" => TuiAction::NewBoard,
844 "help" => TuiAction::Help,
845 "quit" => TuiAction::Quit,
846 "text_filter" => TuiAction::TextFilter,
847 "reorder_up" => TuiAction::ReorderUp,
848 "reorder_down" => TuiAction::ReorderDown,
849 "close" => TuiAction::Close,
850 "scroll_up" => TuiAction::ScrollUp,
851 "scroll_down" => TuiAction::ScrollDown,
852 "comment" => TuiAction::Comment,
853 "asset" => TuiAction::Asset,
854 "new_relationship" => TuiAction::NewRelationship,
855 "edit_relationship" => TuiAction::EditRelationship,
856 "remove_relationship" => TuiAction::RemoveRelationship,
857 "next_relationship" => TuiAction::NextRelationship,
858 "previous_relationship" => TuiAction::PreviousRelationship,
859 "submit" => TuiAction::Submit,
860 "cancel" => TuiAction::Cancel,
861 "next" => TuiAction::Next,
862 "previous" => TuiAction::Previous,
863 "open_picker" => TuiAction::OpenPicker,
864 "reset" => TuiAction::Reset,
865 "toggle" => TuiAction::Toggle,
866 "add_column" => TuiAction::AddColumn,
867 "new_planning_item" => TuiAction::NewPlanningItem,
868 "edit_planning_item" => TuiAction::EditPlanningItem,
869 "delete_planning_item" => TuiAction::DeletePlanningItem,
870 "confirm" => TuiAction::Confirm,
871 "create" => TuiAction::Create,
872 "previous_year" => TuiAction::PreviousYear,
873 "next_year" => TuiAction::NextYear,
874 "previous_month" => TuiAction::PreviousMonth,
875 "next_month" => TuiAction::NextMonth,
876 "reedit" => TuiAction::Reedit,
877 "ignore" => TuiAction::Ignore,
878 _ => unreachable!(),
879 };
880 set(&mut map, action, keys);
881 }
882 map
883}
884
885fn default_mode(context: InputContext) -> HashMap<TuiAction, Vec<KeyChord>> {
886 match context {
887 InputContext::Board => defaults(&[
888 ("move_up", &["up", "k"]),
889 ("move_down", &["down", "j"]),
890 ("scroll_up", &["pgup"]),
897 ("scroll_down", &["pgdn"]),
898 ("move_left", &["left", "h"]),
899 ("move_right", &["right", "l"]),
900 ("next_board", &["tab"]),
901 ("previous_board", &["shift+tab"]),
902 ("open", &["enter"]),
903 ("edit", &["e"]),
904 ("agent", &["A"]),
905 ("toggle_column", &["c"]),
906 ("filter_modal", &["f"]),
907 ("bulk_modal", &["B"]),
908 ("triage", &["t"]),
911 ("mark", &["space"]),
915 ("unmark", &["x"]),
916 ("move_previous", &["<"]),
917 ("move_next", &[">"]),
918 ("new_issue", &["n"]),
919 ("clone_issue", &["C"]),
923 ("toggle_archived", &["a"]),
925 ("delete", &["d"]),
926 ("refresh", &["r"]),
927 ("settings", &["s"]),
928 ("planning", &["m", "M"]),
929 ("new_board", &["b"]),
930 ("help", &["?"]),
931 ("quit", &["q", "Q", "esc"]),
932 ("text_filter", &["/"]),
933 ("reorder_up", &["ctrl+up"]),
934 ("reorder_down", &["ctrl+down"]),
935 ]),
936 InputContext::Rollover => defaults(&[
937 ("submit", &["enter"]),
938 ("previous", &["left", "right", "tab"]),
939 ("cancel", &["esc"]),
940 ]),
941 InputContext::Detail => defaults(&[
942 ("close", &["q", "Q", "esc"]),
943 ("edit", &["e"]),
944 ("scroll_up", &["pgup"]),
945 ("scroll_down", &["pgdn"]),
946 ("help", &["?"]),
947 ("comment", &["c"]),
948 ("asset", &["a"]),
949 ("new_relationship", &["n"]),
953 ("edit_relationship", &["E"]),
954 ("remove_relationship", &["d"]),
955 ("next_relationship", &["j", "down"]),
956 ("previous_relationship", &["k", "up"]),
957 ]),
958 InputContext::Form | InputContext::PlanningForm => defaults(&[
959 ("cancel", &["esc"]),
960 ("submit", &["enter"]),
961 ("next", &["tab", "down"]),
962 ("previous", &["shift+tab", "up"]),
963 ("open_picker", &["f2"]),
964 ]),
965 InputContext::FormPicker => defaults(&[
966 ("cancel", &["esc", "f2"]),
967 ("next", &["up"]),
968 ("previous", &["down"]),
969 ("submit", &["enter"]),
970 ("create", &["+", "insert"]),
971 ]),
972 InputContext::ConfirmDelete => defaults(&[("confirm", &["y", "Y"]), ("cancel", &["esc"])]),
973 InputContext::ConfirmBlockedMove => defaults(&[
976 ("confirm", &["y", "Y", "enter"]),
977 ("cancel", &["n", "N", "esc"]),
978 ]),
979 InputContext::MoveIntoDone
1008 | InputContext::Comment
1009 | InputContext::Asset
1010 | InputContext::Filter => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
1011 InputContext::Help => defaults(&[
1016 ("cancel", &["esc", "q", "?"]),
1017 ("next", &["down", "j"]),
1018 ("previous", &["up", "k"]),
1019 ("submit", &["enter"]),
1020 ("text_filter", &["/"]),
1024 ("scroll_up", &["pgup"]),
1029 ("scroll_down", &["pgdn"]),
1030 ]),
1031 InputContext::ContextMenu => defaults(&[
1032 ("cancel", &["esc", "q", "?"]),
1033 ("next", &["down", "j"]),
1034 ("previous", &["up", "k"]),
1035 ("submit", &["enter"]),
1036 ]),
1037 InputContext::HelpSearch => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
1042 InputContext::BulkModal => defaults(&[
1043 ("cancel", &["esc"]),
1044 ("next", &["tab", "down"]),
1045 ("previous", &["shift+tab", "up"]),
1046 ("submit", &["enter"]),
1047 ]),
1048 InputContext::Triage => defaults(&[
1054 ("cancel", &["esc"]),
1055 ("submit", &["enter"]),
1056 ("move_up", &["up"]),
1057 ("move_down", &["down"]),
1058 ("toggle", &["space"]),
1059 ]),
1060 InputContext::FilterModal => defaults(&[
1061 ("cancel", &["esc"]),
1062 ("next", &["tab", "down"]),
1063 ("previous", &["shift+tab", "up"]),
1064 ("reset", &["r", "R"]),
1065 ("submit", &["enter"]),
1066 ("open_picker", &["f2"]),
1067 ]),
1068 InputContext::Settings => defaults(&[
1069 ("close", &["q", "Q", "esc"]),
1070 ("next", &["tab"]),
1071 ("previous", &["shift+tab"]),
1072 ("move_up", &["up", "k"]),
1073 ("move_down", &["down", "j"]),
1074 ("toggle", &["space", "enter"]),
1075 ("add_column", &["a"]),
1076 ("new_board", &["n"]),
1077 ("delete", &["x", "X"]),
1078 ]),
1079 InputContext::SettingsPrompt => defaults(&[
1080 ("cancel", &["esc", "q", "Q"]),
1081 ("submit", &["enter", "y", "Y"]),
1082 ]),
1083 InputContext::PlanningManagement => defaults(&[
1084 ("close", &["q", "Q", "esc"]),
1085 ("move_up", &["up", "k"]),
1086 ("move_down", &["down", "j"]),
1087 ("next", &["tab", "shift+tab"]),
1088 ("new_planning_item", &["n"]),
1089 ("edit_planning_item", &["e"]),
1090 ("delete_planning_item", &["x", "X"]),
1091 ]),
1092 InputContext::PlanningConfirm => {
1093 defaults(&[("confirm", &["y", "Y", "enter"]), ("cancel", &["esc"])])
1094 }
1095 InputContext::DatePicker => defaults(&[
1096 ("cancel", &["esc", "q"]),
1097 ("submit", &["enter"]),
1098 ("move_up", &["up", "k"]),
1099 ("move_down", &["down", "j"]),
1100 ("move_left", &["left", "h"]),
1101 ("move_right", &["right", "l"]),
1102 ("previous_month", &["pgup"]),
1103 ("next_month", &["pgdn"]),
1104 ("previous_year", &["["]),
1105 ("next_year", &["]"]),
1106 ("reset", &["delete", "c", "C"]),
1107 ]),
1108 InputContext::EditWarning => defaults(&[
1109 ("reedit", &["r", "R"]),
1110 ("ignore", &["i", "I", "enter", "esc"]),
1111 ]),
1112 InputContext::LinkPrompt => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
1117 }
1118}
1119
1120macro_rules! overlay_fields {
1121 ($map:ident, $overrides:ident, $context:expr, $($field:ident => $action:ident),+ $(,)?) => {
1122 $(
1123 if let Some(values) = &$overrides.$field {
1124 let mut chords = Vec::with_capacity(values.len());
1125 for value in values {
1126 chords.push(KeyChord::parse(value).map_err(|message| TixError::Config(format!(
1127 "`keybindings.{}.{}`: {message}", $context.as_str(), stringify!($field)
1128 )))?);
1129 }
1130 if chords.is_empty() {
1131 return Err(TixError::Config(format!(
1132 "`keybindings.{}.{}` must not be empty", $context.as_str(), stringify!($field)
1133 )));
1134 }
1135 $map.insert(TuiAction::$action, chords);
1136 }
1137 )+
1138 };
1139}
1140
1141fn overlay(
1142 map: &mut HashMap<TuiAction, Vec<KeyChord>>,
1143 overrides: &ActionBindings,
1144 context: InputContext,
1145) -> Result<()> {
1146 overlay_fields!(map, overrides, context,
1147 move_up => MoveUp, move_down => MoveDown, move_left => MoveLeft, move_right => MoveRight,
1148 next_board => NextBoard, previous_board => PreviousBoard, open => Open, edit => Edit,
1149 agent => Agent, toggle_column => ToggleColumn, filter_modal => FilterModal,
1150 bulk_modal => BulkModal, mark => Mark, unmark => Unmark, triage => Triage,
1151 move_previous => MovePrevious, move_next => MoveNext, new_issue => NewIssue,
1152 clone_issue => CloneIssue,
1153 toggle_archived => ToggleArchived,
1154 delete => Delete, refresh => Refresh, settings => Settings, planning => Planning,
1155 new_board => NewBoard, help => Help, quit => Quit, text_filter => TextFilter, reorder_up => ReorderUp,
1156 reorder_down => ReorderDown, close => Close, scroll_up => ScrollUp, scroll_down => ScrollDown,
1157 comment => Comment, asset => Asset, submit => Submit, cancel => Cancel, next => Next,
1158 previous => Previous, open_picker => OpenPicker, reset => Reset, toggle => Toggle,
1159 add_column => AddColumn, new_planning_item => NewPlanningItem,
1160 edit_planning_item => EditPlanningItem, delete_planning_item => DeletePlanningItem,
1161 confirm => Confirm, create => Create, previous_year => PreviousYear, next_year => NextYear,
1162 previous_month => PreviousMonth, next_month => NextMonth, reedit => Reedit, ignore => Ignore,
1163 );
1164 let mut seen: HashMap<KeyChord, TuiAction> = HashMap::new();
1165 for (action, chords) in map {
1166 for key in chords {
1167 if let Some(previous) = seen.insert(*key, *action) {
1168 return Err(TixError::Config(format!(
1169 "`keybindings.{}`: `{}` is assigned to both `{}` and `{}`",
1170 context.as_str(),
1171 key,
1172 previous.as_str(),
1173 action.as_str()
1174 )));
1175 }
1176 }
1177 }
1178 Ok(())
1179}
1180
1181impl ActionBindings {
1182 pub fn is_empty(&self) -> bool {
1183 self == &Self::default()
1184 }
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189 use super::*;
1190
1191 #[test]
1192 fn parses_and_normalizes_documented_chords() {
1193 assert_eq!(KeyChord::parse("ESC").unwrap(), chord("esc"));
1194 assert_eq!(KeyChord::parse("Enter").unwrap(), chord("enter"));
1195 assert_eq!(KeyChord::parse("LEFT").unwrap(), chord("left"));
1196 assert_eq!(KeyChord::parse("PgUp").unwrap(), chord("pgup"));
1197 assert_eq!(KeyChord::parse("F2").unwrap(), chord("f2"));
1198 assert_eq!(KeyChord::parse("Ctrl+UP").unwrap(), chord("ctrl+up"));
1199 assert_eq!(KeyChord::parse("A").unwrap().display(), "Shift+A");
1200 assert!(KeyChord::parse("ctrl+wat").is_err());
1201 assert!(KeyChord::parse("f99").is_err());
1202 }
1203
1204 #[test]
1205 fn defaults_validate_and_cross_mode_reuse_is_allowed() {
1206 let defaults = KeyBindings::resolve(&KeyBindings::default()).unwrap();
1207 assert!(
1208 defaults
1209 .action(
1210 InputContext::Board,
1211 KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)
1212 )
1213 .is_some()
1214 );
1215 let mut overrides = KeyBindings::default();
1216 overrides.board.open = Some(vec!["f4".into()]);
1217 overrides.detail.close = Some(vec!["f4".into()]);
1218 assert!(KeyBindings::resolve(&overrides).is_ok());
1219 }
1220
1221 #[test]
1222 fn conflicts_report_mode_and_actions() {
1223 let mut overrides = KeyBindings::default();
1224 overrides.board.open = Some(vec!["ctrl+n".into()]);
1225 overrides.board.new_issue = Some(vec!["ctrl+n".into()]);
1226 let error = KeyBindings::resolve(&overrides).unwrap_err().to_string();
1227 assert!(error.contains("board") && error.contains("open") && error.contains("new_issue"));
1228 }
1229}