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