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::density::spacing;
19use teksilo_core::styles::{SnackbarStyle, SnackbarStyleConfig};
20use teksilo_core::widget::{
21    LayoutContext, LayoutResponse, PaintContext, PendingChild, Widget, WidgetPlacement,
22};
23use teksilo_core::widget_id::WidgetId;
24use teksilo_tokens::{CornerRadius, InputTokens};
25
26// IntUI design tokens for Snackbar. The recipe owns its own dimensions.
27pub const SNACKBAR_PADDING_HORIZONTAL: f32 = 12.0;
28pub const SNACKBAR_PADDING_VERTICAL: f32 = 10.0;
29pub const SNACKBAR_CORNER_RADIUS: f32 = 8.0;
30
31/// Configurable dimensions for [`RecipeSnackbarStyle`].
32#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct SnackbarRecipe {
34    pub padding_horizontal: f32,
35    pub padding_vertical: f32,
36    pub corner_radius: f32,
37}
38
39impl SnackbarRecipe {
40    /// This recipe's dimensions resolved against a density's [`InputTokens`].
41    ///
42    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
43    /// ladder — so the shipped values below are the Compact column by
44    /// construction and cannot drift from it.
45    pub fn for_tokens(tokens: &InputTokens) -> Self {
46        Self {
47            padding_horizontal: spacing(SNACKBAR_PADDING_HORIZONTAL, tokens),
48            padding_vertical: spacing(SNACKBAR_PADDING_VERTICAL, tokens),
49            corner_radius: SNACKBAR_CORNER_RADIUS,
50        }
51    }
52}
53
54impl Default for SnackbarRecipe {
55    fn default() -> Self {
56        Self::for_tokens(&InputTokens::default())
57    }
58}
59
60/// Default `SnackbarStyle` shipped with Teksilo. Chrome from
61/// `theme.colors.tooltip_bg` + `tooltip_border`.
62#[derive(Debug, Default, Clone, Copy)]
63pub struct RecipeSnackbarStyle {
64    pub recipe: SnackbarRecipe,
65}
66
67impl RecipeSnackbarStyle {
68    pub fn new(recipe: SnackbarRecipe) -> Self {
69        Self { recipe }
70    }
71
72    /// This style with every dimension resolved against a density's
73    /// [`InputTokens`], as `RecipeSnackbarStyle::for_tokens(&ctx.theme().input)` at
74    /// the widget's own build site.
75    ///
76    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
77    /// tree gets exactly the values this module documents.
78    pub fn for_tokens(tokens: &InputTokens) -> Self {
79        Self {
80            recipe: SnackbarRecipe::for_tokens(tokens),
81        }
82    }
83}
84
85impl SnackbarStyle for RecipeSnackbarStyle {
86    fn make_body(&self, cfg: &SnackbarStyleConfig, ctx: &mut BuildContext) -> WidgetId {
87        ctx.add(SnackbarFrame {
88            child_id: None,
89            pending_child: Some(PendingChild::Id(cfg.content)),
90            recipe: self.recipe,
91        })
92    }
93}
94
95/// Internal container that paints the snackbar chrome (dark
96/// `tooltip_bg` surface + `tooltip_border` stroke + corner radius) and
97/// positions the content with the snackbar padding inset. Mirrors the
98/// pre-migration `SnackbarSurface` layout exactly.
99struct SnackbarFrame {
100    child_id: Option<WidgetId>,
101    pending_child: Option<PendingChild>,
102    recipe: SnackbarRecipe,
103}
104
105impl std::fmt::Debug for SnackbarFrame {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        f.debug_struct("SnackbarFrame").finish()
108    }
109}
110
111impl Widget for SnackbarFrame {
112    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
113        if let Some(pending) = self.pending_child.take() {
114            self.child_id = Some(match pending {
115                PendingChild::Id(id) => id,
116                PendingChild::Deferred(w) => ctx.add_boxed(w),
117            });
118        }
119        self.child_id.into_iter().collect()
120    }
121
122    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
123        let inset_x = self.recipe.padding_horizontal * 2.0;
124        let inset_y = self.recipe.padding_vertical * 2.0;
125        let content = self
126            .child_id
127            .and_then(|id| {
128                ctx.child_size(
129                    id,
130                    SizeProposal {
131                        width: proposal.width.map(|width| (width - inset_x).max(0.0)),
132                        height: proposal.height.map(|height| (height - inset_y).max(0.0)),
133                    },
134                )
135            })
136            .unwrap_or_else(|| proposal.resolve(220.0, 44.0));
137
138        Size::new(content.width + inset_x, content.height + inset_y).into()
139    }
140
141    fn place_children(
142        &self,
143        bounds: Rect,
144        _proposal: SizeProposal,
145        children: &mut [WidgetPlacement],
146        _ctx: &LayoutContext,
147    ) {
148        for child in children.iter_mut() {
149            child.origin = teksilo_canvas::Point::new(
150                bounds.x + self.recipe.padding_horizontal,
151                bounds.y + self.recipe.padding_vertical,
152            );
153            child.size = Size::new(
154                (bounds.width - self.recipe.padding_horizontal * 2.0).max(0.0),
155                (bounds.height - self.recipe.padding_vertical * 2.0).max(0.0),
156            );
157        }
158    }
159
160    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
161        let radius = CornerRadius::uniform(self.recipe.corner_radius);
162        // Notifications use the (dark) tooltip surface for high-contrast popups.
163        canvas.fill_rounded_rect(bounds, radius, ctx.theme.colors.tooltip_bg);
164        canvas.stroke_rounded_rect(
165            bounds,
166            radius,
167            ctx.theme.colors.tooltip_border,
168            ctx.theme.shape.border_width,
169        );
170    }
171
172    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
173        // Presentational: the parent `SnackbarSurface` emits the
174        // `Role::Alert` + `Live::Polite` node with the announcement. A
175        // `GenericContainer` is dropped by every adapter with its children
176        // kept; `set_hidden()` would take the message and its action button
177        // out of reach with it.
178        //
179        // `Live::Off` as well, because the alert's name is the announcement
180        // and `accesskit_consumer` hands its politeness down to every
181        // descendant that sets none (node.rs:906-910). All three adapters
182        // announce each named node entering the filtered tree with an
183        // inherited politeness (atspi_common adapter.rs:71-77, windows
184        // adapter.rs:255-263, macos event.rs:236-241), so without it the
185        // message was announced a second time beside the name the caller
186        // set to the same words, and its action button with it.
187        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
188        builder.set_live(teksilo_core::accesskit::Live::Off);
189    }
190
191    fn children(&self) -> Vec<WidgetId> {
192        self.child_id.into_iter().collect()
193    }
194}