Skip to main content

sioul_app/
pauses.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The two pauses as the window has them (docs/pauses.md; the rules are
5//! `sioul_core::pause`): **Free time**, a switch in the bottom row, and
6//! **Pause**, a button apart, which covers the window. Their state is in
7//! `quiet.toml`, which the sharing carries to your other devices: a pause
8//! pressed on one is a pause on all (P8). Each device puts its own system's
9//! do-not-disturb on and off (`dnd`): at the press, at each minute (what came
10//! from another device, what a crash left), when the window comes back.
11
12use crate::backend::qobject::Sioul;
13use crate::backend::{QtThread, Shared, load_config, mode_json, say, show, tr};
14use cxx_qt::Threading;
15use cxx_qt_lib::QString;
16use jiff::Zoned;
17use sioul_core::pause::{self, After, Day};
18use sioul_core::quiet::Overrides;
19use std::pin::Pin;
20use std::sync::{Arc, Mutex};
21
22/// The overrides as they were before the pause pressed here, for "Undo" after
23/// coming back: as if it had not been pressed (an accidental press costs
24/// nothing). With the press's stamp, so that another pause never takes it.
25static BEFORE_PRESS: Mutex<Option<(i64, Overrides)>> = Mutex::new(None);
26
27fn load() -> Overrides {
28    Overrides::load(&Overrides::default_path())
29}
30
31fn save(sioul: &mut Pin<&mut Sioul>, overrides: &Overrides) -> bool {
32    match overrides.save(&Overrides::default_path()) {
33        Ok(()) => true,
34        Err(e) => {
35            sioul.as_mut().set_status(QString::from(&e));
36            false
37        }
38    }
39}
40
41/// Today as the evening's rules read it: hours, events, meals, the night.
42fn today(now: &Zoned) -> Day {
43    let midnight = now.date().to_zoned(now.time_zone().clone()).map_or(0, |z| z.timestamp().as_second());
44    Day::of(&load_config(), crate::hours::blocks(now), &sioul_core::agenda::occurrences(midnight, midnight + 26 * 3600))
45}
46
47/// "17:00", on the clock.
48fn hm(stamp: i64, now: &Zoned) -> String {
49    jiff::Timestamp::from_second(stamp).map(|t| t.to_zoned(now.time_zone().clone()).strftime("%H:%M").to_string()).unwrap_or_default()
50}
51
52/// Today's usual end of work, when today has working hours and no time off.
53fn usual_end(config: &sioul_core::config::Config, now: &Zoned) -> Option<i64> {
54    if sioul_core::quiet::time_off_on(&config.time_off, now.date()).is_some() {
55        return None;
56    }
57    sioul_core::window::hours_on(&config.working_hours(), now.date(), now.time_zone()).map(|(_, closing)| closing.timestamp().as_second())
58}
59
60/// Every page shown again for the time it is, the other devices told at once.
61fn shown_again(mut sioul: Pin<&mut Sioul>) {
62    sioul.as_mut().set_mode(QString::from(&mode_json()));
63    let (qt, shared) = (sioul.qt_thread(), sioul.shared());
64    show(&qt, &shared);
65    crate::work::show_work(&qt, &shared);
66    crate::share::exchange(&qt, &shared);
67    std::thread::spawn(apply);
68}
69
70/// The system's do-not-disturb as the pauses are now, on this device: the one
71/// shared do-not-disturb state decides it (`everywhere`, docs/do-not-disturb.md),
72/// the pauses among its reasons, read from `quiet.toml` as here: the pause's
73/// own mode while paused, Free time's while not.
74pub(crate) fn apply() {
75    crate::everywhere::apply();
76}
77
78/// Free time gone past the night's start ended by itself (sleep comes
79/// first): said in the file, so that every device agrees, nothing said.
80fn settle(now: &Zoned) {
81    let path = Overrides::default_path();
82    let mut overrides = Overrides::load(&path);
83    let Some(since) = overrides.free_from() else { return };
84    let blocks = crate::hours::blocks(now);
85    let end = pause::free_until(since, &blocks, now.time_zone());
86    if now.timestamp().as_second() < end {
87        return;
88    }
89    let Ok(at) = jiff::Timestamp::from_second(end).map(|t| t.to_zoned(now.time_zone().clone())) else { return };
90    let day = today(&at);
91    pause::end_free(&mut overrides, &day.evening(), &at, load_config().free_time.moves);
92    if let Err(e) = overrides.save(&path) {
93        eprintln!("{e}");
94    }
95}
96
97/// Each minute, at the start, and when the window comes back: Free time
98/// that ended by itself, the do-not-disturb, and on a phone the "Pause"
99/// pressed on the quick-settings tile or the home screen's shortcut.
100pub(crate) fn tick(qt: &QtThread, shared: &Arc<Shared>) {
101    // Do-not-disturb both ways: the window kept, the system's own followed (once).
102    crate::everywhere::started(qt, shared);
103    let qt = qt.clone();
104    std::thread::spawn(move || {
105        settle(&Zoned::now());
106        apply();
107        if crate::dnd::take_pause_pressed() {
108            let _ = qt.queue(|sioul| press(sioul));
109        }
110    });
111}
112
113/// What the status line and the bottom row need of the pauses (`mode_json`).
114pub(crate) fn moment(config: &sioul_core::config::Config, overrides: &Overrides, now: &Zoned) -> serde_json::Value {
115    let usual = usual_end(config, now);
116    let moved = overrides.moved_end(now.date());
117    let free = overrides.free_from().is_some();
118    // "Keep my usual end": in free time on a working day, or while today's end is moved.
119    let can_keep = config.free_time.moves && usual.is_some() && overrides.keep_end_on != Some(now.date()) && (free || moved.is_some());
120    serde_json::json!({
121        "nothing": pause::nothing_now(overrides, &config.free_time),
122        "usual_end": usual.map(|u| hm(u, now)).unwrap_or_default(),
123        "moved_end": moved.map(|m| hm(m, now)).unwrap_or_default(),
124        "can_keep": can_keep,
125    })
126}
127
128/// Free time on: leisure whatever the hour (GP1, GP5). A focus session
129/// running stops and counts; true when one did, for the window to offer the
130/// line on where you stopped (GP3). Off: back, the end of work moved as said
131/// once (GP10, GP16): the line said, else "".
132pub(crate) fn set_free(mut sioul: Pin<&mut Sioul>, on: bool) -> QString {
133    let now = Zoned::now();
134    let stamp = now.timestamp().as_second();
135    // A free time that ended by itself (the night came) is said ended first.
136    settle(&now);
137    let mut overrides = load();
138    if on {
139        if overrides.paused_from().is_some() || overrides.free_from().is_some() {
140            return QString::from("false");
141        }
142        pause::start_free(&mut overrides, stamp);
143        if !save(&mut sioul, &overrides) {
144            return QString::from("false");
145        }
146        let (qt, shared) = (sioul.qt_thread(), sioul.shared());
147        let stopped = sioul_core::timelog::running().is_some();
148        if stopped {
149            crate::work::focus_stop(&qt, &shared, false, "");
150        }
151        sioul.as_mut().set_status(QString::default());
152        shown_again(sioul);
153        return QString::from(if stopped { "true" } else { "false" });
154    }
155    let config = load_config();
156    let day = today(&now);
157    let moved = pause::end_free(&mut overrides, &day.evening(), &now, config.free_time.moves);
158    if !save(&mut sioul, &overrides) {
159        return QString::default();
160    }
161    // Said once, never as a countdown: when today's work ends now.
162    let line = moved.map(|end| say("free-back-moved", &[("time", hm(end, &now))])).unwrap_or_default();
163    sioul.as_mut().set_status(QString::from(&line));
164    shown_again(sioul);
165    QString::from(&line)
166}
167
168/// "Keep my usual end" (GP11, GP19): no reason asked; what no longer fits
169/// goes to later days.
170pub(crate) fn keep_usual_end(mut sioul: Pin<&mut Sioul>) {
171    let now = Zoned::now();
172    let mut overrides = load();
173    pause::keep_usual_end(&mut overrides, now.date());
174    if !save(&mut sioul, &overrides) {
175        return;
176    }
177    let line = usual_end(&load_config(), &now).map(|end| say("free-kept", &[("time", hm(end, &now))])).unwrap_or_default();
178    sioul.as_mut().set_status(QString::from(&line));
179    shown_again(sioul);
180}
181
182/// "Nothing at all" for this free time (GP6), or back to the safe list.
183pub(crate) fn set_free_nothing(mut sioul: Pin<&mut Sioul>, on: bool) {
184    let mut overrides = load();
185    overrides.free_nothing = Some(on);
186    if save(&mut sioul, &overrides) {
187        shown_again(sioul);
188    }
189}
190
191/// The Pause: it asks nothing (P6). Everything Sioul shows is held, on every
192/// device (P7–P8); free time ends there and today's end of work stays as
193/// usual: a pause never costs an evening (GP19–GP20); a session running
194/// stops and counts. Pressed while paused: the screen, nothing else.
195pub(crate) fn press(mut sioul: Pin<&mut Sioul>) {
196    let now = Zoned::now();
197    let stamp = now.timestamp().as_second();
198    let mut overrides = load();
199    if overrides.paused_from().is_none() {
200        let before = overrides.clone();
201        if overrides.free_from().is_some() {
202            overrides.free_ended = Some(stamp);
203        }
204        pause::keep_usual_end(&mut overrides, now.date());
205        pause::start_pause(&mut overrides, stamp);
206        if !save(&mut sioul, &overrides) {
207            return;
208        }
209        if let Ok(mut kept) = BEFORE_PRESS.lock() {
210            *kept = Some((stamp, before));
211        }
212        let (qt, shared) = (sioul.qt_thread(), sioul.shared());
213        if sioul_core::timelog::running().is_some() {
214            crate::work::focus_stop(&qt, &shared, false, "");
215        }
216    }
217    sioul.as_mut().set_status(QString::default());
218    shown_again(sioul);
219}
220
221/// When the Porch opens again after a pause (P24): at the next admin hours,
222/// else the next working hours, else tomorrow morning.
223fn porch_back(config: &sioul_core::config::Config, now: &Zoned) -> Option<i64> {
224    let admin: Vec<_> = config.week_hours().into_iter().filter(|w| w.kind() == "admin").collect();
225    let hours = if admin.is_empty() { config.week_hours() } else { admin };
226    let morning = i8::try_from(config.agenda.day_start.unwrap_or(7)).unwrap_or(7);
227    let next = sioul_core::quiet::next_work(&hours, &config.time_off, now).unwrap_or_else(|| sioul_core::quiet::after_today(&[], &[], now, morning));
228    Some(next.timestamp().as_second())
229}
230
231/// Coming back (P22–P26): the rest of today lighter, or closed, as set up;
232/// the Porch resting until the next admin hours; "Undo" ten seconds in the
233/// status line, as if the pause had not been pressed. No question, no
234/// count. The screen after it, as JSON: its few lines and the one offer.
235pub(crate) fn come_back(mut sioul: Pin<&mut Sioul>) -> QString {
236    let config = load_config();
237    let now = Zoned::now();
238    let stamp = now.timestamp().as_second();
239    let before = load();
240    let Some(since) = before.paused_from() else { return QString::default() };
241    let mut overrides = before.clone();
242    let after = config.pause.after();
243    let morning = i8::try_from(config.agenda.day_start.unwrap_or(7)).unwrap_or(7);
244    let back = sioul_core::quiet::after_today(&config.working_hours(), &config.time_off, &now, morning);
245    let rest = (after == After::Rest).then(|| back.timestamp().as_second());
246    pause::end_pause(&mut overrides, &now, after, rest, porch_back(&config, &now));
247    if !save(&mut sioul, &overrides) {
248        return QString::default();
249    }
250    // Undo: as before the press when it was pressed here, else as now without this pause.
251    let mut undone = BEFORE_PRESS.lock().ok().and_then(|kept| kept.as_ref().filter(|(at, _)| *at == since).map(|(_, o)| o.clone())).unwrap_or(before);
252    pause::forget(&mut undone);
253    let (qt, shared) = (sioul.qt_thread(), sioul.shared());
254    let title = tr().text("pause-back-title", None);
255    crate::mail::rest(&qt, &shared, undone, overrides.clone(), title.clone());
256    // Words only where they change something: today's work not over yet, a working day tomorrow.
257    let work_left = usual_end(&config, &now).is_some_and(|end| end > stamp);
258    let day = match after {
259        After::Lighter if work_left => tr().text("pause-back-lighter", None),
260        After::Rest if work_left => say("pause-back-rest", &[("back", sioul_core::quiet::until_text(tr(), &back, &now))]),
261        _ => String::new(),
262    };
263    let tomorrow = now.date().tomorrow().ok().and_then(|d| d.to_zoned(now.time_zone().clone()).ok());
264    let working_tomorrow = tomorrow.is_some_and(|t| config.week_hours().is_empty() || usual_end(&config, &t).is_some());
265    shown_again(sioul);
266    QString::from(
267        &serde_json::json!({
268            "title": title,
269            "day": day,
270            "tomorrow": if working_tomorrow { tr().text("pause-back-tomorrow", None) } else { String::new() },
271            "lighten": tr().text("pause-lighten-tomorrow", None),
272            "go": tr().text("pause-back-go", None),
273        })
274        .to_string(),
275    )
276}
277
278/// "Lighten tomorrow", the one offer coming back (default: no, P23).
279pub(crate) fn lighten_tomorrow(mut sioul: Pin<&mut Sioul>) {
280    let now = Zoned::now();
281    let Ok(tomorrow) = now.date().tomorrow() else { return };
282    let mut overrides = load();
283    pause::lighten(&mut overrides, tomorrow);
284    if save(&mut sioul, &overrides) {
285        sioul.as_mut().set_status(QString::from(&tr().text("pause-tomorrow-lighter", None)));
286        shown_again(sioul);
287    }
288}
289
290/// "Forget the last pause" (P26): when it began and ended, gone.
291pub(crate) fn forget(mut sioul: Pin<&mut Sioul>) {
292    let mut overrides = load();
293    if overrides.paused_from().is_some() {
294        return;
295    }
296    pause::forget(&mut overrides);
297    if save(&mut sioul, &overrides) {
298        sioul.as_mut().set_status(QString::from(&tr().text("pause-setup-forgotten", None)));
299    }
300}
301
302/// The pause's screen (P11–P17), as JSON: your list, the breathing guide and
303/// the grounding line as set up, the numbers of your country; whether doses
304/// still come, as the matrix of what reaches you says.
305pub(crate) fn screen() -> String {
306    let config = load_config();
307    let mut screen = pause::screen(&config.pause, config.contacts.region.as_deref(), tr());
308    screen.doses = sioul_core::attention::Attention::of(&config).cell(sioul_core::attention::Row::Own(sioul_core::attention::Kind::Doses), sioul_core::attention::Column::Pause) == sioul_core::attention::Level::Now;
309    serde_json::to_string(&screen).unwrap_or_default()
310}
311
312/// The setup's do-not-disturb part, as JSON: what this device can do, its
313/// buttons and GNOME's consent (`dnd::Report::json`), and the consent as set.
314/// A system round trip: asked when the tab opens, never per frame.
315pub(crate) fn setup() -> String {
316    let mut report = crate::dnd::can().json();
317    report["gnome"] = serde_json::Value::Bool(load_config().pause.gnome);
318    report["forgettable"] = serde_json::Value::Bool({
319        let overrides = load();
320        overrides.paused_since.is_some() && overrides.paused_from().is_none()
321    });
322    report.to_string()
323}
324
325/// After a pause, the Porch rests until the next admin hours unless opened
326/// (P24): only the codes you asked for, and when it opens.
327pub(crate) fn porch_rests(porch: &mut sioul_core::view::PorchView, opened_anyway: bool, now: &Zoned) {
328    let overrides = load();
329    let stamp = now.timestamp().as_second();
330    if opened_anyway || !overrides.porch_rests(stamp) {
331        return;
332    }
333    let Some(until) = overrides.porch_rests_until.and_then(|u| jiff::Timestamp::from_second(u).ok()).map(|t| t.to_zoned(now.time_zone().clone())) else { return };
334    porch.open = false;
335    porch.closed = Some(say("porch-rests", &[("when", tr().when(&until))]));
336    porch.summary.clear();
337    porch.status.clear();
338    porch.lanes.clear();
339}
340
341/// In free time, the leisure the Tasks page offers (GP7): open tasks in view
342/// now, joy and someday included, never ranked, at most eight; never a list
343/// to finish (no count, no next step). Empty outside free time.
344pub(crate) fn offers(tasks: &[sioul_core::tasks::Task], quiet: Option<&sioul_core::quiet::QuietTasks>, free: bool) -> Vec<serde_json::Value> {
345    let Some(quiet) = quiet.filter(|_| free) else { return Vec::new() };
346    let mut open: Vec<&sioul_core::tasks::Task> = tasks.iter().filter(|t| t.status.is_open() && quiet.keeps(t)).collect();
347    open.sort_by_key(|t| t.created);
348    open.into_iter().take(8).map(|t| serde_json::json!({ "uid": t.uid, "title": t.title })).collect()
349}
350
351#[cfg(test)]
352mod tests {
353    #[test]
354    fn porch_back_without_hours_is_tomorrow_morning() {
355        let now: jiff::Zoned = "2026-10-05T15:00[Europe/Paris]".parse().unwrap();
356        let config = sioul_core::config::Config::default();
357        let back = super::porch_back(&config, &now).unwrap();
358        assert_eq!(super::hm(back, &now), "07:00");
359    }
360}