//! Vim-style keys: every key sequence skyshift understands, in one place. //! //! Keys are written in vim notation: //! - letters and symbols as they are: `j`, `G`, `gt`, `yy`, `:`, `?` //! - special keys in angle brackets: `` (Enter), ``, ``, //! ``, ``, ``, ``, ``, ``, ``, //! `` (the `<` key itself) //! - Ctrl with a letter: `` //! //! Screens never see raw keys: [`Keys`] turns key presses into [`Command`]s //! using the tables below, with vim-style counts (`5j`) and multi-key //! sequences (`gg`, `yy`). To change a key, change it in a table: the help //! screen and the which-key hints are generated from the same tables. use std::time::Instant; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use super::Tab; use crate::export::timesheet::CopyPreset; /// What a key sequence asks for. Each screen decides what it means there: /// `Down` moves through days on the Days screen and through settings on the /// Settings screen. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Command { // Moving around Up, Down, Left, Right, Top, Bottom, HalfPageUp, HalfPageDown, NextWeek, PreviousWeek, NextScreen, PreviousScreen, GoTo(Tab), // The shift Start, Pause, Toggle, EndDay, StartAt, PauseAt, EndDayAt, StartEarlier, StartLater, TakeBreak, Snooze, // Recorded work Edit, AddAfter, AddBefore, Delete, Undo, Redo, // Copy and export Copy(CopyTarget, Option), CopyMenu, Export, // Insights and Settings ShorterPeriod, LongerPeriod, ToggleExpert, ResetSetting, // Popups and text fields (normal mode) Confirm, Close, Insert, InsertAtStart, Retype, // The app OpenCommandLine, Help, ToggleWhichKey, Quit, } /// What a copy command copies. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CopyTarget { Day, Week, } /// One key sequence and what it does. #[derive(Debug, Clone, Copy)] pub struct Binding { pub keys: &'static str, pub command: Command, pub help: &'static str, } const fn bind(keys: &'static str, command: Command, help: &'static str) -> Binding { Binding { keys, command, help } } use Command as C; /// Keys that work on every screen (when no popup is open). pub const GLOBAL: &[Binding] = &[ bind("s", C::Start, "start / resume work"), bind("p", C::Pause, "pause (take a break)"), bind("e", C::EndDay, "end the work day"), bind("", C::StartEarlier, "session started earlier (-step)"), bind("", C::StartLater, "session started later (+step)"), bind("u", C::Undo, "undo"), bind("", C::Redo, "redo"), bind("yy", C::Copy(CopyTarget::Day, None), "copy the day"), bind("yw", C::Copy(CopyTarget::Week, None), "copy the week (a line per day)"), bind("yh", C::Copy(CopyTarget::Day, Some(CopyPreset::TotalClock)), "copy total as H:MM"), bind("yd", C::Copy(CopyTarget::Day, Some(CopyPreset::TotalDecimal)), "copy total as decimal hours"), bind("ys", C::Copy(CopyTarget::Day, Some(CopyPreset::Segments)), "copy the segments"), bind("yi", C::Copy(CopyTarget::Day, Some(CopyPreset::InOutBreak)), "copy in / out / break"), bind("yc", C::Copy(CopyTarget::Day, Some(CopyPreset::Custom)), "copy with your template"), bind("gt", C::NextScreen, "next screen"), bind("gT", C::PreviousScreen, "previous screen"), bind("gh", C::GoTo(Tab::Tracker), "go to Tracker"), bind("gd", C::GoTo(Tab::Days), "go to Days"), bind("gi", C::GoTo(Tab::Insights), "go to Insights"), bind("gs", C::GoTo(Tab::Settings), "go to Settings"), bind("", C::NextScreen, "next screen"), bind("", C::PreviousScreen, "previous screen"), bind("zz", C::Snooze, "snooze the break reminder"), bind("zb", C::TakeBreak, "take a pomodoro break / back from it"), bind("", C::Toggle, "start / pause"), bind("s", C::StartAt, "start at an earlier time..."), bind("p", C::PauseAt, "pause at an earlier time..."), bind("e", C::EndDayAt, "end the day at an earlier time..."), bind("c", C::CopyMenu, "copy with a format..."), bind("x", C::Export, "export a report..."), bind("w", C::ToggleWhichKey, "toggle key hints"), bind("?", C::Help, "all keys"), bind(":", C::OpenCommandLine, "command line"), bind("?", C::Help, "all keys"), bind("", C::Close, "cancel"), bind("q", C::Quit, "quit (a running shift keeps running)"), bind("ZZ", C::Quit, "quit"), ]; /// Moving through a list (Days, Settings). pub const LIST: &[Binding] = &[ bind("j", C::Down, "down"), bind("", C::Down, "down"), bind("k", C::Up, "up"), bind("", C::Up, "up"), bind("gg", C::Top, "go to the top"), bind("G", C::Bottom, "go to the bottom"), bind("", C::HalfPageDown, "half a page down"), bind("", C::HalfPageUp, "half a page up"), ]; pub const TRACKER: &[Binding] = &[ bind("b", C::TakeBreak, "take a pomodoro break / back from it"), bind("", C::Edit, "fix the session in progress"), bind("i", C::Edit, "fix the session in progress"), bind("o", C::AddAfter, "add forgotten work to today"), ]; pub const DAYS: &[Binding] = &[ bind("h", C::Left, "focus the day list"), bind("", C::Left, "focus the day list"), bind("l", C::Right, "focus the segments"), bind("", C::Right, "focus the segments"), bind("w", C::NextWeek, "down to the next week"), bind("b", C::PreviousWeek, "up to the previous week"), bind("", C::Edit, "edit the segment"), bind("i", C::Edit, "edit the segment"), bind("o", C::AddAfter, "add a segment"), bind("O", C::AddBefore, "add a segment before"), bind("dd", C::Delete, "delete the segment"), bind("x", C::Delete, "delete the segment"), ]; pub const INSIGHTS: &[Binding] = &[ bind("h", C::Left, "previous view"), bind("", C::Left, "previous view"), bind("l", C::Right, "next view"), bind("", C::Right, "next view"), bind("", C::ShorterPeriod, "shorter period"), bind(">", C::LongerPeriod, "longer period"), ]; pub const SETTINGS: &[Binding] = &[ bind("h", C::Left, "previous value"), bind("", C::Left, "previous value"), bind("l", C::Right, "next value"), bind("", C::Right, "next value"), bind("", C::Edit, "edit the value"), bind("i", C::Edit, "edit the value"), bind("a", C::Edit, "edit the value"), bind("cc", C::Retype, "retype the value"), bind("x", C::ResetSetting, "reset to the default"), bind("za", C::ToggleExpert, "show / hide expert settings"), ]; /// Normal mode inside popups (forms and menus). pub const POPUP: &[Binding] = &[ bind("j", C::Down, "next field"), bind("", C::Down, "next field"), bind("", C::Down, "next field"), bind("k", C::Up, "previous field"), bind("", C::Up, "previous field"), bind("", C::Up, "previous field"), bind("gg", C::Top, "first"), bind("G", C::Bottom, "last"), bind("h", C::Left, "previous choice"), bind("", C::Left, "previous choice"), bind("l", C::Right, "next choice"), bind("", C::Right, "next choice"), bind("i", C::Insert, "type"), bind("a", C::Insert, "type"), bind("A", C::Insert, "type"), bind("I", C::InsertAtStart, "type at the start"), bind("cc", C::Retype, "retype the field"), bind("S", C::Retype, "retype the field"), bind("", C::Confirm, "confirm"), bind("", C::Close, "cancel"), bind("q", C::Close, "cancel"), ]; /// Names of the key groups, shown as titles in the which-key popup. pub const GROUPS: &[(&str, &str)] = &[ ("g", "go to"), ("y", "copy (yank)"), ("d", "delete"), ("c", "change"), ("z", "reminders & view"), ("Z", "quit"), ("", "actions"), ]; // ----- Keys ---------------------------------------------------------------- /// One key press: a key, and whether Ctrl was held. Shift is part of the /// character itself (`G` is Shift+g). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Key { code: KeyCode, ctrl: bool, } impl Key { pub fn from_event(event: KeyEvent) -> Key { let ctrl = event.modifiers.contains(KeyModifiers::CONTROL); let code = match event.code { KeyCode::Char(c) if ctrl => KeyCode::Char(c.to_ascii_lowercase()), other => other, }; Key { code, ctrl } } fn char(c: char) -> Key { Key { code: KeyCode::Char(c), ctrl: false } } } /// Reads vim key notation: `"gt"` is g then t, `""` is Ctrl+a, /// `"s"` is Space then s. pub fn parse(notation: &str) -> Vec { let mut keys = Vec::new(); let mut rest = notation; while let Some(c) = rest.chars().next() { let special = if c == '<' { rest.find('>').map(|end| &rest[1..end]) } else { None }; match special.and_then(special_key) { Some(key) => { keys.push(key); rest = &rest[special.map_or(0, str::len) + 2..]; } None => { keys.push(Key::char(c)); rest = &rest[c.len_utf8()..]; } } } keys } /// A name between angle brackets, like `CR` or `C-a`. fn special_key(name: &str) -> Option { let code = match name { "CR" | "Enter" => KeyCode::Enter, "Esc" => KeyCode::Esc, "Tab" => KeyCode::Tab, "S-Tab" => KeyCode::BackTab, "BS" => KeyCode::Backspace, "Del" => KeyCode::Delete, "Space" => KeyCode::Char(' '), "Up" => KeyCode::Up, "Down" => KeyCode::Down, "Left" => KeyCode::Left, "Right" => KeyCode::Right, "lt" => KeyCode::Char('<'), _ => { let letter = name.strip_prefix("C-")?; let mut chars = letter.chars(); let (Some(c), None) = (chars.next(), chars.next()) else { return None }; return Some(Key { code: KeyCode::Char(c.to_ascii_lowercase()), ctrl: true }); } }; Some(Key { code, ctrl: false }) } /// Writes one key back in vim notation (for the which-key popup). fn notation(key: Key) -> String { match (key.code, key.ctrl) { (KeyCode::Char(c), true) => format!(""), (KeyCode::Char(' '), false) => "".into(), (KeyCode::Char('<'), false) => "<".into(), (KeyCode::Char(c), false) => c.to_string(), (KeyCode::Enter, _) => "".into(), (KeyCode::Esc, _) => "".into(), (KeyCode::Tab, _) => "".into(), (KeyCode::BackTab, _) => "".into(), (code, _) => format!("<{code:?}>"), } } // ----- Turning key presses into commands ----------------------------------- /// What a key press led to. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Outcome { /// A whole sequence matched. `count` is the number typed before it, if any. Run { command: Command, count: Option }, /// The keys so far are the start of a sequence: waiting for the next key. Waiting, /// Esc cancelled a sequence in progress. Cancelled, /// No sequence starts like this (the keys were dropped). Unknown(String), } /// Remembers the keys of a sequence in progress, like vim does after `g`. #[derive(Debug, Default)] pub struct Keys { pending: Vec, count: Option, /// When the sequence in progress started (for the which-key delay). since: Option, } impl Keys { /// Feeds one key press. `tables` are searched in order: an earlier table /// wins when two define the same keys (screens override global keys). pub fn press(&mut self, key: Key, tables: &[&[Binding]]) -> Outcome { if key.code == KeyCode::Esc && self.is_pending() { self.reset(); return Outcome::Cancelled; } // A count before a command, like the 5 in `5j` (0 only continues one). if self.pending.is_empty() && !key.ctrl && let KeyCode::Char(c @ '0'..='9') = key.code && (c != '0' || self.count.is_some()) { let digit = c as usize - '0' as usize; self.count = Some((self.count.unwrap_or(0) * 10 + digit).min(9999)); return Outcome::Waiting; } self.pending.push(key); self.since.get_or_insert_with(Instant::now); let candidates = self.candidates(tables); if let Some(exact) = candidates.iter().find(|(keys, _)| *keys == self.pending) { let outcome = Outcome::Run { command: exact.1.command, count: self.count }; self.reset(); return outcome; } if candidates.is_empty() { let typed: String = self.pending.iter().map(|key| notation(*key)).collect(); self.reset(); return Outcome::Unknown(typed); } Outcome::Waiting } pub fn reset(&mut self) { *self = Keys::default(); } /// A sequence (or a count) is in progress. pub fn is_pending(&self) -> bool { !self.pending.is_empty() || self.count.is_some() } /// When the sequence in progress started (not counting a count). pub fn pending_since(&self) -> Option { self.since } /// The keys typed so far, like vim's "showcmd": `3g`. pub fn typed(&self) -> String { let count = self.count.map(|count| count.to_string()).unwrap_or_default(); count + &self.pending.iter().map(|key| notation(*key)).collect::() } /// The title of the which-key popup: the group the typed keys open. pub fn group_name(&self) -> Option<&'static str> { let typed: String = self.pending.iter().map(|key| notation(*key)).collect(); GROUPS.iter().find(|(keys, _)| *keys == typed).map(|(_, name)| *name) } /// Every key that can come next, with what it does, for the which-key /// popup. A key that starts a longer sequence shows its group name. pub fn hints(&self, tables: &[&[Binding]]) -> Vec<(String, String)> { let mut hints: Vec<(String, String)> = Vec::new(); if self.pending.is_empty() { return hints; } for (keys, binding) in self.candidates(tables) { let next = notation(keys[self.pending.len()]); if hints.iter().any(|(key, _)| *key == next) { continue; } let label = if keys.len() == self.pending.len() + 1 { binding.help.to_string() } else { let group: String = keys[..=self.pending.len()].iter().map(|key| notation(*key)).collect(); let name = GROUPS.iter().find(|(keys, _)| *keys == group).map_or("more", |(_, name)| name); format!("+{name}") }; hints.push((next, label)); } hints } /// Bindings that start with the keys typed so far, parsed. Bindings /// hidden by an earlier table with the same keys are skipped. fn candidates(&self, tables: &[&[Binding]]) -> Vec<(Vec, Binding)> { let mut seen: Vec> = Vec::new(); let mut found = Vec::new(); for binding in tables.iter().flat_map(|table| table.iter()) { let keys = parse(binding.keys); if seen.contains(&keys) { continue; } if keys.starts_with(&self.pending) { found.push((keys.clone(), *binding)); } seen.push(keys); } found } } #[cfg(test)] mod tests { use super::*; fn press_all(keys: &mut Keys, notation: &str, tables: &[&[Binding]]) -> Outcome { let mut last = Outcome::Waiting; for key in parse(notation) { last = keys.press(key, tables); } last } #[test] fn notation_round_trips() { for text in ["gt", "", "s", "", "", "ZZ", ""] { let keys = parse(text); let back: String = keys.iter().map(|key| notation(*key)).collect(); assert_eq!(parse(&back), keys, "{text}"); } assert_eq!(parse(""), vec![Key { code: KeyCode::Char('a'), ctrl: true }]); } #[test] fn sequences_counts_and_overrides() { let mut keys = Keys::default(); let tables: &[&[Binding]] = &[DAYS, LIST, GLOBAL]; assert_eq!(press_all(&mut keys, "g", tables), Outcome::Waiting); assert!(keys.hints(tables).iter().any(|(key, _)| key == "t")); assert_eq!(press_all(&mut keys, "g", tables), Outcome::Run { command: C::Top, count: None }); assert_eq!(press_all(&mut keys, "5j", tables), Outcome::Run { command: C::Down, count: Some(5) }); // On the Days screen, `x` deletes; `gx` means nothing. assert_eq!(press_all(&mut keys, "gx", tables), Outcome::Unknown("gx".into())); assert_eq!(press_all(&mut keys, "y", tables), Outcome::Cancelled); } /// A binding must never be the start of a longer one in the same place, /// or the longer one could never be typed. #[test] fn no_binding_hides_another() { let places: [&[&[Binding]]; 5] = [ &[TRACKER, GLOBAL], &[DAYS, LIST, GLOBAL], &[INSIGHTS, GLOBAL], &[SETTINGS, LIST, GLOBAL], &[POPUP], ]; for tables in places { let all: Vec> = tables.iter().flat_map(|t| t.iter()).map(|b| parse(b.keys)).collect(); for a in &all { for b in &all { assert!(!(b.len() > a.len() && b.starts_with(a)), "{a:?} hides {b:?}"); } } } } }