teksilo_widgets/
group_header.rs1use 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
32pub struct GroupHeader {
34 label: LocalizedString,
35 style: Option<TextStyleProp>,
42 color: Option<ColorProp>,
47 gap: f32,
49 root_child_id: Option<WidgetId>,
51}
52
53impl GroupHeader {
54 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 pub fn style(mut self, style: impl Into<TextStyleProp>) -> Self {
70 self.style = Some(style.into());
71 self
72 }
73
74 pub fn color(mut self, color: impl Into<ColorProp>) -> Self {
78 self.color = Some(color.into());
79 self
80 }
81
82 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 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 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 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 assert!(
196 (b.width - 400.0).abs() < 0.01,
197 "expected header width 400, got {}",
198 b.width
199 );
200 assert!(b.height > 0.0);
204 }
205
206 #[test]
207 fn rule_line_absorbs_remaining_width() {
208 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 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 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 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}