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