use std::rc::Rc;
use teksilo_i18n::lit;
use teksilo_canvas::{EllipsisMode, Rect, SizeProposal, TextOverflow};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::binding::BindingLevel;
use teksilo_core::build_context::BuildContext;
use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
use teksilo_core::widget_builder::WidgetBuilder;
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::{TextRole, TextStyleRole};
use crate::button::{Button, ButtonVariant};
use crate::link::Link;
use crate::notification::{
ArchivedAction, ArchivedActionStyle, NotificationArchiveModel, NotificationEntry, route_visible,
};
use crate::primitives::{Center, Expand, HStack, Padding, Shrinkable, Spacer, TextWidget, VStack};
use crate::scroll_area::ScrollArea;
use crate::severity_badge::SeverityBadge;
use crate::standard_item::StandardListItem;
use crate::styles::recipe_standard_item_style as si;
use crate::toast::{ToastAudience, ToastRoute};
use crate::tooltip::TooltipContent;
use teksilo_core::window::TeksiloWindowId;
use teksilo_i18n::LocalizedString;
const DEFAULT_PREFERRED_WIDTH: f32 = 380.0;
const DEFAULT_PREFERRED_HEIGHT: f32 = 320.0;
pub struct NotificationLog {
archive: Rc<NotificationArchiveModel>,
show_toolbar: bool,
empty_state: Option<Rc<dyn Fn() -> Box<dyn Widget>>>,
on_entry_invoked: Option<Rc<dyn Fn(&NotificationEntry, &mut EventContext)>>,
on_action_invoked: Option<Rc<dyn Fn(&NotificationEntry, &ArchivedAction, &mut EventContext)>>,
root_child_id: Option<WidgetId>,
preferred_width: f32,
preferred_height: f32,
route_scope: Option<ToastRoute>,
}
impl NotificationLog {
pub fn new(archive: Rc<NotificationArchiveModel>) -> Self {
Self {
archive,
show_toolbar: true,
empty_state: None,
on_entry_invoked: None,
on_action_invoked: None,
root_child_id: None,
preferred_width: DEFAULT_PREFERRED_WIDTH,
preferred_height: DEFAULT_PREFERRED_HEIGHT,
route_scope: None,
}
}
pub fn for_window(mut self, window_id: TeksiloWindowId) -> Self {
self.route_scope = Some(ToastRoute::Window(window_id));
self
}
pub fn for_audience(mut self, audience: ToastAudience) -> Self {
self.route_scope = Some(ToastRoute::Audience(audience));
self
}
pub fn show_toolbar(mut self, show: bool) -> Self {
self.show_toolbar = show;
self
}
pub fn empty_state(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
self.empty_state = Some(Rc::new(f));
self
}
pub fn preferred_width(mut self, width: f32) -> Self {
self.preferred_width = width;
self
}
pub fn preferred_height(mut self, height: f32) -> Self {
self.preferred_height = height;
self
}
pub fn on_entry_invoked(
mut self,
f: impl Fn(&NotificationEntry, &mut EventContext) + 'static,
) -> Self {
self.on_entry_invoked = Some(Rc::new(f));
self
}
pub fn on_action_invoked(
mut self,
f: impl Fn(&NotificationEntry, &ArchivedAction, &mut EventContext) + 'static,
) -> Self {
self.on_action_invoked = Some(Rc::new(f));
self
}
fn build_row(
entry: &NotificationEntry,
on_entry: Option<&Rc<dyn Fn(&NotificationEntry, &mut EventContext)>>,
on_action: Option<&Rc<dyn Fn(&NotificationEntry, &ArchivedAction, &mut EventContext)>>,
) -> Box<dyn Widget> {
let glyph: Box<dyn Widget> = Box::new(SeverityBadge::new(entry.severity.into(), 14.0));
let mut row = StandardListItem::new(lit!(entry.title.clone()))
.leading_slot_boxed(glyph)
.label_style(if entry.read {
TextStyleRole::Body
} else {
TextStyleRole::BodyBold
})
.label_overflow(TextOverflow::Ellipsis(EllipsisMode::Trailing))
.subtitle_overflow(TextOverflow::Ellipsis(EllipsisMode::Trailing));
if let Some(body) = &entry.body {
row = row.subtitle(lit!(body.clone()));
}
row = row.rich_tooltip_content(TooltipContent::new(
format!("notification.entry.{}", entry.id),
lit!(match &entry.body {
Some(body) => format!("{}\n{}", entry.title, body),
None => entry.title.clone(),
}),
));
if !entry.actions.is_empty() {
let actions_row = build_actions_row(entry, on_action.cloned());
row = row.trailing_slot_boxed(actions_row);
}
if let Some(cb) = on_entry {
let cb = cb.clone();
let entry_clone = entry.clone();
Box::new(
row.on_tap(move |_event, ctx| {
cb(&entry_clone, ctx);
})
.cursor(teksilo_core::widget::CursorIcon::Pointer),
)
} else {
Box::new(row)
}
}
}
impl std::fmt::Debug for NotificationLog {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NotificationLog")
.field("archive_entries", &self.archive.entries().len())
.field("show_toolbar", &self.show_toolbar)
.field("has_empty_state", &self.empty_state.is_some())
.field("preferred_width", &self.preferred_width)
.field("preferred_height", &self.preferred_height)
.finish_non_exhaustive()
}
}
impl Widget for NotificationLog {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
let archive = self.archive.clone();
let on_entry = self.on_entry_invoked.clone();
let on_action = self.on_action_invoked.clone();
archive.version_signal().bind_to(
ctx.self_id(),
ctx.binding_registry(),
BindingLevel::Rebuild,
);
let scope = self.route_scope;
let model = archive.entries();
let entries: Vec<NotificationEntry> = (0..model.len())
.filter_map(|i| model.with_item(i, |e| e.clone()))
.filter(|e| route_visible(e.route, scope))
.collect();
let mut column = VStack::new().spacing(6.0);
if self.show_toolbar {
let archive_for_mark = archive.clone();
let archive_for_clear = archive.clone();
let toolbar = HStack::new()
.spacing(8.0)
.add_child(ctx.add(Spacer::new()))
.add_child(
ctx.add(
Button::new(teksilo_i18n::tr_widget!(notifications_mark_all_read()))
.variant(ButtonVariant::Plain)
.on_activate_fn(move |_| match scope {
Some(s) => archive_for_mark
.mark_read_where(|e| route_visible(e.route, Some(s))),
None => archive_for_mark.mark_all_read(),
}),
),
)
.add_child(
ctx.add(
Button::new(teksilo_i18n::tr_widget!(notifications_clear()))
.variant(ButtonVariant::Plain)
.on_activate_fn(move |_| match scope {
Some(s) => archive_for_clear
.clear_where(|e| route_visible(e.route, Some(s))),
None => archive_for_clear.clear(),
}),
),
);
column = column.add_child(ctx.add(toolbar));
}
if entries.is_empty() {
let empty = match &self.empty_state {
Some(factory) => ctx.add_boxed(factory()),
None => ctx.add(
TextWidget::new(teksilo_i18n::tr_widget!(notifications_empty()))
.color(TextRole::Secondary)
.style(TextStyleRole::Body),
),
};
column = column.add_child(
ctx.add(
Expand::vertical()
.flex(1.0)
.child(Center::new().child_id(empty)),
),
);
} else {
let now = jiff::Zoned::now();
let today = now.date();
let zone = now.time_zone().clone();
let mut sections = VStack::new().spacing(8.0);
let mut current_bucket: Option<DayBucket> = None;
for entry in &entries {
let bucket = day_bucket_for(entry.timestamp, today, &zone);
if Some(bucket) != current_bucket {
let header = TextWidget::new(bucket_label(bucket))
.style(TextStyleRole::SmallBold)
.color(TextRole::Secondary);
sections = sections.add_child(ctx.add(
Padding::symmetric(0.0, si::STANDARD_ITEM_PADDING_HORIZONTAL).child(header),
));
current_bucket = Some(bucket);
}
sections = sections.add_child(ctx.add_boxed(Self::build_row(
entry,
on_entry.as_ref(),
on_action.as_ref(),
)));
}
let scrollable = ScrollArea::new()
.preferred_height(self.preferred_height)
.child(sections);
column = column.add_child(
ctx.add(
Shrinkable::new()
.min_height(si::STANDARD_ITEM_MIN_HEIGHT_TWO_LINE)
.child(
Expand::vertical()
.flex(1.0)
.respect_intrinsic()
.child(scrollable),
),
),
);
}
let root = ctx.add(column);
self.root_child_id = Some(root);
vec![root]
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
let effective = SizeProposal {
width: proposal.width.or(Some(self.preferred_width)),
height: proposal.height,
};
self.root_child_id
.and_then(|id| ctx.child_layout_response(id, effective))
.unwrap_or_else(|| {
effective
.resolve(self.preferred_width, self.preferred_height)
.into()
})
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
for child in children.iter_mut() {
child.origin = bounds.origin();
child.size = bounds.size();
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_role(teksilo_core::accesskit::Role::List);
builder.set_name(teksilo_i18n::tr_widget!(notifications_title()).resolve_now());
}
fn children(&self) -> Vec<WidgetId> {
self.root_child_id.into_iter().collect()
}
}
fn build_actions_row(
entry: &NotificationEntry,
on_action: Option<Rc<dyn Fn(&NotificationEntry, &ArchivedAction, &mut EventContext)>>,
) -> Box<dyn Widget> {
let mut row = HStack::new().spacing(8.0);
for action in entry.actions.iter() {
let action_owned = action.clone();
let entry_owned = entry.clone();
let on_action_for_handler = on_action.clone();
let clickable = action.intent_name.is_some() && on_action_for_handler.is_some();
if !clickable {
let label = format!(
"{} {}",
action.label,
teksilo_i18n::tr_widget!(notifications_archive_replay_disabled()).resolve_now()
);
row = row.child(
TextWidget::new(lit!(label))
.style(TextStyleRole::Small)
.color(TextRole::Secondary),
);
continue;
}
let activate = move |ctx: &mut EventContext| {
if let Some(cb) = on_action_for_handler.as_ref() {
cb(&entry_owned, &action_owned, ctx);
}
};
row = match action.style {
ArchivedActionStyle::Link => {
row.child(Link::new(lit!(action.label.clone())).on_activate_fn(activate))
}
ArchivedActionStyle::PrimaryButton => row.child(
Button::new(lit!(action.label.clone()))
.variant(ButtonVariant::Filled)
.on_activate_fn(activate),
),
ArchivedActionStyle::SecondaryButton => row.child(
Button::new(lit!(action.label.clone()))
.variant(ButtonVariant::Plain)
.on_activate_fn(activate),
),
ArchivedActionStyle::Destructive => row.child(
Button::new(lit!(action.label.clone()))
.variant(ButtonVariant::Destructive)
.on_activate_fn(activate),
),
};
}
Box::new(row)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DayBucket {
Today,
Yesterday,
ThisWeek,
Earlier,
}
fn day_bucket_for(
timestamp: jiff::Timestamp,
today: jiff::civil::Date,
zone: &jiff::tz::TimeZone,
) -> DayBucket {
let entry_date = timestamp.to_zoned(zone.clone()).date();
let delta_days = today
.since(entry_date)
.map(|span| span.get_days())
.unwrap_or(0);
if delta_days <= 0 {
DayBucket::Today
} else if delta_days == 1 {
DayBucket::Yesterday
} else if delta_days <= 6 {
DayBucket::ThisWeek
} else {
DayBucket::Earlier
}
}
fn bucket_label(bucket: DayBucket) -> LocalizedString {
match bucket {
DayBucket::Today => teksilo_i18n::tr_widget!(notifications_bucket_today()),
DayBucket::Yesterday => teksilo_i18n::tr_widget!(notifications_bucket_yesterday()),
DayBucket::ThisWeek => teksilo_i18n::tr_widget!(notifications_bucket_this_week()),
DayBucket::Earlier => teksilo_i18n::tr_widget!(notifications_bucket_earlier()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::notification::{ArchivedActionStyle, NotificationArchiveModel};
use teksilo_core::styles::{BannerSeverity, ToastPriority};
use teksilo_core::widget_tree::WidgetTree;
fn entry(title: &str, body: Option<&str>, actions: Vec<ArchivedAction>) -> NotificationEntry {
NotificationEntry {
id: 0,
severity: BannerSeverity::Info,
priority: ToastPriority::Normal,
title: title.to_string(),
body: body.map(|s| s.to_string()),
actions,
timestamp: jiff::Timestamp::UNIX_EPOCH,
group: None,
source: None,
read: false,
dedup_id: None,
updates: Vec::new(),
route: ToastRoute::Broadcast,
}
}
fn fresh_archive() -> Rc<NotificationArchiveModel> {
Rc::new(NotificationArchiveModel::in_memory())
}
fn tree_with(log: NotificationLog) -> WidgetTree {
let (tree, _) = tree_sized(log, SizeProposal::exact(480.0, 360.0));
tree
}
fn tree_sized(log: NotificationLog, proposal: SizeProposal) -> (WidgetTree, WidgetId) {
let mut tree = WidgetTree::new()
.with_theme(teksilo_core::presets::intui::light())
.with_text_backend(Rc::new(std::cell::RefCell::new(
teksilo_canvas::MockTextBackend::new(),
)));
let id = tree.add(log);
tree.layout(proposal);
(tree, id)
}
fn find_by_type(tree: &WidgetTree, root: WidgetId, suffix: &str) -> Option<WidgetId> {
if tree
.widget_type_name(root)
.is_some_and(|n| n.ends_with(suffix))
{
return Some(root);
}
tree.children(root)
.into_iter()
.find_map(|c| find_by_type(tree, c, suffix))
}
#[test]
fn empty_archive_renders_empty_state() {
let archive = fresh_archive();
let tree = tree_with(NotificationLog::new(archive));
let expected = teksilo_i18n::tr_widget!(notifications_empty()).resolve_now();
assert!(
tree.find_by_label(&expected).is_some(),
"empty-state hint must be in the AT tree when the archive is empty"
);
}
#[test]
fn populated_archive_renders_list_role() {
let archive = fresh_archive();
archive.push(entry("first", Some("body 1"), Vec::new()));
archive.push(entry("second", None, Vec::new()));
let tree = tree_with(NotificationLog::new(archive));
let list_role = tree.find_by_role(teksilo_core::accesskit::Role::List);
assert!(list_role.is_some(), "Log root exposes Role::List");
}
#[test]
fn intent_action_without_callback_is_inert() {
let archive = fresh_archive();
archive.push(entry(
"Build failed",
None,
vec![ArchivedAction {
label: "Retry".into(),
intent_name: Some("app.build.retry".into()),
style: ArchivedActionStyle::PrimaryButton,
closes_on_invoke: true,
}],
));
let tree = tree_with(NotificationLog::new(archive));
assert!(
tree.find_by_label("Retry").is_none(),
"without on_action_invoked, archive actions render as inert text tags with a \
suffix — no exact-'Retry' label appears"
);
}
#[test]
fn the_list_area_fills_the_height_its_host_offers() {
let archive = fresh_archive();
for i in 0..8 {
archive.push(entry(&format!("Notice {i}"), Some("body"), Vec::new()));
}
let (tree, root) = tree_sized(
NotificationLog::new(archive),
SizeProposal::exact(480.0, 600.0),
);
let scroll = find_by_type(&tree, root, "ScrollArea").expect("log has a ScrollArea");
let scroll_h = tree.bounds(scroll).height;
let root_h = tree.bounds(root).height;
assert!(
scroll_h > root_h - 60.0,
"list must fill the host height: list {scroll_h} in a {root_h} tall log"
);
}
#[test]
fn the_list_area_compresses_inside_a_short_host() {
let archive = fresh_archive();
for i in 0..8 {
archive.push(entry(&format!("Notice {i}"), Some("body"), Vec::new()));
}
let (tree, root) = tree_sized(
NotificationLog::new(archive),
SizeProposal::exact(480.0, 160.0),
);
let scroll = find_by_type(&tree, root, "ScrollArea").expect("log has a ScrollArea");
let b = tree.bounds(scroll);
assert!(
b.y + b.height <= 160.0 + 0.01,
"list bottom {} must stay inside the 160 dp host",
b.y + b.height
);
}
#[test]
fn a_long_title_keeps_the_action_button_inside_the_row() {
let archive = fresh_archive();
archive.push(entry(
"Build failed for target aarch64-unknown-linux-gnu after 42 seconds",
Some("the linker could not resolve symbol __teksilo_frobnicate_v2"),
vec![ArchivedAction {
label: "Retry".into(),
intent_name: Some("app.build.retry".into()),
style: ArchivedActionStyle::PrimaryButton,
closes_on_invoke: true,
}],
));
let (tree, root) = tree_sized(
NotificationLog::new(archive).on_action_invoked(|_e, _a, _c| {}),
SizeProposal::exact(320.0, 400.0),
);
let button = tree.find_by_label("Retry").expect("Retry button");
let b = tree.bounds(button);
let right_edge = tree.bounds(root).width;
assert!(
b.x + b.width <= right_edge + 0.01,
"action button right edge {} must stay within the {right_edge} dp row",
b.x + b.width
);
}
#[test]
fn a_content_hugging_host_gets_the_preferred_width() {
let archive = fresh_archive();
archive.push(entry("Export finished", Some("14 chapters"), Vec::new()));
let unbounded = SizeProposal {
width: None,
height: None,
};
let (tree, root) = tree_sized(NotificationLog::new(archive.clone()), unbounded);
assert!(
(tree.bounds(root).width - DEFAULT_PREFERRED_WIDTH).abs() < 0.01,
"unbounded width = {}, expected the preferred {DEFAULT_PREFERRED_WIDTH}",
tree.bounds(root).width
);
let (tree, root) = tree_sized(
NotificationLog::new(archive).preferred_width(520.0),
unbounded,
);
assert!(
(tree.bounds(root).width - 520.0).abs() < 0.01,
"preferred_width override ignored: got {}",
tree.bounds(root).width
);
}
#[test]
fn a_custom_empty_state_survives_a_rebuild() {
let archive = fresh_archive();
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(
NotificationLog::new(archive.clone())
.empty_state(|| Box::new(TextWidget::new(lit!("Inbox zero")))),
);
tree.layout(SizeProposal::exact(480.0, 360.0));
assert!(
tree.find_by_label("Inbox zero").is_some(),
"shown initially"
);
archive.push(entry("Notice", None, Vec::new()));
tree.layout(SizeProposal::exact(480.0, 360.0));
archive.clear();
tree.layout(SizeProposal::exact(480.0, 360.0));
assert!(
tree.find_by_label("Inbox zero").is_some(),
"the custom empty state must come back when the archive empties again"
);
}
fn ts(year: i16, month: i8, day: i8, hour: i8, minute: i8) -> jiff::Timestamp {
let utc_zone = jiff::tz::TimeZone::UTC;
jiff::civil::DateTime::new(year, month, day, hour, minute, 0, 0)
.unwrap()
.to_zoned(utc_zone)
.unwrap()
.timestamp()
}
#[test]
fn day_bucket_today_for_same_calendar_date() {
let today = jiff::civil::Date::new(2025, 5, 17).unwrap();
let entry = ts(2025, 5, 17, 8, 30);
assert_eq!(
day_bucket_for(entry, today, &jiff::tz::TimeZone::UTC),
DayBucket::Today
);
}
#[test]
fn day_bucket_yesterday_for_t_minus_one() {
let today = jiff::civil::Date::new(2025, 5, 17).unwrap();
let entry = ts(2025, 5, 16, 23, 0);
assert_eq!(
day_bucket_for(entry, today, &jiff::tz::TimeZone::UTC),
DayBucket::Yesterday
);
}
#[test]
fn day_bucket_this_week_for_2_to_6_days_ago() {
let today = jiff::civil::Date::new(2025, 5, 17).unwrap();
for days_ago in 2..=6 {
let date = today
.checked_sub(jiff::ToSpan::days(days_ago as i64))
.unwrap();
let entry = ts(date.year(), date.month(), date.day(), 12, 0);
assert_eq!(
day_bucket_for(entry, today, &jiff::tz::TimeZone::UTC),
DayBucket::ThisWeek,
"{days_ago} days ago must bucket as ThisWeek"
);
}
}
#[test]
fn day_bucket_earlier_for_7_plus_days_ago() {
let today = jiff::civil::Date::new(2025, 5, 17).unwrap();
let week_ago_date = today.checked_sub(jiff::ToSpan::days(7)).unwrap();
let entry = ts(
week_ago_date.year(),
week_ago_date.month(),
week_ago_date.day(),
12,
0,
);
assert_eq!(
day_bucket_for(entry, today, &jiff::tz::TimeZone::UTC),
DayBucket::Earlier
);
}
#[test]
fn day_bucket_future_entries_count_as_today() {
let today = jiff::civil::Date::new(2025, 5, 17).unwrap();
let entry = ts(2025, 5, 18, 0, 0);
assert_eq!(
day_bucket_for(entry, today, &jiff::tz::TimeZone::UTC),
DayBucket::Today
);
}
#[test]
fn day_bucket_label_resolves_through_i18n() {
for bucket in [
DayBucket::Today,
DayBucket::Yesterday,
DayBucket::ThisWeek,
DayBucket::Earlier,
] {
let label = bucket_label(bucket).resolve_now();
assert!(!label.is_empty(), "{bucket:?} has an empty label");
}
}
#[test]
fn log_with_entries_across_buckets_renders_each_header() {
let archive = fresh_archive();
let now = jiff::Zoned::now();
let today = now.date();
let zone = now.time_zone().clone();
let today_ts = today
.at(12, 0, 0, 0)
.to_zoned(zone.clone())
.unwrap()
.timestamp();
let yesterday_ts = today
.checked_sub(jiff::ToSpan::days(1))
.unwrap()
.at(12, 0, 0, 0)
.to_zoned(zone.clone())
.unwrap()
.timestamp();
let earlier_ts = today
.checked_sub(jiff::ToSpan::days(30))
.unwrap()
.at(12, 0, 0, 0)
.to_zoned(zone)
.unwrap()
.timestamp();
let mut earlier = entry("Very old notice", None, Vec::new());
earlier.timestamp = earlier_ts;
archive.push(earlier);
let mut yesterday = entry("Yesterday's notice", None, Vec::new());
yesterday.timestamp = yesterday_ts;
archive.push(yesterday);
let mut today_entry = entry("Today's notice", None, Vec::new());
today_entry.timestamp = today_ts;
archive.push(today_entry);
let tree = tree_with(NotificationLog::new(archive));
let today_label = teksilo_i18n::tr_widget!(notifications_bucket_today()).resolve_now();
let yesterday_label =
teksilo_i18n::tr_widget!(notifications_bucket_yesterday()).resolve_now();
let earlier_label = teksilo_i18n::tr_widget!(notifications_bucket_earlier()).resolve_now();
assert!(
tree.find_by_label(&today_label).is_some(),
"Today header must appear"
);
assert!(
tree.find_by_label(&yesterday_label).is_some(),
"Yesterday header must appear"
);
assert!(
tree.find_by_label(&earlier_label).is_some(),
"Earlier header must appear"
);
}
#[test]
fn intent_action_with_callback_fires_on_click() {
use std::cell::Cell;
let archive = fresh_archive();
archive.push(entry(
"Build failed",
None,
vec![ArchivedAction {
label: "Retry".into(),
intent_name: Some("app.build.retry".into()),
style: ArchivedActionStyle::PrimaryButton,
closes_on_invoke: true,
}],
));
let fired = Rc::new(Cell::new(false));
let fired_clone = fired.clone();
let log = NotificationLog::new(archive).on_action_invoked(move |_entry, action, _ctx| {
assert_eq!(action.intent_name.as_deref(), Some("app.build.retry"));
fired_clone.set(true);
});
let mut tree = tree_with(log);
let btn = tree
.find_by_label("Retry")
.expect("Retry button must be in the AT tree when on_action_invoked is wired");
tree.dispatch_event(teksilo_core::event::WidgetEvent::AccessAction {
action: teksilo_core::accesskit::Action::Click,
target: Some(btn),
target_node: teksilo_core::accessibility::root_node_id(),
data: None,
});
assert!(
fired.get(),
"on_action_invoked callback fires on Retry click"
);
}
}