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}