1use accesskit::Role;
39use teksilo_canvas::{Canvas, Point, Rect, StrokeStyle};
40use teksilo_core::accessibility::AccessNodeBuilder;
41use teksilo_core::binding::BindingLevel;
42use teksilo_core::build_context::BuildContext;
43use teksilo_core::color_prop::ColorProp;
44use teksilo_core::widget_id::WidgetId;
45use teksilo_tokens::Color;
46
47use crate::item::{SceneItem, SceneItemA11yContext, SceneItemPaintContext};
48use crate::items::{AccessSubtreeMode, ItemA11yOverrides};
49use teksilo_i18n::LocalizedString;
50
51#[derive(Debug)]
58pub struct GroupItem {
59 local_bounds: Rect,
60 label: Option<String>,
61 show_label: bool,
62 fill: Option<ColorProp>,
63 stroke: Option<(ColorProp, StrokeStyle)>,
64 corner_radius: f32,
65 label_inset: (f32, f32),
66 label_color: Option<ColorProp>,
67 a11y: ItemA11yOverrides,
68}
69
70impl GroupItem {
71 pub fn new(local_bounds: Rect) -> Self {
75 Self {
76 local_bounds,
77 label: None,
78 show_label: false,
79 fill: None,
80 stroke: None,
81 corner_radius: 0.0,
82 label_inset: (8.0, 4.0),
83 label_color: None,
84 a11y: ItemA11yOverrides::default(),
85 }
86 }
87
88 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
91 let ls: LocalizedString = label.into();
92 self.label = Some(ls.resolve_now());
93 self
94 }
95
96 pub fn show_label(mut self, show: bool) -> Self {
98 self.show_label = show;
99 self
100 }
101
102 pub fn label_inset(mut self, dx: f32, dy: f32) -> Self {
104 self.label_inset = (dx, dy);
105 self
106 }
107
108 pub fn label_color(mut self, color: impl Into<ColorProp>) -> Self {
112 self.label_color = Some(color.into());
113 self
114 }
115
116 pub fn fill(mut self, color: impl Into<ColorProp>) -> Self {
120 self.fill = Some(color.into());
121 self
122 }
123
124 pub fn stroke(mut self, color: impl Into<ColorProp>, width: f32) -> Self {
126 self.stroke = Some((color.into(), StrokeStyle::solid(width.max(0.0))));
127 self
128 }
129
130 pub fn stroke_cosmetic(mut self, color: impl Into<ColorProp>, width: f32) -> Self {
135 self.stroke = Some((color.into(), StrokeStyle::hairline(width.max(0.0))));
136 self
137 }
138
139 pub fn stroke_styled(mut self, color: impl Into<ColorProp>, style: StrokeStyle) -> Self {
143 self.stroke = Some((color.into(), style));
144 self
145 }
146
147 pub fn corner_radius(mut self, radius: f32) -> Self {
149 self.corner_radius = radius.max(0.0);
150 self
151 }
152
153 pub fn is_visual(&self) -> bool {
155 self.fill.is_some() || self.stroke.is_some() || self.show_label
156 }
157
158 crate::items::item_a11y_builders!();
159}
160
161impl SceneItem for GroupItem {
162 fn local_bounds(&self) -> Rect {
163 self.local_bounds
164 }
165
166 fn set_local_bounds(&mut self, bounds: Rect) {
167 self.local_bounds = bounds;
168 }
169
170 fn paint(&self, canvas: &mut Canvas, ctx: &SceneItemPaintContext<'_>) {
171 if !self.is_visual() {
172 return;
173 }
174 let lb = self.local_bounds;
175 if let Some(prop) = &self.fill {
176 let fill = prop.resolve(ctx.theme, ctx.enabled);
177 if self.corner_radius > 0.0 {
178 canvas.fill_rounded_rect(
179 lb,
180 teksilo_tokens::CornerRadius::uniform(self.corner_radius),
181 fill,
182 );
183 } else {
184 canvas.fill_rect(lb, fill);
185 }
186 }
187 if let Some((prop, style)) = &self.stroke {
188 let color = prop.resolve(ctx.theme, ctx.enabled);
189 if self.corner_radius > 0.0 {
190 canvas.stroke_rounded_rect(
191 lb,
192 teksilo_tokens::CornerRadius::uniform(self.corner_radius),
193 color,
194 style.clone(),
195 );
196 } else {
197 canvas.stroke_rect(lb, color, style.clone());
198 }
199 }
200 if self.show_label
201 && let Some(label) = &self.label
202 {
203 let color = self
206 .label_color
207 .as_ref()
208 .or_else(|| self.stroke.as_ref().map(|(c, _)| c))
209 .map(|p| p.resolve(ctx.theme, ctx.enabled))
210 .unwrap_or(Color::BLACK);
211 let (dx, dy) = self.label_inset;
212 let label_bounds = Rect::new(
213 lb.x + dx,
214 lb.y + dy,
215 (lb.width - 2.0 * dx).max(0.0),
216 (lb.height - 2.0 * dy).max(0.0),
217 );
218 canvas.draw_text(
219 label,
220 label_bounds,
221 &teksilo_tokens::TextStyle::default(),
222 color,
223 );
224 }
225 }
226
227 fn set_fill(&mut self, fill: Option<ColorProp>) -> bool {
228 self.fill = fill;
229 true
230 }
231
232 fn set_stroke(&mut self, stroke: Option<(ColorProp, StrokeStyle)>) -> bool {
233 self.stroke = stroke;
234 true
235 }
236
237 fn register_bindings(&self, ctx: &mut BuildContext, view_id: WidgetId) {
238 let registry = ctx.binding_registry();
239 if let Some(p) = &self.fill {
240 p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
241 }
242 if let Some((p, _)) = &self.stroke {
243 p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
244 }
245 if let Some(p) = &self.label_color {
246 p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
247 }
248 }
249
250 fn shape_contains(&self, local_pt: Point) -> bool {
254 if self.is_visual() {
255 self.local_bounds.contains(local_pt)
256 } else {
257 false
258 }
259 }
260
261 fn clone_shape_test(&self) -> Box<dyn Fn(Point, f32) -> bool + 'static> {
267 let is_visual = self.is_visual();
268 let bounds = self.local_bounds;
269 Box::new(move |p, _view_scale| is_visual && bounds.contains(p))
270 }
271
272 fn thumbnail_color(&self) -> Color {
273 if let Some(c) = crate::items::fill_or_stroke_hint(self.fill.as_ref(), self.stroke.as_ref())
277 {
278 return c;
279 }
280 if self.is_visual() {
281 return Color::new(0.6, 0.6, 0.6, 1.0);
282 }
283 Color::new(0.0, 0.0, 0.0, 0.0)
284 }
285
286 fn label(&self) -> Option<String> {
287 self.label.clone()
288 }
289
290 fn accessibility(&self, builder: &mut AccessNodeBuilder, _ctx: &SceneItemA11yContext) {
291 builder.set_role(Role::Group);
292 if let Some(label) = self.label() {
293 builder.set_name(label);
294 }
295 self.a11y.apply(builder);
296 }
297
298 fn access_subtree_mode(&self) -> AccessSubtreeMode {
299 self.a11y.subtree_mode()
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use teksilo_canvas::Transform2D;
307 use teksilo_i18n::lit;
308
309 #[test]
310 fn group_item_does_not_hit_test_through_aabb() {
311 let g = GroupItem::new(Rect::new(0.0, 0.0, 1000.0, 1000.0));
312 assert!(!g.shape_contains(Point::new(500.0, 500.0)));
313 }
314
315 #[test]
316 fn group_item_default_is_not_visual() {
317 let g = GroupItem::new(Rect::new(0.0, 0.0, 100.0, 100.0));
318 assert!(!g.is_visual());
319 }
320
321 #[test]
322 fn group_item_with_fill_is_visual_and_hit_tests() {
323 let g = GroupItem::new(Rect::new(0.0, 0.0, 100.0, 100.0)).fill(Color::RED);
324 assert!(g.is_visual());
325 assert!(g.shape_contains(Point::new(50.0, 50.0)));
326 assert!(!g.shape_contains(Point::new(150.0, 50.0)));
327 }
328
329 #[test]
330 fn group_item_with_stroke_only_is_visual() {
331 let g = GroupItem::new(Rect::new(0.0, 0.0, 100.0, 100.0)).stroke(Color::BLACK, 1.0);
332 assert!(g.is_visual());
333 assert!(g.shape_contains(Point::new(50.0, 50.0)));
334 }
335
336 #[test]
337 fn group_item_with_label_only_is_not_visual() {
338 let g = GroupItem::new(Rect::new(0.0, 0.0, 100.0, 100.0)).label(lit!("Act 1"));
339 assert!(!g.is_visual());
340 assert!(!g.shape_contains(Point::new(50.0, 50.0)));
341 }
342
343 #[test]
344 fn group_item_with_show_label_is_visual() {
345 let g = GroupItem::new(Rect::new(0.0, 0.0, 100.0, 100.0))
346 .label(lit!("Act 1"))
347 .show_label(true);
348 assert!(g.is_visual());
349 assert!(g.shape_contains(Point::new(50.0, 50.0)));
350 }
351
352 #[test]
353 fn group_item_visual_paint_emits_draws() {
354 let invisible = GroupItem::new(Rect::new(0.0, 0.0, 100.0, 100.0));
355 let visible = GroupItem::new(Rect::new(0.0, 0.0, 100.0, 100.0))
356 .fill(Color::RED)
357 .stroke(Color::BLACK, 2.0)
358 .corner_radius(8.0);
359
360 let theme = teksilo_core::presets::intui::light();
361 let ctx = SceneItemPaintContext::new(Transform2D::identity(), None, &theme);
362
363 let mut c1 = teksilo_canvas::Canvas::new();
364 invisible.paint(&mut c1, &ctx);
365 let f1 = c1.into_render_frame();
366 assert!(f1.draw_order.is_empty(), "invisible group emitted draws");
367
368 let mut c2 = teksilo_canvas::Canvas::new();
369 visible.paint(&mut c2, &ctx);
370 let f2 = c2.into_render_frame();
371 assert!(!f2.draw_order.is_empty(), "visible group emitted no draws");
372 }
373
374 #[test]
375 fn group_item_stroke_styled_stores_dash_pattern() {
376 let g = GroupItem::new(Rect::new(0.0, 0.0, 100.0, 100.0))
378 .stroke_styled(Color::BLACK, StrokeStyle::dashed(2.0, 6.0, 4.0));
379 let (_, style) = g.stroke.as_ref().expect("stroke set");
380 assert!(style.dash_pattern.is_some());
381 }
382
383 #[test]
384 fn group_item_role_fill_resolves_against_theme() {
385 use teksilo_tokens::SurfaceRole;
387 let theme = teksilo_core::presets::intui::light();
388 let expected = ColorProp::from(SurfaceRole::Container).resolve(&theme, true);
389 let g = GroupItem::new(Rect::new(0.0, 0.0, 100.0, 100.0)).fill(SurfaceRole::Container);
390 let ctx = SceneItemPaintContext::new(Transform2D::identity(), None, &theme);
391 let mut c = teksilo_canvas::Canvas::new();
392 g.paint(&mut c, &ctx);
393 assert!(
394 c.into_render_frame()
395 .decorations
396 .iter()
397 .any(|d| d.color == expected.to_array())
398 );
399 }
400}