Add the UI foundation: vim keymap, widgets and the component contract

- keymap: every key sequence in vim notation (gg, yy, <C-a>,
  <Space>s), with counts (5j), per-screen overrides and which-key hints,
  all from the same tables.
- Component: the one trait for screens and popups. They only look at
  the data through a Context and answer with Actions.
- Action: everything a screen can ask the app to do.
- widgets: big digits, buttons, a day timeline, a heatmap, a text field.
- ui/mod.rs: tabs, status line and the which-key popup; terminal.rs sets
  the terminal up (and restores it after a crash).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-09-24 02:59:22 -04:00
1 parent eff7834838
commit 7c8a02275e
13 files changed
+1588

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
//! Everything a screen or popup can ask the app to do.
//!
//! Screens and popups never change your data themselves: they return an
//! `Action`, and the app applies it in one place (`App::apply`), saves, and
//! reports what happened. That's the only way anything changes.
use jiff::civil::Date;
use jiff::{SignedDuration, Timestamp};
use crate::domain::Segment;
use crate::export::ExportFormat;
use crate::export::timesheet::CopyPreset;
use crate::time::DateRange;
use crate::ui::{Component, Tab};
pub enum Action {
// ----- Moving around the app
GoTo(Tab),
OpenPopup(Box<dyn Component>),
ClosePopup,
/// The help popup for the current screen.
ShowHelp,
/// Show a message in the status line.
Notify(String),
Quit,
// ----- The shift
Start,
/// Start working as if you had pressed Start at this earlier moment.
StartAt(Timestamp),
Pause,
/// Pause as if you had pressed Pause at this earlier moment.
PauseAt(Timestamp),
EndDay,
EndDayAt(Timestamp),
/// Move the start of the session in progress by this much.
ShiftRunningStart(SignedDuration),
/// Correct the session in progress: when it started, and optionally
/// when it actually stopped (you forgot to pause).
FixRunning {
since: Timestamp,
stopped: Option<Timestamp>,
},
/// Silence the break suggestion for a while.
Snooze,
// ----- Recorded work
AddSegment {
day: Date,
segment: Segment,
},
/// Replace segment `index` of `day`, filing the new one under `new_day`.
ReplaceSegment {
day: Date,
index: usize,
segment: Segment,
new_day: Date,
},
DeleteSegment {
day: Date,
index: usize,
},
Undo,
Redo,
// ----- Getting data out
Copy {
days: Vec<Date>,
preset: CopyPreset,
},
Export {
format: ExportFormat,
range: DateRange,
},
// ----- Settings, by their key in config.toml
SetSetting {
key: &'static str,
value: String,
},
ResetSetting {
key: &'static str,
},
ToggleWhichKey,
}
+2
View File
@@ -4,6 +4,7 @@
//! then this only declares the modules written so far.
#![allow(dead_code)]
mod action;
mod analysis;
mod domain;
mod export;
@@ -11,5 +12,6 @@ mod reminders;
mod settings;
mod storage;
mod time;
mod ui;
fn main() {}
+119
View File
@@ -0,0 +1,119 @@
//! What every screen and popup has in common.
use std::path::Path;
use jiff::civil::{Date, Time};
use jiff::{Timestamp, Zoned};
use ratatui::Frame;
use ratatui::crossterm::event::{KeyEvent, MouseEvent};
use ratatui::layout::Rect;
use super::keymap::{Binding, Command};
use super::theme::Theme;
use crate::action::Action;
use crate::domain::{Journal, WorkDay};
use crate::export::timesheet::CopyOptions;
use crate::reminders::Pomodoro;
use crate::settings::Settings;
use crate::time::resolve::on_date;
use crate::time::{Clock, DateRange, Formatter};
/// A screen or a popup. Components only *look* at the data (through the
/// [`Context`]) and answer with [`Action`]s; the app does the rest.
pub trait Component {
/// The key tables for normal mode, most specific first. For screens, the
/// app adds the global keys after these.
fn keys(&self) -> &'static [&'static [Binding]];
/// Draws the component. Screens get the screen area; popups get the whole
/// window and place themselves.
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context);
/// Reacts to a command from the keys. `count` is the number typed before
/// it, like the 5 in `5j`.
fn command(&mut self, command: Command, count: Option<usize>, ctx: &Context) -> Option<Action>;
/// Reacts to the mouse. Most popups don't.
fn mouse(&mut self, _event: MouseEvent, _ctx: &Context) -> Option<Action> {
None
}
/// True while a text field takes the keys directly (vim's insert mode).
fn inserting(&self) -> bool {
false
}
/// A key typed in insert mode.
fn insert_key(&mut self, _key: KeyEvent, _ctx: &Context) -> Option<Action> {
None
}
}
/// Everything a component can look at while it draws or reacts.
pub struct Context<'a> {
pub journal: &'a Journal,
/// Every work day as of `now`, oldest first, the running session included.
pub days: &'a [WorkDay],
pub settings: &'a Settings,
pub clock: &'a Clock,
pub now: &'a Zoned,
pub fmt: Formatter,
pub theme: &'a Theme,
pub pomodoro: Pomodoro,
/// Break suggestions are silenced until then.
pub snoozed_until: Option<Timestamp>,
pub export_folder: &'a Path,
}
impl Context<'_> {
/// The work day in focus when no other is selected: the open one, or today.
pub fn current_day(&self) -> Date {
self.journal.state().open_day().unwrap_or(self.now.date())
}
pub fn day(&self, date: Date) -> Option<&WorkDay> {
let index = self.days.binary_search_by_key(&date, WorkDay::date).ok()?;
self.days.get(index)
}
/// The work days of the week containing `date` that have some work.
pub fn week_days(&self, date: Date) -> Vec<Date> {
let week = DateRange::week_of(date, self.settings.week_start);
self.days.iter().map(WorkDay::date).filter(|day| week.contains(*day)).collect()
}
/// A stored moment, seen in the chosen time zone.
pub fn local(&self, moment: Timestamp) -> Zoned {
self.clock.local(moment)
}
/// A stored moment as a clock time: "22:05" or "10:05 PM".
pub fn time(&self, moment: Timestamp) -> String {
self.fmt.time(self.local(moment).time())
}
/// A clock time, marked "+1" when it falls the day after `day`.
pub fn time_on(&self, moment: Timestamp, day: Date) -> String {
let days_later = self.local(moment).date().duration_since(day).as_hours() / 24;
match days_later {
0 => self.time(moment),
later => format!("{} +{later}", self.time(moment)),
}
}
/// Noon on `date`: a neutral reference for times typed about that day.
pub fn noon(&self, date: Date) -> Zoned {
on_date(date, Time::constant(12, 0, 0, 0), self.clock.zone())
}
/// What copying should look like, from the settings.
pub fn copy_options(&self) -> CopyOptions {
CopyOptions {
fmt: self.fmt,
zone: self.clock.zone().clone(),
rounding: self.settings.rounding,
rounding_step: self.settings.rounding_step,
template: self.settings.custom_template.clone(),
}
}
}
+502
View File
@@ -0,0 +1,502 @@
//! 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,
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("<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("<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:?}");
}
}
}
}
}
+219
View File
@@ -0,0 +1,219 @@
//! The terminal user interface.
//!
//! - [`keymap`]: every key sequence, vim-style, turned into [`keymap::Command`]s.
//! - [`component`]: what screens and popups have in common.
//! - [`screens`]: Tracker, Days, Insights, Settings (one per tab).
//! - [`popups`]: small windows for one task (editing a segment, copying...).
//! - [`widgets`]: building blocks (big digits, buttons, timeline, heatmap...).
//! - [`theme`]: the colors.
//! - [`terminal`]: switching the terminal into and out of full-screen mode.
//!
//! This file draws what surrounds every screen: the tabs at the top, the
//! status line at the bottom, and the which-key hints.
pub mod component;
pub mod keymap;
pub mod terminal;
pub mod theme;
pub mod widgets;
use std::time::Instant;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Clear, Paragraph};
pub use component::{Component, Context};
use crate::action::Action;
use crate::domain::ShiftState;
/// The screens, in tab order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
Tracker,
Days,
Insights,
Settings,
}
impl Tab {
pub const ALL: [Tab; 4] = [Tab::Tracker, Tab::Days, Tab::Insights, Tab::Settings];
pub fn title(self) -> &'static str {
match self {
Tab::Tracker => "Tracker",
Tab::Days => "Days",
Tab::Insights => "Insights",
Tab::Settings => "Settings",
}
}
pub fn next(self) -> Tab {
Tab::ALL[(self.index() + 1) % Tab::ALL.len()]
}
pub fn previous(self) -> Tab {
Tab::ALL[(self.index() + Tab::ALL.len() - 1) % Tab::ALL.len()]
}
fn index(self) -> usize {
Tab::ALL.iter().position(|tab| *tab == self).unwrap_or(0)
}
}
/// Opens a popup (a shortcut used by the screens).
pub fn popup(component: impl Component + 'static) -> Action {
Action::OpenPopup(Box::new(component))
}
/// A message in the status line.
#[derive(Debug, Clone)]
pub struct Message {
pub text: String,
pub tone: Tone,
pub shown_at: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tone {
Info,
Good,
Nudge,
Problem,
}
/// Splits the window: tabs on top, the screen, the status line.
pub fn layout(area: Rect) -> [Rect; 3] {
Layout::vertical([Constraint::Length(1), Constraint::Fill(1), Constraint::Length(1)]).areas(area)
}
/// The top line: the app's name, the tabs, and the date and time. Returns
/// where each tab was drawn (for mouse clicks).
pub fn draw_header(frame: &mut Frame, area: Rect, current: Tab, ctx: &Context) -> Vec<(Tab, Rect)> {
let mut x = area.x;
let mut put = |frame: &mut Frame, text: String, style: Style| {
let width = text.chars().count() as u16;
let rect = Rect { x, y: area.y, width, height: 1 }.intersection(area);
frame.render_widget(Paragraph::new(Span::styled(text, style)), rect);
x += width;
rect
};
put(
frame,
" skyshift ".into(),
Style::new().fg(Color::Black).bg(ctx.theme.accent).add_modifier(Modifier::BOLD),
);
put(frame, " ".into(), Style::new());
let mut tabs = Vec::new();
for tab in Tab::ALL {
let style = if tab == current {
ctx.theme.title().add_modifier(Modifier::UNDERLINED)
} else {
ctx.theme.muted()
};
tabs.push((tab, put(frame, format!(" {} ", tab.title()), style)));
}
let time = if ctx.settings.show_seconds {
ctx.fmt.time_with_seconds(ctx.now.time())
} else {
ctx.fmt.time(ctx.now.time())
};
let clock = Line::from(vec![
Span::styled(ctx.fmt.date_short(ctx.now.date()), ctx.theme.muted()),
Span::raw(" "),
Span::styled(time, Style::new().add_modifier(Modifier::BOLD)),
Span::raw(" "),
]);
frame.render_widget(Paragraph::new(clock).right_aligned(), area);
tabs
}
/// The bottom line: what you're doing on the left, then a message (or key
/// hints), and the keys typed so far on the right, like vim's "showcmd".
pub fn draw_footer(frame: &mut Frame, area: Rect, ctx: &Context, message: Option<&Message>, typed: &str) {
let state = ctx.journal.state();
let now = ctx.now.timestamp();
let pill_text = match state {
ShiftState::Working { .. } => format!(" ● {} ", ctx.fmt.duration(ctx.journal.running_for(now))),
ShiftState::OnBreak { .. } => {
format!(" ◐ break {} ", ctx.fmt.duration(ctx.journal.on_break_for(now).unwrap_or_default()))
}
ShiftState::Idle => " ○ off ".to_string(),
};
let pill = Span::styled(
pill_text,
Style::new().fg(Color::Black).bg(ctx.theme.state(state)).add_modifier(Modifier::BOLD),
);
let middle = match message {
Some(message) => {
let color = match message.tone {
Tone::Info => Color::Reset,
Tone::Good => ctx.theme.working,
Tone::Nudge => ctx.theme.warning,
Tone::Problem => ctx.theme.error,
};
Span::styled(format!(" {}", message.text), Style::new().fg(color))
}
None => Span::styled(
" s start · p pause · e end day · yy copy · <Space> actions · ? keys · q quit",
ctx.theme.muted(),
),
};
frame.render_widget(Paragraph::new(Line::from(vec![pill, middle])), area);
frame.render_widget(
Paragraph::new(Span::styled(format!("{typed} "), ctx.theme.title())).right_aligned(),
area,
);
}
/// The which-key popup: the keys that can follow what you typed, in the
/// bottom right corner of `area`.
pub fn draw_which_key(frame: &mut Frame, area: Rect, title: &str, hints: &[(String, String)], ctx: &Context) {
if hints.is_empty() {
return;
}
let key_width = hints.iter().map(|(key, _)| key.chars().count()).max().unwrap_or(1);
let entry_width = hints.iter().map(|(_, help)| help.chars().count()).max().unwrap_or(1) + key_width + 5;
let max_rows = usize::from(area.height.saturating_sub(4)).max(1);
let columns = hints.len().div_ceil(max_rows).max(1);
let rows = hints.len().div_ceil(columns);
let width = ((entry_width * columns) as u16 + 2).min(area.width);
let height = (rows as u16 + 2).min(area.height);
let rect = Rect {
// Inside the screen's border.
x: area.right().saturating_sub(width + 2),
y: area.bottom().saturating_sub(height + 1),
width,
height,
};
let lines: Vec<Line> = (0..rows)
.map(|row| {
let mut spans = Vec::new();
for column in 0..columns {
if let Some((key, help)) = hints.get(column * rows + row) {
spans.push(Span::styled(format!(" {key:>key_width$} "), ctx.theme.title()));
let color = if help.starts_with('+') { ctx.theme.accent } else { Color::Reset };
spans.push(Span::styled(
format!("{help:<width$}", width = entry_width - key_width - 3),
Style::new().fg(color),
));
}
}
Line::from(spans)
})
.collect();
frame.render_widget(Clear, rect);
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(ctx.theme.muted())
.title(Line::styled(format!(" {title} "), ctx.theme.title()));
frame.render_widget(Paragraph::new(lines).block(block), rect);
}
+38
View File
@@ -0,0 +1,38 @@
//! Switching the terminal into full-screen mode and back.
//!
//! ratatui handles the screen and raw mode (and puts them back if skyshift
//! crashes); this adds mouse support, which also has to be turned off again.
use std::io::{self, stdout};
use ratatui::DefaultTerminal;
use ratatui::crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use ratatui::crossterm::execute;
pub fn start(mouse: bool) -> io::Result<DefaultTerminal> {
let terminal = ratatui::try_init()?;
set_mouse(mouse)?;
// On a crash, turn the mouse off too, then let ratatui restore the rest.
let restore_the_rest = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = execute!(stdout(), DisableMouseCapture);
restore_the_rest(info);
}));
Ok(terminal)
}
pub fn stop() {
let _ = execute!(stdout(), DisableMouseCapture);
ratatui::restore();
}
pub fn set_mouse(on: bool) -> io::Result<()> {
if on { execute!(stdout(), EnableMouseCapture) } else { execute!(stdout(), DisableMouseCapture) }
}
/// Rings the terminal bell (for reminders, when the setting is on).
pub fn bell() {
use std::io::Write;
let mut out = stdout();
let _ = out.write_all(b"\x07").and_then(|()| out.flush());
}
+79
View File
@@ -0,0 +1,79 @@
//! Colors and styles, in one place. Named terminal colors follow your
//! terminal's color scheme, so skyshift fits in with it.
use ratatui::style::{Color, Modifier, Style};
use crate::domain::ShiftState;
#[derive(Debug, Clone, Copy)]
pub struct Theme {
pub accent: Color,
pub working: Color,
pub on_break: Color,
pub idle: Color,
pub warning: Color,
pub error: Color,
pub muted: Color,
/// Heatmap colors, from "nothing" to "a lot" (256-color greens).
pub heat: [Color; 5],
}
impl Theme {
pub fn new(accent: Color) -> Theme {
Theme {
accent,
working: Color::Green,
on_break: Color::Yellow,
idle: Color::Gray,
warning: Color::Yellow,
error: Color::LightRed,
muted: Color::DarkGray,
heat: [
Color::Indexed(237),
Color::Indexed(22),
Color::Indexed(28),
Color::Indexed(34),
Color::Indexed(40),
],
}
}
pub fn muted(&self) -> Style {
Style::new().fg(self.muted)
}
pub fn accent(&self) -> Style {
Style::new().fg(self.accent)
}
pub fn title(&self) -> Style {
Style::new().fg(self.accent).add_modifier(Modifier::BOLD)
}
pub fn border(&self, focused: bool) -> Style {
if focused { Style::new().fg(self.accent) } else { self.muted() }
}
/// The highlighted row of a list.
pub fn selected(&self, focused: bool) -> Style {
if focused {
Style::new().fg(Color::Black).bg(self.accent)
} else {
Style::new().add_modifier(Modifier::REVERSED)
}
}
pub fn state(&self, state: ShiftState) -> Color {
match state {
ShiftState::Working { .. } => self.working,
ShiftState::OnBreak { .. } => self.on_break,
ShiftState::Idle => self.idle,
}
}
/// The heatmap color for an intensity from 0.0 (nothing) to 1.0 (a lot).
pub fn heat(&self, intensity: f64) -> Color {
let level = if intensity <= 0.0 { 0 } else { (intensity * 4.0).ceil().clamp(1.0, 4.0) as usize };
self.heat[level]
}
}
+76
View File
@@ -0,0 +1,76 @@
//! Large digits for the running timer, five rows high, easy to read from
//! across the room:
//!
//! ```text
//! █ ███ ███ ███
//! ██ █ █ █ █
//! █ ███ ███ ███
//! █ █ █ █ █
//! ███ ███ ███ ███
//! ```
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::Widget;
pub const HEIGHT: u16 = 5;
/// Draws `text` (digits, colons and spaces) in large block characters,
/// centered in the area.
pub struct BigDigits<'a> {
text: &'a str,
style: Style,
}
impl<'a> BigDigits<'a> {
pub fn new(text: &'a str, style: Style) -> BigDigits<'a> {
BigDigits { text, style }
}
/// Columns needed: each glyph plus a one-column gap between glyphs.
pub fn width(&self) -> u16 {
let glyphs: u16 = self.text.chars().map(|c| glyph(c)[0].len() as u16).sum();
glyphs + self.text.chars().count().saturating_sub(1) as u16
}
}
impl Widget for BigDigits<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let mut x = area.x + area.width.saturating_sub(self.width()) / 2;
let top = area.y + area.height.saturating_sub(HEIGHT) / 2;
for c in self.text.chars() {
let rows = glyph(c);
for (dy, row) in rows.iter().enumerate() {
for (dx, pixel) in row.chars().enumerate() {
let position = (x + dx as u16, top + dy as u16);
if pixel == '#'
&& area.contains(position.into())
&& let Some(cell) = buf.cell_mut(position)
{
cell.set_char('█').set_style(self.style);
}
}
}
x += rows[0].len() as u16 + 1;
}
}
}
/// The shape of a character: five rows, `#` where the block is filled.
fn glyph(c: char) -> [&'static str; 5] {
match c {
'0' => ["###", "# #", "# #", "# #", "###"],
'1' => [" # ", "## ", " # ", " # ", "###"],
'2' => ["###", " #", "###", "# ", "###"],
'3' => ["###", " #", "###", " #", "###"],
'4' => ["# #", "# #", "###", " #", " #"],
'5' => ["###", "# ", "###", " #", "###"],
'6' => ["###", "# ", "###", "# #", "###"],
'7' => ["###", " #", " #", " #", " #"],
'8' => ["###", "# #", "###", "# #", "###"],
'9' => ["###", "# #", "###", " #", "###"],
':' => [" ", "#", " ", "#", " "],
_ => [" ", " ", " ", " ", " "],
}
}
+71
View File
@@ -0,0 +1,71 @@
//! A button you can click (or trigger with its key):
//!
//! ```text
//! ╭───────────╮
//! │ ▶ Start s │
//! ╰───────────╯
//! ```
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Widget};
use crate::ui::theme::Theme;
pub const HEIGHT: u16 = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Look {
Normal,
/// The obvious next thing to do.
Primary,
/// Can't be pressed right now.
Disabled,
}
pub struct Button<'a> {
label: &'a str,
key: &'a str,
look: Look,
theme: &'a Theme,
}
impl<'a> Button<'a> {
pub fn new(label: &'a str, key: &'a str, look: Look, theme: &'a Theme) -> Button<'a> {
Button { label, key, look, theme }
}
/// Columns needed: the label, a space, the key, padding and borders.
pub fn width(&self) -> u16 {
(self.label.chars().count() + self.key.chars().count() + 5) as u16
}
pub fn look(&self) -> Look {
self.look
}
}
impl Widget for Button<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let (frame, text) = match self.look {
Look::Primary => {
let style = self.theme.accent().add_modifier(Modifier::BOLD);
(style, style)
}
Look::Normal => (Style::new(), Style::new()),
Look::Disabled => (self.theme.muted(), self.theme.muted()),
};
let block = Block::bordered().border_type(BorderType::Rounded).border_style(frame);
let inner = block.inner(area);
block.render(area, buf);
Line::from(vec![
Span::styled(self.label, text),
Span::raw(" "),
Span::styled(self.key, self.theme.muted()),
])
.centered()
.render(inner, buf);
}
}
+87
View File
@@ -0,0 +1,87 @@
//! A grid of colored cells with labels. The calendar (weekdays × weeks) and
//! the rhythm (weekdays × hours) are both drawn with it.
//!
//! ```text
//! Sep Oct
//! Mon ■ ■ ■ ■ ■ ■
//! Tue ■ ■ ■ ■ ■ ■
//! ```
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::widgets::Widget;
use crate::ui::theme::Theme;
pub struct Heatmap<'a> {
/// `cells[row][column]`, from 0.0 (nothing) to 1.0 (a lot). `None`
/// leaves the cell blank (for example, dates in the future).
pub cells: &'a [Vec<Option<f64>>],
/// A label left of each row.
pub row_labels: &'a [String],
/// Labels above the grid, as (column, text).
pub column_labels: &'a [(usize, String)],
/// How one cell looks, like "■ " or "██ ". Its length is the column width.
pub cell: &'a str,
/// A cell to underline (today, for example).
pub highlight: Option<(usize, usize)>,
pub theme: &'a Theme,
}
impl Heatmap<'_> {
/// Columns and rows needed to draw the whole grid.
pub fn size(&self) -> (u16, u16) {
let columns = self.cells.iter().map(Vec::len).max().unwrap_or(0);
let width = self.label_width() + columns as u16 * self.cell_width();
(width, self.cells.len() as u16 + 1)
}
fn label_width(&self) -> u16 {
self.row_labels.iter().map(|label| label.chars().count()).max().unwrap_or(0) as u16 + 1
}
fn cell_width(&self) -> u16 {
self.cell.chars().count() as u16
}
}
impl Widget for Heatmap<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let grid_x = area.x + self.label_width();
let right_edge = area.x + area.width;
// Labels that would touch the previous one are skipped.
let mut free_from = area.x;
for (column, label) in self.column_labels {
let x = grid_x + *column as u16 * self.cell_width();
let end = x + label.chars().count() as u16;
if x >= free_from && end <= right_edge {
buf.set_string(x, area.y, label, self.theme.muted());
free_from = end + 1;
}
}
for (row, cells) in self.cells.iter().enumerate() {
let y = area.y + 1 + row as u16;
if y >= area.y + area.height {
break;
}
if let Some(label) = self.row_labels.get(row) {
buf.set_string(area.x, y, label, self.theme.muted());
}
for (column, value) in cells.iter().enumerate() {
let x = grid_x + column as u16 * self.cell_width();
let Some(intensity) = value else { continue };
if x + self.cell_width() > right_edge {
break;
}
let mut style = Style::new().fg(self.theme.heat(*intensity));
if self.highlight == Some((row, column)) {
style = style.fg(self.theme.accent).add_modifier(Modifier::BOLD);
}
buf.set_string(x, y, self.cell, style);
}
}
}
}
+26
View File
@@ -0,0 +1,26 @@
//! Building blocks the screens draw with.
pub mod big_digits;
pub mod button;
pub mod heatmap;
pub mod text_input;
pub mod timeline;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::Span;
/// A progress bar made of text, `width` characters wide, filled to `ratio`
/// (0.0 to 1.0): `██████░░░░`.
pub fn progress_bar(width: u16, ratio: f64, filled: Style, empty: Style) -> [Span<'static>; 2] {
let width = usize::from(width);
let full = ((ratio.clamp(0.0, 1.0) * width as f64).round() as usize).min(width);
[Span::styled("█".repeat(full), filled), Span::styled("░".repeat(width - full), empty)]
}
/// A `width` × `height` rectangle in the middle of `area` (shrunk to fit).
pub fn centered(area: Rect, width: u16, height: u16) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
Rect { x: area.x + (area.width - width) / 2, y: area.y + (area.height - height) / 2, width, height }
}
+152
View File
@@ -0,0 +1,152 @@
//! A one-line text field. In vim insert mode it takes the keys directly:
//! typing, Backspace, Delete, arrows, Home/End, Ctrl-w (delete a word) and
//! Ctrl-u (delete to the start).
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TextInput {
text: String,
/// Where the cursor is, counted in characters (not bytes).
cursor: usize,
}
/// What a key did to the field, for keys the field doesn't handle itself.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputEvent {
/// The text or the cursor changed (or the key was ignored).
Edited,
/// Enter
Submit,
/// Esc: back to normal mode.
Exit,
/// Tab
Next,
/// Shift-Tab
Previous,
}
impl TextInput {
/// A field holding `text`, with the cursor at the end.
pub fn new(text: impl Into<String>) -> TextInput {
let text = text.into();
let cursor = text.chars().count();
TextInput { text, cursor }
}
pub fn text(&self) -> &str {
&self.text
}
pub fn clear(&mut self) {
*self = TextInput::default();
}
pub fn cursor_to_start(&mut self) {
self.cursor = 0;
}
pub fn cursor_to_end(&mut self) {
self.cursor = self.text.chars().count();
}
/// Handles one key in insert mode.
pub fn insert_key(&mut self, key: KeyEvent) -> InputEvent {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Enter => return InputEvent::Submit,
KeyCode::Esc => return InputEvent::Exit,
KeyCode::Tab => return InputEvent::Next,
KeyCode::BackTab => return InputEvent::Previous,
KeyCode::Char('w') if ctrl => self.delete_word_before_cursor(),
KeyCode::Char('u') if ctrl => {
self.text = self.text.chars().skip(self.cursor).collect();
self.cursor = 0;
}
KeyCode::Char(c) if !ctrl => {
let at = self.byte_index(self.cursor);
self.text.insert(at, c);
self.cursor += 1;
}
KeyCode::Backspace if self.cursor > 0 => {
self.cursor -= 1;
let at = self.byte_index(self.cursor);
self.text.remove(at);
}
KeyCode::Delete if self.cursor < self.text.chars().count() => {
let at = self.byte_index(self.cursor);
self.text.remove(at);
}
KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
KeyCode::Right => self.cursor = (self.cursor + 1).min(self.text.chars().count()),
KeyCode::Home => self.cursor_to_start(),
KeyCode::End => self.cursor_to_end(),
_ => {}
}
InputEvent::Edited
}
/// The text as a line of spans. With `editing`, the character under the
/// cursor is shown reversed (a block cursor).
pub fn line(&self, editing: bool, style: Style) -> Line<'static> {
if !editing {
return Line::from(Span::styled(self.text.clone(), style));
}
let before: String = self.text.chars().take(self.cursor).collect();
let under: String = self.text.chars().nth(self.cursor).map_or(" ".into(), String::from);
let after: String = self.text.chars().skip(self.cursor + 1).collect();
Line::from(vec![
Span::styled(before, style),
Span::styled(under, style.add_modifier(Modifier::REVERSED)),
Span::styled(after, style),
])
}
fn delete_word_before_cursor(&mut self) {
let before: Vec<char> = self.text.chars().take(self.cursor).collect();
let mut start = before.len();
while start > 0 && before[start - 1] == ' ' {
start -= 1;
}
while start > 0 && before[start - 1] != ' ' {
start -= 1;
}
let after: String = self.text.chars().skip(self.cursor).collect();
self.text = before[..start].iter().collect::<String>() + &after;
self.cursor = start;
}
/// The byte position of character number `char_index`.
fn byte_index(&self, char_index: usize) -> usize {
self.text.char_indices().nth(char_index).map_or(self.text.len(), |(index, _)| index)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn press(input: &mut TextInput, code: KeyCode, modifiers: KeyModifiers) {
input.insert_key(KeyEvent::new(code, modifiers));
}
#[test]
fn edits_like_a_shell_line() {
let mut input = TextInput::new("17:31");
press(&mut input, KeyCode::Backspace, KeyModifiers::NONE);
press(&mut input, KeyCode::Char('0'), KeyModifiers::NONE);
assert_eq!(input.text(), "17:30");
press(&mut input, KeyCode::Home, KeyModifiers::NONE);
press(&mut input, KeyCode::Delete, KeyModifiers::NONE);
assert_eq!(input.text(), "7:30");
input.cursor_to_end();
press(&mut input, KeyCode::Char(' '), KeyModifiers::NONE);
press(&mut input, KeyCode::Char('é'), KeyModifiers::NONE);
press(&mut input, KeyCode::Char('w'), KeyModifiers::CONTROL);
assert_eq!(input.text(), "7:30 ");
press(&mut input, KeyCode::Char('u'), KeyModifiers::CONTROL);
assert_eq!(input.text(), "");
}
}
+132
View File
@@ -0,0 +1,132 @@
//! One work day drawn on a time axis: when you worked, and your breaks.
//!
//! ```text
//! 22 23 00 01 02 03 04
//! ███████████████████░░░███████████▌
//! ```
use jiff::tz::TimeZone;
use jiff::{RoundMode, SignedDuration, Timestamp, Unit, ZonedRound};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::Widget;
use crate::time::Formatter;
use crate::ui::theme::Theme;
pub const HEIGHT: u16 = 2;
pub struct Timeline<'a> {
/// The stretches of work, in order. The session in progress (if any) is
/// the last one, ending now.
pub pieces: &'a [(Timestamp, Timestamp)],
/// When the session in progress started, if you're working.
pub running_since: Option<Timestamp>,
/// Where to put the "now" mark, if the window includes it.
pub now: Option<Timestamp>,
/// The span of time the axis covers.
pub window: (Timestamp, Timestamp),
pub zone: &'a TimeZone,
pub fmt: &'a Formatter,
pub theme: &'a Theme,
}
/// The axis for some pieces of work: from the start of the hour of the
/// first one to the end of the hour of the last one (or now), at least
/// `min_hours` wide.
pub fn window_for(
pieces: &[(Timestamp, Timestamp)],
now: Option<Timestamp>,
zone: &TimeZone,
min_hours: i64,
) -> (Timestamp, Timestamp) {
let first = pieces.first().map(|piece| piece.0).or(now).unwrap_or_else(Timestamp::now);
let last = pieces.iter().map(|piece| piece.1).chain(now).max().unwrap_or(first);
let start = start_of_hour(first, zone);
let mut end = start_of_hour(last, zone);
if end < last {
end += SignedDuration::from_hours(1);
}
let min_end = start + SignedDuration::from_hours(min_hours);
(start, end.max(min_end))
}
fn start_of_hour(moment: Timestamp, zone: &TimeZone) -> Timestamp {
let local = moment.to_zoned(zone.clone());
local
.round(ZonedRound::new().smallest(Unit::Hour).mode(RoundMode::Trunc))
.map_or(moment, |hour| hour.timestamp())
}
impl Widget for Timeline<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height < HEIGHT {
return;
}
let (start, end) = self.window;
let seconds_per_column = (end.duration_since(start).as_secs() / i64::from(area.width)).max(1);
let column_start =
|column: u16| start + SignedDuration::from_secs(i64::from(column) * seconds_per_column);
self.render_hours(area, seconds_per_column, buf);
let first_start = self.pieces.first().map(|piece| piece.0);
let last_end = self.pieces.last().map(|piece| piece.1);
let bar_y = area.y + 1;
for column in 0..area.width {
let (from, to) = (column_start(column), column_start(column + 1));
let worked: i64 = self
.pieces
.iter()
.map(|(piece_start, piece_end)| {
let overlap = (*piece_end).min(to).duration_since((*piece_start).max(from));
overlap.as_secs().max(0)
})
.sum();
let running = self.running_since.is_some_and(|since| since < to);
let is_now = self.now.is_some_and(|now| from <= now && now < to);
let within_day =
first_start.is_some_and(|first| first < to) && last_end.is_some_and(|last| last > from);
let (symbol, style) = if worked * 2 >= seconds_per_column {
let color = if running { self.theme.accent } else { self.theme.working };
("█", Style::new().fg(color))
} else if is_now {
("│", self.theme.accent())
} else if within_day {
("░", self.theme.muted())
} else {
("·", self.theme.muted())
};
if let Some(cell) = buf.cell_mut((area.x + column, bar_y)) {
cell.set_symbol(symbol).set_style(style);
}
}
}
}
impl Timeline<'_> {
/// Hour marks above the bar, spaced so they never touch.
fn render_hours(&self, area: Rect, seconds_per_column: i64, buf: &mut Buffer) {
let (start, end) = self.window;
let label_width = self.fmt.hour_mark(22).chars().count() as i64 + 1;
let hours_per_label = [1, 2, 3, 4, 6, 12, 24]
.into_iter()
.find(|hours| hours * 3600 / seconds_per_column >= label_width + 1)
.unwrap_or(24);
let mut moment = start;
while moment < end {
let local = moment.to_zoned(self.zone.clone());
if i64::from(local.hour()) % hours_per_label == 0 {
let column = moment.duration_since(start).as_secs() / seconds_per_column;
let label = self.fmt.hour_mark(local.hour());
if column + label.chars().count() as i64 <= i64::from(area.width) {
buf.set_string(area.x + column as u16, area.y, label, self.theme.muted());
}
}
moment += SignedDuration::from_hours(1);
}
}
}