Skip to main content

tilo_app/framework/
saves.rs

1//! A window's save status, presented by the framework (HIG §7, "Status has
2//! two homes").
3//!
4//! Saving is the system's job, so a framework app says nothing about it.
5//! The root of every framework window holds one [`SaveStatusSurface`] and
6//! feeds it the durability the host reports for the window's record; no
7//! app wires it. A save that lands, however slowly, says nothing. Only a
8//! save still unconfirmed after [`SAVE_STALL`](ui_core::SAVE_STALL)
9//! ("Couldn't save yet. Retrying…", then "Not saved" with "Try again") or
10//! one that failed (an error with "Try again", held until a save succeeds)
11//! is said, in the window's bar, and the save that ends either says
12//! "Saved" once.
13//!
14//! An app that also persists outside its record (a file it writes) reports
15//! those saves through [`Saves`] ([`RecordEnv::saves`](super::RecordEnv::saves)),
16//! and the same surface says them. It never shows a save pill, a "Saving…"
17//! line or a "Saved" notice of its own.
18
19use std::cell::RefCell;
20use std::rc::Rc;
21
22use ui_core::record::RecordSaveStatus;
23use ui_core::time::Instant;
24use ui_core::{SaveAction, SaveState, SaveStatusSurface};
25
26/// Saves an app makes outside its record, for the window's save status.
27///
28/// The framework already presents the record's own durability; this is for
29/// what it cannot see, such as a file the app writes. Report each save's
30/// state as it changes ([`Self::saving`], [`Self::saved`], [`Self::failed`]);
31/// the window says nothing unless the save stalls or fails, and then offers
32/// "Try again", which the app reads with [`Self::take_retry`] and answers by
33/// saving again. Clones share one report.
34#[derive(Clone, Default)]
35pub struct Saves(Rc<RefCell<Reported>>);
36
37#[derive(Default)]
38struct Reported {
39    /// The last state reported; `None` before any.
40    state: Option<SaveState>,
41    /// The person asked to try the app's failed save again.
42    retry: bool,
43}
44
45impl Saves {
46    /// A save started and is not stored yet.
47    pub fn saving(&self) {
48        self.report(SaveState::Saving);
49    }
50
51    /// The save landed.
52    pub fn saved(&self) {
53        self.report(SaveState::Saved);
54    }
55
56    /// The save failed; `reason` says why, in the person's words.
57    pub fn failed(&self, reason: impl Into<String>) {
58        self.report(SaveState::Failed(reason.into()));
59    }
60
61    /// Report a save's state.
62    pub fn report(&self, state: SaveState) {
63        self.0.borrow_mut().state = Some(state);
64    }
65
66    /// Whether the person asked to try the app's stalled or failed save
67    /// again, once. The app saves again and reports it.
68    pub fn take_retry(&self) -> bool {
69        std::mem::take(&mut self.0.borrow_mut().retry)
70    }
71
72    /// The state last reported, `None` before any: for automation and
73    /// tests, never for a "Saving…" or "Saved" of the app's own.
74    pub fn state(&self) -> Option<SaveState> {
75        self.0.borrow().state.clone()
76    }
77
78    fn ask_retry(&self) {
79        self.0.borrow_mut().retry = true;
80    }
81}
82
83/// How bad a save state is, to show the worse of two.
84fn severity(state: &SaveState) -> u8 {
85    match state {
86        SaveState::Idle => 0,
87        SaveState::Saved => 1,
88        SaveState::Saving => 2,
89        SaveState::Failed(_) => 3,
90    }
91}
92
93/// Whether a save in `state` is one "Try again" would submit again.
94fn unsettled(state: &SaveState) -> bool {
95    matches!(state, SaveState::Saving | SaveState::Failed(_))
96}
97
98/// The save status a framework root says in the window's bar: the
99/// record's durability and the app's own [`Saves`], on one
100/// [`SaveStatusSurface`].
101pub(crate) struct SaveIndicator {
102    surface: SaveStatusSurface,
103    /// What the host last reported of the record; `None` before it did
104    /// (a past version never reports).
105    record: Option<SaveState>,
106    saves: Saves,
107    /// The record's save is to be submitted again, not yet taken by the
108    /// host.
109    retry_record: bool,
110    /// The notification raised for a save that gave up out of sight.
111    notices: super::save_notice::SaveNotices,
112    /// The app's name, which that notification says the changes are in.
113    app: &'static str,
114}
115
116impl SaveIndicator {
117    pub(crate) fn new(saves: Saves, app: &'static str) -> Self {
118        Self {
119            surface: SaveStatusSurface::new(),
120            record: None,
121            saves,
122            retry_record: false,
123            notices: super::save_notice::SaveNotices::new(),
124            app,
125        }
126    }
127
128    /// The host reported the record's durability.
129    /// Said in the window's bar at once: a save lands or fails outside any
130    /// sync of the window, and the window shows it without one.
131    pub(crate) fn record(&mut self, status: &RecordSaveStatus, now: Instant) {
132        self.record = Some(match status {
133            // What it means for the person, not the storage error behind it
134            // (a path, an errno), which goes to the log.
135            RecordSaveStatus::Failed(reason) => {
136                tracing::warn!(%reason, "the window's record failed to save");
137                SaveState::Failed(ui_core::i18n!("save-status-failed-detail"))
138            }
139            status => SaveState::from(status),
140        });
141        self.follow(now);
142        self.present();
143    }
144
145    /// Keep the window's bar saying the save: its notice, and the mark on a
146    /// document whose work is not yet saved.
147    fn present(&self) {
148        self.surface.publish();
149        ui_core::window_toolbar::show_edited(self.surface.shows_unsaved());
150    }
151
152    /// The worse of the record's state and the app's own.
153    fn merged(&self) -> Option<SaveState> {
154        match (self.record.clone(), self.saves.state()) {
155            (Some(record), Some(app)) if severity(&app) > severity(&record) => Some(app),
156            (Some(record), _) => Some(record),
157            (None, app) => app,
158        }
159    }
160
161    /// Bring the surface up to date at `now`: the merged state, the stage
162    /// of a save that waits, and a submission the surface asked for.
163    pub(crate) fn follow(&mut self, now: Instant) {
164        if let Some(state) = self.merged() {
165            self.surface.set(state, now);
166        }
167        self.surface.advance(now);
168        if self.surface.take_retry() {
169            self.ask_retry();
170        }
171    }
172
173    /// On every sync of the window: run "Try again" pressed in the window's
174    /// bar, bring the surface up to `now`, and keep the bar saying it.
175    pub(crate) fn sync(&mut self, now: Instant) {
176        if let Some(action) = self.surface.take_bar_action() {
177            self.acted(action);
178        }
179        self.follow(now);
180        // Out of sight, a save that gave up is said by a notification,
181        // named by the document the window's bar names.
182        let title = ui_core::window_toolbar::declared()
183            .and_then(|bar| bar.location)
184            .map(|location| location.label);
185        if self
186            .notices
187            .deliver(&mut self.surface, title.as_deref(), self.app, now)
188        {
189            self.ask_retry();
190        }
191        self.present();
192    }
193
194    /// The window drew a frame: the person can see what its bar says.
195    pub(crate) fn seen(&mut self) {
196        self.surface.seen();
197    }
198
199    /// Present the save state as it stands at `now`, forgetting how long it
200    /// took to get there. Staging a capture spends wall time no person
201    /// waited through (decodes, page renders, a loaded machine), and a save
202    /// that waited past [`SAVE_STALL`](ui_core::SAVE_STALL) in it would put
203    /// "Retrying…", or the "Saved" that confirms a noticed wait, into the
204    /// frame — or not, depending on how fast the machine was. A save that
205    /// landed says nothing, as one that landed at once; a failure still
206    /// shows; a save still in flight starts its wait now.
207    pub(crate) fn settle_for_capture(&mut self, now: Instant) {
208        self.surface = SaveStatusSurface::new();
209        self.retry_record = false;
210        self.follow(now);
211        self.present();
212    }
213
214    /// Submit again whichever save is unsettled: the record's, the app's,
215    /// or both.
216    fn ask_retry(&mut self) {
217        if self.record.as_ref().is_some_and(unsettled) {
218            self.retry_record = true;
219        }
220        if self.saves.state().as_ref().is_some_and(unsettled) {
221            self.saves.ask_retry();
222        }
223    }
224
225    fn acted(&mut self, action: SaveAction) {
226        if action == SaveAction::Retry {
227            self.ask_retry();
228        }
229    }
230
231    /// Whether the record's save is to be submitted again, once.
232    pub(crate) fn take_record_retry(&mut self) -> bool {
233        std::mem::take(&mut self.retry_record)
234    }
235
236    /// When the surface next moves along by itself.
237    pub(crate) fn next_wake_deadline(&self) -> Option<Instant> {
238        self.surface.next_wake_deadline()
239    }
240
241    /// Whether the window needs a sync for the bar to say what changed.
242    pub(crate) fn needs_redraw(&self, now: Instant) -> bool {
243        self.merged()
244            .is_some_and(|state| self.surface.state() != Some(&state))
245            || self.surface.needs_publish()
246            || self.next_wake_deadline().is_some_and(|due| due <= now)
247    }
248
249    /// What the window's bar says of the save, for a test.
250    #[cfg(test)]
251    pub(crate) fn said(&self) -> Option<String> {
252        self.surface.notice().map(|notice| notice.text)
253    }
254
255    /// The failure that stands, for a test.
256    #[cfg(test)]
257    pub(crate) fn failure(&self) -> Option<&str> {
258        self.surface.failure()
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use ui_core::SAVE_STALL;
266    use ui_core::time::Duration;
267
268    fn at(t0: Instant, secs: u64) -> Instant {
269        t0.checked_add(Duration::from_secs(secs)).unwrap()
270    }
271
272    fn init() {
273        ui_core::i18n::I18nManager::init("en-US");
274        ui_core::window_toolbar::notify(None);
275    }
276
277    #[test]
278    fn a_record_save_that_lands_says_nothing_however_slow() {
279        init();
280        let t0 = ui_core::time::now();
281        let mut indicator = SaveIndicator::new(Saves::default(), "Probe");
282        indicator.record(&RecordSaveStatus::Saving, t0);
283        indicator.sync(at(t0, 9));
284        assert_eq!(indicator.said(), None, "nothing before the stall");
285        assert_eq!(ui_core::window_toolbar::notice(), None, "the bar is quiet");
286        indicator.record(&RecordSaveStatus::Saved, at(t0, 9));
287        assert_eq!(
288            indicator.said(),
289            None,
290            "no Saved for a save nobody waited on"
291        );
292        assert!(!indicator.take_record_retry());
293    }
294
295    #[test]
296    fn a_stalled_record_save_speaks_at_the_stall_and_retries_once() {
297        init();
298        let t0 = ui_core::time::now();
299        let mut indicator = SaveIndicator::new(Saves::default(), "Probe");
300        indicator.record(&RecordSaveStatus::Saving, t0);
301        assert_eq!(
302            indicator.next_wake_deadline(),
303            Some(at(t0, SAVE_STALL.as_secs())),
304            "the window wakes at the stall, not before"
305        );
306        let stalled = at(t0, SAVE_STALL.as_secs());
307        assert!(indicator.needs_redraw(stalled), "the due stall is a sync");
308        indicator.sync(stalled);
309        assert_eq!(
310            ui_core::window_toolbar::notice()
311                .map(|notice| notice.text)
312                .as_deref(),
313            Some("Couldn't save yet. Retrying…"),
314            "the window's bar says it"
315        );
316        assert!(indicator.take_record_retry(), "the stall submits once more");
317        assert!(!indicator.take_record_retry());
318        indicator.record(&RecordSaveStatus::Saved, at(t0, 12));
319        assert_eq!(
320            indicator.said().as_deref(),
321            Some("Saved"),
322            "the waited-on save ends with Saved"
323        );
324    }
325
326    /// A capture's staging can outlast the stall on a slow machine; the
327    /// frame it takes shows the record's state, never a stall or the
328    /// "Saved" that confirms one, so two captures of one scenario agree.
329    #[test]
330    fn a_capture_shows_the_save_state_not_how_long_staging_took() {
331        init();
332        let t0 = ui_core::time::now();
333        let mut indicator = SaveIndicator::new(Saves::default(), "Probe");
334        indicator.record(&RecordSaveStatus::Saving, t0);
335        indicator.follow(at(t0, SAVE_STALL.as_secs()));
336        indicator.record(&RecordSaveStatus::Saved, at(t0, 12));
337        assert_eq!(indicator.said().as_deref(), Some("Saved"));
338        indicator.settle_for_capture(at(t0, 12));
339        assert_eq!(indicator.said(), None, "a landed save says nothing");
340        assert!(!indicator.take_record_retry(), "the stall's retry is gone");
341
342        let mut failed = SaveIndicator::new(Saves::default(), "Probe");
343        failed.record(&RecordSaveStatus::Failed("The disk is full".into()), t0);
344        failed.settle_for_capture(at(t0, 30));
345        assert_eq!(
346            failed.failure(),
347            Some("Your latest changes are not stored yet."),
348            "a failure still shows"
349        );
350    }
351
352    /// "Try again" pressed in the window's bar.
353    fn press_try_again() {
354        ui_core::window_toolbar::deliver(ui_core::window_toolbar::ToolbarEvent::Action {
355            id: ui_core::SAVE_RETRY_ACTION.to_owned(),
356        });
357    }
358
359    #[test]
360    fn a_failed_save_holds_try_again_in_the_bar_which_reaches_the_record() {
361        init();
362        let t0 = ui_core::time::now();
363        let mut indicator = SaveIndicator::new(Saves::default(), "Probe");
364        indicator.record(&RecordSaveStatus::Failed("The disk is full".into()), t0);
365        indicator.sync(t0);
366        assert_eq!(
367            indicator.failure(),
368            Some("Your latest changes are not stored yet."),
369            "in the person's words"
370        );
371        let held = ui_core::window_toolbar::notice().expect("the bar says the failure");
372        assert_eq!(
373            held.text,
374            "Save failed: Your latest changes are not stored yet."
375        );
376        assert_eq!(
377            held.actions.first().map(|action| action.name.as_str()),
378            Some("Try again")
379        );
380        indicator.sync(at(t0, 600));
381        assert_eq!(
382            ui_core::window_toolbar::notice().as_ref(),
383            Some(&held),
384            "held for as long as the failure stands"
385        );
386        press_try_again();
387        indicator.sync(at(t0, 601));
388        assert!(
389            indicator.take_record_retry(),
390            "Try again submits the record again"
391        );
392        indicator.record(&RecordSaveStatus::Saving, at(t0, 602));
393        indicator.sync(at(t0, 602));
394        assert_eq!(
395            ui_core::window_toolbar::notice().as_ref(),
396            Some(&held),
397            "it stands through the retry"
398        );
399        indicator.record(&RecordSaveStatus::Saved, at(t0, 603));
400        indicator.sync(at(t0, 603));
401        assert_eq!(indicator.failure(), None);
402        assert_eq!(
403            ui_core::window_toolbar::notice()
404                .map(|notice| notice.text)
405                .as_deref(),
406            Some("Saved"),
407            "the save that ends a failure says so once"
408        );
409    }
410
411    #[test]
412    fn an_apps_own_save_shows_through_the_same_surface() {
413        init();
414        let t0 = ui_core::time::now();
415        let saves = Saves::default();
416        let mut indicator = SaveIndicator::new(saves.clone(), "Probe");
417        indicator.record(&RecordSaveStatus::Saved, t0);
418        saves.saving();
419        indicator.sync(at(t0, 3));
420        assert_eq!(indicator.said(), None);
421        saves.failed("The folder is read-only");
422        assert!(
423            indicator.needs_redraw(at(t0, 4)),
424            "a reported failure is a sync"
425        );
426        indicator.sync(at(t0, 4));
427        assert_eq!(indicator.failure(), Some("The folder is read-only"));
428        press_try_again();
429        indicator.sync(at(t0, 4));
430        assert!(saves.take_retry(), "the app is asked to save again");
431        assert!(!saves.take_retry());
432        assert!(
433            !indicator.take_record_retry(),
434            "the record was stored; only the app's save retries"
435        );
436        saves.saved();
437        indicator.sync(at(t0, 5));
438        assert_eq!(indicator.failure(), None);
439    }
440
441    /// A save that gave up while nothing painted the window raises a
442    /// notification naming the document the bar names; its "Try again"
443    /// submits the record again and takes the notification down. A window
444    /// in view says it in its bar alone.
445    #[test]
446    fn a_save_that_gives_up_out_of_sight_notifies_and_its_try_again_retries() {
447        init();
448        ui_core::window_toolbar::declare(Some(ui_core::window_toolbar::WindowToolbar {
449            location: Some(ui_core::window_toolbar::ToolbarLocation {
450                label: "Shopping".into(),
451                ..Default::default()
452            }),
453            ..Default::default()
454        }));
455        let staged = |recording: &std::sync::Arc<ui_core::notify::conformance::Recording>| {
456            let t0 = ui_core::time::now();
457            let mut indicator = SaveIndicator::new(Saves::default(), "Notes");
458            indicator.notices.set_notifier(recording.clone());
459            indicator.record(&RecordSaveStatus::Saving, t0);
460            indicator.sync(at(t0, 21));
461            (indicator, t0)
462        };
463
464        let in_view = std::sync::Arc::new(ui_core::notify::conformance::Recording::default());
465        let (mut indicator, t0) = staged(&in_view);
466        indicator.seen();
467        indicator.sync(at(t0, 23));
468        assert_eq!(indicator.said().as_deref(), Some("Not saved"));
469        assert!(in_view.shown().is_empty(), "in view, nothing is notified");
470
471        let out_of_sight = std::sync::Arc::new(ui_core::notify::conformance::Recording::default());
472        let (mut indicator, t0) = staged(&out_of_sight);
473        indicator.sync(at(t0, 23));
474        let shown = out_of_sight.shown();
475        assert_eq!(shown.len(), 1, "nothing painted it: notify");
476        assert_eq!(shown[0].title, "Shopping wasn't saved");
477        assert_eq!(shown[0].body, "Your changes are still in Notes.");
478        assert_eq!(shown[0].action.as_deref(), Some("Try again"));
479        let tag = shown[0].tag.clone();
480        // The retry the stall asked for on the way.
481        let _ = indicator.take_record_retry();
482        assert!(!indicator.take_record_retry());
483        indicator.notices.click();
484        indicator.sync(at(t0, 24));
485        assert!(
486            indicator.take_record_retry(),
487            "Try again submits the record"
488        );
489        assert_eq!(out_of_sight.withdrawn(), [tag], "and takes it down");
490        ui_core::window_toolbar::declare(None);
491    }
492
493    #[test]
494    fn the_window_marks_unsaved_work_only_once_a_save_stalls_or_fails() {
495        init();
496        ui_core::window_toolbar::show_edited(false);
497        let t0 = ui_core::time::now();
498        let mut indicator = SaveIndicator::new(Saves::default(), "Probe");
499        indicator.record(&RecordSaveStatus::Saving, t0);
500        assert!(
501            !ui_core::window_toolbar::edited(),
502            "a save under way marks nothing: it may land at once"
503        );
504        indicator.sync(at(t0, 11));
505        assert!(
506            ui_core::window_toolbar::edited(),
507            "a stalled save is unsaved work"
508        );
509        indicator.record(&RecordSaveStatus::Saved, at(t0, 12));
510        assert!(
511            !ui_core::window_toolbar::edited(),
512            "the save that lands takes the mark down without a sync"
513        );
514        indicator.record(
515            &RecordSaveStatus::Failed("The disk is full".into()),
516            at(t0, 13),
517        );
518        assert!(
519            ui_core::window_toolbar::edited(),
520            "a failed save is unsaved work"
521        );
522    }
523}
524
525/// The save status as a framework window presents it, with no wiring in
526/// the app: its view only reports a file save through [`Saves`].
527#[cfg(test)]
528#[cfg(not(target_arch = "wasm32"))]
529mod window_tests {
530    use super::Saves;
531    use crate::prelude::*;
532    use std::cell::RefCell;
533    use ui_core::time::{Duration, Instant};
534
535    thread_local! {
536        static SAVES: RefCell<Option<Saves>> = const { RefCell::new(None) };
537    }
538
539    #[derive(Model, Default)]
540    pub struct Draft {
541        pub text: String,
542    }
543
544    #[resource(kind = "test.save-status-draft", label = "Draft")]
545    impl Draft {
546        /// Set the text.
547        #[action]
548        pub fn write(&mut self, text: String) {
549            self.text = text;
550        }
551    }
552
553    struct DraftApp;
554
555    impl ResourceApp for DraftApp {
556        type Resource = Draft;
557        const ID: &'static str = "so.tilo.test-save-status";
558        const TITLE: &'static str = "Draft";
559
560        fn view(cx: &mut ViewCx<Draft>) -> Element {
561            SAVES.with_borrow_mut(|saves| *saves = Some(cx.saves()));
562            Box::new(Column::new().push(Box::new(Text::new("A draft"))))
563        }
564    }
565
566    fn saves() -> Saves {
567        SAVES.with_borrow(|saves| saves.clone().unwrap())
568    }
569
570    /// What the window's bar says, and the button beside it.
571    fn bar() -> Option<(String, Option<String>)> {
572        ui_core::window_toolbar::notice().map(|notice| {
573            (
574                notice.text,
575                notice.actions.into_iter().next().map(|action| action.name),
576            )
577        })
578    }
579
580    /// Bring the window's save status to `at`, as its wake there would,
581    /// then run a frame.
582    fn frame_at(app: &mut TestApp<DraftApp>, at: Instant) {
583        app.harness()
584            .with_widget_mut(|root: &mut crate::framework::root::ResourceRoot| {
585                crate::framework::root::RootModel::save_indicator_mut(root).follow(at);
586            })
587            .unwrap();
588        app.settle();
589    }
590
591    #[test]
592    fn a_window_is_silent_until_a_save_stalls_then_offers_try_again_when_it_fails() {
593        ui_core::i18n::I18nManager::init("en-US");
594        let mut app = TestApp::<DraftApp>::new().unwrap();
595        app.settle();
596        assert_eq!(bar(), None, "a stored record says nothing");
597
598        saves().saving();
599        let t0 = ui_core::time::now();
600        let after = |secs| t0.checked_add(Duration::from_secs(secs)).unwrap();
601        frame_at(&mut app, t0);
602        frame_at(&mut app, after(9));
603        assert_eq!(bar(), None, "nothing while a save may still land");
604
605        frame_at(&mut app, after(10));
606        assert_eq!(
607            bar(),
608            Some(("Couldn't save yet. Retrying…".to_owned(), None)),
609            "the stall speaks at ten seconds"
610        );
611        assert!(saves().take_retry(), "and asks the app to save again");
612
613        saves().failed("The disk is full");
614        frame_at(&mut app, after(11));
615        let held = Some((
616            "Save failed: The disk is full".to_owned(),
617            Some("Try again".to_owned()),
618        ));
619        assert_eq!(bar(), held, "a failure is said with Try again");
620        frame_at(&mut app, after(600));
621        assert_eq!(bar(), held, "and held while it stands");
622        app.harness()
623            .press_window_slot(ui_core::window_toolbar::ToolbarEvent::Action {
624                id: ui_core::SAVE_RETRY_ACTION.to_owned(),
625            });
626        app.settle();
627        assert!(saves().take_retry(), "Try again reaches the app");
628
629        saves().saved();
630        frame_at(&mut app, after(601));
631        assert_eq!(
632            bar(),
633            Some(("Saved".to_owned(), None)),
634            "the save that ends a failure says so once; the failure is gone"
635        );
636    }
637}