git.lucas.co / cce-mail
mail client (IMAP/SMTP)
git clone https://git.lucas.co/cce-mail.git

commit4f56c8419c6e967cafc948ff6151fd18dfa02ec6
parentf73531d8cd
authorLucas Galante <[email protected]>
date2026-09-11 13:50
Expire info toasts on a deadline, not a dt countdown

`StatusToast::Info` carried a `ttl` counted down by tick's `dt`. But
`dt` is animation time: a window whose only pending work is a toast's
own expiry never redraws, so the runner parks on its 1 s idle sleep and
hands back one frame's worth of `dt` per second. The 3-4 s toasts
("Email Sent Successfully", "Saved to Drafts") sat on screen for
minutes — the same bug that stuck cce-notifier's screenshot card.

Resolve the ttl to an `Instant` in `StatusToast::info` (call sites keep
passing seconds) and compare against the clock, with an
`idle_poll_interval` while one is up so it comes down on time.

Co-Authored-By: Claude Opus 5 <[email protected]>

 src/main.rs | 33 ++++++++++++++++++++++++++-------
 1 file changed, 26 insertions(+), 7 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 0b26feb..9c9679e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -142,18 +142,29 @@ struct HtmlMail {
     inline: Vec<(String, String, Vec<u8>)>,
 }
 
-/// The single status slot at the bottom of the window. Info toasts count
-/// down in `tick` and expire; errors carry no timer — they stay until
+/// The single status slot at the bottom of the window. Info toasts expire on
+/// a deadline checked in `tick`; errors carry no timer — they stay until
 /// clicked away or replaced, so a failed sync can't vanish unseen.
+///
+/// The deadline is a wall clock rather than a `ttl` counted down by `tick`'s
+/// `dt`, because `dt` is animation time: the runner clamps it to one frame
+/// after an idle sleep, and a window whose only pending work is a toast's own
+/// expiry never leaves that sleep. Counted in `dt`, a 4 s toast sat on screen
+/// for about four minutes.
 #[derive(Debug, Clone)]
 enum StatusToast {
-    Info { text: String, ttl: f32 },
+    Info { text: String, expires_at: std::time::Instant },
     Error { text: String },
 }
 
 impl StatusToast {
+    /// `ttl` is in seconds, resolved to a deadline here so every call site
+    /// keeps reading as a duration.
     fn info(text: impl Into<String>, ttl: f32) -> Self {
-        StatusToast::Info { text: text.into(), ttl }
+        StatusToast::Info {
+            text: text.into(),
+            expires_at: std::time::Instant::now() + std::time::Duration::from_secs_f32(ttl),
+        }
     }
     fn error(text: impl Into<String>) -> Self {
         StatusToast::Error { text: text.into() }
@@ -4383,6 +4394,15 @@ impl Application for ClearEmailApp {
         }
     }
 
+    /// A live info toast's only pending work is its own expiry, which the
+    /// runner cannot see: nothing redraws, so the loop would park on its
+    /// default idle sleep and retire the toast up to a second late. Poll
+    /// while one is up; the scheduled backfill is happy with the default.
+    fn idle_poll_interval(&self) -> Option<std::time::Duration> {
+        matches!(self.status_message, Some(StatusToast::Info { .. }))
+            .then(|| std::time::Duration::from_millis(100))
+    }
+
     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
         // Pump the widget tick walk: animating widgets (dropdown menus'
         // expand/contract) register as tick receivers and report changed
@@ -4432,9 +4452,8 @@ impl Application for ClearEmailApp {
         }
 
         // Only info toasts expire; an error stays until clicked or replaced.
-        if let Some(StatusToast::Info { ref mut ttl, .. }) = self.status_message {
-            *ttl -= dt;
-            if *ttl <= 0.0 {
+        if let Some(StatusToast::Info { expires_at, .. }) = self.status_message {
+            if std::time::Instant::now() >= expires_at {
                 self.status_message = None;
                 *needs_rebuild = true;
                 self.needs_rebuild = true;