use std::fmt;
use std::sync::LazyLock;
use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use super::render::{Spot, Zone};
use crate::rules::Kind;
use crate::tree::Order;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Motion {
Up,
Down,
PageUp,
PageDown,
Top,
Bottom,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Turn {
Next,
Prev,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
Quit,
Cursor(Motion),
Expand,
Collapse,
ToggleSubtree,
CollapseAll,
Mark,
MarkAll,
Commit,
ToggleMap,
CycleSort,
ReverseSort,
SortBy(Order),
Select(crate::tree::NodeId),
OpenRow(crate::tree::NodeId),
MarkRow(crate::tree::NodeId),
Price(crate::tree::NodeId),
ScrollRows(Motion),
OpenFilter,
CyclePreset(Turn),
CycleTiers,
ToggleFiles,
ToggleKind(Kind),
Type(char),
Erase,
EraseAhead,
Wipe,
Caret(Motion),
Submit,
Help,
Back,
Dismiss,
Highlight(Turn),
Answer,
Scroll(Motion),
Listing(Motion),
Spare,
Ignore,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Surface {
Global,
Tree,
Help,
Prompt,
Confirm,
}
impl Surface {
#[must_use]
pub fn title(self) -> &'static str {
match self {
Self::Global => "Everywhere",
Self::Tree => "The tree",
Self::Help => "This overlay",
Self::Prompt => "The filter prompt",
Self::Confirm => "A confirmation",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Chord {
pub code: KeyCode,
pub ctrl: bool,
}
impl Chord {
const fn plain(code: KeyCode) -> Self {
Self { code, ctrl: false }
}
const fn ctrl(letter: char) -> Self {
Self {
code: KeyCode::Char(letter),
ctrl: true,
}
}
#[must_use]
pub fn of(key: KeyEvent) -> Self {
Self {
code: key.code,
ctrl: key.modifiers.contains(KeyModifiers::CONTROL),
}
}
}
impl fmt::Display for Chord {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.ctrl {
f.write_str("Ctrl-")?;
}
match self.code {
KeyCode::Char(' ') => f.write_str("space"),
KeyCode::Char(letter) => write!(f, "{letter}"),
KeyCode::Up => f.write_str("↑"),
KeyCode::Down => f.write_str("↓"),
KeyCode::Left => f.write_str("←"),
KeyCode::Right => f.write_str("→"),
KeyCode::Enter => f.write_str("Enter"),
KeyCode::Esc => f.write_str("Esc"),
KeyCode::Home => f.write_str("Home"),
KeyCode::End => f.write_str("End"),
KeyCode::PageUp => f.write_str("PgUp"),
KeyCode::PageDown => f.write_str("PgDn"),
KeyCode::Backspace => f.write_str("Backspace"),
KeyCode::Delete => f.write_str("Delete"),
other => write!(f, "{other:?}"),
}
}
}
#[derive(Clone, Debug)]
pub struct Binding {
pub surface: Surface,
pub chords: Vec<Chord>,
pub what: &'static str,
pub action: Action,
}
impl Binding {
#[must_use]
pub fn keys(&self) -> String {
self.chords
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(" ")
}
}
fn bind(surface: Surface, chords: &[Chord], what: &'static str, action: Action) -> Binding {
Binding {
surface,
chords: chords.to_vec(),
what,
action,
}
}
const fn key(letter: char) -> Chord {
Chord::plain(KeyCode::Char(letter))
}
static KEYMAP: LazyLock<Vec<Binding>> = LazyLock::new(build);
#[must_use]
pub fn bindings() -> &'static [Binding] {
&KEYMAP
}
fn build() -> Vec<Binding> {
let mut map = globals();
map.extend(tree_keys());
map.extend(overlay_keys());
map
}
fn globals() -> Vec<Binding> {
use Surface::Global;
vec![
bind(Global, &[key('q'), Chord::ctrl('c')], "quit", Action::Quit),
bind(Global, &[key('?')], "show or hide this help", Action::Help),
bind(
Global,
&[key('/')],
"filter by a regex over the whole path",
Action::OpenFilter,
),
bind(
Global,
&[Chord::plain(KeyCode::Esc)],
"step back one level — never quits",
Action::Back,
),
]
}
fn tree_keys() -> Vec<Binding> {
let mut map = tree_motion();
map.extend(tree_verbs());
for kind in Kind::ALL {
map.push(bind(
Surface::Tree,
&[key(kind_key(kind))],
match kind {
Kind::Unrecoverable => "show or hide what nothing brings back",
Kind::Dependencies => "show or hide installed dependencies",
Kind::Build => "show or hide compiled output",
Kind::Cache => "show or hide caches",
Kind::Noise => "show or hide logs and system cruft",
},
Action::ToggleKind(kind),
));
}
for (nth, order) in Order::ALL.iter().enumerate() {
map.push(bind(
Surface::Tree,
&[key(digit_for(nth))],
match order {
Order::Size => "sort by size, biggest subtree first — again to reverse",
Order::Path => "sort by path — again to reverse",
Order::Age => "sort by age, stalest first — again to reverse",
},
Action::SortBy(*order),
));
}
map
}
fn tree_motion() -> Vec<Binding> {
use Surface::Tree;
vec![
bind(
Tree,
&[Chord::plain(KeyCode::Up), key('k')],
"move up",
Action::Cursor(Motion::Up),
),
bind(
Tree,
&[Chord::plain(KeyCode::Down), key('j')],
"move down",
Action::Cursor(Motion::Down),
),
bind(
Tree,
&[Chord::plain(KeyCode::PageUp), Chord::ctrl('u')],
"up a page",
Action::Cursor(Motion::PageUp),
),
bind(
Tree,
&[Chord::plain(KeyCode::PageDown), Chord::ctrl('d')],
"down a page",
Action::Cursor(Motion::PageDown),
),
bind(
Tree,
&[Chord::plain(KeyCode::Home), key('g')],
"to the top",
Action::Cursor(Motion::Top),
),
bind(
Tree,
&[Chord::plain(KeyCode::End), key('G')],
"to the bottom",
Action::Cursor(Motion::Bottom),
),
bind(
Tree,
&[
Chord::plain(KeyCode::Right),
key('l'),
Chord::plain(KeyCode::Enter),
],
"open a row, or step into an open one",
Action::Expand,
),
bind(
Tree,
&[Chord::plain(KeyCode::Left), key('h')],
"close a row, or step out of a closed one",
Action::Collapse,
),
bind(
Tree,
&[key('*')],
"open or close the whole subtree",
Action::ToggleSubtree,
),
bind(
Tree,
&[key('z')],
"close every open row, back to the roots",
Action::CollapseAll,
),
]
}
fn tree_verbs() -> Vec<Binding> {
use Surface::Tree;
vec![
bind(
Tree,
&[key(' ')],
"mark this row's whole subtree, or unmark it",
Action::Mark,
),
bind(
Tree,
&[key('a')],
"mark everything, or clear the marks",
Action::MarkAll,
),
bind(
Tree,
&[key('x')],
"delete what is marked — asks first",
Action::Commit,
),
bind(
Tree,
&[key('m')],
"show or hide the map beside the tree",
Action::ToggleMap,
),
bind(
Tree,
&[key('f')],
"the next view: default, dependencies, all-ignored, all",
Action::CyclePreset(Turn::Next),
),
bind(
Tree,
&[key('F')],
"the view before it",
Action::CyclePreset(Turn::Prev),
),
bind(
Tree,
&[key('t')],
"which tiers are shown, on its own: named, both, gitignored",
Action::CycleTiers,
),
bind(
Tree,
&[key('i')],
"show or hide gitignored files, on their own",
Action::ToggleFiles,
),
bind(Tree, &[key('s')], "the next sort key", Action::CycleSort),
bind(
Tree,
&[key('S')],
"the same sort, upside down",
Action::ReverseSort,
),
]
}
fn overlay_keys() -> Vec<Binding> {
let mut map = prompt_keys();
map.extend(help_keys());
map.extend(confirm_keys());
map
}
fn prompt_keys() -> Vec<Binding> {
use Surface::Prompt;
vec![
bind(
Prompt,
&[Chord::plain(KeyCode::Enter)],
"apply the filter",
Action::Submit,
),
bind(
Prompt,
&[Chord::plain(KeyCode::Backspace)],
"rub out the character before the caret",
Action::Erase,
),
bind(
Prompt,
&[Chord::plain(KeyCode::Delete)],
"rub out the character after it",
Action::EraseAhead,
),
bind(
Prompt,
&[Chord::ctrl('u')],
"throw the line away",
Action::Wipe,
),
bind(
Prompt,
&[Chord::plain(KeyCode::Left)],
"caret left",
Action::Caret(Motion::Up),
),
bind(
Prompt,
&[Chord::plain(KeyCode::Right)],
"caret right",
Action::Caret(Motion::Down),
),
bind(
Prompt,
&[Chord::plain(KeyCode::Home)],
"caret to the start",
Action::Caret(Motion::Top),
),
bind(
Prompt,
&[Chord::plain(KeyCode::End)],
"caret to the end",
Action::Caret(Motion::Bottom),
),
bind(
Prompt,
&[Chord::plain(KeyCode::Esc)],
"close the prompt, leaving the filter as it was",
Action::Back,
),
bind(
Prompt,
&[Chord::ctrl('c')],
"quit — reserved everywhere, this surface included",
Action::Quit,
),
]
}
fn help_keys() -> Vec<Binding> {
use Surface::Help;
vec![
bind(
Help,
&[Chord::plain(KeyCode::Up), key('k')],
"scroll up",
Action::Scroll(Motion::Up),
),
bind(
Help,
&[Chord::plain(KeyCode::Down), key('j')],
"scroll down",
Action::Scroll(Motion::Down),
),
bind(
Help,
&[Chord::plain(KeyCode::PageUp)],
"scroll up a page",
Action::Scroll(Motion::PageUp),
),
bind(
Help,
&[Chord::plain(KeyCode::PageDown)],
"scroll down a page",
Action::Scroll(Motion::PageDown),
),
bind(
Help,
&[Chord::plain(KeyCode::Home), key('g')],
"to the top",
Action::Scroll(Motion::Top),
),
bind(
Help,
&[Chord::plain(KeyCode::End), key('G')],
"to the bottom",
Action::Scroll(Motion::Bottom),
),
]
}
fn confirm_keys() -> Vec<Binding> {
use Surface::Confirm;
vec![
bind(
Confirm,
&[Chord::plain(KeyCode::Left)],
"highlight cancel, the left-hand answer",
Action::Highlight(Turn::Prev),
),
bind(
Confirm,
&[Chord::plain(KeyCode::Right)],
"highlight delete",
Action::Highlight(Turn::Next),
),
bind(
Confirm,
&[Chord::plain(KeyCode::Enter)],
"answer with the highlighted one",
Action::Answer,
),
bind(
Confirm,
&[Chord::plain(KeyCode::Up), key('k')],
"up the batch it is listing",
Action::Listing(Motion::Up),
),
bind(
Confirm,
&[Chord::plain(KeyCode::Down), key('j')],
"down the batch",
Action::Listing(Motion::Down),
),
bind(
Confirm,
&[Chord::plain(KeyCode::PageUp)],
"up a page of it",
Action::Listing(Motion::PageUp),
),
bind(
Confirm,
&[Chord::plain(KeyCode::PageDown)],
"down a page",
Action::Listing(Motion::PageDown),
),
bind(
Confirm,
&[Chord::plain(KeyCode::Home), key('g')],
"to the first entry",
Action::Listing(Motion::Top),
),
bind(
Confirm,
&[Chord::plain(KeyCode::End), key('G')],
"to the last",
Action::Listing(Motion::Bottom),
),
bind(
Confirm,
&[key(' ')],
"take the highlighted directory out of the batch",
Action::Spare,
),
]
}
const fn kind_key(kind: Kind) -> char {
match kind {
Kind::Unrecoverable => 'u',
Kind::Dependencies => 'd',
Kind::Build => 'b',
Kind::Cache => 'c',
Kind::Noise => 'n',
}
}
fn digit_for(nth: usize) -> char {
u32::try_from(nth)
.ok()
.and_then(|nth| char::from_digit(nth + 1, 10))
.unwrap_or('\0')
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Overlay {
Help,
Prompt,
Confirm,
}
#[must_use]
pub fn chain(overlay: Option<Overlay>) -> Vec<Surface> {
match overlay {
Some(Overlay::Prompt) => vec![Surface::Prompt],
Some(Overlay::Help) => vec![Surface::Help, Surface::Global],
Some(Overlay::Confirm) => vec![Surface::Confirm, Surface::Global],
None => vec![Surface::Tree, Surface::Global],
}
}
#[must_use]
pub fn action_for(event: &Event, overlay: Option<Overlay>) -> Action {
let Event::Key(key) = event else {
return Action::Ignore;
};
if key.kind == KeyEventKind::Release {
return Action::Ignore;
}
let chord = Chord::of(*key);
if let Some(action) = chain(overlay)
.iter()
.find_map(|&surface| lookup(surface, chord))
{
return action;
}
match (overlay, chord) {
(
Some(Overlay::Prompt),
Chord {
code: KeyCode::Char(character),
ctrl: false,
},
) => Action::Type(character),
_ => Action::Ignore,
}
}
fn lookup(surface: Surface, chord: Chord) -> Option<Action> {
bindings()
.iter()
.find(|binding| binding.surface == surface && binding.chords.contains(&chord))
.map(|binding| binding.action)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Gesture {
Aim,
Click,
Double,
Wheel(Motion),
}
impl Gesture {
fn same(self, row: Self) -> bool {
std::mem::discriminant(&self) == std::mem::discriminant(&row)
}
fn motion(self) -> Option<Motion> {
match self {
Self::Wheel(motion) => Some(motion),
_ => None,
}
}
}
impl fmt::Display for Gesture {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Aim => "point at",
Self::Click => "click",
Self::Double => "double-click",
Self::Wheel(_) => "wheel over",
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Target {
Heading,
Box,
Indicator,
Name,
Row,
Pane,
Help,
Answer,
Question,
Prompt,
Away,
Notice,
Elsewhere,
}
impl Target {
pub const ALL: [Self; 13] = [
Self::Heading,
Self::Box,
Self::Indicator,
Self::Name,
Self::Row,
Self::Pane,
Self::Help,
Self::Answer,
Self::Question,
Self::Prompt,
Self::Away,
Self::Notice,
Self::Elsewhere,
];
fn of(gesture: Gesture, spot: Spot) -> Self {
match spot {
Spot::Heading(_) => Self::Heading,
Spot::Row { zone, .. } if matches!(gesture, Gesture::Click) => match zone {
Zone::Mark => Self::Box,
Zone::Open => Self::Indicator,
Zone::Name => Self::Name,
},
Spot::Row { .. } => Self::Row,
Spot::Tree => Self::Pane,
Spot::Help => Self::Help,
Spot::Answer(_) => Self::Answer,
Spot::Confirm => Self::Question,
Spot::Prompt => Self::Prompt,
Spot::Outside => Self::Away,
Spot::Notice => Self::Notice,
Spot::Nowhere => Self::Elsewhere,
}
}
}
#[derive(Clone, Debug)]
pub struct Pointing {
pub gesture: Gesture,
pub targets: &'static [Target],
pub place: &'static str,
pub what: &'static str,
deed: fn(Gesture, Spot) -> Action,
}
impl Pointing {
#[must_use]
pub fn how(&self) -> String {
format!("{} {}", self.gesture, self.place)
}
}
const fn point(
gesture: Gesture,
targets: &'static [Target],
place: &'static str,
what: &'static str,
deed: fn(Gesture, Spot) -> Action,
) -> Pointing {
Pointing {
gesture,
targets,
place,
what,
deed,
}
}
static POINTER: LazyLock<Vec<Pointing>> = LazyLock::new(|| {
vec![
point(
Gesture::Click,
&[Target::Heading],
"a column heading",
"order the levels by it — again to turn it upside down",
sort_by,
),
point(
Gesture::Click,
&[Target::Box],
"a row's box",
"mark this row's whole subtree, or unmark it",
mark_row,
),
point(
Gesture::Click,
&[Target::Indicator],
"a row's ▸",
"open the row, or close it",
open_row,
),
point(
Gesture::Click,
&[Target::Name],
"a row's name",
"put the cursor on it",
select,
),
point(
Gesture::Double,
&[Target::Row],
"a row",
"price this subtree — what --breakdown-under does, on one directory",
price,
),
point(
Gesture::Aim,
&[Target::Answer],
"a confirmation's answer",
"highlight it, so the button under the pointer is the one a click takes",
aim,
),
point(
Gesture::Click,
&[Target::Answer],
"a confirmation's answer",
"answer with it — the press has to have landed on it too",
answer,
),
point(
Gesture::Click,
&[Target::Away],
"outside an overlay",
"close it, exactly as Esc does",
dismiss,
),
point(
Gesture::Click,
&[Target::Notice],
"what the footer is saying",
"dismiss what it says",
take_away,
),
point(
Gesture::Wheel(Motion::Down),
&[Target::Heading, Target::Row, Target::Pane],
"the tree",
"scroll the rows",
scroll_rows,
),
point(
Gesture::Wheel(Motion::Down),
&[Target::Help],
"the help",
"scroll the page",
scroll_page,
),
point(
Gesture::Wheel(Motion::Down),
&[Target::Question, Target::Answer],
"a confirmation",
"move down the batch it is listing",
walk_listing,
),
]
});
#[must_use]
pub fn pointing() -> &'static [Pointing] {
&POINTER
}
#[must_use]
pub fn pointer(gesture: Gesture, spot: Spot) -> Action {
let target = Target::of(gesture, spot);
pointing()
.iter()
.find(|row| row.gesture.same(gesture) && row.targets.contains(&target))
.map_or(Action::Ignore, |row| (row.deed)(gesture, spot))
}
#[must_use]
pub fn finish(pressed: Spot, double: bool, released: Spot) -> Action {
if matches!(pressed, Spot::Answer(_)) && pressed != released {
return Action::Ignore;
}
pointer(
if double {
Gesture::Double
} else {
Gesture::Click
},
pressed,
)
}
fn on_row(spot: Spot, deed: fn(crate::tree::NodeId) -> Action) -> Action {
match spot {
Spot::Row { id, .. } => deed(id),
_ => Action::Ignore,
}
}
fn select(_: Gesture, spot: Spot) -> Action {
on_row(spot, Action::Select)
}
fn open_row(_: Gesture, spot: Spot) -> Action {
on_row(spot, Action::OpenRow)
}
fn mark_row(_: Gesture, spot: Spot) -> Action {
on_row(spot, Action::MarkRow)
}
fn price(_: Gesture, spot: Spot) -> Action {
on_row(spot, Action::Price)
}
fn sort_by(_: Gesture, spot: Spot) -> Action {
match spot {
Spot::Heading(order) => Action::SortBy(order),
_ => Action::Ignore,
}
}
fn aim(_: Gesture, spot: Spot) -> Action {
match spot {
Spot::Answer(answer) => Action::Highlight(answer.turn()),
_ => Action::Ignore,
}
}
fn answer(_: Gesture, spot: Spot) -> Action {
match spot {
Spot::Answer(_) => Action::Answer,
_ => Action::Ignore,
}
}
fn dismiss(_: Gesture, _: Spot) -> Action {
Action::Back
}
fn take_away(_: Gesture, _: Spot) -> Action {
Action::Dismiss
}
fn scroll_rows(gesture: Gesture, _: Spot) -> Action {
gesture.motion().map_or(Action::Ignore, Action::ScrollRows)
}
fn scroll_page(gesture: Gesture, _: Spot) -> Action {
gesture.motion().map_or(Action::Ignore, Action::Scroll)
}
fn walk_listing(gesture: Gesture, _: Spot) -> Action {
gesture.motion().map_or(Action::Ignore, Action::Listing)
}
#[must_use]
pub fn help() -> Vec<(&'static str, Vec<(String, &'static str)>)> {
let surfaces = [
Surface::Global,
Surface::Tree,
Surface::Prompt,
Surface::Confirm,
Surface::Help,
];
let mut page: Vec<(&'static str, Vec<(String, &'static str)>)> = surfaces
.into_iter()
.map(|surface| {
let rows = bindings()
.iter()
.filter(|binding| binding.surface == surface)
.map(|binding| (binding.keys(), binding.what))
.collect();
(surface.title(), rows)
})
.collect();
page.push((
"The pointer",
pointing().iter().map(|row| (row.how(), row.what)).collect(),
));
page
}
#[cfg(test)]
mod tests {
use super::{
Action, Chord, Gesture, Motion, Overlay, Spot, Surface, Target, Turn, Zone, action_for,
bindings, finish, help, lookup, pointer, pointing,
};
use crate::rules::Kind;
use crate::tree::{NodeId, Order};
use crate::tui::state::Answer;
use ratatui::crossterm::event::{
Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers,
};
fn press(code: KeyCode) -> Event {
Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
fn letter(letter: char) -> Event {
press(KeyCode::Char(letter))
}
fn spot(target: Target) -> Spot {
const ROW: NodeId = 7;
match target {
Target::Heading => Spot::Heading(Order::Size),
Target::Box => Spot::Row {
id: ROW,
zone: Zone::Mark,
},
Target::Indicator => Spot::Row {
id: ROW,
zone: Zone::Open,
},
Target::Name | Target::Row => Spot::Row {
id: ROW,
zone: Zone::Name,
},
Target::Pane => Spot::Tree,
Target::Help => Spot::Help,
Target::Answer => Spot::Answer(Answer::Delete),
Target::Question => Spot::Confirm,
Target::Prompt => Spot::Prompt,
Target::Away => Spot::Outside,
Target::Notice => Spot::Notice,
Target::Elsewhere => Spot::Nowhere,
}
}
#[test]
fn the_tree_never_shadows_a_global_key() {
for binding in bindings() {
if binding.surface == Surface::Global {
continue;
}
for chord in &binding.chords {
let shadowed =
binding.surface != Surface::Prompt && lookup(Surface::Global, *chord).is_some();
assert!(
!shadowed,
"{:?} takes {chord}, which is global",
binding.surface
);
}
}
}
#[test]
fn every_binding_has_a_sentence_and_at_least_one_key() {
for binding in bindings() {
assert!(!binding.chords.is_empty(), "{binding:?} binds nothing");
assert!(!binding.what.is_empty(), "{binding:?} says nothing");
assert!(
binding.what.starts_with(|c: char| c.is_lowercase()),
"{:?} is not a lower-case imperative",
binding.what
);
}
}
#[test]
fn no_surface_binds_one_key_to_two_things() {
for binding in bindings() {
for chord in &binding.chords {
let claimants = bindings()
.iter()
.filter(|other| {
other.surface == binding.surface && other.chords.contains(chord)
})
.count();
assert_eq!(
claimants, 1,
"{chord} is bound twice on {:?}",
binding.surface
);
}
}
}
#[test]
fn the_help_page_lists_every_binding_and_every_gesture_there_is() {
let listed: usize = help().iter().map(|(_, rows)| rows.len()).sum();
assert_eq!(listed, bindings().len() + pointing().len());
}
#[test]
fn a_tree_key_cannot_reach_the_tree_from_behind_an_overlay() {
assert_eq!(action_for(&letter('x'), None), Action::Commit);
assert_eq!(
action_for(&letter('x'), Some(Overlay::Help)),
Action::Ignore
);
assert_eq!(
action_for(&letter('x'), Some(Overlay::Confirm)),
Action::Ignore
);
}
#[test]
fn a_printable_key_is_content_while_the_prompt_is_up() {
assert_eq!(
action_for(&letter('x'), Some(Overlay::Prompt)),
Action::Type('x')
);
assert_eq!(
action_for(&letter(' '), Some(Overlay::Prompt)),
Action::Type(' ')
);
assert_eq!(
action_for(
&Event::Key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)),
Some(Overlay::Prompt)
),
Action::Wipe
);
}
#[test]
fn quitting_is_reachable_from_every_surface_including_the_text_field() {
for overlay in [
None,
Some(Overlay::Help),
Some(Overlay::Confirm),
Some(Overlay::Prompt),
] {
let ctrl_c = Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert_eq!(action_for(&ctrl_c, overlay), Action::Quit, "{overlay:?}");
}
}
#[test]
fn a_ctrl_chord_is_not_the_bare_letter() {
assert_eq!(
action_for(
&Event::Key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)),
None
),
Action::Cursor(Motion::PageDown)
);
assert_eq!(
action_for(&letter('d'), None),
Action::ToggleKind(Kind::Dependencies)
);
}
#[test]
fn a_key_release_is_not_a_second_press() {
let release = Event::Key(KeyEvent::new_with_kind_and_state(
KeyCode::Char('x'),
KeyModifiers::NONE,
KeyEventKind::Release,
KeyEventState::NONE,
));
assert_eq!(action_for(&release, None), Action::Ignore);
}
#[test]
fn a_chord_spells_itself_the_way_the_help_page_prints_it() {
assert_eq!(Chord::plain(KeyCode::Char(' ')).to_string(), "space");
assert_eq!(Chord::ctrl('u').to_string(), "Ctrl-u");
assert_eq!(Chord::plain(KeyCode::Up).to_string(), "↑");
}
#[test]
fn no_row_of_the_pointer_map_is_dead() {
for row in pointing() {
for &target in row.targets {
assert_ne!(
pointer(row.gesture, spot(target)),
Action::Ignore,
"{:?} does nothing, and the help page says {:?}",
row.how(),
row.what
);
}
}
}
#[test]
fn no_spot_gives_one_gesture_two_meanings() {
for gesture in [
Gesture::Aim,
Gesture::Click,
Gesture::Double,
Gesture::Wheel(Motion::Down),
] {
for target in Target::ALL {
let claimants = pointing()
.iter()
.filter(|row| row.gesture.same(gesture) && row.targets.contains(&target))
.count();
assert!(claimants <= 1, "{gesture} {target:?} is bound twice");
}
}
}
#[test]
fn a_row_is_named_by_its_directory_and_never_by_where_it_is_on_the_screen() {
let spot = Spot::Row {
id: 42,
zone: Zone::Name,
};
assert_eq!(pointer(Gesture::Click, spot), Action::Select(42));
assert_eq!(pointer(Gesture::Double, spot), Action::Price(42));
}
#[test]
fn the_zones_of_a_row_are_a_clicks_business_and_nothing_elses() {
for (zone, action) in [
(Zone::Mark, Action::MarkRow(3)),
(Zone::Open, Action::OpenRow(3)),
(Zone::Name, Action::Select(3)),
] {
assert_eq!(
pointer(Gesture::Click, Spot::Row { id: 3, zone }),
action,
"{zone:?}"
);
assert_eq!(
pointer(Gesture::Double, Spot::Row { id: 3, zone }),
Action::Price(3),
"{zone:?}"
);
assert_eq!(
pointer(Gesture::Wheel(Motion::Down), Spot::Row { id: 3, zone }),
Action::ScrollRows(Motion::Down),
"{zone:?}"
);
}
}
#[test]
fn a_press_aims_and_does_no_more_than_aim() {
for target in Target::ALL {
let aimed = pointer(Gesture::Aim, spot(target));
let expected = match target {
Target::Answer => Action::Highlight(Turn::Next),
_ => Action::Ignore,
};
assert_eq!(aimed, expected, "{target:?}");
}
}
#[test]
fn the_click_acts_on_what_the_press_was_aimed_at() {
assert_eq!(
finish(Spot::Heading(Order::Age), false, Spot::Nowhere),
Action::SortBy(Order::Age)
);
}
#[test]
fn a_confirmation_is_the_one_surface_that_needs_both_halves_in_the_same_button() {
let delete = Spot::Answer(Answer::Delete);
assert_eq!(finish(delete, false, delete), Action::Answer);
assert_eq!(finish(delete, false, Spot::Confirm), Action::Ignore);
assert_eq!(
finish(delete, false, Spot::Answer(Answer::Cancel)),
Action::Ignore
);
assert_eq!(finish(Spot::Tree, false, delete), Action::Ignore);
}
#[test]
fn a_press_inside_an_overlay_that_lands_on_nothing_does_nothing() {
for spot in [Spot::Help, Spot::Prompt, Spot::Confirm, Spot::Nowhere] {
assert_eq!(pointer(Gesture::Click, spot), Action::Ignore, "{spot:?}");
}
assert_eq!(pointer(Gesture::Click, Spot::Outside), Action::Back);
}
#[test]
fn the_wheel_means_the_nearest_thing_to_scrolling_each_surface_has() {
assert_eq!(
pointer(Gesture::Wheel(Motion::Up), Spot::Tree),
Action::ScrollRows(Motion::Up)
);
assert_eq!(
pointer(Gesture::Wheel(Motion::Down), Spot::Help),
Action::Scroll(Motion::Down)
);
for spot in [Spot::Confirm, Spot::Answer(Answer::Delete)] {
assert_eq!(
pointer(Gesture::Wheel(Motion::Down), spot),
Action::Listing(Motion::Down),
"{spot:?}"
);
}
assert_eq!(
pointer(Gesture::Wheel(Motion::Down), Spot::Nowhere),
Action::Ignore
);
}
#[test]
fn the_help_page_spells_a_gesture_the_way_a_reader_would_say_it() {
let page = help();
let (title, rows) = page.last().unwrap();
assert_eq!(*title, "The pointer");
let said: Vec<&str> = rows.iter().map(|(how, _)| how.as_str()).collect();
assert!(said.contains(&"double-click a row"), "{said:?}");
assert!(said.contains(&"wheel over the tree"), "{said:?}");
assert!(said.contains(&"click a column heading"), "{said:?}");
}
}