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 a bare `Role::GenericContainer`, which every adapter steps
18//! through to the button. It is never hidden: a hidden node takes its whole
19//! subtree out of every platform's tree, the button with it. The badge count is not
20//! separately announced — the button label and badge label together convey
21//! the state to sighted users; AT users interact through the button itself.
22//!
23//! ```ignore
24//! // Typical setup — archive comes from install_toast_default():
25//! let archive: Rc<NotificationArchiveModel> = ctx.app_state().unwrap();
26//! let bell = NotificationCenterButton::new(archive)
27//!     .on_action_invoked(|_entry, action, ctx| {
28//!         if let Some(name) = &action.intent_name {
29//!             ctx.send_intent(teksilo_core::Intent::new(name));
30//!         }
31//!     });
32//! ```
33
34use std::rc::Rc;
35use teksilo_i18n::{LocalizedString, lit};
36
37use teksilo_canvas::{Rect, SizeProposal};
38use teksilo_core::accessibility::AccessNodeBuilder;
39use teksilo_core::binding::BindingLevel;
40use teksilo_core::build_context::BuildContext;
41use teksilo_core::overlay::{DismissBehavior, OverlayPlacement};
42use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
43use teksilo_core::widget_id::WidgetId;
44
45use teksilo_core::widget_builder::WidgetBuilder;
46use teksilo_tokens::Alignment;
47
48use crate::badge::Badge;
49use crate::icon_button::{IconButton, IconButtonSize};
50use crate::notification::log::NotificationLog;
51use crate::notification::{
52    ArchivedAction, NotificationArchiveModel, NotificationEntry, route_visible,
53};
54use crate::popover_widget::PopoverIconButton;
55use crate::primitives::ZStack;
56use crate::toast::{ToastAudience, ToastRoute};
57use teksilo_core::window::TeksiloWindowId;
58
59/// Bell-icon trigger + unread-count badge + popover that contains a
60/// [`NotificationLog`]. On popover *close* the entries in this bell's
61/// scope are marked read (the user is presumed to have seen the
62/// toasts now).
63pub struct NotificationCenterButton {
64    archive: Rc<NotificationArchiveModel>,
65    size: IconButtonSize,
66    show_badge_when_zero: bool,
67    max_badge_count: u32,
68    placement: OverlayPlacement,
69    on_action_invoked: Option<Rc<dyn Fn(&NotificationEntry, &ArchivedAction, &mut EventContext)>>,
70    root_child_id: Option<WidgetId>,
71    /// Plain single-line tooltip text shown after a hover delay.
72    /// Mutually exclusive with `rich_tooltip_source` and
73    /// `composite_tooltip_content` — last setter wins.
74    tooltip_text: Option<LocalizedString>,
75    /// Rich tooltip source (registry key or inline content).
76    /// Mutually exclusive with `tooltip_text` and `composite_tooltip_content`.
77    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
78    /// Composite tooltip body (arbitrary widget tree).
79    /// Mutually exclusive with `tooltip_text` and `rich_tooltip_source`.
80    composite_tooltip_content: Option<Box<dyn Widget>>,
81    /// `None` (default) = unscoped — the badge counts every unread
82    /// entry in the whole shared archive and the popover shows every
83    /// entry, matching this widget's behaviour before routing existed.
84    /// `Some(route)` restricts both to entries matching `route` (plus
85    /// `Broadcast`, always counted/shown). Set via [`Self::for_window`]
86    /// / [`Self::for_audience`].
87    route_scope: Option<ToastRoute>,
88}
89
90impl NotificationCenterButton {
91    /// Construct bound to a shared archive. The archive is typically
92    /// held in `app_state` and cloned to every consumer.
93    pub fn new(archive: Rc<NotificationArchiveModel>) -> Self {
94        Self {
95            archive,
96            size: IconButtonSize::Toolbar,
97            show_badge_when_zero: false,
98            max_badge_count: 99,
99            placement: OverlayPlacement::BelowPreferred,
100            on_action_invoked: None,
101            root_child_id: None,
102            tooltip_text: None,
103            rich_tooltip_source: None,
104            composite_tooltip_content: None,
105            route_scope: None,
106        }
107    }
108
109    /// Scope this bell to window `window_id`: its badge counts unread
110    /// among entries routed to that window (plus any `Broadcast`
111    /// entry), and its popover shows only those. Overrides any
112    /// previous `for_window` / `for_audience` call.
113    pub fn for_window(mut self, window_id: TeksiloWindowId) -> Self {
114        self.route_scope = Some(ToastRoute::Window(window_id));
115        self
116    }
117
118    /// Scope this bell to `audience`: its badge counts unread among
119    /// entries routed to that audience (plus any `Broadcast` entry),
120    /// and its popover shows only those. Overrides any previous
121    /// `for_window` / `for_audience` call.
122    pub fn for_audience(mut self, audience: ToastAudience) -> Self {
123        self.route_scope = Some(ToastRoute::Audience(audience));
124        self
125    }
126
127    /// Bell-icon size. Default `IconButtonSize::Toolbar` (30 dp) —
128    /// matches the JetBrains status-bar density.
129    pub fn size(mut self, size: IconButtonSize) -> Self {
130        self.size = size;
131        self
132    }
133
134    /// Whether to keep the badge visible when the unread count is
135    /// zero. Default `false` (badge hidden when no unread). Apps
136    /// that want a persistent "0" indicator pass `true`.
137    pub fn show_badge_when_zero(mut self, show: bool) -> Self {
138        self.show_badge_when_zero = show;
139        self
140    }
141
142    /// Cap the displayed badge count. Default `99` — counts above
143    /// the cap display as `"99+"`. Set to `u32::MAX` to disable the
144    /// cap.
145    pub fn max_badge_count(mut self, max: u32) -> Self {
146        self.max_badge_count = max;
147        self
148    }
149
150    /// Popover placement relative to the bell. Default
151    /// `BelowPreferred` — flips above when the button is near the
152    /// viewport bottom edge.
153    pub fn placement(mut self, p: OverlayPlacement) -> Self {
154        self.placement = p;
155        self
156    }
157
158    /// Threaded into the embedded `NotificationLog` —
159    /// see [`NotificationLog::on_action_invoked`] for the contract.
160    /// Wire this to dispatch archived actions; without it the
161    /// action buttons in the log are inert.
162    pub fn on_action_invoked(
163        mut self,
164        f: impl Fn(&NotificationEntry, &ArchivedAction, &mut EventContext) + 'static,
165    ) -> Self {
166        self.on_action_invoked = Some(Rc::new(f));
167        self
168    }
169
170    /// Attach a plain single-line tooltip shown after a hover delay.
171    ///
172    /// Mutually exclusive with [`rich_tooltip`](Self::rich_tooltip),
173    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
174    /// [`composite_tooltip`](Self::composite_tooltip) — the last setter
175    /// called wins.
176    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
177        self.tooltip_text = Some(text.into());
178        self.rich_tooltip_source = None;
179        self.composite_tooltip_content = None;
180        self
181    }
182
183    /// Attach a rich tooltip identified by a registry key.
184    ///
185    /// Mutually exclusive with [`tooltip`](Self::tooltip),
186    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
187    /// [`composite_tooltip`](Self::composite_tooltip).
188    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
189        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
190        self.tooltip_text = None;
191        self.composite_tooltip_content = None;
192        self
193    }
194
195    /// Attach a rich tooltip from inline [`crate::tooltip::TooltipContent`].
196    ///
197    /// Mutually exclusive with [`tooltip`](Self::tooltip),
198    /// [`rich_tooltip`](Self::rich_tooltip), and
199    /// [`composite_tooltip`](Self::composite_tooltip).
200    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
201        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
202        self.tooltip_text = None;
203        self.composite_tooltip_content = None;
204        self
205    }
206
207    /// Attach a composite tooltip containing an arbitrary widget tree.
208    ///
209    /// Mutually exclusive with [`tooltip`](Self::tooltip),
210    /// [`rich_tooltip`](Self::rich_tooltip), and
211    /// [`rich_tooltip_content`](Self::rich_tooltip_content).
212    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
213        self.composite_tooltip_content = Some(Box::new(content));
214        self.tooltip_text = None;
215        self.rich_tooltip_source = None;
216        self
217    }
218}
219
220impl std::fmt::Debug for NotificationCenterButton {
221    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222        f.debug_struct("NotificationCenterButton")
223            .field("size", &self.size)
224            .field("show_badge_when_zero", &self.show_badge_when_zero)
225            .field("placement", &self.placement)
226            .finish_non_exhaustive()
227    }
228}
229
230impl Widget for NotificationCenterButton {
231    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
232        let archive = self.archive.clone();
233        let max_badge = self.max_badge_count;
234        let show_when_zero = self.show_badge_when_zero;
235        let scope = self.route_scope;
236
237        // The bell itself does NOT follow the archive: only its badge does
238        // (`UnreadBadge` below). Every toast pushes to the archive, and a
239        // bell rebuilt on each push replaced the button a keyboard user was
240        // on (focus re-fired on a fresh node, cutting the toast being
241        // announced) and closed an open popover under them.
242
243        // Bell trigger: an IconButton(bell) at the requested size.
244        let trigger = IconButton::bell().size(self.size);
245
246        // Popover content: a NotificationLog, scoped identically to
247        // this bell so the popover body and the badge always agree on
248        // which entries "belong" to this window/audience. The
249        // on_action_invoked hook is forwarded if present.
250        let mut log = NotificationLog::new(archive.clone());
251        log = match scope {
252            Some(ToastRoute::Window(w)) => log.for_window(w),
253            Some(ToastRoute::Audience(a)) => log.for_audience(a),
254            Some(ToastRoute::Broadcast) | None => log,
255        };
256        if let Some(cb) = self.on_action_invoked.clone() {
257            log = log.on_action_invoked(move |e, a, ctx| cb(e, a, ctx));
258        }
259
260        // `PopoverIconButton` wraps the content in the themed popover
261        // surface (background, border, padding, shadow) by default, so
262        // the chrome-less `NotificationLog` gets a proper surface for
263        // free — no manual `Panel` needed.
264
265        // Bell + popover combo. Mark archive entries read when the
266        // popover *closes*, NOT when it opens: the reader has seen them
267        // once the popover has been open. Scoped exactly like the log's
268        // own mark-all-read: a scoped bell must only mark ITS entries
269        // read, never every window's/audience's history.
270        let archive_for_close = archive.clone();
271        let pib = PopoverIconButton::new(trigger)
272            .content(log)
273            .placement(self.placement.clone())
274            .dismiss_behavior(DismissBehavior::EscapeOrClickOutside)
275            .on_close(move || match scope {
276                Some(s) => archive_for_close.mark_read_where(|e| route_visible(e.route, Some(s))),
277                None => archive_for_close.mark_all_read(),
278            });
279        let pib_id = ctx.add(pib);
280
281        // Stack bell + badge.
282        //
283        // The badge is pinned to the top-trailing corner (where count
284        // badges belong) via the stack alignment, and its whole subtree
285        // is marked hit-transparent. A `ZStack` centers its children by
286        // default, so the badge sat on top of the bell icon; `Badge` is
287        // a *composite* widget, so `event_pass_through` (per-node) would
288        // not help — its inner text/rect children still swallowed the
289        // tap, and the popover never opened whenever there were unread
290        // notifications (i.e. exactly when you'd press the bell).
291        // `hit_transparent` excludes the entire badge subtree from
292        // hit-testing, so the click falls through to the bell beneath.
293        let badge_id = ctx.add(
294            UnreadBadge {
295                archive: archive.clone(),
296                scope,
297                max_badge,
298                show_when_zero,
299                child: None,
300            }
301            .hit_transparent(true),
302        );
303        let root = ctx.add(
304            ZStack::new()
305                .alignment(Alignment::TOP_TRAILING)
306                .child(pib_id)
307                .child(badge_id),
308        );
309
310        // Attach tooltip if configured. The three setters
311        // (`tooltip`, `rich_tooltip*`, `composite_tooltip`) are
312        // mutually exclusive — every setter clears the other two so
313        // exactly one branch runs.
314        if let Some(content) = self.composite_tooltip_content.take() {
315            let delay = ctx.theme().motion.tooltip_delay_heavy;
316            crate::tooltip::attach_composite_tooltip_boxed(ctx, root, content, delay);
317        } else if let Some(source) = self.rich_tooltip_source.clone() {
318            let delay = ctx.theme().motion.tooltip_delay;
319            crate::tooltip::attach_rich_tooltip_source(ctx, root, source, delay);
320        } else if let Some(text) = self.tooltip_text.clone() {
321            let delay = ctx.theme().motion.tooltip_delay;
322            crate::tooltip::attach_plain_tooltip(ctx, root, text, delay);
323        }
324
325        self.root_child_id = Some(root);
326        vec![root]
327    }
328
329    fn layout_response(
330        &self,
331        proposal: SizeProposal,
332        ctx: &LayoutContext,
333    ) -> teksilo_core::widget::LayoutResponse {
334        self.root_child_id
335            .and_then(|id| ctx.child_size(id, proposal))
336            .unwrap_or_else(|| proposal.resolve(30.0, 30.0))
337            .into()
338    }
339
340    fn place_children(
341        &self,
342        bounds: Rect,
343        _proposal: SizeProposal,
344        children: &mut [WidgetPlacement],
345        _ctx: &LayoutContext,
346    ) {
347        for child in children.iter_mut() {
348            child.origin = bounds.origin();
349            child.size = bounds.size();
350        }
351    }
352
353    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
354        // The IconButton inside contributes its own role + name; we pass
355        // through as a bare generic container, which every adapter drops
356        // with the button kept. Never `set_hidden()`, which an adapter reads
357        // as hiding the button with it.
358        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
359    }
360
361    fn children(&self) -> Vec<WidgetId> {
362        self.root_child_id.into_iter().collect()
363    }
364}
365
366/// The bell's unread-count badge: the one part of the bell that follows the
367/// archive. It rebuilds on every archive change, the bell beside it never does.
368#[derive(Debug)]
369struct UnreadBadge {
370    archive: Rc<NotificationArchiveModel>,
371    scope: Option<ToastRoute>,
372    max_badge: u32,
373    show_when_zero: bool,
374    child: Option<WidgetId>,
375}
376
377impl Widget for UnreadBadge {
378    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
379        // The archive's mutation version, not `unread_count`: a scoped
380        // bell's count is a local scan over `archive.entries()`, so it must
381        // follow ANY mutation that could change which of ITS entries are
382        // unread, not just the (global, unscoped) `unread_count` signal.
383        //
384        // One signal for every window's bell: this window's own
385        // `BindingRegistry` remembers the generation it last reconciled, so
386        // a bell in window B cannot miss a mutation just because window A's
387        // tree reconciled first.
388        self.archive.version_signal().bind_to(
389            ctx.self_id(),
390            ctx.binding_registry(),
391            BindingLevel::Rebuild,
392        );
393
394        // A local scan over the (bounded, ≤ DEFAULT_ARCHIVE_LIMIT) archive
395        // entries rather than a dedicated per-audience counter signal: cheap,
396        // and it is the single source of truth `route_visible` already uses
397        // for the popover body, so the two can never disagree.
398        let model = self.archive.entries();
399        let scope = self.scope;
400        let unread_count = (0..model.len())
401            .filter(|&i| {
402                model
403                    .with_item(i, |e| !e.read && route_visible(e.route, scope))
404                    .unwrap_or(false)
405            })
406            .count();
407        // Omitted entirely when there are no unread (and `show_when_zero`
408        // is false) so the bell renders bare.
409        self.child = (unread_count > 0 || self.show_when_zero).then(|| {
410            let label = if unread_count > self.max_badge as usize {
411                format!("{}+", self.max_badge)
412            } else {
413                unread_count.to_string()
414            };
415            ctx.add(Badge::new(lit!(label)))
416        });
417        self.child.into_iter().collect()
418    }
419
420    fn layout_response(
421        &self,
422        proposal: SizeProposal,
423        ctx: &LayoutContext,
424    ) -> teksilo_core::widget::LayoutResponse {
425        // No badge takes no room: the stack keeps the bell's size.
426        self.child
427            .and_then(|id| ctx.child_size(id, proposal))
428            .unwrap_or_default()
429            .into()
430    }
431
432    fn place_children(
433        &self,
434        bounds: Rect,
435        _proposal: SizeProposal,
436        children: &mut [WidgetPlacement],
437        _ctx: &LayoutContext,
438    ) {
439        for child in children.iter_mut() {
440            child.origin = bounds.origin();
441            child.size = bounds.size();
442        }
443    }
444
445    fn children(&self) -> Vec<WidgetId> {
446        self.child.into_iter().collect()
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::notification::NotificationEntry;
454    use teksilo_core::styles::{BannerSeverity, ToastPriority};
455    use teksilo_core::widget_tree::WidgetTree;
456
457    fn entry(title: &str) -> NotificationEntry {
458        entry_with_route(title, ToastRoute::Broadcast)
459    }
460
461    fn entry_with_route(title: &str, route: ToastRoute) -> NotificationEntry {
462        NotificationEntry {
463            id: 0,
464            severity: BannerSeverity::Info,
465            priority: ToastPriority::Normal,
466            title: title.to_string(),
467            body: None,
468            actions: Vec::new(),
469            timestamp: jiff::Timestamp::UNIX_EPOCH,
470            group: None,
471            source: None,
472            read: false,
473            dedup_id: None,
474            updates: Vec::new(),
475            route,
476        }
477    }
478
479    fn tree_with(btn: NotificationCenterButton) -> WidgetTree {
480        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
481        tree.add(btn);
482        tree.layout(SizeProposal::exact(120.0, 60.0));
483        tree
484    }
485
486    #[test]
487    fn bell_label_present() {
488        let archive = Rc::new(NotificationArchiveModel::in_memory());
489        let tree = tree_with(NotificationCenterButton::new(archive));
490        let bell_label = teksilo_i18n::tr_widget!(a11y_builtin_bell()).resolve_now();
491        assert!(
492            tree.find_by_label(&bell_label).is_some(),
493            "bell tooltip / label present in the AT tree"
494        );
495    }
496
497    #[test]
498    fn badge_appears_when_unread_count_grows() {
499        let archive = Rc::new(NotificationArchiveModel::in_memory());
500        // Pre-populate before mounting — the rebuild-on-signal-change
501        // path doesn't fully fire in unit-test layout passes
502        // (same caveat as the toast host tests).
503        archive.push(entry("a"));
504        archive.push(entry("b"));
505        assert_eq!(archive.unread_count().get(), 2);
506        let tree = tree_with(NotificationCenterButton::new(archive));
507        assert!(
508            tree.find_by_label("2").is_some(),
509            "badge with count '2' renders when unread_count > 0"
510        );
511    }
512
513    /// Reproduces the real app: bell mounted at the BOTTOM of the
514    /// window (status bar), under the full-viewport pass-through toast
515    /// host installed by `install_toast`. Clicking it must open the
516    /// popover overlay, and the popover must land on-screen.
517    /// Mounts the bell at the bottom of the window (status-bar
518    /// position), optionally under the full-viewport pass-through toast
519    /// host installed by `install_toast`, with `unread` notifications in
520    /// the archive. Returns (active overlays before click, after click).
521    fn bell_popover_open_check(with_toast_host: bool, unread: usize) -> (usize, usize) {
522        use crate::primitives::{Expand, FixedSize, Spacer, VStack, ZStack};
523        use crate::toast::{ToastHost, ToastInstallOptions, ToastRegistry};
524
525        let archive = Rc::new(NotificationArchiveModel::in_memory());
526        for i in 0..unread {
527            archive.push(entry(&format!("n{i}")));
528        }
529
530        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
531
532        // A spacer pushes the bell to the bottom edge (status bar).
533        let spacer = tree.add(FixedSize::new().height(500.0).child(Spacer::new()));
534        let bell = tree.add(NotificationCenterButton::new(archive.clone()));
535        let user_root = tree.add(VStack::new().child(spacer).child(bell));
536
537        if with_toast_host {
538            // Mirror install_toast: ZStack { Expand(user_root), host }.
539            let opts = ToastInstallOptions {
540                archive: None,
541                ..ToastInstallOptions::default()
542            };
543            let registry = ToastRegistry::new(opts.clone());
544            let filled = tree.add(Expand::new().respect_intrinsic().child(user_root));
545            let host = tree.add(ToastHost::new(registry, opts));
546            tree.add(ZStack::new().child(filled).child(host));
547        }
548
549        tree.layout(SizeProposal::exact(400.0, 600.0));
550
551        let before = tree.active_overlays().len();
552        tree.click(bell);
553        tree.layout(SizeProposal::exact(400.0, 600.0));
554        let after = tree.active_overlays().len();
555        (before, after)
556    }
557
558    #[test]
559    fn bell_popover_opens_with_no_unread() {
560        // Empty archive → no badge → isolates the popover mechanism.
561        let (before, after) = bell_popover_open_check(false, 0);
562        assert_eq!(after, before + 1, "popover should open (no badge)");
563    }
564
565    /// Clicking an in-content action ("mark all read" / "clear") mutates
566    /// the archive, which changes `unread_count`. The bell used to rebuild
567    /// then, destroying the popover's owner; now only its badge follows
568    /// the archive, so the popover stays open under the reader, and it is
569    /// still the bell's: Escape closes it, it does not linger as an
570    /// invisible click-blocker.
571    #[test]
572    fn in_content_action_does_not_orphan_overlay() {
573        use crate::primitives::{FixedSize, Spacer, VStack};
574        let archive = Rc::new(NotificationArchiveModel::in_memory());
575        for i in 0..3 {
576            archive.push(entry(&format!("n{i}")));
577        }
578        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
579        let spacer = tree.add(FixedSize::new().height(500.0).child(Spacer::new()));
580        let bell = tree.add(NotificationCenterButton::new(archive.clone()));
581        tree.add(VStack::new().child(spacer).child(bell));
582        tree.layout(SizeProposal::exact(400.0, 600.0));
583
584        tree.click(bell);
585        tree.layout(SizeProposal::exact(400.0, 600.0));
586        assert_eq!(tree.active_overlays().len(), 1, "popover should be open");
587
588        // Simulate clicking "Mark all read" inside the log.
589        archive.mark_all_read();
590        tree.layout(SizeProposal::exact(400.0, 600.0));
591        assert_eq!(
592            tree.active_overlays().len(),
593            1,
594            "the in-content action leaves the popover open"
595        );
596
597        tree.press_key(
598            teksilo_core::event::Key::Escape,
599            teksilo_core::event::Modifiers::NONE,
600        );
601        tree.layout(SizeProposal::exact(400.0, 600.0));
602        assert_eq!(
603            tree.active_overlays().len(),
604            0,
605            "and the popover still answers Escape (not left as an invisible \
606             click-blocker)"
607        );
608    }
609
610    /// Two trees with NO window state, sharing one archive: one push,
611    /// **both** must come out needing a render.
612    ///
613    /// Distinct from `both_unscoped_bells_pick_up_a_badge_change_*`
614    /// below, which give their trees real `TeksiloWindowId`s. Those
615    /// used to be served by a per-window duplicate of the version
616    /// signal; a windowless tree fell through to the shared one and was
617    /// exactly the configuration that broke. Dirty tracking used to be
618    /// a `bool` on the signal that each tree's reconcile pass read *and
619    /// cleared*, so whichever tree laid out first consumed it and the
620    /// other silently — and permanently — kept a stale badge. Verified
621    /// against the pre-fix tree: this test failed on window B.
622    ///
623    /// Reconciles in the opposite order the second time round. The old
624    /// failure picked its victim by `HashMap` iteration order, so a
625    /// test that only ever laid out A-then-B could pass against a
626    /// "fix" that merely moved which window loses.
627    #[test]
628    fn two_windowless_trees_both_rebuild_on_one_archive_push() {
629        use crate::primitives::{FixedSize, Spacer, VStack};
630
631        let archive = Rc::new(NotificationArchiveModel::in_memory());
632
633        let window = |_| {
634            let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
635            let spacer = tree.add(FixedSize::new().height(500.0).child(Spacer::new()));
636            let bell = tree.add(NotificationCenterButton::new(archive.clone()));
637            tree.add(VStack::new().child(spacer).child(bell));
638            tree.layout(SizeProposal::exact(400.0, 600.0));
639            tree.render();
640            tree
641        };
642        let (mut a, mut b) = (window(()), window(()));
643        assert!(!a.needs_render() && !b.needs_render(), "both start clean");
644
645        // Round 1 — reconcile A first, then B.
646        archive.push(entry("from somewhere"));
647        a.layout(SizeProposal::exact(400.0, 600.0));
648        assert!(a.needs_render(), "window A's bell must rebuild");
649        b.layout(SizeProposal::exact(400.0, 600.0));
650        assert!(
651            b.needs_render(),
652            "window B's bell must rebuild too — A's reconcile consumed nothing"
653        );
654        a.render();
655        b.render();
656
657        // Round 2 — same push, opposite reconcile order.
658        archive.push(entry("and again"));
659        b.layout(SizeProposal::exact(400.0, 600.0));
660        assert!(b.needs_render(), "window B first this time");
661        a.layout(SizeProposal::exact(400.0, 600.0));
662        assert!(a.needs_render(), "and window A still follows");
663    }
664
665    #[test]
666    fn bell_popover_opens_with_unread_badge() {
667        // Regression: a centered, hit-testable badge swallowed the tap,
668        // so the popover never opened when there were unread items.
669        let (before, after) = bell_popover_open_check(false, 3);
670        assert_eq!(
671            after,
672            before + 1,
673            "popover must open even with an unread badge"
674        );
675    }
676
677    #[test]
678    fn bell_popover_opens_under_toast_host_with_badge() {
679        let (before, after) = bell_popover_open_check(true, 3);
680        assert_eq!(
681            after,
682            before + 1,
683            "popover must open under the toast host, with a badge"
684        );
685    }
686
687    #[test]
688    fn badge_caps_at_max_count() {
689        let archive = Rc::new(NotificationArchiveModel::in_memory());
690        for i in 0..150 {
691            archive.push(entry(&format!("t{i}")));
692        }
693        assert_eq!(archive.unread_count().get(), 150);
694        let tree = tree_with(NotificationCenterButton::new(archive).max_badge_count(99));
695        assert!(
696            tree.find_by_label("99+").is_some(),
697            "badge caps at '99+' for counts above max"
698        );
699    }
700
701    #[test]
702    fn scoped_bell_only_counts_its_audience_and_broadcast_unread() {
703        use crate::toast::ToastAudience;
704
705        let archive = Rc::new(NotificationArchiveModel::in_memory());
706        let audience_a = ToastAudience::new(1);
707        let audience_b = ToastAudience::new(2);
708
709        archive.push(entry_with_route("for a", ToastRoute::Audience(audience_a)));
710        archive.push(entry_with_route("for b", ToastRoute::Audience(audience_b)));
711        archive.push(entry_with_route(
712            "for b again",
713            ToastRoute::Audience(audience_b),
714        ));
715        archive.push(entry_with_route("everyone", ToastRoute::Broadcast));
716        assert_eq!(
717            archive.unread_count().get(),
718            4,
719            "the shared archive's global counter sees all four"
720        );
721
722        // Bell scoped to Window(1): no entry is routed to that window,
723        // so only the broadcast one counts → badge shows "1". This
724        // proves window-scoping and audience-scoping are independent:
725        // a window-scoped bell shows only Window(_) + Broadcast, never
726        // an Audience(_) entry.
727        let tree_a = tree_with(
728            NotificationCenterButton::new(archive.clone()).for_window(TeksiloWindowId::new(1)),
729        );
730        assert!(
731            tree_a.find_by_label("1").is_some(),
732            "no entry is routed to Window(1); only the broadcast one should count"
733        );
734
735        // Bell scoped to audience A: "for a" (1) + "everyone" (1) = 2.
736        let tree_scoped_a =
737            tree_with(NotificationCenterButton::new(archive.clone()).for_audience(audience_a));
738        assert!(
739            tree_scoped_a.find_by_label("2").is_some(),
740            "audience A's bell counts its own entry plus the broadcast one"
741        );
742
743        // Bell scoped to audience B: "for b" + "for b again" (2) +
744        // "everyone" (1) = 3.
745        let tree_scoped_b =
746            tree_with(NotificationCenterButton::new(archive.clone()).for_audience(audience_b));
747        assert!(
748            tree_scoped_b.find_by_label("3").is_some(),
749            "audience B's bell counts both of its own entries plus the broadcast one"
750        );
751
752        // Unscoped bell (legacy, back-compat path): sees the whole
753        // shared archive, exactly like before routing existed.
754        let tree_unscoped = tree_with(NotificationCenterButton::new(archive));
755        assert!(
756            tree_unscoped.find_by_label("4").is_some(),
757            "an unscoped bell keeps the old 'see everything' behaviour"
758        );
759    }
760
761    /// End-to-end counterpart of `scoped_bell_only_counts_its_audience_and_broadcast_unread`
762    /// above: that test (and every other one in this file) proves the
763    /// SCOPING FILTER is correct by hand-building `NotificationEntry`
764    /// rows with `entry_with_route`. It never goes through the real
765    /// `ToastRegistry::enqueue` → archive-mirror path, so it can't
766    /// catch a regression in the OTHER half of the seam: whether a
767    /// toast's resolved route actually survives the trip into the
768    /// archive at all (see `toast.rs`'s
769    /// `registry_mirrors_the_resolved_route_onto_the_archived_entry`
770    /// for that half in isolation). This test drives both halves
771    /// together — real toasts, real routes, real archive mirror, real
772    /// scoped bell — the shape a Skribisto per-Work bell actually sees.
773    #[test]
774    fn scoped_bell_reflects_toasts_presented_through_the_real_registry_pipeline() {
775        use crate::toast::host::ToastInstallOptions;
776        use crate::toast::{Toast, ToastAudience, ToastRegistry};
777
778        let archive = Rc::new(NotificationArchiveModel::in_memory());
779        let registry = ToastRegistry::with_archive(
780            ToastInstallOptions {
781                archive: None,
782                ..ToastInstallOptions::default()
783            },
784            archive.clone(),
785        );
786        let audience_a = ToastAudience::new(1);
787        let audience_b = ToastAudience::new(2);
788
789        registry.enqueue(Toast::info(lit!("for a")).target(audience_a));
790        registry.enqueue(Toast::info(lit!("for b")).target(audience_b));
791        registry.enqueue(Toast::warning(lit!("everyone")).broadcast());
792        assert_eq!(
793            archive.unread_count().get(),
794            3,
795            "all three toasts were mirrored into the shared archive"
796        );
797
798        let tree_a =
799            tree_with(NotificationCenterButton::new(archive.clone()).for_audience(audience_a));
800        assert!(
801            tree_a.find_by_label("2").is_some(),
802            "audience A's bell must count its own real toast plus the broadcast one \
803             (2), excluding B's — not 3 (everything) and not 1 (missing the broadcast)"
804        );
805
806        let tree_b = tree_with(NotificationCenterButton::new(archive).for_audience(audience_b));
807        assert!(
808            tree_b.find_by_label("2").is_some(),
809            "audience B's bell must count its own real toast plus the broadcast one, \
810             excluding A's"
811        );
812    }
813
814    #[test]
815    fn scoped_bell_close_only_marks_its_own_entries_read() {
816        use crate::primitives::{FixedSize, Spacer, VStack};
817        use crate::toast::ToastAudience;
818
819        let archive = Rc::new(NotificationArchiveModel::in_memory());
820        let audience_a = ToastAudience::new(1);
821        let audience_b = ToastAudience::new(2);
822        archive.push(entry_with_route("for a", ToastRoute::Audience(audience_a)));
823        archive.push(entry_with_route("for b", ToastRoute::Audience(audience_b)));
824        assert_eq!(archive.unread_count().get(), 2);
825
826        // Mirror `bell_popover_open_check`'s status-bar layout (bell
827        // pinned to the bottom of a normal-sized window via a leading
828        // spacer) rather than the bare `tree_with` 120x60 helper: in a
829        // window that tiny, `BelowPreferred`'s popover has nowhere to
830        // go but directly over the bell, so the second synthesized
831        // click (which re-hit-tests at the bell's screen coordinate)
832        // lands on the popover instead of toggling the trigger closed.
833        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
834        let spacer = tree.add(FixedSize::new().height(500.0).child(Spacer::new()));
835        let bell =
836            tree.add(NotificationCenterButton::new(archive.clone()).for_audience(audience_a));
837        tree.add(VStack::new().child(spacer).child(bell));
838        tree.layout(SizeProposal::exact(400.0, 600.0));
839
840        // Open then close the popover — closing is what triggers the
841        // scoped mark-read.
842        tree.click(bell);
843        tree.layout(SizeProposal::exact(400.0, 600.0));
844        tree.click(bell); // PopoverIconButton toggles: second click closes it.
845        tree.layout(SizeProposal::exact(400.0, 600.0));
846
847        assert_eq!(
848            archive.unread_count().get(),
849            1,
850            "only audience A's entry was marked read; audience B's stays unread"
851        );
852    }
853
854    // -----------------------------------------------------------------
855    // Multi-window delivery — two REAL `NotificationCenterButton`s in
856    // two REAL `WidgetTree`s sharing one archive, mirroring
857    // `toast::host::tests::two_window_hosts`. Every test above builds
858    // at most one tree/bell, so none of them can catch a bell in a
859    // second window silently missing an archive mutation because the
860    // first window's tree already consumed the shared version signal's
861    // change notification — see `NotificationArchiveModel::version_signal`
862    // and `teksilo_core::binding::BindingRegistry` for why one signal
863    // can now serve every window.
864    // -----------------------------------------------------------------
865
866    /// Two independent windows (ids 1 and 2), each with its own
867    /// `WidgetTree` + unscoped `NotificationCenterButton`, both bound
868    /// to ONE shared archive.
869    fn two_window_bells(archive: Rc<NotificationArchiveModel>) -> (WidgetTree, WidgetTree) {
870        use teksilo_core::window::state::WindowStateInit;
871        use teksilo_core::window::{WindowPlacement, WindowState};
872
873        let build_window = |window_id: u64| {
874            let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
875            tree.set_window_state(WindowState::new(WindowStateInit {
876                id: TeksiloWindowId::new(window_id),
877                string_id: Some(format!("w{window_id}")),
878                placement: WindowPlacement::Floating,
879                title: "Test".to_string(),
880                size: (400, 600),
881                position: (0, 0),
882                focused: false,
883                resizable: true,
884                always_on_top: false,
885            }));
886            tree.add(NotificationCenterButton::new(archive.clone()));
887            tree.layout(SizeProposal::exact(400.0, 600.0));
888            tree
889        };
890
891        (build_window(1), build_window(2))
892    }
893
894    /// An archive push must update EVERY open window's bell badge —
895    /// and must keep doing so regardless of which window's
896    /// `WidgetTree` reconciles first, exactly like `WindowManager::
897    /// request_redraw_needing_render` sweeping windows in whatever
898    /// order its internal `HashMap` iterates them.
899    #[test]
900    fn both_unscoped_bells_pick_up_a_badge_change_regardless_of_reconcile_order() {
901        let archive = Rc::new(NotificationArchiveModel::in_memory());
902        let (mut tree1, mut tree2) = two_window_bells(archive.clone());
903        assert!(tree1.find_by_label("1").is_none());
904        assert!(tree2.find_by_label("1").is_none());
905
906        archive.push(entry("new"));
907
908        // Window 1 reconciles first.
909        tree1.layout(SizeProposal::exact(400.0, 600.0));
910        assert!(
911            tree1.find_by_label("1").is_some(),
912            "window 1's bell must show the new unread badge"
913        );
914        // Window 2 reconciles SECOND — this is exactly the case that
915        // silently missed the badge update before per-window signals:
916        // the shared flag was already cleared by window 1's flush.
917        tree2.layout(SizeProposal::exact(400.0, 600.0));
918        assert!(
919            tree2.find_by_label("1").is_some(),
920            "window 2's bell must ALSO show the badge, even reconciling second"
921        );
922    }
923
924    /// Same scenario with the reconcile order flipped, to prove
925    /// delivery genuinely doesn't depend on iteration order.
926    #[test]
927    fn both_unscoped_bells_pick_up_a_badge_change_in_the_reverse_reconcile_order_too() {
928        let archive = Rc::new(NotificationArchiveModel::in_memory());
929        let (mut tree1, mut tree2) = two_window_bells(archive.clone());
930
931        archive.push(entry("new"));
932
933        tree2.layout(SizeProposal::exact(400.0, 600.0));
934        assert!(
935            tree2.find_by_label("1").is_some(),
936            "window 2's bell must show the badge when it reconciles first"
937        );
938        tree1.layout(SizeProposal::exact(400.0, 600.0));
939        assert!(
940            tree1.find_by_label("1").is_some(),
941            "window 1's bell must ALSO show it, even reconciling second"
942        );
943    }
944
945    #[test]
946    fn tooltip_appears_on_hover() {
947        let archive = Rc::new(NotificationArchiveModel::in_memory());
948        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
949        let id = tree.add(NotificationCenterButton::new(archive).tooltip(lit!("Tip")));
950        tree.layout(SizeProposal::exact(300.0, 200.0));
951        tree.pointer_move(tree.bounds(id).center());
952        tree.advance_time(std::time::Duration::from_secs(1));
953        assert_eq!(
954            tree.active_overlays().len(),
955            1,
956            "tooltip should appear on hover"
957        );
958        assert!(tree.find_by_label("Tip").is_some());
959    }
960}