diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..6b55ca6 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,904 @@ +//! The core of skyshift: it holds everything, runs the loop, and applies +//! actions. +//! +//! The loop: +//! 1. draw the window, +//! 2. wait for a key, a click, or the next second on the clock, +//! 3. keys become commands (`ui::keymap`), commands become actions (the +//! screens and popups decide what a command means for them), +//! 4. `App::apply` applies the action, saves, and says what happened. +//! +//! `App::apply` is the only place where your data changes. + +use std::mem; +use std::path::PathBuf; +use std::time::Duration; + +use jiff::tz::TimeZone; +use jiff::{SignedDuration, Timestamp, Zoned}; +use ratatui::crossterm::event::{ + self, Event, KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind, +}; +use ratatui::layout::{Position, Rect}; +use ratatui::{DefaultTerminal, Frame}; + +use crate::action::Action; +use crate::domain::{DomainError, Journal, ShiftState, StartOutcome, WorkDay}; +use crate::export::clipboard::Clipboard; +use crate::export::{self, Report, timesheet}; +use crate::reminders::{self, Phase}; +use crate::settings::{FIELDS, Settings}; +use crate::storage::journal_file::{JournalStore, OutsideChange}; +use crate::storage::paths::Paths; +use crate::storage::settings_file; +use crate::time::clock::parse_zone; +use crate::time::{Clock, Formatter}; +use crate::ui::keymap::{self, Binding, Command, Key, Keys, Outcome}; +use crate::ui::popups::command_line::CommandLine; +use crate::ui::popups::export_dialog::ExportDialog; +use crate::ui::popups::help::Help; +use crate::ui::popups::still_working::StillWorking; +use crate::ui::popups::time_prompt::{Purpose, TimePrompt}; +use crate::ui::screens::days::Days; +use crate::ui::screens::insights::Insights; +use crate::ui::screens::settings::SettingsScreen; +use crate::ui::screens::tracker::Tracker; +use crate::ui::theme::Theme; +use crate::ui::{self, Component, Context, Message, Tab, Tone, popup, terminal}; + +/// How many changes `u` can undo. +const UNDO_LIMIT: usize = 100; +/// How long a message stays in the status line. +const MESSAGE_TIME: Duration = Duration::from_secs(6); + +pub struct App { + model: Model, + screens: Screens, + /// Open popups; the last one is on top and gets the keys. + popups: Vec>, + tab: Tab, + keys: Keys, + message: Option, + /// Where each tab was drawn, for mouse clicks. + tab_areas: Vec<(Tab, Rect)>, + quit: bool, +} + +/// Your data, and what works on it. +struct Model { + settings: Settings, + journal: Journal, + undo: Vec, + redo: Vec, + clock: Clock, + theme: Theme, + clipboard: Clipboard, + store: JournalStore, + paths: Paths, + export_folder: PathBuf, + nudges: Nudges, +} + +/// What the reminders already said, so each is said only once. +#[derive(Debug, Default)] +struct Nudges { + snoozed_until: Option, + /// The focus stretch (by its start) a break was already suggested for. + break_suggested: Option, + /// The break (by its start) already said to be over. + break_over: Option, + /// The session (by its start) already asked "still working?" about. + still_working_asked: Option, +} + +struct Screens { + tracker: Tracker, + days: Days, + insights: Insights, + settings: SettingsScreen, +} + +impl Screens { + fn get(&self, tab: Tab) -> &dyn Component { + match tab { + Tab::Tracker => &self.tracker, + Tab::Days => &self.days, + Tab::Insights => &self.insights, + Tab::Settings => &self.settings, + } + } + + fn get_mut(&mut self, tab: Tab) -> &mut dyn Component { + match tab { + Tab::Tracker => &mut self.tracker, + Tab::Days => &mut self.days, + Tab::Insights => &mut self.insights, + Tab::Settings => &mut self.settings, + } + } +} + +impl App { + pub fn new( + paths: Paths, + settings: Settings, + clock: Clock, + store: JournalStore, + journal: Journal, + warnings: Vec, + ) -> App { + let model = Model { + theme: Theme::new(settings.accent_color), + clipboard: Clipboard::new(settings.clipboard, settings.clipboard_command.clone()), + export_folder: paths.export_folder(&settings), + settings, + journal, + undo: Vec::new(), + redo: Vec::new(), + clock, + store, + paths, + nudges: Nudges::default(), + }; + let mut app = App { + model, + screens: Screens { + tracker: Tracker::new(), + days: Days::new(), + insights: Insights::new(), + settings: SettingsScreen::new(), + }, + popups: Vec::new(), + tab: Tab::Tracker, + keys: Keys::default(), + message: None, + tab_areas: Vec::new(), + quit: false, + }; + match warnings.as_slice() { + [] if app.model.journal.days().next().is_none() => { + app.say(Tone::Info, "Welcome! Press s to start your first shift. ? lists every key."); + } + [] => {} + [only] => app.say(Tone::Problem, only.clone()), + [first, rest @ ..] => app.say(Tone::Problem, format!("{first} (and {} more)", rest.len())), + } + app + } + + pub fn mouse_enabled(&self) -> bool { + self.model.settings.mouse + } + + /// Runs until you quit. + pub fn run(&mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> { + while !self.quit { + self.tick(); + terminal.draw(|frame| self.draw(frame))?; + if event::poll(self.time_to_next_frame())? { + // Handle everything waiting before drawing again. + loop { + self.handle(event::read()?); + if self.quit || !event::poll(Duration::ZERO)? { + break; + } + } + } + } + Ok(()) + } + + /// A line to print after quitting, when a shift is still running. + pub fn farewell(&self) -> Option { + let ShiftState::Working { since, .. } = self.model.journal.state() else { return None }; + let fmt = self.model.settings.formatter(); + let now = self.model.clock.now(); + Some(format!( + "skyshift: you're still working (since {}, {} so far). The timer keeps running; open skyshift again to pause.", + fmt.time(self.model.clock.local(since).time()), + fmt.duration(now.timestamp().duration_since(since)), + )) + } + + // ----- Drawing ------------------------------------------------------ + + fn draw(&mut self, frame: &mut Frame) { + let now = self.model.clock.now(); + let days = self.model.journal.snapshot(now.timestamp()); + let ctx = self.model.context(&now, &days); + let window = frame.area(); + let [header, body, footer] = ui::layout(window); + + self.tab_areas = ui::draw_header(frame, header, self.tab, &ctx); + self.screens.get_mut(self.tab).render(frame, body, &ctx); + ui::draw_footer(frame, footer, &ctx, self.message.as_ref(), &self.keys.typed()); + for popup in &mut self.popups { + popup.render(frame, window, &ctx); + } + if self.which_key_due() { + let tables = self.current_tables(); + let typed = self.keys.typed(); + let title = + self.keys.group_name().map_or_else(|| typed.clone(), |name| format!("{typed} · {name}")); + ui::draw_which_key(frame, body, &title, &self.keys.hints(&tables), &ctx); + } + } + + /// Sleeps until the clock shows the next second, so the timer ticks on + /// time, or sooner while a key sequence waits for its which-key hints. + fn time_to_next_frame(&self) -> Duration { + let nanos = self.model.clock.now().subsec_nanosecond().max(0) as u64; + let next_second = Duration::from_nanos(1_000_000_000 - nanos) + Duration::from_millis(5); + if self.keys.is_pending() { next_second.min(Duration::from_millis(50)) } else { next_second } + } + + fn which_key_due(&self) -> bool { + let delay = self.model.settings.which_key_delay.unsigned_abs(); + self.model.settings.which_key + && self.keys.pending_since().is_some_and(|since| since.elapsed() >= delay) + } + + /// The key tables in use: the top popup's, or the screen's plus the global ones. + fn current_tables(&self) -> Vec<&'static [Binding]> { + match self.popups.last() { + Some(popup) => popup.keys().to_vec(), + None => { + let mut tables = self.screens.get(self.tab).keys().to_vec(); + tables.push(keymap::GLOBAL); + tables + } + } + } + + // ----- Events ------------------------------------------------------- + + fn handle(&mut self, event: Event) { + match event { + // Windows also reports key releases; only presses count. + Event::Key(key) if key.kind != KeyEventKind::Release => self.key(key), + Event::Mouse(mouse) => self.mouse(mouse), + _ => {} + } + } + + fn key(&mut self, key: KeyEvent) { + let now = self.model.clock.now(); + let days = self.model.journal.snapshot(now.timestamp()); + + // A popup, when one is open, gets every key. + if let Some(mut popup) = self.popups.pop() { + let action = { + let ctx = self.model.context(&now, &days); + if popup.inserting() { + popup.insert_key(key, &ctx) + } else { + match self.keys.press(Key::from_event(key), popup.keys()) { + Outcome::Run { command, count } => popup.command(command, count, &ctx), + _ => None, + } + } + }; + match action { + None => self.popups.push(popup), + // The popup closes when its action works; otherwise it stays + // open so you can fix what's wrong. + Some(action) => { + if let Err(error) = self.apply(action) { + self.say(Tone::Problem, error); + self.popups.push(popup); + } + } + } + return; + } + + // A screen typing a value (Settings) gets the keys directly. + if self.screens.get(self.tab).inserting() { + let action = { + let ctx = self.model.context(&now, &days); + self.screens.get_mut(self.tab).insert_key(key, &ctx) + }; + self.run_action(action); + return; + } + + let tables = self.current_tables(); + match self.keys.press(Key::from_event(key), &tables) { + Outcome::Run { command, count } => self.command(command, count, &now, &days), + Outcome::Unknown(keys) => { + self.say(Tone::Info, format!("{keys} does nothing here (? lists the keys)")) + } + Outcome::Waiting | Outcome::Cancelled => {} + } + } + + fn mouse(&mut self, event: MouseEvent) { + let position = Position::new(event.column, event.row); + if event.kind == MouseEventKind::Down(MouseButton::Left) + && self.popups.is_empty() + && let Some((tab, _)) = self.tab_areas.iter().find(|(_, area)| area.contains(position)) + { + self.tab = *tab; + return; + } + let now = self.model.clock.now(); + let days = self.model.journal.snapshot(now.timestamp()); + let action = { + let ctx = self.model.context(&now, &days); + match self.popups.last_mut() { + Some(popup) => popup.mouse(event, &ctx), + None => self.screens.get_mut(self.tab).mouse(event, &ctx), + } + }; + self.run_action(action); + } + + /// Commands every screen shares are handled here; the rest go to the screen. + fn command(&mut self, command: Command, count: Option, now: &Zoned, days: &[WorkDay]) { + let step = self.model.settings.adjust_step * count.unwrap_or(1).min(1000) as i32; + let action = match command { + Command::Quit => Some(Action::Quit), + // Like vim's `2gt`: a count picks the tab by number. + Command::NextScreen => Some(Action::GoTo(match count { + Some(number) => Tab::ALL[number.clamp(1, Tab::ALL.len()) - 1], + None => self.tab.next(), + })), + Command::PreviousScreen => Some(Action::GoTo(self.tab.previous())), + Command::GoTo(tab) => Some(Action::GoTo(tab)), + Command::Start => Some(Action::Start), + Command::Pause => Some(Action::Pause), + Command::Toggle if self.model.journal.state().is_working() => Some(Action::Pause), + Command::Toggle => Some(Action::Start), + Command::EndDay => Some(Action::EndDay), + Command::StartEarlier => Some(Action::ShiftRunningStart(-step)), + Command::StartLater => Some(Action::ShiftRunningStart(step)), + Command::StartAt => Some(popup(TimePrompt::new(Purpose::Start))), + Command::PauseAt => Some(popup(TimePrompt::new(Purpose::Pause))), + Command::EndDayAt => Some(popup(TimePrompt::new(Purpose::EndDay))), + Command::Snooze => Some(Action::Snooze), + Command::Undo => Some(Action::Undo), + Command::Redo => Some(Action::Redo), + Command::Help => Some(Action::ShowHelp), + Command::OpenCommandLine => Some(popup(CommandLine::new())), + Command::ToggleWhichKey => Some(Action::ToggleWhichKey), + Command::Close => { + self.message = None; + None + } + Command::Export => Some(popup(ExportDialog::new(&self.model.context(now, days)))), + other => { + let ctx = self.model.context(now, days); + self.screens.get_mut(self.tab).command(other, count, &ctx) + } + }; + self.run_action(action); + } + + fn run_action(&mut self, action: Option) { + if let Some(action) = action + && let Err(error) = self.apply(action) + { + self.say(Tone::Problem, error); + } + } + + // ----- Applying actions: the only place where data changes ---------- + + fn apply(&mut self, action: Action) -> Result<(), String> { + let fmt = self.model.settings.formatter(); + match action { + Action::Quit => self.quit = true, + Action::GoTo(tab) => self.tab = tab, + Action::OpenPopup(popup) => self.popups.push(popup), + Action::ClosePopup => {} + Action::ShowHelp => { + let help = Help::new(self.tab.title(), self.screens.get(self.tab).keys()); + self.popups.push(Box::new(help)); + } + Action::Notify(text) => self.say(Tone::Info, text), + + Action::Start => { + let now = self.model.clock.punch(); + let new_day_after = self.model.settings.new_day_after_break; + let outcome = self.model.change(|journal| journal.start(&now, new_day_after))?; + self.say(Tone::Good, start_message(outcome, &fmt)); + } + Action::StartAt(since) => { + let now = self.model.clock.punch(); + let new_day_after = self.model.settings.new_day_after_break; + self.model.change(|journal| { + let outcome = journal.start(&now, new_day_after)?; + journal.set_running_start(since, now.timestamp())?; + Ok(outcome) + })?; + self.say(Tone::Good, format!("Working since {}", self.model.time(since))); + } + Action::Pause => { + let now = self.model.clock.punch().timestamp(); + let segment = self.model.change(|journal| journal.pause(now))?; + let text = match segment { + Some(segment) => { + format!("Paused after {}. Enjoy the break.", fmt.duration(segment.duration())) + } + None => "Paused (under a minute, so nothing was recorded)".to_string(), + }; + self.say(Tone::Good, text); + } + Action::PauseAt(at) => { + self.model.change(|journal| journal.pause(at))?; + self.say(Tone::Good, format!("Paused at {}", self.model.time(at))); + } + Action::EndDay => { + let now = self.model.clock.punch().timestamp(); + let day = self.model.change(|journal| journal.end_day(now))?; + self.say(Tone::Good, self.model.day_summary(day, "Work day ended")); + } + Action::EndDayAt(at) => { + let day = self.model.change(|journal| journal.end_day(at))?; + self.say( + Tone::Good, + self.model.day_summary(day, &format!("Work day ended at {}", self.model.time(at))), + ); + } + Action::ShiftRunningStart(delta) => { + let ShiftState::Working { since, .. } = self.model.journal.state() else { + return Err("You're not working right now".into()); + }; + let now = self.model.clock.now().timestamp(); + self.model.change(|journal| journal.set_running_start(since + delta, now))?; + self.say( + Tone::Good, + format!("This session now started at {}", self.model.time(since + delta)), + ); + } + Action::FixRunning { since, stopped } => { + let now = self.model.clock.now().timestamp(); + self.model.change(|journal| { + journal.set_running_start(since, now)?; + if let Some(stopped) = stopped { + journal.pause(stopped)?; + } + Ok(()) + })?; + let text = match stopped { + Some(stopped) => { + format!("Recorded {}-{}", self.model.time(since), self.model.time(stopped)) + } + None => format!("Working since {}", self.model.time(since)), + }; + self.say(Tone::Good, text); + } + Action::Snooze => { + let until = self.model.clock.now().timestamp() + self.model.settings.snooze; + self.model.nudges.snoozed_until = Some(until); + self.say(Tone::Info, format!("Break suggestion snoozed until {}", self.model.time(until))); + } + + Action::AddSegment { day, segment } => { + let now = self.model.clock.now().timestamp(); + self.model.change(|journal| journal.add_segment(day, segment, now))?; + let range = + format!("{}-{}", self.model.time(segment.start()), self.model.time(segment.end())); + self.say(Tone::Good, format!("Added {range} to {}", fmt.date_short(day))); + } + Action::ReplaceSegment { day, index, segment, new_day } => { + let now = self.model.clock.now().timestamp(); + self.model.change(|journal| journal.replace_segment(day, index, segment, new_day, now))?; + self.say(Tone::Good, "Segment updated (u to undo)".to_string()); + } + Action::DeleteSegment { day, index } => { + self.model.change(|journal| journal.remove_segment(day, index).map(|_| ()))?; + self.say(Tone::Good, "Segment deleted (u to undo)".to_string()); + } + Action::Undo => { + let previous = self.model.undo.pop().ok_or("Nothing to undo")?; + let current = mem::replace(&mut self.model.journal, previous); + self.model.redo.push(current); + self.model.save()?; + self.say(Tone::Info, "Undone (Ctrl-r to redo)".to_string()); + } + Action::Redo => { + let next = self.model.redo.pop().ok_or("Nothing to redo")?; + let current = mem::replace(&mut self.model.journal, next); + self.model.undo.push(current); + self.model.save()?; + self.say(Tone::Info, "Redone".to_string()); + } + + Action::Copy { days, preset } => { + let now = self.model.clock.now(); + let snapshot = self.model.journal.snapshot(now.timestamp()); + let chosen: Vec<&WorkDay> = + snapshot.iter().filter(|day| days.contains(&day.date()) && !day.is_empty()).collect(); + if chosen.is_empty() { + return Err("Nothing recorded there to copy".into()); + } + let options = self.model.context(&now, &snapshot).copy_options(); + let text = timesheet::days_text(&chosen, preset, &options); + let place = self.model.clipboard.copy(&text)?; + let shown = match text.lines().count() { + 1 => format!("\"{}\"", text.replace('\t', " → ")), + lines => format!("{lines} lines"), + }; + self.say(Tone::Good, format!("Copied {shown} to the {place}")); + } + Action::Export { format, range } => { + let now = self.model.clock.now(); + let snapshot = self.model.journal.snapshot(now.timestamp()); + let settings = &self.model.settings; + let report = Report::new( + &snapshot, + range, + fmt, + self.model.clock.zone(), + settings.week_start, + now.clone(), + ); + if report.days.is_empty() { + return Err(format!( + "Nothing recorded from {} to {}", + fmt.date_short(range.first()), + fmt.date_short(range.last()) + )); + } + let contents = export::render(format, &report); + let path = export::save(&self.model.export_folder, format, range, &contents) + .map_err(|error| format!("Couldn't save the export: {error}"))?; + self.say(Tone::Good, format!("Exported {} days to {}", report.days.len(), path.display())); + } + + Action::SetSetting { key, value } => { + let text = self.model.set_setting(key, &value)?; + self.say(Tone::Good, text); + } + Action::ResetSetting { key } => { + let field = FIELDS.iter().find(|field| field.key == key).ok_or("No such setting")?; + let default = (field.get)(&Settings::default()); + let text = self.model.set_setting(key, &default)?; + self.say(Tone::Good, format!("{text} (the default)")); + } + Action::ToggleWhichKey => { + let on = !self.model.settings.which_key; + self.model.set_setting("which_key", &on.to_string())?; + self.say(Tone::Info, format!("Key hints {}", if on { "on" } else { "off" })); + } + } + Ok(()) + } + + fn say(&mut self, tone: Tone, text: impl Into) { + self.message = Some(Message { text: text.into(), tone, shown_at: std::time::Instant::now() }); + } + + // ----- Every second ------------------------------------------------- + + fn tick(&mut self) { + if self.message.as_ref().is_some_and(|message| message.shown_at.elapsed() > MESSAGE_TIME) { + self.message = None; + } + self.check_outside_changes(); + self.remind(); + } + + /// Picks up edits made to shifts.toml outside skyshift. + fn check_outside_changes(&mut self) { + match self.model.store.check_outside_changes(self.model.clock.zone()) { + Some(OutsideChange::Reloaded(journal)) => { + let before = mem::replace(&mut self.model.journal, journal); + self.model.undo.push(before); + self.say(Tone::Info, "shifts.toml was changed outside skyshift: reloaded (u to undo)"); + } + Some(OutsideChange::Broken(error)) => { + self.say( + Tone::Problem, + format!("shifts.toml has a mistake ({error}); nothing is saved until it's fixed"), + ); + } + None => {} + } + } + + /// Pomodoro suggestions and the "still working?" check. + fn remind(&mut self) { + let now = self.model.clock.now().timestamp(); + let fmt = self.model.settings.formatter(); + if self.model.nudges.snoozed_until.is_some_and(|until| until <= now) { + self.model.nudges.snoozed_until = None; + self.model.nudges.break_suggested = None; + } + + let pomodoro = reminders::pomodoro(&self.model.journal, now, &self.model.settings.pomodoro()); + let nudge = match pomodoro.phase { + Phase::Focus { started, focused, .. } + if pomodoro.break_due() + && self.model.nudges.snoozed_until.is_none() + && self.model.nudges.break_suggested != Some(started) => + { + self.model.nudges.break_suggested = Some(started); + Some(format!("Break time: {} of focus. p pauses, zz snoozes.", fmt.duration(focused))) + } + Phase::Break { started, goal, .. } + if pomodoro.break_over() && self.model.nudges.break_over != Some(started) => + { + self.model.nudges.break_over = Some(started); + Some(format!("Your {} break is over. s when you're ready.", fmt.duration(goal))) + } + _ => None, + }; + if let Some(text) = nudge { + self.say(Tone::Nudge, text); + if self.model.settings.reminder_bell { + terminal::bell(); + } + } + + let check = self.model.settings.still_working_check; + if let ShiftState::Working { since, .. } = self.model.journal.state() + && check > SignedDuration::ZERO + && now.duration_since(since) >= check + && self.model.nudges.still_working_asked != Some(since) + && self.popups.is_empty() + { + self.model.nudges.still_working_asked = Some(since); + self.popups.push(Box::new(StillWorking::new(since))); + if self.model.settings.reminder_bell { + terminal::bell(); + } + } + } +} + +impl Model { + /// What screens and popups get to look at. + fn context<'a>(&'a self, now: &'a Zoned, days: &'a [WorkDay]) -> Context<'a> { + Context { + journal: &self.journal, + days, + settings: &self.settings, + clock: &self.clock, + now, + fmt: self.settings.formatter(), + theme: &self.theme, + pomodoro: reminders::pomodoro(&self.journal, now.timestamp(), &self.settings.pomodoro()), + snoozed_until: self.nudges.snoozed_until, + export_folder: &self.export_folder, + } + } + + /// Changes the journal with `edit`, keeping the previous version for + /// undo, then saves. `edit` works on a copy: if it fails, or saving + /// fails, nothing changes at all. + fn change(&mut self, edit: impl FnOnce(&mut Journal) -> Result) -> Result { + let mut journal = self.journal.clone(); + let result = edit(&mut journal).map_err(|error| sentence(&error.to_string()))?; + let before = mem::replace(&mut self.journal, journal); + if let Err(error) = self.save() { + self.journal = before; + return Err(error); + } + self.undo.push(before); + if self.undo.len() > UNDO_LIMIT { + self.undo.remove(0); + } + self.redo.clear(); + Ok(result) + } + + fn save(&mut self) -> Result<(), String> { + self.store.save(&self.journal, self.clock.zone()) + } + + /// Changes one setting (by its key in config.toml), saves config.toml, + /// and updates what depends on it. Returns a message for the status line. + fn set_setting(&mut self, key: &str, value: &str) -> Result { + let field = FIELDS.iter().find(|field| field.key == key).ok_or(format!("No setting named {key}"))?; + let mut settings = self.settings.clone(); + (field.set)(&mut settings, value).map_err(|error| format!("{}: {error}", field.label))?; + let old = mem::replace(&mut self.settings, settings); + settings_file::save(&self.paths.config_file, &self.settings).map_err(|error| format!("{error:#}"))?; + self.settings_changed(&old); + + let mut text = format!("{}: {}", field.label, (field.get)(&self.settings)); + if field.help.contains("after a restart") { + text.push_str(" (after a restart)"); + } + Ok(text) + } + + /// Rebuilds what depends on settings that just changed. + fn settings_changed(&mut self, old: &Settings) { + let new = &self.settings; + if new.timezone != old.timezone + || new.clock_adjustment != old.clock_adjustment + || new.punch_precision != old.punch_precision + { + let zone = parse_zone(&new.timezone).unwrap_or_else(|_| TimeZone::system()); + self.clock = Clock::new(zone, new.clock_adjustment, new.punch_precision); + } + if new.accent_color != old.accent_color { + self.theme = Theme::new(new.accent_color); + } + if new.clipboard != old.clipboard || new.clipboard_command != old.clipboard_command { + self.clipboard = Clipboard::new(new.clipboard, new.clipboard_command.clone()); + } + if new.mouse != old.mouse { + let _ = terminal::set_mouse(new.mouse); + } + if new.export_folder != old.export_folder { + self.export_folder = self.paths.export_folder(new); + } + } + + fn time(&self, moment: Timestamp) -> String { + self.settings.formatter().time(self.clock.local(moment).time()) + } + + /// "Work day ended: Thu Sep 24, 5h 40m". + fn day_summary(&self, day: jiff::civil::Date, what: &str) -> String { + let fmt = self.settings.formatter(); + match self.journal.day(day) { + Some(work_day) => format!("{what}: {}, {}", fmt.date_short(day), fmt.duration(work_day.worked())), + None => format!("{what} (nothing was recorded)"), + } + } +} + +/// What pressing Start did, as a message. +fn start_message(outcome: StartOutcome, fmt: &Formatter) -> String { + match outcome { + StartOutcome::Opened(day) => format!("Started: new work day {}", fmt.date_short(day)), + StartOutcome::Reopened(day) => format!("Started: back on the work day {}", fmt.date_short(day)), + StartOutcome::Resumed(_) => "Back to work".to_string(), + StartOutcome::NewDayAfterLongBreak { closed, opened } => format!( + "Long break, so {} was closed and {} opened (u to undo)", + fmt.date_short(closed), + fmt.date_short(opened) + ), + } +} + +/// Capitalizes the first letter: "you're not working" → "You're not working". +fn sentence(text: &str) -> String { + let mut chars = text.chars(); + match chars.next() { + Some(first) => first.to_uppercase().chain(chars).collect(), + None => String::new(), + } +} + +/// These drive the whole app with fake key presses and draw it into an +/// in-memory terminal, to catch drawing code that breaks at some size. +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::Segment; + use crate::time::PunchPrecision; + use crate::time::resolve::on_date; + use jiff::ToSpan; + use jiff::civil::Time; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::crossterm::event::{KeyCode, KeyModifiers}; + + /// An app with six weeks of made-up work (some night shifts) and a + /// session in progress, storing its files in a temporary folder. + fn sample_app(name: &str) -> App { + let folder = std::env::temp_dir().join(format!("skyshift-test-{name}")); + let _ = std::fs::remove_dir_all(&folder); + let zone = TimeZone::get("America/Toronto").unwrap(); + let clock = Clock::new(zone.clone(), SignedDuration::ZERO, PunchPrecision::Minute); + let today = clock.now().date(); + let at = |days_ago: i64, hour: i8, minute: i8| { + on_date(today.saturating_sub(days_ago.days()), Time::new(hour, minute, 0, 0).unwrap(), &zone) + .timestamp() + }; + let segment = |start, end| Segment::new(start, end).unwrap(); + let days: Vec<_> = (2..42i64) + .rev() + .filter(|days_ago| days_ago % 7 < 5) + .map(|days_ago| { + let segments = if days_ago % 3 == 0 { + vec![ + segment(at(days_ago, 22, 0), at(days_ago - 1, 1, 30)), + segment(at(days_ago - 1, 2, 0), at(days_ago - 1, 4, 10)), + ] + } else { + vec![ + segment(at(days_ago, 9, 2), at(days_ago, 12, 0)), + segment(at(days_ago, 12, 45), at(days_ago, 17, 31)), + ] + }; + (today.saturating_sub(days_ago.days()), segments) + }) + .collect(); + let since = clock.now().timestamp() - SignedDuration::from_mins(72); + let journal = Journal::from_parts(days, ShiftState::Working { day: today, since }).unwrap(); + + let paths = Paths { config_file: folder.join("config.toml"), data_dir: folder.clone() }; + let settings = Settings { + which_key_delay: SignedDuration::ZERO, + clipboard: crate::export::clipboard::ClipboardMode::Command, + ..Settings::default() + }; + let (store, _) = JournalStore::open(folder.join("shifts.toml"), &zone).unwrap(); + App::new(paths, settings, clock, store, journal, Vec::new()) + } + + /// Types keys in vim notation, like "gd" or "i09:00". + fn press(app: &mut App, keys: &str) { + let mut rest = keys; + while let Some(c) = rest.chars().next() { + let (code, length) = match rest.find('>').filter(|_| c == '<').map(|end| &rest[1..end]) { + Some("CR") => (KeyCode::Enter, 4), + Some("Esc") => (KeyCode::Esc, 5), + Some("Space") => (KeyCode::Char(' '), 7), + _ => (KeyCode::Char(c), c.len_utf8()), + }; + app.handle(Event::Key(KeyEvent::new(code, KeyModifiers::NONE))); + rest = &rest[length..]; + } + } + + fn screen(terminal: &Terminal) -> String { + let buffer = terminal.backend().buffer(); + let row = |y| (0..buffer.area.width).map(|x| buffer[(x, y)].symbol()).collect::(); + (0..buffer.area.height).map(row).collect::>().join("\n") + } + + /// Everything to look at: each screen, view, popup and hint. + const TOUR: [&str; 16] = [ + "", + "", + "gd", + "l", + "i", + "", + "gi", + "l", + "l", + "l", + "gs", + "za", + "?", + "q", + "x", + ":set bogus", + ]; + + #[test] + fn every_screen_and_popup_draws_at_any_size() { + for (width, height) in [(140, 45), (100, 30), (80, 24), (50, 14), (20, 5)] { + let mut app = sample_app(&format!("{width}x{height}")); + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + for keys in TOUR { + press(&mut app, keys); + terminal.draw(|frame| app.draw(frame)).unwrap(); + } + } + } + + #[test] + fn editing_through_the_keys() { + let mut app = sample_app("editing"); + press(&mut app, "p"); + assert_eq!(app.model.journal.state().open_day(), Some(app.model.clock.now().date())); + assert!(!app.model.journal.state().is_working()); + press(&mut app, "u"); + assert!(app.model.journal.state().is_working(), "undo resumes the session"); + press(&mut app, ":set time_format=12h"); + assert_eq!(app.model.settings.time_format, crate::time::TimeFormat::Hour12); + } + + /// Prints each stop of the tour: `cargo test show_screens -- --ignored --nocapture` + #[test] + #[ignore] + fn show_screens() { + let mut app = sample_app("show"); + let mut terminal = Terminal::new(TestBackend::new(120, 36)).unwrap(); + for keys in TOUR { + press(&mut app, keys); + terminal.draw(|frame| app.draw(frame)).unwrap(); + println!("===== after {keys:?} =====\n{}\n", screen(&terminal)); + } + } +} diff --git a/src/main.rs b/src/main.rs index e17be65..1c8e919 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,23 @@ //! skyshift: track your work shifts from the terminal. //! -//! The real entry point comes last, once the app core exists. Until -//! then this only declares the modules written so far. -#![allow(dead_code)] +//! Where things are (each file starts with a comment explaining it): +//! +//! | Folder / file | What's in it | +//! |-----------------|------------------------------------------------------------| +//! | `domain/` | the rules: segments, work days, the journal (no I/O, no UI) | +//! | `time/` | the clock, 12h/24h text, typed times, date ranges | +//! | `analysis/` | the numbers behind the Insights screen | +//! | `export/` | timesheet text, the clipboard, CSV/JSON/Markdown reports | +//! | `storage/` | config.toml, shifts.toml, backups, the lock | +//! | `ui/` | keys (vim-style), screens, popups, widgets | +//! | `settings.rs` | every setting, described once | +//! | `reminders.rs` | the pomodoro rhythm | +//! | `action.rs` | everything the screens can ask for | +//! | `app.rs` | the core: the loop, and the one place where data changes | mod action; mod analysis; +mod app; mod domain; mod export; mod reminders; @@ -14,4 +26,109 @@ mod storage; mod time; mod ui; -fn main() {} +use std::path::PathBuf; +use std::process::ExitCode; + +use anyhow::{Context, bail}; +use jiff::tz::TimeZone; + +use app::App; +use storage::backup::daily_backup; +use storage::journal_file::JournalStore; +use storage::lock::InstanceLock; +use storage::paths::Paths; +use storage::settings_file; +use time::Clock; +use time::clock::parse_zone; + +const USAGE: &str = "\ +skyshift: track your work shifts from the terminal + +Usage: skyshift [--config ] + +Options: + --config use this settings file instead of the usual one + --paths show where skyshift keeps its files, then quit + -h, --help show this help + -V, --version show the version + +Inside skyshift, press ? to see every key."; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("skyshift: {error:#}"); + ExitCode::FAILURE + } + } +} + +fn run() -> anyhow::Result<()> { + let options = Options::read(std::env::args().skip(1))?; + if options.help { + println!("{USAGE}"); + return Ok(()); + } + if options.version { + println!("skyshift {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + + let paths = Paths::locate(options.config)?; + let (settings, mut warnings) = settings_file::load(&paths.config_file)?; + let data_file = paths.data_file(&settings); + if options.paths { + println!("settings {}", paths.config_file.display()); + println!("shifts {}", data_file.display()); + println!("exports {}", paths.export_folder(&settings).display()); + return Ok(()); + } + + let zone = parse_zone(&settings.timezone).unwrap_or_else(|error| { + warnings.push(format!("time zone: {error}")); + TimeZone::system() + }); + let clock = Clock::new(zone, settings.clock_adjustment, settings.punch_precision); + + // Kept until skyshift exits: no second copy can use the same data file. + let _lock = InstanceLock::acquire(&data_file)?; + if let Err(error) = daily_backup(&data_file, clock.now().date(), settings.backups) { + warnings.push(format!("today's backup failed: {error}")); + } + let (store, journal) = JournalStore::open(data_file, clock.zone())?; + + let mut app = App::new(paths, settings, clock, store, journal, warnings); + let mut terminal = ui::terminal::start(app.mouse_enabled()).context("can't set up the terminal")?; + let result = app.run(&mut terminal); + ui::terminal::stop(); + if let Some(note) = app.farewell() { + println!("{note}"); + } + result +} + +/// The command line options. +#[derive(Debug, Default)] +struct Options { + config: Option, + paths: bool, + help: bool, + version: bool, +} + +impl Options { + fn read(mut args: impl Iterator) -> anyhow::Result { + let mut options = Options::default(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--config" => options.config = Some(args.next().context("--config needs a file")?.into()), + "--paths" => options.paths = true, + "-h" | "--help" => options.help = true, + "-V" | "--version" => options.version = true, + other => bail!("unknown option \"{other}\"\n\n{USAGE}"), + } + } + Ok(options) + } +}