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]>
506 lines
18 KiB
Rust
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:?}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|