diff --git a/src/action.rs b/src/action.rs new file mode 100644 index 0000000..1b5b99b --- /dev/null +++ b/src/action.rs @@ -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), + 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, + }, + /// 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, + 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, +} diff --git a/src/main.rs b/src/main.rs index 4cfe254..e17be65 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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() {} diff --git a/src/ui/component.rs b/src/ui/component.rs new file mode 100644 index 0000000..82b0940 --- /dev/null +++ b/src/ui/component.rs @@ -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, ctx: &Context) -> Option; + + /// Reacts to the mouse. Most popups don't. + fn mouse(&mut self, _event: MouseEvent, _ctx: &Context) -> Option { + 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 { + 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, + 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 { + 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(), + } + } +} diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs new file mode 100644 index 0000000..29a05b5 --- /dev/null +++ b/src/ui/keymap.rs @@ -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: `` (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, + 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("", 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("", 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:?}"); + } + } + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..fa574ef --- /dev/null +++ b/src/ui/mod.rs @@ -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 · 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 = (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: io::Result { + 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()); +} diff --git a/src/ui/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..a6c3dc8 --- /dev/null +++ b/src/ui/theme.rs @@ -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] + } +} diff --git a/src/ui/widgets/big_digits.rs b/src/ui/widgets/big_digits.rs new file mode 100644 index 0000000..62ce182 --- /dev/null +++ b/src/ui/widgets/big_digits.rs @@ -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' => ["###", "# #", "###", " #", "###"], + ':' => [" ", "#", " ", "#", " "], + _ => [" ", " ", " ", " ", " "], + } +} diff --git a/src/ui/widgets/button.rs b/src/ui/widgets/button.rs new file mode 100644 index 0000000..ea81a55 --- /dev/null +++ b/src/ui/widgets/button.rs @@ -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); + } +} diff --git a/src/ui/widgets/heatmap.rs b/src/ui/widgets/heatmap.rs new file mode 100644 index 0000000..f5ed991 --- /dev/null +++ b/src/ui/widgets/heatmap.rs @@ -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>], + /// 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); + } + } + } +} diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs new file mode 100644 index 0000000..c53b3ed --- /dev/null +++ b/src/ui/widgets/mod.rs @@ -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 } +} diff --git a/src/ui/widgets/text_input.rs b/src/ui/widgets/text_input.rs new file mode 100644 index 0000000..e88dac6 --- /dev/null +++ b/src/ui/widgets/text_input.rs @@ -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) -> 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 = 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::() + &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(), ""); + } +} diff --git a/src/ui/widgets/timeline.rs b/src/ui/widgets/timeline.rs new file mode 100644 index 0000000..ee61134 --- /dev/null +++ b/src/ui/widgets/timeline.rs @@ -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, + /// Where to put the "now" mark, if the window includes it. + pub now: Option, + /// 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, + 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); + } + } +}