Add the app core and the real entry point

app.rs holds everything and runs the loop: keys become commands, the
screen turns them into actions, and App::apply is the only place data
changes. Journal edits happen on a copy, are undoable, saved atomically
and rolled back if saving fails. It also runs the reminders and picks
up outside edits of shifts.toml every second.

main.rs loads the settings, takes the lock, makes the daily backup and
starts the UI; --config, --paths, --help and --version.

The tests drive the whole app with fake key presses and draw every
screen and popup at several sizes in an in-memory terminal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-09-24 02:59:23 -04:00
1 parent 57ea9403b6
commit 89c5279f52
2 files changed
+1025 -4

No files matched your search

+904
View File
@@ -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<Box<dyn Component>>,
tab: Tab,
keys: Keys,
message: Option<Message>,
/// 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<Journal>,
redo: Vec<Journal>,
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<Timestamp>,
/// The focus stretch (by its start) a break was already suggested for.
break_suggested: Option<Timestamp>,
/// The break (by its start) already said to be over.
break_over: Option<Timestamp>,
/// The session (by its start) already asked "still working?" about.
still_working_asked: Option<Timestamp>,
}
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<String>,
) -> 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<String> {
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<usize>, 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<Action>) {
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<String>) {
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<T>(&mut self, edit: impl FnOnce(&mut Journal) -> Result<T, DomainError>) -> Result<T, String> {
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<String, String> {
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<CR>".
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<TestBackend>) -> String {
let buffer = terminal.backend().buffer();
let row = |y| (0..buffer.area.width).map(|x| buffer[(x, y)].symbol()).collect::<String>();
(0..buffer.area.height).map(row).collect::<Vec<_>>().join("\n")
}
/// Everything to look at: each screen, view, popup and hint.
const TOUR: [&str; 16] = [
"",
"<Space>",
"<Esc>gd",
"l",
"i",
"<Esc><Esc>",
"gi",
"l",
"l",
"l",
"gs",
"za",
"?",
"q",
"<Space>x",
"<Esc>: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<CR>");
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));
}
}
}
+121 -4
View File
@@ -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 <file>]
Options:
--config <file> 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<PathBuf>,
paths: bool,
help: bool,
version: bool,
}
impl Options {
fn read(mut args: impl Iterator<Item = String>) -> anyhow::Result<Options> {
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)
}
}