Skip to main content

pristine/tui/
keymap.rs

1//! What a keypress means: one table, and the chain that reads it.
2//!
3//! # Deliberately close to pua's
4//!
5//! pua is a rollup tree over processes and this is a rollup tree over directories, so the two
6//! should feel like siblings: `j`/`k`, `←`/`→`, `*`, `z`, `g`/`G`, `s`/`S`, `/`, `?`, `q` and
7//! `Esc` all mean here exactly what they mean there, and this file is a rewrite of pua's
8//! `tui/keymap.rs` rather than an independent invention.
9//!
10//! It diverges exactly where the verbs do. pua kills one process and needs a key that asks
11//! before signalling; pristine **marks a subtree** and then commits a batch, which is two verbs
12//! rather than one. `space` marks (npkill's key for the same idea), `a` marks or clears
13//! everything, and `x` — pua's one key that writes — commits what is marked. The keys pua
14//! spends on sampling (`space` freezes, `r` re-samples) are free here, because a directory tree
15//! does not tick.
16//!
17//! # Why a table rather than a `match`
18//!
19//! Three things have to agree about the keymap and drift apart the moment any of them is
20//! written by hand: the dispatcher, the help overlay and the footer. [`KEYMAP`] is the single
21//! statement of what is bound and all three read it, so a key that does something is a key the
22//! help page documents by construction.
23//!
24//! # Routing is a chain of surfaces
25//!
26//! Overlay first, then the tree, then the globals. Spelled as a list of [`Surface`]s rather
27//! than as branches in the dispatcher because of the guarantee attached to it: the tree can
28//! never shadow a global key, which is one assertion over the table rather than a property
29//! somebody has to keep noticing. An overlay is modal by *omission* — while one is up the
30//! chain simply does not contain the tree.
31//!
32//! # The pointer is a second table, for the same reason
33//!
34//! [`POINTER`] is to a mouse event what [`KEYMAP`] is to a keystroke, and it is a table for
35//! the argument written above rather than by analogy: the dispatcher, the help overlay and
36//! the guarantee that no gesture acts undocumented all read it. What it does *not* need is
37//! the chain — a press has coordinates, so "which surface is this" is answered by
38//! [`super::render::hit`] geometrically, and restating the order here would be two rules that
39//! have to agree.
40
41use std::fmt;
42use std::sync::LazyLock;
43
44use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
45
46use super::render::{Spot, Zone};
47use crate::rules::Kind;
48use crate::tree::Order;
49
50/// Where a motion key wants the cursor.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum Motion {
53    /// One row back.
54    Up,
55    /// One row on.
56    Down,
57    /// A screenful back.
58    PageUp,
59    /// A screenful on.
60    PageDown,
61    /// The first row.
62    Top,
63    /// The last row.
64    Bottom,
65}
66
67/// Which way round a cycle goes.
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub enum Turn {
70    /// Forwards.
71    Next,
72    /// Backwards.
73    Prev,
74}
75
76/// What a keypress asked for.
77///
78/// Separated from carrying it out so the keymap can be asserted directly: "`h` collapses" is
79/// one assertion here rather than a terminal, a fixture tree and a rendered frame.
80#[derive(Clone, Copy, Debug, PartialEq, Eq)]
81pub enum Action {
82    /// `q` `Ctrl-c` — put the terminal back and go.
83    Quit,
84    /// A motion key — move the cursor, which moves the viewport with it.
85    Cursor(Motion),
86    /// `→` `l` `Enter` — open a row, or step into an open one.
87    Expand,
88    /// `←` `h` — close a row, or step out of a closed one.
89    Collapse,
90    /// `*` — open or close everything under the cursor at once.
91    ToggleSubtree,
92    /// `z` — close every open row, back to the roots.
93    ///
94    /// **Not a toggle**, where [`ToggleSubtree`](Self::ToggleSubtree) is: `*` is ambiguous on
95    /// a partly-open subtree and this key is not, so it keeps the one meaning it has. An
96    /// "open everything" companion is deliberately absent — one real home directory expands
97    /// to 22,765 rows, and that is not a view anybody asked for.
98    CollapseAll,
99    /// `space` — mark the row's whole subtree, or unmark it.
100    ///
101    /// The key npkill uses for selecting a row, doing the thing npkill's flat list cannot: a
102    /// mark on a collapsed row covers everything beneath it.
103    Mark,
104    /// `a` — mark everything, or clear the marks.
105    ///
106    /// Ambiguous on a partial selection in a way `space` is not, and resolved toward
107    /// **clearing**: a reader who has marked forty directories and presses an unfamiliar key
108    /// can afford to lose the selection and cannot afford to gain thirty more.
109    MarkAll,
110    /// `x` — remove what is marked. Asks first.
111    ///
112    /// pua's key for the one thing that writes, and the sentence in the help says that it
113    /// asks: a reader scanning the page for a way to free space must not have to press it to
114    /// find out whether it is armed. The key **asks** and never deletes; the only thing that
115    /// commits is the dialog handing back what it was holding.
116    Commit,
117    /// `m` — show or hide the treemap pane.
118    ///
119    /// A key rather than a flag, and the reason is the one every part of [`super::treemap`]
120    /// turns on: an enhancement a reader cannot dismiss is not an enhancement. It is on the
121    /// tree's surface rather than among the globals because what it changes is how the tree
122    /// pane is laid out.
123    ToggleMap,
124    /// `s` — the next sort key.
125    CycleSort,
126    /// `S` — the same key, upside down.
127    ReverseSort,
128    /// `1` `2` `3`, or a click on a column heading — order a level by that key.
129    ///
130    /// Reverses when it names the order already in force, and starts a *new* column the right
131    /// way up: see [`super::state::View::sort_by`].
132    SortBy(Order),
133    /// A click on a row's name — put the cursor on that directory.
134    ///
135    /// By [`NodeId`](crate::tree::NodeId) and never by row index; see [`Spot::Row`].
136    Select(crate::tree::NodeId),
137    /// A click on a row's `▸` — open that row, or close it.
138    ///
139    /// The one thing `→` and `←` between them do, reached with one gesture and without the
140    /// cursor having to be there first.
141    OpenRow(crate::tree::NodeId),
142    /// A click on a row's `[ ]` — mark that row's subtree, or unmark it.
143    ///
144    /// `space` for a reader who is pointing. pristine draws a box on every row, and a box
145    /// that cannot be pressed is a lie the screen tells about itself.
146    MarkRow(crate::tree::NodeId),
147    /// A double click on a row — price everything under it that carries no price.
148    ///
149    /// The expensive thing a reader wants on one specific subtree, which is what
150    /// `--breakdown-under` is on the command line. A double click is the gesture for it
151    /// because it is the one that says "this one, in particular".
152    Price(crate::tree::NodeId),
153    /// The wheel over the tree — move the viewport, taking the cursor with it.
154    ///
155    /// Distinct from [`Cursor`](Self::Cursor), which moves the cursor and lets the viewport
156    /// follow: this is the other way round.
157    ScrollRows(Motion),
158    /// `/` — open the filter prompt.
159    OpenFilter,
160    /// `f` `F` — the next named view, or the one before.
161    ///
162    /// A view is the two axes of [`super::lens`] together, and this key walks the presets over
163    /// them. Deliberately **not** a key that changes the selection: what is marked is
164    /// independent of what is visible, and the whole point of the pair is that a reader can
165    /// narrow the screen without narrowing what they are about to delete.
166    CyclePreset(Turn),
167    /// `t` — move the **tier** axis on its own: named, named + gitignored, gitignored.
168    ///
169    /// The presets are shortcuts through four points; this and [`ToggleKind`](Self::ToggleKind)
170    /// are what make every *other* point reachable. Without them "show me every cache a rule
171    /// named" is a combination the model can hold and no reader can ask for, which is not what
172    /// "stays expressible" can mean.
173    CycleTiers,
174    /// `i` — show or hide gitignored **files**, leaving both other axes exactly as they were.
175    ///
176    /// Its own key rather than a value on the tier axis, for the reason [`super::lens::Lens::files`]
177    /// gives: a preset moves one axis per step and `all` would have to move two to reach files.
178    /// The request asked for exactly this — includable and excludable independently of ignored
179    /// directories — so the key is the feature rather than a convenience over it.
180    ToggleFiles,
181    /// `u` `d` `b` `c` `n` — turn one **kind** on or off, leaving the others and the tier axis
182    /// exactly as they were.
183    ///
184    /// One key per member rather than a cycle, because a set of five has thirty-two states and
185    /// a cycle through thirty-two is a key nobody can aim.
186    ToggleKind(Kind),
187    /// A printable character, while the prompt has it.
188    ///
189    /// **Not in [`KEYMAP`]**, and it could not be: it stands for every character a terminal
190    /// can report, which is not a list. It is also not a keybinding — typing `v` into a text
191    /// field is content, not a command.
192    Type(char),
193    /// `Backspace` in the prompt.
194    Erase,
195    /// `Delete` in the prompt.
196    EraseAhead,
197    /// `Ctrl-u` — throw the prompt's line away.
198    Wipe,
199    /// `←` `→` `Home` `End` in the prompt.
200    Caret(Motion),
201    /// `Enter` in the prompt — apply the filter.
202    Submit,
203    /// `?` — show or hide the help.
204    Help,
205    /// `Esc` — step back one rung, and never quit.
206    Back,
207    /// A click on what the footer is saying — take it away, and **nothing else**.
208    ///
209    /// The rung [`Back`](Self::Back) takes first, reached on its own rather than through the
210    /// ladder. That distinction is the whole reason this is not just `Back`: a press is
211    /// resolved against the frame the reader aimed at and acted on at the release, so a report
212    /// that goes in between would leave a `Back` to fall through onto the rung below — and the
213    /// rung below is the reader's filter. A dismissal with nothing to dismiss does nothing.
214    Dismiss,
215    /// `←` `→` on a confirmation — move the highlight between the two answers.
216    ///
217    /// Arrows and **not `Tab`**: a modal quietly redefining a key is worst in the one place a
218    /// reader is being asked to be careful.
219    Highlight(Turn),
220    /// `Enter` on a confirmation — answer with whichever one is highlighted.
221    ///
222    /// It does not say *which* answer: the dialog holds both, so this is only "the one I am
223    /// looking at". The highlight starts on cancel, so the key a reader presses to get rid of
224    /// what is in front of them is the safe one.
225    Answer,
226    /// `↑` `↓` inside the help overlay.
227    Scroll(Motion),
228    /// `↑` `↓` on a confirmation — move down the batch it is listing.
229    ///
230    /// Distinct from [`Scroll`](Self::Scroll), which moves a document with no cursor in it:
231    /// here the line under the cursor is a directory, and [`Spare`](Self::Spare) acts on it.
232    Listing(Motion),
233    /// `space` on a confirmation — take the highlighted directory out of the batch.
234    ///
235    /// The tree's own mark key, on the one screen where a reader can see everything they
236    /// marked at once. It is what makes the listing an answer to a surprise rather than a
237    /// notification of one.
238    Spare,
239    /// A key with no meaning here, or a resize. Any event redraws, so this is genuinely
240    /// nothing — the resize included, which needs only the frame.
241    Ignore,
242}
243
244/// Which layer of the screen a binding belongs to.
245#[derive(Clone, Copy, Debug, PartialEq, Eq)]
246pub enum Surface {
247    /// Reserved everywhere. Small on purpose — every key here is one the tree may never take.
248    Global,
249    /// The rows themselves, which is where every key that acts on a directory lives.
250    Tree,
251    /// The help overlay's own keys, reachable only while it is up.
252    Help,
253    /// The filter prompt's, which are the one surface allowed to take a global key: while a
254    /// text field has input there is no chain past it. See [`chain`].
255    Prompt,
256    /// The confirmation dialog's two answers.
257    Confirm,
258}
259
260impl Surface {
261    /// The heading this surface gets in the help overlay.
262    #[must_use]
263    pub fn title(self) -> &'static str {
264        match self {
265            Self::Global => "Everywhere",
266            Self::Tree => "The tree",
267            Self::Help => "This overlay",
268            Self::Prompt => "The filter prompt",
269            Self::Confirm => "A confirmation",
270        }
271    }
272}
273
274/// One key, with the modifier that distinguishes it from the bare version.
275///
276/// `Shift` is deliberately absent: a terminal reports `S` as `Char('S')`, so the shifted
277/// letter is already a different [`KeyCode`]. Recording it as well would mean matching two
278/// spellings of every capital.
279#[derive(Clone, Copy, Debug, PartialEq, Eq)]
280pub struct Chord {
281    /// The key.
282    pub code: KeyCode,
283    /// Whether Control was held.
284    pub ctrl: bool,
285}
286
287impl Chord {
288    const fn plain(code: KeyCode) -> Self {
289        Self { code, ctrl: false }
290    }
291
292    const fn ctrl(letter: char) -> Self {
293        Self {
294            code: KeyCode::Char(letter),
295            ctrl: true,
296        }
297    }
298
299    /// What the reader actually pressed.
300    #[must_use]
301    pub fn of(key: KeyEvent) -> Self {
302        Self {
303            code: key.code,
304            ctrl: key.modifiers.contains(KeyModifiers::CONTROL),
305        }
306    }
307}
308
309impl fmt::Display for Chord {
310    /// How the help overlay spells this key.
311    ///
312    /// Rendered from the chord rather than written beside it, so a binding cannot advertise a
313    /// key it does not answer to — which is the failure a hand-maintained help screen has by
314    /// construction.
315    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
316        if self.ctrl {
317            f.write_str("Ctrl-")?;
318        }
319        match self.code {
320            KeyCode::Char(' ') => f.write_str("space"),
321            KeyCode::Char(letter) => write!(f, "{letter}"),
322            KeyCode::Up => f.write_str("↑"),
323            KeyCode::Down => f.write_str("↓"),
324            KeyCode::Left => f.write_str("←"),
325            KeyCode::Right => f.write_str("→"),
326            KeyCode::Enter => f.write_str("Enter"),
327            KeyCode::Esc => f.write_str("Esc"),
328            KeyCode::Home => f.write_str("Home"),
329            KeyCode::End => f.write_str("End"),
330            KeyCode::PageUp => f.write_str("PgUp"),
331            KeyCode::PageDown => f.write_str("PgDn"),
332            KeyCode::Backspace => f.write_str("Backspace"),
333            KeyCode::Delete => f.write_str("Delete"),
334            other => write!(f, "{other:?}"),
335        }
336    }
337}
338
339/// One row of the keymap: the keys that do a thing, and what the thing is.
340#[derive(Clone, Debug)]
341pub struct Binding {
342    /// Which layer of the screen this binding belongs to.
343    pub surface: Surface,
344    /// Every key that produces this action, in the order the help lists them.
345    pub chords: Vec<Chord>,
346    /// The sentence the help overlay prints. Lower case and imperative, so the generated page
347    /// reads as a list rather than as prose.
348    pub what: &'static str,
349    /// What the key asks for.
350    pub action: Action,
351}
352
353impl Binding {
354    /// The keys, spelled as the overlay spells them.
355    #[must_use]
356    pub fn keys(&self) -> String {
357        self.chords
358            .iter()
359            .map(ToString::to_string)
360            .collect::<Vec<_>>()
361            .join(" ")
362    }
363}
364
365fn bind(surface: Surface, chords: &[Chord], what: &'static str, action: Action) -> Binding {
366    Binding {
367        surface,
368        chords: chords.to_vec(),
369        what,
370        action,
371    }
372}
373
374const fn key(letter: char) -> Chord {
375    Chord::plain(KeyCode::Char(letter))
376}
377
378/// Every binding pristine has, in help order.
379///
380/// Built at first use rather than written as a `const`, because the sort digits are *derived*
381/// from [`Order::ALL`]: a fourth way to order a level arrives with its key already bound,
382/// documented and dispatched.
383static KEYMAP: LazyLock<Vec<Binding>> = LazyLock::new(build);
384
385/// The whole keymap, for anything that renders or checks it.
386#[must_use]
387pub fn bindings() -> &'static [Binding] {
388    &KEYMAP
389}
390
391fn build() -> Vec<Binding> {
392    let mut map = globals();
393    map.extend(tree_keys());
394    map.extend(overlay_keys());
395    map
396}
397
398/// The keys no surface may ever take.
399fn globals() -> Vec<Binding> {
400    use Surface::Global;
401    vec![
402        bind(Global, &[key('q'), Chord::ctrl('c')], "quit", Action::Quit),
403        bind(Global, &[key('?')], "show or hide this help", Action::Help),
404        bind(
405            Global,
406            &[key('/')],
407            "filter by a regex over the whole path",
408            Action::OpenFilter,
409        ),
410        // A report in the footer is one of the levels, and the only one a reader cannot see a
411        // border around — so the footer says so itself, on the frames that have one to take
412        // away, rather than this sentence growing a list the overlay would clip. The order is
413        // in [`super::state::View::step_back`].
414        bind(
415            Global,
416            &[Chord::plain(KeyCode::Esc)],
417            "step back one level — never quits",
418            Action::Back,
419        ),
420    ]
421}
422
423/// The keys that act on a directory. Every one of them belongs to the pane that has a cursor.
424fn tree_keys() -> Vec<Binding> {
425    let mut map = tree_motion();
426    map.extend(tree_verbs());
427    // One key per kind, derived rather than listed, so a fourth kind in #623's vocabulary
428    // arrives already filterable, already documented and already dispatched. The `match` is
429    // what makes that true rather than hopeful: adding a kind stops this compiling.
430    for kind in Kind::ALL {
431        map.push(bind(
432            Surface::Tree,
433            &[key(kind_key(kind))],
434            match kind {
435                Kind::Unrecoverable => "show or hide what nothing brings back",
436                Kind::Dependencies => "show or hide installed dependencies",
437                Kind::Build => "show or hide compiled output",
438                Kind::Cache => "show or hide caches",
439                Kind::Noise => "show or hide logs and system cruft",
440            },
441            Action::ToggleKind(kind),
442        ));
443    }
444    // One digit per order, derived rather than listed, so the key, the help row and the
445    // dispatch for a fourth ordering all arrive together.
446    for (nth, order) in Order::ALL.iter().enumerate() {
447        map.push(bind(
448            Surface::Tree,
449            &[key(digit_for(nth))],
450            // Each sentence says the key turns its own order upside down when pressed again,
451            // because it does — the digits and a click on the heading are one door, and a
452            // reader who does not know that would press `S` and reverse whatever `s` last
453            // left in force instead.
454            match order {
455                Order::Size => "sort by size, biggest subtree first — again to reverse",
456                Order::Path => "sort by path — again to reverse",
457                Order::Age => "sort by age, stalest first — again to reverse",
458            },
459            Action::SortBy(*order),
460        ));
461    }
462    map
463}
464
465/// Moving the cursor, and moving the tree's own shape around it.
466fn tree_motion() -> Vec<Binding> {
467    use Surface::Tree;
468    vec![
469        bind(
470            Tree,
471            &[Chord::plain(KeyCode::Up), key('k')],
472            "move up",
473            Action::Cursor(Motion::Up),
474        ),
475        bind(
476            Tree,
477            &[Chord::plain(KeyCode::Down), key('j')],
478            "move down",
479            Action::Cursor(Motion::Down),
480        ),
481        bind(
482            Tree,
483            &[Chord::plain(KeyCode::PageUp), Chord::ctrl('u')],
484            "up a page",
485            Action::Cursor(Motion::PageUp),
486        ),
487        bind(
488            Tree,
489            &[Chord::plain(KeyCode::PageDown), Chord::ctrl('d')],
490            "down a page",
491            Action::Cursor(Motion::PageDown),
492        ),
493        bind(
494            Tree,
495            &[Chord::plain(KeyCode::Home), key('g')],
496            "to the top",
497            Action::Cursor(Motion::Top),
498        ),
499        bind(
500            Tree,
501            &[Chord::plain(KeyCode::End), key('G')],
502            "to the bottom",
503            Action::Cursor(Motion::Bottom),
504        ),
505        bind(
506            Tree,
507            &[
508                Chord::plain(KeyCode::Right),
509                key('l'),
510                Chord::plain(KeyCode::Enter),
511            ],
512            "open a row, or step into an open one",
513            Action::Expand,
514        ),
515        bind(
516            Tree,
517            &[Chord::plain(KeyCode::Left), key('h')],
518            "close a row, or step out of a closed one",
519            Action::Collapse,
520        ),
521        bind(
522            Tree,
523            &[key('*')],
524            "open or close the whole subtree",
525            Action::ToggleSubtree,
526        ),
527        bind(
528            Tree,
529            &[key('z')],
530            "close every open row, back to the roots",
531            Action::CollapseAll,
532        ),
533    ]
534}
535
536/// What a reader does to what they have found.
537fn tree_verbs() -> Vec<Binding> {
538    use Surface::Tree;
539    vec![
540        bind(
541            Tree,
542            &[key(' ')],
543            "mark this row's whole subtree, or unmark it",
544            Action::Mark,
545        ),
546        bind(
547            Tree,
548            &[key('a')],
549            "mark everything, or clear the marks",
550            Action::MarkAll,
551        ),
552        // The only key here that writes, and the only sentence that has to say it asks.
553        bind(
554            Tree,
555            &[key('x')],
556            "delete what is marked — asks first",
557            Action::Commit,
558        ),
559        bind(
560            Tree,
561            &[key('m')],
562            "show or hide the map beside the tree",
563            Action::ToggleMap,
564        ),
565        bind(
566            Tree,
567            &[key('f')],
568            "the next view: default, dependencies, all-ignored, all",
569            Action::CyclePreset(Turn::Next),
570        ),
571        bind(
572            Tree,
573            &[key('F')],
574            "the view before it",
575            Action::CyclePreset(Turn::Prev),
576        ),
577        bind(
578            Tree,
579            &[key('t')],
580            "which tiers are shown, on its own: named, both, gitignored",
581            Action::CycleTiers,
582        ),
583        bind(
584            Tree,
585            &[key('i')],
586            "show or hide gitignored files, on their own",
587            Action::ToggleFiles,
588        ),
589        bind(Tree, &[key('s')], "the next sort key", Action::CycleSort),
590        bind(
591            Tree,
592            &[key('S')],
593            "the same sort, upside down",
594            Action::ReverseSort,
595        ),
596    ]
597}
598
599/// The three modal surfaces: the filter prompt, the help page, and a confirmation.
600fn overlay_keys() -> Vec<Binding> {
601    let mut map = prompt_keys();
602    map.extend(help_keys());
603    map.extend(confirm_keys());
604    map
605}
606
607/// A text field's keys.
608fn prompt_keys() -> Vec<Binding> {
609    use Surface::Prompt;
610    vec![
611        // ---- The filter prompt ---------------------------------------
612        //
613        // Every key here is one a *text field* needs, which is why this surface is the one
614        // place a global may be shadowed: while the prompt is up there is no chain past it,
615        // so `Ctrl-c` and `Esc` are re-bound here rather than reached through the globals.
616        // Printable characters are not in this list — see [`Action::Type`].
617        bind(
618            Prompt,
619            &[Chord::plain(KeyCode::Enter)],
620            "apply the filter",
621            Action::Submit,
622        ),
623        bind(
624            Prompt,
625            &[Chord::plain(KeyCode::Backspace)],
626            "rub out the character before the caret",
627            Action::Erase,
628        ),
629        bind(
630            Prompt,
631            &[Chord::plain(KeyCode::Delete)],
632            "rub out the character after it",
633            Action::EraseAhead,
634        ),
635        bind(
636            Prompt,
637            &[Chord::ctrl('u')],
638            "throw the line away",
639            Action::Wipe,
640        ),
641        bind(
642            Prompt,
643            &[Chord::plain(KeyCode::Left)],
644            "caret left",
645            Action::Caret(Motion::Up),
646        ),
647        bind(
648            Prompt,
649            &[Chord::plain(KeyCode::Right)],
650            "caret right",
651            Action::Caret(Motion::Down),
652        ),
653        bind(
654            Prompt,
655            &[Chord::plain(KeyCode::Home)],
656            "caret to the start",
657            Action::Caret(Motion::Top),
658        ),
659        bind(
660            Prompt,
661            &[Chord::plain(KeyCode::End)],
662            "caret to the end",
663            Action::Caret(Motion::Bottom),
664        ),
665        bind(
666            Prompt,
667            &[Chord::plain(KeyCode::Esc)],
668            "close the prompt, leaving the filter as it was",
669            Action::Back,
670        ),
671        bind(
672            Prompt,
673            &[Chord::ctrl('c')],
674            "quit — reserved everywhere, this surface included",
675            Action::Quit,
676        ),
677    ]
678}
679
680/// Scrolling a document, and nothing else.
681fn help_keys() -> Vec<Binding> {
682    use Surface::Help;
683    vec![
684        // ---- The help overlay ----------------------------------------
685        //
686        // Scrolling only. `Esc` and `?` close it through their global bindings, which is what
687        // stops this surface from being a second place those two keys are defined.
688        bind(
689            Help,
690            &[Chord::plain(KeyCode::Up), key('k')],
691            "scroll up",
692            Action::Scroll(Motion::Up),
693        ),
694        bind(
695            Help,
696            &[Chord::plain(KeyCode::Down), key('j')],
697            "scroll down",
698            Action::Scroll(Motion::Down),
699        ),
700        bind(
701            Help,
702            &[Chord::plain(KeyCode::PageUp)],
703            "scroll up a page",
704            Action::Scroll(Motion::PageUp),
705        ),
706        bind(
707            Help,
708            &[Chord::plain(KeyCode::PageDown)],
709            "scroll down a page",
710            Action::Scroll(Motion::PageDown),
711        ),
712        bind(
713            Help,
714            &[Chord::plain(KeyCode::Home), key('g')],
715            "to the top",
716            Action::Scroll(Motion::Top),
717        ),
718        bind(
719            Help,
720            &[Chord::plain(KeyCode::End), key('G')],
721            "to the bottom",
722            Action::Scroll(Motion::Bottom),
723        ),
724    ]
725}
726
727/// Two answers, chosen rather than named.
728fn confirm_keys() -> Vec<Binding> {
729    use Surface::Confirm;
730    vec![
731        // ---- A confirmation ------------------------------------------
732        //
733        // Two answers, chosen rather than named: `←` and `→` move the highlight and `Enter`
734        // takes the highlighted one. `Esc` cancels through its global binding. `y` and `n`
735        // are deliberately unbound — a key that acts while being undocumented is the failure
736        // this table exists to make impossible, and the box shows the two answers.
737        bind(
738            Confirm,
739            &[Chord::plain(KeyCode::Left)],
740            "highlight cancel, the left-hand answer",
741            Action::Highlight(Turn::Prev),
742        ),
743        bind(
744            Confirm,
745            &[Chord::plain(KeyCode::Right)],
746            "highlight delete",
747            Action::Highlight(Turn::Next),
748        ),
749        bind(
750            Confirm,
751            &[Chord::plain(KeyCode::Enter)],
752            "answer with the highlighted one",
753            Action::Answer,
754        ),
755        // ---- and the batch it is listing -----------------------------
756        //
757        // `↑`/`↓` rather than `←`/`→`, which are the answers: the two are a list and a pair of
758        // buttons, and a modal that made one key mean both would be worst in the one place a
759        // reader is being asked to be careful.
760        bind(
761            Confirm,
762            &[Chord::plain(KeyCode::Up), key('k')],
763            "up the batch it is listing",
764            Action::Listing(Motion::Up),
765        ),
766        bind(
767            Confirm,
768            &[Chord::plain(KeyCode::Down), key('j')],
769            "down the batch",
770            Action::Listing(Motion::Down),
771        ),
772        bind(
773            Confirm,
774            &[Chord::plain(KeyCode::PageUp)],
775            "up a page of it",
776            Action::Listing(Motion::PageUp),
777        ),
778        bind(
779            Confirm,
780            &[Chord::plain(KeyCode::PageDown)],
781            "down a page",
782            Action::Listing(Motion::PageDown),
783        ),
784        bind(
785            Confirm,
786            &[Chord::plain(KeyCode::Home), key('g')],
787            "to the first entry",
788            Action::Listing(Motion::Top),
789        ),
790        bind(
791            Confirm,
792            &[Chord::plain(KeyCode::End), key('G')],
793            "to the last",
794            Action::Listing(Motion::Bottom),
795        ),
796        bind(
797            Confirm,
798            &[key(' ')],
799            "take the highlighted directory out of the batch",
800            Action::Spare,
801        ),
802    ]
803}
804
805/// Which letter toggles this kind.
806///
807/// The initial of the word the help page prints for it, which is the only mnemonic worth having
808/// — and a `match` rather than a table, so a fourth kind cannot arrive without one.
809const fn kind_key(kind: Kind) -> char {
810    match kind {
811        Kind::Unrecoverable => 'u',
812        Kind::Dependencies => 'd',
813        Kind::Build => 'b',
814        Kind::Cache => 'c',
815        // Not the initial: `n` rather than the `l` of "logs", because `l` is the tree's own
816        // right-hand motion and a key cannot be two things.
817        Kind::Noise => 'n',
818    }
819}
820
821/// The digit key for the `nth` member of a positional group, counting from 1.
822///
823/// Falls back to a key nobody presses rather than wrapping round to `1`, which would silently
824/// give a tenth ordering the first one's key.
825fn digit_for(nth: usize) -> char {
826    u32::try_from(nth)
827        .ok()
828        .and_then(|nth| char::from_digit(nth + 1, 10))
829        .unwrap_or('\0')
830}
831
832/// Which overlay is up, if any.
833///
834/// Named rather than a `bool`, because they differ in the one way routing cares about: help is
835/// a document laid over the screen and the globals still reach past it, while the prompt is a
836/// **text field** where every printable key belongs to the field.
837#[derive(Clone, Copy, Debug, PartialEq, Eq)]
838pub enum Overlay {
839    /// The generated key reference.
840    Help,
841    /// The filter's text field.
842    Prompt,
843    /// A confirmation dialog. Modal the way the other two are — by leaving the tree out of the
844    /// chain — rather than by a rule of its own: the globals still reach past it, which keeps
845    /// `q` and `Ctrl-c` the way out everywhere, and a reader who reached the question by
846    /// accident must not have to guess that it took quitting away.
847    Confirm,
848}
849
850/// The surfaces a key is offered to, in order.
851///
852/// An overlay is modal by omission: while one is up the tree is simply not in the chain, so no
853/// tree key can reach the tree from behind it. The prompt is the one surface that comes
854/// *before* the globals, because a text field owns every key it needs — including `Esc`, and
855/// including the printable letters that would otherwise be tree commands.
856#[must_use]
857pub fn chain(overlay: Option<Overlay>) -> Vec<Surface> {
858    match overlay {
859        Some(Overlay::Prompt) => vec![Surface::Prompt],
860        Some(Overlay::Help) => vec![Surface::Help, Surface::Global],
861        Some(Overlay::Confirm) => vec![Surface::Confirm, Surface::Global],
862        None => vec![Surface::Tree, Surface::Global],
863    }
864}
865
866/// What one terminal event means, here and now.
867#[must_use]
868pub fn action_for(event: &Event, overlay: Option<Overlay>) -> Action {
869    let Event::Key(key) = event else {
870        return Action::Ignore;
871    };
872    // Terminals that speak the kitty protocol report releases as well as presses. Without
873    // this every key would fire twice.
874    if key.kind == KeyEventKind::Release {
875        return Action::Ignore;
876    }
877
878    let chord = Chord::of(*key);
879    if let Some(action) = chain(overlay)
880        .iter()
881        .find_map(|&surface| lookup(surface, chord))
882    {
883        return action;
884    }
885
886    // The prompt's catch-all, and the reason it is here rather than in the table: it stands
887    // for every character a terminal can report. A modifier rules it out — `Ctrl-x` in a text
888    // field is a command nobody bound, not an `x` — which is what keeps the explicit prompt
889    // chords above reachable.
890    match (overlay, chord) {
891        (
892            Some(Overlay::Prompt),
893            Chord {
894                code: KeyCode::Char(character),
895                ctrl: false,
896            },
897        ) => Action::Type(character),
898        _ => Action::Ignore,
899    }
900}
901
902/// What this chord does on this surface, if anything.
903fn lookup(surface: Surface, chord: Chord) -> Option<Action> {
904    bindings()
905        .iter()
906        .find(|binding| binding.surface == surface && binding.chords.contains(&chord))
907        .map(|binding| binding.action)
908}
909
910// ---- the pointer ----------------------------------------------------------------------
911
912/// What the loop has judged one mouse event to be.
913///
914/// Judgements about *previous* events, which the one in hand cannot see — whether a press has
915/// already moved, whether one landed on this spot a moment ago — so they are made by
916/// [`super::Pointer`] and handed here. That keeps [`pointer()`] a pure function of one gesture
917/// and one spot, which is what makes the whole table assertable without a terminal.
918#[derive(Clone, Copy, Debug, PartialEq, Eq)]
919pub enum Gesture {
920    /// A press, or the pointer merely passing over. It **aims and no more**.
921    ///
922    /// The rule the deferred click forced: at the moment the button goes down there is no way
923    /// to tell a click from a drag, so a press that acted would re-sort the tree under a hand
924    /// that was about to select from it. See [`finish`].
925    Aim,
926    /// A press and a release, with no movement between them — the click, at last.
927    Click,
928    /// Two clicks on one **row**, close enough together to be one gesture.
929    Double,
930    /// One turn of the wheel.
931    ///
932    /// The direction belongs to the *action* rather than to the row of the table: the wheel
933    /// does the same thing over a spot whichever way it turns, so both turns are one row and
934    /// [`Gesture::same`] is what keys them together.
935    Wheel(Motion),
936}
937
938impl Gesture {
939    /// Whether this is the gesture a table row describes.
940    ///
941    /// By kind rather than by value, which matters for exactly one variant: the two wheel
942    /// turns are one row of the table, and the row has to be written with *some* direction
943    /// in it.
944    fn same(self, row: Self) -> bool {
945        std::mem::discriminant(&self) == std::mem::discriminant(&row)
946    }
947
948    /// Which way the wheel turned, if it was the wheel.
949    fn motion(self) -> Option<Motion> {
950        match self {
951            Self::Wheel(motion) => Some(motion),
952            _ => None,
953        }
954    }
955}
956
957impl fmt::Display for Gesture {
958    /// How the help overlay spells this gesture.
959    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
960        f.write_str(match self {
961            Self::Aim => "point at",
962            Self::Click => "click",
963            Self::Double => "double-click",
964            Self::Wheel(_) => "wheel over",
965        })
966    }
967}
968
969/// What a gesture landed on, as the table names it — a [`Spot`] with the identity taken out.
970///
971/// The identity is what the *action* needs and what the table must not carry: a row of
972/// [`POINTER`] says "a click on a name selects", and which directory is a fact about the
973/// press rather than about the rule.
974#[derive(Clone, Copy, Debug, PartialEq, Eq)]
975pub enum Target {
976    /// A column heading.
977    Heading,
978    /// A row's mark box.
979    Box,
980    /// A row's expand indicator.
981    Indicator,
982    /// A row's name, and the space around it.
983    Name,
984    /// A row, whichever part of it — what a gesture that is not a click aims at.
985    Row,
986    /// The tree pane, past its last row.
987    Pane,
988    /// The help overlay.
989    Help,
990    /// One of a confirmation's two answers.
991    Answer,
992    /// A confirmation, off both of its answers.
993    Question,
994    /// The filter prompt.
995    Prompt,
996    /// Outside whichever overlay is up.
997    Away,
998    /// The footer, while it is saying what just happened.
999    Notice,
1000    /// Chrome, or a frame with nothing on it.
1001    Elsewhere,
1002}
1003
1004impl Target {
1005    /// Every target there is, for the assertions over the table.
1006    pub const ALL: [Self; 13] = [
1007        Self::Heading,
1008        Self::Box,
1009        Self::Indicator,
1010        Self::Name,
1011        Self::Row,
1012        Self::Pane,
1013        Self::Help,
1014        Self::Answer,
1015        Self::Question,
1016        Self::Prompt,
1017        Self::Away,
1018        Self::Notice,
1019        Self::Elsewhere,
1020    ];
1021
1022    /// What this gesture, landing here, is aimed at.
1023    ///
1024    /// The zones are a **click**'s business and nothing else's. A double click and a wheel
1025    /// turn are both claims about the row rather than about the cell of it under the hand —
1026    /// "double-click a row" is what the gesture means everywhere, and a wheel that scrolled
1027    /// differently over the mark box would be unusable.
1028    fn of(gesture: Gesture, spot: Spot) -> Self {
1029        match spot {
1030            Spot::Heading(_) => Self::Heading,
1031            Spot::Row { zone, .. } if matches!(gesture, Gesture::Click) => match zone {
1032                Zone::Mark => Self::Box,
1033                Zone::Open => Self::Indicator,
1034                Zone::Name => Self::Name,
1035            },
1036            Spot::Row { .. } => Self::Row,
1037            Spot::Tree => Self::Pane,
1038            Spot::Help => Self::Help,
1039            Spot::Answer(_) => Self::Answer,
1040            Spot::Confirm => Self::Question,
1041            Spot::Prompt => Self::Prompt,
1042            Spot::Outside => Self::Away,
1043            Spot::Notice => Self::Notice,
1044            Spot::Nowhere => Self::Elsewhere,
1045        }
1046    }
1047}
1048
1049/// One row of the pointer map: a gesture, where it lands, and what it does there.
1050#[derive(Clone, Debug)]
1051pub struct Pointing {
1052    /// Which gesture.
1053    pub gesture: Gesture,
1054    /// Every spot it means this on. Several, exactly as a [`Binding`] has several chords: the
1055    /// wheel scrolls the tree over the heading, over a row and over the empty pane below.
1056    pub targets: &'static [Target],
1057    /// How the help overlay names where it lands, as the object of [`Gesture`]'s verb.
1058    pub place: &'static str,
1059    /// The sentence the help overlay prints, in the same lower-case imperative the keymap's
1060    /// sentences use.
1061    pub what: &'static str,
1062    /// What it produces. A function of the spot, because the action carries the identity the
1063    /// table deliberately does not.
1064    deed: fn(Gesture, Spot) -> Action,
1065}
1066
1067impl Pointing {
1068    /// The gesture, spelled as the overlay spells it.
1069    #[must_use]
1070    pub fn how(&self) -> String {
1071        format!("{} {}", self.gesture, self.place)
1072    }
1073}
1074
1075const fn point(
1076    gesture: Gesture,
1077    targets: &'static [Target],
1078    place: &'static str,
1079    what: &'static str,
1080    deed: fn(Gesture, Spot) -> Action,
1081) -> Pointing {
1082    Pointing {
1083        gesture,
1084        targets,
1085        place,
1086        what,
1087        deed,
1088    }
1089}
1090
1091/// Every pointer gesture pristine has, in help order.
1092///
1093/// # Only the left button, and the wheel
1094///
1095/// The right and middle are left to the terminal's own menus. A **drag** is deliberately
1096/// absent from this table and that is not an omission: a press that moved is not a click, and
1097/// there is nothing else for it to be here — pristine has no text selection of its own, so a
1098/// drag's only job is to make sure the press it belongs to never becomes one. See
1099/// [`super::Pointer`].
1100static POINTER: LazyLock<Vec<Pointing>> = LazyLock::new(|| {
1101    vec![
1102        point(
1103            Gesture::Click,
1104            &[Target::Heading],
1105            "a column heading",
1106            "order the levels by it — again to turn it upside down",
1107            sort_by,
1108        ),
1109        point(
1110            Gesture::Click,
1111            &[Target::Box],
1112            "a row's box",
1113            "mark this row's whole subtree, or unmark it",
1114            mark_row,
1115        ),
1116        point(
1117            Gesture::Click,
1118            &[Target::Indicator],
1119            "a row's ▸",
1120            "open the row, or close it",
1121            open_row,
1122        ),
1123        point(
1124            Gesture::Click,
1125            &[Target::Name],
1126            "a row's name",
1127            "put the cursor on it",
1128            select,
1129        ),
1130        point(
1131            Gesture::Double,
1132            &[Target::Row],
1133            "a row",
1134            "price this subtree — what --breakdown-under does, on one directory",
1135            price,
1136        ),
1137        point(
1138            Gesture::Aim,
1139            &[Target::Answer],
1140            "a confirmation's answer",
1141            "highlight it, so the button under the pointer is the one a click takes",
1142            aim,
1143        ),
1144        point(
1145            Gesture::Click,
1146            &[Target::Answer],
1147            "a confirmation's answer",
1148            "answer with it — the press has to have landed on it too",
1149            answer,
1150        ),
1151        point(
1152            Gesture::Click,
1153            &[Target::Away],
1154            "outside an overlay",
1155            "close it, exactly as Esc does",
1156            dismiss,
1157        ),
1158        // The same rung `Esc` takes first, reached by hand: a reader who is already pointing
1159        // should not have to go back to the keyboard to be rid of a line they have finished
1160        // reading. Why it is not simply `Back` is in [`Action::Dismiss`].
1161        point(
1162            Gesture::Click,
1163            &[Target::Notice],
1164            "what the footer is saying",
1165            "dismiss what it says",
1166            take_away,
1167        ),
1168        point(
1169            Gesture::Wheel(Motion::Down),
1170            &[Target::Heading, Target::Row, Target::Pane],
1171            "the tree",
1172            "scroll the rows",
1173            scroll_rows,
1174        ),
1175        point(
1176            Gesture::Wheel(Motion::Down),
1177            &[Target::Help],
1178            "the help",
1179            "scroll the page",
1180            scroll_page,
1181        ),
1182        // A confirmation used to be eight static lines, and a wheel over it was rightly
1183        // nothing: it is not a way past something that swallowed the keyboard. It now lists
1184        // the whole batch, which is a document with a cursor in it, and a list nothing can
1185        // scroll while everything beside it scrolls is a list a reader will believe is short.
1186        point(
1187            Gesture::Wheel(Motion::Down),
1188            &[Target::Question, Target::Answer],
1189            "a confirmation",
1190            "move down the batch it is listing",
1191            walk_listing,
1192        ),
1193    ]
1194});
1195
1196/// The whole pointer map, for anything that renders or checks it.
1197#[must_use]
1198pub fn pointing() -> &'static [Pointing] {
1199    &POINTER
1200}
1201
1202/// What one mouse gesture means, given what it landed on.
1203///
1204/// There is no chain here, and that is the point. A key is offered to a list of surfaces
1205/// because a keyboard has no coordinates; a press has them, so the same question is answered
1206/// by [`super::render::hit`] — an overlay covers what it is over, so a press inside one cannot
1207/// also be a press on the tree, and one outside is the dismissal.
1208#[must_use]
1209pub fn pointer(gesture: Gesture, spot: Spot) -> Action {
1210    let target = Target::of(gesture, spot);
1211    pointing()
1212        .iter()
1213        .find(|row| row.gesture.same(gesture) && row.targets.contains(&target))
1214        .map_or(Action::Ignore, |row| (row.deed)(gesture, spot))
1215}
1216
1217/// Letting go of a press that never moved — the click.
1218///
1219/// It acts on what the **press** landed on rather than on what the release did, which is the
1220/// only rule that can be right: the press is the aimed half of the gesture, resolved against
1221/// the frame the reader was looking at when they aimed.
1222///
1223/// A confirmation is the exception, and it is the one surface where the worst case is
1224/// irreversible: there both halves have to land in the same button. That single equality is
1225/// the whole guard, and it is stronger than it looks — [`Spot::Answer`] exists only on a frame
1226/// that drew a question, so a press made *before* the box appeared can never equal one, and a
1227/// box arriving under a held button cannot be answered by the hand that was already down.
1228#[must_use]
1229pub fn finish(pressed: Spot, double: bool, released: Spot) -> Action {
1230    if matches!(pressed, Spot::Answer(_)) && pressed != released {
1231        return Action::Ignore;
1232    }
1233    pointer(
1234        if double {
1235            Gesture::Double
1236        } else {
1237            Gesture::Click
1238        },
1239        pressed,
1240    )
1241}
1242
1243/// A click on a row, with the identity the table left out put back.
1244fn on_row(spot: Spot, deed: fn(crate::tree::NodeId) -> Action) -> Action {
1245    match spot {
1246        Spot::Row { id, .. } => deed(id),
1247        _ => Action::Ignore,
1248    }
1249}
1250
1251fn select(_: Gesture, spot: Spot) -> Action {
1252    on_row(spot, Action::Select)
1253}
1254
1255fn open_row(_: Gesture, spot: Spot) -> Action {
1256    on_row(spot, Action::OpenRow)
1257}
1258
1259fn mark_row(_: Gesture, spot: Spot) -> Action {
1260    on_row(spot, Action::MarkRow)
1261}
1262
1263fn price(_: Gesture, spot: Spot) -> Action {
1264    on_row(spot, Action::Price)
1265}
1266
1267fn sort_by(_: Gesture, spot: Spot) -> Action {
1268    match spot {
1269        Spot::Heading(order) => Action::SortBy(order),
1270        _ => Action::Ignore,
1271    }
1272}
1273
1274/// Aiming at an answer moves the *keyboard's* highlight, which is what keeps the pointer and
1275/// the arrow keys driving one selection rather than two.
1276fn aim(_: Gesture, spot: Spot) -> Action {
1277    match spot {
1278        Spot::Answer(answer) => Action::Highlight(answer.turn()),
1279        _ => Action::Ignore,
1280    }
1281}
1282
1283/// Taking the answer the press aimed at, which the press has already highlighted.
1284fn answer(_: Gesture, spot: Spot) -> Action {
1285    match spot {
1286        Spot::Answer(_) => Action::Answer,
1287        _ => Action::Ignore,
1288    }
1289}
1290
1291fn dismiss(_: Gesture, _: Spot) -> Action {
1292    Action::Back
1293}
1294
1295fn take_away(_: Gesture, _: Spot) -> Action {
1296    Action::Dismiss
1297}
1298
1299fn scroll_rows(gesture: Gesture, _: Spot) -> Action {
1300    gesture.motion().map_or(Action::Ignore, Action::ScrollRows)
1301}
1302
1303fn scroll_page(gesture: Gesture, _: Spot) -> Action {
1304    gesture.motion().map_or(Action::Ignore, Action::Scroll)
1305}
1306
1307fn walk_listing(gesture: Gesture, _: Spot) -> Action {
1308    gesture.motion().map_or(Action::Ignore, Action::Listing)
1309}
1310
1311/// The help page, as headed groups of `(keys, sentence)`.
1312///
1313/// Generated from the tables rather than written, which is what keeps it honest: a binding
1314/// added without a sentence does not compile, and a sentence with no binding cannot exist.
1315/// The pointer's rows are generated the same way and for the same reason — a gesture that
1316/// acts while being undocumented is exactly the failure these tables exist to make
1317/// impossible.
1318#[must_use]
1319pub fn help() -> Vec<(&'static str, Vec<(String, &'static str)>)> {
1320    let surfaces = [
1321        Surface::Global,
1322        Surface::Tree,
1323        Surface::Prompt,
1324        Surface::Confirm,
1325        Surface::Help,
1326    ];
1327    let mut page: Vec<(&'static str, Vec<(String, &'static str)>)> = surfaces
1328        .into_iter()
1329        .map(|surface| {
1330            let rows = bindings()
1331                .iter()
1332                .filter(|binding| binding.surface == surface)
1333                .map(|binding| (binding.keys(), binding.what))
1334                .collect();
1335            (surface.title(), rows)
1336        })
1337        .collect();
1338    page.push((
1339        "The pointer",
1340        pointing().iter().map(|row| (row.how(), row.what)).collect(),
1341    ));
1342    page
1343}
1344
1345#[cfg(test)]
1346mod tests {
1347    use super::{
1348        Action, Chord, Gesture, Motion, Overlay, Spot, Surface, Target, Turn, Zone, action_for,
1349        bindings, finish, help, lookup, pointer, pointing,
1350    };
1351    use crate::rules::Kind;
1352    use crate::tree::{NodeId, Order};
1353    use crate::tui::state::Answer;
1354    use ratatui::crossterm::event::{
1355        Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers,
1356    };
1357
1358    fn press(code: KeyCode) -> Event {
1359        Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
1360    }
1361
1362    fn letter(letter: char) -> Event {
1363        press(KeyCode::Char(letter))
1364    }
1365
1366    /// A spot of each kind, for the assertions that sweep the whole pointer map.
1367    ///
1368    /// One per [`Target`], in the same order, so a target added without a spot to reach it
1369    /// with does not compile.
1370    fn spot(target: Target) -> Spot {
1371        const ROW: NodeId = 7;
1372        match target {
1373            Target::Heading => Spot::Heading(Order::Size),
1374            Target::Box => Spot::Row {
1375                id: ROW,
1376                zone: Zone::Mark,
1377            },
1378            Target::Indicator => Spot::Row {
1379                id: ROW,
1380                zone: Zone::Open,
1381            },
1382            // A row aimed at by something other than a click resolves to `Target::Row`
1383            // whichever zone it names, so the name is as good a stand-in as any.
1384            Target::Name | Target::Row => Spot::Row {
1385                id: ROW,
1386                zone: Zone::Name,
1387            },
1388            Target::Pane => Spot::Tree,
1389            Target::Help => Spot::Help,
1390            Target::Answer => Spot::Answer(Answer::Delete),
1391            Target::Question => Spot::Confirm,
1392            Target::Prompt => Spot::Prompt,
1393            Target::Away => Spot::Outside,
1394            Target::Notice => Spot::Notice,
1395            Target::Elsewhere => Spot::Nowhere,
1396        }
1397    }
1398
1399    #[test]
1400    fn the_tree_never_shadows_a_global_key() {
1401        // The guarantee behind the chain being a list of surfaces rather than a pile of
1402        // conditions: `q` means quit wherever a reader presses it, and no future binding can
1403        // quietly take it away on one screen.
1404        for binding in bindings() {
1405            if binding.surface == Surface::Global {
1406                continue;
1407            }
1408            for chord in &binding.chords {
1409                let shadowed =
1410                    binding.surface != Surface::Prompt && lookup(Surface::Global, *chord).is_some();
1411                assert!(
1412                    !shadowed,
1413                    "{:?} takes {chord}, which is global",
1414                    binding.surface
1415                );
1416            }
1417        }
1418    }
1419
1420    #[test]
1421    fn every_binding_has_a_sentence_and_at_least_one_key() {
1422        for binding in bindings() {
1423            assert!(!binding.chords.is_empty(), "{binding:?} binds nothing");
1424            assert!(!binding.what.is_empty(), "{binding:?} says nothing");
1425            assert!(
1426                binding.what.starts_with(|c: char| c.is_lowercase()),
1427                "{:?} is not a lower-case imperative",
1428                binding.what
1429            );
1430        }
1431    }
1432
1433    #[test]
1434    fn no_surface_binds_one_key_to_two_things() {
1435        for binding in bindings() {
1436            for chord in &binding.chords {
1437                let claimants = bindings()
1438                    .iter()
1439                    .filter(|other| {
1440                        other.surface == binding.surface && other.chords.contains(chord)
1441                    })
1442                    .count();
1443                assert_eq!(
1444                    claimants, 1,
1445                    "{chord} is bound twice on {:?}",
1446                    binding.surface
1447                );
1448            }
1449        }
1450    }
1451
1452    #[test]
1453    fn the_help_page_lists_every_binding_and_every_gesture_there_is() {
1454        let listed: usize = help().iter().map(|(_, rows)| rows.len()).sum();
1455        assert_eq!(listed, bindings().len() + pointing().len());
1456    }
1457
1458    #[test]
1459    fn a_tree_key_cannot_reach_the_tree_from_behind_an_overlay() {
1460        assert_eq!(action_for(&letter('x'), None), Action::Commit);
1461        // The dangerous key, in particular: a reader reading the help page must not be able
1462        // to delete their marked batch by pressing the key their eye is on.
1463        assert_eq!(
1464            action_for(&letter('x'), Some(Overlay::Help)),
1465            Action::Ignore
1466        );
1467        assert_eq!(
1468            action_for(&letter('x'), Some(Overlay::Confirm)),
1469            Action::Ignore
1470        );
1471    }
1472
1473    #[test]
1474    fn a_printable_key_is_content_while_the_prompt_is_up() {
1475        assert_eq!(
1476            action_for(&letter('x'), Some(Overlay::Prompt)),
1477            Action::Type('x')
1478        );
1479        assert_eq!(
1480            action_for(&letter(' '), Some(Overlay::Prompt)),
1481            Action::Type(' ')
1482        );
1483        // …and a chord is not content, so the prompt's own editing keys stay reachable.
1484        assert_eq!(
1485            action_for(
1486                &Event::Key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)),
1487                Some(Overlay::Prompt)
1488            ),
1489            Action::Wipe
1490        );
1491    }
1492
1493    #[test]
1494    fn quitting_is_reachable_from_every_surface_including_the_text_field() {
1495        for overlay in [
1496            None,
1497            Some(Overlay::Help),
1498            Some(Overlay::Confirm),
1499            Some(Overlay::Prompt),
1500        ] {
1501            let ctrl_c = Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
1502            assert_eq!(action_for(&ctrl_c, overlay), Action::Quit, "{overlay:?}");
1503        }
1504    }
1505
1506    #[test]
1507    fn a_ctrl_chord_is_not_the_bare_letter() {
1508        // `Ctrl-d` is half a page and `d` is nothing at all; matching on the code alone
1509        // would make those the same key, recoverable only by checking the modifier first.
1510        assert_eq!(
1511            action_for(
1512                &Event::Key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)),
1513                None
1514            ),
1515            Action::Cursor(Motion::PageDown)
1516        );
1517        // …and the bare letter is its own binding, which is exactly why the modifier has to
1518        // be checked first: `d` toggles a kind and `Ctrl-d` moves half a page.
1519        assert_eq!(
1520            action_for(&letter('d'), None),
1521            Action::ToggleKind(Kind::Dependencies)
1522        );
1523    }
1524
1525    #[test]
1526    fn a_key_release_is_not_a_second_press() {
1527        let release = Event::Key(KeyEvent::new_with_kind_and_state(
1528            KeyCode::Char('x'),
1529            KeyModifiers::NONE,
1530            KeyEventKind::Release,
1531            KeyEventState::NONE,
1532        ));
1533        assert_eq!(action_for(&release, None), Action::Ignore);
1534    }
1535
1536    #[test]
1537    fn a_chord_spells_itself_the_way_the_help_page_prints_it() {
1538        assert_eq!(Chord::plain(KeyCode::Char(' ')).to_string(), "space");
1539        assert_eq!(Chord::ctrl('u').to_string(), "Ctrl-u");
1540        assert_eq!(Chord::plain(KeyCode::Up).to_string(), "↑");
1541    }
1542
1543    // ---- the pointer ------------------------------------------------------------------
1544
1545    #[test]
1546    fn no_row_of_the_pointer_map_is_dead() {
1547        // The table *is* the dispatcher, so "a gesture that acts is a gesture the help page
1548        // lists" holds by construction. What does not is the other direction: a row wired to
1549        // the wrong shape of spot — `sort_by` under a `Row` target — would document a
1550        // gesture that quietly does nothing. Each row is asked for its own gesture on each
1551        // of its own targets, which is exactly the sentence the help page prints.
1552        for row in pointing() {
1553            for &target in row.targets {
1554                assert_ne!(
1555                    pointer(row.gesture, spot(target)),
1556                    Action::Ignore,
1557                    "{:?} does nothing, and the help page says {:?}",
1558                    row.how(),
1559                    row.what
1560                );
1561            }
1562        }
1563    }
1564
1565    #[test]
1566    fn no_spot_gives_one_gesture_two_meanings() {
1567        for gesture in [
1568            Gesture::Aim,
1569            Gesture::Click,
1570            Gesture::Double,
1571            Gesture::Wheel(Motion::Down),
1572        ] {
1573            for target in Target::ALL {
1574                let claimants = pointing()
1575                    .iter()
1576                    .filter(|row| row.gesture.same(gesture) && row.targets.contains(&target))
1577                    .count();
1578                assert!(claimants <= 1, "{gesture} {target:?} is bound twice");
1579            }
1580        }
1581    }
1582
1583    #[test]
1584    fn a_row_is_named_by_its_directory_and_never_by_where_it_is_on_the_screen() {
1585        // The rule the whole model turns on. The action a press produces carries the
1586        // `NodeId`, so the row it acts on is the one that was pressed even after a price
1587        // lands and re-sorts the level under the hand.
1588        let spot = Spot::Row {
1589            id: 42,
1590            zone: Zone::Name,
1591        };
1592        assert_eq!(pointer(Gesture::Click, spot), Action::Select(42));
1593        assert_eq!(pointer(Gesture::Double, spot), Action::Price(42));
1594    }
1595
1596    #[test]
1597    fn the_zones_of_a_row_are_a_clicks_business_and_nothing_elses() {
1598        // Each part of a row does its own thing under a click…
1599        for (zone, action) in [
1600            (Zone::Mark, Action::MarkRow(3)),
1601            (Zone::Open, Action::OpenRow(3)),
1602            (Zone::Name, Action::Select(3)),
1603        ] {
1604            assert_eq!(
1605                pointer(Gesture::Click, Spot::Row { id: 3, zone }),
1606                action,
1607                "{zone:?}"
1608            );
1609            // …and every part of it is the same row to a double click and to the wheel. A
1610            // wheel that scrolled differently over the mark box would be unusable, and
1611            // "double-click a row" is what the gesture means everywhere.
1612            assert_eq!(
1613                pointer(Gesture::Double, Spot::Row { id: 3, zone }),
1614                Action::Price(3),
1615                "{zone:?}"
1616            );
1617            assert_eq!(
1618                pointer(Gesture::Wheel(Motion::Down), Spot::Row { id: 3, zone }),
1619                Action::ScrollRows(Motion::Down),
1620                "{zone:?}"
1621            );
1622        }
1623    }
1624
1625    #[test]
1626    fn a_press_aims_and_does_no_more_than_aim() {
1627        // The rule the deferred click exists for: at the moment the button goes down there
1628        // is no telling a click from a drag, so a press that acted would re-sort the tree
1629        // under the hand that was about to select from it.
1630        for target in Target::ALL {
1631            let aimed = pointer(Gesture::Aim, spot(target));
1632            let expected = match target {
1633                // The one exception, and it is not really one: highlighting the button under
1634                // the pointer moves a selection, which a hover would have moved too.
1635                Target::Answer => Action::Highlight(Turn::Next),
1636                _ => Action::Ignore,
1637            };
1638            assert_eq!(aimed, expected, "{target:?}");
1639        }
1640    }
1641
1642    #[test]
1643    fn the_click_acts_on_what_the_press_was_aimed_at() {
1644        // The release's own spot is not the target: a hand that lets go a cell off the
1645        // heading it pressed has still clicked that heading, and the press is the aimed half
1646        // of the gesture.
1647        assert_eq!(
1648            finish(Spot::Heading(Order::Age), false, Spot::Nowhere),
1649            Action::SortBy(Order::Age)
1650        );
1651    }
1652
1653    #[test]
1654    fn a_confirmation_is_the_one_surface_that_needs_both_halves_in_the_same_button() {
1655        let delete = Spot::Answer(Answer::Delete);
1656        assert_eq!(finish(delete, false, delete), Action::Answer);
1657
1658        // Landing near an answer, or on the other one, is a miss — and so is a press made
1659        // *before* the box appeared, which is the case that matters: `Spot::Answer` exists
1660        // only on a frame that drew a question, so such a press can never equal one, and a
1661        // box arriving under a held button cannot be answered by the hand already down.
1662        assert_eq!(finish(delete, false, Spot::Confirm), Action::Ignore);
1663        assert_eq!(
1664            finish(delete, false, Spot::Answer(Answer::Cancel)),
1665            Action::Ignore
1666        );
1667        assert_eq!(finish(Spot::Tree, false, delete), Action::Ignore);
1668    }
1669
1670    #[test]
1671    fn a_press_inside_an_overlay_that_lands_on_nothing_does_nothing() {
1672        // Deliberately not a dismissal: a press on a caveat line or on the prompt's own text
1673        // is a miss, and closing the thing being read is the one response that loses work.
1674        for spot in [Spot::Help, Spot::Prompt, Spot::Confirm, Spot::Nowhere] {
1675            assert_eq!(pointer(Gesture::Click, spot), Action::Ignore, "{spot:?}");
1676        }
1677        // Outside it is the dismissal, which is `Esc`'s own action rather than a second one.
1678        assert_eq!(pointer(Gesture::Click, Spot::Outside), Action::Back);
1679    }
1680
1681    #[test]
1682    fn the_wheel_means_the_nearest_thing_to_scrolling_each_surface_has() {
1683        // Over the tree it moves the viewport; over the help overlay it moves a document.
1684        // Those are genuinely different verbs, and over a question it is neither — a wheel
1685        // is not a way past something that swallowed the keyboard.
1686        assert_eq!(
1687            pointer(Gesture::Wheel(Motion::Up), Spot::Tree),
1688            Action::ScrollRows(Motion::Up)
1689        );
1690        assert_eq!(
1691            pointer(Gesture::Wheel(Motion::Down), Spot::Help),
1692            Action::Scroll(Motion::Down)
1693        );
1694        // Over a confirmation it moves down the batch the box is listing. That is not a way
1695        // past something that swallowed the keyboard — the answers are untouched — it is the
1696        // one verb a list of eight thousand directories has, and a list nothing can scroll
1697        // while everything beside it scrolls is a list a reader will believe is short.
1698        for spot in [Spot::Confirm, Spot::Answer(Answer::Delete)] {
1699            assert_eq!(
1700                pointer(Gesture::Wheel(Motion::Down), spot),
1701                Action::Listing(Motion::Down),
1702                "{spot:?}"
1703            );
1704        }
1705        assert_eq!(
1706            pointer(Gesture::Wheel(Motion::Down), Spot::Nowhere),
1707            Action::Ignore
1708        );
1709    }
1710
1711    #[test]
1712    fn the_help_page_spells_a_gesture_the_way_a_reader_would_say_it() {
1713        let page = help();
1714        let (title, rows) = page.last().unwrap();
1715        assert_eq!(*title, "The pointer");
1716        let said: Vec<&str> = rows.iter().map(|(how, _)| how.as_str()).collect();
1717        assert!(said.contains(&"double-click a row"), "{said:?}");
1718        assert!(said.contains(&"wheel over the tree"), "{said:?}");
1719        assert!(said.contains(&"click a column heading"), "{said:?}");
1720    }
1721}