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

+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
}
}