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