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::{PanelStyle, PanelStyleConfig, PanelVariant};
28use teksilo_core::widget::{
29    LayoutContext, LayoutResponse, PaintContext, PendingChild, Widget, WidgetPlacement,
30};
31use teksilo_core::widget_id::WidgetId;
32use teksilo_tokens::CornerRadius;
33
34// IntUI design tokens for Panel. The recipe owns its own dimensions.
35pub const PANEL_PADDING: f32 = 12.0;
36pub const PANEL_CORNER_RADIUS: f32 = 8.0;
37pub const PANEL_BORDER_WIDTH: f32 = 1.0;
38
39/// Tunable dimensions for [`RecipePanelStyle`].
40#[derive(Debug, Clone, Copy, PartialEq)]
41pub struct PanelRecipe {
42    pub padding: f32,
43    pub corner_radius: f32,
44    pub border_width: f32,
45}
46
47impl Default for PanelRecipe {
48    fn default() -> Self {
49        Self {
50            padding: PANEL_PADDING,
51            corner_radius: PANEL_CORNER_RADIUS,
52            border_width: PANEL_BORDER_WIDTH,
53        }
54    }
55}
56
57/// Default `PanelStyle` shipped with Teksilo. Honours all four
58/// `PanelVariant` values via background / border defaults; honours
59/// caller overrides (background, border, corner radius, padding) when
60/// set.
61#[derive(Debug, Default, Clone, Copy)]
62pub struct RecipePanelStyle {
63    pub recipe: PanelRecipe,
64}
65
66impl RecipePanelStyle {
67    pub fn new(recipe: PanelRecipe) -> Self {
68        Self { recipe }
69    }
70}
71
72impl PanelStyle for RecipePanelStyle {
73    fn make_body(&self, cfg: &PanelStyleConfig, ctx: &mut BuildContext) -> WidgetId {
74        let frame = PanelFrame {
75            child_id: None,
76            pending_child: Some(PendingChild::Id(cfg.content)),
77            variant: cfg.variant,
78            background: cfg.background_override.clone(),
79            border_color: cfg.border_color_override.clone(),
80            border_width: cfg
81                .border_width_override
82                .clone()
83                .unwrap_or(Prop::Static(self.recipe.border_width)),
84            corner_radius: cfg
85                .corner_radius_override
86                .clone()
87                .unwrap_or(Prop::Static(self.recipe.corner_radius)),
88            padding: cfg
89                .padding_override
90                .clone()
91                .unwrap_or(Prop::Static(self.recipe.padding)),
92        };
93        ctx.add(frame)
94    }
95}
96
97/// Internal container widget that paints the panel chrome and lays out
98/// the content with padding inset. Combines what the pre-refactor
99/// `Panel` did into a single Widget so proposal propagation works
100/// correctly (a separate `Padding` inside a `ZStack` measures with
101/// `unspecified` and inflates to the content's preferred size).
102struct PanelFrame {
103    child_id: Option<WidgetId>,
104    pending_child: Option<PendingChild>,
105    variant: PanelVariant,
106    background: Option<ColorProp>,
107    border_color: Option<ColorProp>,
108    border_width: Prop<f32>,
109    corner_radius: Prop<f32>,
110    padding: Prop<f32>,
111}
112
113impl std::fmt::Debug for PanelFrame {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        f.debug_struct("PanelFrame")
116            .field("variant", &self.variant)
117            .finish()
118    }
119}
120
121impl Widget for PanelFrame {
122    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
123        if let Some(pending) = self.pending_child.take() {
124            self.child_id = Some(match pending {
125                PendingChild::Id(id) => id,
126                PendingChild::Deferred(w) => ctx.add_boxed(w),
127            });
128        }
129        let id = ctx.self_id();
130        let registry = ctx.binding_registry();
131        if let Some(p) = &self.background {
132            p.register_if_bound(id, registry, BindingLevel::RepaintOnly);
133        }
134        if let Some(p) = &self.border_color {
135            p.register_if_bound(id, registry, BindingLevel::RepaintOnly);
136        }
137        self.border_width
138            .register_if_bound(id, registry, BindingLevel::RepaintOnly);
139        self.corner_radius
140            .register_if_bound(id, registry, BindingLevel::RepaintOnly);
141        self.padding
142            .register_if_bound(id, registry, BindingLevel::Relayout);
143        self.child_id.into_iter().collect()
144    }
145
146    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
147        let pad = self.padding.get();
148        let inset = pad * 2.0;
149        if let Some(child_id) = self.child_id {
150            let inner_proposal = SizeProposal {
151                width: proposal.width.map(|w| (w - inset).max(0.0)),
152                height: proposal.height.map(|h| (h - inset).max(0.0)),
153            };
154            if let Some(child_size) = ctx.child_size(child_id, inner_proposal) {
155                return (Size::new(child_size.width + inset, child_size.height + inset)).into();
156            }
157        }
158        proposal.resolve(inset, inset).into()
159    }
160
161    fn place_children(
162        &self,
163        bounds: Rect,
164        _proposal: SizeProposal,
165        children: &mut [WidgetPlacement],
166        _ctx: &LayoutContext,
167    ) {
168        let pad = self.padding.get();
169        for child in children.iter_mut() {
170            child.origin = teksilo_canvas::Point::new(bounds.x + pad, bounds.y + pad);
171            child.size = Size::new(
172                (bounds.width - pad * 2.0).max(0.0),
173                (bounds.height - pad * 2.0).max(0.0),
174            );
175        }
176    }
177
178    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
179        let colors = &ctx.theme.colors;
180
181        let bg = if let Some(p) = &self.background {
182            p.resolve(ctx.theme, ctx.effective_enabled)
183        } else {
184            match self.variant {
185                PanelVariant::Plain => colors.surface_main,
186                PanelVariant::Sunken => colors.surface_sunken,
187                PanelVariant::Raised => colors.surface_raised,
188                PanelVariant::Highlighted => colors.accent_subtle_bg,
189            }
190        };
191
192        let radius = self.corner_radius.get();
193        let border_w = self.border_width.get();
194        canvas.fill_rounded_rect(bounds, CornerRadius::uniform(radius), bg);
195
196        if border_w > 0.0 {
197            let border = if let Some(p) = &self.border_color {
198                p.resolve(ctx.theme, ctx.effective_enabled)
199            } else {
200                match self.variant {
201                    PanelVariant::Plain => colors.border,
202                    PanelVariant::Sunken | PanelVariant::Raised => colors.border,
203                    PanelVariant::Highlighted => colors.accent,
204                }
205            };
206            canvas.stroke_rounded_rect(bounds, CornerRadius::uniform(radius), border, border_w);
207        }
208    }
209
210    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
211        // Presentational chrome — the parent Panel emits `Role::Group`.
212        builder.set_hidden();
213    }
214
215    fn children(&self) -> Vec<WidgetId> {
216        self.child_id.into_iter().collect()
217    }
218}