Add popups: segment editor, copy menu, export, help and command line

- segment_editor: type start/end times; the exact moments they mean are
  shown live, and invalid edits keep the popup open.
- time_prompt: "I started / paused / ended the day at...".
- copy_menu: every copy preset with a preview of what will be pasted.
- export_dialog, still_working (for sessions left running very long),
  help (generated from the key tables) and the `:` command line.

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

No files matched your search

+1
View File
@@ -13,6 +13,7 @@
pub mod component; pub mod component;
pub mod keymap; pub mod keymap;
pub mod popups;
pub mod terminal; pub mod terminal;
pub mod theme; pub mod theme;
pub mod widgets; pub mod widgets;
+237
View File
@@ -0,0 +1,237 @@
//! The command line, opened with `:` like in vim. Handy for things that
//! need a few words: `:pause 17:30`, `:export csv lastmonth`,
//! `:set time_format=12h`.
use jiff::Timestamp;
use jiff::civil::Date;
use ratatui::Frame;
use ratatui::crossterm::event::KeyEvent;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, Paragraph};
use super::export_dialog::named_ranges;
use crate::action::Action;
use crate::export::ExportFormat;
use crate::export::timesheet::CopyPreset;
use crate::settings::{Choice, FIELDS, Kind};
use crate::time::DateRange;
use crate::time::format::parse_time;
use crate::time::resolve::latest_until;
use crate::ui::keymap::{Binding, Command};
use crate::ui::widgets::text_input::{InputEvent, TextInput};
use crate::ui::{Component, Context, Tab};
/// Every command, for the help screen.
pub const COMMANDS: &[(&str, &str)] = &[
(":start [time]", "start now, or at an earlier time"),
(":pause [time]", "pause now, or at an earlier time"),
(":end [time]", "end the work day, now or at an earlier time"),
(":copy [format] [week]", "copy; formats: clock decimal segments inout custom"),
(":export [format] [dates]", "csv json md; week lastweek month lastmonth 30d year all"),
("", " dates can also be 2026-09 or 2026-09-01..2026-09-15"),
(":set name=value", "change a setting (name? shows it, name& resets it)"),
(":set name :set noname", "turn an on/off setting on or off (name! flips it)"),
(":undo :redo", "undo / redo"),
(":snooze", "snooze the break reminder"),
(":tracker :days :insights :settings", "go to a screen"),
(":help", "all keys"),
(":q", "quit"),
];
#[derive(Default)]
pub struct CommandLine {
input: TextInput,
error: Option<String>,
}
impl CommandLine {
pub fn new() -> CommandLine {
CommandLine::default()
}
}
impl Component for CommandLine {
fn keys(&self) -> &'static [&'static [Binding]] {
&[]
}
/// Drawn over the status line at the bottom of the window.
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
let bottom = Rect { y: area.bottom().saturating_sub(1), height: 1, ..area };
frame.render_widget(Clear, bottom);
let mut line = self.input.line(true, Style::new());
line.spans.insert(0, Span::styled(":", ctx.theme.title()));
frame.render_widget(Paragraph::new(line), bottom);
if let Some(error) = &self.error {
let above = Rect { y: bottom.y.saturating_sub(1), ..bottom };
frame.render_widget(Clear, above);
frame.render_widget(
Paragraph::new(Line::styled(error.clone(), Style::new().fg(ctx.theme.error))),
above,
);
}
}
fn command(&mut self, command: Command, _count: Option<usize>, _ctx: &Context) -> Option<Action> {
(command == Command::Close).then_some(Action::ClosePopup)
}
fn inserting(&self) -> bool {
true
}
fn insert_key(&mut self, key: KeyEvent, ctx: &Context) -> Option<Action> {
self.error = None;
match self.input.insert_key(key) {
InputEvent::Submit => match parse(self.input.text(), ctx) {
Ok(action) => Some(action),
Err(error) => {
self.error = Some(error);
None
}
},
InputEvent::Exit => Some(Action::ClosePopup),
_ => None,
}
}
}
/// Turns a command line into an action.
pub fn parse(text: &str, ctx: &Context) -> Result<Action, String> {
let mut words = text.split_whitespace();
let Some(name) = words.next() else { return Ok(Action::ClosePopup) };
let rest: Vec<&str> = words.collect();
// An optional time after the command, as the latest moment it happened.
let moment = || -> Result<Option<Timestamp>, String> {
match rest.first() {
None => Ok(None),
Some(text) => {
let time = parse_time(text).map_err(|error| error.to_string())?;
Ok(Some(latest_until(time, ctx.now).timestamp()))
}
}
};
match name {
"q" | "q!" | "quit" | "wq" | "x" | "qa" => Ok(Action::Quit),
"w" => Ok(Action::Notify("skyshift saves every change right away".into())),
"start" => Ok(moment()?.map_or(Action::Start, Action::StartAt)),
"pause" => Ok(moment()?.map_or(Action::Pause, Action::PauseAt)),
"end" => Ok(moment()?.map_or(Action::EndDay, Action::EndDayAt)),
"undo" | "u" => Ok(Action::Undo),
"redo" => Ok(Action::Redo),
"snooze" => Ok(Action::Snooze),
"tracker" => Ok(Action::GoTo(Tab::Tracker)),
"days" => Ok(Action::GoTo(Tab::Days)),
"insights" => Ok(Action::GoTo(Tab::Insights)),
"settings" => Ok(Action::GoTo(Tab::Settings)),
"help" | "h" => Ok(Action::ShowHelp),
"copy" | "y" => copy(&rest, ctx),
"export" => export(&rest, ctx),
"set" | "se" => set(&rest.join(" "), ctx),
other => Err(format!("unknown command :{other} (:help lists them)")),
}
}
fn copy(words: &[&str], ctx: &Context) -> Result<Action, String> {
let mut preset = ctx.settings.copy_preset;
let mut week = false;
for word in words {
match *word {
"week" => week = true,
"day" => week = false,
"clock" | "hm" | "total" => preset = CopyPreset::TotalClock,
"decimal" => preset = CopyPreset::TotalDecimal,
"segments" => preset = CopyPreset::Segments,
"inout" => preset = CopyPreset::InOutBreak,
"custom" | "template" => preset = CopyPreset::Custom,
other => {
preset = CopyPreset::from_name(other).ok_or(format!("unknown copy format \"{other}\""))?
}
}
}
let day = ctx.current_day();
let days = if week { ctx.week_days(day) } else { vec![day] };
Ok(Action::Copy { days, preset })
}
fn export(words: &[&str], ctx: &Context) -> Result<Action, String> {
let mut format = ctx.settings.export_format;
let today = ctx.now.date();
let named = named_ranges(today, ctx.settings.week_start, ctx.days);
let mut range = named[0].2;
for word in words {
if let Some(found) = ExportFormat::from_name(word) {
format = found;
} else if *word == "md" {
format = ExportFormat::Markdown;
} else if let Some((_, _, found)) = named.iter().find(|(_, name, _)| name == word) {
range = *found;
} else {
range = parse_dates(word)?;
}
}
Ok(Action::Export { format, range })
}
/// "2026-09" (a month), "2026-09-24" (a day) or "2026-09-01..2026-09-15".
fn parse_dates(text: &str) -> Result<DateRange, String> {
let error =
|| format!("\"{text}\" isn't a format or dates (try week, month, 2026-09 or 2026-09-01..2026-09-15)");
let date = |text: &str| text.parse::<Date>().map_err(|_| error());
if let Some((first, last)) = text.split_once("..") {
return Ok(DateRange::new(date(first)?, date(last)?));
}
if text.len() == 7 {
return Ok(DateRange::month_of(date(&format!("{text}-01"))?));
}
let day = date(text)?;
Ok(DateRange::new(day, day))
}
/// `name=value`, `name value`, `name?` (show), `name&` (reset), and for
/// on/off settings `name`, `noname` and `name!` (flip), like vim.
fn set(text: &str, ctx: &Context) -> Result<Action, String> {
let text = text.trim();
if text.is_empty() {
return Err("usage: :set name=value (the names are in config.toml)".into());
}
let (name, value) = match text.split_once(['=', ' ']) {
Some((name, value)) => (name.trim(), Some(value.trim())),
None => (text, None),
};
let find = |name: &str| FIELDS.iter().find(|field| field.key == name);
if let Some(name) = name.strip_suffix('?') {
let field = find(name).ok_or(format!("no setting named {name}"))?;
return Ok(Action::Notify(format!("{} = {}", field.key, (field.get)(ctx.settings))));
}
if let Some(name) = name.strip_suffix('&') {
let field = find(name).ok_or(format!("no setting named {name}"))?;
return Ok(Action::ResetSetting { key: field.key });
}
if let Some(value) = value {
let field = find(name).ok_or(format!("no setting named {name}"))?;
return Ok(Action::SetSetting { key: field.key, value: value.to_string() });
}
// On/off settings: `mouse`, `nomouse`, `mouse!`.
let (name, value) = if let Some(name) = name.strip_suffix('!') {
let field = find(name).ok_or(format!("no setting named {name}"))?;
let on = (field.get)(ctx.settings) == "true";
(name, (!on).to_string())
} else if let Some(name) = name.strip_prefix("no").filter(|name| find(name).is_some()) {
(name, "false".to_string())
} else {
(name, "true".to_string())
};
let field = find(name).ok_or(format!("no setting named {name}"))?;
if !matches!(field.kind, Kind::Toggle) {
return Err(format!("{name} isn't an on/off setting: use :set {name}=value"));
}
Ok(Action::SetSetting { key: field.key, value })
}
+122
View File
@@ -0,0 +1,122 @@
//! Choosing what to copy for your timesheet, with a preview of exactly what
//! will be pasted.
use jiff::civil::Date;
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use super::window;
use crate::action::Action;
use crate::domain::WorkDay;
use crate::export::timesheet::{CopyPreset, days_text};
use crate::ui::keymap::{self, Binding, Command, CopyTarget};
use crate::ui::{Component, Context};
pub struct CopyMenu {
day: Date,
target: CopyTarget,
/// Index in `CopyPreset::ALL`.
selected: usize,
}
impl CopyMenu {
pub fn new(day: Date, ctx: &Context) -> CopyMenu {
let selected =
CopyPreset::ALL.iter().position(|preset| *preset == ctx.settings.copy_preset).unwrap_or(0);
CopyMenu { day, target: CopyTarget::Day, selected }
}
/// The days the copy covers: the day, or its whole week.
fn dates(&self, ctx: &Context) -> Vec<Date> {
match self.target {
CopyTarget::Day => vec![self.day],
CopyTarget::Week => ctx.week_days(self.day),
}
}
fn preview(&self, preset: CopyPreset, ctx: &Context) -> String {
let days: Vec<&WorkDay> = self.dates(ctx).into_iter().filter_map(|date| ctx.day(date)).collect();
if days.is_empty() {
return "(nothing recorded)".into();
}
// Show tabs and line breaks, which are otherwise invisible.
days_text(&days, preset, &ctx.copy_options()).replace('\t', " → ").replace('\n', " ⏎ ")
}
}
impl Component for CopyMenu {
fn keys(&self) -> &'static [&'static [Binding]] {
&[keymap::POPUP]
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
let title = format!("Copy · {}", ctx.fmt.date_short(self.day));
let inner = window(
frame,
area,
76,
11,
&title,
"j/k choose · h/l day or week · Enter copy · Esc cancel",
ctx.theme,
);
let tab = |label: &'static str, active: bool| {
if active {
Span::styled(format!(" {label} "), ctx.theme.selected(true))
} else {
Span::raw(format!(" {label} "))
}
};
let mut lines = vec![
Line::from(vec![
Span::raw(" "),
tab("This day", self.target == CopyTarget::Day),
Span::raw(" "),
tab("Its week, a line per day", self.target == CopyTarget::Week),
]),
Line::default(),
];
let preview_width = usize::from(inner.width).saturating_sub(44);
for (index, preset) in CopyPreset::ALL.iter().enumerate() {
let selected = index == self.selected;
let marker = if selected { "▶ " } else { " " };
let label_style = if selected { ctx.theme.title() } else { Style::new() };
let preview: String = self.preview(*preset, ctx).chars().take(preview_width).collect();
lines.push(Line::from(vec![
Span::styled(format!(" {marker}{:<40}", preset.label()), label_style),
Span::styled(
preview,
ctx.theme.muted().add_modifier(if selected { Modifier::BOLD } else { Modifier::empty() }),
),
]));
}
frame.render_widget(Paragraph::new(lines), inner);
}
fn command(&mut self, command: Command, count: Option<usize>, ctx: &Context) -> Option<Action> {
let last = CopyPreset::ALL.len() - 1;
let steps = count.unwrap_or(1);
match command {
Command::Down => self.selected = (self.selected + steps).min(last),
Command::Up => self.selected = self.selected.saturating_sub(steps),
Command::Top => self.selected = 0,
Command::Bottom => self.selected = last,
Command::Left | Command::Right => {
self.target = match self.target {
CopyTarget::Day => CopyTarget::Week,
CopyTarget::Week => CopyTarget::Day,
}
}
Command::Confirm => {
return Some(Action::Copy { days: self.dates(ctx), preset: CopyPreset::ALL[self.selected] });
}
Command::Close => return Some(Action::ClosePopup),
_ => {}
}
None
}
}
+118
View File
@@ -0,0 +1,118 @@
//! Exporting a report file: pick a format and a range of dates.
use jiff::ToSpan;
use jiff::civil::{Date, Weekday};
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use super::window;
use crate::action::Action;
use crate::domain::WorkDay;
use crate::export::ExportFormat;
use crate::time::DateRange;
use crate::ui::keymap::{self, Binding, Command};
use crate::ui::{Component, Context};
/// The ranges offered for exports, with a name for the `:export` command.
pub fn named_ranges(
today: Date,
week_start: Weekday,
days: &[WorkDay],
) -> Vec<(&'static str, &'static str, DateRange)> {
let this_week = DateRange::week_of(today, week_start);
let this_month = DateRange::month_of(today);
let first_day = days.first().map_or(today, WorkDay::date);
vec![
("This week", "week", this_week),
("Last week", "lastweek", this_week.previous()),
("This month", "month", this_month),
("Last month", "lastmonth", DateRange::month_of(this_month.first().saturating_sub(1.day()))),
("Last 30 days", "30d", DateRange::last_days(today, 30)),
("This year", "year", DateRange::new(today.first_of_year(), today)),
("All time", "all", DateRange::new(first_day, today)),
]
}
pub struct ExportDialog {
format: ExportFormat,
/// Index in `named_ranges`.
selected: usize,
}
impl ExportDialog {
pub fn new(ctx: &Context) -> ExportDialog {
ExportDialog { format: ctx.settings.export_format, selected: 0 }
}
fn cycle_format(&mut self, forward: bool) {
let all = ExportFormat::ALL;
let index = all.iter().position(|format| *format == self.format).unwrap_or(0);
let next = if forward { (index + 1) % all.len() } else { (index + all.len() - 1) % all.len() };
self.format = all[next];
}
}
impl Component for ExportDialog {
fn keys(&self) -> &'static [&'static [Binding]] {
&[keymap::POPUP]
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
let inner = window(
frame,
area,
64,
15,
"Export a report",
"h/l format · j/k dates · Enter export · Esc cancel",
ctx.theme,
);
let mut lines = vec![
Line::default(),
Line::from(vec![
Span::raw(" Format "),
Span::styled(format!("◀ {} ▶", self.format.label()), ctx.theme.title()),
]),
Line::default(),
];
for (index, (label, _, range)) in
named_ranges(ctx.now.date(), ctx.settings.week_start, ctx.days).iter().enumerate()
{
let selected = index == self.selected;
let marker = if selected { "▶ " } else { " " };
let style = if selected { ctx.theme.title() } else { Style::new() };
let dates =
format!("{} - {}", ctx.fmt.date_short(range.first()), ctx.fmt.date_short(range.last()));
lines.push(Line::from(vec![
Span::styled(format!(" {marker}{label:<14}"), style),
Span::styled(dates, ctx.theme.muted()),
]));
}
lines.push(Line::default());
lines.push(Line::styled(format!(" Saved in {}", ctx.export_folder.display()), ctx.theme.muted()));
frame.render_widget(Paragraph::new(lines), inner);
}
fn command(&mut self, command: Command, count: Option<usize>, ctx: &Context) -> Option<Action> {
let ranges = named_ranges(ctx.now.date(), ctx.settings.week_start, ctx.days);
let last = ranges.len() - 1;
let steps = count.unwrap_or(1);
match command {
Command::Down => self.selected = (self.selected + steps).min(last),
Command::Up => self.selected = self.selected.saturating_sub(steps),
Command::Top => self.selected = 0,
Command::Bottom => self.selected = last,
Command::Left => self.cycle_format(false),
Command::Right => self.cycle_format(true),
Command::Confirm => {
return Some(Action::Export { format: self.format, range: ranges[self.selected].2 });
}
Command::Close => return Some(Action::ClosePopup),
_ => {}
}
None
}
}
+114
View File
@@ -0,0 +1,114 @@
//! Every key, generated from the key tables (so it's never out of date).
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use super::command_line::COMMANDS;
use super::window;
use crate::action::Action;
use crate::ui::keymap::{self, Binding, Command};
use crate::ui::{Component, Context};
const TYPING: &[(&str, &str)] = &[
("<CR>", "confirm"),
("<Esc>", "back to normal mode (Esc again closes)"),
("<Tab> <S-Tab>", "next / previous field"),
("<C-w>", "delete the word before the cursor"),
("<C-u>", "delete everything before the cursor"),
("<Left> <Right> <Home> <End>", "move the cursor"),
];
pub struct Help {
sections: Vec<(String, Vec<(String, String)>)>,
scroll: usize,
/// Lines that fit on screen (known after drawing), for paging.
visible: usize,
}
impl Help {
/// Help for a screen: its own keys first, then everything else.
pub fn new(screen: &str, screen_tables: &[&[Binding]]) -> Help {
let pairs = |rows: &[(&str, &str)]| {
rows.iter().map(|(keys, help)| (keys.to_string(), help.to_string())).collect()
};
Help {
sections: vec![
(format!("{screen} screen"), merged(screen_tables)),
("Everywhere".into(), merged(&[keymap::GLOBAL])),
("In popups (normal mode)".into(), merged(&[keymap::POPUP])),
("Typing (insert mode)".into(), pairs(TYPING)),
("Command line".into(), pairs(COMMANDS)),
],
scroll: 0,
visible: 10,
}
}
fn lines(&self, ctx: &Context) -> Vec<Line<'static>> {
let mut lines = Vec::new();
for (title, rows) in &self.sections {
if rows.is_empty() {
continue;
}
lines.push(Line::styled(format!(" {title}"), ctx.theme.title()));
for (keys, help) in rows {
lines.push(Line::from(vec![
Span::styled(format!(" {keys:<28}"), ctx.theme.accent()),
Span::styled(help.clone(), Style::new()),
]));
}
lines.push(Line::default());
}
lines
}
}
/// The bindings of some tables as (keys, help). Keys that do the same
/// thing are merged on one line: "j <Down>".
fn merged(tables: &[&[Binding]]) -> Vec<(String, String)> {
let mut rows: Vec<(String, String)> = Vec::new();
for binding in tables.iter().flat_map(|table| table.iter()) {
match rows.iter_mut().find(|(_, help)| help == binding.help) {
Some((keys, _)) => {
keys.push(' ');
keys.push_str(binding.keys);
}
None => rows.push((binding.keys.to_string(), binding.help.to_string())),
}
}
rows
}
impl Component for Help {
fn keys(&self) -> &'static [&'static [Binding]] {
&[keymap::POPUP]
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
let width = area.width.saturating_sub(8).min(100);
let height = area.height.saturating_sub(4);
let inner = window(frame, area, width, height, "Keys", "j/k scroll · q close", ctx.theme);
let lines = self.lines(ctx);
self.visible = usize::from(inner.height).max(1);
self.scroll = self.scroll.min(lines.len().saturating_sub(self.visible));
frame.render_widget(Paragraph::new(lines).scroll((self.scroll as u16, 0)), inner);
}
fn command(&mut self, command: Command, count: Option<usize>, _ctx: &Context) -> Option<Action> {
let steps = count.unwrap_or(1);
match command {
Command::Down => self.scroll += steps,
Command::Up => self.scroll = self.scroll.saturating_sub(steps),
Command::HalfPageDown => self.scroll += self.visible / 2,
Command::HalfPageUp => self.scroll = self.scroll.saturating_sub(self.visible / 2),
Command::Top => self.scroll = 0,
Command::Bottom => self.scroll = usize::MAX / 2,
Command::Close | Command::Confirm => return Some(Action::ClosePopup),
_ => {}
}
None
}
}
+77
View File
@@ -0,0 +1,77 @@
//! Popups: small windows over the screen, each for one focused task.
//!
//! Every popup is a [`Component`](super::Component). An action a popup
//! returns closes it when the app applies it successfully; when it fails
//! (say, the times overlap another segment), the popup stays open.
pub mod command_line;
pub mod copy_menu;
pub mod export_dialog;
pub mod help;
pub mod segment_editor;
pub mod still_working;
pub mod time_prompt;
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Clear};
use super::theme::Theme;
use super::widgets::centered;
use super::widgets::text_input::TextInput;
/// Draws an empty popup window of `width` × `height` in the middle of
/// `area`, with a title and a hint at the bottom. Returns the space inside.
pub fn window(
frame: &mut Frame,
area: Rect,
width: u16,
height: u16,
title: &str,
hint: &str,
theme: &Theme,
) -> Rect {
let rect = centered(area, width, height);
frame.render_widget(Clear, rect);
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(theme.accent())
.title(Line::styled(format!(" {title} "), theme.title()))
.title_bottom(Line::styled(format!(" {hint} "), theme.muted()).right_aligned());
let inner = block.inner(rect);
frame.render_widget(block, rect);
inner
}
/// One line of a form: ` Start 22:00 Thu Sep 24 22:00`.
/// The focused field is highlighted; in insert mode it shows the cursor.
pub fn field_line(
label: &str,
input: &TextInput,
focused: bool,
inserting: bool,
note: Span<'static>,
theme: &Theme,
) -> Line<'static> {
const INPUT_WIDTH: usize = 14;
let label_style = if focused { theme.title() } else { Style::new() };
let input_style = if focused { Style::new().add_modifier(Modifier::UNDERLINED) } else { theme.muted() };
let mut spans = vec![Span::styled(format!(" {label:<10}"), label_style)];
let mut text = input.line(focused && inserting, input_style).spans;
let used: usize = text.iter().map(|span| span.content.chars().count()).sum();
text.push(Span::styled(" ".repeat(INPUT_WIDTH.saturating_sub(used)), input_style));
spans.extend(text);
spans.push(Span::raw(" "));
spans.push(note);
Line::from(spans)
}
/// The line under a form: the error if there is one, a tip otherwise.
pub fn message_line(error: Option<&str>, tip: &str, theme: &Theme) -> Line<'static> {
match error {
Some(error) => Line::styled(format!(" {error}"), Style::new().fg(theme.error)),
None => Line::styled(format!(" {tip}"), theme.muted()),
}
}
+277
View File
@@ -0,0 +1,277 @@
//! Adding or fixing a segment by typing its times.
//!
//! You type clock times ("22:00", "1:30am"); the editor shows right away
//! which exact moments they mean (see `time::resolve`), so a night shift
//! "22:00 to 01:30" visibly ends the next day.
use jiff::civil::Date;
use jiff::{Timestamp, Zoned};
use ratatui::Frame;
use ratatui::crossterm::event::KeyEvent;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use super::{field_line, message_line, window};
use crate::action::Action;
use crate::domain::{Segment, WorkDay};
use crate::time::format::parse_time;
use crate::time::resolve::{nearest, next_after};
use crate::ui::keymap::{self, Binding, Command};
use crate::ui::widgets::text_input::{InputEvent, TextInput};
use crate::ui::{Component, Context};
/// What is being edited.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
/// A new segment for the work day `day`, placed after (or before) its
/// existing segments.
New { day: Date, after: bool },
/// Segment number `index` of the work day `day`.
Existing { day: Date, index: usize, original: Segment },
/// The session in progress. Leaving the end empty keeps it running.
Running { day: Date, since: Timestamp },
}
pub struct SegmentEditor {
target: Target,
start: TextInput,
end: TextInput,
day: TextInput,
/// The field with the focus: 0 = start, 1 = end, 2 = work day.
field: usize,
inserting: bool,
error: Option<String>,
}
/// The typed fields, turned into exact moments.
struct Resolved {
start: Zoned,
end: Option<Zoned>,
day: Date,
}
impl SegmentEditor {
pub fn new(target: Target, ctx: &Context) -> SegmentEditor {
let (start, end, day) = match target {
Target::New { day, .. } => (String::new(), String::new(), day),
Target::Existing { day, original, .. } => {
(ctx.time(original.start()), ctx.time(original.end()), day)
}
Target::Running { day, since } => (ctx.time(since), String::new(), day),
};
SegmentEditor {
target,
start: TextInput::new(start),
end: TextInput::new(end),
day: TextInput::new(day.to_string()),
field: 0,
inserting: true,
error: None,
}
}
fn is_running(&self) -> bool {
matches!(self.target, Target::Running { .. })
}
fn field_count(&self) -> usize {
if self.is_running() { 2 } else { 3 }
}
fn input(&mut self) -> &mut TextInput {
match self.field {
0 => &mut self.start,
1 => &mut self.end,
_ => &mut self.day,
}
}
/// Where typed start times are placed: close to this moment.
fn reference(&self, ctx: &Context) -> Zoned {
let day_edge = |day: Date, after: bool| {
let work_day = ctx.day(day);
let edge = if after {
work_day.and_then(WorkDay::last_end)
} else {
work_day.and_then(WorkDay::first_start)
};
edge.map_or_else(|| ctx.noon(day), |moment| ctx.local(moment))
};
match self.target {
Target::New { day, after } => day_edge(day, after),
Target::Existing { original, .. } => ctx.local(original.start()),
Target::Running { since, .. } => ctx.local(since),
}
}
fn resolve(&self, ctx: &Context) -> Result<Resolved, String> {
let start_time = parse_time(self.start.text()).map_err(|error| format!("start: {error}"))?;
let start = nearest(start_time, &self.reference(ctx));
let end = match self.end.text().trim() {
"" if self.is_running() => None,
"" => return Err("end: when did it end?".into()),
text => {
let end_time = parse_time(text).map_err(|error| format!("end: {error}"))?;
Some(next_after(end_time, &start))
}
};
let day = match self.target {
Target::Running { day, .. } => day,
_ => self
.day
.text()
.trim()
.parse()
.map_err(|_| "work day: write it like 2026-09-24".to_string())?,
};
Ok(Resolved { start, end, day })
}
fn submit(&mut self, ctx: &Context) -> Option<Action> {
let result = self.resolve(ctx).and_then(|resolved| self.action(resolved));
match result {
Ok(action) => Some(action),
Err(error) => {
self.error = Some(error);
None
}
}
}
fn action(&self, resolved: Resolved) -> Result<Action, String> {
let start = resolved.start.timestamp();
let end = resolved.end.map(|end| end.timestamp());
if let Target::Running { .. } = self.target {
return Ok(Action::FixRunning { since: start, stopped: end });
}
let segment =
Segment::new(start, end.ok_or("end: when did it end?")?).map_err(|error| error.to_string())?;
Ok(match self.target {
Target::Existing { day, index, .. } => {
Action::ReplaceSegment { day, index, segment, new_day: resolved.day }
}
_ => Action::AddSegment { day: resolved.day, segment },
})
}
fn title(&self, ctx: &Context) -> String {
match self.target {
Target::New { day, .. } => format!("Add work · {}", ctx.fmt.date_short(day)),
Target::Existing { day, index, .. } => {
format!("Segment {} · {}", index + 1, ctx.fmt.date_short(day))
}
Target::Running { .. } => "The session in progress".to_string(),
}
}
/// The exact moment a field means, next to it.
fn note(moment: Option<&Zoned>, empty_note: &str, ctx: &Context) -> Span<'static> {
match moment {
Some(moment) => Span::styled(
format!("{} {}", ctx.fmt.date_short(moment.date()), ctx.fmt.time(moment.time())),
ctx.theme.accent(),
),
None => Span::styled(empty_note.to_string(), ctx.theme.muted()),
}
}
}
impl Component for SegmentEditor {
fn keys(&self) -> &'static [&'static [Binding]] {
&[keymap::POPUP]
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
let title = self.title(ctx);
let inner =
window(frame, area, 76, 11, &title, "Enter save · Tab next field · Esc cancel", ctx.theme);
let resolved = self.resolve(ctx);
let (start, end) = match &resolved {
Ok(resolved) => (Some(&resolved.start), resolved.end.as_ref()),
Err(_) => (None, None),
};
let end_note = if self.is_running() { "empty = still working" } else { "" };
let mut lines = vec![
Line::default(),
field_line(
"Start",
&self.start,
self.field == 0,
self.inserting,
Self::note(start, "", ctx),
ctx.theme,
),
field_line(
"End",
&self.end,
self.field == 1,
self.inserting,
Self::note(end, end_note, ctx),
ctx.theme,
),
];
if !self.is_running() {
let note = Span::styled("the day it counts for", ctx.theme.muted());
lines.push(field_line("Work day", &self.day, self.field == 2, self.inserting, note, ctx.theme));
}
lines.push(Line::default());
let length = match (start, end) {
(Some(start), Some(end)) => ctx.fmt.duration(end.duration_since(start)),
(Some(start), None) if self.is_running() => {
ctx.fmt.duration(ctx.now.duration_since(start)) + " so far"
}
_ => String::new(),
};
lines.push(Line::from(vec![
Span::raw(" Length "),
Span::styled(length, Style::new().add_modifier(Modifier::BOLD)),
]));
let tip = "22:30, 2230 or 10:30pm all work. An end before the start is the next day.";
lines.push(message_line(self.error.as_deref(), tip, ctx.theme));
frame.render_widget(Paragraph::new(lines), inner);
}
fn command(&mut self, command: Command, _count: Option<usize>, ctx: &Context) -> Option<Action> {
match command {
Command::Confirm => return self.submit(ctx),
Command::Close => return Some(Action::ClosePopup),
Command::Down => self.field = (self.field + 1) % self.field_count(),
Command::Up => self.field = (self.field + self.field_count() - 1) % self.field_count(),
Command::Top => self.field = 0,
Command::Bottom => self.field = self.field_count() - 1,
Command::Insert => {
self.input().cursor_to_end();
self.inserting = true;
}
Command::InsertAtStart => {
self.input().cursor_to_start();
self.inserting = true;
}
Command::Retype => {
self.input().clear();
self.inserting = true;
}
_ => {}
}
None
}
fn inserting(&self) -> bool {
self.inserting
}
fn insert_key(&mut self, key: KeyEvent, ctx: &Context) -> Option<Action> {
self.error = None;
match self.input().insert_key(key) {
InputEvent::Submit => return self.submit(ctx),
InputEvent::Exit => self.inserting = false,
InputEvent::Next => self.field = (self.field + 1) % self.field_count(),
InputEvent::Previous => self.field = (self.field + self.field_count() - 1) % self.field_count(),
InputEvent::Edited => {}
}
None
}
}
+126
View File
@@ -0,0 +1,126 @@
//! "Still working?": asked when a session has been running for a very long
//! time, in case you forgot to pause.
use jiff::{Timestamp, Zoned};
use ratatui::Frame;
use ratatui::crossterm::event::KeyEvent;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use super::{field_line, message_line, window};
use crate::action::Action;
use crate::time::format::parse_time;
use crate::time::resolve::next_after;
use crate::ui::keymap::{self, Binding, Command};
use crate::ui::widgets::text_input::{InputEvent, TextInput};
use crate::ui::{Component, Context};
pub struct StillWorking {
since: Timestamp,
input: TextInput,
inserting: bool,
error: Option<String>,
}
impl StillWorking {
pub fn new(since: Timestamp) -> StillWorking {
StillWorking { since, input: TextInput::default(), inserting: true, error: None }
}
/// When you stopped: the first time after the start the clock showed
/// the typed time.
fn stopped(&self, ctx: &Context) -> Result<Zoned, String> {
let time = parse_time(self.input.text()).map_err(|error| error.to_string())?;
let stopped = next_after(time, &ctx.local(self.since));
if stopped.timestamp() > ctx.now.timestamp() {
return Err("that's later than now".into());
}
Ok(stopped)
}
fn submit(&mut self, ctx: &Context) -> Option<Action> {
match self.stopped(ctx) {
Ok(stopped) => Some(Action::PauseAt(stopped.timestamp())),
Err(error) => {
self.error = Some(error);
None
}
}
}
}
impl Component for StillWorking {
fn keys(&self) -> &'static [&'static [Binding]] {
&[keymap::POPUP]
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
let inner = window(
frame,
area,
68,
10,
"Still working?",
"Enter record the pause · Esc I'm still working",
ctx.theme,
);
let started = ctx.local(self.since);
let running = ctx.now.duration_since(&started);
let note = if self.input.text().trim().is_empty() {
Span::styled("like 18:00", ctx.theme.muted())
} else {
match self.stopped(ctx) {
Ok(stopped) => Span::styled(
format!("{} {}", ctx.fmt.date_short(stopped.date()), ctx.fmt.time(stopped.time())),
ctx.theme.accent(),
),
Err(error) => Span::styled(error, Style::new().fg(ctx.theme.error)),
}
};
let lines = vec![
Line::default(),
Line::from(format!(
" This session started {} at {}: {} ago.",
ctx.fmt.date_short(started.date()),
ctx.fmt.time(started.time()),
ctx.fmt.duration(running)
)),
Line::from(" If you forgot to pause, when did you stop?"),
Line::default(),
field_line("Stopped at", &self.input, true, self.inserting, note, ctx.theme),
Line::default(),
message_line(self.error.as_deref(), "No worries: nothing is lost either way.", ctx.theme),
];
frame.render_widget(Paragraph::new(lines), inner);
}
fn command(&mut self, command: Command, _count: Option<usize>, ctx: &Context) -> Option<Action> {
match command {
Command::Confirm => self.submit(ctx),
Command::Close => Some(Action::ClosePopup),
Command::Insert | Command::InsertAtStart | Command::Retype => {
self.inserting = true;
None
}
_ => None,
}
}
fn inserting(&self) -> bool {
self.inserting
}
fn insert_key(&mut self, key: KeyEvent, ctx: &Context) -> Option<Action> {
self.error = None;
match self.input.insert_key(key) {
InputEvent::Submit => self.submit(ctx),
InputEvent::Exit => {
self.inserting = false;
None
}
_ => None,
}
}
}
+159
View File
@@ -0,0 +1,159 @@
//! Asks for one time, for when you forgot to press a button:
//! "Start at...", "Pause at...", "End the day at...".
use jiff::{Timestamp, Zoned};
use ratatui::Frame;
use ratatui::crossterm::event::KeyEvent;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use super::{field_line, message_line, window};
use crate::action::Action;
use crate::domain::ShiftState;
use crate::time::format::parse_time;
use crate::time::resolve::latest_until;
use crate::ui::keymap::{self, Binding, Command};
use crate::ui::widgets::text_input::{InputEvent, TextInput};
use crate::ui::{Component, Context};
/// What the typed time is for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Purpose {
Start,
Pause,
EndDay,
}
impl Purpose {
fn title(self) -> &'static str {
match self {
Purpose::Start => "You started at...",
Purpose::Pause => "You paused at...",
Purpose::EndDay => "You ended the day at...",
}
}
fn action(self, moment: Timestamp) -> Action {
match self {
Purpose::Start => Action::StartAt(moment),
Purpose::Pause => Action::PauseAt(moment),
Purpose::EndDay => Action::EndDayAt(moment),
}
}
}
pub struct TimePrompt {
purpose: Purpose,
input: TextInput,
inserting: bool,
error: Option<String>,
}
impl TimePrompt {
pub fn new(purpose: Purpose) -> TimePrompt {
TimePrompt { purpose, input: TextInput::default(), inserting: true, error: None }
}
/// The moment typed so far: the latest time the clock showed it.
fn moment(&self, ctx: &Context) -> Result<Zoned, String> {
let time = parse_time(self.input.text()).map_err(|error| error.to_string())?;
Ok(latest_until(time, ctx.now))
}
fn submit(&mut self, ctx: &Context) -> Option<Action> {
match self.moment(ctx) {
Ok(moment) => Some(self.purpose.action(moment.timestamp())),
Err(error) => {
self.error = Some(error);
None
}
}
}
/// What the typed time means: "Thu Sep 24 17:30 · 35m ago".
fn preview(&self, ctx: &Context) -> Span<'static> {
if self.input.text().trim().is_empty() {
return Span::styled("like 17:30 or 5:30pm", ctx.theme.muted());
}
match self.moment(ctx) {
Ok(moment) => {
let ago = ctx.fmt.duration(ctx.now.duration_since(&moment));
let mut text = format!(
"{} {} · {ago} ago",
ctx.fmt.date_short(moment.date()),
ctx.fmt.time(moment.time())
);
if let (Purpose::Pause | Purpose::EndDay, ShiftState::Working { since, .. }) =
(self.purpose, ctx.journal.state())
{
let session = moment.timestamp().duration_since(since);
text = format!("{text} · session {}", ctx.fmt.duration(session));
}
Span::styled(text, ctx.theme.accent())
}
Err(error) => Span::styled(error, Style::new().fg(ctx.theme.error)),
}
}
}
impl Component for TimePrompt {
fn keys(&self) -> &'static [&'static [Binding]] {
&[keymap::POPUP]
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
let inner = window(frame, area, 64, 7, self.purpose.title(), "Enter ok · Esc cancel", ctx.theme);
let lines = vec![
Line::default(),
field_line("Time", &self.input, true, self.inserting, self.preview(ctx), ctx.theme),
Line::default(),
message_line(
self.error.as_deref(),
"Times like 22:30, 2230, 22h30 and 10:30pm all work.",
ctx.theme,
),
];
frame.render_widget(Paragraph::new(lines), inner);
}
fn command(&mut self, command: Command, _count: Option<usize>, ctx: &Context) -> Option<Action> {
match command {
Command::Confirm => self.submit(ctx),
Command::Close => Some(Action::ClosePopup),
Command::Insert => {
self.input.cursor_to_end();
self.inserting = true;
None
}
Command::InsertAtStart => {
self.input.cursor_to_start();
self.inserting = true;
None
}
Command::Retype => {
self.input.clear();
self.inserting = true;
None
}
_ => None,
}
}
fn inserting(&self) -> bool {
self.inserting
}
fn insert_key(&mut self, key: KeyEvent, ctx: &Context) -> Option<Action> {
self.error = None;
match self.input.insert_key(key) {
InputEvent::Submit => self.submit(ctx),
InputEvent::Exit => {
self.inserting = false;
None
}
_ => None,
}
}
}