Skip to main content

tix/
keybindings.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::path::Path;
4
5use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
6use serde::de::Error as _;
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
9use crate::error::{Result, TixError};
10
11mod defaults;
12#[allow(clippy::wildcard_imports)]
13use defaults::*;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum InputContext {
17    Board,
18    /// The batch modal (WP07). Its own context so a key means one thing there and
19    /// is not borrowed from the board's — `space` marks a card in a batch and is not
20    /// a board action, and `y` confirms in a batch and is not a board action either.
21    BulkModal,
22    /// The triage inbox and its decision modal (WP06, T029/T031).
23    ///
24    /// Its own context for the reason `BulkModal`'s doc gives:
25    /// `a`/`d`/`u`/`f` decide here and would be board actions there, and the
26    /// inbox is keyboard-driven all the way down. It carries **free text** (a
27    /// decline reason, a duplicate target), so `cancel` is `esc` and *only*
28    /// `esc` — `q` stays a typeable letter for the same reason `LinkPrompt`
29    /// keeps it.
30    Triage,
31    /// The F3 Cycle mode (tui-cycle-mode).
32    ///
33    /// Its own context for the reason `Triage`'s doc gives: `m` opens planning
34    /// management here and would be a board action there, `j`/`k` walk the cycle
35    /// rows, and the mode carries no free text, so `q`/`esc` may close it.
36    Cycle,
37    /// The F5 Milestones mode.
38    ///
39    /// Its own context for the reason `Cycle`'s doc gives: `m` opens planning
40    /// management here, `j`/`k` walk the rows, and it carries no free text, so
41    /// `q`/`esc` may close it.
42    Milestones,
43    /// The F6 Repos mode (tix #1529).
44    ///
45    /// Its own context for the reason `Cycle`'s doc gives: `j`/`k` walk the
46    /// repository rows, `Enter` runs tix in the selected one, and it carries no
47    /// free text, so `q`/`esc` may close it.
48    Repos,
49    /// The `F7` Stats page (mission `project-issue-health-status-01M3C8HX`, WP06;
50    /// the **Time** tab added by `per-issue-time-tracking-01M3C9A4`, WP05).
51    ///
52    /// Its own context for the reason `Repos`'s doc gives: `Tab`/`Shift+Tab` switch
53    /// between the page's two tabs, `j`/`k` walk the active tab's rows, `Enter` opens
54    /// the selected row's issue, and the Time tab's `a`/`e`/`x`/`f`/`s`/`h` act on its
55    /// entries and its scope. It carries no free text, so `q`/`esc` may close it. The
56    /// mutations are the Time tab's forms, which are routed through the shared time
57    /// service.
58    Stats,
59    /// The F8 Schedule page (tix #1593, mission `recurring-issues-01M3C7G7`).
60    ///
61    /// Its own context for the reason `Cycle`'s doc gives: `j`/`k` walk the series
62    /// rows, `g`/`p`/`r`/`x`/`s`/`d`/`i` act on the selected series, and it carries
63    /// no free text, so `q`/`esc` may close it.
64    Schedule,
65    /// The F11 Library page (tix #1593).
66    ///
67    /// Its own context for the reason `Cycle`'s doc gives: `Tab` switches between
68    /// its two registries rather than moving a board field, `Enter` applies the
69    /// selected view, and `j`/`k` walk rows. It carries no free text, so
70    /// `q`/`esc` may close it.
71    Library,
72    /// The F10 **Portfolio** page (WP06, tix #1593).
73    ///
74    /// Its own context for the reason `Cycle`'s doc gives: `Tab`/`Shift+Tab` switch
75    /// between the Projects and Epics registries, `j`/`k` walk the active tab's rows,
76    /// `h`/`l` walk the selected epic's members on the Epics tab, `Enter` opens the
77    /// selected member issue's detail directly (FR-004), and `q`/`esc` may close it
78    /// because it carries no free text.
79    Portfolio,
80    Rollover,
81    Detail,
82    Form,
83    FormPicker,
84    ConfirmDelete,
85    MoveIntoDone,
86    /// The blocked-move acknowledgement prompt (WP04). Its own context so the
87    /// command bar can advertise `y`/`esc` and nothing else.
88    ConfirmBlockedMove,
89    Help,
90    /// The keybindings overlay's `/` input line (tix #1496).
91    ///
92    /// Its own context for the reason [`InputContext::Filter`] is one: it reads **free
93    /// text**. Keeping the list mode's `q`/`?` bound to cancel here would rewrite them
94    /// to `esc` before the handler saw them, and a query could never contain a `q` —
95    /// the same trap the free-text modes below document.
96    HelpSearch,
97    ContextMenu,
98    /// The detail pane's `related_issues` picker (#1513).
99    ///
100    /// Its own context so the picker's `Enter` selects a related entry and does
101    /// not borrow any detail-pane key. It carries no free text, so `cancel` is
102    /// `Esc`; an unbound letter is a no-op rather than a filter character.
103    RelatedPicker,
104    Filter,
105    FilterModal,
106    Settings,
107    SettingsPrompt,
108    PlanningManagement,
109    PlanningConfirm,
110    PlanningForm,
111    DatePicker,
112    Comment,
113    Asset,
114    /// The editor round trip's repair prompt (tix #1506).
115    ///
116    /// Its own context: every key here is a decision about a file the editor just
117    /// produced — apply the proposed fix, declare the key, reopen the editor on
118    /// the retained copy, acknowledge and keep it, or discard it behind a second
119    /// confirmation. It is the successor to #1506's `edit_warning` context, which
120    /// the same prompt grew out of; there is deliberately only one such prompt.
121    EditRepair,
122    /// The edit-conflict prompt (tix #1510).
123    ///
124    /// Its own context for the reason [`InputContext::EditRepair`] has one: it is
125    /// a modal decision with three distinct outcomes, and one of them — *compare* —
126    /// reopens the editor, which no board key does. It carries no free text, so a
127    /// letter could close it — but only `Esc` (bound to `ignore`) does, because the
128    /// prompt closes through a decision and nothing else. `q` stays unbound.
129    EditConflict,
130    /// The two-step create/edit relationship prompt (#1359).
131    ///
132    /// Free text, so `cancel` is `Esc` and **only** `Esc` — see the note on the
133    /// `Asset`/`Comment`/`Filter` group for why binding `q` here would be a real
134    /// defect rather than a preference: a title fragment containing `q` is ordinary.
135    LinkPrompt,
136    /// The top-level mode shell (tui-mode-navigation).
137    ///
138    /// It binds only the five mode switches and the navigation toggle. It is
139    /// consulted **after** the active top-level context (Board, Triage,
140    /// PlanningManagement, Settings, Form) declines the pressed key, so a
141    /// context that binds the key locally always wins — which is what keeps
142    /// `F2` as `open_picker` inside a form and the `F(12)` sentinel untouched
143    /// for a truly unbound key.
144    Shell,
145    /// The saved-views modal (WP05, T024–T027).
146    ///
147    /// Its own context for the reason `Settings` has one: `n`, `e`, `u`, `d`, and
148    /// `x` mean "new view", "rename", "update from live", "duplicate", and
149    /// "delete view" in this modal and something else on the board. Nothing in the
150    /// list is typeable, so ordinary letters are free to be commands.
151    Views,
152    /// The saved-views modal's inline name prompt (WP05, T027).
153    ///
154    /// A separate context because it carries **free text**: a view named `dense`
155    /// must be a name and not a "duplicate" command, and a name containing `q`
156    /// must not close the prompt. Only `submit` and `cancel` are bound, which is
157    /// what leaves every other key a letter — the same arrangement, for the same
158    /// reason, as `SettingsPrompt`.
159    ViewsPrompt,
160}
161
162impl InputContext {
163    pub const ALL: &'static [Self] = &[
164        Self::Board,
165        Self::Rollover,
166        Self::Detail,
167        Self::Form,
168        Self::FormPicker,
169        Self::ConfirmDelete,
170        Self::MoveIntoDone,
171        Self::ConfirmBlockedMove,
172        Self::Help,
173        Self::HelpSearch,
174        Self::ContextMenu,
175        // Walked like every other context so the overlay's completeness guard
176        // sees the related picker's keys too (#1513).
177        Self::RelatedPicker,
178        Self::Filter,
179        Self::FilterModal,
180        Self::BulkModal,
181        Self::Triage,
182        Self::Cycle,
183        Self::Milestones,
184        // Walked like every other context so the overlay's guard sees the Repos
185        // mode's keys too (tix #1529).
186        Self::Repos,
187        // Walked like every other context so the guard sees the Stats page's keys
188        // too (mission `project-issue-health-status-01M3C8HX`).
189        Self::Stats,
190        // Walked like every other context so the guard sees the Schedule page's
191        // keys too (tix #1593).
192        Self::Schedule,
193        // Walked like every other context so the overlay's guard sees the
194        // Library page's keys too (tix #1593).
195        Self::Library,
196        // Walked like every other context so the guard sees the Portfolio page's
197        // keys too (WP06). A context the guard does not walk is a context whose
198        // keys can go undocumented.
199        Self::Portfolio,
200        Self::Settings,
201        Self::SettingsPrompt,
202        Self::PlanningManagement,
203        Self::PlanningConfirm,
204        Self::PlanningForm,
205        Self::DatePicker,
206        Self::Comment,
207        Self::Asset,
208        Self::EditRepair,
209        // Walked like every other context so the overlay's completeness guard
210        // sees the conflict prompt's keys too (tix #1510).
211        Self::EditConflict,
212        // Free text like the others, and missing from this list until the overlay's
213        // completeness guard needed it: a context the guard does not walk is a context
214        // whose keys can go undocumented.
215        Self::LinkPrompt,
216        // The mode shell's switches, walked like every other context so the
217        // completeness guard can see them.
218        Self::Shell,
219        Self::Views,
220        Self::ViewsPrompt,
221    ];
222
223    pub const fn as_str(self) -> &'static str {
224        match self {
225            Self::Board => "board",
226            Self::Rollover => "rollover",
227            Self::Detail => "detail",
228            Self::Form => "form",
229            Self::FormPicker => "form_picker",
230            Self::ConfirmDelete => "confirm_delete",
231            Self::ConfirmBlockedMove => "confirm_blocked_move",
232            Self::MoveIntoDone => "move_into_done",
233            Self::Help => "help",
234            Self::HelpSearch => "help_search",
235            Self::ContextMenu => "context_menu",
236            Self::RelatedPicker => "related_picker",
237            Self::Filter => "filter",
238            Self::FilterModal => "filter_modal",
239            Self::BulkModal => "bulk_modal",
240            Self::Settings => "settings",
241            Self::SettingsPrompt => "settings_prompt",
242            Self::PlanningManagement => "planning_management",
243            Self::PlanningConfirm => "planning_confirm",
244            Self::PlanningForm => "planning_form",
245            Self::DatePicker => "date_picker",
246            Self::Comment => "comment",
247            Self::Asset => "asset",
248            Self::EditRepair => "edit_repair",
249            Self::EditConflict => "edit_conflict",
250            Self::LinkPrompt => "link_prompt",
251            Self::Views => "views",
252            Self::ViewsPrompt => "views_prompt",
253            Self::Triage => "triage",
254            Self::Cycle => "cycle",
255            Self::Milestones => "milestones",
256            Self::Repos => "repos",
257            Self::Stats => "stats",
258            Self::Schedule => "schedule",
259            Self::Library => "library",
260            Self::Portfolio => "portfolio",
261            Self::Shell => "shell",
262        }
263    }
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
267#[serde(rename_all = "snake_case")]
268pub enum TuiAction {
269    MoveUp,
270    MoveDown,
271    MoveLeft,
272    MoveRight,
273    NextBoard,
274    PreviousBoard,
275    Open,
276    Edit,
277    Agent,
278    /// Open the selected issue in a read-only viewer (tix #1507).
279    ///
280    /// `v` was free on both the board and the detail pane. Read-only by
281    /// construction: the viewer runs on the issue file and never writes back.
282    View,
283    ToggleColumn,
284    FilterModal,
285    BulkModal,
286    /// Open the triage inbox (WP06, T029). Its own action rather than a chord
287    /// bolted onto another one, so `t` can be rebound from `keybindings.board`
288    /// like every other board key and the help overlay can name it.
289    Triage,
290    /// Open the mentions view (OpenSpec change `issue-mentions`): every pending
291    /// `@mention` the local user holds, across issues.
292    ///
293    /// Its own action rather than a chord bolted onto another one, for the reason
294    /// `Triage`'s is: the chord is a rebindable board key, the help overlay can name
295    /// it, and the bottom indicator row reads it back out of the effective bindings
296    /// so the row cannot advertise a key the user has rebound away.
297    ///
298    /// The default is `i`, which was free on the board — `t` is the triage inbox, `v`
299    /// the saved views, `f` the filter, and the board's letters are its navigation.
300    OpenMentions,
301    /// Open the saved-views modal (WP05, T024). Its own action for the reason
302    /// `Triage`'s is: `v` is a rebindable board key and the help overlay names it.
303    Views,
304    /// Apply the view under the cursor (WP05, T025).
305    ApplyView,
306    /// Create a view from the live plan (WP05, T027).
307    NewView,
308    /// Rename the view under the cursor (WP05, T027).
309    RenameView,
310    /// Replace the view under the cursor's plan with the live plan (WP05, T027).
311    UpdateView,
312    /// Duplicate the view under the cursor under a fresh stable id (WP05, T027).
313    DuplicateView,
314    /// Delete the view under the cursor's **definition**, after confirmation
315    /// (WP05, T027).
316    DeleteView,
317    Mark,
318    Unmark,
319    /// Empty the batch selection from the board (`X`).
320    ///
321    /// Its own action rather than an overload of `Unmark`: the modal's `X` empties
322    /// the whole set while `x` removes one card, and the board now reads the same
323    /// two keys. Canonicalizing both to one action would collapse the distinction.
324    ClearMarks,
325    MovePrevious,
326    MoveNext,
327    NewIssue,
328    /// Duplicate the selected issue into the create-issue form, prefilled from it
329    /// (tix #1484).
330    ///
331    /// `C`, the capital of `c` (toggle column): a pair that reads together without
332    /// changing what any existing key means, which is the same rule `B`/`A` follow.
333    CloneIssue,
334    /// Show the archived issues on the board, or hide them again (tix #1492).
335    ///
336    /// `a` was unbound on the board. The toggle is a *reload*, not a display
337    /// filter: the archived files are not read until it is on.
338    ToggleArchived,
339    /// Show each issue's sub-issues beneath it on the board, or hide them again
340    /// (tix #1399).
341    ///
342    /// `u` was unbound on the board. Unlike the archive toggle this is display-only:
343    /// the children are already in the board state, so flipping it **reorders** the
344    /// column (one level deep) rather than re-reading the store.
345    ToggleChildren,
346    Delete,
347    Refresh,
348    Settings,
349    Planning,
350    NewBoard,
351    Help,
352    Quit,
353    TextFilter,
354    ReorderUp,
355    ReorderDown,
356    Close,
357    ScrollUp,
358    ScrollDown,
359    /// Scroll the detail pane's body one row. `j`/`Down` and `k`/`Up` were freed
360    /// for this when the relationship cursor moved to `Tab`/`BackTab`.
361    ScrollLineDown,
362    ScrollLineUp,
363    /// Scroll the detail pane's body to the top (`g`) or bottom (`G`).
364    ScrollTop,
365    ScrollBottom,
366    Comment,
367    Asset,
368    /// Open the create-relationship prompt from the detail view (#1359).
369    NewRelationship,
370    /// Re-kind or retarget the relationship under the detail pane's cursor.
371    EditRelationship,
372    /// Remove the relationship under the cursor, after confirmation.
373    RemoveRelationship,
374    /// Set, change or clear the open issue's parent from the detail pane
375    /// (tix #1384).
376    ///
377    /// `P` was free in the detail pane. The prompt routes its write through the
378    /// shared hierarchy service (`Store::set_parent`/`clear_parent`), which is
379    /// the same seam `tix edit --parent` uses, so the TUI gains the
380    /// set/change/clear parity for `parent` that the create-only form could not
381    /// express.
382    EditParent,
383    /// Move the detail pane's relationship cursor (`Tab`/`BackTab`).
384    NextRelationship,
385    PreviousRelationship,
386    /// Open the picker listing the detail pane's `related_issues` entries
387    /// (tix #1513).
388    ///
389    /// `r` was free in the detail pane — the board's `r` is `refresh`, a
390    /// different context, so the two never collide.
391    RelatedPicker,
392    /// Follow the detail pane's selected `related_issues` entry (tix #1513).
393    ///
394    /// `o` was free in the detail pane and reads as "open".
395    FollowRelated,
396    /// Pop the detail pane's navigation stack and restore the previous issue
397    /// (tix #1513).
398    ///
399    /// `Backspace` was free in the detail pane and reads as "back".
400    NavBack,
401    Submit,
402    Cancel,
403    Next,
404    Previous,
405    OpenPicker,
406    Reset,
407    Toggle,
408    AddColumn,
409    /// Move the Config mode's selected board column one position earlier
410    /// (`shift+k` by default, tui-config-mode).
411    MoveColumnUp,
412    /// Move the Config mode's selected board column one position later
413    /// (`shift+j` by default, tui-config-mode).
414    MoveColumnDown,
415    NewPlanningItem,
416    EditPlanningItem,
417    DeletePlanningItem,
418    Confirm,
419    Create,
420    PreviousYear,
421    NextYear,
422    PreviousMonth,
423    NextMonth,
424    Reedit,
425    Ignore,
426    /// Declare the repair prompt's undeclared frontmatter key in `tix.toml`
427    /// (tix #1506).
428    ///
429    /// Its own action, not `confirm`: `confirm` applies whatever fix the prompt
430    /// proposes, which *happens* to be the declaration for an unknown key, while
431    /// `declare_key` always means "write it into `[custom_attributes]`" and says
432    /// so when there is no key to declare.
433    DeclareKey,
434    /// Discard the repair prompt's retained copy, after a second confirmation
435    /// (tix #1506).
436    ///
437    /// The **only** action that removes the editor's copy. The first press arms
438    /// the prompt; a second press is the decision. `Esc` deliberately
439    /// acknowledges and keeps the copy instead, so a stray key cannot destroy a
440    /// user's edit.
441    Discard,
442    /// Switch the top-level shell to the Board mode (F1 by default).
443    SwitchToBoard,
444    /// Switch the top-level shell to the Triage mode (F2 by default).
445    SwitchToTriage,
446    /// Switch the top-level shell to the Cycle mode (F4 by default).
447    SwitchToCycle,
448    /// Switch the top-level shell to the Milestones mode (F5 by default).
449    SwitchToMilestones,
450    /// Switch the top-level shell to the Repos mode (F6 by default, tix #1529).
451    ///
452    /// F6 was free in the shell and sits directly after the milestone switch, so
453    /// the repository list reads as the last of the managed registries.
454    SwitchToRepos,
455    /// Switch the top-level shell to the Stats page (F7 by default).
456    ///
457    /// F7 was reserved in the shell until this page existed; the Stats page takes it,
458    /// and its first tab is Health (mission `project-issue-health-status-01M3C8HX`).
459    SwitchToStats,
460    /// Open the health form for the issue in the detail pane.
461    ///
462    /// One action, two layers: the form's own field chooses whether the value is the
463    /// issue's `current` state or an `override` layered over its derived one, so a
464    /// user does not have to learn two key paths that do the same thing with one
465    /// field different.
466    HealthForm,
467    /// Open the add-entry form on the `F7` Stats page's **Time** tab (mission
468    /// `per-issue-time-tracking-01M3C9A4`, WP05 T025).
469    ///
470    /// One of six keys the Time tab contributes to [`InputContext::Stats`]. The key
471    /// opens a form and writes nothing; the form's `Enter` is the only path that
472    /// reaches the shared time service, so a stray key cannot record an entry.
473    TimeAdd,
474    /// Open the correction form for the Time tab's selected entry (WP05 T025).
475    TimeEdit,
476    /// Open the deletion form for the Time tab's selected entry (WP05 T025).
477    ///
478    /// A deletion removes the entry from the issue's current projection; the form asks
479    /// for a reason and a confirmation, and History keeps the prior value (C-003,
480    /// C-004).
481    TimeDelete,
482    /// Edit the Time tab's scope: issue, assignee, period (WP05 T026).
483    ///
484    /// It edits the **shared** `TimeFilter` the CLI's `--issue/--assignee/--from/--to`
485    /// build, so a filter has one grammar and one validator.
486    TimeFilter,
487    /// Cycle the Time tab's breakdown dimension: all, issue, assignee, date
488    /// (WP05 T026).
489    TimeBreakdown,
490    /// Toggle the Time tab's audit view (WP05 T027): what the current projection no
491    /// longer holds but History still attributes.
492    TimeHistory,
493    /// Switch to the F8 Schedule page (tix #1593).
494    SwitchToSchedule,
495    /// Generate the selected series' due occurrence (tix #1593).
496    SeriesGenerate,
497    /// Pause the selected series.
498    SeriesPause,
499    /// Resume the selected series.
500    SeriesResume,
501    /// End the selected series.
502    SeriesEnd,
503    /// Record the selected series' due date as skipped.
504    SeriesSkip,
505    /// Move the selected series' candidate past its due date.
506    SeriesDefer,
507    /// Stop offering the selected series' due date.
508    SeriesIgnore,
509    /// Define a new recurring series (tix #1593).
510    ///
511    /// The Series tab's `n`: it opens the page's create form, whose save reaches the
512    /// same `tix recurring create` service the CLI runs, with the same rule and anchor
513    /// parsing and the same validation order.
514    SeriesNew,
515    /// Switch to the F11 Library page (tix #1593).
516    SwitchToLibrary,
517    /// Switch to the F10 **Portfolio** page (WP06).
518    ///
519    /// F10 was reserved in the shell until this page existed; the Portfolio page's
520    /// Projects tab takes it.
521    SwitchToPortfolio,
522    /// Switch the top-level shell to the New Issue mode (F1 by default).
523    SwitchToNewIssue,
524    /// Switch the top-level shell to the Config mode (F12 by default).
525    SwitchToConfig,
526    /// Cycle the vertical navigation display state `Off`/`Small`/`Default`.
527    ToggleNavigation,
528}
529
530impl TuiAction {
531    pub const fn as_str(self) -> &'static str {
532        match self {
533            Self::MoveUp => "move_up",
534            Self::MoveDown => "move_down",
535            Self::MoveLeft => "move_left",
536            Self::MoveRight => "move_right",
537            Self::NextBoard => "next_board",
538            Self::PreviousBoard => "previous_board",
539            Self::Open => "open",
540            Self::Edit => "edit",
541            Self::Agent => "agent",
542            Self::View => "view",
543            Self::ToggleColumn => "toggle_column",
544            Self::OpenMentions => "open_mentions",
545            Self::FilterModal => "filter_modal",
546            Self::BulkModal => "bulk_modal",
547            Self::Triage => "triage",
548            Self::Views => "views",
549            Self::ApplyView => "apply_view",
550            Self::NewView => "new_view",
551            Self::RenameView => "rename_view",
552            Self::UpdateView => "update_view",
553            Self::DuplicateView => "duplicate_view",
554            Self::DeleteView => "delete_view",
555            Self::Mark => "mark",
556            Self::Unmark => "unmark",
557            Self::ClearMarks => "clear_marks",
558            Self::MovePrevious => "move_previous",
559            Self::MoveNext => "move_next",
560            Self::NewIssue => "new_issue",
561            Self::CloneIssue => "clone_issue",
562            Self::ToggleArchived => "toggle_archived",
563            Self::ToggleChildren => "toggle_children",
564            Self::Delete => "delete",
565            Self::Refresh => "refresh",
566            Self::Settings => "settings",
567            Self::Planning => "planning",
568            Self::NewBoard => "new_board",
569            Self::Help => "help",
570            Self::Quit => "quit",
571            Self::TextFilter => "text_filter",
572            Self::ReorderUp => "reorder_up",
573            Self::ReorderDown => "reorder_down",
574            Self::Close => "close",
575            Self::ScrollUp => "scroll_up",
576            Self::ScrollDown => "scroll_down",
577            Self::ScrollLineDown => "scroll_line_down",
578            Self::ScrollLineUp => "scroll_line_up",
579            Self::ScrollTop => "scroll_top",
580            Self::ScrollBottom => "scroll_bottom",
581            Self::Comment => "comment",
582            Self::Asset => "asset",
583            Self::NewRelationship => "new_relationship",
584            Self::EditRelationship => "edit_relationship",
585            Self::RemoveRelationship => "remove_relationship",
586            Self::EditParent => "edit_parent",
587            Self::NextRelationship => "next_relationship",
588            Self::PreviousRelationship => "previous_relationship",
589            Self::RelatedPicker => "related_picker",
590            Self::FollowRelated => "follow_related",
591            Self::NavBack => "nav_back",
592            Self::Submit => "submit",
593            Self::Cancel => "cancel",
594            Self::Next => "next",
595            Self::Previous => "previous",
596            Self::OpenPicker => "open_picker",
597            Self::Reset => "reset",
598            Self::Toggle => "toggle",
599            Self::AddColumn => "add_column",
600            Self::MoveColumnUp => "move_column_up",
601            Self::MoveColumnDown => "move_column_down",
602            Self::NewPlanningItem => "new_planning_item",
603            Self::EditPlanningItem => "edit_planning_item",
604            Self::DeletePlanningItem => "delete_planning_item",
605            Self::Confirm => "confirm",
606            Self::Create => "create",
607            Self::PreviousYear => "previous_year",
608            Self::NextYear => "next_year",
609            Self::PreviousMonth => "previous_month",
610            Self::NextMonth => "next_month",
611            Self::Reedit => "reedit",
612            Self::Ignore => "ignore",
613            Self::DeclareKey => "declare_key",
614            Self::Discard => "discard",
615            Self::SwitchToBoard => "switch_to_board",
616            Self::SwitchToTriage => "switch_to_triage",
617            Self::SwitchToCycle => "switch_to_cycle",
618            Self::SwitchToMilestones => "switch_to_milestones",
619            Self::SwitchToRepos => "switch_to_repos",
620            Self::SwitchToStats => "switch_to_stats",
621            Self::HealthForm => "health_form",
622            Self::TimeAdd => "time_add",
623            Self::TimeEdit => "time_edit",
624            Self::TimeDelete => "time_delete",
625            Self::TimeFilter => "time_filter",
626            Self::TimeBreakdown => "time_breakdown",
627            Self::TimeHistory => "time_history",
628            Self::SwitchToSchedule => "switch_to_schedule",
629            Self::SeriesGenerate => "series_generate",
630            Self::SeriesPause => "series_pause",
631            Self::SeriesResume => "series_resume",
632            Self::SeriesEnd => "series_end",
633            Self::SeriesSkip => "series_skip",
634            Self::SeriesDefer => "series_defer",
635            Self::SeriesIgnore => "series_ignore",
636            Self::SeriesNew => "series_new",
637            Self::SwitchToLibrary => "switch_to_library",
638            Self::SwitchToPortfolio => "switch_to_portfolio",
639            Self::SwitchToNewIssue => "switch_to_new_issue",
640            Self::SwitchToConfig => "switch_to_config",
641            Self::ToggleNavigation => "toggle_navigation",
642        }
643    }
644
645    pub const fn description(self) -> &'static str {
646        match self {
647            Self::MoveUp => "move up",
648            Self::MoveDown => "move down",
649            Self::MoveLeft => "previous column",
650            Self::MoveRight => "next column",
651            Self::NextBoard => "next board",
652            Self::PreviousBoard => "previous board",
653            Self::Open => "open detail",
654            Self::Edit => "edit",
655            Self::Agent => "open agent",
656            Self::View => "view issue",
657            Self::ToggleColumn => "toggle column",
658            Self::FilterModal => "filter criteria",
659            Self::BulkModal => "batch select",
660            Self::Triage => "triage inbox",
661            Self::OpenMentions => "mentions",
662            Self::Views => "saved views",
663            Self::ApplyView => "apply view",
664            Self::NewView => "new view",
665            Self::RenameView => "rename view",
666            Self::UpdateView => "update view from live",
667            Self::DuplicateView => "duplicate view",
668            Self::DeleteView => "delete view",
669            Self::Mark => "mark for batch",
670            Self::Unmark => "unmark for batch",
671            Self::ClearMarks => "clear marks",
672            Self::MovePrevious => "move to previous column",
673            Self::MoveNext => "move to next column",
674            Self::NewIssue => "new issue",
675            Self::CloneIssue => "clone issue",
676            Self::ToggleArchived => "show archived issues",
677            Self::ToggleChildren => "toggle sub-issues",
678            Self::Delete => "delete",
679            Self::Refresh => "refresh",
680            Self::Settings => "settings",
681            Self::Planning => "planning",
682            Self::NewBoard => "new board",
683            Self::Help => "help",
684            Self::Quit => "quit",
685            Self::TextFilter => "search",
686            Self::ReorderUp => "reorder up",
687            Self::ReorderDown => "reorder down",
688            Self::Close => "close",
689            Self::ScrollUp => "scroll up one page",
690            Self::ScrollDown => "scroll down one page",
691            Self::ScrollLineDown => "scroll down one line",
692            Self::ScrollLineUp => "scroll up one line",
693            Self::ScrollTop => "scroll to top",
694            Self::ScrollBottom => "scroll to bottom",
695            Self::Comment => "comment",
696            Self::Asset => "asset",
697            Self::NewRelationship => "new relationship",
698            Self::EditRelationship => "edit relationship",
699            Self::RemoveRelationship => "remove relationship",
700            Self::EditParent => "set parent",
701            Self::NextRelationship => "next relationship",
702            Self::PreviousRelationship => "previous relationship",
703            Self::RelatedPicker => "related issues",
704            Self::FollowRelated => "follow related issue",
705            Self::NavBack => "back",
706            Self::Submit => "submit",
707            Self::Cancel => "cancel",
708            Self::Next => "next",
709            Self::Previous => "previous",
710            Self::OpenPicker => "open picker",
711            Self::Reset => "reset",
712            Self::Toggle => "toggle",
713            Self::AddColumn => "add column",
714            Self::MoveColumnUp => "move column up",
715            Self::MoveColumnDown => "move column down",
716            Self::NewPlanningItem => "new",
717            Self::EditPlanningItem => "edit",
718            Self::DeletePlanningItem => "delete",
719            Self::Confirm => "confirm",
720            Self::Create => "create",
721            Self::PreviousYear => "previous year",
722            Self::NextYear => "next year",
723            Self::PreviousMonth => "previous month",
724            Self::NextMonth => "next month",
725            Self::Reedit => "re-edit",
726            Self::Ignore => "ignore",
727            Self::DeclareKey => "declare the key in tix.toml",
728            Self::Discard => "discard the edit",
729            Self::SwitchToBoard => "board mode",
730            Self::SwitchToTriage => "triage mode",
731            Self::SwitchToCycle => "cycle mode",
732            Self::SwitchToMilestones => "milestones mode",
733            Self::SwitchToRepos => "repos mode",
734            Self::SwitchToStats => "the Stats page",
735            Self::HealthForm => "record this issue's health",
736            Self::TimeAdd => "add a time entry",
737            Self::TimeEdit => "correct the selected entry",
738            Self::TimeDelete => "delete the selected entry",
739            Self::TimeFilter => "edit the time filter",
740            Self::TimeBreakdown => "cycle the breakdown",
741            Self::TimeHistory => "show the audit view",
742            Self::SwitchToSchedule => "schedule mode",
743            Self::SeriesGenerate => "generate the due occurrence",
744            Self::SeriesPause => "pause the series",
745            Self::SeriesResume => "resume the series",
746            Self::SeriesEnd => "end the series",
747            Self::SeriesSkip => "skip the due date",
748            Self::SeriesDefer => "defer the due date",
749            Self::SeriesIgnore => "ignore the due date",
750            Self::SeriesNew => "create a new series",
751            Self::SwitchToLibrary => "library mode",
752            Self::SwitchToPortfolio => "portfolio mode",
753            Self::SwitchToNewIssue => "new issue mode",
754            Self::SwitchToConfig => "config mode",
755            Self::ToggleNavigation => "toggle navigation",
756        }
757    }
758
759    /// The wording this action gets in `context`'s help.
760    ///
761    /// `move_left`/`move_right` walk a **column** on the board, the Config
762    /// mode's two **panes** (rail ⇄ body), and a **group** in the Cycle
763    /// listing; `submit` steps into the body and then activates the row there.
764    /// The action — and the key — is the same event, so only the noun the help
765    /// shows changes. Every other action keeps its single description.
766    pub const fn description_in(self, context: InputContext) -> &'static str {
767        match (context, self) {
768            (InputContext::Cycle, Self::MoveLeft) => "previous group",
769            (InputContext::Cycle, Self::MoveRight) => "next group",
770            // tix #1593: on the Library page the two directions step between its
771            // registries, not between rows.
772            (InputContext::Library, Self::Previous) => "previous tab",
773            (InputContext::Library, Self::Next) => "next tab",
774            (InputContext::Library, Self::Submit) => "apply the selected view",
775            // WP05: both of the Stats page's tabs open the selected row's issue, so the
776            // one description is honest for both — the Health tab has no other meaning
777            // for `Enter`, and the Time tab's row names an entry that belongs to one.
778            (InputContext::Stats, Self::Submit) => "open the row's issue",
779            (InputContext::Settings, Self::MoveLeft) => "previous pane",
780            (InputContext::Settings, Self::MoveRight) => "next pane",
781            (InputContext::Settings, Self::Submit) => "step into the body / activate row",
782            // The filter modal applies ad-hoc: a pick takes effect at once and
783            // closing keeps it, so both exits mean "apply and close" and the generic
784            // "cancel" would read as a discard that no longer exists.
785            (InputContext::FilterModal, Self::OpenPicker) => "open the focused row's picker",
786            (InputContext::FilterModal, Self::Submit) => "apply and close",
787            (InputContext::FilterModal, Self::Cancel) => "apply and close",
788            // #1506. Every key in the repair prompt is a decision about the file
789            // the editor just produced, and "confirm"/"ignore" say nothing about
790            // which one — the overlay has to spell out each.
791            (InputContext::EditRepair, Self::Confirm) => "apply the proposed fix",
792            (InputContext::EditRepair, Self::DeclareKey) => "declare the key in tix.toml",
793            (InputContext::EditRepair, Self::Reedit) => "reopen the editor on the retained copy",
794            (InputContext::EditRepair, Self::Ignore) => "acknowledge (keeps the copy)",
795            (InputContext::EditRepair, Self::Discard) => "discard the edit (asks again)",
796            // #1510. The conflict prompt reuses the edit warning's three actions:
797            // each is the same *kind* of decision, so what the key means here is
798            // what the wording has to spell out.
799            (InputContext::EditConflict, Self::Confirm) => "overwrite the file with this edit",
800            (InputContext::EditConflict, Self::Ignore) => "keep the on-disk version",
801            (InputContext::EditConflict, Self::Reedit) => "compare in the editor",
802            _ => self.description(),
803        }
804    }
805}
806
807pub type Binding = Vec<String>;
808
809macro_rules! partial_bindings {
810    ($($field:ident),+ $(,)?) => {
811        #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
812        #[serde(default, deny_unknown_fields)]
813        pub struct ActionBindings {
814            $(
815                #[serde(skip_serializing_if = "Option::is_none")]
816                pub $field: Option<Binding>,
817            )+
818        }
819    };
820}
821
822partial_bindings!(
823    // The mentions view (OpenSpec change `issue-mentions`): a rebindable board
824    // action, so `[board] open_mentions = [...]` works like every other
825    // board key and the bottom indicator row can read back the chord in force.
826    open_mentions,
827    move_up,
828    move_down,
829    move_left,
830    move_right,
831    next_board,
832    previous_board,
833    open,
834    edit,
835    agent,
836    view,
837    toggle_column,
838    filter_modal,
839    bulk_modal,
840    triage,
841    views,
842    apply_view,
843    new_view,
844    rename_view,
845    update_view,
846    duplicate_view,
847    delete_view,
848    mark,
849    unmark,
850    clear_marks,
851    move_previous,
852    move_next,
853    new_issue,
854    clone_issue,
855    toggle_archived,
856    toggle_children,
857    delete,
858    refresh,
859    settings,
860    planning,
861    new_board,
862    help,
863    quit,
864    text_filter,
865    reorder_up,
866    reorder_down,
867    close,
868    scroll_up,
869    scroll_down,
870    comment,
871    asset,
872    edit_parent,
873    submit,
874    cancel,
875    next,
876    previous,
877    open_picker,
878    reset,
879    toggle,
880    add_column,
881    move_column_up,
882    move_column_down,
883    new_planning_item,
884    edit_planning_item,
885    delete_planning_item,
886    confirm,
887    create,
888    previous_year,
889    next_year,
890    previous_month,
891    next_month,
892    reedit,
893    ignore,
894    declare_key,
895    discard,
896    switch_to_board,
897    switch_to_triage,
898    switch_to_cycle,
899    switch_to_milestones,
900    switch_to_repos,
901    switch_to_stats,
902    health_form,
903    // Mission `per-issue-time-tracking-01M3C9A4` (WP05): the Time tab's six keys, one
904    // action each so every key is rebindable through `[stats]`, appears in
905    // the help overlay, and has its own canonical event the handler can read.
906    time_add,
907    time_edit,
908    time_delete,
909    time_filter,
910    time_breakdown,
911    time_history,
912    // tix #1593.
913    switch_to_schedule,
914    // tix #1593: the F8 Schedule page's seven keyed actions, one action each so
915    // every key is rebindable, appears in the help overlay, and has its own
916    // canonical event the handler can read.
917    series_generate,
918    series_pause,
919    series_resume,
920    series_end,
921    series_skip,
922    series_defer,
923    series_ignore,
924    series_new,
925    // tix #1593.
926    switch_to_library,
927    switch_to_portfolio,
928    switch_to_new_issue,
929    switch_to_config,
930    toggle_navigation,
931);
932
933macro_rules! keybindings_config {
934    ($($field:ident => $context:ident),+ $(,)?) => {
935        #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
936        #[serde(default, deny_unknown_fields)]
937        pub struct KeyBindings {
938            $(
939                #[serde(default, skip_serializing_if = "Option::is_none")]
940                pub $field: Option<Box<ActionBindings>>,
941            )+
942        }
943
944        impl KeyBindings {
945            pub fn is_empty(&self) -> bool {
946                self == &Self::default()
947            }
948
949            fn overrides(&self, context: InputContext) -> Option<&ActionBindings> {
950                match context {
951                    $(InputContext::$context => self.$field.as_deref(),)+
952                }
953            }
954        }
955    };
956}
957
958keybindings_config!(
959    board => Board,
960    rollover => Rollover,
961    detail => Detail,
962    form => Form,
963    form_picker => FormPicker,
964    confirm_delete => ConfirmDelete,
965    confirm_blocked_move => ConfirmBlockedMove,
966    help_search => HelpSearch,
967    move_into_done => MoveIntoDone,
968    help => Help,
969    context_menu => ContextMenu,
970    // #1513: the related-issues picker's own section, so its keys are rebindable
971    // through `[related_picker]` like every other context's.
972    related_picker => RelatedPicker,
973    filter => Filter,
974    filter_modal => FilterModal,
975    bulk_modal => BulkModal,
976    triage => Triage,
977    cycle => Cycle,
978    milestones => Milestones,
979    // tix #1529: the F6 Repos mode's own section, so its keys are rebindable
980    // through `[repos]` like every other context's.
981    repos => Repos,
982    // Mission `project-issue-health-status-01M3C8HX`: the F7 Stats page's own
983    // section, so its keys are rebindable through `[stats]`.
984    stats => Stats,
985    // tix #1593: the F8 Schedule page's own section, so its keys are rebindable
986    // through `[schedule]` like every other context's.
987    schedule => Schedule,
988    // tix #1593: the F11 Library page's own section, so its keys are rebindable
989    // through `[library]` like every other context's.
990    library => Library,
991    // WP06: the F10 Portfolio page's own section, so its keys are rebindable
992    // through `[portfolio]` like every other context's.
993    portfolio => Portfolio,
994    settings => Settings,
995    settings_prompt => SettingsPrompt,
996    planning_management => PlanningManagement,
997    planning_confirm => PlanningConfirm,
998    planning_form => PlanningForm,
999    date_picker => DatePicker,
1000    comment => Comment,
1001    asset => Asset,
1002    edit_repair => EditRepair,
1003    edit_conflict => EditConflict,
1004    link_prompt => LinkPrompt,
1005    shell => Shell,
1006    views => Views,
1007    views_prompt => ViewsPrompt,
1008);
1009
1010#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1011pub struct KeyChord {
1012    pub code: KeyCode,
1013    pub modifiers: KeyModifiers,
1014}
1015
1016impl KeyChord {
1017    pub fn parse(value: &str) -> std::result::Result<Self, String> {
1018        let value = value.trim();
1019        if value.is_empty() {
1020            return Err("key chord must not be empty".into());
1021        }
1022        if value.chars().count() == 1 {
1023            let ch = value.chars().next().expect("value has one character");
1024            if ch.is_control() {
1025                return Err(format!("unsupported key name `{value}`"));
1026            }
1027            let modifiers = if ch.is_ascii_uppercase() {
1028                KeyModifiers::SHIFT
1029            } else {
1030                KeyModifiers::NONE
1031            };
1032            return Ok(Self {
1033                code: KeyCode::Char(ch),
1034                modifiers,
1035            });
1036        }
1037        let mut parts = value.split('+');
1038        let key = parts.next_back().ok_or("missing key name")?.trim();
1039        let mut modifiers = KeyModifiers::NONE;
1040        for part in parts {
1041            match part.trim().to_ascii_lowercase().as_str() {
1042                "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
1043                "alt" | "option" => modifiers |= KeyModifiers::ALT,
1044                "shift" => modifiers |= KeyModifiers::SHIFT,
1045                "super" | "cmd" | "command" => modifiers |= KeyModifiers::SUPER,
1046                other => return Err(format!("unsupported modifier `{other}`")),
1047            }
1048        }
1049        if key.is_empty() {
1050            return Err("missing key name".into());
1051        }
1052        let mut code = match key.to_ascii_lowercase().as_str() {
1053            "backtab" => return Err("use shift+tab instead of `backtab`".into()),
1054            "shift+tab" => KeyCode::BackTab,
1055            "backspace" => KeyCode::Backspace,
1056            "tab" => KeyCode::Tab,
1057            "enter" | "return" => KeyCode::Enter,
1058            "esc" | "escape" => KeyCode::Esc,
1059            "space" => KeyCode::Char(' '),
1060            "left" => KeyCode::Left,
1061            "right" => KeyCode::Right,
1062            "up" => KeyCode::Up,
1063            "down" => KeyCode::Down,
1064            "home" => KeyCode::Home,
1065            "end" => KeyCode::End,
1066            "pageup" | "pgup" => KeyCode::PageUp,
1067            "pagedown" | "pgdn" | "pgdown" => KeyCode::PageDown,
1068            "delete" | "del" => KeyCode::Delete,
1069            "insert" | "ins" => KeyCode::Insert,
1070            _ => {
1071                if let Some(number) = key
1072                    .to_ascii_lowercase()
1073                    .strip_prefix('f')
1074                    .and_then(|n| n.parse::<u8>().ok())
1075                {
1076                    if (1..=12).contains(&number) {
1077                        KeyCode::F(number)
1078                    } else {
1079                        return Err(format!("unsupported function key `{key}`"));
1080                    }
1081                } else {
1082                    let mut chars = key.chars();
1083                    let Some(ch) = chars.next().filter(|_| chars.next().is_none()) else {
1084                        return Err(format!("unsupported key name `{key}`"));
1085                    };
1086                    if ch.is_control() {
1087                        return Err(format!("unsupported key name `{key}`"));
1088                    }
1089                    if ch.is_ascii_uppercase() {
1090                        modifiers |= KeyModifiers::SHIFT;
1091                    }
1092                    KeyCode::Char(ch)
1093                }
1094            }
1095        };
1096        if code == KeyCode::Tab && modifiers.contains(KeyModifiers::SHIFT) {
1097            code = KeyCode::BackTab;
1098            modifiers.remove(KeyModifiers::SHIFT);
1099        }
1100        if code == KeyCode::BackTab {
1101            modifiers |= KeyModifiers::SHIFT;
1102        }
1103        Ok(Self { code, modifiers })
1104    }
1105
1106    pub fn matches(&self, event: KeyEvent) -> bool {
1107        if self.code != event.code {
1108            return false;
1109        }
1110        if self.modifiers.is_empty() {
1111            return event.modifiers.is_empty();
1112        }
1113        if self.code == KeyCode::BackTab {
1114            return event.modifiers.contains(KeyModifiers::SHIFT) || event.code == KeyCode::BackTab;
1115        }
1116        if let (KeyCode::Char(expected), KeyCode::Char(actual)) = (self.code, event.code) {
1117            let shift_expected = self.modifiers.contains(KeyModifiers::SHIFT);
1118            let normalized_expected = if shift_expected {
1119                expected.to_ascii_lowercase()
1120            } else {
1121                expected
1122            };
1123            let shift_actual =
1124                actual.is_uppercase() || event.modifiers.contains(KeyModifiers::SHIFT);
1125            let expected_other = self.modifiers.difference(KeyModifiers::SHIFT);
1126            let actual_other = event.modifiers.difference(KeyModifiers::SHIFT);
1127            return expected_other == actual_other
1128                && normalized_expected == actual.to_ascii_lowercase()
1129                && shift_expected == shift_actual;
1130        }
1131        event.modifiers == self.modifiers
1132    }
1133
1134    pub fn display(&self) -> String {
1135        let mut out = String::new();
1136        if self.modifiers.contains(KeyModifiers::CONTROL) {
1137            out.push_str("Ctrl+");
1138        }
1139        if self.modifiers.contains(KeyModifiers::ALT) {
1140            out.push_str("Alt+");
1141        }
1142        if self.modifiers.contains(KeyModifiers::SUPER) {
1143            out.push_str("Super+");
1144        }
1145        if self.modifiers.contains(KeyModifiers::SHIFT) && self.code != KeyCode::BackTab {
1146            out.push_str("Shift+");
1147        }
1148        out.push_str(&match self.code {
1149            KeyCode::BackTab => "Shift+Tab".into(),
1150            KeyCode::Char(' ') => "Space".into(),
1151            KeyCode::Char(ch) => ch.to_string(),
1152            KeyCode::Enter => "Enter".into(),
1153            KeyCode::Esc => "Esc".into(),
1154            KeyCode::Left => "Left".into(),
1155            KeyCode::Right => "Right".into(),
1156            KeyCode::Up => "Up".into(),
1157            KeyCode::Down => "Down".into(),
1158            KeyCode::PageUp => "PgUp".into(),
1159            KeyCode::PageDown => "PgDn".into(),
1160            KeyCode::Delete => "Delete".into(),
1161            KeyCode::Insert => "Insert".into(),
1162            KeyCode::Home => "Home".into(),
1163            KeyCode::End => "End".into(),
1164            KeyCode::F(n) => format!("F{n}"),
1165            other => format!("{other:?}"),
1166        });
1167        out
1168    }
1169}
1170
1171impl Serialize for KeyChord {
1172    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1173    where
1174        S: Serializer,
1175    {
1176        serializer.serialize_str(&self.display())
1177    }
1178}
1179
1180impl<'de> Deserialize<'de> for KeyChord {
1181    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1182    where
1183        D: Deserializer<'de>,
1184    {
1185        let value = String::deserialize(deserializer)?;
1186        Self::parse(&value).map_err(D::Error::custom)
1187    }
1188}
1189
1190impl fmt::Display for KeyChord {
1191    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1192        formatter.write_str(&self.display())
1193    }
1194}
1195
1196#[derive(Debug, Clone, Default, PartialEq, Eq)]
1197pub struct EffectiveBindings {
1198    modes: HashMap<InputContext, HashMap<TuiAction, Vec<KeyChord>>>,
1199}
1200
1201impl KeyBindings {
1202    /// Load sparse TUI overrides from `tix_keybindings.toml` in `project_root`.
1203    ///
1204    /// A missing file is the default configuration, not an error. A present file
1205    /// deserializes directly into [`KeyBindings`], whose mode tables are sparse:
1206    /// omitted modes and actions overlay nothing and therefore keep their
1207    /// built-in chords.
1208    pub fn load(project_root: &Path) -> Result<Self> {
1209        let path = project_root.join(crate::keybindings::KEYBINDINGS_FILE);
1210        if !path.exists() {
1211            return Ok(Self::default());
1212        }
1213        let contents = std::fs::read_to_string(&path)
1214            .map_err(|error| TixError::Config(format!("{}: {error}", path.display())))?;
1215        let overrides: Self = toml::from_str(&contents)
1216            .map_err(|error| TixError::Config(format!("{}: {error}", path.display())))?;
1217        Ok(overrides)
1218    }
1219
1220    /// Load the dedicated file and resolve it over the built-in defaults.
1221    pub fn load_effective(project_root: &Path) -> Result<EffectiveBindings> {
1222        Self::resolve(&Self::load(project_root)?)
1223    }
1224
1225    pub fn resolve(overrides: &KeyBindings) -> Result<EffectiveBindings> {
1226        let mut effective = EffectiveBindings::default();
1227        for context in InputContext::ALL {
1228            let mut mode = default_mode(*context);
1229            if let Some(overrides) = overrides.overrides(*context) {
1230                overlay(&mut mode, overrides, *context)?;
1231            }
1232            effective.modes.insert(*context, mode);
1233        }
1234        Ok(effective)
1235    }
1236}
1237
1238/// The dedicated, optional keybinding override file.
1239pub const KEYBINDINGS_FILE: &str = "tix_keybindings.toml";
1240
1241impl EffectiveBindings {
1242    pub fn action(&self, context: InputContext, event: KeyEvent) -> Option<TuiAction> {
1243        self.modes
1244            .get(&context)?
1245            .iter()
1246            .find_map(|(action, chords)| {
1247                chords
1248                    .iter()
1249                    .any(|key| key.matches(event))
1250                    .then_some(*action)
1251            })
1252    }
1253
1254    /// Every action this context binds, sorted by name so the order is stable.
1255    ///
1256    /// For the help overlay's completeness guard: that overlay's per-context action
1257    /// list is written by hand, and a binding it does not name is a key that works and
1258    /// that nobody can discover.
1259    pub fn bound_actions(&self, context: InputContext) -> Vec<TuiAction> {
1260        let mut actions: Vec<TuiAction> = self
1261            .modes
1262            .get(&context)
1263            .map(|mode| mode.keys().copied().collect())
1264            .unwrap_or_default();
1265        actions.sort_by_key(|action| action.as_str());
1266        actions
1267    }
1268
1269    pub fn chords(&self, context: InputContext, action: TuiAction) -> &[KeyChord] {
1270        self.modes
1271            .get(&context)
1272            .and_then(|mode| mode.get(&action))
1273            .map(Vec::as_slice)
1274            .unwrap_or_default()
1275    }
1276
1277    pub fn label(&self, context: InputContext, action: TuiAction) -> String {
1278        let labels: Vec<String> = self
1279            .chords(context, action)
1280            .iter()
1281            .map(ToString::to_string)
1282            .collect();
1283        if labels.is_empty() {
1284            "—".into()
1285        } else {
1286            labels.join(" / ")
1287        }
1288    }
1289
1290    #[allow(clippy::too_many_lines)]
1291    pub fn canonical_event(&self, context: InputContext, action: TuiAction) -> KeyEvent {
1292        let (code, modifiers) = match (context, action) {
1293            (_, TuiAction::ReorderUp) => (KeyCode::Up, KeyModifiers::CONTROL),
1294            (_, TuiAction::ReorderDown) => (KeyCode::Down, KeyModifiers::CONTROL),
1295            (InputContext::Rollover, TuiAction::Previous) => (KeyCode::Tab, KeyModifiers::NONE),
1296            (InputContext::PlanningManagement, TuiAction::Next) => {
1297                (KeyCode::Tab, KeyModifiers::NONE)
1298            }
1299            (InputContext::FormPicker, TuiAction::Next) => (KeyCode::Up, KeyModifiers::NONE),
1300            (InputContext::FormPicker, TuiAction::Previous) => (KeyCode::Down, KeyModifiers::NONE),
1301            (InputContext::DatePicker, TuiAction::PreviousYear) => {
1302                (KeyCode::Char('['), KeyModifiers::NONE)
1303            }
1304            (InputContext::DatePicker, TuiAction::NextYear) => {
1305                (KeyCode::Char(']'), KeyModifiers::NONE)
1306            }
1307            (InputContext::DatePicker, TuiAction::PreviousMonth) => {
1308                (KeyCode::PageUp, KeyModifiers::NONE)
1309            }
1310            (InputContext::DatePicker, TuiAction::NextMonth) => {
1311                (KeyCode::PageDown, KeyModifiers::NONE)
1312            }
1313            (InputContext::DatePicker, TuiAction::Reset) => (KeyCode::Delete, KeyModifiers::NONE),
1314            // tix #1593: on the Library page `next`/`previous` step between the
1315            // two registries, so they must arrive as Tab/BackTab — the generic
1316            // arms below fold them into Down/Up, which the page's own `j`/`k`
1317            // already own and which would make the tab keys indistinguishable
1318            // from the row keys.
1319            (InputContext::Library, TuiAction::Next) => (KeyCode::Tab, KeyModifiers::NONE),
1320            (InputContext::Library, TuiAction::Previous) => (KeyCode::BackTab, KeyModifiers::NONE),
1321            // WP06: the Portfolio page reads two registries too, so its tab keys
1322            // must arrive as Tab/BackTab for the same reason — the generic arms
1323            // would fold them into Up/Down and make them indistinguishable from the
1324            // row keys.
1325            (InputContext::Portfolio, TuiAction::Next) => (KeyCode::Tab, KeyModifiers::NONE),
1326            (InputContext::Portfolio, TuiAction::Previous) => {
1327                (KeyCode::BackTab, KeyModifiers::NONE)
1328            }
1329            // WP05: the Stats page's two tabs switch on Tab/BackTab for the same
1330            // reason — the generic arms would fold them into Up/Down and make the tab
1331            // keys indistinguishable from the row keys, so `Tab` would stop switching.
1332            (InputContext::Stats, TuiAction::Next) => (KeyCode::Tab, KeyModifiers::NONE),
1333            (InputContext::Stats, TuiAction::Previous) => (KeyCode::BackTab, KeyModifiers::NONE),
1334            (InputContext::Settings, TuiAction::NewBoard) => {
1335                (KeyCode::Char('n'), KeyModifiers::NONE)
1336            }
1337            (InputContext::Settings, TuiAction::Delete) => (KeyCode::Char('x'), KeyModifiers::NONE),
1338            // #1441/#1442: `scroll_up`/`scroll_down` are **page** steps and must
1339            // canonicalize to themselves. The generic arms below fold them into
1340            // Up/Down, which is what made the detail pane's `PgUp`/`PgDn` arms dead
1341            // code: the key arrived canonicalized as `Down` and moved the
1342            // relationship cursor instead of scrolling, so a bound key did something
1343            // other than what it was bound to. These arms sit above the generics
1344            // because the match is top-down.
1345            (_, TuiAction::ScrollUp) => (KeyCode::PageUp, KeyModifiers::NONE),
1346            (_, TuiAction::ScrollDown) => (KeyCode::PageDown, KeyModifiers::NONE),
1347            // The line and end-of-body scrolls canonicalize to the keys their
1348            // handlers read, so `j`/`Down` both arrive as `Down` and `g`/`G` as
1349            // themselves (the detail pane's handler reads the canonical event).
1350            (_, TuiAction::ScrollLineDown) => (KeyCode::Down, KeyModifiers::NONE),
1351            (_, TuiAction::ScrollLineUp) => (KeyCode::Up, KeyModifiers::NONE),
1352            (_, TuiAction::ScrollTop) => (KeyCode::Char('g'), KeyModifiers::NONE),
1353            (_, TuiAction::ScrollBottom) => (KeyCode::Char('G'), KeyModifiers::NONE),
1354            (_, TuiAction::MoveUp | TuiAction::Previous) => (KeyCode::Up, KeyModifiers::NONE),
1355            (_, TuiAction::MoveDown | TuiAction::Next) => (KeyCode::Down, KeyModifiers::NONE),
1356            (_, TuiAction::MoveLeft) => (KeyCode::Left, KeyModifiers::NONE),
1357            (_, TuiAction::MoveRight) => (KeyCode::Right, KeyModifiers::NONE),
1358            (_, TuiAction::PreviousBoard) => (KeyCode::BackTab, KeyModifiers::NONE),
1359            (_, TuiAction::NextBoard) => (KeyCode::Tab, KeyModifiers::NONE),
1360            (_, TuiAction::Submit | TuiAction::Open | TuiAction::Ignore) => {
1361                (KeyCode::Enter, KeyModifiers::NONE)
1362            }
1363            (_, TuiAction::NewIssue) => (KeyCode::Char('n'), KeyModifiers::NONE),
1364            (_, TuiAction::CloneIssue) => (KeyCode::Char('C'), KeyModifiers::NONE),
1365            (_, TuiAction::ToggleArchived) => (KeyCode::Char('a'), KeyModifiers::NONE),
1366            (_, TuiAction::ToggleChildren) => (KeyCode::Char('u'), KeyModifiers::NONE),
1367            (_, TuiAction::Edit | TuiAction::EditPlanningItem) => {
1368                (KeyCode::Char('e'), KeyModifiers::NONE)
1369            }
1370            (_, TuiAction::Agent) => (KeyCode::Char('A'), KeyModifiers::NONE),
1371            // The board's `view` action is `V` (#1507): plain `v` there is the
1372            // saved-views modal, so the synthesized event must be the shifted
1373            // letter the board's own handler arm reads.
1374            (InputContext::Board, TuiAction::View) => (KeyCode::Char('V'), KeyModifiers::NONE),
1375            (_, TuiAction::View) => (KeyCode::Char('v'), KeyModifiers::NONE),
1376            (_, TuiAction::ToggleColumn | TuiAction::Comment) => {
1377                (KeyCode::Char('c'), KeyModifiers::NONE)
1378            }
1379            (_, TuiAction::FilterModal) => (KeyCode::Char('f'), KeyModifiers::NONE),
1380            (_, TuiAction::BulkModal) => (KeyCode::Char('B'), KeyModifiers::NONE),
1381            (_, TuiAction::Triage) => (KeyCode::Char('t'), KeyModifiers::NONE),
1382            // The mentions view's canonical event is the default chord the indicator
1383            // row advertises, so the board's own handler reads `i` whatever the user
1384            // rebound `open_mentions` to.
1385            (_, TuiAction::OpenMentions) => (KeyCode::Char('i'), KeyModifiers::NONE),
1386            // WP05. Each view action's canonical event is the key the modal's own
1387            // footer advertises, so a click region can synthesize exactly the key
1388            // the handler expects.
1389            (_, TuiAction::Views) => (KeyCode::Char('v'), KeyModifiers::NONE),
1390            (_, TuiAction::ApplyView) => (KeyCode::Enter, KeyModifiers::NONE),
1391            (_, TuiAction::NewView) => (KeyCode::Char('n'), KeyModifiers::NONE),
1392            (_, TuiAction::RenameView) => (KeyCode::Char('e'), KeyModifiers::NONE),
1393            (_, TuiAction::UpdateView) => (KeyCode::Char('u'), KeyModifiers::NONE),
1394            (_, TuiAction::DuplicateView) => (KeyCode::Char('d'), KeyModifiers::NONE),
1395            (_, TuiAction::DeleteView) => (KeyCode::Char('x'), KeyModifiers::NONE),
1396            (_, TuiAction::Mark) => (KeyCode::Char(' '), KeyModifiers::NONE),
1397            (_, TuiAction::Unmark) => (KeyCode::Char('x'), KeyModifiers::NONE),
1398            (_, TuiAction::ClearMarks) => (KeyCode::Char('X'), KeyModifiers::NONE),
1399            (_, TuiAction::MovePrevious) => (KeyCode::Char('<'), KeyModifiers::NONE),
1400            (_, TuiAction::MoveNext) => (KeyCode::Char('>'), KeyModifiers::NONE),
1401            (_, TuiAction::Delete) => (KeyCode::Char('d'), KeyModifiers::NONE),
1402            (_, TuiAction::DeletePlanningItem) => (KeyCode::Char('x'), KeyModifiers::NONE),
1403            (_, TuiAction::Refresh | TuiAction::Reset | TuiAction::Reedit) => {
1404                (KeyCode::Char('r'), KeyModifiers::NONE)
1405            }
1406            (_, TuiAction::Settings) => (KeyCode::Char('s'), KeyModifiers::NONE),
1407            (_, TuiAction::Planning) => (KeyCode::Char('m'), KeyModifiers::NONE),
1408            (_, TuiAction::NewPlanningItem) => (KeyCode::Char('n'), KeyModifiers::NONE),
1409            (_, TuiAction::NewBoard) => (KeyCode::Char('b'), KeyModifiers::NONE),
1410            (_, TuiAction::AddColumn) => (KeyCode::Char('a'), KeyModifiers::NONE),
1411            (_, TuiAction::MoveColumnUp) => (KeyCode::Char('K'), KeyModifiers::SHIFT),
1412            (_, TuiAction::MoveColumnDown) => (KeyCode::Char('J'), KeyModifiers::SHIFT),
1413            (_, TuiAction::Help) => (KeyCode::Char('?'), KeyModifiers::NONE),
1414            (_, TuiAction::Quit) => (KeyCode::Char('q'), KeyModifiers::NONE),
1415            (_, TuiAction::TextFilter) => (KeyCode::Char('/'), KeyModifiers::NONE),
1416            (_, TuiAction::Close | TuiAction::Cancel) => (KeyCode::Esc, KeyModifiers::NONE),
1417            (_, TuiAction::Asset) => (KeyCode::Char('a'), KeyModifiers::NONE),
1418            // #1359. `e` is the editor in the detail view, so re-kind is `E` — the
1419            // same relationship the CLI's `tix link edit <id>` changes.
1420            (_, TuiAction::NewRelationship) => (KeyCode::Char('n'), KeyModifiers::NONE),
1421            (_, TuiAction::EditRelationship) => (KeyCode::Char('E'), KeyModifiers::SHIFT),
1422            (_, TuiAction::RemoveRelationship) => (KeyCode::Char('d'), KeyModifiers::NONE),
1423            // #1384: hand the handler the key the footer advertises, not the
1424            // catch-all `Esc`.
1425            (_, TuiAction::EditParent) => (KeyCode::Char('P'), KeyModifiers::NONE),
1426            (_, TuiAction::NextRelationship) => (KeyCode::Tab, KeyModifiers::NONE),
1427            (_, TuiAction::PreviousRelationship) => (KeyCode::BackTab, KeyModifiers::NONE),
1428            // #1513. The three related-navigation actions hand the handler the
1429            // key they advertise — without these arms the generic catch-all
1430            // would fold them into `Esc`, and `o` on a related entry would close
1431            // the pane instead of following the link.
1432            (_, TuiAction::RelatedPicker) => (KeyCode::Char('r'), KeyModifiers::NONE),
1433            (_, TuiAction::FollowRelated) => (KeyCode::Char('o'), KeyModifiers::NONE),
1434            (_, TuiAction::NavBack) => (KeyCode::Backspace, KeyModifiers::NONE),
1435            (_, TuiAction::OpenPicker) => (KeyCode::F(2), KeyModifiers::NONE),
1436            // The shell's mode switches. `F(2)` is also `open_picker`'s canonical
1437            // event — they are different actions in different contexts, which is
1438            // exactly why mode switching is scoped to the shell context and a form's
1439            // `F2` keeps its picker meaning.
1440            (_, TuiAction::SwitchToBoard) => (KeyCode::F(3), KeyModifiers::NONE),
1441            (_, TuiAction::SwitchToTriage) => (KeyCode::F(2), KeyModifiers::NONE),
1442            (_, TuiAction::SwitchToCycle) => (KeyCode::F(4), KeyModifiers::NONE),
1443            (_, TuiAction::SwitchToMilestones) => (KeyCode::F(5), KeyModifiers::NONE),
1444            (_, TuiAction::SwitchToRepos) => (KeyCode::F(6), KeyModifiers::NONE),
1445            // Mission `project-issue-health-status-01M3C8HX` (WP06): F7 was reserved
1446            // in the shell until its page existed; the Stats page takes it.
1447            (_, TuiAction::SwitchToStats) => (KeyCode::F(7), KeyModifiers::NONE),
1448            // The health form's key. `handle_detail_key` reads raw keys, so its
1449            // canonical event must be that key: without this the default (`Esc`) is
1450            // what the handler sees, and the key meant to open the form would close
1451            // the pane instead.
1452            (_, TuiAction::HealthForm) => (KeyCode::Char('H'), KeyModifiers::NONE),
1453            // WP05: the Time tab's commands canonicalize to their own letters, so the
1454            // handler reads one event per action however the user rebound it. Without
1455            // these the catch-all would fold all six into `Esc` — closing the page
1456            // instead of acting.
1457            (_, TuiAction::TimeAdd) => (KeyCode::Char('a'), KeyModifiers::NONE),
1458            (_, TuiAction::TimeEdit) => (KeyCode::Char('e'), KeyModifiers::NONE),
1459            (_, TuiAction::TimeDelete) => (KeyCode::Char('x'), KeyModifiers::NONE),
1460            (_, TuiAction::TimeFilter) => (KeyCode::Char('f'), KeyModifiers::NONE),
1461            (_, TuiAction::TimeBreakdown) => (KeyCode::Char('s'), KeyModifiers::NONE),
1462            (_, TuiAction::TimeHistory) => (KeyCode::Char('H'), KeyModifiers::NONE),
1463            // tix #1593: F8 was free in the shell; the Schedule page takes it.
1464            (_, TuiAction::SwitchToSchedule) => (KeyCode::F(8), KeyModifiers::NONE),
1465            // The Schedule page's actions canonicalize to their own letters: the
1466            // handler reads the canonical event, so each action needs one.
1467            (_, TuiAction::SeriesGenerate) => (KeyCode::Char('g'), KeyModifiers::NONE),
1468            (_, TuiAction::SeriesPause) => (KeyCode::Char('p'), KeyModifiers::NONE),
1469            (_, TuiAction::SeriesResume) => (KeyCode::Char('r'), KeyModifiers::NONE),
1470            (_, TuiAction::SeriesEnd) => (KeyCode::Char('x'), KeyModifiers::NONE),
1471            (_, TuiAction::SeriesSkip) => (KeyCode::Char('s'), KeyModifiers::NONE),
1472            (_, TuiAction::SeriesDefer) => (KeyCode::Char('d'), KeyModifiers::NONE),
1473            (_, TuiAction::SeriesIgnore) => (KeyCode::Char('i'), KeyModifiers::NONE),
1474            // The Series tab's create key: the handler opens the form when it reads
1475            // this canonical event, so a rebind still lands there.
1476            (_, TuiAction::SeriesNew) => (KeyCode::Char('n'), KeyModifiers::NONE),
1477            // tix #1593: F11 was free in the shell; the Library page follows the
1478            // repos list.
1479            (_, TuiAction::SwitchToLibrary) => (KeyCode::F(11), KeyModifiers::NONE),
1480            // WP06: F10 was reserved in the shell; the Portfolio page takes it.
1481            (_, TuiAction::SwitchToPortfolio) => (KeyCode::F(10), KeyModifiers::NONE),
1482            (_, TuiAction::SwitchToNewIssue) => (KeyCode::F(1), KeyModifiers::NONE),
1483            (_, TuiAction::SwitchToConfig) => (KeyCode::F(12), KeyModifiers::NONE),
1484            (_, TuiAction::ToggleNavigation) => (KeyCode::Char('n'), KeyModifiers::CONTROL),
1485            (_, TuiAction::Toggle) => (KeyCode::Char(' '), KeyModifiers::NONE),
1486            (_, TuiAction::Confirm) => (KeyCode::Char('y'), KeyModifiers::NONE),
1487            // #1506. The repair prompt's own two decisions canonicalize to the
1488            // keys the prompt advertises, so `handle_edit_repair_key` reads a
1489            // canonical `d`/`x` however the user reached the action.
1490            (_, TuiAction::DeclareKey) => (KeyCode::Char('d'), KeyModifiers::NONE),
1491            (_, TuiAction::Discard) => (KeyCode::Char('x'), KeyModifiers::NONE),
1492            (_, TuiAction::Create) => (KeyCode::Char('+'), KeyModifiers::NONE),
1493            _ => (KeyCode::Esc, KeyModifiers::NONE),
1494        };
1495        KeyEvent::new(code, modifiers)
1496    }
1497}
1498
1499impl ActionBindings {
1500    pub fn is_empty(&self) -> bool {
1501        self == &Self::default()
1502    }
1503}
1504
1505#[cfg(test)]
1506mod tests;