1use std::rc::Rc;
33use teksilo_i18n::{LocalizedString, lit};
34
35use teksilo_canvas::{Rect, SizeProposal};
36use teksilo_core::accessibility::AccessNodeBuilder;
37use teksilo_core::binding::BindingLevel;
38use teksilo_core::build_context::BuildContext;
39use teksilo_core::overlay::{DismissBehavior, OverlayPlacement};
40use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
41use teksilo_core::widget_id::WidgetId;
42
43use teksilo_core::widget_builder::WidgetBuilder;
44use teksilo_tokens::Alignment;
45
46use crate::badge::Badge;
47use crate::icon_button::{IconButton, IconButtonSize};
48use crate::notification::log::NotificationLog;
49use crate::notification::{
50 ArchivedAction, NotificationArchiveModel, NotificationEntry, route_visible,
51};
52use crate::popover_widget::PopoverIconButton;
53use crate::primitives::ZStack;
54use crate::toast::{ToastAudience, ToastRoute};
55use teksilo_core::window::TeksiloWindowId;
56
57pub struct NotificationCenterButton {
62 archive: Rc<NotificationArchiveModel>,
63 size: IconButtonSize,
64 show_badge_when_zero: bool,
65 max_badge_count: u32,
66 placement: OverlayPlacement,
67 on_action_invoked: Option<Rc<dyn Fn(&NotificationEntry, &ArchivedAction, &mut EventContext)>>,
68 root_child_id: Option<WidgetId>,
69 tooltip_text: Option<LocalizedString>,
73 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
76 composite_tooltip_content: Option<Box<dyn Widget>>,
79 route_scope: Option<ToastRoute>,
86}
87
88impl NotificationCenterButton {
89 pub fn new(archive: Rc<NotificationArchiveModel>) -> Self {
92 Self {
93 archive,
94 size: IconButtonSize::Toolbar,
95 show_badge_when_zero: false,
96 max_badge_count: 99,
97 placement: OverlayPlacement::BelowPreferred,
98 on_action_invoked: None,
99 root_child_id: None,
100 tooltip_text: None,
101 rich_tooltip_source: None,
102 composite_tooltip_content: None,
103 route_scope: None,
104 }
105 }
106
107 pub fn for_window(mut self, window_id: TeksiloWindowId) -> Self {
112 self.route_scope = Some(ToastRoute::Window(window_id));
113 self
114 }
115
116 pub fn for_audience(mut self, audience: ToastAudience) -> Self {
121 self.route_scope = Some(ToastRoute::Audience(audience));
122 self
123 }
124
125 pub fn size(mut self, size: IconButtonSize) -> Self {
128 self.size = size;
129 self
130 }
131
132 pub fn show_badge_when_zero(mut self, show: bool) -> Self {
136 self.show_badge_when_zero = show;
137 self
138 }
139
140 pub fn max_badge_count(mut self, max: u32) -> Self {
144 self.max_badge_count = max;
145 self
146 }
147
148 pub fn placement(mut self, p: OverlayPlacement) -> Self {
152 self.placement = p;
153 self
154 }
155
156 pub fn on_action_invoked(
161 mut self,
162 f: impl Fn(&NotificationEntry, &ArchivedAction, &mut EventContext) + 'static,
163 ) -> Self {
164 self.on_action_invoked = Some(Rc::new(f));
165 self
166 }
167
168 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
175 self.tooltip_text = Some(text.into());
176 self.rich_tooltip_source = None;
177 self.composite_tooltip_content = None;
178 self
179 }
180
181 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
187 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
188 self.tooltip_text = None;
189 self.composite_tooltip_content = None;
190 self
191 }
192
193 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
199 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
200 self.tooltip_text = None;
201 self.composite_tooltip_content = None;
202 self
203 }
204
205 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
211 self.composite_tooltip_content = Some(Box::new(content));
212 self.tooltip_text = None;
213 self.rich_tooltip_source = None;
214 self
215 }
216}
217
218impl std::fmt::Debug for NotificationCenterButton {
219 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220 f.debug_struct("NotificationCenterButton")
221 .field("size", &self.size)
222 .field("show_badge_when_zero", &self.show_badge_when_zero)
223 .field("placement", &self.placement)
224 .finish_non_exhaustive()
225 }
226}
227
228impl Widget for NotificationCenterButton {
229 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
230 let archive = self.archive.clone();
231 let max_badge = self.max_badge_count;
232 let show_when_zero = self.show_badge_when_zero;
233 let scope = self.route_scope;
234
235 archive.version_signal().bind_to(
247 ctx.self_id(),
248 ctx.binding_registry(),
249 BindingLevel::Rebuild,
250 );
251
252 let trigger = IconButton::bell().size(self.size);
254
255 let mut log = NotificationLog::new(archive.clone());
260 log = match scope {
261 Some(ToastRoute::Window(w)) => log.for_window(w),
262 Some(ToastRoute::Audience(a)) => log.for_audience(a),
263 Some(ToastRoute::Broadcast) | None => log,
264 };
265 if let Some(cb) = self.on_action_invoked.clone() {
266 log = log.on_action_invoked(move |e, a, ctx| cb(e, a, ctx));
267 }
268
269 let archive_for_close = archive.clone();
286 let pib = PopoverIconButton::new(trigger)
287 .content(log)
288 .placement(self.placement.clone())
289 .dismiss_behavior(DismissBehavior::EscapeOrClickOutside)
290 .on_close(move || match scope {
291 Some(s) => archive_for_close.mark_read_where(|e| route_visible(e.route, Some(s))),
292 None => archive_for_close.mark_all_read(),
293 });
294 let pib_id = ctx.add(pib);
295
296 let model = archive.entries();
302 let unread_count = (0..model.len())
303 .filter(|&i| {
304 model
305 .with_item(i, |e| !e.read && route_visible(e.route, scope))
306 .unwrap_or(false)
307 })
308 .count();
309 let label = if unread_count == 0 {
310 String::new()
311 } else if unread_count > max_badge as usize {
312 format!("{max_badge}+")
313 } else {
314 unread_count.to_string()
315 };
316
317 let mut stack = ZStack::new()
332 .alignment(Alignment::TOP_TRAILING)
333 .child(pib_id);
334 if unread_count > 0 || show_when_zero {
335 let badge_id = ctx.add(Badge::new(lit!(label)).hit_transparent(true));
336 stack = stack.child(badge_id);
337 }
338 let root = ctx.add(stack);
339
340 if let Some(content) = self.composite_tooltip_content.take() {
345 let delay = ctx.theme().motion.tooltip_delay_heavy;
346 crate::tooltip::attach_composite_tooltip_boxed(ctx, root, content, delay);
347 } else if let Some(source) = self.rich_tooltip_source.clone() {
348 let delay = ctx.theme().motion.tooltip_delay;
349 crate::tooltip::attach_rich_tooltip_source(ctx, root, source, delay);
350 } else if let Some(text) = self.tooltip_text.clone() {
351 let delay = ctx.theme().motion.tooltip_delay;
352 crate::tooltip::attach_plain_tooltip(ctx, root, text, delay);
353 }
354
355 self.root_child_id = Some(root);
356 vec![root]
357 }
358
359 fn layout_response(
360 &self,
361 proposal: SizeProposal,
362 ctx: &LayoutContext,
363 ) -> teksilo_core::widget::LayoutResponse {
364 self.root_child_id
365 .and_then(|id| ctx.child_size(id, proposal))
366 .unwrap_or_else(|| proposal.resolve(30.0, 30.0))
367 .into()
368 }
369
370 fn place_children(
371 &self,
372 bounds: Rect,
373 _proposal: SizeProposal,
374 children: &mut [WidgetPlacement],
375 _ctx: &LayoutContext,
376 ) {
377 for child in children.iter_mut() {
378 child.origin = bounds.origin();
379 child.size = bounds.size();
380 }
381 }
382
383 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
384 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
387 builder.set_hidden();
388 }
389
390 fn children(&self) -> Vec<WidgetId> {
391 self.root_child_id.into_iter().collect()
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use crate::notification::NotificationEntry;
399 use teksilo_core::styles::{BannerSeverity, ToastPriority};
400 use teksilo_core::widget_tree::WidgetTree;
401
402 fn entry(title: &str) -> NotificationEntry {
403 entry_with_route(title, ToastRoute::Broadcast)
404 }
405
406 fn entry_with_route(title: &str, route: ToastRoute) -> NotificationEntry {
407 NotificationEntry {
408 id: 0,
409 severity: BannerSeverity::Info,
410 priority: ToastPriority::Normal,
411 title: title.to_string(),
412 body: None,
413 actions: Vec::new(),
414 timestamp: jiff::Timestamp::UNIX_EPOCH,
415 group: None,
416 source: None,
417 read: false,
418 dedup_id: None,
419 updates: Vec::new(),
420 route,
421 }
422 }
423
424 fn tree_with(btn: NotificationCenterButton) -> WidgetTree {
425 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
426 tree.add(btn);
427 tree.layout(SizeProposal::exact(120.0, 60.0));
428 tree
429 }
430
431 #[test]
432 fn bell_label_present() {
433 let archive = Rc::new(NotificationArchiveModel::in_memory());
434 let tree = tree_with(NotificationCenterButton::new(archive));
435 let bell_label = teksilo_i18n::tr_widget!(a11y_builtin_bell()).resolve_now();
436 assert!(
437 tree.find_by_label(&bell_label).is_some(),
438 "bell tooltip / label present in the AT tree"
439 );
440 }
441
442 #[test]
443 fn badge_appears_when_unread_count_grows() {
444 let archive = Rc::new(NotificationArchiveModel::in_memory());
445 archive.push(entry("a"));
449 archive.push(entry("b"));
450 assert_eq!(archive.unread_count().get(), 2);
451 let tree = tree_with(NotificationCenterButton::new(archive));
452 assert!(
453 tree.find_by_label("2").is_some(),
454 "badge with count '2' renders when unread_count > 0"
455 );
456 }
457
458 fn bell_popover_open_check(with_toast_host: bool, unread: usize) -> (usize, usize) {
467 use crate::primitives::{Expand, FixedSize, Spacer, VStack, ZStack};
468 use crate::toast::{ToastHost, ToastInstallOptions, ToastRegistry};
469
470 let archive = Rc::new(NotificationArchiveModel::in_memory());
471 for i in 0..unread {
472 archive.push(entry(&format!("n{i}")));
473 }
474
475 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
476
477 let spacer = tree.add(FixedSize::new().height(500.0).child(Spacer::new()));
479 let bell = tree.add(NotificationCenterButton::new(archive.clone()));
480 let user_root = tree.add(VStack::new().child(spacer).child(bell));
481
482 if with_toast_host {
483 let opts = ToastInstallOptions {
485 archive: None,
486 ..ToastInstallOptions::default()
487 };
488 let registry = ToastRegistry::new(opts.clone());
489 let filled = tree.add(Expand::new().respect_intrinsic().child(user_root));
490 let host = tree.add(ToastHost::new(registry, opts));
491 tree.add(ZStack::new().child(filled).child(host));
492 }
493
494 tree.layout(SizeProposal::exact(400.0, 600.0));
495
496 let before = tree.active_overlays().len();
497 tree.click(bell);
498 tree.layout(SizeProposal::exact(400.0, 600.0));
499 let after = tree.active_overlays().len();
500 (before, after)
501 }
502
503 #[test]
504 fn bell_popover_opens_with_no_unread() {
505 let (before, after) = bell_popover_open_check(false, 0);
507 assert_eq!(after, before + 1, "popover should open (no badge)");
508 }
509
510 #[test]
515 fn in_content_action_does_not_orphan_overlay() {
516 use crate::primitives::{FixedSize, Spacer, VStack};
517 let archive = Rc::new(NotificationArchiveModel::in_memory());
518 for i in 0..3 {
519 archive.push(entry(&format!("n{i}")));
520 }
521 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
522 let spacer = tree.add(FixedSize::new().height(500.0).child(Spacer::new()));
523 let bell = tree.add(NotificationCenterButton::new(archive.clone()));
524 tree.add(VStack::new().child(spacer).child(bell));
525 tree.layout(SizeProposal::exact(400.0, 600.0));
526
527 tree.click(bell);
528 tree.layout(SizeProposal::exact(400.0, 600.0));
529 assert_eq!(tree.active_overlays().len(), 1, "popover should be open");
530
531 archive.mark_all_read();
533 tree.layout(SizeProposal::exact(400.0, 600.0));
534 assert_eq!(
535 tree.active_overlays().len(),
536 0,
537 "overlay must be dismissed (not left as an invisible click-blocker) \
538 after the in-content action rebuilds the bell"
539 );
540 }
541
542 #[test]
560 fn two_windowless_trees_both_rebuild_on_one_archive_push() {
561 use crate::primitives::{FixedSize, Spacer, VStack};
562
563 let archive = Rc::new(NotificationArchiveModel::in_memory());
564
565 let window = |_| {
566 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
567 let spacer = tree.add(FixedSize::new().height(500.0).child(Spacer::new()));
568 let bell = tree.add(NotificationCenterButton::new(archive.clone()));
569 tree.add(VStack::new().child(spacer).child(bell));
570 tree.layout(SizeProposal::exact(400.0, 600.0));
571 tree.render();
572 tree
573 };
574 let (mut a, mut b) = (window(()), window(()));
575 assert!(!a.needs_render() && !b.needs_render(), "both start clean");
576
577 archive.push(entry("from somewhere"));
579 a.layout(SizeProposal::exact(400.0, 600.0));
580 assert!(a.needs_render(), "window A's bell must rebuild");
581 b.layout(SizeProposal::exact(400.0, 600.0));
582 assert!(
583 b.needs_render(),
584 "window B's bell must rebuild too — A's reconcile consumed nothing"
585 );
586 a.render();
587 b.render();
588
589 archive.push(entry("and again"));
591 b.layout(SizeProposal::exact(400.0, 600.0));
592 assert!(b.needs_render(), "window B first this time");
593 a.layout(SizeProposal::exact(400.0, 600.0));
594 assert!(a.needs_render(), "and window A still follows");
595 }
596
597 #[test]
598 fn bell_popover_opens_with_unread_badge() {
599 let (before, after) = bell_popover_open_check(false, 3);
602 assert_eq!(
603 after,
604 before + 1,
605 "popover must open even with an unread badge"
606 );
607 }
608
609 #[test]
610 fn bell_popover_opens_under_toast_host_with_badge() {
611 let (before, after) = bell_popover_open_check(true, 3);
612 assert_eq!(
613 after,
614 before + 1,
615 "popover must open under the toast host, with a badge"
616 );
617 }
618
619 #[test]
620 fn badge_caps_at_max_count() {
621 let archive = Rc::new(NotificationArchiveModel::in_memory());
622 for i in 0..150 {
623 archive.push(entry(&format!("t{i}")));
624 }
625 assert_eq!(archive.unread_count().get(), 150);
626 let tree = tree_with(NotificationCenterButton::new(archive).max_badge_count(99));
627 assert!(
628 tree.find_by_label("99+").is_some(),
629 "badge caps at '99+' for counts above max"
630 );
631 }
632
633 #[test]
634 fn scoped_bell_only_counts_its_audience_and_broadcast_unread() {
635 use crate::toast::ToastAudience;
636
637 let archive = Rc::new(NotificationArchiveModel::in_memory());
638 let audience_a = ToastAudience::new(1);
639 let audience_b = ToastAudience::new(2);
640
641 archive.push(entry_with_route("for a", ToastRoute::Audience(audience_a)));
642 archive.push(entry_with_route("for b", ToastRoute::Audience(audience_b)));
643 archive.push(entry_with_route(
644 "for b again",
645 ToastRoute::Audience(audience_b),
646 ));
647 archive.push(entry_with_route("everyone", ToastRoute::Broadcast));
648 assert_eq!(
649 archive.unread_count().get(),
650 4,
651 "the shared archive's global counter sees all four"
652 );
653
654 let tree_a = tree_with(
660 NotificationCenterButton::new(archive.clone()).for_window(TeksiloWindowId::new(1)),
661 );
662 assert!(
663 tree_a.find_by_label("1").is_some(),
664 "no entry is routed to Window(1); only the broadcast one should count"
665 );
666
667 let tree_scoped_a =
669 tree_with(NotificationCenterButton::new(archive.clone()).for_audience(audience_a));
670 assert!(
671 tree_scoped_a.find_by_label("2").is_some(),
672 "audience A's bell counts its own entry plus the broadcast one"
673 );
674
675 let tree_scoped_b =
678 tree_with(NotificationCenterButton::new(archive.clone()).for_audience(audience_b));
679 assert!(
680 tree_scoped_b.find_by_label("3").is_some(),
681 "audience B's bell counts both of its own entries plus the broadcast one"
682 );
683
684 let tree_unscoped = tree_with(NotificationCenterButton::new(archive));
687 assert!(
688 tree_unscoped.find_by_label("4").is_some(),
689 "an unscoped bell keeps the old 'see everything' behaviour"
690 );
691 }
692
693 #[test]
706 fn scoped_bell_reflects_toasts_presented_through_the_real_registry_pipeline() {
707 use crate::toast::host::ToastInstallOptions;
708 use crate::toast::{Toast, ToastAudience, ToastRegistry};
709
710 let archive = Rc::new(NotificationArchiveModel::in_memory());
711 let registry = ToastRegistry::with_archive(
712 ToastInstallOptions {
713 archive: None,
714 ..ToastInstallOptions::default()
715 },
716 archive.clone(),
717 );
718 let audience_a = ToastAudience::new(1);
719 let audience_b = ToastAudience::new(2);
720
721 registry.enqueue(Toast::info(lit!("for a")).target(audience_a));
722 registry.enqueue(Toast::info(lit!("for b")).target(audience_b));
723 registry.enqueue(Toast::warning(lit!("everyone")).broadcast());
724 assert_eq!(
725 archive.unread_count().get(),
726 3,
727 "all three toasts were mirrored into the shared archive"
728 );
729
730 let tree_a =
731 tree_with(NotificationCenterButton::new(archive.clone()).for_audience(audience_a));
732 assert!(
733 tree_a.find_by_label("2").is_some(),
734 "audience A's bell must count its own real toast plus the broadcast one \
735 (2), excluding B's — not 3 (everything) and not 1 (missing the broadcast)"
736 );
737
738 let tree_b = tree_with(NotificationCenterButton::new(archive).for_audience(audience_b));
739 assert!(
740 tree_b.find_by_label("2").is_some(),
741 "audience B's bell must count its own real toast plus the broadcast one, \
742 excluding A's"
743 );
744 }
745
746 #[test]
747 fn scoped_bell_close_only_marks_its_own_entries_read() {
748 use crate::primitives::{FixedSize, Spacer, VStack};
749 use crate::toast::ToastAudience;
750
751 let archive = Rc::new(NotificationArchiveModel::in_memory());
752 let audience_a = ToastAudience::new(1);
753 let audience_b = ToastAudience::new(2);
754 archive.push(entry_with_route("for a", ToastRoute::Audience(audience_a)));
755 archive.push(entry_with_route("for b", ToastRoute::Audience(audience_b)));
756 assert_eq!(archive.unread_count().get(), 2);
757
758 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
766 let spacer = tree.add(FixedSize::new().height(500.0).child(Spacer::new()));
767 let bell =
768 tree.add(NotificationCenterButton::new(archive.clone()).for_audience(audience_a));
769 tree.add(VStack::new().child(spacer).child(bell));
770 tree.layout(SizeProposal::exact(400.0, 600.0));
771
772 tree.click(bell);
775 tree.layout(SizeProposal::exact(400.0, 600.0));
776 tree.click(bell); tree.layout(SizeProposal::exact(400.0, 600.0));
778
779 assert_eq!(
780 archive.unread_count().get(),
781 1,
782 "only audience A's entry was marked read; audience B's stays unread"
783 );
784 }
785
786 fn two_window_bells(archive: Rc<NotificationArchiveModel>) -> (WidgetTree, WidgetTree) {
802 use teksilo_core::window::state::WindowStateInit;
803 use teksilo_core::window::{WindowPlacement, WindowState};
804
805 let build_window = |window_id: u64| {
806 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
807 tree.set_window_state(WindowState::new(WindowStateInit {
808 id: TeksiloWindowId::new(window_id),
809 string_id: Some(format!("w{window_id}")),
810 placement: WindowPlacement::Floating,
811 title: "Test".to_string(),
812 size: (400, 600),
813 position: (0, 0),
814 focused: false,
815 resizable: true,
816 always_on_top: false,
817 }));
818 tree.add(NotificationCenterButton::new(archive.clone()));
819 tree.layout(SizeProposal::exact(400.0, 600.0));
820 tree
821 };
822
823 (build_window(1), build_window(2))
824 }
825
826 #[test]
832 fn both_unscoped_bells_pick_up_a_badge_change_regardless_of_reconcile_order() {
833 let archive = Rc::new(NotificationArchiveModel::in_memory());
834 let (mut tree1, mut tree2) = two_window_bells(archive.clone());
835 assert!(tree1.find_by_label("1").is_none());
836 assert!(tree2.find_by_label("1").is_none());
837
838 archive.push(entry("new"));
839
840 tree1.layout(SizeProposal::exact(400.0, 600.0));
842 assert!(
843 tree1.find_by_label("1").is_some(),
844 "window 1's bell must show the new unread badge"
845 );
846 tree2.layout(SizeProposal::exact(400.0, 600.0));
850 assert!(
851 tree2.find_by_label("1").is_some(),
852 "window 2's bell must ALSO show the badge, even reconciling second"
853 );
854 }
855
856 #[test]
859 fn both_unscoped_bells_pick_up_a_badge_change_in_the_reverse_reconcile_order_too() {
860 let archive = Rc::new(NotificationArchiveModel::in_memory());
861 let (mut tree1, mut tree2) = two_window_bells(archive.clone());
862
863 archive.push(entry("new"));
864
865 tree2.layout(SizeProposal::exact(400.0, 600.0));
866 assert!(
867 tree2.find_by_label("1").is_some(),
868 "window 2's bell must show the badge when it reconciles first"
869 );
870 tree1.layout(SizeProposal::exact(400.0, 600.0));
871 assert!(
872 tree1.find_by_label("1").is_some(),
873 "window 1's bell must ALSO show it, even reconciling second"
874 );
875 }
876
877 #[test]
878 fn tooltip_appears_on_hover() {
879 let archive = Rc::new(NotificationArchiveModel::in_memory());
880 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
881 let id = tree.add(NotificationCenterButton::new(archive).tooltip(lit!("Tip")));
882 tree.layout(SizeProposal::exact(300.0, 200.0));
883 tree.pointer_move(tree.bounds(id).center());
884 tree.advance_time(std::time::Duration::from_secs(1));
885 assert_eq!(
886 tree.active_overlays().len(),
887 1,
888 "tooltip should appear on hover"
889 );
890 assert!(tree.find_by_label("Tip").is_some());
891 }
892}