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