Skip to main content

teksilo_widgets/styles/
recipe_snackbar_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `SnackbarStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeSnackbarStyle` ships the IntUI snackbar chrome: the
7//! high-contrast (dark) `tooltip_bg` surface with a `tooltip_border`
8//! stroke and rounded corners, content inset by the snackbar padding.
9//!
10//! Apps that want a different notification look (light surface,
11//! status-tinted background, branded chrome) write their own
12//! `impl SnackbarStyle` block and install it per-call
13//! (`Snackbar::style(...)`) or theme-wide (`theme.style_slots.snackbar`).
14
15use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
16use teksilo_core::accessibility::AccessNodeBuilder;
17use teksilo_core::build_context::BuildContext;
18use teksilo_core::styles::{SnackbarStyle, SnackbarStyleConfig};
19use teksilo_core::widget::{
20    LayoutContext, LayoutResponse, PaintContext, PendingChild, Widget, WidgetPlacement,
21};
22use teksilo_core::widget_id::WidgetId;
23use teksilo_tokens::CornerRadius;
24
25// IntUI design tokens for Snackbar. The recipe owns its own dimensions.
26pub const SNACKBAR_PADDING_HORIZONTAL: f32 = 12.0;
27pub const SNACKBAR_PADDING_VERTICAL: f32 = 10.0;
28pub const SNACKBAR_CORNER_RADIUS: f32 = 8.0;
29
30/// Configurable dimensions for [`RecipeSnackbarStyle`].
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct SnackbarRecipe {
33    pub padding_horizontal: f32,
34    pub padding_vertical: f32,
35    pub corner_radius: f32,
36}
37
38impl Default for SnackbarRecipe {
39    fn default() -> Self {
40        Self {
41            padding_horizontal: SNACKBAR_PADDING_HORIZONTAL,
42            padding_vertical: SNACKBAR_PADDING_VERTICAL,
43            corner_radius: SNACKBAR_CORNER_RADIUS,
44        }
45    }
46}
47
48/// Default `SnackbarStyle` shipped with Teksilo. Chrome from
49/// `theme.colors.tooltip_bg` + `tooltip_border`.
50#[derive(Debug, Default, Clone, Copy)]
51pub struct RecipeSnackbarStyle {
52    pub recipe: SnackbarRecipe,
53}
54
55impl RecipeSnackbarStyle {
56    pub fn new(recipe: SnackbarRecipe) -> Self {
57        Self { recipe }
58    }
59}
60
61impl SnackbarStyle for RecipeSnackbarStyle {
62    fn make_body(&self, cfg: &SnackbarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
63        ctx.add(SnackbarFrame {
64            child_id: None,
65            pending_child: Some(PendingChild::Id(cfg.content)),
66            recipe: self.recipe,
67        })
68    }
69}
70
71/// Internal container that paints the snackbar chrome (dark
72/// `tooltip_bg` surface + `tooltip_border` stroke + corner radius) and
73/// positions the content with the snackbar padding inset. Mirrors the
74/// pre-migration `SnackbarSurface` layout exactly.
75struct SnackbarFrame {
76    child_id: Option<WidgetId>,
77    pending_child: Option<PendingChild>,
78    recipe: SnackbarRecipe,
79}
80
81impl std::fmt::Debug for SnackbarFrame {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct("SnackbarFrame").finish()
84    }
85}
86
87impl Widget for SnackbarFrame {
88    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
89        if let Some(pending) = self.pending_child.take() {
90            self.child_id = Some(match pending {
91                PendingChild::Id(id) => id,
92                PendingChild::Deferred(w) => ctx.add_boxed(w),
93            });
94        }
95        self.child_id.into_iter().collect()
96    }
97
98    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
99        let inset_x = self.recipe.padding_horizontal * 2.0;
100        let inset_y = self.recipe.padding_vertical * 2.0;
101        let content = self
102            .child_id
103            .and_then(|id| {
104                ctx.child_size(
105                    id,
106                    SizeProposal {
107                        width: proposal.width.map(|width| (width - inset_x).max(0.0)),
108                        height: proposal.height.map(|height| (height - inset_y).max(0.0)),
109                    },
110                )
111            })
112            .unwrap_or_else(|| proposal.resolve(220.0, 44.0));
113
114        Size::new(content.width + inset_x, content.height + inset_y).into()
115    }
116
117    fn place_children(
118        &self,
119        bounds: Rect,
120        _proposal: SizeProposal,
121        children: &mut [WidgetPlacement],
122        _ctx: &LayoutContext,
123    ) {
124        for child in children.iter_mut() {
125            child.origin = teksilo_canvas::Point::new(
126                bounds.x + self.recipe.padding_horizontal,
127                bounds.y + self.recipe.padding_vertical,
128            );
129            child.size = Size::new(
130                (bounds.width - self.recipe.padding_horizontal * 2.0).max(0.0),
131                (bounds.height - self.recipe.padding_vertical * 2.0).max(0.0),
132            );
133        }
134    }
135
136    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
137        let radius = CornerRadius::uniform(self.recipe.corner_radius);
138        // Notifications use the (dark) tooltip surface for high-contrast popups.
139        canvas.fill_rounded_rect(bounds, radius, ctx.theme.colors.tooltip_bg);
140        canvas.stroke_rounded_rect(
141            bounds,
142            radius,
143            ctx.theme.colors.tooltip_border,
144            ctx.theme.shape.border_width,
145        );
146    }
147
148    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
149        // Presentational — the parent `SnackbarSurface` emits the
150        // `Role::Alert` + `Live::Polite` node with the announcement.
151        builder.set_hidden();
152    }
153
154    fn children(&self) -> Vec<WidgetId> {
155        self.child_id.into_iter().collect()
156    }
157}