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}