Skip to main content

teksilo_widgets/
group_box.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! GroupBox — titled cluster of controls in Int UI / Jewel style.
5//!
6//! A bold title (optionally preceded by a checkbox) sits above an indented
7//! content area. No border, no frame — pure composition. The standard use
8//! is grouping related settings controls on a preferences sheet or
9//! form — the IntelliJ "group" pattern.
10//!
11//! In checkable mode, unchecking disables event dispatch to every descendant
12//! of the content area (via `ctx.enabled_when` with ancestor propagation) AND
13//! paints a translucent surface overlay over the content so it reads as
14//! greyed-out. The title checkbox itself stays interactive.
15//!
16//! ## When to use
17//!
18//! - **GroupBox** — logical cluster with a title; optional enable/disable
19//!   toggle for the whole cluster. Use for settings sections.
20//! - [`GroupHeader`](crate::GroupHeader) — lighter-weight "soft divider +
21//!   caption" without a content slot; use to label regions that are not
22//!   collapsed or disabled as a unit.
23//!
24//! ## Accessibility
25//!
26//! The box node carries `Role::Group` and its `name` is set to the title
27//! string. When checkable and unchecked, `set_disabled()` is set on the
28//! group node so assistive technology announces the cluster as unavailable.
29//!
30//! ```rust
31//! # use teksilo_widgets::GroupBox;
32//! # use teksilo_widgets::primitives::TextWidget;
33//! # use teksilo_i18n::lit;
34//! let _w = GroupBox::new(lit!("Indentation"))
35//!     .child(TextWidget::new(lit!("Tab width: 4")));
36//! ```
37
38use teksilo_canvas::{Rect, Size, SizeProposal};
39use teksilo_core::accessibility::AccessNodeBuilder;
40use teksilo_core::binding::BindingLevel;
41use teksilo_core::build_context::BuildContext;
42use teksilo_core::signal::Signal;
43use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
44use teksilo_core::widget_id::WidgetId;
45
46use crate::Checkbox;
47use crate::primitives::{HStack, Padding, RectWidget, TextWidget, VStack, ZStack};
48use teksilo_core::styles::density::spacing;
49use teksilo_i18n::LocalizedString;
50use teksilo_tokens::{InputTokens, TextRole, TextStyleRole};
51
52/// Horizontal indent of the content area below the title (dp).
53pub const GROUP_BOX_CONTENT_INDENT: f32 = 24.0;
54
55/// [`GROUP_BOX_CONTENT_INDENT`] scaled by the density's `spacing_factor`
56/// (1.00 / 1.15 / 1.30).
57pub fn group_box_content_indent(tokens: &InputTokens) -> f32 {
58    spacing(GROUP_BOX_CONTENT_INDENT, tokens)
59}
60/// Vertical gap between the title row and the content area (dp).
61pub const GROUP_BOX_TITLE_CONTENT_SPACING: f32 = 8.0;
62
63/// [`GROUP_BOX_TITLE_CONTENT_SPACING`] scaled by the density's `spacing_factor`
64/// (1.00 / 1.15 / 1.30).
65pub fn group_box_title_content_spacing(tokens: &InputTokens) -> f32 {
66    spacing(GROUP_BOX_TITLE_CONTENT_SPACING, tokens)
67}
68/// Gap between the checkbox and the adjacent title label in checkable mode (dp).
69pub const GROUP_BOX_CHECKBOX_GAP: f32 = 6.0;
70
71/// [`GROUP_BOX_CHECKBOX_GAP`] scaled by the density's `spacing_factor`
72/// (1.00 / 1.15 / 1.30).
73pub fn group_box_checkbox_gap(tokens: &InputTokens) -> f32 {
74    spacing(GROUP_BOX_CHECKBOX_GAP, tokens)
75}
76
77/// A titled cluster of controls with optional enable/disable toggle.
78///
79/// See the [module documentation](self) for the checkable-mode details and
80/// the [`GroupHeader`](crate::GroupHeader) sibling.
81pub struct GroupBox {
82    title: LocalizedString,
83    checked: Option<Signal<bool>>,
84    pending_content: Option<Box<dyn Widget>>,
85    content_id: Option<WidgetId>,
86    root_child_id: Option<WidgetId>,
87}
88
89impl GroupBox {
90    /// Create a non-checkable group box with the given `title`.
91    pub fn new(title: impl Into<LocalizedString>) -> Self {
92        let ls: LocalizedString = title.into();
93        Self {
94            title: ls,
95            checked: None,
96            pending_content: None,
97            content_id: None,
98            root_child_id: None,
99        }
100    }
101
102    /// Turn this into a checkable GroupBox. When the signal is `false`, events
103    /// to descendants of the content area are blocked via effective-enabled
104    /// ancestor propagation. The title checkbox itself stays interactive.
105    pub fn checkable(mut self, checked: Signal<bool>) -> Self {
106        self.checked = Some(checked);
107        self
108    }
109
110    /// Set the content widget inline (deferred insertion).
111    pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
112        match teksilo_core::IntoTeksiChild::into_pending(widget) {
113            teksilo_core::PendingChild::Id(id) => {
114                self.content_id = Some(id);
115                self
116            }
117            teksilo_core::PendingChild::Deferred(w) => {
118                self.pending_content = Some(w);
119                self
120            }
121        }
122    }
123    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
124    ///
125    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
126    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
127    /// adds no arena node, so nothing is laid out, painted, or published to the
128    /// accessibility tree, and a stack applies no spacing around it.
129    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
130        match widget {
131            Some(w) => self.child(w),
132            None => self,
133        }
134    }
135}
136
137impl std::fmt::Debug for GroupBox {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.debug_struct("GroupBox")
140            .field("title", &self.title)
141            .field("checkable", &self.checked.is_some())
142            .finish()
143    }
144}
145
146impl Widget for GroupBox {
147    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
148        if let Some(pending) = self.pending_content.take() {
149            self.content_id = Some(ctx.add_boxed(pending));
150        }
151
152        // When checkable, refresh the group's own a11y node (set_disabled
153        // tracks the unchecked state) without triggering a relayout.
154        if let Some(ref checked) = self.checked {
155            let self_id = ctx.self_id();
156            checked.bind_to(
157                self_id,
158                ctx.binding_registry(),
159                BindingLevel::AccessibilityOnly,
160            );
161        }
162
163        let theme_signal = ctx.theme_signal();
164        let _ = theme_signal.get();
165
166        let title_label = TextWidget::new(self.title.clone())
167            .style(TextStyleRole::BodyBold)
168            .color(TextRole::Primary)
169            .single_line()
170            .a11y_hidden();
171
172        let title_row_id = if let Some(ref checked) = self.checked {
173            // The adjacent title text is `a11y_hidden`, so the checkbox must
174            // carry the accessible name for the group's on/off state.
175            let checkbox = Checkbox::new(checked.clone()).label(self.title.clone());
176            ctx.add(
177                HStack::new()
178                    .spacing(group_box_checkbox_gap(&ctx.theme().input))
179                    .child(checkbox)
180                    .child(title_label),
181            )
182        } else {
183            ctx.add(title_label)
184        };
185
186        let padded_content_id = if let Some(content_id) = self.content_id {
187            ctx.add(
188                Padding::new(0.0, 0.0, 0.0, group_box_content_indent(&ctx.theme().input))
189                    .child(content_id),
190            )
191        } else {
192            ctx.add(Padding::new(
193                0.0,
194                0.0,
195                0.0,
196                group_box_content_indent(&ctx.theme().input),
197            ))
198        };
199
200        // When checkable and unchecked, lay a translucent surface tint over
201        // the padded content so it reads as greyed-out. The dispatcher-level
202        // ancestor-enabled check already blocks interaction; this overlay is
203        // purely a visual cue.
204        let content_wrapper_id = if let Some(ref checked) = self.checked {
205            let dim_color = theme_signal.map(|t| t.colors.surface_main.with_alpha(0.6));
206            let dim_overlay_id = ctx.add(RectWidget::new().background(dim_color));
207            ctx.visible_when(dim_overlay_id, checked.map(|v| !*v));
208            ctx.enabled_when(padded_content_id, checked.clone());
209            ctx.add(ZStack::new().child(padded_content_id).child(dim_overlay_id))
210        } else {
211            padded_content_id
212        };
213
214        let root = ctx.add(
215            VStack::new()
216                .spacing(group_box_title_content_spacing(&ctx.theme().input))
217                .child(title_row_id)
218                .child(content_wrapper_id),
219        );
220        self.root_child_id = Some(root);
221
222        vec![root]
223    }
224
225    fn layout_response(
226        &self,
227        proposal: SizeProposal,
228        ctx: &LayoutContext,
229    ) -> teksilo_core::widget::LayoutResponse {
230        if let Some(root) = self.root_child_id
231            && let Some(size) = ctx.child_size(root, proposal)
232        {
233            return (size).into();
234        }
235        proposal.resolve(0.0, 0.0).into()
236    }
237
238    fn place_children(
239        &self,
240        bounds: Rect,
241        _proposal: SizeProposal,
242        children: &mut [WidgetPlacement],
243        _ctx: &LayoutContext,
244    ) {
245        for child in children.iter_mut() {
246            child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
247            child.size = Size::new(bounds.width, bounds.height);
248        }
249    }
250
251    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
252        builder.set_role(teksilo_core::accesskit::Role::Group);
253        builder.set_name(self.title.resolve_now());
254        if let Some(ref checked) = self.checked
255            && !checked.get()
256        {
257            builder.set_disabled();
258        }
259    }
260
261    fn children(&self) -> Vec<WidgetId> {
262        self.root_child_id.into_iter().collect()
263    }
264}