1use accesskit::Role;
57use teksilo_canvas::{Canvas, Rect, StrokeStyle};
58use teksilo_core::accessibility::AccessNodeBuilder;
59use teksilo_core::binding::BindingLevel;
60use teksilo_core::build_context::BuildContext;
61use teksilo_core::color_prop::ColorProp;
62use teksilo_core::widget_id::WidgetId;
63use teksilo_tokens::CornerRadius;
64
65use crate::flags::ItemFlags;
66use crate::item::{AppearanceWrite, SceneItem, SceneItemA11yContext, SceneItemPaintContext};
67use crate::items::{AccessSubtreeMode, ItemA11yOverrides};
68use teksilo_i18n::LocalizedString;
69
70#[derive(Debug)]
75pub struct RectItem {
76 local_bounds: Rect,
77 fill: Option<ColorProp>,
78 stroke: Option<(ColorProp, StrokeStyle)>,
79 corner_radius: f32,
80 label: Option<String>,
81 flags: ItemFlags,
82 a11y: ItemA11yOverrides,
83}
84
85impl RectItem {
86 pub fn new(local_bounds: Rect) -> Self {
91 Self {
92 local_bounds,
93 fill: None,
94 stroke: None,
95 corner_radius: 0.0,
96 label: None,
97 flags: ItemFlags::default(),
98 a11y: ItemA11yOverrides::default(),
99 }
100 }
101
102 pub fn fill(mut self, color: impl Into<ColorProp>) -> Self {
106 self.fill = Some(color.into());
107 self
108 }
109
110 pub fn stroke(mut self, color: impl Into<ColorProp>, width: f32) -> Self {
113 self.stroke = Some((color.into(), StrokeStyle::solid(width.max(0.0))));
114 self
115 }
116
117 pub fn stroke_cosmetic(mut self, color: impl Into<ColorProp>, width: f32) -> Self {
121 self.stroke = Some((color.into(), StrokeStyle::hairline(width.max(0.0))));
122 self
123 }
124
125 pub fn stroke_styled(mut self, color: impl Into<ColorProp>, style: StrokeStyle) -> Self {
135 self.stroke = Some((color.into(), style));
136 self
137 }
138
139 pub fn corner_radius(mut self, radius: f32) -> Self {
143 self.corner_radius = radius.max(0.0);
144 self
145 }
146
147 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
152 let ls: LocalizedString = label.into();
153 self.label = Some(ls.resolve_now());
154 self
155 }
156
157 pub fn draggable(mut self, draggable: bool) -> Self {
159 self.flags.set(ItemFlags::IS_DRAGGABLE, draggable);
160 self
161 }
162
163 crate::items::item_a11y_builders!();
164}
165
166impl SceneItem for RectItem {
167 fn local_bounds(&self) -> Rect {
168 self.local_bounds
169 }
170
171 fn set_local_bounds(&mut self, bounds: Rect) {
172 self.local_bounds = bounds;
173 }
174
175 fn paint(&self, canvas: &mut Canvas, ctx: &SceneItemPaintContext<'_>) {
176 let lb = self.local_bounds;
177 let radius = self.corner_radius;
178 if let Some(prop) = &self.fill {
179 let fill = prop.resolve(ctx.theme, ctx.enabled);
180 if radius > 0.0 {
181 canvas.fill_rounded_rect(lb, CornerRadius::uniform(radius), fill);
182 } else {
183 canvas.fill_rect(lb, fill);
184 }
185 }
186 if let Some((prop, style)) = &self.stroke {
187 let color = prop.resolve(ctx.theme, ctx.enabled);
188 if radius > 0.0 {
189 canvas.stroke_rounded_rect(lb, CornerRadius::uniform(radius), color, style.clone());
190 } else {
191 canvas.stroke_rect(lb, color, style.clone());
192 }
193 }
194 }
195
196 fn set_fill(&mut self, fill: Option<ColorProp>) -> AppearanceWrite<ColorProp> {
197 AppearanceWrite::Accepted {
198 was: std::mem::replace(&mut self.fill, fill),
199 }
200 }
201
202 fn set_stroke(
203 &mut self,
204 stroke: Option<(ColorProp, StrokeStyle)>,
205 ) -> AppearanceWrite<(ColorProp, StrokeStyle)> {
206 AppearanceWrite::Accepted {
207 was: std::mem::replace(&mut self.stroke, stroke),
208 }
209 }
210
211 fn register_bindings(&self, ctx: &mut BuildContext, view_id: WidgetId) {
212 let registry = ctx.binding_registry();
213 if let Some(p) = &self.fill {
214 p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
215 }
216 if let Some((p, _)) = &self.stroke {
217 p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
218 }
219 }
220
221 fn shape(&self) -> crate::shape::ItemShape {
254 if self.corner_radius > 0.0 {
255 crate::shape::ItemShape::rounded_rect(self.local_bounds, self.corner_radius)
256 } else {
257 crate::shape::ItemShape::bounds(self.local_bounds)
258 }
259 }
260
261 fn thumbnail_color(&self) -> teksilo_tokens::Color {
262 crate::items::fill_or_stroke_hint(self.fill.as_ref(), self.stroke.as_ref())
266 .unwrap_or_else(|| teksilo_tokens::Color::new(0.6, 0.6, 0.6, 1.0))
267 }
268
269 fn label(&self) -> Option<String> {
270 self.label.clone()
271 }
272
273 fn initial_flags(&self) -> ItemFlags {
274 self.flags
275 }
276
277 fn access_subtree_mode(&self) -> AccessSubtreeMode {
278 self.a11y.subtree_mode()
279 }
280
281 fn accessibility(&self, builder: &mut AccessNodeBuilder, _ctx: &SceneItemA11yContext) {
282 builder.set_role(Role::GraphicsObject);
283 if let Some(label) = self.label() {
284 builder.set_name(label);
285 }
286 self.a11y.apply(builder);
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use teksilo_canvas::{Canvas, Point, Transform2D};
294 use teksilo_core::signal::Signal;
295 use teksilo_tokens::{Color, SurfaceRole};
296
297 fn test_ctx<'a>(theme: &'a teksilo_core::styles::Theme) -> SceneItemPaintContext<'a> {
298 SceneItemPaintContext::new(Transform2D::identity(), None, theme)
299 }
300
301 #[test]
302 fn rect_item_local_bounds_round_trip() {
303 let r = Rect::new(0.0, 0.0, 30.0, 40.0);
304 let item = RectItem::new(r);
305 assert_eq!(item.local_bounds(), r);
306 }
307
308 #[test]
309 fn rect_item_default_shape_is_its_box() {
310 let item = RectItem::new(Rect::new(0.0, 0.0, 50.0, 50.0));
311 let shape = item.shape();
312 assert!(shape.contains(Point::new(20.0, 20.0), 1.0));
313 assert!(!shape.contains(Point::new(-5.0, 20.0), 1.0));
314 assert!(shape.contains(Point::new(0.0, 0.0), 1.0));
316 assert_eq!(shape.bounding_rect(), Rect::new(0.0, 0.0, 50.0, 50.0));
317 }
318
319 #[test]
320 fn rect_item_corner_radius_carves_its_corners_out_of_the_shape() {
321 let item = RectItem::new(Rect::new(0.0, 0.0, 50.0, 50.0)).corner_radius(10.0);
322 let shape = item.shape();
323 assert!(!shape.contains(Point::new(0.5, 0.5), 1.0));
325 assert!(shape.contains(Point::new(10.0, 3.0), 1.0));
327 assert!(shape.contains(Point::new(25.0, 25.0), 1.0));
328 assert!(shape.contains(Point::new(0.0, 25.0), 1.0), "mid-edge");
329 assert_eq!(shape.bounding_rect(), Rect::new(0.0, 0.0, 50.0, 50.0));
331 }
332
333 #[test]
334 fn rect_item_paint_emits_fill_and_stroke() {
335 let theme = teksilo_core::presets::intui::light();
336 let mut canvas = Canvas::new();
337 let item = RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0))
338 .fill(Color::RED)
339 .stroke(Color::BLUE, 2.0);
340 item.paint(&mut canvas, &test_ctx(&theme));
341 let frame = canvas.into_render_frame();
342 assert!(
343 !frame.draw_order.is_empty(),
344 "paint must emit at least one draw command"
345 );
346 }
347
348 #[test]
349 fn rect_item_static_fill_paints_its_colour() {
350 let theme = teksilo_core::presets::intui::light();
351 let mut canvas = Canvas::new();
352 RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0))
353 .fill(Color::RED)
354 .paint(&mut canvas, &test_ctx(&theme));
355 let frame = canvas.into_render_frame();
356 assert!(
357 frame
358 .decorations
359 .iter()
360 .any(|d| d.color == Color::RED.to_array()),
361 "static fill must emit its exact colour"
362 );
363 }
364
365 #[test]
366 fn rect_item_role_fill_resolves_against_theme() {
367 let theme = teksilo_core::presets::intui::light();
370 let expected = ColorProp::from(SurfaceRole::Sunken).resolve(&theme, true);
371 let mut canvas = Canvas::new();
372 RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0))
373 .fill(SurfaceRole::Sunken)
374 .paint(&mut canvas, &test_ctx(&theme));
375 let frame = canvas.into_render_frame();
376 assert!(
377 frame
378 .decorations
379 .iter()
380 .any(|d| d.color == expected.to_array()),
381 "role fill must resolve against ctx.theme"
382 );
383 }
384
385 #[test]
386 fn rect_item_signal_fill_re_resolves_on_change() {
387 let theme = teksilo_core::presets::intui::light();
389 let sig = Signal::new(Color::GREEN);
390 let item = RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)).fill(sig.clone());
391
392 let mut c1 = Canvas::new();
393 item.paint(&mut c1, &test_ctx(&theme));
394 assert!(
395 c1.into_render_frame()
396 .decorations
397 .iter()
398 .any(|d| d.color == Color::GREEN.to_array())
399 );
400
401 sig.set(Color::RED);
402 let mut c2 = Canvas::new();
403 item.paint(&mut c2, &test_ctx(&theme));
404 assert!(
405 c2.into_render_frame()
406 .decorations
407 .iter()
408 .any(|d| d.color == Color::RED.to_array()),
409 "signal fill must re-resolve to the new value"
410 );
411 }
412
413 #[test]
414 fn rect_item_corner_radius_emits_rounded_shape() {
415 let theme = teksilo_core::presets::intui::light();
418 let mut canvas = Canvas::new();
419 RectItem::new(Rect::new(0.0, 0.0, 20.0, 20.0))
420 .fill(Color::RED)
421 .corner_radius(6.0)
422 .paint(&mut canvas, &test_ctx(&theme));
423 let frame = canvas.into_render_frame();
424 assert!(
425 !frame.shapes.is_empty(),
426 "rounded fill must emit an SDF shape"
427 );
428 }
429
430 #[test]
431 fn rect_item_stroke_styled_stores_dash_pattern() {
432 let item = RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0))
437 .stroke_styled(Color::BLUE, StrokeStyle::dashed(2.0, 6.0, 4.0));
438 let (_, style) = item.stroke.as_ref().expect("stroke set");
439 assert!(
440 style.dash_pattern.is_some(),
441 "dashed stroke must keep its dash pattern"
442 );
443 }
444
445 #[test]
446 fn rect_item_dashed_stroke_reaches_the_frame_as_a_dashed_path() {
447 let theme = teksilo_core::presets::intui::light();
451 let mut canvas = Canvas::new();
452 RectItem::new(Rect::new(0.0, 0.0, 100.0, 50.0))
453 .stroke_styled(Color::BLUE, StrokeStyle::dashed(2.0, 6.0, 4.0))
454 .paint(&mut canvas, &test_ctx(&theme));
455 let frame = canvas.into_render_frame();
456
457 assert!(
458 frame.decorations.is_empty(),
459 "a dashed border must not become solid decoration rects"
460 );
461 assert_eq!(frame.paths.len(), 1);
462 assert_eq!(
463 frame.paths[0].stroke_style.dash_pattern,
464 Some(vec![6.0, 4.0])
465 );
466 }
467
468 #[test]
469 fn rect_item_dashed_rounded_stroke_reaches_the_frame_as_a_dashed_path() {
470 let theme = teksilo_core::presets::intui::light();
473 let mut canvas = Canvas::new();
474 RectItem::new(Rect::new(0.0, 0.0, 100.0, 50.0))
475 .corner_radius(8.0)
476 .stroke_styled(Color::BLUE, StrokeStyle::dashed(2.0, 6.0, 4.0))
477 .paint(&mut canvas, &test_ctx(&theme));
478 let frame = canvas.into_render_frame();
479
480 assert!(frame.shapes.is_empty(), "a ShapeQuad cannot carry a dash");
481 assert_eq!(frame.paths.len(), 1);
482 assert_eq!(
483 frame.paths[0].stroke_style.dash_pattern,
484 Some(vec![6.0, 4.0])
485 );
486 }
487
488 #[test]
489 fn rect_item_set_fill_replaces_colour() {
490 let theme = teksilo_core::presets::intui::light();
492 let mut item = RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)).fill(Color::RED);
493 assert!(item.set_fill(Some(ColorProp::from(Color::BLUE))).accepted());
494 let mut canvas = Canvas::new();
495 item.paint(&mut canvas, &test_ctx(&theme));
496 assert!(
497 canvas
498 .into_render_frame()
499 .decorations
500 .iter()
501 .any(|d| d.color == Color::BLUE.to_array())
502 );
503 }
504}