Skip to main content

teksilo_widgets/styles/
recipe_toast_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `ToastStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeToastStyle` ships the IntUI toast chrome: a per-severity
7//! status-tinted surface (`StatusInfo` / `StatusSuccess` /
8//! `StatusWarning` / `StatusError`) with rounded corners, padding,
9//! a leading severity glyph, and an optional trailing close `IconButton`.
10//!
11//! Tokens are co-located here.
12//! Apps that want a different look (full-bleed strip, frosted glass,
13//! icon-free) write their own `impl ToastStyle` block. The widget
14//! always builds the functional pieces (severity glyph, close button,
15//! body content) — the recipe is pure chrome.
16
17use teksilo_core::build_context::BuildContext;
18use teksilo_core::color_prop::ColorProp;
19use teksilo_core::styles::{ToastStyle, ToastStyleConfig};
20use teksilo_core::widget_builder::WidgetBuilder;
21use teksilo_core::widget_id::WidgetId;
22use teksilo_tokens::{CornerRadius, InputTokens, VAlignment};
23
24use crate::primitives::{Expand, HStack, Padding, RectWidget, ZStack};
25use teksilo_core::styles::density::spacing;
26
27/// Outer horizontal padding inside the toast surface.
28pub const TOAST_PADDING_HORIZONTAL: f32 = 14.0;
29/// Outer vertical padding inside the toast surface.
30pub const TOAST_PADDING_VERTICAL: f32 = 12.0;
31/// Rounded corner radius of the toast surface (matches IntUI
32/// `radius_popup`).
33pub const TOAST_CORNER_RADIUS: f32 = 8.0;
34/// Diameter of the leading severity glyph (consumed by the widget, not
35/// the recipe — exposed here so the widget pulls one constant).
36pub const TOAST_GLYPH_SIZE: f32 = 16.0;
37/// Horizontal gap between leading glyph, body column, and trailing
38/// close button.
39pub const TOAST_CONTENT_GAP: f32 = 12.0;
40/// Vertical gap between title and body lines inside the body column.
41pub const TOAST_TITLE_BODY_GAP: f32 = 2.0;
42/// Vertical gap between body and action row (when actions are present).
43pub const TOAST_BODY_ACTIONS_GAP: f32 = 8.0;
44
45/// Dimension recipe for [`RecipeToastStyle`].
46///
47/// All fields default to the matching `TOAST_*` constants so a plain
48/// `ToastRecipe::default()` reproduces the stock IntUI look. Override
49/// individual fields to tune padding, gap, or corner radius without
50/// writing a full custom `ToastStyle`.
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub struct ToastRecipe {
53    /// Outer horizontal padding inside the toast surface.
54    pub padding_horizontal: f32,
55    /// Outer vertical padding inside the toast surface.
56    pub padding_vertical: f32,
57    /// Rounded corner radius of the toast surface.
58    pub corner_radius: f32,
59    /// Diameter of the leading severity glyph.
60    pub glyph_size: f32,
61    /// Horizontal gap between leading glyph, body column, and trailing
62    /// close button.
63    pub content_gap: f32,
64    /// Vertical gap between title and body lines inside the body column.
65    pub title_body_gap: f32,
66    /// Vertical gap between body and action row (when actions are present).
67    pub body_actions_gap: f32,
68}
69
70impl ToastRecipe {
71    /// This recipe's dimensions resolved against a density's [`InputTokens`].
72    ///
73    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
74    /// ladder — so the shipped values below are the Compact column by
75    /// construction and cannot drift from it.
76    pub fn for_tokens(tokens: &InputTokens) -> Self {
77        Self {
78            padding_horizontal: spacing(TOAST_PADDING_HORIZONTAL, tokens),
79            padding_vertical: spacing(TOAST_PADDING_VERTICAL, tokens),
80            corner_radius: TOAST_CORNER_RADIUS,
81            glyph_size: TOAST_GLYPH_SIZE,
82            content_gap: spacing(TOAST_CONTENT_GAP, tokens),
83            title_body_gap: spacing(TOAST_TITLE_BODY_GAP, tokens),
84            body_actions_gap: spacing(TOAST_BODY_ACTIONS_GAP, tokens),
85        }
86    }
87}
88
89impl Default for ToastRecipe {
90    fn default() -> Self {
91        Self::for_tokens(&InputTokens::default())
92    }
93}
94
95/// Default `ToastStyle` shipped with Teksilo. Surface tint comes from
96/// the per-severity `SurfaceRole`. The recipe ignores
97/// `cfg.priority` — High/Urgent toasts look identical to Normal at
98/// this default styling tier (apps that want a heavier shadow on
99/// Urgent provide their own `impl ToastStyle`).
100#[derive(Debug, Default, Clone, Copy)]
101pub struct RecipeToastStyle {
102    pub recipe: ToastRecipe,
103}
104
105impl RecipeToastStyle {
106    pub fn new(recipe: ToastRecipe) -> Self {
107        Self { recipe }
108    }
109
110    /// This style with every dimension resolved against a density's
111    /// [`InputTokens`], as `RecipeToastStyle::for_tokens(&ctx.theme().input)` at
112    /// the widget's own build site.
113    ///
114    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
115    /// tree gets exactly the values this module documents.
116    pub fn for_tokens(tokens: &InputTokens) -> Self {
117        Self {
118            recipe: ToastRecipe::for_tokens(tokens),
119        }
120    }
121}
122
123impl ToastStyle for RecipeToastStyle {
124    fn make_body(&self, cfg: &ToastStyleConfig, ctx: &mut BuildContext) -> WidgetId {
125        let radius = CornerRadius::uniform(self.recipe.corner_radius);
126
127        // Background panel — status surface tint, no border (status
128        // surface tokens already encode contrast with the page bg).
129        let bg = ctx.add(
130            RectWidget::new()
131                .background(ColorProp::SurfaceRole(cfg.severity.surface()))
132                .corner_radius(radius),
133        );
134
135        // Row layout: [glyph] [body (expands)] [close?].
136        let body = ctx.add(Expand::horizontal().child(cfg.content));
137        let mut row = HStack::new()
138            .spacing(self.recipe.content_gap)
139            .alignment(VAlignment::Top)
140            .child(cfg.leading_glyph)
141            .child(body);
142        if let Some(close_id) = cfg.trailing_close {
143            row = row.child(close_id);
144        }
145        let row_id = ctx.add(row);
146        let padded = ctx.add(
147            Padding::symmetric(self.recipe.padding_vertical, self.recipe.padding_horizontal)
148                .child(row_id),
149        );
150
151        // `Live::Off` on the root, because the toast's own node is a live
152        // region announced by its name, and `accesskit_consumer` hands its
153        // politeness down to every descendant that sets none (node.rs:
154        // 906-910). All three adapters announce each named node entering
155        // the filtered tree with an inherited politeness (atspi_common
156        // adapter.rs:71-77, windows adapter.rs:255-263, macos event.rs:
157        // 236-241), so without it a toast was heard as its title, then its
158        // body, its close button and every action, in hash order, each
159        // cutting off the one before. The node stays a `GenericContainer`,
160        // which every adapter drops with its children kept.
161        ctx.add(
162            ZStack::new()
163                .child(bg)
164                .child(padded)
165                .access_live(teksilo_core::accesskit::Live::Off),
166        )
167    }
168}