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::accessibility::text_runs::{TextGeometryHandle, TextRunSource, push_text_runs};
24use teksilo_core::build_context::BuildContext;
25use teksilo_core::color_prop::{ColorProp, TextStyleProp};
26use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
27use teksilo_core::widget_id::WidgetId;
28use teksilo_tokens::{TextRole, TextStyleRole};
29
30use crate::primitives::{Divider, Expand, HStack, TextWidget};
31use teksilo_i18n::LocalizedString;
32
33/// A labelled section header with a trailing rule line.
34pub struct GroupHeader {
35    label: LocalizedString,
36    /// Optional text-style override for the label. Defaults to
37    /// [`TextStyleRole::Body`] — IntelliJ/Jewel group headers render at
38    /// normal body size, not as a smaller caption. Accepts a static
39    /// [`TextStyle`](teksilo_tokens::TextStyle) or a
40    /// [`TextStyleRole`], so the default
41    /// (and any role override) tracks runtime theme changes.
42    style: Option<TextStyleProp>,
43    /// Optional label-color override. Defaults to [`TextRole::Primary`]
44    /// (no dimming). Accepts any `impl Into<ColorProp>` — a literal
45    /// `Color`, a text/surface role, or a `Signal<Color>` — so accent
46    /// headers track runtime theme changes.
47    color: Option<ColorProp>,
48    /// Horizontal gap between the label and the rule line.
49    gap: f32,
50    // Build state
51    root_child_id: Option<WidgetId>,
52    /// What the hidden caption last measured. The header owns its
53    /// accessible name, so it also owns the text runs behind it; the label
54    /// lends its layout through this handle. Empty until the label has been
55    /// placed.
56    label_geometry: Option<TextGeometryHandle>,
57}
58
59impl GroupHeader {
60    /// Create a section header with the given `label`.
61    pub fn new(label: impl Into<LocalizedString>) -> Self {
62        let ls: LocalizedString = label.into();
63        Self {
64            label: ls,
65            style: None,
66            color: None,
67            gap: 8.0,
68            root_child_id: None,
69            label_geometry: None,
70        }
71    }
72
73    /// Override the label's text style (font, size, weight, …). Accepts a
74    /// static [`TextStyle`](teksilo_tokens::TextStyle) or a
75    /// [`TextStyleRole`].
76    pub fn style(mut self, style: impl Into<TextStyleProp>) -> Self {
77        self.style = Some(style.into());
78        self
79    }
80
81    /// Override the label's color. Useful when a consumer wants to
82    /// emphasise a header with an accent. Accepts a literal `Color`, a
83    /// `TextRole`/`SurfaceRole`, or a `Signal<Color>`.
84    pub fn color(mut self, color: impl Into<ColorProp>) -> Self {
85        self.color = Some(color.into());
86        self
87    }
88
89    /// Horizontal gap between the label and the rule line. Defaults to 8 dp.
90    pub fn gap(mut self, gap: f32) -> Self {
91        self.gap = gap;
92        self
93    }
94}
95
96impl std::fmt::Debug for GroupHeader {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        f.debug_struct("GroupHeader")
99            .field("label", &self.label)
100            .field("gap", &self.gap)
101            .finish()
102    }
103}
104
105impl Widget for GroupHeader {
106    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
107        // Default to the Body text role and the Primary text color role so
108        // both the default and any caller override resolve at paint/layout
109        // time and track runtime theme changes.
110        let style = self
111            .style
112            .clone()
113            .unwrap_or_else(|| TextStyleRole::Body.into());
114        let color = self
115            .color
116            .clone()
117            .unwrap_or_else(|| TextRole::Primary.into());
118
119        let label = TextWidget::new(self.label.clone())
120            .style(style)
121            .color(color)
122            .single_line()
123            .a11y_hidden();
124        self.label_geometry = Some(label.geometry_handle());
125        let label_id = ctx.add(label);
126
127        // Fill the remaining horizontal space with a horizontal Divider.
128        // `Expand::horizontal()` defaults to flex=1, claiming leftover slack
129        // from the parent HStack and stretching the divider to its bounds.
130        let rule_id = ctx.add(Expand::horizontal().child(Divider::horizontal()));
131
132        let row_id = ctx.add(
133            HStack::new()
134                .spacing(self.gap)
135                .child(label_id)
136                .child(rule_id),
137        );
138        self.root_child_id = Some(row_id);
139
140        vec![row_id]
141    }
142
143    fn layout_response(
144        &self,
145        proposal: SizeProposal,
146        ctx: &LayoutContext,
147    ) -> teksilo_core::widget::LayoutResponse {
148        match self.root_child_id {
149            Some(id) => ctx
150                .child_size(id, proposal)
151                .unwrap_or_else(|| proposal.resolve(0.0, 0.0)),
152            None => proposal.resolve(0.0, 0.0),
153        }
154        .into()
155    }
156
157    fn place_children(
158        &self,
159        bounds: Rect,
160        _proposal: SizeProposal,
161        children: &mut [WidgetPlacement],
162        _ctx: &LayoutContext,
163    ) {
164        for child in children.iter_mut() {
165            child.origin = bounds.origin();
166            child.size = bounds.size();
167        }
168    }
169
170    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
171        // A GroupHeader is a section caption: it names the region that
172        // follows it without consuming focus or firing actions. `Label`
173        // is the closest accesskit role — screen readers read it as a
174        // non-interactive caption.
175        builder.set_role(teksilo_core::accesskit::Role::Label);
176
177        // The header owns the accessible name, so its caption is hidden
178        // from AT — which would leave the text announceable but not
179        // reviewable, since `supports_text_ranges` needs `Role::TextRun`
180        // children. Borrowing the hidden caption's layout puts them on this
181        // node instead, one level up from where they were measured.
182        //
183        // Those rects are in window space, which is sound only while the
184        // caption sits rigidly inside the header: `place_children` gives
185        // the row the header's own bounds on every pass, and the row places
186        // the caption at its leading edge. A composite that positioned its
187        // donor independently would have to register local rects instead.
188        let unplaced;
189        let source = match self
190            .label_geometry
191            .as_ref()
192            .and_then(|handle| TextRunSource::from_handle(handle, 0))
193        {
194            Some(source) => source,
195            // Never placed — a name probe, or a tree asked for its
196            // accessibility before its first layout.
197            None => {
198                unplaced = self.label.resolve_now();
199                TextRunSource::flat(&unplaced, 0)
200            }
201        };
202        let emission = push_text_runs(builder, None, &source);
203        // The name must be byte-identical to the runs' concatenation: the
204        // consumer derives the document text from the runs, and every
205        // divergence is a place where what a reader reviews and what it
206        // announces disagree.
207        builder.set_name(&emission.value);
208    }
209
210    fn children(&self) -> Vec<WidgetId> {
211        match self.root_child_id {
212            Some(id) => vec![id],
213            None => Vec::new(),
214        }
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use teksilo_core::widget_tree::WidgetTree;
222    use teksilo_i18n::lit;
223
224    #[test]
225    fn builds_and_lays_out_with_proposed_width() {
226        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
227        let header = tree.add(GroupHeader::new(lit!("Appearance")));
228        tree.layout(SizeProposal {
229            width: Some(400.0),
230            height: None,
231        });
232        let b = tree.bounds(header);
233        // Header claims the full proposed width (label + spacer + rule).
234        assert!(
235            (b.width - 400.0).abs() < 0.01,
236            "expected header width 400, got {}",
237            b.width
238        );
239        // Height is driven by the label (single line of `small` text),
240        // which is taller than the 1 dp divider, so the HStack height
241        // equals the label height — strictly positive.
242        assert!(b.height > 0.0);
243    }
244
245    #[test]
246    fn rule_line_absorbs_remaining_width() {
247        // The header's root HStack child is `[label, expand(divider)]`.
248        // Walk the tree to the Expand and verify its bounds consume the
249        // remaining width, not the natural 0-width of a bare Divider.
250        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
251        let header = tree.add(GroupHeader::new(lit!("X")));
252        tree.layout(SizeProposal {
253            width: Some(300.0),
254            height: None,
255        });
256
257        // Collect descendants and find the Divider (role=Splitter).
258        let mut queue = vec![header];
259        let mut divider_bounds = None;
260        while let Some(id) = queue.pop() {
261            let info = tree.accessibility_node(id);
262            if info.role() == teksilo_core::accesskit::Role::Splitter {
263                divider_bounds = Some(tree.bounds(id));
264                break;
265            }
266            queue.extend(tree.children(id));
267        }
268        let db = divider_bounds.expect("GroupHeader should contain a Divider");
269        // The divider should be substantially wider than zero — it fills
270        // whatever the label didn't claim.
271        assert!(
272            db.width > 100.0,
273            "rule line should absorb remaining width (got {})",
274            db.width
275        );
276    }
277
278    /// A tree whose text is measured, so the runs carry real extents
279    /// rather than the degenerate boxes an unmeasured caption falls back
280    /// to.
281    fn measured_tree() -> WidgetTree {
282        WidgetTree::new()
283            .with_theme(teksilo_core::presets::intui::light())
284            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
285                teksilo_canvas::MockTextBackend::new(),
286            )))
287    }
288
289    /// The `Role::TextRun` children one node published, in tree order.
290    fn run_values(
291        update: &teksilo_core::accesskit::TreeUpdate,
292        owner: WidgetId,
293    ) -> Vec<(String, teksilo_core::accesskit::Rect)> {
294        let node_id = teksilo_core::accessibility::widget_id_to_node_id(owner);
295        let node = update
296            .nodes
297            .iter()
298            .find(|(id, _)| *id == node_id)
299            .map(|(_, node)| node)
300            .expect("the header is absent from the emitted tree");
301        node.children()
302            .iter()
303            .filter_map(|child| update.nodes.iter().find(|(id, _)| id == child))
304            .filter(|(_, node)| node.role() == teksilo_core::accesskit::Role::TextRun)
305            .map(|(_, node)| {
306                (
307                    node.value().unwrap_or_default().to_string(),
308                    node.bounds().expect("a run without bounds"),
309                )
310            })
311            .collect()
312    }
313
314    #[test]
315    fn accessibility_role_and_name() {
316        let mut tree = measured_tree();
317        let header = tree.add(GroupHeader::new(lit!("Appearance")));
318        tree.layout(SizeProposal {
319            width: Some(400.0),
320            height: None,
321        });
322        let info = tree.accessibility_node(header);
323        assert_eq!(info.role(), teksilo_core::accesskit::Role::Label);
324        assert_eq!(info.name(), Some("Appearance"));
325
326        // The caption is hidden from AT, so without borrowed runs the
327        // header would be announceable but not reviewable:
328        // `supports_text_ranges` is false for a `Role::Label` with no
329        // `Role::TextRun` children.
330        let update = tree.sync_accessibility();
331        let runs = run_values(&update, header);
332        assert_eq!(runs.len(), 1);
333        assert_eq!(runs[0].0, "Appearance");
334        // Borrowed rects are absolute, so the run has the caption's own
335        // width rather than the header's full 400 dp.
336        assert!(
337            runs[0].1.x1 - runs[0].1.x0 < 400.0,
338            "the run spans the whole header: {:?}",
339            runs[0].1
340        );
341    }
342
343    #[test]
344    fn a_group_header_is_reviewable_by_character() {
345        // `supports_text_ranges` is a consumer method and the gate every
346        // platform's text API sits behind, and `bounding_boxes` is where a
347        // single geometry-less run silently empties a whole label. Neither
348        // can be asserted off the emitted node.
349        let mut tree = measured_tree();
350        let header = tree.add(GroupHeader::new(lit!("Appearance")));
351        tree.layout(SizeProposal {
352            width: Some(400.0),
353            height: None,
354        });
355        let update = tree.sync_accessibility();
356        let node_id = teksilo_core::accessibility::widget_id_to_node_id(header);
357
358        let consumer = accesskit_consumer::Tree::new(update, false);
359        let state = consumer.state();
360        let mut stack = vec![state.root()];
361        let node = loop {
362            let node = stack
363                .pop()
364                .expect("the header is absent from the emitted tree");
365            if node.locate().0 == node_id {
366                break node;
367            }
368            stack.extend(node.children());
369        };
370        assert!(node.supports_text_ranges());
371        assert_eq!(node.document_range().text(), "Appearance");
372        assert!(
373            !node.document_range().bounding_boxes().is_empty(),
374            "the header reports no geometry, so a magnifier cannot follow it"
375        );
376    }
377
378    #[test]
379    fn custom_gap_respected() {
380        // A large gap should push the rule line start further right,
381        // so the rule line width should be smaller than with gap=0.
382        fn divider_width(tree: &WidgetTree, root: WidgetId) -> f32 {
383            let mut queue = vec![root];
384            while let Some(id) = queue.pop() {
385                let info = tree.accessibility_node(id);
386                if info.role() == teksilo_core::accesskit::Role::Splitter {
387                    return tree.bounds(id).width;
388                }
389                queue.extend(tree.children(id));
390            }
391            panic!("no divider found");
392        }
393
394        let mut tree_default = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
395        let h0 = tree_default.add(GroupHeader::new(lit!("Section")).gap(0.0));
396        tree_default.layout(SizeProposal {
397            width: Some(400.0),
398            height: None,
399        });
400
401        let mut tree_wide = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
402        let h60 = tree_wide.add(GroupHeader::new(lit!("Section")).gap(60.0));
403        tree_wide.layout(SizeProposal {
404            width: Some(400.0),
405            height: None,
406        });
407
408        let w0 = divider_width(&tree_default, h0);
409        let w60 = divider_width(&tree_wide, h60);
410        assert!(
411            w60 < w0,
412            "wider gap should shrink the rule line (gap=0 -> {w0}, gap=60 -> {w60})"
413        );
414    }
415}