1use std::rc::Rc;
35use teksilo_i18n::{LocalizedString, lit};
36
37use teksilo_canvas::{Rect, SizeProposal};
38use teksilo_core::accessibility::AccessNodeBuilder;
39use teksilo_core::binding::BindingLevel;
40use teksilo_core::build_context::BuildContext;
41use teksilo_core::overlay::{DismissBehavior, OverlayPlacement};
42use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
43use teksilo_core::widget_id::WidgetId;
44
45use teksilo_core::widget_builder::WidgetBuilder;
46use teksilo_tokens::Alignment;
47
48use crate::badge::Badge;
49use crate::icon_button::{IconButton, IconButtonSize};
50use crate::notification::log::NotificationLog;
51use crate::notification::{
52 ArchivedAction, NotificationArchiveModel, NotificationEntry, route_visible,
53};
54use crate::popover_widget::PopoverIconButton;
55use crate::primitives::ZStack;
56use crate::toast::{ToastAudience, ToastRoute};
57use teksilo_core::window::TeksiloWindowId;
58
59pub struct NotificationCenterButton {
64 archive: Rc<NotificationArchiveModel>,
65 size: IconButtonSize,
66 show_badge_when_zero: bool,
67 max_badge_count: u32,
68 placement: OverlayPlacement,
69 on_action_invoked: Option<Rc<dyn Fn(&NotificationEntry, &ArchivedAction, &mut EventContext)>>,
70 root_child_id: Option<WidgetId>,
71 tooltip_text: Option<LocalizedString>,
75 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
78 composite_tooltip_content: Option<Box<dyn Widget>>,
81 route_scope: Option<ToastRoute>,
88}
89
90impl NotificationCenterButton {
91 pub fn new(archive: Rc<NotificationArchiveModel>) -> Self {
94 Self {
95 archive,
96 size: IconButtonSize::Toolbar,
97 show_badge_when_zero: false,
98 max_badge_count: 99,
99 placement: OverlayPlacement::BelowPreferred,
100 on_action_invoked: None,
101 root_child_id: None,
102 tooltip_text: None,
103 rich_tooltip_source: None,
104 composite_tooltip_content: None,
105 route_scope: None,
106 }
107 }
108
109 pub fn for_window(mut self, window_id: TeksiloWindowId) -> Self {
114 self.route_scope = Some(ToastRoute::Window(window_id));
115 self
116 }
117
118 pub fn for_audience(mut self, audience: ToastAudience) -> Self {
123 self.route_scope = Some(ToastRoute::Audience(audience));
124 self
125 }
126
127 pub fn size(mut self, size: IconButtonSize) -> Self {
130 self.size = size;
131 self
132 }
133
134 pub fn show_badge_when_zero(mut self, show: bool) -> Self {
138 self.show_badge_when_zero = show;
139 self
140 }
141
142 pub fn max_badge_count(mut self, max: u32) -> Self {
146 self.max_badge_count = max;
147 self
148 }
149
150 pub fn placement(mut self, p: OverlayPlacement) -> Self {
154 self.placement = p;
155 self
156 }
157
158 pub fn on_action_invoked(
163 mut self,
164 f: impl Fn(&NotificationEntry, &ArchivedAction, &mut EventContext) + 'static,
165 ) -> Self {
166 self.on_action_invoked = Some(Rc::new(f));
167 self
168 }
169
170 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
177 self.tooltip_text = Some(text.into());
178 self.rich_tooltip_source = None;
179 self.composite_tooltip_content = None;
180 self
181 }
182
183 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
189 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
190 self.tooltip_text = None;
191 self.composite_tooltip_content = None;
192 self
193 }
194
195 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
201 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
202 self.tooltip_text = None;
203 self.composite_tooltip_content = None;
204 self
205 }
206
207 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
213 self.composite_tooltip_content = Some(Box::new(content));
214 self.tooltip_text = None;
215 self.rich_tooltip_source = None;
216 self
217 }
218}
219
220impl std::fmt::Debug for NotificationCenterButton {
221 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222 f.debug_struct("NotificationCenterButton")
223 .field("size", &self.size)
224 .field("show_badge_when_zero", &self.show_badge_when_zero)
225 .field("placement", &self.placement)
226 .finish_non_exhaustive()
227 }
228}
229
230impl Widget for NotificationCenterButton {
231 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
232 let archive = self.archive.clone();
233 let max_badge = self.max_badge_count;
234 let show_when_zero = self.show_badge_when_zero;
235 let scope = self.route_scope;
236
237 let trigger = IconButton::bell().size(self.size);
245
246 let mut log = NotificationLog::new(archive.clone());
251 log = match scope {
252 Some(ToastRoute::Window(w)) => log.for_window(w),
253 Some(ToastRoute::Audience(a)) => log.for_audience(a),
254 Some(ToastRoute::Broadcast) | None => log,
255 };
256 if let Some(cb) = self.on_action_invoked.clone() {
257 log = log.on_action_invoked(move |e, a, ctx| cb(e, a, ctx));
258 }
259
260 let archive_for_close = archive.clone();
271 let pib = PopoverIconButton::new(trigger)
272 .content(log)
273 .placement(self.placement.clone())
274 .dismiss_behavior(DismissBehavior::EscapeOrClickOutside)
275 .on_close(move || match scope {
276 Some(s) => archive_for_close.mark_read_where(|e| route_visible(e.route, Some(s))),
277 None => archive_for_close.mark_all_read(),
278 });
279 let pib_id = ctx.add(pib);
280
281 let badge_id = ctx.add(
294 UnreadBadge {
295 archive: archive.clone(),
296 scope,
297 max_badge,
298 show_when_zero,
299 child: None,
300 }
301 .hit_transparent(true),
302 );
303 let root = ctx.add(
304 ZStack::new()
305 .alignment(Alignment::TOP_TRAILING)
306 .child(pib_id)
307 .child(badge_id),
308 );
309
310 if let Some(content) = self.composite_tooltip_content.take() {
315 let delay = ctx.theme().motion.tooltip_delay_heavy;
316 crate::tooltip::attach_composite_tooltip_boxed(ctx, root, content, delay);
317 } else if let Some(source) = self.rich_tooltip_source.clone() {
318 let delay = ctx.theme().motion.tooltip_delay;
319 crate::tooltip::attach_rich_tooltip_source(ctx, root, source, delay);
320 } else if let Some(text) = self.tooltip_text.clone() {
321 let delay = ctx.theme().motion.tooltip_delay;
322 crate::tooltip::attach_plain_tooltip(ctx, root, text, delay);
323 }
324
325 self.root_child_id = Some(root);
326 vec![root]
327 }
328
329 fn layout_response(
330 &self,
331 proposal: SizeProposal,
332 ctx: &LayoutContext,
333 ) -> teksilo_core::widget::LayoutResponse {
334 self.root_child_id
335 .and_then(|id| ctx.child_size(id, proposal))
336 .unwrap_or_else(|| proposal.resolve(30.0, 30.0))
337 .into()
338 }
339
340 fn place_children(
341 &self,
342 bounds: Rect,
343 _proposal: SizeProposal,
344 children: &mut [WidgetPlacement],
345 _ctx: &LayoutContext,
346 ) {
347 for child in children.iter_mut() {
348 child.origin = bounds.origin();
349 child.size = bounds.size();
350 }
351 }
352
353 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
354 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
359 }
360
361 fn children(&self) -> Vec<WidgetId> {
362 self.root_child_id.into_iter().collect()
363 }
364}
365
366#[derive(Debug)]
369struct UnreadBadge {
370 archive: Rc<NotificationArchiveModel>,
371 scope: Option<ToastRoute>,
372 max_badge: u32,
373 show_when_zero: bool,
374 child: Option<WidgetId>,
375}
376
377impl Widget for UnreadBadge {
378 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
379 self.archive.version_signal().bind_to(
389 ctx.self_id(),
390 ctx.binding_registry(),
391 BindingLevel::Rebuild,
392 );
393
394 let model = self.archive.entries();
399 let scope = self.scope;
400 let unread_count = (0..model.len())
401 .filter(|&i| {
402 model
403 .with_item(i, |e| !e.read && route_visible(e.route, scope))
404 .unwrap_or(false)
405 })
406 .count();
407 self.child = (unread_count > 0 || self.show_when_zero).then(|| {
410 let label = if unread_count > self.max_badge as usize {
411 format!("{}+", self.max_badge)
412 } else {
413 unread_count.to_string()
414 };
415 ctx.add(Badge::new(lit!(label)))
416 });
417 self.child.into_iter().collect()
418 }
419
420 fn layout_response(
421 &self,
422 proposal: SizeProposal,
423 ctx: &LayoutContext,
424 ) -> teksilo_core::widget::LayoutResponse {
425 self.child
427 .and_then(|id| ctx.child_size(id, proposal))
428 .unwrap_or_default()
429 .into()
430 }
431
432 fn place_children(
433 &self,
434 bounds: Rect,
435 _proposal: SizeProposal,
436 children: &mut [WidgetPlacement],
437 _ctx: &LayoutContext,
438 ) {
439 for child in children.iter_mut() {
440 child.origin = bounds.origin();
441 child.size = bounds.size();
442 }
443 }
444
445 fn children(&self) -> Vec<WidgetId> {
446 self.child.into_iter().collect()
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453 use crate::notification::NotificationEntry;
454 use teksilo_core::styles::{BannerSeverity, ToastPriority};
455 use teksilo_core::widget_tree::WidgetTree;
456
457 fn entry(title: &str) -> NotificationEntry {
458 entry_with_route(title, ToastRoute::Broadcast)
459 }
460
461 fn entry_with_route(title: &str, route: ToastRoute) -> NotificationEntry {
462 NotificationEntry {
463 id: 0,
464 severity: BannerSeverity::Info,
465 priority: ToastPriority::Normal,
466 title: title.to_string(),
467 body: None,
468 actions: Vec::new(),
469 timestamp: jiff::Timestamp::UNIX_EPOCH,
470 group: None,
471 source: None,
472 read: false,
473 dedup_id: None,
474 updates: Vec::new(),
475 route,
476 }
477 }
478
479 fn tree_with(btn: NotificationCenterButton) -> WidgetTree {
480 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
481 tree.add(btn);
482 tree.layout(SizeProposal::exact(120.0, 60.0));
483 tree
484 }
485
486 #[test]
487 fn bell_label_present() {
488 let archive = Rc::new(NotificationArchiveModel::in_memory());
489 let tree = tree_with(NotificationCenterButton::new(archive));
490 let bell_label = teksilo_i18n::tr_widget!(a11y_builtin_bell()).resolve_now();
491 assert!(
492 tree.find_by_label(&bell_label).is_some(),
493 "bell tooltip / label present in the AT tree"
494 );
495 }
496
497 #[test]
498 fn badge_appears_when_unread_count_grows() {
499 let archive = Rc::new(NotificationArchiveModel::in_memory());
500 archive.push(entry("a"));
504 archive.push(entry("b"));
505 assert_eq!(archive.unread_count().get(), 2);
506 let tree = tree_with(NotificationCenterButton::new(archive));
507 assert!(
508 tree.find_by_label("2").is_some(),
509 "badge with count '2' renders when unread_count > 0"
510 );
511 }
512
513 fn bell_popover_open_check(with_toast_host: bool, unread: usize) -> (usize, usize) {
522 use crate::primitives::{Expand, FixedSize, Spacer, VStack, ZStack};
523 use crate::toast::{ToastHost, ToastInstallOptions, ToastRegistry};
524
525 let archive = Rc::new(NotificationArchiveModel::in_memory());
526 for i in 0..unread {
527 archive.push(entry(&format!("n{i}")));
528 }
529
530 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
531
532 let spacer = tree.add(FixedSize::new().height(500.0).child(Spacer::new()));
534 let bell = tree.add(NotificationCenterButton::new(archive.clone()));
535 let user_root = tree.add(VStack::new().child(spacer).child(bell));
536
537 if with_toast_host {
538 let opts = ToastInstallOptions {
540 archive: None,
541 ..ToastInstallOptions::default()
542 };
543 let registry = ToastRegistry::new(opts.clone());
544 let filled = tree.add(Expand::new().respect_intrinsic().child(user_root));
545 let host = tree.add(ToastHost::new(registry, opts));
546 tree.add(ZStack::new().child(filled).child(host));
547 }
548
549 tree.layout(SizeProposal::exact(400.0, 600.0));
550
551 let before = tree.active_overlays().len();
552 tree.click(bell);
553 tree.layout(SizeProposal::exact(400.0, 600.0));
554 let after = tree.active_overlays().len();
555 (before, after)
556 }
557
558 #[test]
559 fn bell_popover_opens_with_no_unread() {
560 let (before, after) = bell_popover_open_check(false, 0);
562 assert_eq!(after, before + 1, "popover should open (no badge)");
563 }
564
565 #[test]
572 fn in_content_action_does_not_orphan_overlay() {
573 use crate::primitives::{FixedSize, Spacer, VStack};
574 let archive = Rc::new(NotificationArchiveModel::in_memory());
575 for i in 0..3 {
576 archive.push(entry(&format!("n{i}")));
577 }
578 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
579 let spacer = tree.add(FixedSize::new().height(500.0).child(Spacer::new()));
580 let bell = tree.add(NotificationCenterButton::new(archive.clone()));
581 tree.add(VStack::new().child(spacer).child(bell));
582 tree.layout(SizeProposal::exact(400.0, 600.0));
583
584 tree.click(bell);
585 tree.layout(SizeProposal::exact(400.0, 600.0));
586 assert_eq!(tree.active_overlays().len(), 1, "popover should be open");
587
588 archive.mark_all_read();
590 tree.layout(SizeProposal::exact(400.0, 600.0));
591 assert_eq!(
592 tree.active_overlays().len(),
593 1,
594 "the in-content action leaves the popover open"
595 );
596
597 tree.press_key(
598 teksilo_core::event::Key::Escape,
599 teksilo_core::event::Modifiers::NONE,
600 );
601 tree.layout(SizeProposal::exact(400.0, 600.0));
602 assert_eq!(
603 tree.active_overlays().len(),
604 0,
605 "and the popover still answers Escape (not left as an invisible \
606 click-blocker)"
607 );
608 }
609
610 #[test]
628 fn two_windowless_trees_both_rebuild_on_one_archive_push() {
629 use crate::primitives::{FixedSize, Spacer, VStack};
630
631 let archive = Rc::new(NotificationArchiveModel::in_memory());
632
633 let window = |_| {
634 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
635 let spacer = tree.add(FixedSize::new().height(500.0).child(Spacer::new()));
636 let bell = tree.add(NotificationCenterButton::new(archive.clone()));
637 tree.add(VStack::new().child(spacer).child(bell));
638 tree.layout(SizeProposal::exact(400.0, 600.0));
639 tree.render();
640 tree
641 };
642 let (mut a, mut b) = (window(()), window(()));
643 assert!(!a.needs_render() && !b.needs_render(), "both start clean");
644
645 archive.push(entry("from somewhere"));
647 a.layout(SizeProposal::exact(400.0, 600.0));
648 assert!(a.needs_render(), "window A's bell must rebuild");
649 b.layout(SizeProposal::exact(400.0, 600.0));
650 assert!(
651 b.needs_render(),
652 "window B's bell must rebuild too — A's reconcile consumed nothing"
653 );
654 a.render();
655 b.render();
656
657 archive.push(entry("and again"));
659 b.layout(SizeProposal::exact(400.0, 600.0));
660 assert!(b.needs_render(), "window B first this time");
661 a.layout(SizeProposal::exact(400.0, 600.0));
662 assert!(a.needs_render(), "and window A still follows");
663 }
664
665 #[test]
666 fn bell_popover_opens_with_unread_badge() {
667 let (before, after) = bell_popover_open_check(false, 3);
670 assert_eq!(
671 after,
672 before + 1,
673 "popover must open even with an unread badge"
674 );
675 }
676
677 #[test]
678 fn bell_popover_opens_under_toast_host_with_badge() {
679 let (before, after) = bell_popover_open_check(true, 3);
680 assert_eq!(
681 after,
682 before + 1,
683 "popover must open under the toast host, with a badge"
684 );
685 }
686
687 #[test]
688 fn badge_caps_at_max_count() {
689 let archive = Rc::new(NotificationArchiveModel::in_memory());
690 for i in 0..150 {
691 archive.push(entry(&format!("t{i}")));
692 }
693 assert_eq!(archive.unread_count().get(), 150);
694 let tree = tree_with(NotificationCenterButton::new(archive).max_badge_count(99));
695 assert!(
696 tree.find_by_label("99+").is_some(),
697 "badge caps at '99+' for counts above max"
698 );
699 }
700
701 #[test]
702 fn scoped_bell_only_counts_its_audience_and_broadcast_unread() {
703 use crate::toast::ToastAudience;
704
705 let archive = Rc::new(NotificationArchiveModel::in_memory());
706 let audience_a = ToastAudience::new(1);
707 let audience_b = ToastAudience::new(2);
708
709 archive.push(entry_with_route("for a", ToastRoute::Audience(audience_a)));
710 archive.push(entry_with_route("for b", ToastRoute::Audience(audience_b)));
711 archive.push(entry_with_route(
712 "for b again",
713 ToastRoute::Audience(audience_b),
714 ));
715 archive.push(entry_with_route("everyone", ToastRoute::Broadcast));
716 assert_eq!(
717 archive.unread_count().get(),
718 4,
719 "the shared archive's global counter sees all four"
720 );
721
722 let tree_a = tree_with(
728 NotificationCenterButton::new(archive.clone()).for_window(TeksiloWindowId::new(1)),
729 );
730 assert!(
731 tree_a.find_by_label("1").is_some(),
732 "no entry is routed to Window(1); only the broadcast one should count"
733 );
734
735 let tree_scoped_a =
737 tree_with(NotificationCenterButton::new(archive.clone()).for_audience(audience_a));
738 assert!(
739 tree_scoped_a.find_by_label("2").is_some(),
740 "audience A's bell counts its own entry plus the broadcast one"
741 );
742
743 let tree_scoped_b =
746 tree_with(NotificationCenterButton::new(archive.clone()).for_audience(audience_b));
747 assert!(
748 tree_scoped_b.find_by_label("3").is_some(),
749 "audience B's bell counts both of its own entries plus the broadcast one"
750 );
751
752 let tree_unscoped = tree_with(NotificationCenterButton::new(archive));
755 assert!(
756 tree_unscoped.find_by_label("4").is_some(),
757 "an unscoped bell keeps the old 'see everything' behaviour"
758 );
759 }
760
761 #[test]
774 fn scoped_bell_reflects_toasts_presented_through_the_real_registry_pipeline() {
775 use crate::toast::host::ToastInstallOptions;
776 use crate::toast::{Toast, ToastAudience, ToastRegistry};
777
778 let archive = Rc::new(NotificationArchiveModel::in_memory());
779 let registry = ToastRegistry::with_archive(
780 ToastInstallOptions {
781 archive: None,
782 ..ToastInstallOptions::default()
783 },
784 archive.clone(),
785 );
786 let audience_a = ToastAudience::new(1);
787 let audience_b = ToastAudience::new(2);
788
789 registry.enqueue(Toast::info(lit!("for a")).target(audience_a));
790 registry.enqueue(Toast::info(lit!("for b")).target(audience_b));
791 registry.enqueue(Toast::warning(lit!("everyone")).broadcast());
792 assert_eq!(
793 archive.unread_count().get(),
794 3,
795 "all three toasts were mirrored into the shared archive"
796 );
797
798 let tree_a =
799 tree_with(NotificationCenterButton::new(archive.clone()).for_audience(audience_a));
800 assert!(
801 tree_a.find_by_label("2").is_some(),
802 "audience A's bell must count its own real toast plus the broadcast one \
803 (2), excluding B's — not 3 (everything) and not 1 (missing the broadcast)"
804 );
805
806 let tree_b = tree_with(NotificationCenterButton::new(archive).for_audience(audience_b));
807 assert!(
808 tree_b.find_by_label("2").is_some(),
809 "audience B's bell must count its own real toast plus the broadcast one, \
810 excluding A's"
811 );
812 }
813
814 #[test]
815 fn scoped_bell_close_only_marks_its_own_entries_read() {
816 use crate::primitives::{FixedSize, Spacer, VStack};
817 use crate::toast::ToastAudience;
818
819 let archive = Rc::new(NotificationArchiveModel::in_memory());
820 let audience_a = ToastAudience::new(1);
821 let audience_b = ToastAudience::new(2);
822 archive.push(entry_with_route("for a", ToastRoute::Audience(audience_a)));
823 archive.push(entry_with_route("for b", ToastRoute::Audience(audience_b)));
824 assert_eq!(archive.unread_count().get(), 2);
825
826 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
834 let spacer = tree.add(FixedSize::new().height(500.0).child(Spacer::new()));
835 let bell =
836 tree.add(NotificationCenterButton::new(archive.clone()).for_audience(audience_a));
837 tree.add(VStack::new().child(spacer).child(bell));
838 tree.layout(SizeProposal::exact(400.0, 600.0));
839
840 tree.click(bell);
843 tree.layout(SizeProposal::exact(400.0, 600.0));
844 tree.click(bell); tree.layout(SizeProposal::exact(400.0, 600.0));
846
847 assert_eq!(
848 archive.unread_count().get(),
849 1,
850 "only audience A's entry was marked read; audience B's stays unread"
851 );
852 }
853
854 fn two_window_bells(archive: Rc<NotificationArchiveModel>) -> (WidgetTree, WidgetTree) {
870 use teksilo_core::window::state::WindowStateInit;
871 use teksilo_core::window::{WindowPlacement, WindowState};
872
873 let build_window = |window_id: u64| {
874 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
875 tree.set_window_state(WindowState::new(WindowStateInit {
876 id: TeksiloWindowId::new(window_id),
877 string_id: Some(format!("w{window_id}")),
878 placement: WindowPlacement::Floating,
879 title: "Test".to_string(),
880 size: (400, 600),
881 position: (0, 0),
882 focused: false,
883 resizable: true,
884 always_on_top: false,
885 }));
886 tree.add(NotificationCenterButton::new(archive.clone()));
887 tree.layout(SizeProposal::exact(400.0, 600.0));
888 tree
889 };
890
891 (build_window(1), build_window(2))
892 }
893
894 #[test]
900 fn both_unscoped_bells_pick_up_a_badge_change_regardless_of_reconcile_order() {
901 let archive = Rc::new(NotificationArchiveModel::in_memory());
902 let (mut tree1, mut tree2) = two_window_bells(archive.clone());
903 assert!(tree1.find_by_label("1").is_none());
904 assert!(tree2.find_by_label("1").is_none());
905
906 archive.push(entry("new"));
907
908 tree1.layout(SizeProposal::exact(400.0, 600.0));
910 assert!(
911 tree1.find_by_label("1").is_some(),
912 "window 1's bell must show the new unread badge"
913 );
914 tree2.layout(SizeProposal::exact(400.0, 600.0));
918 assert!(
919 tree2.find_by_label("1").is_some(),
920 "window 2's bell must ALSO show the badge, even reconciling second"
921 );
922 }
923
924 #[test]
927 fn both_unscoped_bells_pick_up_a_badge_change_in_the_reverse_reconcile_order_too() {
928 let archive = Rc::new(NotificationArchiveModel::in_memory());
929 let (mut tree1, mut tree2) = two_window_bells(archive.clone());
930
931 archive.push(entry("new"));
932
933 tree2.layout(SizeProposal::exact(400.0, 600.0));
934 assert!(
935 tree2.find_by_label("1").is_some(),
936 "window 2's bell must show the badge when it reconciles first"
937 );
938 tree1.layout(SizeProposal::exact(400.0, 600.0));
939 assert!(
940 tree1.find_by_label("1").is_some(),
941 "window 1's bell must ALSO show it, even reconciling second"
942 );
943 }
944
945 #[test]
946 fn tooltip_appears_on_hover() {
947 let archive = Rc::new(NotificationArchiveModel::in_memory());
948 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
949 let id = tree.add(NotificationCenterButton::new(archive).tooltip(lit!("Tip")));
950 tree.layout(SizeProposal::exact(300.0, 200.0));
951 tree.pointer_move(tree.bounds(id).center());
952 tree.advance_time(std::time::Duration::from_secs(1));
953 assert_eq!(
954 tree.active_overlays().len(),
955 1,
956 "tooltip should appear on hover"
957 );
958 assert!(tree.find_by_label("Tip").is_some());
959 }
960}