Skip to main content

egui_charts/ui_kit/notifications/
panel.rs

1//! Notification Panel
2//!
3//! A panel for displaying toast notifications.
4
5use crate::localization as l10n;
6use egui::accesskit::{Live, Role};
7use egui::{Align2, Context, Order, RichText, Ui, Vec2};
8
9use super::toast::{Toast, ToastKind};
10use super::toasts::{Toasts, current_time_seconds};
11use crate::icons::icons;
12use crate::painting::symbol_mark::paint_symbol_mark;
13use crate::theme::{Spacing, ThemeExt};
14use crate::tokens::DESIGN_TOKENS;
15use crate::ui_kit::buttons::{Button, ButtonSize, IconButton};
16
17/// Position for the notification panel
18#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)]
19pub enum NotificationPosition {
20    /// Top-right corner
21    TopRight,
22    /// Bottom-right corner
23    BottomRight,
24    /// Bottom-left corner (default)
25    #[default]
26    BottomLeft,
27    /// Top-left corner
28    TopLeft,
29}
30
31impl NotificationPosition {
32    /// Get the anchor alignment for this position
33    fn anchor(&self) -> Align2 {
34        match self {
35            NotificationPosition::TopRight => Align2::RIGHT_TOP,
36            NotificationPosition::BottomRight => Align2::RIGHT_BOTTOM,
37            NotificationPosition::BottomLeft => Align2::LEFT_BOTTOM,
38            NotificationPosition::TopLeft => Align2::LEFT_TOP,
39        }
40    }
41
42    /// Get the offset direction for stacking toasts
43    fn stack_direction(&self) -> f32 {
44        match self {
45            NotificationPosition::TopRight | NotificationPosition::TopLeft => 1.0,
46            NotificationPosition::BottomRight | NotificationPosition::BottomLeft => -1.0,
47        }
48    }
49}
50
51/// Configuration for the notification panel
52pub struct NotificationPanelConfig {
53    /// Position on screen
54    pub position: NotificationPosition,
55    /// Maximum number of toasts shown when the stack is expanded, or when only one toast is queued
56    pub max_visible: usize,
57    /// Toast width
58    pub width: f32,
59    /// Gap between toasts
60    pub gap: f32,
61}
62
63impl Default for NotificationPanelConfig {
64    fn default() -> Self {
65        Self {
66            position: NotificationPosition::default(),
67            max_visible: 5,
68            width: DESIGN_TOKENS.sizing.notification.panel_width,
69            gap: Spacing::default().lg,
70        }
71    }
72}
73
74/// A control of the collapsed or expanded stack that the user pressed this frame.
75enum StackAction {
76    Toggle,
77    DismissAll,
78}
79
80/// A panel for displaying toast notifications
81///
82/// Two or more toasts collapse into a stack showing the newest; a pill expands it and a button
83/// beside it dismisses every dismissible toast.
84pub struct NotificationPanel {
85    config: NotificationPanelConfig,
86}
87
88impl Default for NotificationPanel {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl NotificationPanel {
95    /// Create a new notification panel with default settings
96    pub fn new() -> Self {
97        Self {
98            config: NotificationPanelConfig::default(),
99        }
100    }
101
102    /// Set the position
103    pub fn position(mut self, position: NotificationPosition) -> Self {
104        self.config.position = position;
105        self
106    }
107
108    /// Set the maximum number of toasts shown when the stack is expanded, or when only one toast
109    /// is queued
110    pub fn max_visible(mut self, max: usize) -> Self {
111        self.config.max_visible = max;
112        self
113    }
114
115    /// Set the toast width
116    pub fn width(mut self, width: f32) -> Self {
117        self.config.width = width;
118        self
119    }
120
121    /// Show the notification panel with a per-instance toast list
122    pub fn show_with_toasts(&self, ctx: &Context, toasts: &mut Toasts) {
123        // Clean up expired toasts
124        toasts.cleanup_expired();
125
126        if toasts.len() < 2 {
127            toasts.expanded = false;
128        }
129
130        if toasts.is_empty() {
131            return;
132        }
133
134        // Show toasts
135        let (dismissed, action) = self.show_toasts(ctx, toasts.toasts(), toasts.expanded);
136
137        // Remove dismissed toasts
138        for id in dismissed {
139            toasts.remove(id);
140        }
141
142        match action {
143            Some(StackAction::Toggle) => toasts.expanded = !toasts.expanded,
144            Some(StackAction::DismissAll) => toasts.toasts_mut().retain(|t| !t.dismissible),
145            None => {}
146        }
147
148        if toasts.len() < 2 {
149            toasts.expanded = false;
150        }
151
152        // Request repaint if there are toasts
153        ctx.request_repaint();
154    }
155
156    /// Internal: show toasts and return IDs of dismissed toasts and the stack control pressed
157    fn show_toasts(
158        &self,
159        ctx: &Context,
160        toasts: &[Toast],
161        expanded: bool,
162    ) -> (Vec<u64>, Option<StackAction>) {
163        let mut dismissed = Vec::new();
164        let corner = self.config.position.anchor();
165        let margin = ctx.active_theme().spacing.xxl;
166        let base_pos = corner.pos_in_rect(&ctx.content_rect().shrink(margin));
167        let stack_dir = self.config.position.stack_direction();
168
169        let stacked = toasts.len() >= 2 && self.config.max_visible >= 2;
170        let collapsed = stacked && !expanded;
171        let (shown, layers) = if collapsed {
172            (1, (toasts.len() - 1).min(2))
173        } else {
174            (self.config.max_visible, 0)
175        };
176
177        // Show toasts (limited to `shown`), newest nearest the corner
178        let visible_toasts: Vec<_> = toasts.iter().rev().take(shown).collect();
179        let mut y_offset = 0.0;
180
181        for toast in visible_toasts {
182            let toast_id = egui::Id::unique("toast").with(toast.id);
183
184            // A pivot, not an anchor: egui ignores `fixed_pos` on an anchored area.
185            let pos = base_pos + Vec2::new(0.0, stack_dir * y_offset);
186            let response = egui::Area::new(toast_id)
187                .order(Order::Foreground)
188                .pivot(corner)
189                .fixed_pos(pos)
190                .show(ctx, |ui| {
191                    // Reserved first, so that the edges of the older toasts sit under the card.
192                    let edges = ui.painter().add(egui::Shape::Noop);
193                    let dismissed = self.render_toast(ui, toast);
194                    let card = ui.min_rect();
195                    let spacing = ui.active_theme().spacing;
196                    let visuals = ui.visuals();
197                    let shapes = (1..=layers).rev().map(|layer| {
198                        let layer = layer as f32;
199                        egui::epaint::RectShape::new(
200                            card.shrink2(Vec2::new(layer * spacing.lg, 0.0))
201                                .translate(Vec2::new(0.0, -stack_dir * layer * spacing.sm)),
202                            visuals.window_corner_radius,
203                            visuals.window_fill,
204                            visuals.window_stroke,
205                            egui::StrokeKind::Inside,
206                        )
207                        .into()
208                    });
209                    ui.painter().set(edges, egui::Shape::Vec(shapes.collect()));
210                    dismissed
211                });
212
213            // One live node per toast, so a screen reader announces it once when it appears.
214            let error = toast.kind == ToastKind::Error;
215            let text = toast
216                .title
217                .iter()
218                .chain([&toast.message])
219                .map(|text| l10n::text(ctx, text).to_string())
220                .collect::<Vec<_>>()
221                .join(". ");
222            ctx.accesskit_node_builder(response.response.id, |node| {
223                node.set_role(if error { Role::Alert } else { Role::Status });
224                node.set_label(text);
225                node.set_live(if error { Live::Assertive } else { Live::Polite });
226            });
227
228            // Track height for stacking
229            y_offset += response.response.rect.height() + self.config.gap;
230
231            // Check if toast was dismissed
232            if response.inner {
233                dismissed.push(toast.id);
234            }
235        }
236
237        let action = stacked.then(|| {
238            let pos = base_pos + Vec2::new(0.0, stack_dir * y_offset);
239            self.show_stack_controls(ctx, corner, pos, toasts, expanded)
240        });
241
242        (dismissed, action.flatten())
243    }
244
245    /// Internal: show the pill and the dismiss-all button beyond the stack, on the side away from
246    /// the corner, and return the one pressed
247    fn show_stack_controls(
248        &self,
249        ctx: &Context,
250        corner: Align2,
251        pos: egui::Pos2,
252        toasts: &[Toast],
253        expanded: bool,
254    ) -> Option<StackAction> {
255        egui::Area::new(egui::Id::unique("toast_stack").with(corner))
256            .order(Order::Foreground)
257            .pivot(corner)
258            .fixed_pos(pos)
259            .show(ctx, |ui| {
260                let spacing = ui.active_theme().spacing;
261                let visuals = ui.visuals();
262                let frame = egui::Frame::NONE
263                    .fill(visuals.window_fill)
264                    .stroke(visuals.window_stroke)
265                    .shadow(visuals.popup_shadow)
266                    .corner_radius(ButtonSize::SM.pixels() / 2.0 + spacing.xs)
267                    .inner_margin(egui::Margin::same(spacing.xs as i8));
268                frame
269                    .show(ui, |ui| {
270                        ui.spacing_mut().item_spacing.x = spacing.xs;
271                        ui.horizontal(|ui| {
272                            let label = if expanded {
273                                "Show less".to_owned()
274                            } else {
275                                let hidden = (toasts.len() - 1).to_string();
276                                l10n::format_text(ui.ctx(), "Show more {0}", &[("0", hidden)])
277                            };
278                            let pill = Button::new(label).size(ButtonSize::SM).show(ui);
279                            ui.ctx().accesskit_node_builder(pill.id, |node| {
280                                node.set_expanded(expanded)
281                            });
282                            let dismiss_all = toasts.iter().any(|t| t.dismissible)
283                                && IconButton::new(&icons::CLOSE)
284                                    .size(ButtonSize::SM)
285                                    .tooltip("Dismiss all")
286                                    .show(ui)
287                                    .clicked();
288                            if pill.clicked() {
289                                Some(StackAction::Toggle)
290                            } else if dismiss_all {
291                                Some(StackAction::DismissAll)
292                            } else {
293                                None
294                            }
295                        })
296                        .inner
297                    })
298                    .inner
299            })
300            .inner
301    }
302
303    fn render_trading_toast(
304        &self,
305        ui: &mut Ui,
306        toast: &Toast,
307        trade: &super::TradingToast,
308    ) -> bool {
309        use egui::{FontId, ImageSource, Rect, Sense, Stroke, pos2, vec2};
310        let theme = ui.active_theme();
311        let buy = trade.side == crate::model::OrderSide::Buy;
312        let side_color = crate::theme::trading_side_color(&theme, trade.side);
313        let icon_color = if trade.executed {
314            theme.ui.take_profit
315        } else {
316            side_color
317        };
318        let font = FontId::proportional(ui.active_theme().text.title);
319        let mut title_job = egui::text::LayoutJob::single_section(
320            l10n::text(ui.ctx(), toast.title.clone().unwrap_or_default()),
321            egui::text::TextFormat::simple(FontId::proportional(theme.text.heading), theme.ui.text),
322        );
323        let side_text = if theme.dark {
324            side_color.lerp_to_gamma(theme.ui.text, 0.25)
325        } else {
326            side_color
327        };
328        let mut side_format = egui::text::TextFormat::simple(font.clone(), side_text);
329        side_format.coords.push("wght", 600.0);
330        let side = ui
331            .painter()
332            .layout_job(egui::text::LayoutJob::single_section(
333                l10n::format_text(
334                    ui.ctx(),
335                    if buy {
336                        "Buy {quantity}"
337                    } else {
338                        "Sell {quantity}"
339                    },
340                    &[("quantity", trade.quantity.to_string().as_str())],
341                ),
342                side_format,
343            ));
344        let price = ui.painter().layout_no_wrap(
345            l10n::format_text(ui.ctx(), "at {0}", &[("0", trade.price.to_string())]),
346            font,
347            theme.ui.text_secondary,
348        );
349        let width = self
350            .config
351            .width
352            .min(ui.ctx().content_rect().width() - 32.0)
353            .floor();
354        let content_width = (width - 80.0).max(1.0);
355        title_job.wrap.max_width = content_width;
356        let title = ui.painter().layout_job(title_job);
357        let symbol = ui.painter().layout(
358            trade.symbol.clone(),
359            FontId::proportional(ui.active_theme().text.title),
360            theme.ui.text,
361            (content_width - 28.0).max(1.0),
362        );
363        let symbol_at = vec2(0.0, title.size().y + 4.0);
364        let second_row = symbol_at.y + symbol.size().y + 4.0 + 12.0;
365        let price_at = if side.size().x + price.size().x + 18.0 <= content_width {
366            vec2(side.size().x + 18.0, second_row + 2.0)
367        } else {
368            vec2(0.0, second_row + side.size().y + 10.0)
369        };
370        let price = ui.painter().layout(
371            l10n::format_text(ui.ctx(), "at {0}", &[("0", trade.price.to_string())]),
372            FontId::proportional(ui.active_theme().text.title),
373            theme.ui.text_secondary,
374            (content_width - price_at.x).max(1.0),
375        );
376        let height = (price_at.y + price.size().y + 24.0).max(72.0);
377        let (rect, response) = ui.allocate_exact_size(vec2(width, height.ceil()), Sense::hover());
378        // The text is painted, so name the card for screen readers.
379        let message = l10n::text(ui.ctx(), &toast.message);
380        response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, &message));
381        let painter = ui.painter().with_clip_rect(rect);
382        let radius = theme.radius.lg;
383        painter.rect_filled(rect, radius, theme.ui.panel_bg_floating);
384        let tile = Rect::from_min_size(rect.min, vec2(42.0, rect.height()));
385        painter.rect_filled(
386            tile,
387            egui::CornerRadius {
388                nw: radius as u8,
389                sw: radius as u8,
390                ne: 0,
391                se: 0,
392            },
393            icon_color.gamma_multiply(0.14),
394        );
395        painter.rect_stroke(
396            rect,
397            radius,
398            Stroke::new(1.0, theme.ui.border),
399            egui::StrokeKind::Inside,
400        );
401        let c = tile.center();
402        let points = if trade.executed {
403            painter.circle_filled(c, 9.0, icon_color);
404            [
405                c + vec2(-4.0, 0.0),
406                c + vec2(-1.0, 3.0),
407                c + vec2(5.0, -3.5),
408            ]
409        } else if buy {
410            [c + vec2(-6.0, 2.5), c + vec2(0.0, -2.5), c + vec2(6.0, 2.5)]
411        } else {
412            [
413                c + vec2(-6.0, -2.5),
414                c + vec2(0.0, 2.5),
415                c + vec2(6.0, -2.5),
416            ]
417        };
418        let glyph_color = if trade.executed {
419            theme.chart.bg
420        } else {
421            icon_color
422        };
423        let weight = if trade.executed { 1.5 } else { 4.0 };
424        for pair in points.windows(2) {
425            painter.line_segment([pair[0], pair[1]], Stroke::new(weight, glyph_color));
426        }
427        for point in points {
428            painter.circle_filled(point, weight / 2.0, glyph_color);
429        }
430        let content = painter.with_clip_rect(Rect::from_min_max(
431            rect.min,
432            pos2(rect.right() - 28.0, rect.bottom()),
433        ));
434        let x = tile.right() + 10.0;
435        let top = rect.top() + 12.0;
436        let symbol_rect =
437            Rect::from_min_size(pos2(x, top) + symbol_at, symbol.size() + vec2(28.0, 4.0));
438        content.galley(pos2(x, top), title, theme.ui.text);
439        content.rect_filled(symbol_rect, 10.0, theme.ui.btn_bg_hover);
440        let line = symbol
441            .rows
442            .first()
443            .map_or(symbol.size().y, |row| row.rect().height());
444        let mark = Rect::from_center_size(
445            pos2(
446                symbol_rect.left() + 11.0,
447                symbol_rect.top() + 2.0 + line / 2.0,
448            ),
449            vec2(14.0, 14.0),
450        );
451        paint_symbol_mark(
452            &content,
453            mark,
454            &trade.symbol,
455            trade.logo.as_deref().map(ImageSource::from),
456        );
457        content.galley(symbol_rect.min + vec2(22.0, 2.0), symbol, theme.ui.text);
458        let side_rect =
459            Rect::from_min_size(pos2(x, top + second_row), side.size() + vec2(12.0, 4.0));
460        content.rect_filled(side_rect, 4.0, side_color.gamma_multiply(0.12));
461        content.galley(side_rect.min + vec2(6.0, 2.0), side, side_text);
462        content.galley(pos2(x, top) + price_at, price, theme.ui.text_secondary);
463        if toast.dismissible {
464            let close = Rect::from_center_size(
465                pos2(rect.right() - 14.0, rect.top() + 18.0),
466                vec2(20.0, 20.0),
467            );
468            return ui
469                .scope_builder(egui::UiBuilder::new().max_rect(close), |ui| {
470                    IconButton::new(&icons::CLOSE)
471                        .size(ButtonSize::SM)
472                        .tooltip("Dismiss")
473                        .show(ui)
474                        .clicked()
475                })
476                .inner;
477        }
478        false
479    }
480
481    /// Render a single toast, returns true if dismissed
482    fn render_toast(&self, ui: &mut Ui, toast: &Toast) -> bool {
483        if let Some(trading) = &toast.trading {
484            return self.render_trading_toast(ui, toast, trading);
485        }
486        let mut dismissed = false;
487
488        let theme = ui.active_theme();
489        let kind_color = toast.kind.color(&theme.ui);
490        let text_color = theme.ui.text;
491
492        let frame = egui::Frame::window(ui.style())
493            .shadow(ui.visuals().popup_shadow)
494            .inner_margin(egui::Margin::same(theme.spacing.lg as i8));
495
496        frame.show(ui, |ui| {
497            ui.set_width(
498                self.config.width.min(
499                    (ui.ctx().content_rect().width()
500                        - 2.0 * (theme.spacing.xxl + theme.spacing.lg)
501                        - 2.0)
502                        .max(1.0),
503                ),
504            );
505
506            ui.horizontal(|ui| {
507                // Icon
508                let icon = toast.kind.icon();
509                let icon_size = DESIGN_TOKENS.sizing.icon_md;
510                ui.add(icon.as_image_tinted(Vec2::splat(icon_size), kind_color));
511
512                ui.add_space(ui.active_theme().spacing.md);
513
514                // The close button first, so that a long message wraps short of it.
515                ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
516                    if toast.dismissible {
517                        dismissed = IconButton::new(&icons::CLOSE)
518                            .size(ButtonSize::SM)
519                            .tooltip("Dismiss")
520                            .show(ui)
521                            .clicked();
522                    }
523
524                    // Content
525                    ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
526                        // Title, then the message muted so the title stands out
527                        let mut message_color = text_color;
528                        if let Some(title) = &toast.title {
529                            ui.label(RichText::new(l10n::text(ui.ctx(), title)).strong());
530                            message_color = theme.ui.text_secondary;
531                        }
532                        ui.label(
533                            RichText::new(l10n::text(ui.ctx(), &toast.message))
534                                .color(message_color),
535                        );
536                    });
537                });
538            });
539
540            // Progress bar (if duration > 0)
541            if toast.duration > 0.0 {
542                ui.add_space(ui.active_theme().spacing.sm);
543                let current_time = current_time_seconds();
544                let fraction = toast.remaining_fraction(current_time);
545
546                let (rect, _) = ui.allocate_exact_size(
547                    Vec2::new(ui.available_width(), theme.spacing.xs),
548                    egui::Sense::hover(),
549                );
550
551                // Background bar
552                ui.painter().rect_filled(rect, 1.0, theme.ui.border);
553
554                // Progress bar
555                let progress_rect = egui::Rect::from_min_size(
556                    rect.min,
557                    Vec2::new(rect.width() * fraction, rect.height()),
558                );
559                ui.painter().rect_filled(progress_rect, 1.0, kind_color);
560            }
561        });
562
563        dismissed
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    /// A trading notice is a card of its own over whatever is behind it, with a border, and its
572    /// title is at regular weight.
573    #[test]
574    fn trading_notices_are_bordered_cards_with_regular_titles() {
575        for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
576            let ctx = Context::default();
577            crate::theme::apply_to_egui(&ctx, &theme);
578            let mut toast = Toast::info("Limit order placed").with_title("Limit order placed on");
579            toast.trading = Some(super::super::TradingToast {
580                symbol: "SYM".into(),
581                logo: None,
582                side: crate::model::OrderSide::Buy,
583                quantity: 1.0,
584                price: "100.00".into(),
585                executed: false,
586            });
587            let output = ctx.run_ui(egui::RawInput::default(), |ui| {
588                NotificationPanel::new().render_toast(ui, &toast);
589            });
590            let title = output
591                .shapes
592                .iter()
593                .find_map(|shape| match &shape.shape {
594                    egui::Shape::Text(text) if text.galley.text() == "Limit order placed on" => {
595                        Some(text)
596                    }
597                    _ => None,
598                })
599                .expect("the title");
600            assert_eq!(
601                title.galley.job.sections[0].format.coords,
602                Default::default()
603            );
604            let card = |check: &dyn Fn(&egui::epaint::RectShape) -> bool| {
605                output.shapes.iter().any(|shape| {
606                    matches!(&shape.shape, egui::Shape::Rect(rect)
607                        if rect.rect.contains(title.pos) && check(rect))
608                })
609            };
610            assert!(card(&|rect| rect.fill == theme.ui.panel_bg_floating));
611            assert!(card(&|rect| rect.stroke.color == theme.ui.border));
612            output.drop_without_applying_deltas();
613        }
614    }
615
616    /// Four notices show one card and a pill for the other three. Hidden notices keep their own
617    /// lifetimes, the pill expands the stack and reports that it did, dismiss-all leaves a notice
618    /// that cannot be dismissed, and a single notice has no controls.
619    #[test]
620    fn four_notices_collapse_behind_a_pill_that_expands_and_dismisses_them() {
621        use egui::accesskit::Role;
622        use egui_kittest::kittest::{NodeT, Queryable};
623        let mut toasts = Toasts::new();
624        for message in ["A", "B", "C"] {
625            toasts.add(Toast::info(message));
626        }
627        let mut newest =
628            Toast::info("Placed: Buy 1 AAPL at 100.00").with_title("Limit order placed on");
629        newest.trading = Some(super::super::TradingToast {
630            symbol: "AAPL".into(),
631            logo: None,
632            side: crate::model::OrderSide::Buy,
633            quantity: 1.0,
634            price: "100.00".into(),
635            executed: false,
636        });
637        toasts.add(newest);
638        let mut harness = egui_kittest::Harness::builder()
639            .with_context_setup(|ctx| {
640                egui_extras::install_image_loaders(ctx);
641                crate::theme::apply_to_egui(ctx, &crate::theme::Theme::dark());
642            })
643            .with_size(Vec2::new(1440.0, 900.0))
644            .build_ui_state(
645                |ui, toasts: &mut Toasts| {
646                    NotificationPanel::new().show_with_toasts(ui.ctx(), toasts);
647                },
648                toasts,
649            );
650        // The panel repaints while notices are up, so the harness never settles by itself.
651        harness.run_steps(3);
652        let pill = harness.get_by_role_and_label(Role::Button, "Show more 3");
653        assert_eq!(pill.accesskit_node().data().is_expanded(), Some(false));
654        harness.get_by_role_and_label(Role::Label, "Placed: Buy 1 AAPL at 100.00");
655        for hidden in ["A", "B", "C"] {
656            assert!(
657                harness.query_by_label(hidden).is_none(),
658                "{hidden} is hidden"
659            );
660        }
661
662        harness.state_mut().toasts_mut()[0].created_at -= 10.0;
663        harness.run_steps(3);
664        harness
665            .get_by_role_and_label(Role::Button, "Show more 2")
666            .click();
667        harness.run_steps(3);
668        let pill = harness.get_by_role_and_label(Role::Button, "Show less");
669        assert_eq!(pill.accesskit_node().data().is_expanded(), Some(true));
670        for shown in ["B", "C"] {
671            harness.get_by_role_and_label(Role::Label, shown);
672        }
673
674        harness
675            .state_mut()
676            .add(Toast::info("Kept").permanent().dismissible(false));
677        harness.run_steps(3);
678        harness
679            .get_by_role_and_label(Role::Button, "Dismiss all")
680            .click();
681        harness.run_steps(3);
682        assert_eq!(harness.state().len(), 1);
683        assert_eq!(harness.state().toasts()[0].message, "Kept");
684        for gone in ["Show less", "Show more 0", "Dismiss all"] {
685            assert!(
686                harness
687                    .query_by_role_and_label(Role::Button, gone)
688                    .is_none(),
689                "{gone} is gone"
690            );
691        }
692    }
693
694    /// Each toast is one live node named by its title and message, polite except for errors, and
695    /// the node goes with the toast when it is dismissed.
696    #[test]
697    fn each_toast_is_a_live_region_that_goes_with_the_toast() {
698        use egui::accesskit::Live;
699        use egui_kittest::kittest::{NodeT, Queryable};
700        let cases = [
701            (
702                Toast::info("Body").with_title("Title"),
703                "Title. Body",
704                Live::Polite,
705            ),
706            (Toast::success("Order filled"), "Order filled", Live::Polite),
707            (Toast::warning("Low margin"), "Low margin", Live::Polite),
708            (
709                Toast::error("Order rejected"),
710                "Order rejected",
711                Live::Assertive,
712            ),
713        ];
714        for (toast, label, live) in cases {
715            let mut toasts = Toasts::new();
716            toasts.add(toast.permanent());
717            let mut harness = egui_kittest::Harness::builder()
718                .with_context_setup(|ctx| {
719                    egui_extras::install_image_loaders(ctx);
720                    crate::theme::apply_to_egui(ctx, &crate::theme::Theme::dark());
721                })
722                .with_size(Vec2::new(1440.0, 900.0))
723                .build_ui_state(
724                    |ui, toasts: &mut Toasts| {
725                        NotificationPanel::new().show_with_toasts(ui.ctx(), toasts);
726                    },
727                    toasts,
728                );
729            harness.run_steps(3);
730            let announced = |harness: &egui_kittest::Harness<'_, Toasts>| {
731                harness
732                    .query_all_by_label(label)
733                    .filter_map(|node| node.accesskit_node().data().live())
734                    .collect::<Vec<_>>()
735            };
736            assert_eq!(announced(&harness), [live], "{label}");
737
738            harness.state_mut().clear();
739            harness.run_steps(3);
740            assert!(announced(&harness).is_empty(), "{label} is gone");
741        }
742    }
743
744    /// Toasts stack from the corner without moving the newest between the two states: expanded 8
745    /// points apart, collapsed with the controls 8 points beyond the card and the edge of the older
746    /// toast behind it, all on screen. A long message wraps short of the close button.
747    #[test]
748    fn toasts_stack_from_the_corner_collapsed_and_expanded() {
749        for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
750            for (width, panel_width) in [(1000.0, 340.0), (640.0, 200.0), (320.0, 340.0)] {
751                for (position, corner, direction) in [
752                    (None, Align2::LEFT_BOTTOM, -1.0),
753                    (
754                        Some(NotificationPosition::BottomRight),
755                        Align2::RIGHT_BOTTOM,
756                        -1.0,
757                    ),
758                    (Some(NotificationPosition::TopLeft), Align2::LEFT_TOP, 1.0),
759                    (Some(NotificationPosition::TopRight), Align2::RIGHT_TOP, 1.0),
760                ] {
761                    for (trading, expanded) in
762                        [(false, false), (false, true), (true, false), (true, true)]
763                    {
764                        let ctx = Context::default();
765                        crate::theme::apply_to_egui(&ctx, &theme);
766                        let screen =
767                            egui::Rect::from_min_size(egui::Pos2::ZERO, Vec2::new(width, 800.0));
768                        let mut toasts = Toasts::new();
769                        toasts.add(Toast::info("Chart layout saved").permanent());
770                        let long = "Order rejected: Insufficient buying power: 762450.00 USD needed, 100000.00 available";
771                        let title = "Limit order partially executed (1 of 3) on";
772                        let mut toast = Toast::error(long).permanent();
773                        if trading {
774                            toast = toast.with_title(title);
775                            toast.trading = Some(super::super::TradingToast {
776                                symbol: "LONG:SYMBOL".into(),
777                                logo: None,
778                                side: crate::model::OrderSide::Buy,
779                                quantity: 0.5,
780                                price: "123456789.00".into(),
781                                executed: true,
782                            });
783                        }
784                        toasts.add(toast);
785                        toasts.expanded = expanded;
786                        let mut wrap = f32::INFINITY;
787                        let edge = |newer: egui::Rect| {
788                            newer
789                                .shrink2(Vec2::new(8.0, 0.0))
790                                .translate(Vec2::new(0.0, -direction * 4.0))
791                        };
792                        let mut edge_drawn = false;
793                        for pass in 0..3 {
794                            let output = ctx.run_ui(
795                                egui::RawInput {
796                                    screen_rect: Some(screen),
797                                    ..Default::default()
798                                },
799                                |ui| {
800                                    let panel = NotificationPanel::new().width(panel_width);
801                                    let panel = position.map_or(panel, |position| {
802                                        NotificationPanel::new()
803                                            .width(panel_width)
804                                            .position(position)
805                                    });
806                                    panel.show_with_toasts(ui.ctx(), &mut toasts);
807                                },
808                            );
809                            for shape in &output.shapes {
810                                if let egui::Shape::Text(text) = &shape.shape {
811                                    if text.galley.text() == long {
812                                        wrap = text.pos.x + text.galley.job.wrap.max_width;
813                                    }
814                                    if pass == 2 {
815                                        let bounds = text.galley.rect.translate(text.pos.to_vec2());
816                                        assert!(
817                                            shape.clip_rect.expand(0.5).contains_rect(bounds),
818                                            "text is fully visible at {width}: {}",
819                                            text.galley.text()
820                                        );
821                                    }
822                                }
823                            }
824                            if trading && pass == 2 {
825                                assert!(output.shapes.iter().any(|shape| {
826                                matches!(&shape.shape, egui::Shape::Text(text) if text.galley.text() == title)
827                            }), "partial-fill progress remains visible");
828                            }
829                            if pass == 2 {
830                                let newer = ctx
831                                    .memory(|m| {
832                                        m.area_rect(
833                                            egui::Id::unique("toast").with(toasts.toasts()[1].id),
834                                        )
835                                    })
836                                    .unwrap();
837                                edge_drawn = output.shapes.iter().any(|shape| {
838                                    matches!(&shape.shape, egui::Shape::Vec(shapes) if shapes.iter().any(|shape| {
839                                        matches!(shape, egui::Shape::Rect(rect) if rect.rect == edge(newer))
840                                    }))
841                                });
842                            }
843                            output.drop_without_applying_deltas();
844                        }
845                        let rect = |toast: &Toast| {
846                            ctx.memory(|m| m.area_rect(egui::Id::unique("toast").with(toast.id)))
847                        };
848                        let (older, newer) = (rect(&toasts.toasts()[0]), rect(&toasts.toasts()[1]));
849                        let newer = newer.unwrap();
850                        assert_eq!(
851                            corner.pos_in_rect(&newer),
852                            corner.pos_in_rect(&screen.shrink(16.0))
853                        );
854                        let beyond = |far: egui::Rect| {
855                            if direction < 0.0 {
856                                newer.top() - far.bottom()
857                            } else {
858                                far.top() - newer.bottom()
859                            }
860                        };
861                        assert_eq!(edge_drawn, !expanded);
862                        if expanded {
863                            assert_eq!(beyond(older.unwrap()), 8.0);
864                        } else {
865                            assert_eq!(older, None);
866                            let controls = ctx
867                                .memory(|m| {
868                                    m.area_rect(egui::Id::unique("toast_stack").with(corner))
869                                })
870                                .unwrap();
871                            assert_eq!(beyond(controls), 8.0);
872                            assert!(screen.shrink(15.0).contains_rect(controls));
873                            assert!(
874                                (corner.pos_in_rect(&controls).x - corner.pos_in_rect(&newer).x)
875                                    .abs()
876                                    < 1.0
877                            );
878                            assert!(screen.shrink(12.0).contains_rect(edge(newer)));
879                        }
880                        assert!(screen.shrink(15.0).contains_rect(newer));
881                        if trading {
882                            assert!(newer.width() <= panel_width);
883                        }
884                        if !trading {
885                            let button = newer.right()
886                                - ctx.active_theme().spacing.lg
887                                - DESIGN_TOKENS.sizing.button_sm;
888                            assert!(
889                                wrap <= button,
890                                "the message wraps at {wrap}, past the close button at {button}"
891                            );
892                        }
893                    }
894                }
895            }
896        }
897    }
898}