fix(ui): show error toasts raised by a slow frame for their full 3 s

A toast's clock started from the time of the frame that first drew it. That
time is taken when the frame starts, before whatever raised the toast ran:
the Open dialog and a failed open blocked that frame for longer than 3 s, so
the error expired before it was ever seen and the open failed silently. The
clock now starts on the frame after the first one that draws the toast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrii Volkov
2026-10-15 14:51:01 +03:00
co-authored by Claude Opus 5.5
parent 102404e8c3
commit bf506712ba
2 changed files with 52 additions and 6 deletions
+19
View File
@@ -190,6 +190,25 @@ fn a_toast_wakes_the_ui_only_to_go_away() {
assert!(h.app.ui.toast.is_none(), "gone after 3 s");
}
#[test]
fn a_toast_raised_by_a_slow_frame_still_shows_for_3_s() {
let (mut h, _) = document();
h.settle();
// The Open dialog and a failed open block the frame that raises the error: the next frame
// comes 5 s after it started.
h.app.toast("can't open file.fig");
h.frame(vec![]);
h.time += 5.0;
h.frame(vec![]);
assert!(h.app.ui.toast.is_some(), "shown, not expired by the wait");
h.time += 2.0;
h.frame(vec![]);
assert!(h.app.ui.toast.is_some());
h.time += 1.5;
h.frame(vec![]);
assert!(h.app.ui.toast.is_none(), "gone after 3 s");
}
#[test]
fn a_finished_background_render_wakes_the_ui() {
let ctx = egui::Context::default();
+33 -6
View File
@@ -274,6 +274,17 @@ pub const AUTOSAVE_SECS: f64 = 30.0 * 60.0;
/// How long a toast shows (seconds).
const TOAST_SECS: f64 = 3.0;
/// How long a toast has been showing ([`UiState::toast`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ToastClock {
/// Raised, not drawn yet.
Raised,
/// Drawn once; the next frame starts the clock.
Drawn,
/// Showing since this time (UI clock, seconds).
Since(f64),
}
/// Draw another frame `secs` from now (nothing else needs drawing until then: an idle window draws
/// no frames).
#[track_caller]
@@ -337,8 +348,8 @@ pub struct UiState {
pub title_bar: TitleBar,
/// Every icon on one screen, for reviewing the set (control channel `ui.set {iconGallery}`).
pub icon_gallery: bool,
/// A message at the bottom of the window and when it first showed (UI clock, seconds).
pub toast: Option<(String, Option<f64>)>,
/// A message at the bottom of the window and how long it has been showing.
pub toast: Option<(String, ToastClock)>,
pub present: Option<present::Presenter>,
/// The full-window presenter's options (how it sizes the prototype), kept between
/// presentations.
@@ -648,7 +659,7 @@ impl FigApp {
}
pub fn toast(&mut self, msg: impl Into<String>) {
self.ui.toast = Some((msg.into(), None));
self.ui.toast = Some((msg.into(), ToastClock::Raised));
}
/// Say that `n` documents left unsaved by a crash were opened again from recovery copies.
@@ -1135,16 +1146,32 @@ impl FigApp {
}
fn toast_ui(&mut self, ctx: &egui::Context) {
let Some((msg, shown)) = &mut self.ui.toast else { return };
let Some((msg, clock)) = &mut self.ui.toast else { return };
let now = ctx.input(|i| i.time);
let left = TOAST_SECS - (now - *shown.get_or_insert(now));
// The clock starts on the frame after the one that first draws the toast: that frame's
// time was taken before whatever raised it ran (a file dialog, a long open), and would
// count that wait against it, so a failed open's message expired before it was seen.
let left = match *clock {
ToastClock::Raised => {
*clock = ToastClock::Drawn;
ctx.request_repaint();
TOAST_SECS
}
ToastClock::Drawn => {
*clock = ToastClock::Since(now);
TOAST_SECS
}
ToastClock::Since(t) => TOAST_SECS - (now - t),
};
if left <= 0.0 {
self.ui.toast = None;
return;
}
let msg = msg.clone();
// Nothing moves while it shows: the next frame it needs is the one that hides it.
repaint_in(ctx, left);
if matches!(clock, ToastClock::Since(_)) {
repaint_in(ctx, left);
}
let screen = ctx.content_rect();
egui::Area::new(egui::Id::new("toast"))
.fixed_pos(egui::pos2(screen.center().x, screen.max.y - 96.0))