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 an inline child widget (deferred insertion).
118    pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
119        self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
120        self
121    }
122    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
123    ///
124    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
125    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
126    /// adds no arena node, so nothing is laid out, painted, or published to the
127    /// accessibility tree, and a stack applies no spacing around it.
128    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
129        match widget {
130            Some(w) => self.child(w),
131            None => self,
132        }
133    }
134
135    /// Override the background. Accepts `Color`, a [`SurfaceRole`](teksilo_tokens::SurfaceRole),
136    /// or a `Signal<Color>`. Default (unset) is `SurfaceRole::Main`.
137    pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
138        self.background = Some(color.into());
139        self
140    }
141
142    /// Override the border color. Accepts `Color`, a [`BorderRole`](teksilo_tokens::BorderRole),
143    /// or a `Signal<Color>`. Default (unset) is `BorderRole::Default`.
144    pub fn border_color(mut self, color: impl Into<ColorProp>) -> Self {
145        self.border_color = Some(color.into());
146        self
147    }
148
149    /// Override the border width (default: the active `PanelStyle` recipe's own
150    /// border width — `RecipePanelStyle` uses 1 dp).
151    /// Accepts a static `f32` or a reactive `Signal<f32>`.
152    pub fn border_width(mut self, width: impl Into<Prop<f32>>) -> Self {
153        self.border_width = Some(width.into());
154        self
155    }
156
157    /// Override the corner radius (default: theme `radius_popup`).
158    /// Accepts a static `f32` or a reactive `Signal<f32>`.
159    pub fn corner_radius(mut self, radius: impl Into<Prop<f32>>) -> Self {
160        self.corner_radius = Some(radius.into());
161        self
162    }
163
164    /// Override the padding (default: the active `PanelStyle` recipe's own
165    /// padding — `RecipePanelStyle`'s 12 dp, density-scaled).
166    /// Accepts a static `f32` or a reactive `Signal<f32>`.
167    pub fn padding(mut self, padding: impl Into<Prop<f32>>) -> Self {
168        self.padding = Some(padding.into());
169        self
170    }
171}
172
173impl Default for Panel {
174    fn default() -> Self {
175        Self::new()
176    }
177}
178
179impl Widget for Panel {
180    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
181        if let Some(pending) = self.pending_child.take() {
182            self.child_id = Some(match pending {
183                PendingChild::Id(id) => id,
184                PendingChild::Deferred(w) => ctx.add_boxed(w),
185            });
186        }
187        let content = match self.child_id {
188            Some(id) => id,
189            // Headless / empty panel — emit a zero-size placeholder so
190            // the style still has a `content: WidgetId` to wrap.
191            None => ctx.add(crate::primitives::FixedSize::new().width(0.0).height(0.0)),
192        };
193
194        let style: SharedPanelStyle = self
195            .style_override
196            .clone()
197            .or_else(|| ctx.theme().style_slots.panel.clone())
198            .unwrap_or_else(|| {
199                Rc::new(crate::styles::RecipePanelStyle::for_tokens(
200                    &ctx.theme().input,
201                ))
202            });
203        let cfg = PanelStyleConfig {
204            content,
205            variant: self.variant,
206            background_override: self.background.clone(),
207            border_color_override: self.border_color.clone(),
208            border_width_override: self.border_width.clone(),
209            corner_radius_override: self.corner_radius.clone(),
210            padding_override: self.padding.clone(),
211        };
212        let root_id = style.make_body(&cfg, ctx);
213        self.root_child_id = Some(root_id);
214        vec![root_id]
215    }
216
217    fn layout_response(
218        &self,
219        proposal: SizeProposal,
220        ctx: &LayoutContext,
221    ) -> teksilo_core::widget::LayoutResponse {
222        if let Some(root) = self.root_child_id
223            && let Some(size) = ctx.child_size(root, proposal)
224        {
225            return (size).into();
226        }
227        proposal.resolve(0.0, 0.0).into()
228    }
229
230    fn place_children(
231        &self,
232        bounds: Rect,
233        _proposal: SizeProposal,
234        children: &mut [WidgetPlacement],
235        _ctx: &LayoutContext,
236    ) {
237        for child in children.iter_mut() {
238            child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
239            child.size = Size::new(bounds.width, bounds.height);
240        }
241    }
242
243    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
244        if self.a11y_presentational {
245            builder.set_hidden();
246            return;
247        }
248        builder.set_role(teksilo_core::accesskit::Role::Group);
249    }
250
251    fn children(&self) -> Vec<WidgetId> {
252        self.root_child_id.into_iter().collect()
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use teksilo_core::widget_tree::WidgetTree;
260
261    #[derive(Debug)]
262    struct FixedLeaf(f32, f32);
263    impl Widget for FixedLeaf {
264        fn layout_response(
265            &self,
266            _proposal: SizeProposal,
267            _ctx: &LayoutContext,
268        ) -> teksilo_core::widget::LayoutResponse {
269            Size::new(self.0, self.1).into()
270        }
271    }
272
273    #[test]
274    fn panel_adds_padding_to_child_size() {
275        let theme = teksilo_core::presets::intui::light();
276        let mut tree = WidgetTree::new().with_theme(theme.clone());
277        let child = tree.add(FixedLeaf(80.0, 40.0));
278        let panel = tree.add(Panel::new().padding(10.0).child(child));
279        tree.layout(SizeProposal::unspecified());
280
281        let pb = tree.bounds(panel);
282        assert!((pb.width - 100.0).abs() < 0.01); // 80 + 10*2
283        assert!((pb.height - 60.0).abs() < 0.01); // 40 + 10*2
284    }
285
286    #[test]
287    fn panel_child_positioned_with_padding() {
288        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
289        let child = tree.add(FixedLeaf(80.0, 40.0));
290        let _panel = tree.add(Panel::new().padding(12.0).child(child));
291        tree.layout(SizeProposal::exact(200.0, 100.0));
292
293        let cb = tree.bounds(child);
294        assert!((cb.x - 12.0).abs() < 0.01);
295        assert!((cb.y - 12.0).abs() < 0.01);
296    }
297
298    #[test]
299    fn panel_paints_background() {
300        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
301        let child = tree.add(FixedLeaf(50.0, 30.0));
302        let _panel = tree.add(
303            Panel::new()
304                .background(Color::RED)
305                .corner_radius(8.0)
306                .child(child),
307        );
308        tree.layout(SizeProposal::exact(200.0, 100.0));
309        let frame = tree.render();
310        assert!(
311            !frame.shapes.is_empty(),
312            "panel should render a background shape"
313        );
314    }
315}