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, shared) = (qt.clone(), Arc::clone(shared));
104    std::thread::spawn(move || {
105        settle(&Zoned::now());
106        apply();
107        // Reachable begun or ended (here, on another device, by its end): the Porch and the mail's pace follow.
108        crate::backend::follow_reachable(&qt, &shared);
109        if crate::dnd::take_pause_pressed() {
110            let _ = qt.queue(|sioul| press(sioul));
111        }
112    });
113}
114
115/// What the status line and the bottom row need of the pauses (`mode_json`).
116pub(crate) fn moment(config: &sioul_core::config::Config, overrides: &Overrides, now: &Zoned) -> serde_json::Value {
117    let usual = usual_end(config, now);
118    let moved = overrides.moved_end(now.date());
119    let free = overrides.free_from().is_some();
120    // "Keep my usual end": in free time on a working day, or while today's end is moved.
121    let can_keep = config.free_time.moves && usual.is_some() && overrides.keep_end_on != Some(now.date()) && (free || moved.is_some());
122    serde_json::json!({
123        "nothing": pause::nothing_now(overrides, &config.free_time),
124        "usual_end": usual.map(|u| hm(u, now)).unwrap_or_default(),
125        "moved_end": moved.map(|m| hm(m, now)).unwrap_or_default(),
126        "can_keep": can_keep,
127    })
128}
129
130/// Free time on: leisure whatever the hour (GP1, GP5). A focus session
131/// running stops and counts; true when one did, for the window to offer the
132/// line on where you stopped (GP3). Off: back, the end of work moved as said
133/// once (GP10, GP16): the line said, else "".
134pub(crate) fn set_free(mut sioul: Pin<&mut Sioul>, on: bool) -> QString {
135    let now = Zoned::now();
136    let stamp = now.timestamp().as_second();
137    // A free time that ended by itself (the night came) is said ended first.
138    settle(&now);
139    let mut overrides = load();
140    if on {
141        if overrides.paused_from().is_some() || overrides.free_from().is_some() {
142            return QString::from("false");
143        }
144        pause::start_free(&mut overrides, stamp);
145        if !save(&mut sioul, &overrides) {
146            return QString::from("false");
147        }
148        let (qt, shared) = (sioul.qt_thread(), sioul.shared());
149        let stopped = sioul_core::timelog::running().is_some();
150        if stopped {
151            crate::work::focus_stop(&qt, &shared, false, "");
152        }
153        sioul.as_mut().set_status(QString::default());
154        shown_again(sioul);
155        return QString::from(if stopped { "true" } else { "false" });
156    }
157    let config = load_config();
158    let day = today(&now);
159    let moved = pause::end_free(&mut overrides, &day.evening(), &now, config.free_time.moves);
160    if !save(&mut sioul, &overrides) {
161        return QString::default();
162    }
163    // Said once, never as a countdown: when today's work ends now.
164    let line = moved.map(|end| say("free-back-moved", &[("time", hm(end, &now))])).unwrap_or_default();
165    sioul.as_mut().set_status(QString::from(&line));
166    shown_again(sioul);
167    QString::from(&line)
168}
169
170/// "Keep my usual end" (GP11, GP19): no reason asked; what no longer fits
171/// goes to later days.
172pub(crate) fn keep_usual_end(mut sioul: Pin<&mut Sioul>) {
173    let now = Zoned::now();
174    let mut overrides = load();
175    pause::keep_usual_end(&mut overrides, now.date());
176    if !save(&mut sioul, &overrides) {
177        return;
178    }
179    let line = usual_end(&load_config(), &now).map(|end| say("free-kept", &[("time", hm(end, &now))])).unwrap_or_default();
180    sioul.as_mut().set_status(QString::from(&line));
181    shown_again(sioul);
182}
183
184/// "Nothing at all" for this free time (GP6), or back to the safe list.
185pub(crate) fn set_free_nothing(mut sioul: Pin<&mut Sioul>, on: bool) {
186    let mut overrides = load();
187    overrides.free_nothing = Some(on);
188    if save(&mut sioul, &overrides) {
189        shown_again(sioul);
190    }
191}
192
193/// The Pause: it asks nothing (P6). Everything Sioul shows is held, on every
194/// device (P7–P8); free time ends there and today's end of work stays as
195/// usual: a pause never costs an evening (GP19–GP20); a session running
196/// stops and counts. Pressed while paused: the screen, nothing else.
197pub(crate) fn press(mut sioul: Pin<&mut Sioul>) {
198    let now = Zoned::now();
199    let stamp = now.timestamp().as_second();
200    let mut overrides = load();
201    if overrides.paused_from().is_none() {
202        let before = overrides.clone();
203        if overrides.free_from().is_some() {
204            overrides.free_ended = Some(stamp);
205        }
206        pause::keep_usual_end(&mut overrides, now.date());
207        pause::start_pause(&mut overrides, stamp, crate::everywhere::latest_press());
208        if !save(&mut sioul, &overrides) {
209            return;
210        }
211        if let Ok(mut kept) = BEFORE_PRESS.lock() {
212            *kept = Some((stamp, before));
213        }
214        let (qt, shared) = (sioul.qt_thread(), sioul.shared());
215        if sioul_core::timelog::running().is_some() {
216            crate::work::focus_stop(&qt, &shared, false, "");
217        }
218    }
219    sioul.as_mut().set_status(QString::default());
220    shown_again(sioul);
221}
222
223/// When the Porch opens again after a pause (P24): at the next admin hours,
224/// else the next working hours, else tomorrow morning.
225fn porch_back(config: &sioul_core::config::Config, now: &Zoned) -> Option<i64> {
226    let admin: Vec<_> = config.week_hours().into_iter().filter(|w| w.kind() == "admin").collect();
227    let hours = if admin.is_empty() { config.week_hours() } else { admin };
228    let morning = i8::try_from(config.agenda.day_start.unwrap_or(7)).unwrap_or(7);
229    let next = sioul_core::quiet::next_work(&hours, &config.time_off, now).unwrap_or_else(|| sioul_core::quiet::after_today(&[], &[], now, morning));
230    Some(next.timestamp().as_second())
231}
232
233/// Coming back (P22–P26): the rest of today lighter, or closed, as set up;
234/// the Porch resting until the next admin hours; "Undo" ten seconds in the
235/// status line, as if the pause had not been pressed. No question, no
236/// count. The screen after it, as JSON: its few lines and the one offer.
237pub(crate) fn come_back(mut sioul: Pin<&mut Sioul>) -> QString {
238    let config = load_config();
239    let now = Zoned::now();
240    let stamp = now.timestamp().as_second();
241    let before = load();
242    let Some(since) = before.paused_from() else { return QString::default() };
243    let mut overrides = before.clone();
244    let after = config.pause.after();
245    let morning = i8::try_from(config.agenda.day_start.unwrap_or(7)).unwrap_or(7);
246    let back = sioul_core::quiet::after_today(&config.working_hours(), &config.time_off, &now, morning);
247    let rest = (after == After::Rest).then(|| back.timestamp().as_second());
248    pause::end_pause(&mut overrides, &now, after, rest, porch_back(&config, &now));
249    if !save(&mut sioul, &overrides) {
250        return QString::default();
251    }
252    // Undo: as before the press when it was pressed here, else as now without this pause.
253    let mut undone = BEFORE_PRESS.lock().ok().and_then(|kept| kept.as_ref().filter(|(at, _)| *at == since).map(|(_, o)| o.clone())).unwrap_or(before);
254    pause::forget(&mut undone);
255    let (qt, shared) = (sioul.qt_thread(), sioul.shared());
256    let title = tr().text("pause-back-title", None);
257    crate::mail::rest(&qt, &shared, undone, overrides.clone(), title.clone());
258    // Words only where they change something: today's work not over yet, a working day tomorrow.
259    let work_left = usual_end(&config, &now).is_some_and(|end| end > stamp);
260    let day = match after {
261        After::Lighter if work_left => tr().text("pause-back-lighter", None),
262        After::Rest if work_left => say("pause-back-rest", &[("back", sioul_core::quiet::until_text(tr(), &back, &now))]),
263        _ => String::new(),
264    };
265    let tomorrow = now.date().tomorrow().ok().and_then(|d| d.to_zoned(now.time_zone().clone()).ok());
266    let working_tomorrow = tomorrow.is_some_and(|t| config.week_hours().is_empty() || usual_end(&config, &t).is_some());
267    shown_again(sioul);
268    QString::from(
269        &serde_json::json!({
270            "title": title,
271            "day": day,
272            "tomorrow": if working_tomorrow { tr().text("pause-back-tomorrow", None) } else { String::new() },
273            "lighten": tr().text("pause-lighten-tomorrow", None),
274            "go": tr().text("pause-back-go", None),
275        })
276        .to_string(),
277    )
278}
279
280/// "Lighten tomorrow", the one offer coming back (default: no, P23).
281pub(crate) fn lighten_tomorrow(mut sioul: Pin<&mut Sioul>) {
282    let now = Zoned::now();
283    let Ok(tomorrow) = now.date().tomorrow() else { return };
284    let mut overrides = load();
285    pause::lighten(&mut overrides, tomorrow);
286    if save(&mut sioul, &overrides) {
287        sioul.as_mut().set_status(QString::from(&tr().text("pause-tomorrow-lighter", None)));
288        shown_again(sioul);
289    }
290}
291
292/// "Forget the last pause" (P26): when it began and ended, gone.
293pub(crate) fn forget(mut sioul: Pin<&mut Sioul>) {
294    let mut overrides = load();
295    if overrides.paused_from().is_some() {
296        return;
297    }
298    pause::forget(&mut overrides);
299    if save(&mut sioul, &overrides) {
300        sioul.as_mut().set_status(QString::from(&tr().text("pause-setup-forgotten", None)));
301    }
302}
303
304/// The pause's screen (P11–P17), as JSON: your list, the breathing guide and
305/// the grounding line as set up, the numbers of your country; whether doses
306/// still come, as the matrix of what reaches you says.
307pub(crate) fn screen() -> String {
308    let config = load_config();
309    let mut screen = pause::screen(&config.pause, config.contacts.region.as_deref(), tr());
310    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;
311    serde_json::to_string(&screen).unwrap_or_default()
312}
313
314/// The setup's do-not-disturb part, as JSON: what this device can do, its
315/// buttons and GNOME's consent (`dnd::Report::json`), and the consent as set.
316/// A system round trip: asked when the tab opens, never per frame.
317pub(crate) fn setup() -> String {
318    let mut report = crate::dnd::can().json();
319    report["gnome"] = serde_json::Value::Bool(load_config().pause.gnome);
320    report["forgettable"] = serde_json::Value::Bool({
321        let overrides = load();
322        overrides.paused_since.is_some() && overrides.paused_from().is_none()
323    });
324    report.to_string()
325}
326
327/// After a pause, the Porch rests until the next admin hours unless opened
328/// (P24): only the codes you asked for, and when it opens.
329pub(crate) fn porch_rests(porch: &mut sioul_core::view::PorchView, opened_anyway: bool, now: &Zoned) {
330    let overrides = load();
331    let stamp = now.timestamp().as_second();
332    if opened_anyway || !overrides.porch_rests(stamp) {
333        return;
334    }
335    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 };
336    porch.open = false;
337    porch.closed = Some(say("porch-rests", &[("when", tr().when(&until))]));
338    porch.summary.clear();
339    porch.status.clear();
340    porch.lanes.clear();
341}
342
343/// In free time, the leisure the Tasks page offers (GP7): open tasks in view
344/// now, joy and someday included, never ranked, at most eight; never a list
345/// to finish (no count, no next step). Empty outside free time.
346pub(crate) fn offers(tasks: &[sioul_core::tasks::Task], quiet: Option<&sioul_core::quiet::QuietTasks>, free: bool) -> Vec<serde_json::Value> {
347    let Some(quiet) = quiet.filter(|_| free) else { return Vec::new() };
348    let mut open: Vec<&sioul_core::tasks::Task> = tasks.iter().filter(|t| t.status.is_open() && quiet.keeps(t)).collect();
349    open.sort_by_key(|t| t.created);
350    open.into_iter().take(8).map(|t| serde_json::json!({ "uid": t.uid, "title": t.title })).collect()
351}
352
353#[cfg(test)]
354mod tests {
355    #[test]
356    fn porch_back_without_hours_is_tomorrow_morning() {
357        let now: jiff::Zoned = "2026-10-05T15:00[Europe/Paris]".parse().unwrap();
358        let config = sioul_core::config::Config::default();
359        let back = super::porch_back(&config, &now).unwrap();
360        assert_eq!(super::hm(back, &now), "07:00");
361    }
362}