Files
skyshift/src/ui/keymap.rs
T
skkeyeandClaude Opus 5.5 49e14b820a Add paid pomodoro breaks
Some jobs pay for breaks, so a pomodoro break shouldn't take time off
the workday. By default (new setting `paid_breaks`, in [reminders]), b
on the Tracker, zb anywhere or :break starts a pomodoro break WITHOUT
pausing the shift: the break stays in your worked time and the data
file isn't touched. b (or s) brings you back to focus. With the setting
off, b pauses the shift like p. p and e always stop the clock.

For the rhythm, a paid break still counts as a break: reminders::PaidBreaks
keeps them in memory and pomodoro() cuts them out of the focus stretches,
the same way as a pause. Stopping the shift ends a paid break, and
closing the work day forgets them.

The Tracker shows "on a paid break" and the paid break's progress, and
the nudges name the right key (b or p/s).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-25 00:06:26 -04:00

506 lines
18 KiB
Rust

//! 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: `<CR>` (Enter), `<Esc>`, `<Tab>`,
//! `<S-Tab>`, `<BS>`, `<Space>`, `<Up>`, `<Down>`, `<Left>`, `<Right>`,
//! `<lt>` (the `<` key itself)
//! - Ctrl with a letter: `<C-a>`
//!
//! 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<CopyPreset>),
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-x>", C::StartEarlier, "session started earlier (-step)"),
bind("<C-a>", C::StartLater, "session started later (+step)"),
bind("u", C::Undo, "undo"),
bind("<C-r>", 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("<Tab>", C::NextScreen, "next screen"),
bind("<S-Tab>", C::PreviousScreen, "previous screen"),
bind("zz", C::Snooze, "snooze the break reminder"),
bind("zb", C::TakeBreak, "take a pomodoro break / back from it"),
bind("<Space><Space>", C::Toggle, "start / pause"),
bind("<Space>s", C::StartAt, "start at an earlier time..."),
bind("<Space>p", C::PauseAt, "pause at an earlier time..."),
bind("<Space>e", C::EndDayAt, "end the day at an earlier time..."),
bind("<Space>c", C::CopyMenu, "copy with a format..."),
bind("<Space>x", C::Export, "export a report..."),
bind("<Space>w", C::ToggleWhichKey, "toggle key hints"),
bind("<Space>?", C::Help, "all keys"),
bind(":", C::OpenCommandLine, "command line"),
bind("?", C::Help, "all keys"),
bind("<Esc>", 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("<Down>", C::Down, "down"),
bind("k", C::Up, "up"),
bind("<Up>", C::Up, "up"),
bind("gg", C::Top, "go to the top"),
bind("G", C::Bottom, "go to the bottom"),
bind("<C-d>", C::HalfPageDown, "half a page down"),
bind("<C-u>", C::HalfPageUp, "half a page up"),
];
pub const TRACKER: &[Binding] = &[
bind("b", C::TakeBreak, "take a pomodoro break / back from it"),
bind("<CR>", 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("<Left>", C::Left, "focus the day list"),
bind("l", C::Right, "focus the segments"),
bind("<Right>", C::Right, "focus the segments"),
bind("w", C::NextWeek, "down to the next week"),
bind("b", C::PreviousWeek, "up to the previous week"),
bind("<CR>", 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("<Left>", C::Left, "previous view"),
bind("l", C::Right, "next view"),
bind("<Right>", C::Right, "next view"),
bind("<lt>", C::ShorterPeriod, "shorter period"),
bind(">", C::LongerPeriod, "longer period"),
];
pub const SETTINGS: &[Binding] = &[
bind("h", C::Left, "previous value"),
bind("<Left>", C::Left, "previous value"),
bind("l", C::Right, "next value"),
bind("<Right>", C::Right, "next value"),
bind("<CR>", 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("<Down>", C::Down, "next field"),
bind("<Tab>", C::Down, "next field"),
bind("k", C::Up, "previous field"),
bind("<Up>", C::Up, "previous field"),
bind("<S-Tab>", C::Up, "previous field"),
bind("gg", C::Top, "first"),
bind("G", C::Bottom, "last"),
bind("h", C::Left, "previous choice"),
bind("<Left>", C::Left, "previous choice"),
bind("l", C::Right, "next choice"),
bind("<Right>", 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("<CR>", C::Confirm, "confirm"),
bind("<Esc>", 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"),
("<Space>", "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, `"<C-a>"` is Ctrl+a,
/// `"<Space>s"` is Space then s.
pub fn parse(notation: &str) -> Vec<Key> {
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<Key> {
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!("<C-{c}>"),
(KeyCode::Char(' '), false) => "<Space>".into(),
(KeyCode::Char('<'), false) => "<".into(),
(KeyCode::Char(c), false) => c.to_string(),
(KeyCode::Enter, _) => "<CR>".into(),
(KeyCode::Esc, _) => "<Esc>".into(),
(KeyCode::Tab, _) => "<Tab>".into(),
(KeyCode::BackTab, _) => "<S-Tab>".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<usize> },
/// 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<Key>,
count: Option<usize>,
/// When the sequence in progress started (for the which-key delay).
since: Option<Instant>,
}
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<Instant> {
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::<String>()
}
/// 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<Key>, Binding)> {
let mut seen: Vec<Vec<Key>> = 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", "<C-a>", "<Space>s", "<CR>", "<S-Tab>", "ZZ", "<lt>"] {
let keys = parse(text);
let back: String = keys.iter().map(|key| notation(*key)).collect();
assert_eq!(parse(&back), keys, "{text}");
}
assert_eq!(parse("<C-a>"), 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<Esc>", 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<Vec<Key>> = 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:?}");
}
}
}
}
}