Skip to main content

cui/
keymap.rs

1//! The keyboard command system: one static binding table drives
2//! both dispatch (`lookup`) and the command bars (`bar_items`), so
3//! a binding shown in a bar is by construction the binding that
4//! fires.
5
6use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
7
8use crate::cmdline::ExCommand;
9use crate::state::{AppState, Focus};
10
11/// Everything the reducer can be asked to do. Key bindings produce
12/// the simple variants; the ':' command line produces `Ex`.
13#[derive(Clone, Debug, PartialEq)]
14pub enum Command {
15    Quit,
16    Help,
17    Reload,
18    CycleFocus,
19    Open,
20    Back,
21    MoveDown,
22    MoveUp,
23    MoveTop,
24    MoveBottom,
25    HalfPageDown,
26    HalfPageUp,
27    NextProfile,
28    /// Enter command mode with the buffer prefilled.
29    StartCommand(&'static str),
30    Backlinks,
31    /// Double the result count of the active search.
32    MoreResults,
33    /// Toggle transclusion in the content view.
34    ToggleExpand,
35    /// Open the selected jot in $EDITOR (handled by the run loop,
36    /// which owns the terminal — see `run_editor`).
37    Edit,
38    Ex(ExCommand),
39}
40
41#[derive(Clone, Copy, PartialEq, Eq)]
42pub enum Bar {
43    Global,
44    Context,
45}
46
47#[derive(Clone, Copy)]
48pub struct KeyChord {
49    pub code: KeyCode,
50    pub mods: KeyModifiers,
51}
52
53impl KeyChord {
54    fn matches(&self, key: &KeyEvent) -> bool {
55        if self.code != key.code {
56            return false;
57        }
58        // Chars arrive with SHIFT already applied to the char
59        // itself ('G', '?', ':'), so ignore the SHIFT bit there.
60        let mods = match key.code {
61            KeyCode::Char(_) => key.modifiers.difference(KeyModifiers::SHIFT),
62            _ => key.modifiers,
63        };
64        mods == self.mods
65    }
66}
67
68/// Applicability of a binding, shared by dispatch and the bars.
69/// Bindings apply in Normal mode only — Command mode keys go to the
70/// line editor, and any key closes Help.
71#[derive(Clone, Copy)]
72pub struct When {
73    /// None = either panel.
74    pub focus: Option<Focus>,
75    /// Only while a find/search/backlinks result list is active.
76    pub results_only: bool,
77}
78
79impl When {
80    fn matches(&self, state: &AppState) -> bool {
81        self.focus.is_none_or(|f| f == state.focus)
82            && (!self.results_only || !state.source.is_all())
83    }
84}
85
86pub struct Binding {
87    pub chord: KeyChord,
88    pub when: When,
89    pub cmd: Command,
90    /// Which bar shows this binding; None = hidden alias.
91    pub bar: Option<Bar>,
92    pub key_label: &'static str,
93    pub action_label: &'static str,
94}
95
96const NONE: KeyModifiers = KeyModifiers::NONE;
97const CTRL: KeyModifiers = KeyModifiers::CONTROL;
98const ANY: When = When {
99    focus: None,
100    results_only: false,
101};
102const LIST: When = When {
103    focus: Some(Focus::List),
104    results_only: false,
105};
106const VIEW: When = When {
107    focus: Some(Focus::View),
108    results_only: false,
109};
110const LIST_RESULTS: When = When {
111    focus: Some(Focus::List),
112    results_only: true,
113};
114
115macro_rules! bind {
116    ($code:expr, $mods:expr, $when:expr, $cmd:expr, $bar:expr, $key:expr, $action:expr) => {
117        Binding {
118            chord: KeyChord {
119                code: $code,
120                mods: $mods,
121            },
122            when: $when,
123            cmd: $cmd,
124            bar: $bar,
125            key_label: $key,
126            action_label: $action,
127        }
128    };
129}
130
131use Command as C;
132use KeyCode::*;
133
134/// The single source of truth. Order matters twice over: the first
135/// matching row dispatches, and bar order follows table order.
136pub static KEYMAP: &[Binding] = &[
137    // ── global bar ────────────────────────────────────────────
138    bind!(
139        Char('q'),
140        NONE,
141        ANY,
142        C::Quit,
143        Some(Bar::Global),
144        "q",
145        "quit"
146    ),
147    bind!(
148        Char(':'),
149        NONE,
150        ANY,
151        C::StartCommand(""),
152        Some(Bar::Global),
153        ":",
154        "cmd"
155    ),
156    bind!(
157        Char('/'),
158        NONE,
159        ANY,
160        C::StartCommand("search "),
161        Some(Bar::Global),
162        "/",
163        "search"
164    ),
165    bind!(
166        Char('?'),
167        NONE,
168        ANY,
169        C::Help,
170        Some(Bar::Global),
171        "?",
172        "help"
173    ),
174    bind!(
175        Char('p'),
176        NONE,
177        ANY,
178        C::NextProfile,
179        Some(Bar::Global),
180        "p",
181        "profile"
182    ),
183    bind!(
184        Char('r'),
185        NONE,
186        ANY,
187        C::Reload,
188        Some(Bar::Global),
189        "r",
190        "reload"
191    ),
192    bind!(
193        Tab,
194        NONE,
195        ANY,
196        C::CycleFocus,
197        Some(Bar::Global),
198        "Tab",
199        "focus"
200    ),
201    // ── context bar: staged Esc first (View back, then results) ──
202    bind!(Esc, NONE, VIEW, C::Back, None, "Esc", "back"),
203    bind!(
204        Esc,
205        NONE,
206        LIST_RESULTS,
207        C::Back,
208        Some(Bar::Context),
209        "Esc",
210        "all jots"
211    ),
212    // ── context bar: movement ─────────────────────────────────
213    bind!(
214        Char('j'),
215        NONE,
216        LIST,
217        C::MoveDown,
218        Some(Bar::Context),
219        "j/k",
220        "move"
221    ),
222    bind!(Char('k'), NONE, LIST, C::MoveUp, None, "k", "up"),
223    bind!(
224        Char('j'),
225        NONE,
226        VIEW,
227        C::MoveDown,
228        Some(Bar::Context),
229        "j/k",
230        "scroll"
231    ),
232    bind!(Char('k'), NONE, VIEW, C::MoveUp, None, "k", "up"),
233    bind!(Down, NONE, ANY, C::MoveDown, None, "↓", "down"),
234    bind!(Up, NONE, ANY, C::MoveUp, None, "↑", "up"),
235    bind!(
236        Char('g'),
237        NONE,
238        ANY,
239        C::MoveTop,
240        Some(Bar::Context),
241        "g/G",
242        "top/end"
243    ),
244    bind!(Char('G'), NONE, ANY, C::MoveBottom, None, "G", "end"),
245    bind!(Home, NONE, ANY, C::MoveTop, None, "Home", "top"),
246    bind!(End, NONE, ANY, C::MoveBottom, None, "End", "end"),
247    bind!(
248        Char('d'),
249        NONE,
250        ANY,
251        C::HalfPageDown,
252        Some(Bar::Context),
253        "d/u",
254        "½page"
255    ),
256    bind!(Char('u'), NONE, ANY, C::HalfPageUp, None, "u", "½page up"),
257    bind!(Char('d'), CTRL, ANY, C::HalfPageDown, None, "C-d", "½page"),
258    bind!(Char('u'), CTRL, ANY, C::HalfPageUp, None, "C-u", "½page up"),
259    bind!(PageDown, NONE, ANY, C::HalfPageDown, None, "PgDn", "½page"),
260    bind!(PageUp, NONE, ANY, C::HalfPageUp, None, "PgUp", "½page up"),
261    // ── context bar: panel actions ────────────────────────────
262    bind!(Enter, NONE, LIST, C::Open, Some(Bar::Context), "⏎", "open"),
263    bind!(Char('l'), NONE, LIST, C::Open, None, "l", "open"),
264    bind!(Right, NONE, LIST, C::Open, None, "→", "open"),
265    bind!(
266        Char('h'),
267        NONE,
268        VIEW,
269        C::Back,
270        Some(Bar::Context),
271        "h",
272        "back"
273    ),
274    bind!(Left, NONE, VIEW, C::Back, None, "←", "back"),
275    bind!(
276        Char('f'),
277        NONE,
278        LIST,
279        C::StartCommand("find "),
280        Some(Bar::Context),
281        "f",
282        "find"
283    ),
284    bind!(
285        Char('b'),
286        NONE,
287        ANY,
288        C::Backlinks,
289        Some(Bar::Context),
290        "b",
291        "backlinks"
292    ),
293    bind!(
294        Char('m'),
295        NONE,
296        LIST_RESULTS,
297        C::MoreResults,
298        Some(Bar::Context),
299        "m",
300        "more"
301    ),
302    bind!(
303        Char('x'),
304        NONE,
305        VIEW,
306        C::ToggleExpand,
307        Some(Bar::Context),
308        "x",
309        "expand"
310    ),
311    bind!(
312        Char('e'),
313        NONE,
314        ANY,
315        C::Edit,
316        Some(Bar::Context),
317        "e",
318        "edit"
319    ),
320];
321
322/// Resolve a Normal-mode key press to a command: the first binding
323/// whose applicability and chord both match.
324pub fn lookup(state: &AppState, key: &KeyEvent) -> Option<Command> {
325    KEYMAP
326        .iter()
327        .find(|b| b.when.matches(state) && b.chord.matches(key))
328        .map(|b| b.cmd.clone())
329}
330
331/// The (key, action) labels a bar shows in the current state —
332/// derived from the same rows and predicate `lookup` dispatches
333/// through.
334pub fn bar_items(state: &AppState, bar: Bar) -> Vec<(&'static str, &'static str)> {
335    KEYMAP
336        .iter()
337        .filter(|b| b.bar == Some(bar) && b.when.matches(state))
338        .map(|b| (b.key_label, b.action_label))
339        .collect()
340}