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:
1 parent
7c8a02275e
commit
255dcd1392
9 files changed
+1231
No files matched your search
@@ -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;
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user