a73x

src/tui/keys.rs

Ref:   Size: 30.6 KiB   History

//! Every key this dashboard binds, in one table.
//!
//! Before this, key handling was fifty-seven `KeyCode::` arms spread across
//! two files and nested by mode and pane, and the footer describing them was a
//! separate set of string literals. Nothing connected the two, so nothing
//! could notice when they disagreed — and they did. `c` meant "comment" on a
//! patch, "open the Event History" on an issue, and nothing at all one pane
//! over, where it fell through a `pane == Detail` guard into silence.
//!
//! So the bindings are data here, and everything else is derived from them:
//!
//! - [`lookup`] is the dispatcher. A key that is not in the table for the
//!   current context is *unbound*, and the reader is told so; there is no
//!   longer a silent fall-through.
//! - [`footer`] and [`overlay`] are generated. Help cannot drift from the
//!   bindings because it is not written down anywhere else. This is the move
//!   `tests/cli_surface_test.rs` made when it took clap's command tree as the
//!   oracle for command citations instead of a hand-kept list.
//! - The tests at the bottom assert what the old structure could not express:
//!   that no context binds a key twice, and that no context is bound but
//!   unreachable.
//!
//! Text entry is deliberately *not* in the table. In [`InputMode::Search`] and
//! [`InputMode::CreateTitle`] every printable key is data rather than a
//! command, so there is nothing to bind and nothing to advertise; those two
//! are intercepted before dispatch in `events.rs`.

use crossterm::event::KeyCode;

use crate::state::IssueStatus;

use super::state::{App, InputMode, ListMode, Pane, StatusFilter, ViewMode};

// ── Contexts ────────────────────────────────────────────────────────────────

/// Where a key is being pressed.
///
/// The (`ViewMode`, `Pane`) pair that already governed dispatch, with
/// `ListMode` folded in where it genuinely changes what a key means: the
/// issue and patch lists share a `ViewMode` and a `Pane` and disagree about
/// `c`, `e`, `p` and `u`, so treating them as one context is what let those
/// disagreements be settled by an `if` buried in an arm.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Context {
    /// The issue list, with focus on the list.
    IssueList,
    /// The issue list, with focus on the detail pane beside it.
    IssueDetail,
    /// The patch list, with focus on the list.
    PatchList,
    /// The patch list, with focus on the summary pane beside it. Reachable
    /// only when there is no patch to open — `Tab` opens the patch itself
    /// otherwise — but reachable, so it is bound.
    PatchSummary,
    /// One patch, its revisions and its diff: the review surface.
    PatchDetail,
    /// An issue's event stream, listed.
    EventHistory,
    /// One event from that stream.
    EventDetail,
    /// The modal prompt `R` opens, waiting for a verdict.
    ReviewVerdict,
    /// The `?` overlay, listing the context behind it.
    Help,
}

impl Context {
    /// Every context, densely indexed. The inventory the invariant tests walk,
    /// and nothing the program itself needs — dispatch always has an [`App`]
    /// to ask.
    ///
    /// The match in [`Context::ordinal`] is exhaustive, so a new variant
    /// cannot be added without giving it an index, and `all_contexts_are_listed`
    /// checks that index is this array's.
    #[cfg(test)]
    pub(crate) const ALL: [Context; 9] = [
        Context::IssueList,
        Context::IssueDetail,
        Context::PatchList,
        Context::PatchSummary,
        Context::PatchDetail,
        Context::EventHistory,
        Context::EventDetail,
        Context::ReviewVerdict,
        Context::Help,
    ];

    #[cfg(test)]
    fn ordinal(self) -> usize {
        match self {
            Context::IssueList => 0,
            Context::IssueDetail => 1,
            Context::PatchList => 2,
            Context::PatchSummary => 3,
            Context::PatchDetail => 4,
            Context::EventHistory => 5,
            Context::EventDetail => 6,
            Context::ReviewVerdict => 7,
            Context::Help => 8,
        }
    }

    /// What the overlay calls this pane.
    pub(crate) fn title(self) -> &'static str {
        match self {
            Context::IssueList => "Issues — list",
            Context::IssueDetail => "Issues — detail",
            Context::PatchList => "Patches — list",
            Context::PatchSummary => "Patches — detail",
            Context::PatchDetail => "Patch detail",
            Context::EventHistory => "Event History",
            Context::EventDetail => "Event detail",
            Context::ReviewVerdict => "Review verdict",
            Context::Help => "Keys",
        }
    }

    /// The context a key pressed right now belongs to.
    pub(crate) fn of(app: &App) -> Context {
        if app.show_help {
            return Context::Help;
        }
        if app.input_mode == InputMode::ReviewVerdict {
            return Context::ReviewVerdict;
        }
        Context::surface(app)
    }

    /// The context underneath any overlay — the one the `?` list is about.
    pub(crate) fn surface(app: &App) -> Context {
        match app.mode {
            ViewMode::PatchDetail => Context::PatchDetail,
            ViewMode::EventHistory => Context::EventHistory,
            ViewMode::EventDetail => Context::EventDetail,
            ViewMode::Details => match (app.list_mode, &app.pane) {
                (ListMode::Issues, Pane::ItemList) => Context::IssueList,
                (ListMode::Issues, Pane::Detail) => Context::IssueDetail,
                (ListMode::Patches, Pane::ItemList) => Context::PatchList,
                (ListMode::Patches, Pane::Detail) => Context::PatchSummary,
            },
        }
    }
}

// ── Keys ────────────────────────────────────────────────────────────────────

/// A key as a binding names it.
///
/// Control is part of the binding; Shift is not. The terminal has already
/// folded Shift into the character by the time this sees it, which is why `R`
/// and `r` are two different keys in the table rather than one key and a
/// modifier.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Key {
    pub(crate) code: KeyCode,
    pub(crate) ctrl: bool,
}

const fn k(c: char) -> Key {
    Key {
        code: KeyCode::Char(c),
        ctrl: false,
    }
}

const fn ctrl(c: char) -> Key {
    Key {
        code: KeyCode::Char(c),
        ctrl: true,
    }
}

const fn c(code: KeyCode) -> Key {
    Key { code, ctrl: false }
}

impl Key {
    /// How help prints it.
    pub(crate) fn name(self) -> String {
        let base = match self.code {
            KeyCode::Char(' ') => "Space".to_string(),
            KeyCode::Char(ch) => ch.to_string(),
            KeyCode::Enter => "Enter".to_string(),
            KeyCode::Tab => "Tab".to_string(),
            KeyCode::BackTab => "Shift-Tab".to_string(),
            KeyCode::Esc => "Esc".to_string(),
            KeyCode::Backspace => "Backspace".to_string(),
            KeyCode::Up => "Up".to_string(),
            KeyCode::Down => "Down".to_string(),
            KeyCode::Left => "Left".to_string(),
            KeyCode::Right => "Right".to_string(),
            KeyCode::PageUp => "PgUp".to_string(),
            KeyCode::PageDown => "PgDn".to_string(),
            KeyCode::Home => "Home".to_string(),
            KeyCode::End => "End".to_string(),
            KeyCode::Delete => "Del".to_string(),
            KeyCode::Insert => "Ins".to_string(),
            KeyCode::F(n) => format!("F{}", n),
            other => format!("{:?}", other),
        };
        if self.ctrl {
            format!("Ctrl-{}", base)
        } else {
            base
        }
    }
}

// ── Actions ─────────────────────────────────────────────────────────────────

/// What a key means. One variant per distinct meaning, which is why moving
/// down has four of them: the footer used to say `j/k:navigate` on a pane
/// where `j` scrolled, because "move down" was one arm that did different
/// things and one string that described only the first of them.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Action {
    Quit,
    /// Leave this view for the one behind it.
    Back,
    SelectNext,
    SelectPrev,
    ScrollDown,
    ScrollUp,
    ScrollPageDown,
    ScrollPageUp,
    CursorDown,
    CursorUp,
    CursorPageDown,
    CursorPageUp,
    /// Move the view a row without moving the cursor: the only way through a
    /// line that wrapped taller than the pane.
    ScrollRowDown,
    ScrollRowUp,
    EventNext,
    EventPrev,
    OpenEvent,
    TogglePane,
    OpenSelectedPatch,
    OpenLinkedPatch,
    SwitchList,
    CycleStatusFilter,
    ToggleUnresolvedOnly,
    Reload,
    BeginSearch,
    BeginCreateIssue,
    CommentOnIssue,
    CloseOrReopenIssue,
    OpenEventHistory,
    CommentOnPatch,
    BeginReviewVerdict,
    ToggleResolve,
    ShowAnswers,
    Checkout,
    NextRevision,
    PrevRevision,
    ToggleInterdiff,
    ToggleWrap,
    ShowHelp,
    CloseHelp,
    VerdictApprove,
    VerdictRequestChanges,
    VerdictComment,
    CancelVerdict,
}

// ── The table ───────────────────────────────────────────────────────────────

/// What help calls a binding. Some labels depend on what the key would do
/// next — `a` cycles a filter, so it has to say which way — and those are a
/// function of the app rather than a literal, so that the footer stays right
/// without anyone maintaining a parallel `match`.
#[derive(Clone, Copy)]
pub(crate) enum Label {
    Fixed(&'static str),
    OfApp(fn(&App) -> &'static str),
}

impl Label {
    fn text(self, app: &App) -> &'static str {
        match self {
            Label::Fixed(s) => s,
            Label::OfApp(f) => f(app),
        }
    }
}

pub(crate) struct Entry {
    /// The keys that mean this here. More than one when they are aliases —
    /// `j` and `Down` — and the first is the one help prints.
    pub(crate) keys: &'static [Key],
    pub(crate) action: Action,
    pub(crate) label: Label,
    /// Whether the one-line footer advertises it. The overlay lists all of it.
    pub(crate) in_footer: bool,
    /// Another action whose key is printed alongside this one's, for the pairs
    /// that read as one entry: `j/k`, `[/]`. Still generated from the table, so
    /// rebinding either half moves the help with it.
    pub(crate) pairs_with: Option<Action>,
}

/// Shorthand for the common shape: one key, one action, one fixed label.
const fn e(keys: &'static [Key], action: Action, label: &'static str, in_footer: bool) -> Entry {
    Entry {
        keys,
        action,
        label: Label::Fixed(label),
        in_footer,
        pairs_with: None,
    }
}

/// Bound in every context that is not a modal prompt.
///
/// Kept out of the per-context tables so that `q` cannot come to mean
/// something else on one pane by accident — and the duplicate check below
/// tests these against every context, so it cannot come to mean something else
/// on purpose either without the test saying so.
const COMMON: &[Entry] = &[
    e(&[k('?')], Action::ShowHelp, "keys", true),
    e(&[k('q')], Action::Quit, "quit", true),
    e(&[ctrl('c')], Action::Quit, "quit", false),
];

fn filter_label(app: &App) -> &'static str {
    match app.status_filter {
        StatusFilter::Open => "show all",
        StatusFilter::All => "closed",
        StatusFilter::Closed => "open only",
    }
}

fn list_label(app: &App) -> &'static str {
    match app.list_mode {
        ListMode::Issues => "patches",
        ListMode::Patches => "issues",
    }
}

/// `C` is one key because closing and reopening are one decision, and the
/// issue on screen already says which way it goes.
fn close_label(app: &App) -> &'static str {
    match app.selected_issue() {
        Some(issue) if issue.status == IssueStatus::Closed => "reopen",
        _ => "close",
    }
}

/// Down and up, with their arrow-key aliases. Named once so that a rebinding
/// moves every pane's navigation together, and so that the footer's `j/k` is
/// these keys rather than two characters someone typed into a string.
const DOWN: &[Key] = &[k('j'), c(KeyCode::Down)];
const UP: &[Key] = &[k('k'), c(KeyCode::Up)];

const fn nav_pair(next: Action, prev: Action, label: &'static str) -> [Entry; 2] {
    [
        Entry {
            keys: DOWN,
            action: next,
            label: Label::Fixed(label),
            in_footer: true,
            pairs_with: Some(prev),
        },
        Entry {
            keys: UP,
            action: prev,
            label: Label::Fixed(label),
            in_footer: false,
            pairs_with: None,
        },
    ]
}

const ISSUE_NAV: [Entry; 2] = nav_pair(Action::SelectNext, Action::SelectPrev, "navigate");
const SCROLL_NAV: [Entry; 2] = nav_pair(Action::ScrollDown, Action::ScrollUp, "scroll");

/// The keys the two issue contexts share. Everything about an issue is
/// addressed by the row under the cursor, and the row under the cursor does
/// not change when focus moves to the pane beside it — which is exactly the
/// assumption the old `pane == Pane::Detail` guard got wrong.
const ISSUE_VERBS: &[Entry] = &[
    e(&[k('c')], Action::CommentOnIssue, "comment", true),
    e(&[k('e')], Action::OpenEventHistory, "events", true),
    e(&[k('p')], Action::OpenLinkedPatch, "patch", true),
    Entry {
        keys: &[k('P')],
        action: Action::SwitchList,
        label: Label::OfApp(list_label),
        in_footer: true,
        pairs_with: None,
    },
    Entry {
        keys: &[k('a')],
        action: Action::CycleStatusFilter,
        label: Label::OfApp(filter_label),
        in_footer: true,
        pairs_with: None,
    },
    Entry {
        keys: &[k('C')],
        action: Action::CloseOrReopenIssue,
        label: Label::OfApp(close_label),
        in_footer: true,
        pairs_with: None,
    },
    e(&[k('/')], Action::BeginSearch, "search", true),
    e(&[k('r')], Action::Reload, "refresh", true),
    e(&[k('n')], Action::BeginCreateIssue, "new issue", true),
    e(
        &[k('o')],
        Action::Checkout,
        "check out the linked patch",
        false,
    ),
    e(
        &[c(KeyCode::PageDown)],
        Action::ScrollPageDown,
        "page down the detail",
        false,
    ),
    e(
        &[c(KeyCode::PageUp)],
        Action::ScrollPageUp,
        "page up the detail",
        false,
    ),
    // Esc is "back" wherever there is something behind. On the top-level
    // lists there is not, so it keeps the meaning it has always had here.
    e(&[c(KeyCode::Esc)], Action::Quit, "quit", false),
];

/// The same, for the patch list. `u` lives here and only here: issues carry no
/// unresolved count, so on that pane it would narrow nothing while looking
/// like it had.
const PATCH_LIST_VERBS: &[Entry] = &[
    e(&[k('u')], Action::ToggleUnresolvedOnly, "unresolved", true),
    Entry {
        keys: &[k('P')],
        action: Action::SwitchList,
        label: Label::OfApp(list_label),
        in_footer: true,
        pairs_with: None,
    },
    Entry {
        keys: &[k('a')],
        action: Action::CycleStatusFilter,
        label: Label::OfApp(filter_label),
        in_footer: true,
        pairs_with: None,
    },
    e(&[k('/')], Action::BeginSearch, "search", true),
    e(&[k('r')], Action::Reload, "refresh", true),
    e(&[k('n')], Action::BeginCreateIssue, "new issue", true),
    e(&[k('o')], Action::Checkout, "check out this patch", false),
    e(
        &[c(KeyCode::PageDown)],
        Action::ScrollPageDown,
        "page down the detail",
        false,
    ),
    e(
        &[c(KeyCode::PageUp)],
        Action::ScrollPageUp,
        "page up the detail",
        false,
    ),
    // Esc is "back" wherever there is something behind. On the top-level
    // lists there is not, so it keeps the meaning it has always had here.
    e(&[c(KeyCode::Esc)], Action::Quit, "quit", false),
];

const PATCH_DETAIL: &[Entry] = &[
    Entry {
        keys: DOWN,
        action: Action::CursorDown,
        label: Label::Fixed("line"),
        in_footer: true,
        pairs_with: Some(Action::CursorUp),
    },
    Entry {
        keys: UP,
        action: Action::CursorUp,
        label: Label::Fixed("line"),
        in_footer: false,
        pairs_with: None,
    },
    e(&[k('c')], Action::CommentOnPatch, "comment", true),
    e(&[k('R')], Action::BeginReviewVerdict, "review", true),
    e(&[k('x')], Action::ToggleResolve, "resolve", true),
    e(&[k('a')], Action::ShowAnswers, "answers", true),
    e(&[c(KeyCode::Esc)], Action::Back, "back", true),
    Entry {
        keys: &[k('[')],
        action: Action::PrevRevision,
        label: Label::Fixed("revision"),
        in_footer: true,
        pairs_with: Some(Action::NextRevision),
    },
    Entry {
        keys: &[k(']')],
        action: Action::NextRevision,
        label: Label::Fixed("revision"),
        in_footer: false,
        pairs_with: None,
    },
    e(&[k('d')], Action::ToggleInterdiff, "interdiff", true),
    e(&[k('o')], Action::Checkout, "checkout", true),
    e(&[k('w')], Action::ToggleWrap, "wrap", true),
    e(&[ctrl('e')], Action::ScrollRowDown, "scroll a row", false),
    e(
        &[ctrl('y')],
        Action::ScrollRowUp,
        "scroll a row back",
        false,
    ),
    e(
        &[c(KeyCode::PageDown)],
        Action::CursorPageDown,
        "page down",
        false,
    ),
    e(
        &[c(KeyCode::PageUp)],
        Action::CursorPageUp,
        "page up",
        false,
    ),
];

const EVENT_HISTORY: &[Entry] = &[
    Entry {
        keys: DOWN,
        action: Action::EventNext,
        label: Label::Fixed("navigate"),
        in_footer: true,
        pairs_with: Some(Action::EventPrev),
    },
    Entry {
        keys: UP,
        action: Action::EventPrev,
        label: Label::Fixed("navigate"),
        in_footer: false,
        pairs_with: None,
    },
    e(&[c(KeyCode::Enter)], Action::OpenEvent, "detail", true),
    e(&[c(KeyCode::Esc)], Action::Back, "back", true),
];

const EVENT_DETAIL: &[Entry] = &[
    e(&[c(KeyCode::Esc)], Action::Back, "back", true),
    e(
        &[c(KeyCode::PageDown)],
        Action::ScrollPageDown,
        "page down",
        false,
    ),
    e(
        &[c(KeyCode::PageUp)],
        Action::ScrollPageUp,
        "page up",
        false,
    ),
];

const REVIEW_VERDICT: &[Entry] = &[
    e(&[k('a')], Action::VerdictApprove, "approve", true),
    e(
        &[k('r')],
        Action::VerdictRequestChanges,
        "request-changes",
        true,
    ),
    e(&[k('c')], Action::VerdictComment, "comment", true),
    e(&[c(KeyCode::Esc)], Action::CancelVerdict, "cancel", true),
];

const HELP: &[Entry] = &[
    Entry {
        keys: &[c(KeyCode::Esc), k('?')],
        action: Action::CloseHelp,
        label: Label::Fixed("close"),
        in_footer: true,
        pairs_with: None,
    },
    e(&[k('q')], Action::Quit, "quit", true),
];

/// The bindings of one context, in footer order.
///
/// Ordered by what the reader reaches for, because the footer is one line and
/// a narrow terminal clips the tail. `?` and `q` lead: one line cannot hold
/// this surface, so its first duty is to name the key that can, and the second
/// is the way out.
pub(crate) fn entries(context: Context) -> Vec<&'static Entry> {
    let own: Vec<&'static Entry> = match context {
        Context::IssueList => ISSUE_NAV
            .iter()
            .chain(TAB_PANE.iter())
            .chain(ISSUE_VERBS.iter())
            .collect(),
        Context::IssueDetail => SCROLL_NAV
            .iter()
            .chain(TAB_PANE.iter())
            .chain(ISSUE_VERBS.iter())
            .collect(),
        Context::PatchList => ISSUE_NAV
            .iter()
            .chain(OPEN_PATCH.iter())
            .chain(PATCH_LIST_VERBS.iter())
            .collect(),
        Context::PatchSummary => SCROLL_NAV
            .iter()
            .chain(TAB_PANE.iter())
            .chain(PATCH_LIST_VERBS.iter())
            .collect(),
        Context::PatchDetail => PATCH_DETAIL.iter().collect(),
        Context::EventHistory => EVENT_HISTORY.iter().collect(),
        Context::EventDetail => SCROLL_NAV.iter().chain(EVENT_DETAIL.iter()).collect(),
        Context::ReviewVerdict => REVIEW_VERDICT.iter().collect(),
        Context::Help => HELP.iter().collect(),
    };
    let mut all: Vec<&'static Entry> = common(context).iter().collect();
    all.extend(own);
    all
}

const TAB_PANE: [Entry; 1] = [Entry {
    keys: &[c(KeyCode::Tab), c(KeyCode::Enter)],
    action: Action::TogglePane,
    label: Label::Fixed("pane"),
    in_footer: true,
    pairs_with: None,
}];

/// In the patch list, `Enter` opens the patch rather than moving focus: the
/// pane beside the list is a summary of the thing `Enter` opens, so stopping
/// there would be a step to nowhere.
const OPEN_PATCH: [Entry; 1] = [Entry {
    keys: &[c(KeyCode::Enter), c(KeyCode::Tab)],
    action: Action::OpenSelectedPatch,
    label: Label::Fixed("view patch"),
    in_footer: true,
    pairs_with: None,
}];

fn common(context: Context) -> &'static [Entry] {
    match context {
        // Modal prompts. `q` in a verdict prompt is a verdict-shaped mistake,
        // and the overlay's own `q` is bound below.
        Context::Help | Context::ReviewVerdict => &[],
        _ => COMMON,
    }
}

// ── Dispatch ────────────────────────────────────────────────────────────────

/// What this key means here, or `None` if it means nothing here.
///
/// `None` is a real answer, and the caller owes the reader a sentence for it.
/// That is the whole difference from the structure this replaces, where a key
/// with no binding and a key whose binding was gated out both arrived at the
/// same `_ => KeyAction::Continue`.
pub(crate) fn lookup(context: Context, key: Key) -> Option<Action> {
    entries(context)
        .into_iter()
        .find(|entry| entry.keys.contains(&key))
        .map(|entry| entry.action)
}

// ── Generated help ──────────────────────────────────────────────────────────

/// The key names printed for one entry, `j/k` and `[/]` included.
fn hint_keys(context: Context, entry: &Entry) -> String {
    let mut names = vec![entry.keys[0].name()];
    if let Some(partner) = entry.pairs_with {
        if let Some(other) = entries(context)
            .into_iter()
            .find(|candidate| candidate.action == partner)
        {
            names.push(other.keys[0].name());
        }
    }
    names.join("/")
}

/// Whether some other entry already prints this one as the tail of its pair,
/// so `j/k` and `[/]` appear once rather than twice.
fn is_a_partner(context: Context, entry: &Entry) -> bool {
    entries(context)
        .into_iter()
        .any(|other| other.pairs_with == Some(entry.action))
}

/// The one-line footer for wherever the reader is.
pub(crate) fn footer(app: &App) -> String {
    let context = Context::of(app);
    let mut items: Vec<String> = Vec::new();
    for entry in entries(context) {
        if !entry.in_footer {
            continue;
        }
        items.push(format!(
            "{}:{}",
            hint_keys(context, entry),
            entry.label.text(app)
        ));
    }
    items.join("  ")
}

/// Every binding of the context behind the overlay, as `(keys, meaning)`.
///
/// `surface` rather than `of`, because the list the overlay draws is the list
/// for the pane it is covering — the overlay's own two keys are on its footer.
pub(crate) fn overlay(app: &App) -> Vec<(String, String)> {
    let context = Context::surface(app);
    entries(context)
        .into_iter()
        .filter(|entry| !is_a_partner(context, entry))
        .map(|entry| {
            let keys = if entry.pairs_with.is_some() {
                hint_keys(context, entry)
            } else {
                entry
                    .keys
                    .iter()
                    .map(|key| key.name())
                    .collect::<Vec<_>>()
                    .join(" / ")
            };
            (keys, entry.label.text(app).to_string())
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::abbrev::Abbrev;

    fn app() -> App {
        App::new(Vec::new(), Vec::new(), Abbrev::minimal(), Abbrev::minimal())
    }

    /// The invariant the old structure could not express.
    ///
    /// Two keys meaning the same thing is fine — `j` and `Down` are aliases.
    /// One key meaning two things is the bug this whole table exists to make
    /// impossible, and it was live: `c` was comment, Event History, and
    /// silence, depending on where you stood.
    #[test]
    fn no_context_binds_a_key_twice() {
        for context in Context::ALL {
            let mut seen: Vec<(Key, Action)> = Vec::new();
            for entry in entries(context) {
                for key in entry.keys {
                    if let Some((_, other)) = seen.iter().find(|(k, _)| k == key) {
                        panic!(
                            "{:?} binds {} twice: {:?} and {:?}",
                            context,
                            key.name(),
                            other,
                            entry.action
                        );
                    }
                    seen.push((*key, entry.action));
                }
            }
        }
    }

    /// A binding in a context nothing can reach is a binding nobody can press.
    /// Every context in the table has to be one `Context::of` can produce.
    #[test]
    fn every_context_is_reachable() {
        for context in Context::ALL {
            let mut app = app();
            match context {
                Context::IssueList => {
                    app.list_mode = ListMode::Issues;
                    app.pane = Pane::ItemList;
                }
                Context::IssueDetail => {
                    app.list_mode = ListMode::Issues;
                    app.pane = Pane::Detail;
                }
                Context::PatchList => {
                    app.list_mode = ListMode::Patches;
                    app.pane = Pane::ItemList;
                }
                Context::PatchSummary => {
                    app.list_mode = ListMode::Patches;
                    app.pane = Pane::Detail;
                }
                Context::PatchDetail => app.mode = ViewMode::PatchDetail,
                Context::EventHistory => app.mode = ViewMode::EventHistory,
                Context::EventDetail => app.mode = ViewMode::EventDetail,
                Context::ReviewVerdict => app.input_mode = InputMode::ReviewVerdict,
                Context::Help => app.show_help = true,
            }
            assert_eq!(
                Context::of(&app),
                context,
                "no state produces {:?}, so its bindings are unreachable",
                context
            );
        }
    }

    /// `ALL` is what the other tests iterate, so it has to be all of them.
    #[test]
    fn all_contexts_are_listed() {
        for (index, context) in Context::ALL.iter().enumerate() {
            assert_eq!(
                context.ordinal(),
                index,
                "Context::ALL and Context::ordinal disagree about {:?}",
                context
            );
        }
    }

    /// No context is a room with no door. Every one of them can be left, by
    /// `Esc`, by `q`, or — for the overlay — by the key that opened it.
    #[test]
    fn every_context_has_a_way_out() {
        for context in Context::ALL {
            let ways: Vec<Action> = entries(context)
                .into_iter()
                .map(|entry| entry.action)
                .filter(|action| {
                    matches!(
                        action,
                        Action::Quit | Action::Back | Action::CloseHelp | Action::CancelVerdict
                    )
                })
                .collect();
            assert!(!ways.is_empty(), "{:?} cannot be left", context);
        }
    }

    /// Every binding says what it does, because the overlay prints it.
    #[test]
    fn every_binding_is_labelled() {
        let app = app();
        for context in Context::ALL {
            for entry in entries(context) {
                assert!(
                    !entry.keys.is_empty(),
                    "{:?} has a binding with no key",
                    context
                );
                assert!(
                    !entry.label.text(&app).is_empty(),
                    "{:?} binds {} to nothing it can name",
                    context,
                    entry.keys[0].name()
                );
            }
        }
    }

    /// A pair's partner has to be in the same context, or the footer prints
    /// half of a `j/k`.
    #[test]
    fn every_pair_has_its_partner() {
        for context in Context::ALL {
            for entry in entries(context) {
                if let Some(partner) = entry.pairs_with {
                    assert!(
                        entries(context)
                            .into_iter()
                            .any(|other| other.action == partner),
                        "{:?} pairs {} with {:?}, which is not bound here",
                        context,
                        entry.keys[0].name(),
                        partner
                    );
                }
            }
        }
    }

    /// The footer is generated, so this is a property of the table rather than
    /// of a string literal: what it prints is what the keys do.
    #[test]
    fn the_footer_names_the_bindings_it_has() {
        let mut app = app();
        app.list_mode = ListMode::Issues;
        let text = footer(&app);
        assert!(text.starts_with("?:keys"), "{}", text);
        assert!(text.contains("c:comment"), "{}", text);
        assert!(text.contains("e:events"), "{}", text);
        assert!(!text.contains("c:events"), "{}", text);
    }

    /// The same key, two panes, one meaning.
    #[test]
    fn c_means_comment_on_both_issue_contexts() {
        for context in [Context::IssueList, Context::IssueDetail] {
            assert_eq!(lookup(context, k('c')), Some(Action::CommentOnIssue));
        }
    }

    /// And nothing that is not bound comes back as if it were.
    #[test]
    fn an_unbound_key_looks_up_to_nothing() {
        assert_eq!(lookup(Context::IssueList, k('Z')), None);
    }
}