diff --git a/src/ui/mod.rs b/src/ui/mod.rs index fa574ef..332f3dd 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -13,6 +13,7 @@ pub mod component; pub mod keymap; +pub mod popups; pub mod terminal; pub mod theme; pub mod widgets; diff --git a/src/ui/popups/command_line.rs b/src/ui/popups/command_line.rs new file mode 100644 index 0000000..9512be1 --- /dev/null +++ b/src/ui/popups/command_line.rs @@ -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, +} + +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, _ctx: &Context) -> Option { + (command == Command::Close).then_some(Action::ClosePopup) + } + + fn inserting(&self) -> bool { + true + } + + fn insert_key(&mut self, key: KeyEvent, ctx: &Context) -> Option { + 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 { + 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, 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 { + 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 { + 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 { + 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::().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 { + 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 }) +} diff --git a/src/ui/popups/copy_menu.rs b/src/ui/popups/copy_menu.rs new file mode 100644 index 0000000..4337d2d --- /dev/null +++ b/src/ui/popups/copy_menu.rs @@ -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 { + 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, ctx: &Context) -> Option { + 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 + } +} diff --git a/src/ui/popups/export_dialog.rs b/src/ui/popups/export_dialog.rs new file mode 100644 index 0000000..f53fc90 --- /dev/null +++ b/src/ui/popups/export_dialog.rs @@ -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, ctx: &Context) -> Option { + 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 + } +} diff --git a/src/ui/popups/help.rs b/src/ui/popups/help.rs new file mode 100644 index 0000000..af50608 --- /dev/null +++ b/src/ui/popups/help.rs @@ -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)] = &[ + ("", "confirm"), + ("", "back to normal mode (Esc again closes)"), + (" ", "next / previous field"), + ("", "delete the word before the cursor"), + ("", "delete everything before the cursor"), + (" ", "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> { + 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 ". +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, _ctx: &Context) -> Option { + 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 + } +} diff --git a/src/ui/popups/mod.rs b/src/ui/popups/mod.rs new file mode 100644 index 0000000..111d674 --- /dev/null +++ b/src/ui/popups/mod.rs @@ -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()), + } +} diff --git a/src/ui/popups/segment_editor.rs b/src/ui/popups/segment_editor.rs new file mode 100644 index 0000000..ded48d3 --- /dev/null +++ b/src/ui/popups/segment_editor.rs @@ -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, +} + +/// The typed fields, turned into exact moments. +struct Resolved { + start: Zoned, + end: Option, + 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 { + 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 { + 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 { + 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, ctx: &Context) -> Option { + 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 { + 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 + } +} diff --git a/src/ui/popups/still_working.rs b/src/ui/popups/still_working.rs new file mode 100644 index 0000000..6a3b16f --- /dev/null +++ b/src/ui/popups/still_working.rs @@ -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, +} + +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 { + 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 { + 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, ctx: &Context) -> Option { + 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 { + self.error = None; + match self.input.insert_key(key) { + InputEvent::Submit => self.submit(ctx), + InputEvent::Exit => { + self.inserting = false; + None + } + _ => None, + } + } +} diff --git a/src/ui/popups/time_prompt.rs b/src/ui/popups/time_prompt.rs new file mode 100644 index 0000000..088e832 --- /dev/null +++ b/src/ui/popups/time_prompt.rs @@ -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, +} + +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 { + 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 { + 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, ctx: &Context) -> Option { + 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 { + self.error = None; + match self.input.insert_key(key) { + InputEvent::Submit => self.submit(ctx), + InputEvent::Exit => { + self.inserting = false; + None + } + _ => None, + } + } +}