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:
1 parent
eff7834838
commit
7c8a02275e
13 files changed
+1588
No files matched your search
@@ -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,
|
||||
}
|
||||
@@ -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() {}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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' => ["###", "# #", "###", " #", "###"],
|
||||
':' => [" ", "#", " ", "#", " "],
|
||||
_ => [" ", " ", " ", " ", " "],
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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(), "");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user