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}