Skip to main content

teksilo_widgets/
group_header.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! GroupHeader — a horizontal section header: label followed by a trailing
5//! rule line that fills the remaining width.
6//!
7//! Used to segment settings pages, preference sheets, and forms into labelled
8//! regions without the heavier chrome of a [`GroupBox`](crate::group_box::GroupBox).
9//! Int UI and Jewel use this pattern as a lightweight "soft divider with a
10//! caption" between groups of related controls.
11//!
12//! ```rust
13//! # use teksilo_widgets::GroupHeader;
14//! # use teksilo_i18n::lit;
15//! let _w = GroupHeader::new(lit!("Appearance"));
16//! ```
17//!
18//! Trivially composed from existing primitives:
19//! `HStack → TextWidget + Expand(Divider)`.
20
21use teksilo_canvas::{Rect, SizeProposal};
22use teksilo_core::accessibility::AccessNodeBuilder;
23use teksilo_core::build_context::BuildContext;
24use teksilo_core::color_prop::{ColorProp, TextStyleProp};
25use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
26use teksilo_core::widget_id::WidgetId;
27use teksilo_tokens::{TextRole, TextStyleRole};
28
29use crate::primitives::{Divider, Expand, HStack, TextWidget};
30use teksilo_i18n::LocalizedString;
31
32/// A labelled section header with a trailing rule line.
33pub struct GroupHeader {
34    label: LocalizedString,
35    /// Optional text-style override for the label. Defaults to
36    /// [`TextStyleRole::Body`] — IntelliJ/Jewel group headers render at
37    /// normal body size, not as a smaller caption. Accepts a static
38    /// [`TextStyle`](teksilo_tokens::TextStyle) or a
39    /// [`TextStyleRole`], so the default
40    /// (and any role override) tracks runtime theme changes.
41    style: Option<TextStyleProp>,
42    /// Optional label-color override. Defaults to [`TextRole::Primary`]
43    /// (no dimming). Accepts any `impl Into<ColorProp>` — a literal
44    /// `Color`, a text/surface role, or a `Signal<Color>` — so accent
45    /// headers track runtime theme changes.
46    color: Option<ColorProp>,
47    /// Horizontal gap between the label and the rule line.
48    gap: f32,
49    // Build state
50    root_child_id: Option<WidgetId>,
51}
52
53impl GroupHeader {
54    /// Create a section header with the given `label`.
55    pub fn new(label: impl Into<LocalizedString>) -> Self {
56        let ls: LocalizedString = label.into();
57        Self {
58            label: ls,
59            style: None,
60            color: None,
61            gap: 8.0,
62            root_child_id: None,
63        }
64    }
65
66    /// Override the label's text style (font, size, weight, …). Accepts a
67    /// static [`TextStyle`](teksilo_tokens::TextStyle) or a
68    /// [`TextStyleRole`].
69    pub fn style(mut self, style: impl Into<TextStyleProp>) -> Self {
70        self.style = Some(style.into());
71        self
72    }
73
74    /// Override the label's color. Useful when a consumer wants to
75    /// emphasise a header with an accent. Accepts a literal `Color`, a
76    /// `TextRole`/`SurfaceRole`, or a `Signal<Color>`.
77    pub fn color(mut self, color: impl Into<ColorProp>) -> Self {
78        self.color = Some(color.into());
79        self
80    }
81
82    /// Horizontal gap between the label and the rule line. Defaults to 8 dp.
83    pub fn gap(mut self, gap: f32) -> Self {
84        self.gap = gap;
85        self
86    }
87}
88
89impl std::fmt::Debug for GroupHeader {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        f.debug_struct("GroupHeader")
92            .field("label", &self.label)
93            .field("gap", &self.gap)
94            .finish()
95    }
96}
97
98impl Widget for GroupHeader {
99    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
100        // Default to the Body text role and the Primary text color role so
101        // both the default and any caller override resolve at paint/layout
102        // time and track runtime theme changes.
103        let style = self
104            .style
105            .clone()
106            .unwrap_or_else(|| TextStyleRole::Body.into());
107        let color = self
108            .color
109            .clone()
110            .unwrap_or_else(|| TextRole::Primary.into());
111
112        let label = TextWidget::new(self.label.clone())
113            .style(style)
114            .color(color)
115            .single_line()
116            .a11y_hidden();
117        let label_id = ctx.add(label);
118
119        // Fill the remaining horizontal space with a horizontal Divider.
120        // `Expand::horizontal()` defaults to flex=1, claiming leftover slack
121        // from the parent HStack and stretching the divider to its bounds.
122        let rule_id = ctx.add(Expand::horizontal().child(Divider::horizontal()));
123
124        let row_id = ctx.add(
125            HStack::new()
126                .spacing(self.gap)
127                .add_child(label_id)
128                .add_child(rule_id),
129        );
130        self.root_child_id = Some(row_id);
131
132        vec![row_id]
133    }
134
135    fn layout_response(
136        &self,
137        proposal: SizeProposal,
138        ctx: &LayoutContext,
139    ) -> teksilo_core::widget::LayoutResponse {
140        match self.root_child_id {
141            Some(id) => ctx
142                .child_size(id, proposal)
143                .unwrap_or_else(|| proposal.resolve(0.0, 0.0)),
144            None => proposal.resolve(0.0, 0.0),
145        }
146        .into()
147    }
148
149    fn place_children(
150        &self,
151        bounds: Rect,
152        _proposal: SizeProposal,
153        children: &mut [WidgetPlacement],
154        _ctx: &LayoutContext,
155    ) {
156        for child in children.iter_mut() {
157            child.origin = bounds.origin();
158            child.size = bounds.size();
159        }
160    }
161
162    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
163        // A GroupHeader is a section caption: it names the region that
164        // follows it without consuming focus or firing actions. `Label`
165        // is the closest accesskit role — screen readers read it as a
166        // non-interactive caption.
167        builder.set_role(teksilo_core::accesskit::Role::Label);
168        builder.set_name(self.label.resolve_now());
169    }
170
171    fn children(&self) -> Vec<WidgetId> {
172        match self.root_child_id {
173            Some(id) => vec![id],
174            None => Vec::new(),
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use teksilo_core::widget_tree::WidgetTree;
183    use teksilo_i18n::lit;
184
185    #[test]
186    fn builds_and_lays_out_with_proposed_width() {
187        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
188        let header = tree.add(GroupHeader::new(lit!("Appearance")));
189        tree.layout(SizeProposal {
190            width: Some(400.0),
191            height: None,
192        });
193        let b = tree.bounds(header);
194        // Header claims the full proposed width (label + spacer + rule).
195        assert!(
196            (b.width - 400.0).abs() < 0.01,
197            "expected header width 400, got {}",
198            b.width
199        );
200        // Height is driven by the label (single line of `small` text),
201        // which is taller than the 1 dp divider, so the HStack height
202        // equals the label height — strictly positive.
203        assert!(b.height > 0.0);
204    }
205
206    #[test]
207    fn rule_line_absorbs_remaining_width() {
208        // The header's root HStack child is `[label, expand(divider)]`.
209        // Walk the tree to the Expand and verify its bounds consume the
210        // remaining width, not the natural 0-width of a bare Divider.
211        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
212        let header = tree.add(GroupHeader::new(lit!("X")));
213        tree.layout(SizeProposal {
214            width: Some(300.0),
215            height: None,
216        });
217
218        // Collect descendants and find the Divider (role=Splitter).
219        let mut queue = vec![header];
220        let mut divider_bounds = None;
221        while let Some(id) = queue.pop() {
222            let info = tree.accessibility_node(id);
223            if info.role() == teksilo_core::accesskit::Role::Splitter {
224                divider_bounds = Some(tree.bounds(id));
225                break;
226            }
227            queue.extend(tree.children(id));
228        }
229        let db = divider_bounds.expect("GroupHeader should contain a Divider");
230        // The divider should be substantially wider than zero — it fills
231        // whatever the label didn't claim.
232        assert!(
233            db.width > 100.0,
234            "rule line should absorb remaining width (got {})",
235            db.width
236        );
237    }
238
239    #[test]
240    fn accessibility_role_and_name() {
241        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
242        let header = tree.add(GroupHeader::new(lit!("Appearance")));
243        tree.layout(SizeProposal {
244            width: Some(400.0),
245            height: None,
246        });
247        let info = tree.accessibility_node(header);
248        assert_eq!(info.role(), teksilo_core::accesskit::Role::Label);
249        assert_eq!(info.name(), Some("Appearance"));
250    }
251
252    #[test]
253    fn custom_gap_respected() {
254        // A large gap should push the rule line start further right,
255        // so the rule line width should be smaller than with gap=0.
256        fn divider_width(tree: &WidgetTree, root: WidgetId) -> f32 {
257            let mut queue = vec![root];
258            while let Some(id) = queue.pop() {
259                let info = tree.accessibility_node(id);
260                if info.role() == teksilo_core::accesskit::Role::Splitter {
261                    return tree.bounds(id).width;
262                }
263                queue.extend(tree.children(id));
264            }
265            panic!("no divider found");
266        }
267
268        let mut tree_default = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
269        let h0 = tree_default.add(GroupHeader::new(lit!("Section")).gap(0.0));
270        tree_default.layout(SizeProposal {
271            width: Some(400.0),
272            height: None,
273        });
274
275        let mut tree_wide = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
276        let h60 = tree_wide.add(GroupHeader::new(lit!("Section")).gap(60.0));
277        tree_wide.layout(SizeProposal {
278            width: Some(400.0),
279            height: None,
280        });
281
282        let w0 = divider_width(&tree_default, h0);
283        let w60 = divider_width(&tree_wide, h60);
284        assert!(
285            w60 < w0,
286            "wider gap should shrink the rule line (gap=0 -> {w0}, gap=60 -> {w60})"
287        );
288    }
289}