Skip to main content

teksilo_widgets/styles/
recipe_panel_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `PanelStyle` impl driven by paint-recipe data.
5//!
6//! `RecipePanelStyle` ships the IntUI panel chrome — variant-driven
7//! background / border / corner-radius defaults, with caller overrides
8//! winning when set. Custom styles compose freely (glassmorphism panel,
9//! brutalist box, etc.) by writing their own `impl PanelStyle` block.
10//!
11//! The body is a single `PanelFrame` container widget that paints the
12//! chrome AND positions the content with padding inset — done in one
13//! widget so the proposal-resolve / intrinsic-size logic mirrors the
14//! pre-refactor `Panel` (`Size::new(child + 2*pad, child + 2*pad)` when
15//! unspecified, `proposal.resolve(...)` when bounded). Wrapping
16//! Padding in a generic ZStack would break the proposal handoff: ZStack
17//! measures its children with `unspecified` regardless of the incoming
18//! proposal, so the chrome would inflate to the child's preferred
19//! `unwrap_or(400.0, 300.0)` and overflow its parent.
20
21use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
22use teksilo_core::accessibility::AccessNodeBuilder;
23use teksilo_core::binding::BindingLevel;
24use teksilo_core::build_context::BuildContext;
25use teksilo_core::color_prop::ColorProp;
26use teksilo_core::signal::Prop;
27use teksilo_core::styles::density::spacing;
28use teksilo_core::styles::{PanelStyle, PanelStyleConfig, PanelVariant};
29use teksilo_core::widget::{
30    LayoutContext, LayoutResponse, PaintContext, PendingChild, Widget, WidgetPlacement,
31};
32use teksilo_core::widget_id::WidgetId;
33use teksilo_tokens::{CornerRadius, InputTokens};
34
35// IntUI design tokens for Panel. The recipe owns its own dimensions.
36pub const PANEL_PADDING: f32 = 12.0;
37pub const PANEL_CORNER_RADIUS: f32 = 8.0;
38pub const PANEL_BORDER_WIDTH: f32 = 1.0;
39
40/// Tunable dimensions for [`RecipePanelStyle`].
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct PanelRecipe {
43    pub padding: f32,
44    pub corner_radius: f32,
45    pub border_width: f32,
46}
47
48impl PanelRecipe {
49    /// This recipe's dimensions resolved against a density's [`InputTokens`].
50    ///
51    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
52    /// ladder — so the shipped values below are the Compact column by
53    /// construction and cannot drift from it.
54    pub fn for_tokens(tokens: &InputTokens) -> Self {
55        Self {
56            padding: spacing(PANEL_PADDING, tokens),
57            corner_radius: PANEL_CORNER_RADIUS,
58            border_width: PANEL_BORDER_WIDTH,
59        }
60    }
61}
62
63impl Default for PanelRecipe {
64    fn default() -> Self {
65        Self::for_tokens(&InputTokens::default())
66    }
67}
68
69/// Default `PanelStyle` shipped with Teksilo. Honours all four
70/// `PanelVariant` values via background / border defaults; honours
71/// caller overrides (background, border, corner radius, padding) when
72/// set.
73#[derive(Debug, Default, Clone, Copy)]
74pub struct RecipePanelStyle {
75    pub recipe: PanelRecipe,
76}
77
78impl RecipePanelStyle {
79    pub fn new(recipe: PanelRecipe) -> Self {
80        Self { recipe }
81    }
82
83    /// This style with every dimension resolved against a density's
84    /// [`InputTokens`], as `RecipePanelStyle::for_tokens(&ctx.theme().input)` at
85    /// the widget's own build site.
86    ///
87    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
88    /// tree gets exactly the values this module documents.
89    pub fn for_tokens(tokens: &InputTokens) -> Self {
90        Self {
91            recipe: PanelRecipe::for_tokens(tokens),
92        }
93    }
94}
95
96impl PanelStyle for RecipePanelStyle {
97    fn make_body(&self, cfg: &PanelStyleConfig, ctx: &mut BuildContext) -> WidgetId {
98        let frame = PanelFrame {
99            child_id: None,
100            pending_child: Some(PendingChild::Id(cfg.content)),
101            variant: cfg.variant,
102            background: cfg.background_override.clone(),
103            border_color: cfg.border_color_override.clone(),
104            border_width: cfg
105                .border_width_override
106                .clone()
107                .unwrap_or(Prop::Static(self.recipe.border_width)),
108            corner_radius: cfg
109                .corner_radius_override
110                .clone()
111                .unwrap_or(Prop::Static(self.recipe.corner_radius)),
112            padding: cfg
113                .padding_override
114                .clone()
115                .unwrap_or(Prop::Static(self.recipe.padding)),
116        };
117        ctx.add(frame)
118    }
119}
120
121/// Internal container widget that paints the panel chrome and lays out
122/// the content with padding inset. Combines what the pre-refactor
123/// `Panel` did into a single Widget so proposal propagation works
124/// correctly (a separate `Padding` inside a `ZStack` measures with
125/// `unspecified` and inflates to the content's preferred size).
126struct PanelFrame {
127    child_id: Option<WidgetId>,
128    pending_child: Option<PendingChild>,
129    variant: PanelVariant,
130    background: Option<ColorProp>,
131    border_color: Option<ColorProp>,
132    border_width: Prop<f32>,
133    corner_radius: Prop<f32>,
134    padding: Prop<f32>,
135}
136
137impl std::fmt::Debug for PanelFrame {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.debug_struct("PanelFrame")
140            .field("variant", &self.variant)
141            .finish()
142    }
143}
144
145impl Widget for PanelFrame {
146    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
147        if let Some(pending) = self.pending_child.take() {
148            self.child_id = Some(match pending {
149                PendingChild::Id(id) => id,
150                PendingChild::Deferred(w) => ctx.add_boxed(w),
151            });
152        }
153        let id = ctx.self_id();
154        let registry = ctx.binding_registry();
155        if let Some(p) = &self.background {
156            p.register_if_bound(id, registry, BindingLevel::RepaintOnly);
157        }
158        if let Some(p) = &self.border_color {
159            p.register_if_bound(id, registry, BindingLevel::RepaintOnly);
160        }
161        self.border_width
162            .register_if_bound(id, registry, BindingLevel::RepaintOnly);
163        self.corner_radius
164            .register_if_bound(id, registry, BindingLevel::RepaintOnly);
165        self.padding
166            .register_if_bound(id, registry, BindingLevel::Relayout);
167        self.child_id.into_iter().collect()
168    }
169
170    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
171        let pad = self.padding.get();
172        let inset = pad * 2.0;
173        if let Some(child_id) = self.child_id {
174            let inner_proposal = SizeProposal {
175                width: proposal.width.map(|w| (w - inset).max(0.0)),
176                height: proposal.height.map(|h| (h - inset).max(0.0)),
177            };
178            if let Some(child_size) = ctx.child_size(child_id, inner_proposal) {
179                return (Size::new(child_size.width + inset, child_size.height + inset)).into();
180            }
181        }
182        proposal.resolve(inset, inset).into()
183    }
184
185    fn place_children(
186        &self,
187        bounds: Rect,
188        _proposal: SizeProposal,
189        children: &mut [WidgetPlacement],
190        _ctx: &LayoutContext,
191    ) {
192        let pad = self.padding.get();
193        for child in children.iter_mut() {
194            child.origin = teksilo_canvas::Point::new(bounds.x + pad, bounds.y + pad);
195            child.size = Size::new(
196                (bounds.width - pad * 2.0).max(0.0),
197                (bounds.height - pad * 2.0).max(0.0),
198            );
199        }
200    }
201
202    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
203        let colors = &ctx.theme.colors;
204
205        let bg = if let Some(p) = &self.background {
206            p.resolve(ctx.theme, ctx.effective_enabled)
207        } else {
208            match self.variant {
209                PanelVariant::Plain => colors.surface_main,
210                PanelVariant::Sunken => colors.surface_sunken,
211                PanelVariant::Raised => colors.surface_raised,
212                PanelVariant::Highlighted => colors.accent_subtle_bg,
213            }
214        };
215
216        let radius = self.corner_radius.get();
217        let border_w = self.border_width.get();
218        canvas.fill_rounded_rect(bounds, CornerRadius::uniform(radius), bg);
219
220        if border_w > 0.0 {
221            let border = if let Some(p) = &self.border_color {
222                p.resolve(ctx.theme, ctx.effective_enabled)
223            } else {
224                match self.variant {
225                    PanelVariant::Plain => colors.border,
226                    PanelVariant::Sunken | PanelVariant::Raised => colors.border,
227                    PanelVariant::Highlighted => colors.accent,
228                }
229            };
230            canvas.stroke_rounded_rect(bounds, CornerRadius::uniform(radius), border, border_w);
231        }
232    }
233
234    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
235        // Presentational chrome: the parent Panel emits `Role::Group`. A bare
236        // `GenericContainer` is dropped by the walker and by every adapter
237        // with its children kept; `set_hidden()` would take the panel's
238        // content out of reach with it.
239        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
240    }
241
242    fn children(&self) -> Vec<WidgetId> {
243        self.child_id.into_iter().collect()
244    }
245}