teksilo_widgets/styles/recipe_banner_style.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `BannerStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeBannerStyle` ships the IntUI banner chrome: a per-severity
7//! status-tinted surface (`StatusInfo` / `StatusSuccess` /
8//! `StatusWarning` / `StatusError`) with rounded corners, the content
9//! inset by the banner padding, and the leading severity glyph placed
10//! to the leading edge of the message/action content.
11//!
12//! The `SeverityGlyph` itself is built by the `Banner` widget — it is
13//! a functional renderer (it draws domain data, the info/warn/error
14//! mark), not chrome (principle 6). Apps that want a different banner
15//! look (full-bleed strip, bordered callout, icon-free) write their
16//! own `impl BannerStyle` block.
17
18use teksilo_core::build_context::BuildContext;
19use teksilo_core::color_prop::ColorProp;
20use teksilo_core::styles::{BannerStyle, BannerStyleConfig};
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// IntUI design tokens for Banner. The recipe owns its own dimensions.
28// `BANNER_GLYPH_SIZE` and `BANNER_TITLE_DESCRIPTION_GAP` are consumed
29// by the `Banner` widget (it builds the glyph + text column); the rest
30// are consumed here.
31pub const BANNER_PADDING_HORIZONTAL: f32 = 12.0;
32pub const BANNER_PADDING_VERTICAL: f32 = 10.0;
33pub const BANNER_CORNER_RADIUS: f32 = 8.0;
34/// Diameter of the leading severity glyph (info / success / error
35/// circle, warning triangle).
36pub const BANNER_GLYPH_SIZE: f32 = 16.0;
37/// Horizontal gap between glyph, text column, action widget, and
38/// dismiss button.
39pub const BANNER_CONTENT_GAP: f32 = 10.0;
40/// Vertical gap between the title and the optional description text
41/// inside the body column.
42pub const BANNER_TITLE_DESCRIPTION_GAP: f32 = 2.0;
43
44/// Dimension recipe for [`RecipeBannerStyle`]. All fields default to the
45/// corresponding `BANNER_*` constants so callers can override individual
46/// measurements without touching the rest.
47#[derive(Debug, Clone, Copy, PartialEq)]
48pub struct BannerRecipe {
49 pub padding_horizontal: f32,
50 pub padding_vertical: f32,
51 pub corner_radius: f32,
52 pub glyph_size: f32,
53 pub content_gap: f32,
54 pub title_description_gap: f32,
55}
56
57impl BannerRecipe {
58 /// This recipe's dimensions resolved against a density's [`InputTokens`].
59 ///
60 /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
61 /// ladder — so the shipped values below are the Compact column by
62 /// construction and cannot drift from it.
63 pub fn for_tokens(tokens: &InputTokens) -> Self {
64 Self {
65 padding_horizontal: spacing(BANNER_PADDING_HORIZONTAL, tokens),
66 padding_vertical: spacing(BANNER_PADDING_VERTICAL, tokens),
67 corner_radius: BANNER_CORNER_RADIUS,
68 glyph_size: BANNER_GLYPH_SIZE,
69 content_gap: spacing(BANNER_CONTENT_GAP, tokens),
70 title_description_gap: spacing(BANNER_TITLE_DESCRIPTION_GAP, tokens),
71 }
72 }
73}
74
75impl Default for BannerRecipe {
76 fn default() -> Self {
77 Self::for_tokens(&InputTokens::default())
78 }
79}
80
81/// Default `BannerStyle` shipped with Teksilo. Surface tint comes from
82/// the per-severity `SurfaceRole` (no border — the status surface
83/// tokens already encode contrast with the page background).
84#[derive(Debug, Default, Clone, Copy)]
85pub struct RecipeBannerStyle {
86 pub recipe: BannerRecipe,
87}
88
89impl RecipeBannerStyle {
90 pub fn new(recipe: BannerRecipe) -> Self {
91 Self { recipe }
92 }
93
94 /// This style with every dimension resolved against a density's
95 /// [`InputTokens`], as `RecipeBannerStyle::for_tokens(&ctx.theme().input)` at
96 /// the widget's own build site.
97 ///
98 /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
99 /// tree gets exactly the values this module documents.
100 pub fn for_tokens(tokens: &InputTokens) -> Self {
101 Self {
102 recipe: BannerRecipe::for_tokens(tokens),
103 }
104 }
105}
106
107impl BannerStyle for RecipeBannerStyle {
108 fn make_body(&self, cfg: &BannerStyleConfig, ctx: &mut BuildContext) -> WidgetId {
109 let radius = CornerRadius::uniform(self.recipe.corner_radius);
110
111 // Background panel — status surface tint, no border.
112 let bg = ctx.add(
113 RectWidget::new()
114 .background(ColorProp::SurfaceRole(cfg.severity.surface()))
115 .corner_radius(radius),
116 );
117
118 // Row layout: [glyph] [content (expands to fill)].
119 let content = ctx.add(Expand::horizontal().child(cfg.content));
120 let row = ctx.add(
121 HStack::new()
122 .spacing(self.recipe.content_gap)
123 .alignment(VAlignment::Center)
124 .child(cfg.leading_glyph)
125 .child(content),
126 );
127 let padded = ctx.add(
128 Padding::symmetric(self.recipe.padding_vertical, self.recipe.padding_horizontal)
129 .child(row),
130 );
131
132 ctx.add(ZStack::new().child(bg).child(padded))
133 }
134}