Skip to main content

teksilo_widgets/notification/
log.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `NotificationLog` — a scrollable, day-bucketed list of archived notifications.
5//!
6//! Renders a [`NotificationArchiveModel`] as a scrollable column of
7//! [`StandardListItem`] rows grouped under section headers (Today /
8//! Yesterday / This week / Earlier), computed against the user's local
9//! timezone on every archive mutation. An optional toolbar row provides
10//! mark-all-read and clear buttons. Unread rows show the title in
11//! `BodyBold`; read rows use `Body`. An empty-state hint is shown when the
12//! archive is empty.
13//!
14//! ## Sizing
15//!
16//! The log grows into a host that bounds its height and compresses
17//! inside one shorter than its natural height (floored at one row);
18//! only a host that hugs its content — which is how the overlay layer
19//! measures the [`NotificationCenterButton`](super::center_button::NotificationCenterButton)
20//! popover — falls back to [`preferred_width`](NotificationLog::preferred_width) /
21//! [`preferred_height`](NotificationLog::preferred_height). Row text is
22//! **elided**, not wrapped, with the full text on the row's rich
23//! tooltip: notification prose is arbitrary and the log does not
24//! control its own width, so a wrapping row would over-constrain
25//! itself and push its trailing action buttons out of view.
26//!
27//! ## When to use
28//!
29//! - Embed directly inside a side panel or settings page for an in-app
30//!   notification centre.
31//! - Wrap in [`NotificationCenterButton`](super::center_button::NotificationCenterButton)
32//!   for the standard bell-icon-with-popover pattern.
33//! - Call [`NotificationLogDialog::show`](super::log_dialog::NotificationLogDialog::show)
34//!   for a one-line modal presentation.
35//!
36//! ```ignore
37//! let archive: Rc<NotificationArchiveModel> = ctx.app_state().unwrap();
38//! let log = NotificationLog::new(archive)
39//!     .on_action_invoked(|_entry, action, ctx| {
40//!         if let Some(name) = &action.intent_name {
41//!             ctx.send_intent(teksilo_core::Intent::new(name));
42//!         }
43//!     });
44//! ```
45//!
46//! ## Touch and pen
47//!
48//! A log row is a `StandardListItem`, so its target floor follows the density
49//! ladder with every other list row, and its tap replays the entry on the release.
50//! The trailing action strip is made of `Link`s and `Button`s, each its own target.
51
52use std::rc::Rc;
53use teksilo_i18n::lit;
54
55use teksilo_canvas::{EllipsisMode, Rect, SizeProposal, TextOverflow};
56use teksilo_core::accessibility::AccessNodeBuilder;
57use teksilo_core::binding::BindingLevel;
58use teksilo_core::build_context::BuildContext;
59use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
60use teksilo_core::widget_builder::WidgetBuilder;
61use teksilo_core::widget_id::WidgetId;
62use teksilo_tokens::{InputTokens, TargetRole, TextRole, TextStyleRole};
63
64use crate::button::{Button, ButtonVariant};
65use crate::link::Link;
66use crate::notification::{
67    ArchivedAction, ArchivedActionStyle, NotificationArchiveModel, NotificationEntry, route_visible,
68};
69use crate::primitives::{Center, Expand, HStack, Padding, Shrinkable, Spacer, TextWidget, VStack};
70use crate::scroll_area::ScrollArea;
71use crate::severity_badge::SeverityBadge;
72use crate::standard_item::StandardListItem;
73use crate::styles::recipe_standard_item_style as si;
74use crate::toast::{ToastAudience, ToastRoute};
75use crate::tooltip::TooltipContent;
76use teksilo_core::styles::density::dp;
77use teksilo_core::window::TeksiloWindowId;
78use teksilo_i18n::LocalizedString;
79
80/// Width the log reports when its host proposes an unbounded one.
81/// Wide enough for a severity glyph, a two-line entry and a trailing
82/// action button without eliding the title to a stub.
83const DEFAULT_PREFERRED_WIDTH: f32 = 380.0;
84
85/// [`DEFAULT_PREFERRED_WIDTH`] raised to the density's `target_size`
86/// (24 / 32 / 44 dp). The identity at Compact.
87fn default_preferred_width(tokens: &InputTokens) -> f32 {
88    dp(DEFAULT_PREFERRED_WIDTH, TargetRole::Target, tokens)
89}
90
91/// Height of the scrolling list area when the host proposes an
92/// unbounded height (roughly seven two-line rows).
93const DEFAULT_PREFERRED_HEIGHT: f32 = 320.0;
94
95/// Configurable archive log. Shipped chrome:
96/// - mark-all-read + clear buttons in a toolbar row;
97/// - empty-state hint when the archive is empty;
98/// - day-bucket section headers (Today / Yesterday / This week /
99///   Earlier) above the rows for each bucket — computed against the
100///   user's local timezone, recomputed on every archive mutation;
101/// - [`StandardListItem`] rows with unread-as-bold differentiation.
102///
103/// A SearchField filter and a severity-chip filter can be composed by
104/// apps using the existing widget toolkit.
105pub struct NotificationLog {
106    archive: Rc<NotificationArchiveModel>,
107    show_toolbar: bool,
108    /// Factory, not a stored widget: `build` re-runs on every archive
109    /// mutation (the `version_signal` binding below), and a
110    /// `Box<dyn Widget>` can only be consumed once — the custom empty
111    /// state would vanish the first time the archive went non-empty
112    /// and never come back on the next `clear()`. Same shape as
113    /// [`GridView::empty_view`](crate::grid_view::GridView::empty_view).
114    empty_state: Option<Rc<dyn Fn() -> Box<dyn Widget>>>,
115    on_entry_invoked: Option<Rc<dyn Fn(&NotificationEntry, &mut EventContext)>>,
116    on_action_invoked: Option<Rc<dyn Fn(&NotificationEntry, &ArchivedAction, &mut EventContext)>>,
117    root_child_id: Option<WidgetId>,
118    /// Width used when the host proposes an unbounded one — i.e. when
119    /// it hugs its content, which is exactly what the overlay layer
120    /// does to size a popover. See [`Self::preferred_width`].
121    preferred_width: f32,
122    /// Height of the scrolling list area when the host proposes an
123    /// unbounded height. See [`Self::preferred_height`].
124    preferred_height: f32,
125    /// `None` (default) = unscoped — every entry is shown, matching
126    /// this widget's behaviour before routing existed. `Some(route)`
127    /// restricts the rendered rows AND the toolbar's mark-all-read /
128    /// clear actions to entries matching `route` (plus `Broadcast`,
129    /// always visible). Set via [`Self::for_window`] / [`Self::for_audience`].
130    route_scope: Option<ToastRoute>,
131    /// The toolbar, built once and kept by every later build.
132    toolbar_id: Option<WidgetId>,
133    /// Each row and the entry it shows, as the last build left them: a row
134    /// whose entry has not changed is kept by the next build.
135    rows: Vec<(NotificationEntry, WidgetId)>,
136}
137
138impl NotificationLog {
139    /// Construct a log bound to the shared archive. The archive is
140    /// expected to outlive the log (typically held in `app_state`).
141    pub fn new(archive: Rc<NotificationArchiveModel>) -> Self {
142        Self {
143            archive,
144            show_toolbar: true,
145            empty_state: None,
146            on_entry_invoked: None,
147            on_action_invoked: None,
148            root_child_id: None,
149            preferred_width: DEFAULT_PREFERRED_WIDTH,
150            preferred_height: DEFAULT_PREFERRED_HEIGHT,
151            route_scope: None,
152            toolbar_id: None,
153            rows: Vec::new(),
154        }
155    }
156
157    /// Scope this log to entries routed to window `window_id` (plus
158    /// any `Broadcast` entry) — the shape a `NotificationCenterButton`
159    /// mounted in that window wants for its popover body. Overrides
160    /// any previous `for_window` / `for_audience` call.
161    pub fn for_window(mut self, window_id: TeksiloWindowId) -> Self {
162        self.route_scope = Some(ToastRoute::Window(window_id));
163        self
164    }
165
166    /// Scope this log to entries routed to `audience` (plus any
167    /// `Broadcast` entry). Overrides any previous `for_window` /
168    /// `for_audience` call.
169    pub fn for_audience(mut self, audience: ToastAudience) -> Self {
170        self.route_scope = Some(ToastRoute::Audience(audience));
171        self
172    }
173
174    /// Whether to render the toolbar row (mark-all-read + clear).
175    /// Default `true`. Apps that want a chrome-less log (e.g. inside
176    /// a custom panel that supplies its own toolbar) pass `false`.
177    pub fn show_toolbar(mut self, show: bool) -> Self {
178        self.show_toolbar = show;
179        self
180    }
181
182    /// Override the empty-state hint. Default: a centered
183    /// "No notifications" text. Pass a factory returning any widget
184    /// for a custom empty view (illustration, call-to-action, …).
185    ///
186    /// A factory rather than a widget because the log rebuilds on
187    /// every archive mutation: the view has to be re-creatable each
188    /// time the archive goes empty again, not just the first time.
189    ///
190    /// ```ignore
191    /// log.empty_state(|| Box::new(TextWidget::new(tr!(inbox_zero()))))
192    /// ```
193    pub fn empty_state(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
194        self.empty_state = Some(Rc::new(f));
195        self
196    }
197
198    /// Width the log reports when the host proposes an unbounded one.
199    /// Default `380` dp.
200    ///
201    /// This is load-bearing for the popover presentation
202    /// ([`NotificationCenterButton`](super::center_button::NotificationCenterButton)):
203    /// the overlay layer measures its content with a fully unbounded
204    /// proposal, and a [`StandardListItem`] asked for an intrinsic
205    /// width reports only its chrome, so without a preferred width the
206    /// popover would size itself to whatever the two toolbar buttons
207    /// happen to measure (~248 dp with the stock labels) and elide
208    /// every title to a stub. Hosts that DO bound the width (a dialog,
209    /// a side panel) ignore this value.
210    pub fn preferred_width(mut self, width: f32) -> Self {
211        self.preferred_width = width;
212        self
213    }
214
215    /// Height of the scrolling list area when the host proposes an
216    /// unbounded height. Default `320` dp.
217    ///
218    /// The log always *grows* into a host that bounds its height (it
219    /// reports a flex weight), so this only sets the natural height a
220    /// content-hugging host — again, the popover — sizes itself to.
221    pub fn preferred_height(mut self, height: f32) -> Self {
222        self.preferred_height = height;
223        self
224    }
225
226    /// Called when the user clicks anywhere on an archived entry's
227    /// row body (outside any specific action button). The default
228    /// behaviour is no-op — the log is read-only display unless
229    /// callers wire this hook.
230    pub fn on_entry_invoked(
231        mut self,
232        f: impl Fn(&NotificationEntry, &mut EventContext) + 'static,
233    ) -> Self {
234        self.on_entry_invoked = Some(Rc::new(f));
235        self
236    }
237
238    /// Called when an archived action button is clicked. Apps wire
239    /// this hook to replay the action — typically by mapping the
240    /// `ArchivedAction::intent_name` to one of the app's registered
241    /// `Action`s via `ctx.send_intent(...)`. Without this hook
242    /// configured the action buttons are inert (the log keeps them
243    /// visible for archival context).
244    ///
245    /// Actions without an `intent_name` render as non-clickable
246    /// past-action tags regardless of this hook — there's nothing
247    /// for the framework to dispatch against once the live closure
248    /// has torn down.
249    ///
250    /// ```ignore
251    /// log.on_action_invoked(|_entry, action, ctx| {
252    ///     // Bridge the dynamic intent_name to one of the app's
253    ///     // typed AppIntent variants:
254    ///     match action.intent_name.as_deref() {
255    ///         Some("app.build.retry") => ctx.send_intent(AppIntent::BuildRetry),
256    ///         Some(name) => log::warn!("unknown archived intent: {name}"),
257    ///         None => {}
258    ///     }
259    /// })
260    /// ```
261    pub fn on_action_invoked(
262        mut self,
263        f: impl Fn(&NotificationEntry, &ArchivedAction, &mut EventContext) + 'static,
264    ) -> Self {
265        self.on_action_invoked = Some(Rc::new(f));
266        self
267    }
268
269    fn build_row(
270        entry: &NotificationEntry,
271        on_entry: Option<&Rc<dyn Fn(&NotificationEntry, &mut EventContext)>>,
272        on_action: Option<&Rc<dyn Fn(&NotificationEntry, &ArchivedAction, &mut EventContext)>>,
273    ) -> Box<dyn Widget> {
274        let glyph: Box<dyn Widget> = Box::new(SeverityBadge::new(entry.severity.into(), 14.0));
275        let mut row = StandardListItem::new(lit!(entry.title.clone()))
276            .leading_slot_boxed(glyph)
277            // Unread rows get a bold title (`BodyBold`); read rows
278            // fall back to the StandardListItem default (`Body`).
279            // This is the visual differentiation between "you
280            // haven't seen this yet" and archived history.
281            .label_style(if entry.read {
282                TextStyleRole::Body
283            } else {
284                TextStyleRole::BodyBold
285            })
286            // Notification text is arbitrary app-supplied prose and
287            // the log does not control its own width (popover, dialog,
288            // side panel). `StandardListItem` defaults to
289            // `TextOverflow::Wrap`, which reports the label's FULL
290            // one-line intrinsic width and shrinks for nobody — so a
291            // long title over-constrained the row: the text ran past
292            // the clip edge (with no horizontal scrollbar to reach it)
293            // and the trailing action buttons were pushed clean out of
294            // the row, unreachable. Eliding keeps every row inside its
295            // width and lets the label column absorb the deficit; the
296            // full text stays readable through the row tooltip below.
297            .label_overflow(TextOverflow::Ellipsis(EllipsisMode::Trailing))
298            .subtitle_overflow(TextOverflow::Ellipsis(EllipsisMode::Trailing));
299        if let Some(body) = &entry.body {
300            row = row.subtitle(lit!(body.clone()));
301        }
302        // Full, un-elided text on hover. The *rich* tier, not the plain
303        // one: `TooltipWidget` is single-line, so a long body would
304        // render as one enormous streak, while `RichTooltipWidget`
305        // clamps to the theme's tooltip max-width and wraps.
306        row = row.rich_tooltip_content(TooltipContent::new(
307            format!("notification.entry.{}", entry.id),
308            lit!(match &entry.body {
309                Some(body) => format!("{}\n{}", entry.title, body),
310                None => entry.title.clone(),
311            }),
312        ));
313        if !entry.actions.is_empty() {
314            // Trailing action strip — Links inline, Buttons as buttons.
315            // The trailing slot accepts a single widget; we wrap the
316            // multiple actions in an HStack.
317            let actions_row = build_actions_row(entry, on_action.cloned());
318            row = row.trailing_slot_boxed(actions_row);
319        }
320        // Wire the body-click handler if requested.
321        if let Some(cb) = on_entry {
322            let cb = cb.clone();
323            let entry_clone = entry.clone();
324            Box::new(
325                row.on_tap(move |_event, ctx| {
326                    cb(&entry_clone, ctx);
327                })
328                .cursor(teksilo_core::widget::CursorIcon::Pointer),
329            )
330        } else {
331            Box::new(row)
332        }
333    }
334}
335
336impl std::fmt::Debug for NotificationLog {
337    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338        f.debug_struct("NotificationLog")
339            .field("archive_entries", &self.archive.entries().len())
340            .field("show_toolbar", &self.show_toolbar)
341            .field("has_empty_state", &self.empty_state.is_some())
342            .field("preferred_width", &self.preferred_width)
343            .field("preferred_height", &self.preferred_height)
344            .finish_non_exhaustive()
345    }
346}
347
348impl Widget for NotificationLog {
349    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
350        let archive = self.archive.clone();
351        let on_entry = self.on_entry_invoked.clone();
352        let on_action = self.on_action_invoked.clone();
353
354        // Rebuild the log whenever the archive mutates (push,
355        // in-place merge, mark_all_read, clear, remove). The
356        // day-bucket headers must re-compute when entries appear /
357        // disappear, and StandardListItem's read/unread title style
358        // needs to flip on mark_all_read.
359        //
360        // The rebuild reconciles (`preserves_children_on_rebuild`): the
361        // toolbar and every row whose entry did not change are kept, so
362        // a background job archiving its progress every few hundred
363        // milliseconds does not take focus off the button or the row the
364        // reader is on.
365        //
366        // One signal for every window's log — a log embedded in one
367        // window shares the signal with a log (or bell) in another
368        // without either being able to consume the other's rebuild.
369        archive.version_signal().bind_to(
370            ctx.self_id(),
371            ctx.binding_registry(),
372            BindingLevel::Rebuild,
373        );
374
375        let scope = self.route_scope;
376
377        // Snapshot + filter entries up front — both the empty-state
378        // decision and the toolbar/section rendering below need the
379        // SCOPED view, not the raw archive (a scoped log must look
380        // empty when only other windows'/audiences' entries exist,
381        // not fall through to the unscoped empty-state check).
382        let model = archive.entries();
383        let entries: Vec<NotificationEntry> = (0..model.len())
384            .filter_map(|i| model.with_item(i, |e| e.clone()))
385            .filter(|e| route_visible(e.route, scope))
386            .collect();
387
388        let mut column = VStack::new().spacing(6.0);
389
390        // Toolbar row. Mark-read/clear are scoped identically to the
391        // rendered rows: a scoped log must only affect ITS entries —
392        // reaching for the unscoped `mark_all_read`/`clear` from a
393        // scoped log would incorrectly touch every other window's or
394        // audience's history too.
395        if let Some(toolbar) = self.toolbar_id.filter(|_| self.show_toolbar) {
396            column = column.child(toolbar);
397        } else if self.show_toolbar {
398            let archive_for_mark = archive.clone();
399            let archive_for_clear = archive.clone();
400            let toolbar = HStack::new()
401                .spacing(8.0)
402                .child(ctx.add(Spacer::new()))
403                .child(
404                    ctx.add(
405                        Button::new(teksilo_i18n::tr_widget!(notifications_mark_all_read()))
406                            .variant(ButtonVariant::Plain)
407                            .on_activate_fn(move |_| match scope {
408                                Some(s) => archive_for_mark
409                                    .mark_read_where(|e| route_visible(e.route, Some(s))),
410                                None => archive_for_mark.mark_all_read(),
411                            }),
412                    ),
413                )
414                .child(
415                    ctx.add(
416                        Button::new(teksilo_i18n::tr_widget!(notifications_clear()))
417                            .variant(ButtonVariant::Plain)
418                            .on_activate_fn(move |ctx| {
419                                match scope {
420                                    Some(s) => archive_for_clear
421                                        .clear_where(|e| route_visible(e.route, Some(s))),
422                                    None => archive_for_clear.clear(),
423                                }
424                                // The rows go and the reader stays on the
425                                // button: say what is left.
426                                ctx.announce(
427                                    teksilo_i18n::tr_widget!(notifications_empty()).resolve_now(),
428                                );
429                            }),
430                    ),
431                );
432            let toolbar = ctx.add(toolbar);
433            self.toolbar_id = Some(toolbar);
434            column = column.child(toolbar);
435        }
436
437        // Empty state or bucketed sections — against the SCOPED
438        // entries snapshot taken above, not the raw archive.
439        if entries.is_empty() {
440            self.rows.clear();
441            let empty = match &self.empty_state {
442                Some(factory) => ctx.add_boxed(factory()),
443                None => ctx.add(
444                    TextWidget::new(teksilo_i18n::tr_widget!(notifications_empty()))
445                        .color(TextRole::Secondary)
446                        .style(TextStyleRole::Body),
447                ),
448            };
449            // Centred in whatever room the host leaves, as documented —
450            // it used to sit flush against the top-leading corner, which
451            // in a 720x520 dialog read as a stray line of grey text.
452            // `Expand::vertical` claims the slack without competing for
453            // the horizontal axis; `Center` does the centring (a bare
454            // `Center` reports no flex, so it would not claim anything).
455            column = column.child(
456                ctx.add(
457                    Expand::vertical()
458                        .flex(1.0)
459                        .child(Center::new().child(empty)),
460                ),
461            );
462        } else {
463            // Compute buckets relative to the local "today". Bucket
464            // transitions across midnight are recomputed on the next
465            // archive mutation (the log's version-signal binding); a
466            // log that stays open across midnight without any push
467            // will keep stale labels until the user closes / reopens
468            // it — acceptable for a popover-shaped UI.
469            let now = jiff::Zoned::now();
470            let today = now.date();
471            let zone = now.time_zone().clone();
472
473            let mut sections = VStack::new().spacing(8.0);
474            let mut current_bucket: Option<DayBucket> = None;
475            let previous_rows = std::mem::take(&mut self.rows);
476            for entry in &entries {
477                let bucket = day_bucket_for(entry.timestamp, today, &zone);
478                if Some(bucket) != current_bucket {
479                    let header = TextWidget::new(bucket_label(bucket))
480                        .style(TextStyleRole::SmallBold)
481                        .color(TextRole::Secondary);
482                    // Indent to the rows' content inset so the header
483                    // lines up with the severity glyph below it instead
484                    // of hanging 8 dp further left than every row.
485                    sections = sections.child(ctx.add(
486                        Padding::symmetric(0.0, si::STANDARD_ITEM_PADDING_HORIZONTAL).child(header),
487                    ));
488                    current_bucket = Some(bucket);
489                }
490                let row = match previous_rows.iter().find(|(shown, _)| shown == entry) {
491                    Some((_, row)) => *row,
492                    None => ctx.add_boxed(Self::build_row(
493                        entry,
494                        on_entry.as_ref(),
495                        on_action.as_ref(),
496                    )),
497                };
498                self.rows.push((entry.clone(), row));
499                sections = sections.child(row);
500            }
501            // Wrap in ScrollArea so the dialog/popover scrolls when
502            // the archive grows past the visible height.
503            //
504            // `ScrollArea` deliberately takes its height from its
505            // PARENT (else it grows to fit its content and never
506            // scrolls), falling back to a fixed default when the
507            // parent offers none. A bare `ScrollArea` in this VStack
508            // is rigid, so it kept that fallback height in every host:
509            // a 520 dp dialog rendered a 200 dp list with ~300 dp of
510            // dead space below it. `Expand::vertical` gives it the
511            // flex weight to claim the leftover; `respect_intrinsic`
512            // keeps the preferred height as the floor so a
513            // content-hugging host (the popover) still gets a sized
514            // list instead of a zero-basis collapse.
515            //
516            // `Shrinkable` on the outside handles the mirror case: a
517            // host SHORTER than the preferred height. `Expand` reports
518            // `shrink = 0`, so on its own the list would keep its full
519            // wanted height and spill out the bottom of a short panel.
520            // `Shrinkable` preserves the inner flex weight and adds the
521            // compression path, floored at one two-line row: the
522            // height the active `StandardItemStyle` gives a row with a
523            // subtitle, which is what every log row is.
524            let one_row =
525                si::resolve_standard_item_style(ctx).min_height_two_line(&ctx.theme().input);
526            let scrollable = ScrollArea::new()
527                .preferred_height(self.preferred_height)
528                .child(sections);
529            column = column.child(
530                ctx.add(
531                    Shrinkable::new().min_height(one_row).child(
532                        Expand::vertical()
533                            .flex(1.0)
534                            .respect_intrinsic()
535                            .child(scrollable),
536                    ),
537                ),
538            );
539        }
540
541        let root = ctx.add(column);
542        self.root_child_id = Some(root);
543        vec![root]
544    }
545
546    fn layout_response(
547        &self,
548        proposal: SizeProposal,
549        ctx: &LayoutContext,
550    ) -> teksilo_core::widget::LayoutResponse {
551        // On an unbounded width the host is hugging its content (the
552        // overlay layer measures popover content with a fully
553        // unbounded proposal). Rows cannot answer that — a
554        // `StandardListItem` reports only its chrome width when asked
555        // for an intrinsic one — so substitute the preferred width and
556        // let the rows lay out inside it.
557        let effective = SizeProposal {
558            width: proposal.width.or(Some(self.preferred_width)),
559            height: proposal.height,
560        };
561        // Forward the child's FULL response — flattening it to a
562        // `Size` (via `child_size`) reports flex 0 / shrink 0 / min =
563        // size, i.e. a rigid log that neither grows into a tall dialog
564        // nor compresses inside a short one, whatever the inner
565        // column says. Same rule the `DeadZone` wrapper follows.
566        self.root_child_id
567            .and_then(|id| ctx.child_layout_response(id, effective))
568            .unwrap_or_else(|| {
569                effective
570                    .resolve(self.preferred_width, self.preferred_height)
571                    .into()
572            })
573    }
574
575    fn place_children(
576        &self,
577        bounds: Rect,
578        _proposal: SizeProposal,
579        children: &mut [WidgetPlacement],
580        _ctx: &LayoutContext,
581    ) {
582        for child in children.iter_mut() {
583            child.origin = bounds.origin();
584            child.size = bounds.size();
585        }
586    }
587
588    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
589        builder.set_role(teksilo_core::accesskit::Role::List);
590        builder.set_name(teksilo_i18n::tr_widget!(notifications_title()).resolve_now());
591    }
592
593    fn children(&self) -> Vec<WidgetId> {
594        self.root_child_id.into_iter().collect()
595    }
596
597    fn preserves_children_on_rebuild(&self) -> bool {
598        // Keeps the toolbar and the unchanged rows; see `build`.
599        true
600    }
601}
602
603/// Build the trailing-slot HStack of action widgets for one entry.
604/// `intent_name`-bearing actions become clickable Link / Button
605/// widgets that invoke the caller-supplied `on_action_invoked` hook.
606/// Actions without `intent_name` (or without the hook set) render
607/// as disabled descriptive labels — the log keeps the action
608/// visible for archival context but the closure that powered the
609/// live toast is long gone.
610fn build_actions_row(
611    entry: &NotificationEntry,
612    on_action: Option<Rc<dyn Fn(&NotificationEntry, &ArchivedAction, &mut EventContext)>>,
613) -> Box<dyn Widget> {
614    let mut row = HStack::new().spacing(8.0);
615    for action in entry.actions.iter() {
616        let action_owned = action.clone();
617        let entry_owned = entry.clone();
618        let on_action_for_handler = on_action.clone();
619        let clickable = action.intent_name.is_some() && on_action_for_handler.is_some();
620
621        if !clickable {
622            // Non-clickable: descriptive tag.
623            let label = format!(
624                "{} {}",
625                action.label,
626                teksilo_i18n::tr_widget!(notifications_archive_replay_disabled()).resolve_now()
627            );
628            row = row.child(
629                TextWidget::new(lit!(label))
630                    .style(TextStyleRole::Small)
631                    .color(TextRole::Secondary),
632            );
633            continue;
634        }
635
636        let activate = move |ctx: &mut EventContext| {
637            if let Some(cb) = on_action_for_handler.as_ref() {
638                cb(&entry_owned, &action_owned, ctx);
639            }
640        };
641        row = match action.style {
642            ArchivedActionStyle::Link => {
643                row.child(Link::new(lit!(action.label.clone())).on_activate_fn(activate))
644            }
645            ArchivedActionStyle::PrimaryButton => row.child(
646                Button::new(lit!(action.label.clone()))
647                    .variant(ButtonVariant::Filled)
648                    .on_activate_fn(activate),
649            ),
650            ArchivedActionStyle::SecondaryButton => row.child(
651                Button::new(lit!(action.label.clone()))
652                    .variant(ButtonVariant::Plain)
653                    .on_activate_fn(activate),
654            ),
655            ArchivedActionStyle::Destructive => row.child(
656                Button::new(lit!(action.label.clone()))
657                    .variant(ButtonVariant::Destructive)
658                    .on_activate_fn(activate),
659            ),
660        };
661    }
662    Box::new(row)
663}
664
665// ------------------------------------------------------------------
666// Day-bucket section headers
667// ------------------------------------------------------------------
668
669/// Coarse time bucket — drives the section header that appears
670/// above the first entry of each bucket in the log.
671#[derive(Debug, Clone, Copy, PartialEq, Eq)]
672enum DayBucket {
673    /// Same local-calendar date as `today`.
674    Today,
675    /// `today - 1` day.
676    Yesterday,
677    /// 2..=6 days ago (within the same calendar week conceptually).
678    ThisWeek,
679    /// 7 or more days ago.
680    Earlier,
681}
682
683/// Compute the bucket for an archive entry. Uses the user's local
684/// timezone to map the entry's UTC timestamp onto a calendar date,
685/// then compares against `today` (also in the local timezone).
686///
687/// Two entries that landed within the same local-calendar date both
688/// get `Today`, regardless of the UTC hours between them — that's
689/// the user-facing notion of "today".
690fn day_bucket_for(
691    timestamp: jiff::Timestamp,
692    today: jiff::civil::Date,
693    zone: &jiff::tz::TimeZone,
694) -> DayBucket {
695    let entry_date = timestamp.to_zoned(zone.clone()).date();
696    // Compare via day delta. `today - entry_date` returns a Span;
697    // we extract the day count. Future entries (clock skew, sync
698    // from a peer) bucket as Today so they don't slip into Earlier
699    // by accident.
700    let delta_days = today
701        .since(entry_date)
702        .map(|span| span.get_days())
703        .unwrap_or(0);
704    if delta_days <= 0 {
705        DayBucket::Today
706    } else if delta_days == 1 {
707        DayBucket::Yesterday
708    } else if delta_days <= 6 {
709        DayBucket::ThisWeek
710    } else {
711        DayBucket::Earlier
712    }
713}
714
715fn bucket_label(bucket: DayBucket) -> LocalizedString {
716    match bucket {
717        DayBucket::Today => teksilo_i18n::tr_widget!(notifications_bucket_today()),
718        DayBucket::Yesterday => teksilo_i18n::tr_widget!(notifications_bucket_yesterday()),
719        DayBucket::ThisWeek => teksilo_i18n::tr_widget!(notifications_bucket_this_week()),
720        DayBucket::Earlier => teksilo_i18n::tr_widget!(notifications_bucket_earlier()),
721    }
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727    use crate::notification::{ArchivedActionStyle, NotificationArchiveModel};
728    use teksilo_core::styles::{BannerSeverity, ToastPriority};
729    use teksilo_core::widget_tree::WidgetTree;
730
731    fn entry(title: &str, body: Option<&str>, actions: Vec<ArchivedAction>) -> NotificationEntry {
732        NotificationEntry {
733            id: 0,
734            severity: BannerSeverity::Info,
735            priority: ToastPriority::Normal,
736            title: title.to_string(),
737            body: body.map(|s| s.to_string()),
738            actions,
739            timestamp: jiff::Timestamp::UNIX_EPOCH,
740            group: None,
741            source: None,
742            read: false,
743            dedup_id: None,
744            updates: Vec::new(),
745            route: ToastRoute::Broadcast,
746        }
747    }
748
749    fn fresh_archive() -> Rc<NotificationArchiveModel> {
750        Rc::new(NotificationArchiveModel::in_memory())
751    }
752
753    fn tree_with(log: NotificationLog) -> WidgetTree {
754        let (tree, _) = tree_sized(log, SizeProposal::exact(480.0, 360.0));
755        tree
756    }
757
758    /// Mount `log` as the root at an explicit proposal and hand back
759    /// its `WidgetId` too, for the layout assertions below. A real text
760    /// backend is installed (fixed 8 dp/char) — without one every
761    /// string measures zero and no width assertion means anything.
762    fn tree_sized(log: NotificationLog, proposal: SizeProposal) -> (WidgetTree, WidgetId) {
763        let mut tree = WidgetTree::new()
764            .with_theme(teksilo_core::presets::intui::light())
765            .with_text_backend(Rc::new(std::cell::RefCell::new(
766                teksilo_canvas::MockTextBackend::new(),
767            )));
768        let id = tree.add(log);
769        tree.layout(proposal);
770        (tree, id)
771    }
772
773    /// First widget in `root`'s subtree whose concrete type name ends
774    /// with `suffix` (e.g. `"ScrollArea"`), in walk order.
775    fn find_by_type(tree: &WidgetTree, root: WidgetId, suffix: &str) -> Option<WidgetId> {
776        if tree
777            .widget_type_name(root)
778            .is_some_and(|n| n.ends_with(suffix))
779        {
780            return Some(root);
781        }
782        tree.children(root)
783            .into_iter()
784            .find_map(|c| find_by_type(tree, c, suffix))
785    }
786
787    /// A finger's tap on a log row replays its entry, on the release. The row is
788    /// a `StandardListItem`, so its own target floor follows the density ladder
789    /// with every other list row (`tests/composite_touch.rs`); what this pins is
790    /// that the row's tap is reachable by a contact at all.
791    #[test]
792    fn a_finger_taps_a_log_row_to_replay_its_entry() {
793        use std::cell::Cell;
794
795        let archive = fresh_archive();
796        archive.push(entry("first", Some("body 1"), Vec::new()));
797        let invoked = Rc::new(Cell::new(0u32));
798        let sink = invoked.clone();
799        let (mut tree, root) = tree_sized(
800            NotificationLog::new(archive).on_entry_invoked(move |_e, _ctx| {
801                sink.set(sink.get() + 1);
802            }),
803            SizeProposal::exact(480.0, 360.0),
804        );
805        // Matched on `contains`, so the row is found whether or not the handler
806        // wrapper its `on_tap` puts around it reports a name of its own — which
807        // is why this walk is local rather than the module's `ends_with` one.
808        fn find_row(tree: &WidgetTree, id: WidgetId) -> Option<WidgetId> {
809            if tree
810                .widget_type_name(id)
811                .is_some_and(|n| n.contains("StandardListItem"))
812            {
813                return Some(id);
814            }
815            tree.children(id)
816                .into_iter()
817                .find_map(|c| find_row(tree, c))
818        }
819        let row = find_row(&tree, root).expect("a log row");
820        let bounds = tree.bounds(row);
821        let at = teksilo_canvas::Point::new(
822            bounds.x + bounds.width * 0.25,
823            bounds.y + bounds.height * 0.5,
824        );
825
826        let f = tree.new_contact();
827        tree.touch_down(f, at);
828        assert_eq!(invoked.get(), 0, "the press alone replays nothing");
829        tree.touch_up(f, at);
830        assert_eq!(invoked.get(), 1, "the release replays the entry");
831    }
832
833    #[test]
834    fn empty_archive_renders_empty_state() {
835        let archive = fresh_archive();
836        let tree = tree_with(NotificationLog::new(archive));
837        // Empty-state text is the localized "No notifications".
838        let expected = teksilo_i18n::tr_widget!(notifications_empty()).resolve_now();
839        assert!(
840            tree.find_by_label(&expected).is_some(),
841            "empty-state hint must be in the AT tree when the archive is empty"
842        );
843    }
844
845    #[test]
846    fn populated_archive_renders_list_role() {
847        let archive = fresh_archive();
848        archive.push(entry("first", Some("body 1"), Vec::new()));
849        archive.push(entry("second", None, Vec::new()));
850        let tree = tree_with(NotificationLog::new(archive));
851        // Log's own root carries Role::List.
852        let list_role = tree.find_by_role(teksilo_core::accesskit::Role::List);
853        assert!(list_role.is_some(), "Log root exposes Role::List");
854    }
855
856    #[test]
857    fn intent_action_without_callback_is_inert() {
858        // An ArchivedAction with intent_name + no on_action_invoked
859        // hook installed → renders as a non-clickable tag (no
860        // Button widget appears for that action).
861        let archive = fresh_archive();
862        archive.push(entry(
863            "Build failed",
864            None,
865            vec![ArchivedAction {
866                label: "Retry".into(),
867                intent_name: Some("app.build.retry".into()),
868                style: ArchivedActionStyle::PrimaryButton,
869                closes_on_invoke: true,
870            }],
871        ));
872        let tree = tree_with(NotificationLog::new(archive));
873        // The inert tag's label is "Retry" + the localized
874        // "(no longer available)" suffix; an exact lookup of just
875        // "Retry" misses — that's the contract.
876        assert!(
877            tree.find_by_label("Retry").is_none(),
878            "without on_action_invoked, archive actions render as inert text tags with a \
879             suffix — no exact-'Retry' label appears"
880        );
881    }
882
883    // ----- Layout -----
884
885    /// The scrolling list claims the height its host offers. It used to
886    /// report `ScrollArea`'s fixed fallback height in every host, so a
887    /// 600 dp panel rendered a 200 dp list over 370 dp of dead space.
888    #[test]
889    fn the_list_area_fills_the_height_its_host_offers() {
890        let archive = fresh_archive();
891        for i in 0..8 {
892            archive.push(entry(&format!("Notice {i}"), Some("body"), Vec::new()));
893        }
894        let (tree, root) = tree_sized(
895            NotificationLog::new(archive),
896            SizeProposal::exact(480.0, 600.0),
897        );
898        let scroll = find_by_type(&tree, root, "ScrollArea").expect("log has a ScrollArea");
899        let scroll_h = tree.bounds(scroll).height;
900        let root_h = tree.bounds(root).height;
901        // Everything below the toolbar row belongs to the list.
902        assert!(
903            scroll_h > root_h - 60.0,
904            "list must fill the host height: list {scroll_h} in a {root_h} tall log"
905        );
906    }
907
908    /// The mirror case: a host SHORTER than the preferred height
909    /// compresses the list rather than spilling out the bottom.
910    #[test]
911    fn the_list_area_compresses_inside_a_short_host() {
912        let archive = fresh_archive();
913        for i in 0..8 {
914            archive.push(entry(&format!("Notice {i}"), Some("body"), Vec::new()));
915        }
916        let (tree, root) = tree_sized(
917            NotificationLog::new(archive),
918            SizeProposal::exact(480.0, 160.0),
919        );
920        let scroll = find_by_type(&tree, root, "ScrollArea").expect("log has a ScrollArea");
921        let b = tree.bounds(scroll);
922        assert!(
923            b.y + b.height <= 160.0 + 0.01,
924            "list bottom {} must stay inside the 160 dp host",
925            b.y + b.height
926        );
927    }
928
929    /// In a host far too short for it, the list stops compressing at one
930    /// row: the two-line row height the active `StandardItemStyle` declares,
931    /// not a constant. A style declaring 90 dp rows floors the list at 90.
932    #[test]
933    fn the_compressed_list_floors_at_the_styles_two_line_row_height() {
934        use teksilo_core::styles::{StandardItemStyle, StandardItemStyleConfig};
935
936        struct TallRows;
937        impl StandardItemStyle for TallRows {
938            fn make_body(&self, cfg: &StandardItemStyleConfig, ctx: &mut BuildContext) -> WidgetId {
939                si::RecipeStandardItemStyle::default().make_body(cfg, ctx)
940            }
941            fn min_height_two_line(&self, _tokens: &InputTokens) -> f32 {
942                90.0
943            }
944        }
945
946        let archive = fresh_archive();
947        for i in 0..8 {
948            archive.push(entry(&format!("Notice {i}"), Some("body"), Vec::new()));
949        }
950        let mut theme = teksilo_core::presets::intui::light();
951        theme.style_slots.standard_item = Some(Rc::new(TallRows));
952        let mut tree = WidgetTree::new()
953            .with_theme(theme)
954            .with_text_backend(Rc::new(std::cell::RefCell::new(
955                teksilo_canvas::MockTextBackend::new(),
956            )));
957        let root = tree.add(NotificationLog::new(archive));
958        tree.layout(SizeProposal::exact(480.0, 20.0));
959        let floor = find_by_type(&tree, root, "Shrinkable").expect("the list is Shrinkable");
960        assert!(
961            (tree.bounds(floor).height - 90.0).abs() < 0.01,
962            "the list must floor at the style's two-line row height, got {}",
963            tree.bounds(floor).height
964        );
965    }
966
967    /// The readability regression: a title longer than the row is
968    /// elided inside it. With `StandardListItem`'s wrapping default the
969    /// label reported its full one-line intrinsic width, over-
970    /// constrained the row, and shoved the trailing action button clean
971    /// outside the row — clipped, unclickable, and with no horizontal
972    /// scrollbar to reach it.
973    #[test]
974    fn a_long_title_keeps_the_action_button_inside_the_row() {
975        let archive = fresh_archive();
976        archive.push(entry(
977            "Build failed for target aarch64-unknown-linux-gnu after 42 seconds",
978            Some("the linker could not resolve symbol __teksilo_frobnicate_v2"),
979            vec![ArchivedAction {
980                label: "Retry".into(),
981                intent_name: Some("app.build.retry".into()),
982                style: ArchivedActionStyle::PrimaryButton,
983                closes_on_invoke: true,
984            }],
985        ));
986        let (tree, root) = tree_sized(
987            NotificationLog::new(archive).on_action_invoked(|_e, _a, _c| {}),
988            SizeProposal::exact(320.0, 400.0),
989        );
990        let button = tree.find_by_label("Retry").expect("Retry button");
991        let b = tree.bounds(button);
992        let right_edge = tree.bounds(root).width;
993        assert!(
994            b.x + b.width <= right_edge + 0.01,
995            "action button right edge {} must stay within the {right_edge} dp row",
996            b.x + b.width
997        );
998    }
999
1000    /// A content-hugging host — which is exactly how the overlay layer
1001    /// measures a popover — gets the preferred width, not whatever the
1002    /// two toolbar buttons happen to measure (~248 dp, the old result).
1003    #[test]
1004    fn a_content_hugging_host_gets_the_preferred_width() {
1005        let archive = fresh_archive();
1006        archive.push(entry("Export finished", Some("14 chapters"), Vec::new()));
1007        let unbounded = SizeProposal {
1008            width: None,
1009            height: None,
1010        };
1011        let (tree, root) = tree_sized(NotificationLog::new(archive.clone()), unbounded);
1012        assert!(
1013            (tree.bounds(root).width - DEFAULT_PREFERRED_WIDTH).abs() < 0.01,
1014            "unbounded width = {}, expected the preferred {DEFAULT_PREFERRED_WIDTH}",
1015            tree.bounds(root).width
1016        );
1017
1018        let (tree, root) = tree_sized(
1019            NotificationLog::new(archive).preferred_width(520.0),
1020            unbounded,
1021        );
1022        assert!(
1023            (tree.bounds(root).width - 520.0).abs() < 0.01,
1024            "preferred_width override ignored: got {}",
1025            tree.bounds(root).width
1026        );
1027    }
1028
1029    /// The empty state is a factory because `build` re-runs on every
1030    /// archive mutation: a stored `Box<dyn Widget>` was consumed by the
1031    /// first build and the custom view never came back.
1032    #[test]
1033    fn a_custom_empty_state_survives_a_rebuild() {
1034        let archive = fresh_archive();
1035        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1036        tree.add(
1037            NotificationLog::new(archive.clone())
1038                .empty_state(|| Box::new(TextWidget::new(lit!("Inbox zero")))),
1039        );
1040        tree.layout(SizeProposal::exact(480.0, 360.0));
1041        assert!(
1042            tree.find_by_label("Inbox zero").is_some(),
1043            "shown initially"
1044        );
1045
1046        archive.push(entry("Notice", None, Vec::new()));
1047        tree.layout(SizeProposal::exact(480.0, 360.0));
1048        archive.clear();
1049        tree.layout(SizeProposal::exact(480.0, 360.0));
1050        assert!(
1051            tree.find_by_label("Inbox zero").is_some(),
1052            "the custom empty state must come back when the archive empties again"
1053        );
1054    }
1055
1056    // ----- Day-bucket helper -----
1057
1058    fn ts(year: i16, month: i8, day: i8, hour: i8, minute: i8) -> jiff::Timestamp {
1059        let utc_zone = jiff::tz::TimeZone::UTC;
1060        jiff::civil::DateTime::new(year, month, day, hour, minute, 0, 0)
1061            .unwrap()
1062            .to_zoned(utc_zone)
1063            .unwrap()
1064            .timestamp()
1065    }
1066
1067    #[test]
1068    fn day_bucket_today_for_same_calendar_date() {
1069        let today = jiff::civil::Date::new(2025, 5, 17).unwrap();
1070        // Same date, different hour → Today.
1071        let entry = ts(2025, 5, 17, 8, 30);
1072        assert_eq!(
1073            day_bucket_for(entry, today, &jiff::tz::TimeZone::UTC),
1074            DayBucket::Today
1075        );
1076    }
1077
1078    #[test]
1079    fn day_bucket_yesterday_for_t_minus_one() {
1080        let today = jiff::civil::Date::new(2025, 5, 17).unwrap();
1081        let entry = ts(2025, 5, 16, 23, 0);
1082        assert_eq!(
1083            day_bucket_for(entry, today, &jiff::tz::TimeZone::UTC),
1084            DayBucket::Yesterday
1085        );
1086    }
1087
1088    #[test]
1089    fn day_bucket_this_week_for_2_to_6_days_ago() {
1090        let today = jiff::civil::Date::new(2025, 5, 17).unwrap();
1091        for days_ago in 2..=6 {
1092            let date = today
1093                .checked_sub(jiff::ToSpan::days(days_ago as i64))
1094                .unwrap();
1095            let entry = ts(date.year(), date.month(), date.day(), 12, 0);
1096            assert_eq!(
1097                day_bucket_for(entry, today, &jiff::tz::TimeZone::UTC),
1098                DayBucket::ThisWeek,
1099                "{days_ago} days ago must bucket as ThisWeek"
1100            );
1101        }
1102    }
1103
1104    #[test]
1105    fn day_bucket_earlier_for_7_plus_days_ago() {
1106        let today = jiff::civil::Date::new(2025, 5, 17).unwrap();
1107        let week_ago_date = today.checked_sub(jiff::ToSpan::days(7)).unwrap();
1108        let entry = ts(
1109            week_ago_date.year(),
1110            week_ago_date.month(),
1111            week_ago_date.day(),
1112            12,
1113            0,
1114        );
1115        assert_eq!(
1116            day_bucket_for(entry, today, &jiff::tz::TimeZone::UTC),
1117            DayBucket::Earlier
1118        );
1119    }
1120
1121    #[test]
1122    fn day_bucket_future_entries_count_as_today() {
1123        // Clock skew or peer sync — an entry stamped in the future
1124        // (delta_days < 0) should be considered Today, not Earlier.
1125        let today = jiff::civil::Date::new(2025, 5, 17).unwrap();
1126        let entry = ts(2025, 5, 18, 0, 0);
1127        assert_eq!(
1128            day_bucket_for(entry, today, &jiff::tz::TimeZone::UTC),
1129            DayBucket::Today
1130        );
1131    }
1132
1133    #[test]
1134    fn day_bucket_label_resolves_through_i18n() {
1135        // Sanity-check that each variant maps to a non-empty
1136        // localized string (the en-US source bundle is present in
1137        // test runs).
1138        for bucket in [
1139            DayBucket::Today,
1140            DayBucket::Yesterday,
1141            DayBucket::ThisWeek,
1142            DayBucket::Earlier,
1143        ] {
1144            let label = bucket_label(bucket).resolve_now();
1145            assert!(!label.is_empty(), "{bucket:?} has an empty label");
1146        }
1147    }
1148
1149    #[test]
1150    fn log_with_entries_across_buckets_renders_each_header() {
1151        // Push entries with timestamps in different buckets and
1152        // confirm each bucket header text appears in the AT tree.
1153        let archive = fresh_archive();
1154        let now = jiff::Zoned::now();
1155        let today = now.date();
1156        let zone = now.time_zone().clone();
1157        let today_ts = today
1158            .at(12, 0, 0, 0)
1159            .to_zoned(zone.clone())
1160            .unwrap()
1161            .timestamp();
1162        let yesterday_ts = today
1163            .checked_sub(jiff::ToSpan::days(1))
1164            .unwrap()
1165            .at(12, 0, 0, 0)
1166            .to_zoned(zone.clone())
1167            .unwrap()
1168            .timestamp();
1169        let earlier_ts = today
1170            .checked_sub(jiff::ToSpan::days(30))
1171            .unwrap()
1172            .at(12, 0, 0, 0)
1173            .to_zoned(zone)
1174            .unwrap()
1175            .timestamp();
1176
1177        // Push oldest first so the newest (Today) ends up at index 0.
1178        let mut earlier = entry("Very old notice", None, Vec::new());
1179        earlier.timestamp = earlier_ts;
1180        archive.push(earlier);
1181        let mut yesterday = entry("Yesterday's notice", None, Vec::new());
1182        yesterday.timestamp = yesterday_ts;
1183        archive.push(yesterday);
1184        let mut today_entry = entry("Today's notice", None, Vec::new());
1185        today_entry.timestamp = today_ts;
1186        archive.push(today_entry);
1187
1188        let tree = tree_with(NotificationLog::new(archive));
1189
1190        let today_label = teksilo_i18n::tr_widget!(notifications_bucket_today()).resolve_now();
1191        let yesterday_label =
1192            teksilo_i18n::tr_widget!(notifications_bucket_yesterday()).resolve_now();
1193        let earlier_label = teksilo_i18n::tr_widget!(notifications_bucket_earlier()).resolve_now();
1194
1195        assert!(
1196            tree.find_by_label(&today_label).is_some(),
1197            "Today header must appear"
1198        );
1199        assert!(
1200            tree.find_by_label(&yesterday_label).is_some(),
1201            "Yesterday header must appear"
1202        );
1203        assert!(
1204            tree.find_by_label(&earlier_label).is_some(),
1205            "Earlier header must appear"
1206        );
1207    }
1208
1209    #[test]
1210    fn intent_action_with_callback_fires_on_click() {
1211        // The cleanest path to verify the callback fires is at the
1212        // widget-builder level — we can't easily simulate a real
1213        // click without a fully-wired dispatcher. The test here
1214        // confirms the action's intent_name + on_action_invoked
1215        // combo is captured correctly.
1216        use std::cell::Cell;
1217        let archive = fresh_archive();
1218        archive.push(entry(
1219            "Build failed",
1220            None,
1221            vec![ArchivedAction {
1222                label: "Retry".into(),
1223                intent_name: Some("app.build.retry".into()),
1224                style: ArchivedActionStyle::PrimaryButton,
1225                closes_on_invoke: true,
1226            }],
1227        ));
1228        let fired = Rc::new(Cell::new(false));
1229        let fired_clone = fired.clone();
1230        let log = NotificationLog::new(archive).on_action_invoked(move |_entry, action, _ctx| {
1231            assert_eq!(action.intent_name.as_deref(), Some("app.build.retry"));
1232            fired_clone.set(true);
1233        });
1234        let mut tree = tree_with(log);
1235        // Find the Retry button via its label + Role::Button.
1236        let btn = tree
1237            .find_by_label("Retry")
1238            .expect("Retry button must be in the AT tree when on_action_invoked is wired");
1239        tree.dispatch_event(teksilo_core::event::WidgetEvent::AccessAction {
1240            action: teksilo_core::accesskit::Action::Click,
1241            target: Some(btn),
1242            target_node: teksilo_core::accessibility::root_node_id(),
1243            data: None,
1244        });
1245        assert!(
1246            fired.get(),
1247            "on_action_invoked callback fires on Retry click"
1248        );
1249    }
1250}