Some jobs pay for breaks, so a pomodoro break shouldn't take time off the workday. By default (new setting `paid_breaks`, in [reminders]), b on the Tracker, zb anywhere or :break starts a pomodoro break WITHOUT pausing the shift: the break stays in your worked time and the data file isn't touched. b (or s) brings you back to focus. With the setting off, b pauses the shift like p. p and e always stop the clock. For the rhythm, a paid break still counts as a break: reminders::PaidBreaks keeps them in memory and pomodoro() cuts them out of the focus stretches, the same way as a pause. Stopping the shift ends a paid break, and closing the work day forgets them. The Tracker shows "on a paid break" and the paid break's progress, and the nudges name the right key (b or p/s). Co-Authored-By: Claude Opus 5.5 <[email protected]>
240 lines
9.2 KiB
Rust
240 lines
9.2 KiB
Rust
//! 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"),
|
|
(":break", "take a pomodoro break / back from it"),
|
|
(":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),
|
|
"break" => Ok(Action::TakeBreak),
|
|
"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 })
|
|
}
|