1use 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
33pub struct GroupHeader {
35 label: LocalizedString,
36 style: Option<TextStyleProp>,
43 color: Option<ColorProp>,
48 gap: f32,
50 root_child_id: Option<WidgetId>,
52 label_geometry: Option<TextGeometryHandle>,
57}
58
59impl GroupHeader {
60 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 pub fn style(mut self, style: impl Into<TextStyleProp>) -> Self {
77 self.style = Some(style.into());
78 self
79 }
80
81 pub fn color(mut self, color: impl Into<ColorProp>) -> Self {
85 self.color = Some(color.into());
86 self
87 }
88
89 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 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 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 builder.set_role(teksilo_core::accesskit::Role::Label);
176
177 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 None => {
198 unplaced = self.label.resolve_now();
199 TextRunSource::flat(&unplaced, 0)
200 }
201 };
202 let emission = push_text_runs(builder, None, &source);
203 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 assert!(
235 (b.width - 400.0).abs() < 0.01,
236 "expected header width 400, got {}",
237 b.width
238 );
239 assert!(b.height > 0.0);
243 }
244
245 #[test]
246 fn rule_line_absorbs_remaining_width() {
247 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 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 assert!(
272 db.width > 100.0,
273 "rule line should absorb remaining width (got {})",
274 db.width
275 );
276 }
277
278 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 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 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 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 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 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}