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//! Either way the content is reachable: a presentational panel drops its own
23//! node and keeps its children.
24//!
25//! ```rust
26//! # use teksilo_widgets::Panel;
27//! # use teksilo_widgets::primitives::TextWidget;
28//! # use teksilo_i18n::lit;
29//! let _w = Panel::new()
30//!     .padding(12.0)
31//!     .child(TextWidget::new(lit!("Content")));
32//! ```
33
34use std::rc::Rc;
35
36use teksilo_canvas::{Rect, Size, SizeProposal};
37use teksilo_core::accessibility::AccessNodeBuilder;
38use teksilo_core::color_prop::ColorProp;
39use teksilo_core::signal::Prop;
40use teksilo_core::styles::{PanelStyleConfig, PanelVariant, SharedPanelStyle};
41use teksilo_core::widget::{LayoutContext, PendingChild, Widget, WidgetPlacement};
42use teksilo_core::widget_id::WidgetId;
43#[cfg(test)]
44use teksilo_tokens::Color;
45
46/// A themed container with background, border, corner radius, and padding.
47pub struct Panel {
48    child_id: Option<WidgetId>,
49    pending_child: Option<PendingChild>,
50    background: Option<ColorProp>,
51    border_color: Option<ColorProp>,
52    border_width: Option<Prop<f32>>,
53    corner_radius: Option<Prop<f32>>,
54    padding: Option<Prop<f32>>,
55    variant: PanelVariant,
56    style_override: Option<SharedPanelStyle>,
57    root_child_id: Option<WidgetId>,
58    a11y_presentational: bool,
59}
60
61impl std::fmt::Debug for Panel {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.debug_struct("Panel")
64            .field("variant", &self.variant)
65            .field("a11y_presentational", &self.a11y_presentational)
66            .finish()
67    }
68}
69
70impl Panel {
71    /// Construct a panel with default theme values (Plain variant, no manual overrides).
72    pub fn new() -> Self {
73        Self {
74            child_id: None,
75            pending_child: None,
76            background: None,
77            border_color: None,
78            border_width: None,
79            corner_radius: None,
80            padding: None,
81            variant: PanelVariant::default(),
82            style_override: None,
83            root_child_id: None,
84            a11y_presentational: false,
85        }
86    }
87
88    /// Pick the design-language variant. Default `Plain`. The active
89    /// `PanelStyle` decides what each variant means visually (the
90    /// IntUI default maps Plain → `surface_main`, Sunken →
91    /// `surface_sunken`, Raised → `surface_raised`, Highlighted →
92    /// `accent_subtle_bg`, with matching border defaults).
93    pub fn variant(mut self, variant: PanelVariant) -> Self {
94        self.variant = variant;
95        self
96    }
97
98    /// Per-call style override. Replaces the theme-wide default
99    /// `PanelStyle` for just this Panel instance — same role as
100    /// `Button::style(...)`. Manual overrides (`background`,
101    /// `border_color`, etc.) are still passed to the style via
102    /// `PanelStyleConfig`; custom styles are free to honour or ignore
103    /// them.
104    pub fn style(mut self, style: impl teksilo_core::styles::PanelStyle) -> Self {
105        self.style_override = Some(Rc::new(style));
106        self
107    }
108
109    /// Mark the panel as presentational for assistive tech: the panel's
110    /// own node becomes a bare `GenericContainer`, which the platform
111    /// adapters drop, so its wrapping chrome (background, border,
112    /// padding) doesn't introduce a spurious `Group` node between an
113    /// outer widget (Toolbar, StatusBar, etc.) and the real content.
114    /// The children stay in the tree, promoted to the panel's parent.
115    pub fn a11y_presentational(mut self) -> Self {
116        self.a11y_presentational = true;
117        self
118    }
119
120    /// Set an inline child widget (deferred insertion).
121    pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
122        self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
123        self
124    }
125    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
126    ///
127    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
128    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
129    /// adds no arena node, so nothing is laid out, painted, or published to the
130    /// accessibility tree, and a stack applies no spacing around it.
131    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
132        match widget {
133            Some(w) => self.child(w),
134            None => self,
135        }
136    }
137
138    /// Override the background. Accepts `Color`, a [`SurfaceRole`](teksilo_tokens::SurfaceRole),
139    /// or a `Signal<Color>`. Default (unset) is `SurfaceRole::Main`.
140    pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
141        self.background = Some(color.into());
142        self
143    }
144
145    /// Override the border color. Accepts `Color`, a [`BorderRole`](teksilo_tokens::BorderRole),
146    /// or a `Signal<Color>`. Default (unset) is `BorderRole::Default`.
147    pub fn border_color(mut self, color: impl Into<ColorProp>) -> Self {
148        self.border_color = Some(color.into());
149        self
150    }
151
152    /// Override the border width (default: the active `PanelStyle` recipe's own
153    /// border width — `RecipePanelStyle` uses 1 dp).
154    /// Accepts a static `f32` or a reactive `Signal<f32>`.
155    pub fn border_width(mut self, width: impl Into<Prop<f32>>) -> Self {
156        self.border_width = Some(width.into());
157        self
158    }
159
160    /// Override the corner radius (default: theme `radius_popup`).
161    /// Accepts a static `f32` or a reactive `Signal<f32>`.
162    pub fn corner_radius(mut self, radius: impl Into<Prop<f32>>) -> Self {
163        self.corner_radius = Some(radius.into());
164        self
165    }
166
167    /// Override the padding (default: the active `PanelStyle` recipe's own
168    /// padding — `RecipePanelStyle`'s 12 dp, density-scaled).
169    /// Accepts a static `f32` or a reactive `Signal<f32>`.
170    pub fn padding(mut self, padding: impl Into<Prop<f32>>) -> Self {
171        self.padding = Some(padding.into());
172        self
173    }
174}
175
176impl Default for Panel {
177    fn default() -> Self {
178        Self::new()
179    }
180}
181
182impl Widget for Panel {
183    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
184        if let Some(pending) = self.pending_child.take() {
185            self.child_id = Some(match pending {
186                PendingChild::Id(id) => id,
187                PendingChild::Deferred(w) => ctx.add_boxed(w),
188            });
189        }
190        let content = match self.child_id {
191            Some(id) => id,
192            // Headless / empty panel — emit a zero-size placeholder so
193            // the style still has a `content: WidgetId` to wrap.
194            None => ctx.add(crate::primitives::FixedSize::new().width(0.0).height(0.0)),
195        };
196
197        let style: SharedPanelStyle = self
198            .style_override
199            .clone()
200            .or_else(|| ctx.theme().style_slots.panel.clone())
201            .unwrap_or_else(|| {
202                Rc::new(crate::styles::RecipePanelStyle::for_tokens(
203                    &ctx.theme().input,
204                ))
205            });
206        let cfg = PanelStyleConfig {
207            content,
208            variant: self.variant,
209            background_override: self.background.clone(),
210            border_color_override: self.border_color.clone(),
211            border_width_override: self.border_width.clone(),
212            corner_radius_override: self.corner_radius.clone(),
213            padding_override: self.padding.clone(),
214        };
215        let root_id = style.make_body(&cfg, ctx);
216        self.root_child_id = Some(root_id);
217        vec![root_id]
218    }
219
220    fn layout_response(
221        &self,
222        proposal: SizeProposal,
223        ctx: &LayoutContext,
224    ) -> teksilo_core::widget::LayoutResponse {
225        if let Some(root) = self.root_child_id
226            && let Some(size) = ctx.child_size(root, proposal)
227        {
228            return (size).into();
229        }
230        proposal.resolve(0.0, 0.0).into()
231    }
232
233    fn place_children(
234        &self,
235        bounds: Rect,
236        _proposal: SizeProposal,
237        children: &mut [WidgetPlacement],
238        _ctx: &LayoutContext,
239    ) {
240        for child in children.iter_mut() {
241            child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
242            child.size = Size::new(bounds.width, bounds.height);
243        }
244    }
245
246    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
247        if self.a11y_presentational {
248            // Dropped by the walker and by every adapter, children kept.
249            // Not `set_hidden()`, which an adapter reads as hiding the whole
250            // subtree: every toolbar and status bar item went with it.
251            builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
252            return;
253        }
254        builder.set_role(teksilo_core::accesskit::Role::Group);
255    }
256
257    fn children(&self) -> Vec<WidgetId> {
258        self.root_child_id.into_iter().collect()
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use teksilo_core::widget_tree::WidgetTree;
266
267    #[derive(Debug)]
268    struct FixedLeaf(f32, f32);
269    impl Widget for FixedLeaf {
270        fn layout_response(
271            &self,
272            _proposal: SizeProposal,
273            _ctx: &LayoutContext,
274        ) -> teksilo_core::widget::LayoutResponse {
275            Size::new(self.0, self.1).into()
276        }
277    }
278
279    #[test]
280    fn panel_adds_padding_to_child_size() {
281        let theme = teksilo_core::presets::intui::light();
282        let mut tree = WidgetTree::new().with_theme(theme.clone());
283        let child = tree.add(FixedLeaf(80.0, 40.0));
284        let panel = tree.add(Panel::new().padding(10.0).child(child));
285        tree.layout(SizeProposal::unspecified());
286
287        let pb = tree.bounds(panel);
288        assert!((pb.width - 100.0).abs() < 0.01); // 80 + 10*2
289        assert!((pb.height - 60.0).abs() < 0.01); // 40 + 10*2
290    }
291
292    #[test]
293    fn panel_child_positioned_with_padding() {
294        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
295        let child = tree.add(FixedLeaf(80.0, 40.0));
296        let _panel = tree.add(Panel::new().padding(12.0).child(child));
297        tree.layout(SizeProposal::exact(200.0, 100.0));
298
299        let cb = tree.bounds(child);
300        assert!((cb.x - 12.0).abs() < 0.01);
301        assert!((cb.y - 12.0).abs() < 0.01);
302    }
303
304    #[test]
305    fn panel_paints_background() {
306        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
307        let child = tree.add(FixedLeaf(50.0, 30.0));
308        let _panel = tree.add(
309            Panel::new()
310                .background(Color::RED)
311                .corner_radius(8.0)
312                .child(child),
313        );
314        tree.layout(SizeProposal::exact(200.0, 100.0));
315        let frame = tree.render();
316        assert!(
317            !frame.shapes.is_empty(),
318            "panel should render a background shape"
319        );
320    }
321}