Skip to main content

teksilo_widgets/notification/
center_button.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `NotificationCenterButton` — bell icon with an unread-count badge that
5//! opens a [`NotificationLog`] popover when clicked.
6//!
7//! Composed as a `ZStack { PopoverIconButton(bell), Badge }`. The badge
8//! shows the current unread count and is hit-transparent so clicks always
9//! reach the bell beneath. On popover close the archive's `mark_all_read`
10//! is called and the badge resets — matching the GitHub / Slack / JetBrains
11//! convention. Most apps mount this in a `StatusBar` or `TitleBar` trailing
12//! slot; all popover behaviour is self-managed with no further wiring.
13//!
14//! ## Accessibility
15//!
16//! The inner `IconButton` carries the bell `Role::Button` label; the outer
17//! container is `set_hidden` (presentational). The badge count is not
18//! separately announced — the button label and badge label together convey
19//! the state to sighted users; AT users interact through the button itself.
20//!
21//! ```ignore
22//! // Typical setup — archive comes from install_toast_default():
23//! let archive: Rc<NotificationArchiveModel> = ctx.app_state().unwrap();
24//! let bell = NotificationCenterButton::new(archive)
25//!     .on_action_invoked(|_entry, action, ctx| {
26//!         if let Some(name) = &action.intent_name {
27//!             ctx.send_intent(teksilo_core::Intent::new(name));
28//!         }
29//!     });
30//! ```
31
32use 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
57/// Bell-icon trigger + unread-count badge + popover that contains a
58/// [`NotificationLog`]. On popover *close* the entries in this bell's
59/// scope are marked read (the user is presumed to have seen the
60/// toasts now).
61pub 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    /// Plain single-line tooltip text shown after a hover delay.
70    /// Mutually exclusive with `rich_tooltip_source` and
71    /// `composite_tooltip_content` — last setter wins.
72    tooltip_text: Option<LocalizedString>,
73    /// Rich tooltip source (registry key or inline content).
74    /// Mutually exclusive with `tooltip_text` and `composite_tooltip_content`.
75    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
76    /// Composite tooltip body (arbitrary widget tree).
77    /// Mutually exclusive with `tooltip_text` and `rich_tooltip_source`.
78    composite_tooltip_content: Option<Box<dyn Widget>>,
79    /// `None` (default) = unscoped — the badge counts every unread
80    /// entry in the whole shared archive and the popover shows every
81    /// entry, matching this widget's behaviour before routing existed.
82    /// `Some(route)` restricts both to entries matching `route` (plus
83    /// `Broadcast`, always counted/shown). Set via [`Self::for_window`]
84    /// / [`Self::for_audience`].
85    route_scope: Option<ToastRoute>,
86}
87
88impl NotificationCenterButton {
89    /// Construct bound to a shared archive. The archive is typically
90    /// held in `app_state` and cloned to every consumer.
91    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    /// Scope this bell to window `window_id`: its badge counts unread
108    /// among entries routed to that window (plus any `Broadcast`
109    /// entry), and its popover shows only those. Overrides any
110    /// previous `for_window` / `for_audience` call.
111    pub fn for_window(mut self, window_id: TeksiloWindowId) -> Self {
112        self.route_scope = Some(ToastRoute::Window(window_id));
113        self
114    }
115
116    /// Scope this bell to `audience`: its badge counts unread among
117    /// entries routed to that audience (plus any `Broadcast` entry),
118    /// and its popover shows only those. Overrides any previous
119    /// `for_window` / `for_audience` call.
120    pub fn for_audience(mut self, audience: ToastAudience) -> Self {
121        self.route_scope = Some(ToastRoute::Audience(audience));
122        self
123    }
124
125    /// Bell-icon size. Default `IconButtonSize::Toolbar` (30 dp) —
126    /// matches the JetBrains status-bar density.
127    pub fn size(mut self, size: IconButtonSize) -> Self {
128        self.size = size;
129        self
130    }
131
132    /// Whether to keep the badge visible when the unread count is
133    /// zero. Default `false` (badge hidden when no unread). Apps
134    /// that want a persistent "0" indicator pass `true`.
135    pub fn show_badge_when_zero(mut self, show: bool) -> Self {
136        self.show_badge_when_zero = show;
137        self
138    }
139
140    /// Cap the displayed badge count. Default `99` — counts above
141    /// the cap display as `"99+"`. Set to `u32::MAX` to disable the
142    /// cap.
143    pub fn max_badge_count(mut self, max: u32) -> Self {
144        self.max_badge_count = max;
145        self
146    }
147
148    /// Popover placement relative to the bell. Default
149    /// `BelowPreferred` — flips above when the button is near the
150    /// viewport bottom edge.
151    pub fn placement(mut self, p: OverlayPlacement) -> Self {
152        self.placement = p;
153        self
154    }
155
156    /// Threaded into the embedded `NotificationLog` —
157    /// see [`NotificationLog::on_action_invoked`] for the contract.
158    /// Wire this to dispatch archived actions; without it the
159    /// action buttons in the log are inert.
160    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    /// Attach a plain single-line tooltip shown after a hover delay.
169    ///
170    /// Mutually exclusive with [`rich_tooltip`](Self::rich_tooltip),
171    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
172    /// [`composite_tooltip`](Self::composite_tooltip) — the last setter
173    /// called wins.
174    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    /// Attach a rich tooltip identified by a registry key.
182    ///
183    /// Mutually exclusive with [`tooltip`](Self::tooltip),
184    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
185    /// [`composite_tooltip`](Self::composite_tooltip).
186    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    /// Attach a rich tooltip from inline [`crate::tooltip::TooltipContent`].
194    ///
195    /// Mutually exclusive with [`tooltip`](Self::tooltip),
196    /// [`rich_tooltip`](Self::rich_tooltip), and
197    /// [`composite_tooltip`](Self::composite_tooltip).
198    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    /// Attach a composite tooltip containing an arbitrary widget tree.
206    ///
207    /// Mutually exclusive with [`tooltip`](Self::tooltip),
208    /// [`rich_tooltip`](Self::rich_tooltip), and
209    /// [`rich_tooltip_content`](Self::rich_tooltip_content).
210    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        // Bind to the archive's mutation version (not `unread_count`)
236        // at Rebuild — a scoped bell's badge count is a local scan
237        // over `archive.entries()` (see below), so it must rebuild on
238        // ANY archive mutation that could change which of ITS entries
239        // are unread, not just the (global, unscoped) `unread_count`
240        // signal. Matches `NotificationLog`'s own binding.
241        //
242        // One signal for every window's bell: this window's own
243        // `BindingRegistry` remembers the generation it last
244        // reconciled, so a bell in window B cannot miss a mutation
245        // just because window A's tree reconciled first.
246        archive.version_signal().bind_to(
247            ctx.self_id(),
248            ctx.binding_registry(),
249            BindingLevel::Rebuild,
250        );
251
252        // Bell trigger: an IconButton(bell) at the requested size.
253        let trigger = IconButton::bell().size(self.size);
254
255        // Popover content: a NotificationLog, scoped identically to
256        // this bell so the popover body and the badge always agree on
257        // which entries "belong" to this window/audience. The
258        // on_action_invoked hook is forwarded if present.
259        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        // `PopoverIconButton` wraps the content in the themed popover
270        // surface (background, border, padding, shadow) by default, so
271        // the chrome-less `NotificationLog` gets a proper surface for
272        // free — no manual `Panel` needed.
273
274        // Bell + popover combo. Mark archive entries read when the
275        // popover *closes*, NOT when it opens — mutating the archive
276        // bumps `version_signal`, which fires this widget's `Rebuild`
277        // binding, and a rebuild on OPEN would tear down the
278        // `PopoverIconButton` (and its just-shown overlay) and replace
279        // it with a fresh, closed one, so the popover would flash and
280        // vanish, leaving only the cleared badge. Deferring to close
281        // lets the rebuild happen after the popover is already gone.
282        // Scoped exactly like the toolbar's mark-all-read above: a
283        // scoped bell must only mark ITS entries read, never every
284        // window's/audience's history.
285        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        // Compute the badge label for this build — a local scan over
297        // the (bounded, ≤ DEFAULT_ARCHIVE_LIMIT) archive entries rather
298        // than a dedicated per-audience counter signal: cheap, and it
299        // is the single source of truth `route_visible` already uses
300        // for the popover body, so the two can never disagree.
301        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        // Stack bell + badge. Badge is omitted entirely when there
318        // are no unread (and `show_when_zero` is false) so the bell
319        // renders bare.
320        //
321        // The badge is pinned to the top-trailing corner (where count
322        // badges belong) via the stack alignment, and its whole subtree
323        // is marked hit-transparent. A `ZStack` centers its children by
324        // default, so the badge sat on top of the bell icon; `Badge` is
325        // a *composite* widget, so `event_pass_through` (per-node) would
326        // not help — its inner text/rect children still swallowed the
327        // tap, and the popover never opened whenever there were unread
328        // notifications (i.e. exactly when you'd press the bell).
329        // `hit_transparent` excludes the entire badge subtree from
330        // hit-testing, so the click falls through to the bell beneath.
331        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        // Attach tooltip if configured. The three setters
341        // (`tooltip`, `rich_tooltip*`, `composite_tooltip`) are
342        // mutually exclusive — every setter clears the other two so
343        // exactly one branch runs.
344        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        // The IconButton inside contributes its own role + name;
385        // we pass through as a generic container.
386        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        // Pre-populate before mounting — the rebuild-on-signal-change
446        // path doesn't fully fire in unit-test layout passes
447        // (same caveat as the toast host tests).
448        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    /// Reproduces the real app: bell mounted at the BOTTOM of the
459    /// window (status bar), under the full-viewport pass-through toast
460    /// host installed by `install_toast`. Clicking it must open the
461    /// popover overlay, and the popover must land on-screen.
462    /// Mounts the bell at the bottom of the window (status-bar
463    /// position), optionally under the full-viewport pass-through toast
464    /// host installed by `install_toast`, with `unread` notifications in
465    /// the archive. Returns (active overlays before click, after click).
466    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        // A spacer pushes the bell to the bottom edge (status bar).
478        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            // Mirror install_toast: ZStack { Expand(user_root), host }.
484            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        // Empty archive → no badge → isolates the popover mechanism.
506        let (before, after) = bell_popover_open_check(false, 0);
507        assert_eq!(after, before + 1, "popover should open (no badge)");
508    }
509
510    /// Clicking an in-content action ("mark all read" / "clear") mutates
511    /// the archive, which changes `unread_count` and rebuilds the bell —
512    /// destroying the popover's owner. The overlay must NOT linger as an
513    /// invisible click-blocker; it must be fully dismissed.
514    #[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        // Simulate clicking "Mark all read" inside the log.
532        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    /// Two trees with NO window state, sharing one archive: one push,
543    /// **both** must come out needing a render.
544    ///
545    /// Distinct from `both_unscoped_bells_pick_up_a_badge_change_*`
546    /// below, which give their trees real `TeksiloWindowId`s. Those
547    /// used to be served by a per-window duplicate of the version
548    /// signal; a windowless tree fell through to the shared one and was
549    /// exactly the configuration that broke. Dirty tracking used to be
550    /// a `bool` on the signal that each tree's reconcile pass read *and
551    /// cleared*, so whichever tree laid out first consumed it and the
552    /// other silently — and permanently — kept a stale badge. Verified
553    /// against the pre-fix tree: this test failed on window B.
554    ///
555    /// Reconciles in the opposite order the second time round. The old
556    /// failure picked its victim by `HashMap` iteration order, so a
557    /// test that only ever laid out A-then-B could pass against a
558    /// "fix" that merely moved which window loses.
559    #[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        // Round 1 — reconcile A first, then B.
578        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        // Round 2 — same push, opposite reconcile order.
590        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        // Regression: a centered, hit-testable badge swallowed the tap,
600        // so the popover never opened when there were unread items.
601        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        // Bell scoped to Window(1): no entry is routed to that window,
655        // so only the broadcast one counts → badge shows "1". This
656        // proves window-scoping and audience-scoping are independent:
657        // a window-scoped bell shows only Window(_) + Broadcast, never
658        // an Audience(_) entry.
659        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        // Bell scoped to audience A: "for a" (1) + "everyone" (1) = 2.
668        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        // Bell scoped to audience B: "for b" + "for b again" (2) +
676        // "everyone" (1) = 3.
677        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        // Unscoped bell (legacy, back-compat path): sees the whole
685        // shared archive, exactly like before routing existed.
686        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    /// End-to-end counterpart of `scoped_bell_only_counts_its_audience_and_broadcast_unread`
694    /// above: that test (and every other one in this file) proves the
695    /// SCOPING FILTER is correct by hand-building `NotificationEntry`
696    /// rows with `entry_with_route`. It never goes through the real
697    /// `ToastRegistry::enqueue` → archive-mirror path, so it can't
698    /// catch a regression in the OTHER half of the seam: whether a
699    /// toast's resolved route actually survives the trip into the
700    /// archive at all (see `toast.rs`'s
701    /// `registry_mirrors_the_resolved_route_onto_the_archived_entry`
702    /// for that half in isolation). This test drives both halves
703    /// together — real toasts, real routes, real archive mirror, real
704    /// scoped bell — the shape a Skribisto per-Work bell actually sees.
705    #[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        // Mirror `bell_popover_open_check`'s status-bar layout (bell
759        // pinned to the bottom of a normal-sized window via a leading
760        // spacer) rather than the bare `tree_with` 120x60 helper: in a
761        // window that tiny, `BelowPreferred`'s popover has nowhere to
762        // go but directly over the bell, so the second synthesized
763        // click (which re-hit-tests at the bell's screen coordinate)
764        // lands on the popover instead of toggling the trigger closed.
765        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        // Open then close the popover — closing is what triggers the
773        // scoped mark-read.
774        tree.click(bell);
775        tree.layout(SizeProposal::exact(400.0, 600.0));
776        tree.click(bell); // PopoverIconButton toggles: second click closes it.
777        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    // -----------------------------------------------------------------
787    // Multi-window delivery — two REAL `NotificationCenterButton`s in
788    // two REAL `WidgetTree`s sharing one archive, mirroring
789    // `toast::host::tests::two_window_hosts`. Every test above builds
790    // at most one tree/bell, so none of them can catch a bell in a
791    // second window silently missing an archive mutation because the
792    // first window's tree already consumed the shared version signal's
793    // change notification — see `NotificationArchiveModel::version_signal`
794    // and `teksilo_core::binding::BindingRegistry` for why one signal
795    // can now serve every window.
796    // -----------------------------------------------------------------
797
798    /// Two independent windows (ids 1 and 2), each with its own
799    /// `WidgetTree` + unscoped `NotificationCenterButton`, both bound
800    /// to ONE shared archive.
801    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    /// An archive push must update EVERY open window's bell badge —
827    /// and must keep doing so regardless of which window's
828    /// `WidgetTree` reconciles first, exactly like `WindowManager::
829    /// request_redraw_needing_render` sweeping windows in whatever
830    /// order its internal `HashMap` iterates them.
831    #[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        // Window 1 reconciles first.
841        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        // Window 2 reconciles SECOND — this is exactly the case that
847        // silently missed the badge update before per-window signals:
848        // the shared flag was already cleared by window 1's flush.
849        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    /// Same scenario with the reconcile order flipped, to prove
857    /// delivery genuinely doesn't depend on iteration order.
858    #[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}