1use 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#[derive(Clone, Default)]
35pub struct Saves(Rc<RefCell<Reported>>);
36
37#[derive(Default)]
38struct Reported {
39 state: Option<SaveState>,
41 retry: bool,
43}
44
45impl Saves {
46 pub fn saving(&self) {
48 self.report(SaveState::Saving);
49 }
50
51 pub fn saved(&self) {
53 self.report(SaveState::Saved);
54 }
55
56 pub fn failed(&self, reason: impl Into<String>) {
58 self.report(SaveState::Failed(reason.into()));
59 }
60
61 pub fn report(&self, state: SaveState) {
63 self.0.borrow_mut().state = Some(state);
64 }
65
66 pub fn take_retry(&self) -> bool {
69 std::mem::take(&mut self.0.borrow_mut().retry)
70 }
71
72 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
83fn 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
93fn unsettled(state: &SaveState) -> bool {
95 matches!(state, SaveState::Saving | SaveState::Failed(_))
96}
97
98pub(crate) struct SaveIndicator {
102 surface: SaveStatusSurface,
103 record: Option<SaveState>,
106 saves: Saves,
107 retry_record: bool,
110 notices: super::save_notice::SaveNotices,
112 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 pub(crate) fn record(&mut self, status: &RecordSaveStatus, now: Instant) {
132 self.record = Some(match status {
133 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 fn present(&self) {
148 self.surface.publish();
149 ui_core::window_toolbar::show_edited(self.surface.shows_unsaved());
150 }
151
152 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 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 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 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 pub(crate) fn seen(&mut self) {
196 self.surface.seen();
197 }
198
199 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 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 pub(crate) fn take_record_retry(&mut self) -> bool {
233 std::mem::take(&mut self.retry_record)
234 }
235
236 pub(crate) fn next_wake_deadline(&self) -> Option<Instant> {
238 self.surface.next_wake_deadline()
239 }
240
241 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 #[cfg(test)]
251 pub(crate) fn said(&self) -> Option<String> {
252 self.surface.notice().map(|notice| notice.text)
253 }
254
255 #[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 #[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 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 #[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 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#[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 #[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 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 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}