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