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_i18n::LocalizedString;
49use teksilo_tokens::{TextRole, TextStyleRole};
50
51/// Horizontal indent of the content area below the title (dp).
52pub const GROUP_BOX_CONTENT_INDENT: f32 = 24.0;
53/// Vertical gap between the title row and the content area (dp).
54pub const GROUP_BOX_TITLE_CONTENT_SPACING: f32 = 8.0;
55/// Gap between the checkbox and the adjacent title label in checkable mode (dp).
56pub const GROUP_BOX_CHECKBOX_GAP: f32 = 6.0;
57
58/// A titled cluster of controls with optional enable/disable toggle.
59///
60/// See the [module documentation](self) for the checkable-mode details and
61/// the [`GroupHeader`](crate::GroupHeader) sibling.
62pub struct GroupBox {
63    title: LocalizedString,
64    checked: Option<Signal<bool>>,
65    pending_content: Option<Box<dyn Widget>>,
66    content_id: Option<WidgetId>,
67    root_child_id: Option<WidgetId>,
68}
69
70impl GroupBox {
71    /// Create a non-checkable group box with the given `title`.
72    pub fn new(title: impl Into<LocalizedString>) -> Self {
73        let ls: LocalizedString = title.into();
74        Self {
75            title: ls,
76            checked: None,
77            pending_content: None,
78            content_id: None,
79            root_child_id: None,
80        }
81    }
82
83    /// Turn this into a checkable GroupBox. When the signal is `false`, events
84    /// to descendants of the content area are blocked via effective-enabled
85    /// ancestor propagation. The title checkbox itself stays interactive.
86    pub fn checkable(mut self, checked: Signal<bool>) -> Self {
87        self.checked = Some(checked);
88        self
89    }
90
91    /// Set the content widget inline (deferred insertion).
92    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
93        self.pending_content = Some(Box::new(widget));
94        self
95    }
96
97    /// Set the content widget by pre-registered ID.
98    pub fn child_id(mut self, id: WidgetId) -> Self {
99        self.content_id = Some(id);
100        self
101    }
102}
103
104impl std::fmt::Debug for GroupBox {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        f.debug_struct("GroupBox")
107            .field("title", &self.title)
108            .field("checkable", &self.checked.is_some())
109            .finish()
110    }
111}
112
113impl Widget for GroupBox {
114    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
115        if let Some(pending) = self.pending_content.take() {
116            self.content_id = Some(ctx.add_boxed(pending));
117        }
118
119        // When checkable, refresh the group's own a11y node (set_disabled
120        // tracks the unchecked state) without triggering a relayout.
121        if let Some(ref checked) = self.checked {
122            let self_id = ctx.self_id();
123            checked.bind_to(
124                self_id,
125                ctx.binding_registry(),
126                BindingLevel::AccessibilityOnly,
127            );
128        }
129
130        let theme_signal = ctx.theme_signal();
131        let _ = theme_signal.get();
132
133        let title_label = TextWidget::new(self.title.clone())
134            .style(TextStyleRole::BodyBold)
135            .color(TextRole::Primary)
136            .single_line()
137            .a11y_hidden();
138
139        let title_row_id = if let Some(ref checked) = self.checked {
140            // The adjacent title text is `a11y_hidden`, so the checkbox must
141            // carry the accessible name for the group's on/off state.
142            let checkbox = Checkbox::new(checked.clone()).label(self.title.clone());
143            ctx.add(
144                HStack::new()
145                    .spacing(GROUP_BOX_CHECKBOX_GAP)
146                    .child(checkbox)
147                    .child(title_label),
148            )
149        } else {
150            ctx.add(title_label)
151        };
152
153        let padded_content_id = if let Some(content_id) = self.content_id {
154            ctx.add(Padding::new(0.0, 0.0, 0.0, GROUP_BOX_CONTENT_INDENT).child_id(content_id))
155        } else {
156            ctx.add(Padding::new(0.0, 0.0, 0.0, GROUP_BOX_CONTENT_INDENT))
157        };
158
159        // When checkable and unchecked, lay a translucent surface tint over
160        // the padded content so it reads as greyed-out. The dispatcher-level
161        // ancestor-enabled check already blocks interaction; this overlay is
162        // purely a visual cue.
163        let content_wrapper_id = if let Some(ref checked) = self.checked {
164            let dim_color = theme_signal.map(|t| t.colors.surface_main.with_alpha(0.6));
165            let dim_overlay_id = ctx.add(RectWidget::new().background(dim_color));
166            ctx.visible_when(dim_overlay_id, checked.map(|v| !*v));
167            ctx.enabled_when(padded_content_id, checked.clone());
168            ctx.add(
169                ZStack::new()
170                    .add_child(padded_content_id)
171                    .add_child(dim_overlay_id),
172            )
173        } else {
174            padded_content_id
175        };
176
177        let root = ctx.add(
178            VStack::new()
179                .spacing(GROUP_BOX_TITLE_CONTENT_SPACING)
180                .add_child(title_row_id)
181                .add_child(content_wrapper_id),
182        );
183        self.root_child_id = Some(root);
184
185        vec![root]
186    }
187
188    fn layout_response(
189        &self,
190        proposal: SizeProposal,
191        ctx: &LayoutContext,
192    ) -> teksilo_core::widget::LayoutResponse {
193        if let Some(root) = self.root_child_id
194            && let Some(size) = ctx.child_size(root, proposal)
195        {
196            return (size).into();
197        }
198        proposal.resolve(0.0, 0.0).into()
199    }
200
201    fn place_children(
202        &self,
203        bounds: Rect,
204        _proposal: SizeProposal,
205        children: &mut [WidgetPlacement],
206        _ctx: &LayoutContext,
207    ) {
208        for child in children.iter_mut() {
209            child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
210            child.size = Size::new(bounds.width, bounds.height);
211        }
212    }
213
214    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
215        builder.set_role(teksilo_core::accesskit::Role::Group);
216        builder.set_name(self.title.resolve_now());
217        if let Some(ref checked) = self.checked
218            && !checked.get()
219        {
220            builder.set_disabled();
221        }
222    }
223
224    fn children(&self) -> Vec<WidgetId> {
225        self.root_child_id.into_iter().collect()
226    }
227}