use std::rc::Rc;
use teksilo_canvas::{Rect, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::build_context::BuildContext;
use teksilo_core::styles::{BannerStyleConfig, SharedBannerStyle};
use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::{TextRole, TextStyleRole, VAlignment};
pub use teksilo_core::styles::BannerSeverity;
use crate::icon_button::IconButton;
use crate::primitives::{Expand, HStack, TextWidget, VStack};
use crate::severity_badge::SeverityBadge;
use crate::styles::recipe_banner_style as banner_tokens;
use teksilo_i18n::LocalizedString;
pub struct Banner {
severity: BannerSeverity,
title: LocalizedString,
description: Option<LocalizedString>,
action: Option<Box<dyn Widget>>,
on_dismiss: Option<Box<dyn Fn(&mut EventContext)>>,
style_override: Option<SharedBannerStyle>,
root_child_id: Option<WidgetId>,
}
impl Banner {
fn new(severity: BannerSeverity, title: impl Into<LocalizedString>) -> Self {
let ls: LocalizedString = title.into();
Self {
severity,
title: ls,
description: None,
action: None,
on_dismiss: None,
style_override: None,
root_child_id: None,
}
}
pub fn style(mut self, style: impl teksilo_core::styles::BannerStyle) -> Self {
self.style_override = Some(Rc::new(style));
self
}
pub fn info(title: impl Into<LocalizedString>) -> Self {
Self::new(BannerSeverity::Info, title)
}
pub fn success(title: impl Into<LocalizedString>) -> Self {
Self::new(BannerSeverity::Success, title)
}
pub fn warning(title: impl Into<LocalizedString>) -> Self {
Self::new(BannerSeverity::Warning, title)
}
pub fn error(title: impl Into<LocalizedString>) -> Self {
Self::new(BannerSeverity::Error, title)
}
pub fn description(mut self, text: impl Into<LocalizedString>) -> Self {
let ls: LocalizedString = text.into();
self.description = Some(ls);
self
}
pub fn action(mut self, widget: impl Widget + 'static) -> Self {
self.action = Some(Box::new(widget));
self
}
pub fn on_dismiss(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
self.on_dismiss = Some(Box::new(f));
self
}
}
impl std::fmt::Debug for Banner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Banner")
.field("severity", &self.severity)
.field("title", &self.title)
.field("description", &self.description)
.finish()
}
}
impl Widget for Banner {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
let severity = self.severity;
let glyph = ctx.add(SeverityBadge::new(
severity.into(),
banner_tokens::BANNER_GLYPH_SIZE,
));
let title = ctx.add(
TextWidget::new(self.title.clone())
.style(TextStyleRole::BodyBold)
.color(TextRole::Primary)
.single_line(),
);
let mut text_column = VStack::new()
.spacing(banner_tokens::BANNER_TITLE_DESCRIPTION_GAP)
.add_child(title);
if let Some(description) = &self.description {
let desc = ctx.add(
TextWidget::new(description.clone())
.style(TextStyleRole::Body)
.color(TextRole::Secondary),
);
text_column = text_column.add_child(desc);
}
let text_column_id = ctx.add(text_column);
let mut content = HStack::new()
.spacing(banner_tokens::BANNER_CONTENT_GAP)
.alignment(VAlignment::Center)
.add_child(ctx.add(Expand::horizontal().child_id(text_column_id)));
if let Some(action) = self.action.take() {
content = content.add_child(ctx.add_boxed(action));
}
if let Some(on_dismiss) = self.on_dismiss.take() {
let btn = IconButton::clear()
.embedded()
.on_activate_fn(move |c| on_dismiss(c));
content = content.add_child(ctx.add(btn));
}
let content_id = ctx.add(content);
let style: SharedBannerStyle = self
.style_override
.clone()
.or_else(|| ctx.theme().style_slots.banner.clone())
.unwrap_or_else(|| Rc::new(crate::styles::RecipeBannerStyle::default()));
let root = style.make_body(
&BannerStyleConfig {
severity,
content: content_id,
leading_glyph: glyph,
},
ctx,
);
self.root_child_id = Some(root);
vec![root]
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
let inner_proposal = SizeProposal {
width: proposal.width,
height: None,
};
let inner = self
.root_child_id
.and_then(|id| ctx.child_size(id, inner_proposal))
.unwrap_or_else(|| proposal.resolve(0.0, 0.0));
let width = proposal.width.unwrap_or(inner.width);
teksilo_canvas::Size::new(width, inner.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::Status);
builder.set_live(teksilo_core::accesskit::Live::Polite);
builder.set_name(self.title.clone());
}
fn children(&self) -> Vec<WidgetId> {
self.root_child_id.into_iter().collect()
}
fn clips_children(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use teksilo_core::widget_tree::WidgetTree;
use teksilo_i18n::lit;
#[test]
fn banner_builds_and_lays_out() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree.add(
Banner::warning(lit!("Unsaved changes"))
.description(lit!("Close will discard your edits.")),
);
tree.layout(SizeProposal {
width: Some(640.0),
height: None,
});
let b = tree.bounds(id);
assert!((b.width - 640.0).abs() < 0.01);
assert!(b.height > 0.0);
}
#[test]
fn banner_a11y_role_and_name() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let id = tree.add(Banner::info(lit!("Heads up")));
tree.layout(SizeProposal {
width: Some(400.0),
height: None,
});
let info = tree.accessibility_node(id);
assert_eq!(info.role(), teksilo_core::accesskit::Role::Status);
assert_eq!(info.name(), Some("Heads up"));
}
#[test]
fn banner_fills_width_inside_vstack() {
use crate::primitives::VStack;
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let banner = Banner::warning(lit!("Unsaved changes"))
.description(lit!("Close will discard your edits."));
let stack_id = tree.add(VStack::new().spacing(8.0).child(banner));
tree.layout(SizeProposal {
width: Some(640.0),
height: None,
});
let mut queue = vec![stack_id];
let mut banner_bounds = None;
while let Some(id) = queue.pop() {
let info = tree.accessibility_node(id);
if info.role() == teksilo_core::accesskit::Role::Status {
banner_bounds = Some(tree.bounds(id));
break;
}
queue.extend(tree.children(id));
}
let b = banner_bounds.expect("Banner should be in the tree under the VStack");
assert!(
(b.width - 640.0).abs() < 0.5,
"Banner inside VStack should span the proposed width 640 dp, got {}",
b.width
);
}
#[test]
fn banner_with_dismiss_emits_clear_button() {
use std::cell::Cell;
use std::rc::Rc;
let dismissed = Rc::new(Cell::new(false));
let dismissed_clone = dismissed.clone();
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
tree.add(
Banner::error(lit!("Disk almost full")).on_dismiss(move |_| dismissed_clone.set(true)),
);
tree.layout(SizeProposal {
width: Some(640.0),
height: None,
});
let dismiss = tree
.find_by_label("Clear")
.or_else(|| tree.find_by_label("Effacer"))
.expect("dismiss button should be present");
tree.dispatch_event(teksilo_core::event::WidgetEvent::AccessAction {
action: teksilo_core::accesskit::Action::Click,
target: Some(dismiss),
target_node: teksilo_core::accessibility::root_node_id(),
data: None,
});
assert!(dismissed.get(), "on_dismiss should have fired");
}
}