Skip to main content

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}