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
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user