Skip to main content

teksilo_widgets/
panel.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Panel — a themed single-child container that provides a background, border,
5//! corner radius, and padding.
6//!
7//! The equivalent of Qt's `QFrame`: a visual wrapper whose chrome comes from
8//! the active [`PanelStyle`](teksilo_core::styles::PanelStyle) trait
9//! implementation. The IntUI default (`RecipePanelStyle`) honours four
10//! [`PanelVariant`] presets (Plain /
11//! Sunken / Raised / Highlighted) while still accepting per-call overrides
12//! for background, border colour/width, corner radius, and padding. Apps
13//! requiring a custom surface (frosted glass, brutalist frame) supply their
14//! own `impl PanelStyle` per-call (`.style(...)`) or theme-wide via
15//! `theme.style_slots.panel`.
16//!
17//! ## Accessibility
18//!
19//! Emits `Role::Group` by default. Call `.a11y_presentational()` to suppress
20//! the group node when the panel is purely decorative (e.g. a toolbar
21//! background that should not introduce a spurious container in the AT tree).
22//!
23//! ```rust
24//! # use teksilo_widgets::Panel;
25//! # use teksilo_widgets::primitives::TextWidget;
26//! # use teksilo_i18n::lit;
27//! let _w = Panel::new()
28//!     .padding(12.0)
29//!     .child(TextWidget::new(lit!("Content")));
30//! ```
31
32use std::rc::Rc;
33
34use teksilo_canvas::{Rect, Size, SizeProposal};
35use teksilo_core::accessibility::AccessNodeBuilder;
36use teksilo_core::color_prop::ColorProp;
37use teksilo_core::signal::Prop;
38use teksilo_core::styles::{PanelStyleConfig, PanelVariant, SharedPanelStyle};
39use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
40use teksilo_core::widget_id::WidgetId;
41#[cfg(test)]
42use teksilo_tokens::Color;
43
44/// A themed container with background, border, corner radius, and padding.
45pub struct Panel {
46    child_id: Option<WidgetId>,
47    pending_child: Option<PendingChild>,
48    background: Option<ColorProp>,
49    border_color: Option<ColorProp>,
50    border_width: Option<Prop<f32>>,
51    corner_radius: Option<Prop<f32>>,
52    padding: Option<Prop<f32>>,
53    variant: PanelVariant,
54    style_override: Option<SharedPanelStyle>,
55    root_child_id: Option<WidgetId>,
56    a11y_presentational: bool,
57}
58
59impl std::fmt::Debug for Panel {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.debug_struct("Panel")
62            .field("variant", &self.variant)
63            .field("a11y_presentational", &self.a11y_presentational)
64            .finish()
65    }
66}
67
68impl Panel {
69    /// Construct a panel with default theme values (Plain variant, no manual overrides).
70    pub fn new() -> Self {
71        Self {
72            child_id: None,
73            pending_child: None,
74            background: None,
75            border_color: None,
76            border_width: None,
77            corner_radius: None,
78            padding: None,
79            variant: PanelVariant::default(),
80            style_override: None,
81            root_child_id: None,
82            a11y_presentational: false,
83        }
84    }
85
86    /// Pick the design-language variant. Default `Plain`. The active
87    /// `PanelStyle` decides what each variant means visually (the
88    /// IntUI default maps Plain → `surface_main`, Sunken →
89    /// `surface_sunken`, Raised → `surface_raised`, Highlighted →
90    /// `accent_subtle_bg`, with matching border defaults).
91    pub fn variant(mut self, variant: PanelVariant) -> Self {
92        self.variant = variant;
93        self
94    }
95
96    /// Per-call style override. Replaces the theme-wide default
97    /// `PanelStyle` for just this Panel instance — same role as
98    /// `Button::style(...)`. Manual overrides (`background`,
99    /// `border_color`, etc.) are still passed to the style via
100    /// `PanelStyleConfig`; custom styles are free to honour or ignore
101    /// them.
102    pub fn style(mut self, style: impl teksilo_core::styles::PanelStyle) -> Self {
103        self.style_override = Some(Rc::new(style));
104        self
105    }
106
107    /// Mark the panel as presentational for assistive tech: the panel's
108    /// own a11y node is hidden so its wrapping chrome (background,
109    /// border, padding) doesn't introduce a spurious `Group` node
110    /// between an outer widget (Toolbar, StatusBar, etc.) and the
111    /// real content. Children remain visible in the a11y tree.
112    pub fn a11y_presentational(mut self) -> Self {
113        self.a11y_presentational = true;
114        self
115    }
116
117    /// Set child by pre-registered ID.
118    pub fn child_id(mut self, id: WidgetId) -> Self {
119        self.pending_child = Some(PendingChild::Id(id));
120        self
121    }
122
123    /// Set an inline child widget (deferred insertion).
124    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
125        self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
126        self
127    }
128
129    /// Override the background. Accepts `Color`, a [`SurfaceRole`](teksilo_tokens::SurfaceRole),
130    /// or a `Signal<Color>`. Default (unset) is `SurfaceRole::Main`.
131    pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
132        self.background = Some(color.into());
133        self
134    }
135
136    /// Override the border color. Accepts `Color`, a [`BorderRole`](teksilo_tokens::BorderRole),
137    /// or a `Signal<Color>`. Default (unset) is `BorderRole::Default`.
138    pub fn border_color(mut self, color: impl Into<ColorProp>) -> Self {
139        self.border_color = Some(color.into());
140        self
141    }
142
143    /// Override the border width (default: 0 — no border).
144    /// Accepts a static `f32` or a reactive `Signal<f32>`.
145    pub fn border_width(mut self, width: impl Into<Prop<f32>>) -> Self {
146        self.border_width = Some(width.into());
147        self
148    }
149
150    /// Override the corner radius (default: theme `radius_popup`).
151    /// Accepts a static `f32` or a reactive `Signal<f32>`.
152    pub fn corner_radius(mut self, radius: impl Into<Prop<f32>>) -> Self {
153        self.corner_radius = Some(radius.into());
154        self
155    }
156
157    /// Override the padding (default: theme `components.panel.padding`).
158    /// Accepts a static `f32` or a reactive `Signal<f32>`.
159    pub fn padding(mut self, padding: impl Into<Prop<f32>>) -> Self {
160        self.padding = Some(padding.into());
161        self
162    }
163}
164
165impl Default for Panel {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171impl Widget for Panel {
172    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
173        if let Some(pending) = self.pending_child.take() {
174            self.child_id = Some(match pending {
175                PendingChild::Id(id) => id,
176                PendingChild::Deferred(w) => ctx.add_boxed(w),
177            });
178        }
179        let content = match self.child_id {
180            Some(id) => id,
181            // Headless / empty panel — emit a zero-size placeholder so
182            // the style still has a `content: WidgetId` to wrap.
183            None => ctx.add(crate::primitives::FixedSize::new().width(0.0).height(0.0)),
184        };
185
186        let style: SharedPanelStyle = self
187            .style_override
188            .clone()
189            .or_else(|| ctx.theme().style_slots.panel.clone())
190            .unwrap_or_else(|| Rc::new(crate::styles::RecipePanelStyle::default()));
191        let cfg = PanelStyleConfig {
192            content,
193            variant: self.variant,
194            background_override: self.background.clone(),
195            border_color_override: self.border_color.clone(),
196            border_width_override: self.border_width.clone(),
197            corner_radius_override: self.corner_radius.clone(),
198            padding_override: self.padding.clone(),
199        };
200        let root_id = style.make_body(&cfg, ctx);
201        self.root_child_id = Some(root_id);
202        vec![root_id]
203    }
204
205    fn layout_response(
206        &self,
207        proposal: SizeProposal,
208        ctx: &LayoutContext,
209    ) -> teksilo_core::widget::LayoutResponse {
210        if let Some(root) = self.root_child_id
211            && let Some(size) = ctx.child_size(root, proposal)
212        {
213            return (size).into();
214        }
215        proposal.resolve(0.0, 0.0).into()
216    }
217
218    fn place_children(
219        &self,
220        bounds: Rect,
221        _proposal: SizeProposal,
222        children: &mut [WidgetPlacement],
223        _ctx: &LayoutContext,
224    ) {
225        for child in children.iter_mut() {
226            child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
227            child.size = Size::new(bounds.width, bounds.height);
228        }
229    }
230
231    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
232        if self.a11y_presentational {
233            builder.set_hidden();
234            return;
235        }
236        builder.set_role(teksilo_core::accesskit::Role::Group);
237    }
238
239    fn children(&self) -> Vec<WidgetId> {
240        self.root_child_id.into_iter().collect()
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use teksilo_core::widget_tree::WidgetTree;
248
249    #[derive(Debug)]
250    struct FixedLeaf(f32, f32);
251    impl Widget for FixedLeaf {
252        fn layout_response(
253            &self,
254            _proposal: SizeProposal,
255            _ctx: &LayoutContext,
256        ) -> teksilo_core::widget::LayoutResponse {
257            Size::new(self.0, self.1).into()
258        }
259    }
260
261    #[test]
262    fn panel_adds_padding_to_child_size() {
263        let theme = teksilo_core::presets::intui::light();
264        let mut tree = WidgetTree::new().with_theme(theme.clone());
265        let child = tree.add(FixedLeaf(80.0, 40.0));
266        let panel = tree.add(Panel::new().padding(10.0).child_id(child));
267        tree.layout(SizeProposal::unspecified());
268
269        let pb = tree.bounds(panel);
270        assert!((pb.width - 100.0).abs() < 0.01); // 80 + 10*2
271        assert!((pb.height - 60.0).abs() < 0.01); // 40 + 10*2
272    }
273
274    #[test]
275    fn panel_child_positioned_with_padding() {
276        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
277        let child = tree.add(FixedLeaf(80.0, 40.0));
278        let _panel = tree.add(Panel::new().padding(12.0).child_id(child));
279        tree.layout(SizeProposal::exact(200.0, 100.0));
280
281        let cb = tree.bounds(child);
282        assert!((cb.x - 12.0).abs() < 0.01);
283        assert!((cb.y - 12.0).abs() < 0.01);
284    }
285
286    #[test]
287    fn panel_paints_background() {
288        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
289        let child = tree.add(FixedLeaf(50.0, 30.0));
290        let _panel = tree.add(
291            Panel::new()
292                .background(Color::RED)
293                .corner_radius(8.0)
294                .child_id(child),
295        );
296        tree.layout(SizeProposal::exact(200.0, 100.0));
297        let frame = tree.render();
298        assert!(
299            !frame.shapes.is_empty(),
300            "panel should render a background shape"
301        );
302    }
303}