From af0c661002bc80ea2dd792dce6ec6b8426e99a1e Mon Sep 17 00:00:00 2001 From: Benjamin Wood Date: Wed, 21 Oct 2026 03:27:53 +0000 Subject: [PATCH 1/2] feat(keys): rebind every key under [keys] Each key the viewer acts on is now an action with a name (`down`, `line-start`, `send`, ...) and a list of keys in config.toml, defaulting to today's bindings. A key bound to two actions that read the same keypress is a config error that names both, and the footer, menu and toolbar hints follow the bindings. Refs #58 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B4DEckuRs9pSTyzuvCvrXN --- README.md | 44 +++ .../plannotator-tui/src/app/archive_view.rs | 16 +- crates/plannotator-tui/src/app/draw.rs | 60 +++- crates/plannotator-tui/src/app/input.rs | 138 ++++---- crates/plannotator-tui/src/app/menu.rs | 40 ++- crates/plannotator-tui/src/app/mod.rs | 11 +- crates/plannotator-tui/src/app/pick.rs | 15 +- crates/plannotator-tui/src/app/tests.rs | 15 + crates/plannotator-tui/src/cli.rs | 8 +- crates/plannotator-tui/src/config.rs | 2 + crates/plannotator-tui/src/keys.rs | 328 ++++++++++++++++++ crates/plannotator-tui/src/keys/tests.rs | 77 ++++ crates/plannotator-tui/src/main.rs | 1 + 13 files changed, 643 insertions(+), 112 deletions(-) create mode 100644 crates/plannotator-tui/src/keys.rs create mode 100644 crates/plannotator-tui/src/keys/tests.rs diff --git a/README.md b/README.md index 9656b4a..eb7412e 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ dimmed. The keys also work without opening the menu. | file/folder review | `E` send new · `m` review menu (`R` resend all · `F` finish review · `U` undo · `H` archive) | | tree | `j`/`k`; `Enter` open; `.` show/hide dot-prefixed entries (`.agents/`, `.github/`); `E` sends new notes across all reviewed files, including collapsed folders | +These are the defaults; every key can be rebound under `[keys]` (see [Keys](#keys)). + Hidden folders are out of the tree until `.` asks for them, and `.git`, `.hg`, `.svn`, `.jj`, `.cache`, `.direnv`, `.venv`, `venv`, `__pycache__`, `node_modules`, `vendor`, `target`, `build`, `dist` and `out` stay out either way — none of them is read at all. @@ -139,6 +141,48 @@ wins over the file, and `plannotator-tui config` prints whichever is in effect. backgrounds plannotator-tui paints itself change; the document keeps your terminal's own colours either way. +### Keys + +Every key is rebindable under `[keys]`: an action, and the keys that run it. + +```toml +[keys] +left = ["j", "Left"] # jkl; instead of hjkl +down = ["k", "Down"] +up = ["l", "Up"] +right = [";", "Right"] +line-start = ["^", "Home"] +``` + +An entry replaces that action's keys and leaves every other action at its default; `[]` +unbinds it. A key is one character (`G` is Shift+g), `ctrl+`, or one of `Up`, +`Down`, `Left`, `Right`, `Home`, `End`, `PageUp`, `PageDown`, `Enter`, `Esc`, `Tab`, +`Delete`, `Backspace`. An action keeps its meaning everywhere it applies, so `down` moves +down in the document, the tree, the notes and every picker. A key bound to two actions that +are live at the same time is an error that names both, and the hints at the bottom of the +screen show your keys. `plannotator-tui config` prints every action with its keys. + +| Action | Default | Does | +|---|---|---| +| `up` `down` `left` `right` | `k` `j` `h` `l`, arrows | move; `left`/`right` in the document start the cursor, `right` in the tree opens | +| `word-forward` `word-back` | `w` `b` | cursor to the next or previous word | +| `line-start` `line-end` | `0` `$`, `Home` `End` | cursor to the start or end of the line | +| `page-down` `page-up` | `ctrl+d` `ctrl+u`, `PageDown` `PageUp` | half a page | +| `top` `bottom` | `g` `G`, `Home` `End` | first or last block | +| `confirm` `back` | `Enter` `Esc` | finish, open, run; cancel, close | +| `quit` `interrupt` | `q` `ctrl+c` | quit | +| `cycle-focus` | `Tab` | tree · document · notes | +| `send` `send-and-close` | `E` `S` | send; send and close a reply review | +| `toggle-tree` `reload` `pick-message` | `t` `r` `p` | show/hide the tree; reload; choose another reply | +| `review-menu` | `m` | the Review menu | +| `resend-all` `finish-review` `undo-finish` `archive` | `R` `F` `U` `H` | file and folder review actions | +| `toggle-hidden` | `.` | show/hide dot-prefixed entries in the tree | +| `edit` `remove` | `e` `x`, `Delete` | the selected note | +| `visual` `move-cursor` | `v` `i` | select with the cursor; move it first | +| `comment` `clear-block` | `c` `x` | on the block; clear its annotations | +| `looks-good` `delete` | `a` `d` | toolbar, with `comment` | +| `yes` `no` | `y` `n` | the send-before-quitting question | + Actions forwarded by Herdr Mirror default to a split beside the invoking remote pane. Mirror does not preserve overlay presentation, and Herdr 0.8.2 opens an overlay in its server's active tab, which can differ from the tab you are viewing. diff --git a/crates/plannotator-tui/src/app/archive_view.rs b/crates/plannotator-tui/src/app/archive_view.rs index 2b47c7b..5e6c497 100644 --- a/crates/plannotator-tui/src/app/archive_view.rs +++ b/crates/plannotator-tui/src/app/archive_view.rs @@ -1,22 +1,24 @@ //! A small archive picker: identify a note by file, quote and body, then restore it. use ratatui::Frame; -use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::Rect; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; use super::{App, Mode, label}; +use crate::keys::{self, Action}; impl App { pub(super) fn archive_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Char('j') | KeyCode::Down => self.move_archive_cursor(1), - KeyCode::Char('k') | KeyCode::Up => self.move_archive_cursor(-1), - KeyCode::Enter => self.restore_selected_archived(), - KeyCode::Esc | KeyCode::Char('H' | 'q') => self.mode = Mode::Browse, - KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => self.mode = Mode::Browse, + match self.keys.action(&key, keys::ARCHIVE) { + Some(Action::Down) => self.move_archive_cursor(1), + Some(Action::Up) => self.move_archive_cursor(-1), + Some(Action::Confirm) => self.restore_selected_archived(), + Some(Action::Back | Action::Archive | Action::Quit | Action::Interrupt) => { + self.mode = Mode::Browse; + } _ => {} } } diff --git a/crates/plannotator-tui/src/app/draw.rs b/crates/plannotator-tui/src/app/draw.rs index 6bb4173..ddb4e52 100644 --- a/crates/plannotator-tui/src/app/draw.rs +++ b/crates/plannotator-tui/src/app/draw.rs @@ -11,6 +11,7 @@ use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; use unicode_width::UnicodeWidthStr; use super::{App, Focus, GUTTER, Geometry, Mode, TOOLBAR, glyph, label}; +use crate::keys::Action; use crate::theme::palette; use crate::wrap::wrap_line; @@ -234,7 +235,8 @@ impl App { } fn draw_toolbar(&mut self, frame: &mut Frame) { - let labels: Vec = TOOLBAR.iter().map(|(g, l, k, _)| format!(" {g} {l} ({k}) ")).collect(); + let labels: Vec = + TOOLBAR.iter().map(|(g, l, a, _)| format!(" {g} {l} ({}) ", self.keys.hint(*a))).collect(); let width: u16 = labels.iter().map(|l| l.width() as u16).sum::() + 1; let Some(rect) = self.float_origin(1, width) else { return }; frame.render_widget(Clear, rect); @@ -374,8 +376,11 @@ impl App { // The question owns the footer: the browse help would name keys that are not // live while it is up. let question = format!( - " send feedback to {} before quitting? y send · n quit · esc cancel", - self.delivery.describe() + " send feedback to {} before quitting? {} send · {} quit · {} cancel", + self.delivery.describe(), + self.keys.hint(Action::Yes), + self.keys.hint(Action::No), + self.keys.hint(Action::Back) ); frame.render_widget(Paragraph::new(Line::from(Span::raw(question).bold())), area); return; @@ -410,14 +415,49 @@ impl App { if frame.area().width < RAIL_MIN_TOTAL_WIDTH { parts.push("rail hidden: widen to ≥80 cols".into()); } - // `S` is bound only in a reply review, so only a reply review names it. - let quit = if self.is_file_review() { "q quit " } else { "S send+quit · q quit " }; + let key = |action| self.keys.hint(action); + // `send-and-close` is bound only in a reply review, so only a reply review names it. + let quit = if self.is_file_review() { + format!("{} quit ", key(Action::Quit)) + } else { + format!("{} send+quit · {} quit ", key(Action::SendAndClose), key(Action::Quit)) + }; + let (up_down, tab) = (format!("{}/{}", key(Action::Down), key(Action::Up)), key(Action::CycleFocus)); let help = match self.focus { - _ if self.pending.is_some() => "a looks good · c comment · d delete · esc clear ".to_owned(), - Focus::Tree => "j/k · enter open · . hidden · E send · t hide · q quit ".to_owned(), - Focus::Rail => format!("j/k · e edit · x remove · tab · {quit}"), - Focus::Document if self.roam => format!("hjkl move · v select · c comment · esc blocks · {quit}"), - Focus::Document => format!("i move · v select · c comment · E send · tab · {quit}"), + _ if self.pending.is_some() => format!( + "{} looks good · {} comment · {} delete · {} clear ", + key(Action::LooksGood), + key(Action::Comment), + key(Action::Delete), + key(Action::Back) + ), + Focus::Tree => format!( + "{up_down} · {} open · {} hidden · {} send · {} hide · {} quit ", + key(Action::Confirm), + key(Action::ToggleHidden), + key(Action::Send), + key(Action::ToggleTree), + key(Action::Quit) + ), + Focus::Rail => format!( + "{up_down} · {} edit · {} remove · {tab} · {quit}", + key(Action::Edit), + key(Action::Remove) + ), + Focus::Document if self.roam => format!( + "{} move · {} select · {} comment · {} blocks · {quit}", + self.keys.directions_hint(), + key(Action::Visual), + key(Action::Comment), + key(Action::Back) + ), + Focus::Document => format!( + "{} move · {} select · {} comment · {} send · {tab} · {quit}", + key(Action::MoveCursor), + key(Action::Visual), + key(Action::Comment), + key(Action::Send) + ), }; // The status must stay readable at any width, so the key help yields columns to it // (and is clipped) rather than the other way round. diff --git a/crates/plannotator-tui/src/app/input.rs b/crates/plannotator-tui/src/app/input.rs index 12fb3fe..3982e05 100644 --- a/crates/plannotator-tui/src/app/input.rs +++ b/crates/plannotator-tui/src/app/input.rs @@ -2,9 +2,7 @@ use anyhow::Result; use plannotator_tui_schema::Kind; -use ratatui::crossterm::event::{ - Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, -}; +use ratatui::crossterm::event::{Event, KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind}; use super::compose::ComposeAction; use super::menu::ReviewAction; @@ -12,6 +10,7 @@ use super::selection::Selection; use super::send::SendState; use super::{App, Focus, GUTTER, Mode, Pending, TOOLBAR}; use crate::delivery::Delivery as _; +use crate::keys::{self, Action}; impl App { pub(crate) fn handle_event(&mut self, event: &Event) -> Result<()> { @@ -46,46 +45,46 @@ impl App { fn browse_key(&mut self, key: KeyEvent) -> Result<()> { // Global keys first. - match (key.code, key.modifiers) { - (KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + match self.keys.action(&key, keys::GLOBAL) { + Some(Action::Quit | Action::Interrupt) => { self.request_quit(); return Ok(()); } - (KeyCode::Tab, _) => { + Some(Action::CycleFocus) => { self.cycle_focus(); return Ok(()); } - (KeyCode::Char('E'), _) => return self.send_feedback(), + Some(Action::Send) => return self.send_feedback(), // A reply review sends every note, so one the agent already has closes like `q`. - (KeyCode::Char('S'), _) if !self.is_file_review() => { + Some(Action::SendAndClose) if !self.is_file_review() => { if self.send_state == SendState::Sent { self.request_quit(); return Ok(()); } return self.send_and_quit(); } - (KeyCode::Char('m'), _) if self.is_file_review() => { + Some(Action::ReviewMenu) if self.is_file_review() => { self.open_review_menu(); return Ok(()); } - (KeyCode::Char('R'), _) if self.is_file_review() => { + Some(Action::ResendAll) if self.is_file_review() => { return self.run_review_action(ReviewAction::ResendAll); } - (KeyCode::Char('F'), _) if self.is_file_review() => { + Some(Action::FinishReview) if self.is_file_review() => { return self.run_review_action(ReviewAction::Finish); } - (KeyCode::Char('U'), _) if self.is_file_review() => { + Some(Action::UndoFinish) if self.is_file_review() => { return self.run_review_action(ReviewAction::Undo); } - (KeyCode::Char('H'), _) if self.is_file_review() => { + Some(Action::Archive) if self.is_file_review() => { return self.run_review_action(ReviewAction::Archive); } - (KeyCode::Char('t'), _) => { + Some(Action::ToggleTree) => { self.toggle_tree(self.geometry.doc.width + self.geometry.tree.width + GUTTER); return Ok(()); } - (KeyCode::Char('r'), _) => return self.reload(), - (KeyCode::Char('p'), _) => { + Some(Action::Reload) => return self.reload(), + Some(Action::PickMessage) => { self.reopen_picker(); return Ok(()); } @@ -100,16 +99,16 @@ impl App { /// The quit confirmation: send first, quit anyway, or stay. fn confirm_quit_key(&mut self, key: KeyEvent) -> Result<()> { - match key.code { - KeyCode::Char('y' | 'Y') | KeyCode::Enter => { + match self.keys.action(&key, keys::CONFIRM_QUIT) { + Some(Action::Yes | Action::Confirm) => { self.mode = Mode::Browse; self.send_and_quit()?; } - KeyCode::Char('n' | 'N') => { + Some(Action::No) => { self.mode = Mode::Browse; self.quit = true; } - KeyCode::Esc => self.mode = Mode::Browse, + Some(Action::Back) => self.mode = Mode::Browse, _ => {} } Ok(()) @@ -127,14 +126,14 @@ impl App { fn tree_key(&mut self, key: KeyEvent) -> Result<()> { let len = self.tree_len(); - match key.code { - KeyCode::Char('j') | KeyCode::Down => { + match self.keys.action(&key, keys::TREE) { + Some(Action::Down) => { self.tree_cursor = (self.tree_cursor + 1).min(len.saturating_sub(1)); } - KeyCode::Char('k') | KeyCode::Up => self.tree_cursor = self.tree_cursor.saturating_sub(1), - KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.open_tree_selection()?, - KeyCode::Char('.') => self.toggle_tree_hidden()?, - KeyCode::Esc => self.focus = Focus::Document, + Some(Action::Up) => self.tree_cursor = self.tree_cursor.saturating_sub(1), + Some(Action::Confirm | Action::Right) => self.open_tree_selection()?, + Some(Action::ToggleHidden) => self.toggle_tree_hidden()?, + Some(Action::Back) => self.focus = Focus::Document, _ => {} } // The tree's height is the last frame's; the first frame has not drawn yet, but its cursor is row 0. @@ -144,14 +143,14 @@ impl App { fn rail_key(&mut self, key: KeyEvent) -> Result<()> { let len = self.open.store.placed().len(); - match key.code { - KeyCode::Char('j') | KeyCode::Down => { + match self.keys.action(&key, keys::RAIL) { + Some(Action::Down) => { self.rail_cursor = (self.rail_cursor + 1).min(len.saturating_sub(1)); } - KeyCode::Char('k') | KeyCode::Up => self.rail_cursor = self.rail_cursor.saturating_sub(1), - KeyCode::Enter | KeyCode::Char('e') => self.edit_selected_annotation(), - KeyCode::Char('x') | KeyCode::Delete => self.remove_selected_annotation()?, - KeyCode::Esc => self.focus = Focus::Document, + Some(Action::Up) => self.rail_cursor = self.rail_cursor.saturating_sub(1), + Some(Action::Confirm | Action::Edit) => self.edit_selected_annotation(), + Some(Action::Remove) => self.remove_selected_annotation()?, + Some(Action::Back) => self.focus = Focus::Document, _ => {} } if let Some(target) = self.open.store.placed().get(self.rail_cursor) @@ -167,7 +166,8 @@ impl App { let page = i64::from(self.geometry.doc.height.max(1)); // With a selection waiting, the toolbar keys win. if self.pending.is_some() - && let Some(&(_, _, _, kind)) = TOOLBAR.iter().find(|(_, _, k, _)| KeyCode::Char(*k) == key.code) + && let Some(action) = self.keys.action(&key, keys::TOOLBAR) + && let Some(&(_, _, _, kind)) = TOOLBAR.iter().find(|item| item.2 == action) { return self.act(kind); } @@ -179,7 +179,7 @@ impl App { // Roaming (`i`): the cursor moves with nothing selected yet, so `v` can start // anywhere. Other keys fall through to their block-mode meaning. if self.roam && self.selection.is_none() { - if key.code == KeyCode::Esc { + if self.keys.is(Action::Back, &key) { self.roam = false; self.status = None; return Ok(()); @@ -188,35 +188,39 @@ impl App { return Ok(()); } } - match (key.code, key.modifiers) { - (KeyCode::Esc, _) => { + match self.keys.action(&key, keys::DOCUMENT) { + Some(Action::Back) => { if self.pending.is_some() || self.selection.is_some() { self.clear_selection(); } else { self.request_quit(); } } - (KeyCode::Char('v'), _) => { + Some(Action::Visual) => { self.clear_selection(); self.selection = Some(Selection::start(self.cursor)); - self.status = Some("visual: move to extend, enter to select, esc to cancel".into()); - } - (KeyCode::Char('i'), _) => self.start_roaming(), - (KeyCode::Char('j') | KeyCode::Down, _) => self.select_block(self.selected + 1), - (KeyCode::Char('k') | KeyCode::Up, _) => self.select_block(self.selected.saturating_sub(1)), + self.status = Some(format!( + "visual: move to extend, {} to select, {} to cancel", + self.keys.hint(Action::Confirm), + self.keys.hint(Action::Back) + )); + } + Some(Action::MoveCursor) => self.start_roaming(), + Some(Action::Down) => self.select_block(self.selected + 1), + Some(Action::Up) => self.select_block(self.selected.saturating_sub(1)), // A cursor that moves must be visible, so a column move in block mode is a // roaming move: the same key, with the cursor drawn. - (KeyCode::Char('h' | 'l') | KeyCode::Left | KeyCode::Right, _) => { + Some(Action::Left | Action::Right) => { self.start_roaming(); self.motion_key(key); } - (KeyCode::Char('d'), KeyModifiers::CONTROL) | (KeyCode::PageDown, _) => self.page_by(page / 2), - (KeyCode::Char('u'), KeyModifiers::CONTROL) | (KeyCode::PageUp, _) => self.page_by(-page / 2), - (KeyCode::Char('g') | KeyCode::Home, _) => self.select_block(0), - (KeyCode::Char('G') | KeyCode::End, _) => { + Some(Action::PageDown) => self.page_by(page / 2), + Some(Action::PageUp) => self.page_by(-page / 2), + Some(Action::Top) => self.select_block(0), + Some(Action::Bottom) => { self.select_block(self.open.doc.blocks.len().saturating_sub(1)); } - (KeyCode::Char('c') | KeyCode::Enter, _) => { + Some(Action::Comment | Action::Confirm) => { // No selection: comment on the whole selected block. if let (Some(block), Some(rendered)) = (self.open.doc.blocks.get(self.selected), self.open.layout.blocks.get(self.selected)) @@ -225,7 +229,7 @@ impl App { self.act(Kind::Comment)?; } } - (KeyCode::Char('x'), _) => { + Some(Action::ClearBlock) => { let removed = self.open.store.remove_in_block(&self.open.doc, self.selected)?; if removed > 0 { self.mark_unsent(); @@ -240,9 +244,9 @@ impl App { /// Keys while a keyboard selection is being extended. fn visual_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => self.clear_selection(), - KeyCode::Enter | KeyCode::Char('v') => self.finish_selection(), + match self.keys.action(&key, keys::VISUAL) { + Some(Action::Back) => self.clear_selection(), + Some(Action::Confirm | Action::Visual) => self.finish_selection(), _ => { self.motion_key(key); } @@ -256,20 +260,30 @@ impl App { fn start_roaming(&mut self) { self.clear_selection(); self.roam = true; - self.status = Some("move: hjkl w b 0 $ · v select · esc back to blocks".into()); + let hint = |action| self.keys.hint(action); + self.status = Some(format!( + "move: {} {} {} {} {} · {} select · {} back to blocks", + self.keys.directions_hint(), + hint(Action::WordForward), + hint(Action::WordBack), + hint(Action::LineStart), + hint(Action::LineEnd), + hint(Action::Visual), + hint(Action::Back) + )); } /// The cursor motions shared by visual and roaming modes. True when `key` was one. fn motion_key(&mut self, key: KeyEvent) -> bool { - match key.code { - KeyCode::Char('h') | KeyCode::Left => self.move_cursor(0, -1), - KeyCode::Char('l') | KeyCode::Right => self.move_cursor(0, 1), - KeyCode::Char('j') | KeyCode::Down => self.move_cursor(1, 0), - KeyCode::Char('k') | KeyCode::Up => self.move_cursor(-1, 0), - KeyCode::Char('w') => self.move_word(1), - KeyCode::Char('b') => self.move_word(-1), - KeyCode::Char('0') | KeyCode::Home => self.cursor.1 = 0, - KeyCode::Char('$') | KeyCode::End => { + match self.keys.action(&key, keys::MOTION) { + Some(Action::Left) => self.move_cursor(0, -1), + Some(Action::Right) => self.move_cursor(0, 1), + Some(Action::Down) => self.move_cursor(1, 0), + Some(Action::Up) => self.move_cursor(-1, 0), + Some(Action::WordForward) => self.move_word(1), + Some(Action::WordBack) => self.move_word(-1), + Some(Action::LineStart) => self.cursor.1 = 0, + Some(Action::LineEnd) => { self.cursor.1 = self.open.layout.row(self.cursor.0).map_or(0, |r| r.cells.len().saturating_sub(1)); } diff --git a/crates/plannotator-tui/src/app/menu.rs b/crates/plannotator-tui/src/app/menu.rs index 0fba3b6..614704d 100644 --- a/crates/plannotator-tui/src/app/menu.rs +++ b/crates/plannotator-tui/src/app/menu.rs @@ -3,7 +3,7 @@ use anyhow::Result; use ratatui::Frame; -use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::Rect; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; @@ -11,6 +11,7 @@ use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; use unicode_width::UnicodeWidthStr as _; use super::{App, Mode}; +use crate::keys::{self, Action}; use crate::theme::palette; #[cfg(test)] @@ -31,18 +32,18 @@ const REVIEW_ACTIONS: [ReviewAction; 4] = const MENU_HELP: &str = " \u{2191}\u{2193} move \u{b7} enter run \u{b7} esc close "; impl ReviewAction { - /// The key that runs the action, from the menu and directly in the review. - fn key(self) -> char { + /// The key binding that runs the action, from the menu and directly in the review. + fn binding(self) -> Action { match self { - Self::ResendAll => 'R', - Self::Finish => 'F', - Self::Undo => 'U', - Self::Archive => 'H', + Self::ResendAll => Action::ResendAll, + Self::Finish => Action::FinishReview, + Self::Undo => Action::UndoFinish, + Self::Archive => Action::Archive, } } - fn from_key(key: char) -> Option { - REVIEW_ACTIONS.into_iter().find(|action| action.key() == key) + fn for_binding(binding: Action) -> Option { + REVIEW_ACTIONS.into_iter().find(|action| action.binding() == binding) } } @@ -111,24 +112,23 @@ impl App { } pub(super) fn menu_key(&mut self, key: KeyEvent) -> Result<()> { - match key.code { - KeyCode::Char('j') | KeyCode::Down => self.move_menu_cursor(true), - KeyCode::Char('k') | KeyCode::Up => self.move_menu_cursor(false), - KeyCode::Enter => { + match self.keys.action(&key, keys::MENU) { + Some(Action::Down) => self.move_menu_cursor(true), + Some(Action::Up) => self.move_menu_cursor(false), + Some(Action::Confirm) => { if let Some(&action) = REVIEW_ACTIONS.get(self.menu_cursor) && self.action_applies(action) { return self.run_review_action(action); } } - KeyCode::Esc | KeyCode::Char('m') => self.mode = Mode::Browse, - KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => self.mode = Mode::Browse, - KeyCode::Char(ch) => { - if let Some(action) = ReviewAction::from_key(ch) { + Some(Action::Back | Action::ReviewMenu | Action::Interrupt) => self.mode = Mode::Browse, + Some(binding) => { + if let Some(action) = ReviewAction::for_binding(binding) { return self.run_review_action(action); } } - _ => {} + None => {} } Ok(()) } @@ -161,7 +161,9 @@ impl App { let area = frame.area(); let rows: Vec<(ReviewAction, String)> = REVIEW_ACTIONS .iter() - .map(|&action| (action, format!(" {} {} ", action.key(), self.action_label(action)))) + .map(|&action| { + (action, format!(" {} {} ", self.keys.hint(action.binding()), self.action_label(action))) + }) .collect(); let content = rows.iter().map(|(_, text)| text.width()).max().unwrap_or(0).max(MENU_HELP.width()); let width = (content as u16 + 2).min(area.width); diff --git a/crates/plannotator-tui/src/app/mod.rs b/crates/plannotator-tui/src/app/mod.rs index 7115697..140d2d6 100644 --- a/crates/plannotator-tui/src/app/mod.rs +++ b/crates/plannotator-tui/src/app/mod.rs @@ -28,6 +28,7 @@ use ratatui::layout::Rect; use crate::delivery::Delivery; use crate::doc::Document; +use crate::keys::{Action, Keymap}; use crate::layout::DocLayout; use crate::store::{Location, Store}; use crate::tree::Tree; @@ -39,10 +40,10 @@ use send::SendState; pub(super) const GUTTER: u16 = 2; /// Toolbar items in display order: (glyph, label, key, kind). -const TOOLBAR: [(&str, &str, char, Kind); 3] = [ - ("👍", "looks good", 'a', Kind::LooksGood), - ("💬", "comment", 'c', Kind::Comment), - ("✗", "delete", 'd', Kind::Delete), +const TOOLBAR: [(&str, &str, Action, Kind); 3] = [ + ("👍", "looks good", Action::LooksGood, Kind::LooksGood), + ("💬", "comment", Action::Comment, Kind::Comment), + ("✗", "delete", Action::Delete, Kind::Delete), ]; #[derive(Debug, PartialEq, Eq)] @@ -184,6 +185,7 @@ pub(crate) struct App { compose: Compose, /// Whether the terminal reports Shift+Enter distinctly (kitty keyboard protocol). pub(super) shift_enter: bool, + pub(crate) keys: Keymap, /// The last primary-button press, for double-click detection. last_click: Option<(std::time::Instant, u16, u16)>, geometry: Geometry, @@ -251,6 +253,7 @@ impl App { terminal_breaks: None, compose: Compose::default(), shift_enter: false, + keys: Keymap::default(), last_click: None, geometry: Geometry::default(), status: None, diff --git a/crates/plannotator-tui/src/app/pick.rs b/crates/plannotator-tui/src/app/pick.rs index c41f84e..24645a9 100644 --- a/crates/plannotator-tui/src/app/pick.rs +++ b/crates/plannotator-tui/src/app/pick.rs @@ -8,7 +8,7 @@ use std::sync::OnceLock; use anyhow::Result; use plannotator_tui_hosts::Message; use ratatui::Frame; -use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::Rect; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; @@ -16,6 +16,7 @@ use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; use unicode_width::UnicodeWidthStr as _; use super::{App, Mode, Open}; +use crate::keys::{self, Action}; use crate::last::message_source; use crate::theme::palette; @@ -98,22 +99,22 @@ impl App { pub(super) fn pick_key(&mut self, key: KeyEvent) -> Result<()> { let last = self.candidates.len().saturating_sub(1); - match key.code { - KeyCode::Char('j') | KeyCode::Down => { + match self.keys.action(&key, keys::PICK) { + Some(Action::Down) => { self.pick_cursor = (self.pick_cursor + 1).min(last); return self.show_candidate(self.pick_cursor); } - KeyCode::Char('k') | KeyCode::Up => { + Some(Action::Up) => { self.pick_cursor = self.pick_cursor.saturating_sub(1); return self.show_candidate(self.pick_cursor); } - KeyCode::Enter => return self.open_candidate(self.pick_cursor), - KeyCode::Esc => { + Some(Action::Confirm) => return self.open_candidate(self.pick_cursor), + Some(Action::Back) => { self.show_candidate(self.pick_return)?; self.pick_cursor = self.pick_return; self.mode = Mode::Browse; } - KeyCode::Char('q') => self.quit = true, + Some(Action::Quit) => self.quit = true, _ => {} } Ok(()) diff --git a/crates/plannotator-tui/src/app/tests.rs b/crates/plannotator-tui/src/app/tests.rs index 49449f9..67701f4 100644 --- a/crates/plannotator-tui/src/app/tests.rs +++ b/crates/plannotator-tui/src/app/tests.rs @@ -736,3 +736,18 @@ fn a_reply_already_sent_is_not_sent_again_from_another_reply() { assert!(calls[1].contains("on the middle"), "{}", calls[1]); assert!(!calls[1].contains("on the newest"), "the newest reply was sent already: {}", calls[1]); } + +/// `[keys]` drives the app and its hints: the rebound key moves, the key it replaced does not. +#[test] +fn rebound_movement_keys_move_and_the_footer_names_them() { + let mut app = app(Box::new(Discard)); + app.keys = + toml::from_str("left = [\"j\"]\ndown = [\"k\"]\nup = [\"l\"]\nright = [\";\"]\n").expect("keys"); + keys(&mut app, &[KeyCode::Char('j')]); + assert_eq!(app.selected, 0, "j is left now, and a column move starts the cursor"); + keys(&mut app, &[KeyCode::Esc, KeyCode::Char('k')]); + assert_eq!(app.selected, 1); + app.roam = true; + let footer = draw(&mut app).pop().expect("footer"); + assert!(footer.contains("jkl; move"), "{footer:?}"); +} diff --git a/crates/plannotator-tui/src/cli.rs b/crates/plannotator-tui/src/cli.rs index e4c3e78..126b6c2 100644 --- a/crates/plannotator-tui/src/cli.rs +++ b/crates/plannotator-tui/src/cli.rs @@ -334,11 +334,12 @@ fn interactive(path: &PathBuf) -> Result<()> { pub(crate) fn run_ui(build: impl FnOnce(usize) -> Result) -> Result<()> { // Settle the palette before the screen is ours: the background-colour query talks to // the terminal directly, and it must not race the alternate screen or the event loop. + // A config that fails to parse never kept the plain TUI from starting; it still does + // not. `plannotator-tui config` is where the error is reported. + let config = Config::load().unwrap_or_default(); crate::theme::install(crate::theme::resolve( |key| std::env::var(key).ok(), - // A config that fails to parse never kept the plain TUI from starting; it still does - // not. `plannotator-tui config` is where the error is reported. - Config::load().map(|config| config.ui.theme).unwrap_or_default(), + config.ui.theme, crate::theme::detect, )?); let mut terminal = ratatui::init(); @@ -357,6 +358,7 @@ pub(crate) fn run_ui(build: impl FnOnce(usize) -> Result) -> Result<()> { let width = doc_width(terminal.size().map_or(120, |s| s.width)); let result = build(width).and_then(|mut app| { app.shift_enter = shift_enter; + app.keys = config.keys; event_loop(&mut terminal, app) }); if shift_enter { diff --git a/crates/plannotator-tui/src/config.rs b/crates/plannotator-tui/src/config.rs index 999d7d4..79d5bdc 100644 --- a/crates/plannotator-tui/src/config.rs +++ b/crates/plannotator-tui/src/config.rs @@ -11,6 +11,7 @@ use std::str::FromStr; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; +use crate::keys::Keymap; use crate::theme::ThemeSetting; #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] @@ -18,6 +19,7 @@ use crate::theme::ThemeSetting; pub(crate) struct Config { pub(crate) herdr: HerdrConfig, pub(crate) ui: UiConfig, + pub(crate) keys: Keymap, } /// How the app looks. One key so far; the colours themselves are not configurable yet. diff --git a/crates/plannotator-tui/src/keys.rs b/crates/plannotator-tui/src/keys.rs new file mode 100644 index 0000000..5f8418a --- /dev/null +++ b/crates/plannotator-tui/src/keys.rs @@ -0,0 +1,328 @@ +//! Key bindings: every key the viewer acts on, by action name, rebindable under `[keys]`. +//! +//! An action keeps its name wherever it applies, so `down = ["n", "Down"]` moves down in the +//! document, the tree, the notes and every picker. A context lists the actions one mode +//! dispatches, in the order it tries them; two actions that read the keyboard at the same +//! time may not share a key, and a config that binds one twice is an error that names both. + +use std::collections::BTreeMap; +use std::fmt; +use std::str::FromStr; + +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum Action { + Up, + Down, + Left, + Right, + WordForward, + WordBack, + LineStart, + LineEnd, + PageDown, + PageUp, + Top, + Bottom, + Confirm, + Back, + Quit, + Interrupt, + CycleFocus, + Send, + SendAndClose, + ToggleTree, + Reload, + PickMessage, + ReviewMenu, + ResendAll, + FinishReview, + UndoFinish, + Archive, + ToggleHidden, + Edit, + Remove, + Visual, + MoveCursor, + Comment, + ClearBlock, + LooksGood, + Delete, + Yes, + No, +} + +use Action::{ + Archive, Back, Bottom, ClearBlock, Comment, Confirm, CycleFocus, Delete, Down, Edit, FinishReview, + Interrupt, Left, LineEnd, LineStart, LooksGood, MoveCursor, No, PageDown, PageUp, PickMessage, Quit, + Reload, Remove, ResendAll, ReviewMenu, Right, Send, SendAndClose, ToggleHidden, ToggleTree, Top, + UndoFinish, Up, Visual, WordBack, WordForward, Yes, +}; + +/// Browsing, before the focused pane gets the key. +pub(crate) const GLOBAL: &[Action] = &[ + Quit, + Interrupt, + CycleFocus, + Send, + SendAndClose, + ReviewMenu, + ResendAll, + FinishReview, + UndoFinish, + Archive, + ToggleTree, + Reload, + PickMessage, +]; +pub(crate) const TREE: &[Action] = &[Down, Up, Confirm, Right, ToggleHidden, Back]; +pub(crate) const RAIL: &[Action] = &[Down, Up, Confirm, Edit, Remove, Back]; +pub(crate) const DOCUMENT: &[Action] = &[ + Back, Visual, MoveCursor, Down, Up, Left, Right, PageDown, PageUp, Top, Bottom, Comment, Confirm, + ClearBlock, +]; +/// Cursor motions, in visual mode and while moving the cursor (`move-cursor`). +pub(crate) const MOTION: &[Action] = &[Left, Right, Down, Up, WordForward, WordBack, LineStart, LineEnd]; +/// While a keyboard selection grows; any other key is a motion. +pub(crate) const VISUAL: &[Action] = &[Back, Confirm, Visual]; +/// While a finished selection waits for its kind. +pub(crate) const TOOLBAR: &[Action] = &[LooksGood, Comment, Delete]; +pub(crate) const CONFIRM_QUIT: &[Action] = &[Yes, Confirm, No, Back]; +pub(crate) const MENU: &[Action] = + &[Down, Up, Confirm, Back, ReviewMenu, Interrupt, ResendAll, FinishReview, UndoFinish, Archive]; +pub(crate) const PICK: &[Action] = &[Down, Up, Confirm, Back, Quit]; +pub(crate) const ARCHIVE: &[Action] = &[Down, Up, Confirm, Back, Archive, Quit, Interrupt]; + +/// Contexts that read the same keypress: the global keys come first while browsing, and +/// roaming tries the motions before the document's own keys. +const SHARED: &[&[&[Action]]] = &[ + &[GLOBAL, TREE], + &[GLOBAL, RAIL], + &[GLOBAL, DOCUMENT], + &[GLOBAL, MOTION], + &[GLOBAL, VISUAL, MOTION], + &[GLOBAL, TOOLBAR], + &[CONFIRM_QUIT], + &[MENU], + &[PICK], + &[ARCHIVE], +]; + +/// Named keys, spelled as the config file writes them. +const NAMED: [(&str, KeyCode); 13] = [ + ("Up", KeyCode::Up), + ("Down", KeyCode::Down), + ("Left", KeyCode::Left), + ("Right", KeyCode::Right), + ("Home", KeyCode::Home), + ("End", KeyCode::End), + ("PageUp", KeyCode::PageUp), + ("PageDown", KeyCode::PageDown), + ("Enter", KeyCode::Enter), + ("Esc", KeyCode::Esc), + ("Tab", KeyCode::Tab), + ("Delete", KeyCode::Delete), + ("Backspace", KeyCode::Backspace), +]; + +/// One binding: a character or a named key, optionally with Ctrl. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Key { + code: KeyCode, + ctrl: bool, +} + +const fn ch(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 named(code: KeyCode) -> Key { + Key { code, ctrl: false } +} + +impl Key { + /// A plain binding ignores modifiers, as a shifted letter arrives with Shift set; a Ctrl + /// binding needs Ctrl alone. + fn matches(self, key: &KeyEvent) -> bool { + self.code == key.code && (!self.ctrl || key.modifiers == KeyModifiers::CONTROL) + } + + /// How the in-app hints spell the key: `j`, `G`, `enter`, `ctrl+d`. + fn hint(self) -> String { + match self.code { + KeyCode::Char(_) => self.to_string(), + _ => self.to_string().to_lowercase(), + } + } +} + +impl fmt::Display for Key { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.ctrl { + f.write_str("ctrl+")?; + } + match (self.code, NAMED.iter().find(|(_, code)| *code == self.code)) { + (_, Some((name, _))) => f.write_str(name), + (KeyCode::Char(c), None) => write!(f, "{c}"), + (other, None) => write!(f, "{other:?}"), + } + } +} + +impl FromStr for Key { + type Err = anyhow::Error; + + fn from_str(s: &str) -> anyhow::Result { + let (ctrl, rest) = s.strip_prefix("ctrl+").map_or((false, s), |rest| (true, rest)); + if let Some((_, code)) = NAMED.iter().find(|(name, _)| name.eq_ignore_ascii_case(rest)) { + return Ok(Self { code: *code, ctrl }); + } + let mut chars = rest.chars(); + if let (Some(c), None) = (chars.next(), chars.next()) { + return Ok(Self { code: KeyCode::Char(c), ctrl }); + } + let names: Vec<&str> = NAMED.iter().map(|(name, _)| *name).collect(); + anyhow::bail!( + "unknown key {s:?}; expected one character, ctrl+, or one of {}", + names.join(", ") + ) + } +} + +impl Serialize for Key { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for Key { + fn deserialize>(deserializer: D) -> Result { + let text = String::deserialize(deserializer)?; + text.parse().map_err(serde::de::Error::custom) + } +} + +const DEFAULTS: &[(Action, &[Key])] = &[ + (Up, &[ch('k'), named(KeyCode::Up)]), + (Down, &[ch('j'), named(KeyCode::Down)]), + (Left, &[ch('h'), named(KeyCode::Left)]), + (Right, &[ch('l'), named(KeyCode::Right)]), + (WordForward, &[ch('w')]), + (WordBack, &[ch('b')]), + (LineStart, &[ch('0'), named(KeyCode::Home)]), + (LineEnd, &[ch('$'), named(KeyCode::End)]), + (PageDown, &[ctrl('d'), named(KeyCode::PageDown)]), + (PageUp, &[ctrl('u'), named(KeyCode::PageUp)]), + (Top, &[ch('g'), named(KeyCode::Home)]), + (Bottom, &[ch('G'), named(KeyCode::End)]), + (Confirm, &[named(KeyCode::Enter)]), + (Back, &[named(KeyCode::Esc)]), + (Quit, &[ch('q')]), + (Interrupt, &[ctrl('c')]), + (CycleFocus, &[named(KeyCode::Tab)]), + (Send, &[ch('E')]), + (SendAndClose, &[ch('S')]), + (ToggleTree, &[ch('t')]), + (Reload, &[ch('r')]), + (PickMessage, &[ch('p')]), + (ReviewMenu, &[ch('m')]), + (ResendAll, &[ch('R')]), + (FinishReview, &[ch('F')]), + (UndoFinish, &[ch('U')]), + (Archive, &[ch('H')]), + (ToggleHidden, &[ch('.')]), + (Edit, &[ch('e')]), + (Remove, &[ch('x'), named(KeyCode::Delete)]), + (Visual, &[ch('v')]), + (MoveCursor, &[ch('i')]), + (Comment, &[ch('c')]), + (ClearBlock, &[ch('x')]), + (LooksGood, &[ch('a')]), + (Delete, &[ch('d')]), + (Yes, &[ch('y'), ch('Y')]), + (No, &[ch('n'), ch('N')]), +]; + +/// The keys bound to each action. A `[keys]` entry replaces that action's keys and leaves the +/// others at their defaults; an empty list unbinds the action. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct Keymap(BTreeMap>); + +impl Default for Keymap { + fn default() -> Self { + Self(DEFAULTS.iter().map(|(action, keys)| (*action, keys.to_vec())).collect()) + } +} + +impl<'de> Deserialize<'de> for Keymap { + fn deserialize>(deserializer: D) -> Result { + let mut keymap = Self::default(); + keymap.0.extend(BTreeMap::>::deserialize(deserializer)?); + keymap.check().map_err(serde::de::Error::custom)?; + Ok(keymap) + } +} + +impl Keymap { + /// The first action in `context` bound to `key`. + pub(crate) fn action(&self, key: &KeyEvent, context: &[Action]) -> Option { + context.iter().copied().find(|&action| self.is(action, key)) + } + + pub(crate) fn is(&self, action: Action, key: &KeyEvent) -> bool { + self.keys(action).iter().any(|k| k.matches(key)) + } + + /// The action's first key as the hints spell it; empty when it is unbound. + pub(crate) fn hint(&self, action: Action) -> String { + self.keys(action).first().map(|key| key.hint()).unwrap_or_default() + } + + /// The four cursor directions run together the way the hints show them: `hjkl`. + pub(crate) fn directions_hint(&self) -> String { + [Left, Down, Up, Right].map(|action| self.hint(action)).concat() + } + + fn keys(&self, action: Action) -> &[Key] { + self.0.get(&action).map_or(&[], Vec::as_slice) + } + + /// No key may mean two actions where both read the same keypress. + fn check(&self) -> Result<(), String> { + for contexts in SHARED { + let mut seen: Vec<(Key, Action)> = Vec::new(); + for &action in contexts.iter().copied().flatten() { + for &key in self.keys(action) { + match seen.iter().find(|(k, _)| *k == key) { + Some(&(_, other)) if other != action => { + return Err(format!( + "key {:?} is bound to both {} and {}", + key.to_string(), + name(other), + name(action) + )); + } + Some(_) => {} + None => seen.push((key, action)), + } + } + } + } + Ok(()) + } +} + +/// The action's name as `[keys]` writes it. +fn name(action: Action) -> String { + toml::Value::try_from(action).ok().and_then(|v| v.as_str().map(str::to_owned)).unwrap_or_default() +} + +#[cfg(test)] +mod tests; diff --git a/crates/plannotator-tui/src/keys/tests.rs b/crates/plannotator-tui/src/keys/tests.rs new file mode 100644 index 0000000..10512f8 --- /dev/null +++ b/crates/plannotator-tui/src/keys/tests.rs @@ -0,0 +1,77 @@ +#![allow(clippy::expect_used, reason = "tests assert by panicking")] + +use super::*; + +fn parse(text: &str) -> Result { + toml::from_str(text) +} + +fn press(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, modifiers) +} + +#[test] +fn the_defaults_bind_no_key_twice() { + assert_eq!(Keymap::default().check(), Ok(())); +} + +#[test] +fn an_entry_replaces_only_its_own_action() { + let keymap = + parse("left = [\"j\", \"Left\"]\ndown = [\"k\"]\nup = [\"l\"]\nright = [\";\"]\n").expect("parses"); + assert_eq!(keymap.action(&press(KeyCode::Char('j'), KeyModifiers::NONE), MOTION), Some(Left)); + assert_eq!(keymap.action(&press(KeyCode::Char(';'), KeyModifiers::NONE), TREE), Some(Right)); + assert_eq!(keymap.action(&press(KeyCode::Down, KeyModifiers::NONE), MOTION), None); + assert_eq!(keymap.action(&press(KeyCode::Char('w'), KeyModifiers::NONE), MOTION), Some(WordForward)); + assert_eq!(keymap.directions_hint(), "jkl;"); +} + +#[test] +fn a_key_two_live_actions_share_is_an_error_naming_both() { + let err = parse("left = [\"j\"]\n").expect_err("j is still down"); + let message = err.message(); + assert!(message.contains("\"j\"") && message.contains("down") && message.contains("left"), "{message}"); +} + +#[test] +fn a_key_may_mean_different_actions_in_different_modes() { + // `Home` is `top` among blocks and `line-start` for the cursor; they never read the same press. + let keymap = Keymap::default(); + let home = press(KeyCode::Home, KeyModifiers::NONE); + assert_eq!(keymap.action(&home, DOCUMENT), Some(Top)); + assert_eq!(keymap.action(&home, MOTION), Some(LineStart)); +} + +#[test] +fn an_unknown_action_error_names_it() { + let err = parse("lefty = [\"h\"]\n").expect_err("rejected"); + assert!(err.message().contains("lefty"), "{}", err.message()); +} + +#[test] +fn an_unknown_key_error_names_it() { + let err = parse("left = [\"Leftt\"]\n").expect_err("rejected"); + assert!(err.message().contains("Leftt"), "{}", err.message()); +} + +#[test] +fn a_ctrl_binding_needs_ctrl_and_a_plain_one_ignores_shift() { + let keymap = Keymap::default(); + assert!(keymap.is(PageDown, &press(KeyCode::Char('d'), KeyModifiers::CONTROL))); + assert!(!keymap.is(PageDown, &press(KeyCode::Char('d'), KeyModifiers::NONE))); + assert!(keymap.is(Bottom, &press(KeyCode::Char('G'), KeyModifiers::SHIFT))); +} + +#[test] +fn named_keys_parse_in_any_case_and_print_as_written_in_the_file() { + let key: Key = "pageup".parse().expect("parses"); + assert_eq!(key.to_string(), "PageUp"); + assert_eq!("ctrl+u".parse::().expect("parses").to_string(), "ctrl+u"); +} + +#[test] +fn an_empty_list_unbinds_the_action() { + let keymap = parse("quit = []\n").expect("parses"); + assert!(!keymap.is(Quit, &press(KeyCode::Char('q'), KeyModifiers::NONE))); + assert_eq!(keymap.hint(Quit), ""); +} diff --git a/crates/plannotator-tui/src/main.rs b/crates/plannotator-tui/src/main.rs index b08126f..6666039 100644 --- a/crates/plannotator-tui/src/main.rs +++ b/crates/plannotator-tui/src/main.rs @@ -9,6 +9,7 @@ mod delivery; mod doc; mod export; mod herdr; +mod keys; mod last; mod layout; mod srcmap; From bf739f30d031176f0dd280486f8d77cc829ddfdb Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Fri, 23 Oct 2026 14:18:56 -0400 Subject: [PATCH 2/2] fix(keys): prefer exact ctrl bindings and name rebound keys in every hint - a ctrl binding wins over a plain binding earlier in the same context - the header, undo button, pickers and archive name the keymap's keys - an unbound action drops out of the hints instead of leaving a bare label - action names come from a plain as_str - readme lists Y and N as yes/no defaults --- README.md | 2 +- .../plannotator-tui/src/app/archive_view.rs | 18 ++- crates/plannotator-tui/src/app/draw.rs | 133 +++++++++++------- crates/plannotator-tui/src/app/header.rs | 13 +- crates/plannotator-tui/src/app/menu.rs | 11 +- crates/plannotator-tui/src/app/menu/tests.rs | 11 ++ crates/plannotator-tui/src/app/pick.rs | 10 +- crates/plannotator-tui/src/app/send.rs | 7 +- crates/plannotator-tui/src/app/tests.rs | 20 +++ crates/plannotator-tui/src/keys.rs | 69 ++++++++- crates/plannotator-tui/src/keys/tests.rs | 31 ++++ 11 files changed, 254 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index eb7412e..55d009f 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ screen show your keys. `plannotator-tui config` prints every action with its key | `visual` `move-cursor` | `v` `i` | select with the cursor; move it first | | `comment` `clear-block` | `c` `x` | on the block; clear its annotations | | `looks-good` `delete` | `a` `d` | toolbar, with `comment` | -| `yes` `no` | `y` `n` | the send-before-quitting question | +| `yes` `no` | `y` `n`, `Y` `N` | the send-before-quitting question | Actions forwarded by Herdr Mirror default to a split beside the invoking remote pane. Mirror does not preserve overlay presentation, and Herdr 0.8.2 opens an diff --git a/crates/plannotator-tui/src/app/archive_view.rs b/crates/plannotator-tui/src/app/archive_view.rs index 5e6c497..90ca137 100644 --- a/crates/plannotator-tui/src/app/archive_view.rs +++ b/crates/plannotator-tui/src/app/archive_view.rs @@ -7,6 +7,7 @@ use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; +use super::draw::picker_help; use super::{App, Mode, label}; use crate::keys::{self, Action}; @@ -64,12 +65,25 @@ impl App { .border_type(BorderType::Rounded) .border_style(Style::new().fg(Color::Cyan)) .title(format!(" Archived annotations ({}) ", self.archive_items.len())) - .title_bottom(" ↑↓ select · enter/click restore · esc close "); + .title_bottom(picker_help( + "select", + [ + // A click restores too, so it is named even with `confirm` unbound. + Some(match self.keys.hint(Action::Confirm) { + key if key.is_empty() => "click restore".to_owned(), + key => format!("{key}/click restore"), + }), + self.keys.labelled(Action::Back, "close"), + ], + )); let inner = block.inner(rect); frame.render_widget(block, rect); if self.archive_items.is_empty() { frame.render_widget( - Paragraph::new("No archived annotations. F finishes sent annotations."), + Paragraph::new(match self.keys.hint(Action::FinishReview) { + key if key.is_empty() => "No archived annotations.".to_owned(), + key => format!("No archived annotations. {key} finishes sent annotations."), + }), inner, ); return; diff --git a/crates/plannotator-tui/src/app/draw.rs b/crates/plannotator-tui/src/app/draw.rs index ddb4e52..decc205 100644 --- a/crates/plannotator-tui/src/app/draw.rs +++ b/crates/plannotator-tui/src/app/draw.rs @@ -235,8 +235,13 @@ impl App { } fn draw_toolbar(&mut self, frame: &mut Frame) { - let labels: Vec = - TOOLBAR.iter().map(|(g, l, a, _)| format!(" {g} {l} ({}) ", self.keys.hint(*a))).collect(); + let labels: Vec = TOOLBAR + .iter() + .map(|(g, l, a, _)| match self.keys.hint(*a) { + key if key.is_empty() => format!(" {g} {l} "), + key => format!(" {g} {l} ({key}) "), + }) + .collect(); let width: u16 = labels.iter().map(|l| l.width() as u16).sum::() + 1; let Some(rect) = self.float_origin(1, width) else { return }; frame.render_widget(Clear, rect); @@ -375,21 +380,22 @@ impl App { if self.mode == Mode::ConfirmQuit { // The question owns the footer: the browse help would name keys that are not // live while it is up. - let question = format!( - " send feedback to {} before quitting? {} send · {} quit · {} cancel", - self.delivery.describe(), - self.keys.hint(Action::Yes), - self.keys.hint(Action::No), - self.keys.hint(Action::Back) - ); + let answers = [(Action::Yes, "send"), (Action::No, "quit"), (Action::Back, "cancel")] + .into_iter() + .filter_map(|(action, label)| self.keys.labelled(action, label)) + .collect::>() + .join(" · "); + let question = + format!(" send feedback to {} before quitting? {answers}", self.delivery.describe()); frame.render_widget(Paragraph::new(Line::from(Span::raw(question).bold())), area); return; } if self.mode == Mode::Browse && !self.undo_archive.is_empty() { - let label = " U Undo finish "; + let label = self.keys.labelled(Action::UndoFinish, "Undo finish"); + let label = format!(" {} ", label.as_deref().unwrap_or("Undo finish")); let width = (label.width() as u16).min(area.width); let rect = Rect { x: area.right() - width, y: area.y, width, height: area.height }; - frame.render_widget(Paragraph::new(label).style(Style::new().fg(Color::Cyan)), rect); + frame.render_widget(Paragraph::new(label.as_str()).style(Style::new().fg(Color::Cyan)), rect); self.geometry.undo_button = Some(rect); area.width = area.width.saturating_sub(width); } @@ -415,50 +421,64 @@ impl App { if frame.area().width < RAIL_MIN_TOTAL_WIDTH { parts.push("rail hidden: widen to ≥80 cols".into()); } - let key = |action| self.keys.hint(action); + // Each hint names its action's first key; an unbound action drops out of the line. + let key = |action, label| self.keys.labelled(action, label); + let bare = |hint: String| (!hint.is_empty()).then_some(hint); + let up_down = bare( + [Action::Down, Action::Up] + .map(|action| self.keys.hint(action)) + .iter() + .filter(|k| !k.is_empty()) + .cloned() + .collect::>() + .join("/"), + ); + let tab = bare(self.keys.hint(Action::CycleFocus)); // `send-and-close` is bound only in a reply review, so only a reply review names it. - let quit = if self.is_file_review() { - format!("{} quit ", key(Action::Quit)) - } else { - format!("{} send+quit · {} quit ", key(Action::SendAndClose), key(Action::Quit)) - }; - let (up_down, tab) = (format!("{}/{}", key(Action::Down), key(Action::Up)), key(Action::CycleFocus)); - let help = match self.focus { - _ if self.pending.is_some() => format!( - "{} looks good · {} comment · {} delete · {} clear ", - key(Action::LooksGood), - key(Action::Comment), - key(Action::Delete), - key(Action::Back) - ), - Focus::Tree => format!( - "{up_down} · {} open · {} hidden · {} send · {} hide · {} quit ", - key(Action::Confirm), - key(Action::ToggleHidden), - key(Action::Send), - key(Action::ToggleTree), - key(Action::Quit) - ), - Focus::Rail => format!( - "{up_down} · {} edit · {} remove · {tab} · {quit}", - key(Action::Edit), - key(Action::Remove) - ), - Focus::Document if self.roam => format!( - "{} move · {} select · {} comment · {} blocks · {quit}", - self.keys.directions_hint(), - key(Action::Visual), - key(Action::Comment), - key(Action::Back) - ), - Focus::Document => format!( - "{} move · {} select · {} comment · {} send · {tab} · {quit}", - key(Action::MoveCursor), - key(Action::Visual), - key(Action::Comment), - key(Action::Send) - ), + let send_and_close = + if self.is_file_review() { None } else { key(Action::SendAndClose, "send+quit") }; + let quit = [send_and_close, key(Action::Quit, "quit")]; + let help: Vec> = match self.focus { + _ if self.pending.is_some() => vec![ + key(Action::LooksGood, "looks good"), + key(Action::Comment, "comment"), + key(Action::Delete, "delete"), + key(Action::Back, "clear"), + ], + Focus::Tree => vec![ + up_down, + key(Action::Confirm, "open"), + key(Action::ToggleHidden, "hidden"), + key(Action::Send, "send"), + key(Action::ToggleTree, "hide"), + key(Action::Quit, "quit"), + ], + Focus::Rail => [up_down, key(Action::Edit, "edit"), key(Action::Remove, "remove"), tab] + .into_iter() + .chain(quit) + .collect(), + Focus::Document if self.roam => [ + bare(self.keys.directions_hint()).map(|keys| format!("{keys} move")), + key(Action::Visual, "select"), + key(Action::Comment, "comment"), + key(Action::Back, "blocks"), + ] + .into_iter() + .chain(quit) + .collect(), + Focus::Document => [ + key(Action::MoveCursor, "move"), + key(Action::Visual, "select"), + key(Action::Comment, "comment"), + key(Action::Send, "send"), + tab, + ] + .into_iter() + .chain(quit) + .collect(), }; + let help: Vec = help.into_iter().flatten().collect(); + let help = if help.is_empty() { String::new() } else { format!("{} ", help.join(" · ")) }; // The status must stay readable at any width, so the key help yields columns to it // (and is clipped) rather than the other way round. let status_width = self.status.as_ref().map_or(0, |s| s.width() + 1) as u16; @@ -478,3 +498,10 @@ fn short_id(id: &str) -> String { let tail: Vec = id.chars().rev().take(5).collect(); tail.into_iter().rev().collect() } + +/// A picker's bottom help: the arrows that move its cursor, then the bound keys. +pub(super) fn picker_help(moves: &str, keys: impl IntoIterator>) -> String { + let parts: Vec = + std::iter::once(format!("\u{2191}\u{2193} {moves}")).chain(keys.into_iter().flatten()).collect(); + format!(" {} ", parts.join(" \u{b7} ")) +} diff --git a/crates/plannotator-tui/src/app/header.rs b/crates/plannotator-tui/src/app/header.rs index e8322e2..3917b95 100644 --- a/crates/plannotator-tui/src/app/header.rs +++ b/crates/plannotator-tui/src/app/header.rs @@ -9,6 +9,7 @@ use unicode_width::UnicodeWidthStr; use super::App; use super::send::SendState; +use crate::keys::Action; use crate::theme::palette; // These three set their own foreground as well as their background, so they read the same @@ -17,7 +18,7 @@ const SEND_BG: Color = Color::Indexed(30); const SENT_BG: Color = Color::Indexed(22); const BLOCKED_BG: Color = Color::Indexed(58); -pub(super) const REVIEW_LABEL: &str = "Review \u{25be} (m)"; +const REVIEW_LABEL: &str = "Review \u{25be}"; #[derive(Debug, Clone, Copy)] enum Button { @@ -34,7 +35,7 @@ impl App { } let mut labels = vec![(Button::Send, self.send_label())]; if self.is_file_review() { - labels.push((Button::Review, REVIEW_LABEL.to_owned())); + labels.push((Button::Review, self.review_label())); } let mut right = width; let mut y = 0; @@ -54,6 +55,14 @@ impl App { .collect() } + /// The Review button names the key that opens the menu, when it has one. + fn review_label(&self) -> String { + match self.keys.hint(Action::ReviewMenu) { + key if key.is_empty() => REVIEW_LABEL.to_owned(), + key => format!("{REVIEW_LABEL} ({key})"), + } + } + pub(super) fn header_height(&self, width: u16) -> u16 { self.header_buttons(width).last().map_or(1, |(_, _, rect)| rect.y + 1) } diff --git a/crates/plannotator-tui/src/app/menu.rs b/crates/plannotator-tui/src/app/menu.rs index 614704d..10580d0 100644 --- a/crates/plannotator-tui/src/app/menu.rs +++ b/crates/plannotator-tui/src/app/menu.rs @@ -10,6 +10,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; use unicode_width::UnicodeWidthStr as _; +use super::draw::picker_help; use super::{App, Mode}; use crate::keys::{self, Action}; use crate::theme::palette; @@ -29,8 +30,6 @@ pub(super) enum ReviewAction { const REVIEW_ACTIONS: [ReviewAction; 4] = [ReviewAction::ResendAll, ReviewAction::Finish, ReviewAction::Undo, ReviewAction::Archive]; -const MENU_HELP: &str = " \u{2191}\u{2193} move \u{b7} enter run \u{b7} esc close "; - impl ReviewAction { /// The key binding that runs the action, from the menu and directly in the review. fn binding(self) -> Action { @@ -165,7 +164,11 @@ impl App { (action, format!(" {} {} ", self.keys.hint(action.binding()), self.action_label(action))) }) .collect(); - let content = rows.iter().map(|(_, text)| text.width()).max().unwrap_or(0).max(MENU_HELP.width()); + let help = picker_help( + "move", + [self.keys.labelled(Action::Confirm, "run"), self.keys.labelled(Action::Back, "close")], + ); + let content = rows.iter().map(|(_, text)| text.width()).max().unwrap_or(0).max(help.width()); let width = (content as u16 + 2).min(area.width); let height = (REVIEW_ACTIONS.len() as u16 + 2).min(area.height); // Under the Review button, flush with its right edge, like a dropdown. @@ -179,7 +182,7 @@ impl App { .border_type(BorderType::Rounded) .border_style(Style::new().fg(Color::Cyan)) .title(Span::styled(" review ", Style::new().dim())) - .title_bottom(Span::styled(MENU_HELP, Style::new().dim())); + .title_bottom(Span::styled(help, Style::new().dim())); let inner = boxed.inner(rect); frame.render_widget(boxed, rect); let mut menu_rows = Vec::new(); diff --git a/crates/plannotator-tui/src/app/menu/tests.rs b/crates/plannotator-tui/src/app/menu/tests.rs index 84cfe85..8d0b0e6 100644 --- a/crates/plannotator-tui/src/app/menu/tests.rs +++ b/crates/plannotator-tui/src/app/menu/tests.rs @@ -246,3 +246,14 @@ fn clicking_the_review_button_opens_the_menu_and_clicking_a_row_runs_it() { assert_eq!(app.mode, Mode::Browse, "a click outside the menu closes it"); std::fs::remove_dir_all(root).expect("cleanup"); } + +#[test] +fn the_header_buttons_name_the_rebound_keys() { + let (root, mut app, _) = file_app("menu-rebound"); + app.keys = toml::from_str("review-menu = [\"M\"]\nsend = []\n").expect("keys"); + app.add_quote_annotation("one", Kind::Comment, "A".into()).expect("A"); + let screen = draw(&mut app, 100, 24); + let header = screen.lines().next().expect("header"); + assert!(header.contains("Review \u{25be} (M)") && !header.contains("(E)"), "{header}"); + std::fs::remove_dir_all(root).expect("cleanup"); +} diff --git a/crates/plannotator-tui/src/app/pick.rs b/crates/plannotator-tui/src/app/pick.rs index 24645a9..a4bf1d1 100644 --- a/crates/plannotator-tui/src/app/pick.rs +++ b/crates/plannotator-tui/src/app/pick.rs @@ -15,6 +15,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; use unicode_width::UnicodeWidthStr as _; +use super::draw::picker_help; use super::{App, Mode, Open}; use crate::keys::{self, Action}; use crate::last::message_source; @@ -147,7 +148,14 @@ impl App { .border_style(Style::new().fg(Color::Cyan)) .title(Span::styled(" which message? ", Style::new().dim())) .title_bottom(Span::styled( - " ↑↓ preview · enter open · esc cancel · q quit ", + picker_help( + "preview", + [ + self.keys.labelled(Action::Confirm, "open"), + self.keys.labelled(Action::Back, "cancel"), + self.keys.labelled(Action::Quit, "quit"), + ], + ), Style::new().dim(), )); let inner = boxed.inner(rect); diff --git a/crates/plannotator-tui/src/app/send.rs b/crates/plannotator-tui/src/app/send.rs index 9aff59e..b67e77f 100644 --- a/crates/plannotator-tui/src/app/send.rs +++ b/crates/plannotator-tui/src/app/send.rs @@ -8,6 +8,7 @@ use plannotator_tui_schema::Provenance; use super::feedback::{Feedback, FeedbackPart, ReviewCounts, SendScope}; use super::{App, Mode}; use crate::delivery::{Clipboard, Delivery as _, DeliveryError}; +use crate::keys::Action; use crate::store::{Location, Store}; #[cfg(test)] @@ -197,7 +198,11 @@ impl App { String::new() }; let to = if self.delivery.is_agent() { format!(" ▸ {target}") } else { String::new() }; - return format!("{verb} {count} new{across}{to} (E)"); + let key = match self.keys.hint(Action::Send) { + key if key.is_empty() => String::new(), + key => format!(" ({key})"), + }; + return format!("{verb} {count} new{across}{to}{key}"); } if self.delivery.is_agent() { match &self.send_state { diff --git a/crates/plannotator-tui/src/app/tests.rs b/crates/plannotator-tui/src/app/tests.rs index 67701f4..110daa8 100644 --- a/crates/plannotator-tui/src/app/tests.rs +++ b/crates/plannotator-tui/src/app/tests.rs @@ -751,3 +751,23 @@ fn rebound_movement_keys_move_and_the_footer_names_them() { let footer = draw(&mut app).pop().expect("footer"); assert!(footer.contains("jkl; move"), "{footer:?}"); } + +/// An unbound action leaves no bare label behind in the footer. +#[test] +fn an_unbound_action_drops_out_of_the_footer() { + let mut app = app(Box::new(Discard)); + app.keys = toml::from_str("quit = []\n").expect("keys"); + let footer = draw(&mut app).pop().expect("footer"); + assert!(footer.contains("S send+quit "), "{footer:?}"); + assert!(!footer.contains("quit · ") && !footer.contains(" · quit"), "{footer:?}"); +} + +/// A ctrl binding whose letter is also a plain binding earlier in the context still runs. +#[test] +fn a_ctrl_binding_beats_the_plain_key_it_shares_a_letter_with() { + let mut app = app(Box::new(Discard)); + // `up` comes before `bottom` in the document, and its plain `k` matches ctrl+k too. + app.keys = toml::from_str("bottom = [\"ctrl+k\"]\n").expect("keys"); + app.handle_event(&Event::Key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL))).expect("key"); + assert_eq!(app.selected, 1, "ctrl+k goes to the last block instead of up"); +} diff --git a/crates/plannotator-tui/src/keys.rs b/crates/plannotator-tui/src/keys.rs index 5f8418a..5602135 100644 --- a/crates/plannotator-tui/src/keys.rs +++ b/crates/plannotator-tui/src/keys.rs @@ -271,9 +271,16 @@ impl<'de> Deserialize<'de> for Keymap { } impl Keymap { - /// The first action in `context` bound to `key`. + /// The first action in `context` bound to `key`. A plain binding also matches the key + /// with Ctrl held, so an exact Ctrl binding is tried first: `page-up = ["ctrl+k"]` must + /// not lose `ctrl+k` to `up`'s plain `k` just because `up` comes first. pub(crate) fn action(&self, key: &KeyEvent, context: &[Action]) -> Option { - context.iter().copied().find(|&action| self.is(action, key)) + let exact_ctrl = |action| self.keys(action).iter().any(|k| k.ctrl && k.matches(key)); + context + .iter() + .copied() + .find(|&action| exact_ctrl(action)) + .or_else(|| context.iter().copied().find(|&action| self.is(action, key))) } pub(crate) fn is(&self, action: Action, key: &KeyEvent) -> bool { @@ -285,6 +292,13 @@ impl Keymap { self.keys(action).first().map(|key| key.hint()).unwrap_or_default() } + /// `key label` for a hint line, or nothing when the action is unbound, so an unbound + /// action leaves no bare label behind. + pub(crate) fn labelled(&self, action: Action, label: &str) -> Option { + let key = self.hint(action); + (!key.is_empty()).then(|| format!("{key} {label}")) + } + /// The four cursor directions run together the way the hints show them: `hjkl`. pub(crate) fn directions_hint(&self) -> String { [Left, Down, Up, Right].map(|action| self.hint(action)).concat() @@ -305,8 +319,8 @@ impl Keymap { return Err(format!( "key {:?} is bound to both {} and {}", key.to_string(), - name(other), - name(action) + other.as_str(), + action.as_str() )); } Some(_) => {} @@ -319,9 +333,50 @@ impl Keymap { } } -/// The action's name as `[keys]` writes it. -fn name(action: Action) -> String { - toml::Value::try_from(action).ok().and_then(|v| v.as_str().map(str::to_owned)).unwrap_or_default() +impl Action { + /// The action's name as `[keys]` writes it. + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Up => "up", + Self::Down => "down", + Self::Left => "left", + Self::Right => "right", + Self::WordForward => "word-forward", + Self::WordBack => "word-back", + Self::LineStart => "line-start", + Self::LineEnd => "line-end", + Self::PageDown => "page-down", + Self::PageUp => "page-up", + Self::Top => "top", + Self::Bottom => "bottom", + Self::Confirm => "confirm", + Self::Back => "back", + Self::Quit => "quit", + Self::Interrupt => "interrupt", + Self::CycleFocus => "cycle-focus", + Self::Send => "send", + Self::SendAndClose => "send-and-close", + Self::ToggleTree => "toggle-tree", + Self::Reload => "reload", + Self::PickMessage => "pick-message", + Self::ReviewMenu => "review-menu", + Self::ResendAll => "resend-all", + Self::FinishReview => "finish-review", + Self::UndoFinish => "undo-finish", + Self::Archive => "archive", + Self::ToggleHidden => "toggle-hidden", + Self::Edit => "edit", + Self::Remove => "remove", + Self::Visual => "visual", + Self::MoveCursor => "move-cursor", + Self::Comment => "comment", + Self::ClearBlock => "clear-block", + Self::LooksGood => "looks-good", + Self::Delete => "delete", + Self::Yes => "yes", + Self::No => "no", + } + } } #[cfg(test)] diff --git a/crates/plannotator-tui/src/keys/tests.rs b/crates/plannotator-tui/src/keys/tests.rs index 10512f8..d575f48 100644 --- a/crates/plannotator-tui/src/keys/tests.rs +++ b/crates/plannotator-tui/src/keys/tests.rs @@ -75,3 +75,34 @@ fn an_empty_list_unbinds_the_action() { assert!(!keymap.is(Quit, &press(KeyCode::Char('q'), KeyModifiers::NONE))); assert_eq!(keymap.hint(Quit), ""); } + +#[test] +fn an_exact_ctrl_binding_wins_over_a_plain_one_earlier_in_the_context() { + // `up` comes before `page-up` in the document, and its plain `k` also matches ctrl+k. + let keymap = parse("page-up = [\"ctrl+k\"]\n").expect("parses"); + assert_eq!(keymap.action(&press(KeyCode::Char('k'), KeyModifiers::CONTROL), DOCUMENT), Some(PageUp)); + assert_eq!(keymap.action(&press(KeyCode::Char('k'), KeyModifiers::NONE), DOCUMENT), Some(Up)); +} + +#[test] +fn a_plain_binding_still_answers_ctrl_when_no_ctrl_binding_claims_it() { + // The defaults keep their behaviour: no action binds ctrl+j, so it still moves down. + let keymap = Keymap::default(); + assert_eq!(keymap.action(&press(KeyCode::Char('j'), KeyModifiers::CONTROL), DOCUMENT), Some(Down)); + assert_eq!(keymap.action(&press(KeyCode::Char('d'), KeyModifiers::CONTROL), DOCUMENT), Some(PageDown)); +} + +#[test] +fn every_action_name_is_the_one_the_config_file_writes() { + for (action, _) in DEFAULTS { + let serialized = toml::Value::try_from(action).expect("serializes"); + assert_eq!(serialized.as_str(), Some(action.as_str())); + } +} + +#[test] +fn an_unbound_action_drops_out_of_the_hints() { + let keymap = parse("quit = []\n").expect("parses"); + assert_eq!(keymap.labelled(Quit, "quit"), None); + assert_eq!(keymap.labelled(Send, "send").as_deref(), Some("E send")); +}