mail client (IMAP/SMTP)
git clone https://git.lucas.co/cce-mail.git
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;