Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 175 additions & 9 deletions src/components.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1417,6 +1417,15 @@ impl ToastStack {
/// Newest toasts rendered at once; older live ones queue behind them.
pub const MAX_VISIBLE: usize = 5;

/// Hard cap on retained toasts, visible or not. `push`/`set_keyed` evict
/// down to this once it's exceeded, so a recurring non-keyed
/// `push_error`/`push_warning` (sticky, never auto-expiring) can't grow
/// `items` without bound over a long-running session. The currently
/// *visible* window (the newest [`Self::MAX_VISIBLE`] toasts, including
/// sticky errors) is never evicted — only older, already-invisible
/// entries are dropped, oldest/already-expired first.
pub const MAX_RETAINED: usize = 100;

fn alloc_id(&mut self) -> u64 {
let id = self.next_id;
self.next_id = self.next_id.wrapping_add(1);
Expand All @@ -1442,6 +1451,7 @@ impl ToastStack {
kind,
until: Self::expiry(kind),
});
self.enforce_capacity();
}

/// Upsert a **keyed** toast: if one with `key` already exists its message
Expand Down Expand Up @@ -1474,6 +1484,7 @@ impl ToastStack {
kind,
until: None,
});
self.enforce_capacity();
}

/// Remove the keyed toast with `key`, if present. No-op otherwise.
Expand Down Expand Up @@ -1510,6 +1521,37 @@ impl ToastStack {
let now = std::time::Instant::now();
self.items.retain(|t| t.until.is_none_or(|u| u > now));
}

/// Bound `items` to [`Self::MAX_RETAINED`] after an insert.
///
/// `items` is oldest-first (pushes append), and [`toast_overlay`] renders
/// the newest [`Self::MAX_VISIBLE`] — that tail is the "currently
/// visible" window and is never touched here, so a live sticky error on
/// screen can never be evicted out from under the user. Already-expired
/// entries are dropped first (mirrors [`Self::prune`], since `push` can
/// run several times between overlay frames); if that alone isn't enough,
/// the oldest remaining invisible entries are dropped next, regardless of
/// kind — an off-screen sticky error the user will never scroll back to
/// is retained no better than a duplicate one further down the queue.
fn enforce_capacity(&mut self) {
if self.items.len() <= Self::MAX_RETAINED {
return;
}
let now = std::time::Instant::now();
self.items.retain(|t| t.until.is_none_or(|u| u > now));

let visible = Self::MAX_VISIBLE.min(self.items.len());
while self.items.len() > Self::MAX_RETAINED {
let evictable = self.items.len() - visible;
if evictable == 0 {
// Nothing left outside the visible window to evict — the cap
// is smaller than MAX_VISIBLE (misconfiguration); stop rather
// than evict something on screen.
break;
}
self.items.remove(0);
}
}
}

/// Paint live toasts anchored to the bottom-right of the egui screen.
Expand Down Expand Up @@ -1576,6 +1618,115 @@ pub fn toast_overlay(ctx: &egui::Context, t: &Tokens, stack: &mut ToastStack) {
}
}

#[cfg(test)]
mod toast_stack_tests {
use super::*;

/// A recurring failure condition (e.g. repeated catalog-transport
/// errors) hammering `push_error` for a long-running session must not
/// grow `items` without bound — that was the whole bug (#26).
#[test]
fn push_error_is_bounded_under_hammering() {
let mut stack = ToastStack::default();
for i in 0..10_000 {
stack.push_error(format!("error {i}"));
}
assert!(
stack.items.len() <= ToastStack::MAX_RETAINED,
"items grew unbounded: {} entries after 10k pushes",
stack.items.len()
);
}

/// Same hammering, mixing in warnings (also sticky) and infos (timed) —
/// the mix shouldn't change the bound.
#[test]
fn mixed_kinds_stay_bounded_under_hammering() {
let mut stack = ToastStack::default();
for i in 0..10_000 {
match i % 3 {
0 => stack.push_error(format!("error {i}")),
1 => stack.push_warning(format!("warning {i}")),
_ => stack.push_info(format!("info {i}")),
}
}
assert!(stack.items.len() <= ToastStack::MAX_RETAINED);
}

/// `toast_overlay` renders `items.iter().rev().take(MAX_VISIBLE)` — the
/// newest `MAX_VISIBLE` toasts. Those must survive eviction even under
/// heavy hammering, so a sticky error currently on screen never
/// disappears out from under the user.
#[test]
fn newest_visible_stickies_survive_hammering() {
let mut stack = ToastStack::default();
for i in 0..10_000u32 {
stack.push_error(format!("error {i}"));
}
let visible: Vec<&str> = stack
.items
.iter()
.rev()
.take(ToastStack::MAX_VISIBLE)
.map(|t| t.message.as_str())
.collect();
let expected: Vec<String> = (10_000 - ToastStack::MAX_VISIBLE as u32..10_000)
.rev()
.map(|i| format!("error {i}"))
.collect();
assert_eq!(visible, expected);
}

/// Dismissing a toast (the ✕ button in `toast_overlay`, reproduced here
/// via the same `retain(|t| t.id != id)` it uses) drops it immediately —
/// it must not linger invisibly waiting for capacity eviction.
#[test]
fn dismissed_toast_is_removed_immediately() {
let mut stack = ToastStack::default();
stack.push_error("first");
stack.push_error("second");
stack.push_error("third");
assert_eq!(stack.items.len(), 3);

let dismiss_id = stack.items[1].id;
stack.items.retain(|t| t.id != dismiss_id);

assert_eq!(stack.items.len(), 2);
assert!(stack.items.iter().all(|t| t.id != dismiss_id));
assert_eq!(stack.items[0].message, "first");
assert_eq!(stack.items[1].message, "third");
}

/// When eviction is forced, an already-expired (but not yet pruned)
/// entry goes before any live one, even an older live one.
#[test]
fn capacity_eviction_prefers_expired_over_oldest_live() {
let mut stack = ToastStack::default();
stack.push_info("stale");
stack.items[0].until = Some(std::time::Instant::now() - std::time::Duration::from_secs(1));

for i in 0..ToastStack::MAX_RETAINED {
stack.push_error(format!("error {i}"));
}

assert!(stack.items.len() <= ToastStack::MAX_RETAINED);
assert!(
stack.items.iter().all(|t| t.kind == ToastKind::Error),
"expired info toast should have been evicted before any live error"
);
}

/// `set_keyed` goes through the same capacity enforcement as `push`.
#[test]
fn set_keyed_is_bounded_under_hammering() {
let mut stack = ToastStack::default();
for i in 0..10_000 {
stack.set_keyed(format!("key-{i}"), format!("status {i}"), ToastKind::Error);
}
assert!(stack.items.len() <= ToastStack::MAX_RETAINED);
}
}

// ---------------------------------------------------------------------------
// chip
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1717,8 +1868,12 @@ pub fn gate_overlay(
button_label: &str,
) -> bool {
let rect = ui.available_rect_before_wrap();
// Claim the whole area so nothing behind it is clickable while gated.
ui.allocate_rect(rect, Sense::hover());
// Claim the whole area as click-and-drag so nothing behind it is
// clickable while gated — `Sense::hover()` alone does not intercept
// clicks under egui 0.35's hit-testing (a hover-only rect never blocks
// a click from reaching whatever egui would otherwise hit at that
// position), so it would silently fail to actually gate the area.
ui.allocate_rect(rect, Sense::click_and_drag());

// Layer 1: a near-opaque base — the closest egui gets to a heavy
// backdrop scrim without real blur support.
Expand All @@ -1729,14 +1884,25 @@ pub fn gate_overlay(
};
ui.painter().rect_filled(rect, 0.0, scrim);

// Layer 2: a soft two-ring accent glow centred behind the card, for
// depth — flat fills, not a real blur, but reads as intentional rather
// than a plain empty-state grey box.
// Layer 2: a soft accent glow centred behind the card, for depth — not
// a real blur, but a multi-step alpha ramp (several concentric fills,
// each fainter and larger than the last) reads as a soft falloff
// rather than the hard-edged concentric rings a single pair of flat
// circles produced. Colour drifts from `accent` at the rim to
// `accent_2` at the core so the glow itself carries the two-tone brand
// gradient instead of a flat wash.
let glow_r = (rect.width().min(rect.height()) * 0.42).max(120.0);
ui.painter()
.circle_filled(rect.center(), glow_r, t.accent_soft);
ui.painter()
.circle_filled(rect.center(), glow_r * 0.6, t.accent_2_soft);
const GLOW_STEPS: usize = 7;
for step in 0..GLOW_STEPS {
// `f` sweeps 0.0 (outermost, faintest) .. 1.0 (innermost, most
// saturated) — painted in that order so each smaller, stronger
// ring layers on top of the softer ones behind it.
let f = step as f32 / (GLOW_STEPS - 1) as f32;
let r = glow_r * (1.0 - f * 0.8);
let colour = lerp_color(t.accent_soft, t.accent_2_soft, f);
let step_colour = colour.gamma_multiply(0.3 + 0.7 * f);
ui.painter().circle_filled(rect.center(), r, step_colour);
}

let mut clicked = false;
ui.scope_builder(UiBuilder::new().max_rect(rect), |ui| {
Expand Down