Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions crates/plannotator-tui/src/app/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

use anyhow::Result;
use plannotator_tui_schema::Kind;
use ratatui::crossterm::event::{Event, KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind};
use ratatui::crossterm::event::{
Event, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};

use super::compose::ComposeAction;
use super::menu::ReviewAction;
Expand Down Expand Up @@ -72,7 +74,7 @@ impl App {
Some(Action::QuickOpen) if self.tree.is_none() => {
let others: Vec<Action> =
keys::GLOBAL.iter().copied().filter(|&action| action != Action::QuickOpen).collect();
self.keys.action(&key, &others)
self.keys.action(&KeyEvent::new(key.code, KeyModifiers::NONE), &others)
}
action => action,
};
Expand Down
1 change: 0 additions & 1 deletion crates/plannotator-tui/src/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -894,7 +894,6 @@ fn an_unbound_action_drops_out_of_the_footer() {
#[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");
Expand Down
20 changes: 8 additions & 12 deletions crates/plannotator-tui/src/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,13 @@ const fn named(code: KeyCode) -> Key {
}

impl Key {
/// A plain binding ignores modifiers, as a shifted letter arrives with Shift set; a Ctrl
/// binding needs Ctrl alone.
/// A Ctrl binding needs Ctrl alone. A plain binding allows Shift, as a shifted letter
/// arrives with Shift set, but not Ctrl; Ctrl with Alt still counts as plain, because
/// Windows reports `AltGr` that way and `AltGr` types ordinary characters.
fn matches(self, key: &KeyEvent) -> bool {
self.code == key.code && (!self.ctrl || key.modifiers == KeyModifiers::CONTROL)
let ctrl_or_alt = key.modifiers & (KeyModifiers::CONTROL | KeyModifiers::ALT);
let held_ctrl = ctrl_or_alt == KeyModifiers::CONTROL;
self.code == key.code && if self.ctrl { key.modifiers == KeyModifiers::CONTROL } else { !held_ctrl }
}

/// How the in-app hints spell the key: `j`, `G`, `enter`, `ctrl+d`.
Expand Down Expand Up @@ -327,16 +330,9 @@ impl<'de> Deserialize<'de> for Keymap {
}

impl Keymap {
/// 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.
/// The first action in `context` bound to `key`.
pub(crate) fn action(&self, key: &KeyEvent, context: &[Action]) -> Option<Action> {
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)))
context.iter().copied().find(|&action| self.is(action, key))
}

pub(crate) fn is(&self, action: Action, key: &KeyEvent) -> bool {
Expand Down
17 changes: 12 additions & 5 deletions crates/plannotator-tui/src/keys/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,21 +85,28 @@ fn an_empty_list_unbinds_the_action() {
}

#[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.
fn a_ctrl_binding_and_a_plain_one_on_the_same_letter_stay_apart() {
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.
fn a_plain_binding_does_not_answer_ctrl() {
let keymap = Keymap::default();
assert_eq!(keymap.action(&press(KeyCode::Char('j'), KeyModifiers::CONTROL), DOCUMENT), Some(Down));
assert_eq!(keymap.action(&press(KeyCode::Char('j'), KeyModifiers::CONTROL), DOCUMENT), None);
assert_eq!(keymap.action(&press(KeyCode::Down, KeyModifiers::CONTROL), DOCUMENT), None);
assert!(!keymap.is(LooksGood, &press(KeyCode::Char('a'), KeyModifiers::CONTROL)));
assert_eq!(keymap.action(&press(KeyCode::Char('d'), KeyModifiers::CONTROL), DOCUMENT), Some(PageDown));
}

#[test]
fn a_plain_binding_answers_altgr_which_windows_reports_as_ctrl_and_alt() {
let keymap = Keymap::default();
let altgr = KeyModifiers::CONTROL | KeyModifiers::ALT;
assert!(keymap.is(LineEnd, &press(KeyCode::Char('$'), altgr)));
}

#[test]
fn every_action_name_is_the_one_the_config_file_writes() {
for (action, _) in DEFAULTS {
Expand Down