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}