1use 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#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)]
19pub enum NotificationPosition {
20 TopRight,
22 BottomRight,
24 #[default]
26 BottomLeft,
27 TopLeft,
29}
30
31impl NotificationPosition {
32 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 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
51pub struct NotificationPanelConfig {
53 pub position: NotificationPosition,
55 pub max_visible: usize,
57 pub width: f32,
59 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
74enum StackAction {
76 Toggle,
77 DismissAll,
78}
79
80pub struct NotificationPanel {
85 config: NotificationPanelConfig,
86}
87
88impl Default for NotificationPanel {
89 fn default() -> Self {
90 Self::new()
91 }
92}
93
94impl NotificationPanel {
95 pub fn new() -> Self {
97 Self {
98 config: NotificationPanelConfig::default(),
99 }
100 }
101
102 pub fn position(mut self, position: NotificationPosition) -> Self {
104 self.config.position = position;
105 self
106 }
107
108 pub fn max_visible(mut self, max: usize) -> Self {
111 self.config.max_visible = max;
112 self
113 }
114
115 pub fn width(mut self, width: f32) -> Self {
117 self.config.width = width;
118 self
119 }
120
121 pub fn show_with_toasts(&self, ctx: &Context, toasts: &mut Toasts) {
123 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 let (dismissed, action) = self.show_toasts(ctx, toasts.toasts(), toasts.expanded);
136
137 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 ctx.request_repaint();
154 }
155
156 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 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 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 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 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 y_offset += response.response.rect.height() + self.config.gap;
230
231 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 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 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 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 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 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 ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
526 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 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 ui.painter().rect_filled(rect, 1.0, theme.ui.border);
553
554 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 #[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 #[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 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 #[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 #[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}