//! 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, } 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, _ctx: &Context) -> Option { (command == Command::Close).then_some(Action::ClosePopup) } fn inserting(&self) -> bool { true } fn insert_key(&mut self, key: KeyEvent, ctx: &Context) -> Option { 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 { 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, 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 { 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 { 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 { 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::().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 { 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 }) }