1use accesskit::Role;
52use teksilo_canvas::{Canvas, Rect, StrokeStyle};
53use teksilo_core::accessibility::AccessNodeBuilder;
54use teksilo_core::binding::BindingLevel;
55use teksilo_core::build_context::BuildContext;
56use teksilo_core::color_prop::ColorProp;
57use teksilo_core::widget_id::WidgetId;
58use teksilo_tokens::CornerRadius;
59
60use crate::flags::ItemFlags;
61use crate::item::{SceneItem, SceneItemA11yContext, SceneItemPaintContext};
62use crate::items::{AccessSubtreeMode, ItemA11yOverrides};
63use teksilo_i18n::LocalizedString;
64
65#[derive(Debug)]
70pub struct RectItem {
71 local_bounds: Rect,
72 fill: Option<ColorProp>,
73 stroke: Option<(ColorProp, StrokeStyle)>,
74 corner_radius: f32,
75 label: Option<String>,
76 flags: ItemFlags,
77 a11y: ItemA11yOverrides,
78}
79
80impl RectItem {
81 pub fn new(local_bounds: Rect) -> Self {
86 Self {
87 local_bounds,
88 fill: None,
89 stroke: None,
90 corner_radius: 0.0,
91 label: None,
92 flags: ItemFlags::default(),
93 a11y: ItemA11yOverrides::default(),
94 }
95 }
96
97 pub fn fill(mut self, color: impl Into<ColorProp>) -> Self {
101 self.fill = Some(color.into());
102 self
103 }
104
105 pub fn stroke(mut self, color: impl Into<ColorProp>, width: f32) -> Self {
108 self.stroke = Some((color.into(), StrokeStyle::solid(width.max(0.0))));
109 self
110 }
111
112 pub fn stroke_cosmetic(mut self, color: impl Into<ColorProp>, width: f32) -> Self {
116 self.stroke = Some((color.into(), StrokeStyle::hairline(width.max(0.0))));
117 self
118 }
119
120 pub fn stroke_styled(mut self, color: impl Into<ColorProp>, style: StrokeStyle) -> Self {
126 self.stroke = Some((color.into(), style));
127 self
128 }
129
130 pub fn corner_radius(mut self, radius: f32) -> Self {
134 self.corner_radius = radius.max(0.0);
135 self
136 }
137
138 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
142 let ls: LocalizedString = label.into();
143 self.label = Some(ls.resolve_now());
144 self
145 }
146
147 pub fn draggable(mut self, draggable: bool) -> Self {
149 self.flags.set(ItemFlags::IS_DRAGGABLE, draggable);
150 self
151 }
152
153 crate::items::item_a11y_builders!();
154}
155
156impl SceneItem for RectItem {
157 fn local_bounds(&self) -> Rect {
158 self.local_bounds
159 }
160
161 fn set_local_bounds(&mut self, bounds: Rect) {
162 self.local_bounds = bounds;
163 }
164
165 fn paint(&self, canvas: &mut Canvas, ctx: &SceneItemPaintContext<'_>) {
166 let lb = self.local_bounds;
167 let radius = self.corner_radius;
168 if let Some(prop) = &self.fill {
169 let fill = prop.resolve(ctx.theme, ctx.enabled);
170 if radius > 0.0 {
171 canvas.fill_rounded_rect(lb, CornerRadius::uniform(radius), fill);
172 } else {
173 canvas.fill_rect(lb, fill);
174 }
175 }
176 if let Some((prop, style)) = &self.stroke {
177 let color = prop.resolve(ctx.theme, ctx.enabled);
178 if radius > 0.0 {
179 canvas.stroke_rounded_rect(lb, CornerRadius::uniform(radius), color, style.clone());
180 } else {
181 canvas.stroke_rect(lb, color, style.clone());
182 }
183 }
184 }
185
186 fn set_fill(&mut self, fill: Option<ColorProp>) -> bool {
187 self.fill = fill;
188 true
189 }
190
191 fn set_stroke(&mut self, stroke: Option<(ColorProp, StrokeStyle)>) -> bool {
192 self.stroke = stroke;
193 true
194 }
195
196 fn register_bindings(&self, ctx: &mut BuildContext, view_id: WidgetId) {
197 let registry = ctx.binding_registry();
198 if let Some(p) = &self.fill {
199 p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
200 }
201 if let Some((p, _)) = &self.stroke {
202 p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
203 }
204 }
205
206 fn thumbnail_color(&self) -> teksilo_tokens::Color {
207 crate::items::fill_or_stroke_hint(self.fill.as_ref(), self.stroke.as_ref())
211 .unwrap_or_else(|| teksilo_tokens::Color::new(0.6, 0.6, 0.6, 1.0))
212 }
213
214 fn label(&self) -> Option<String> {
215 self.label.clone()
216 }
217
218 fn initial_flags(&self) -> ItemFlags {
219 self.flags
220 }
221
222 fn access_subtree_mode(&self) -> AccessSubtreeMode {
223 self.a11y.subtree_mode()
224 }
225
226 fn accessibility(&self, builder: &mut AccessNodeBuilder, _ctx: &SceneItemA11yContext) {
227 builder.set_role(Role::GraphicsObject);
228 if let Some(label) = self.label() {
229 builder.set_name(label);
230 }
231 self.a11y.apply(builder);
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use teksilo_canvas::{Canvas, Point, Transform2D};
239 use teksilo_core::signal::Signal;
240 use teksilo_tokens::{Color, SurfaceRole};
241
242 fn test_ctx<'a>(theme: &'a teksilo_core::styles::Theme) -> SceneItemPaintContext<'a> {
243 SceneItemPaintContext::new(Transform2D::identity(), None, theme)
244 }
245
246 #[test]
247 fn rect_item_local_bounds_round_trip() {
248 let r = Rect::new(0.0, 0.0, 30.0, 40.0);
249 let item = RectItem::new(r);
250 assert_eq!(item.local_bounds(), r);
251 }
252
253 #[test]
254 fn rect_item_default_shape_contains() {
255 let item = RectItem::new(Rect::new(0.0, 0.0, 50.0, 50.0));
256 assert!(item.shape_contains(Point::new(20.0, 20.0)));
257 assert!(!item.shape_contains(Point::new(-5.0, 20.0)));
258 }
259
260 #[test]
261 fn rect_item_paint_emits_fill_and_stroke() {
262 let theme = teksilo_core::presets::intui::light();
263 let mut canvas = Canvas::new();
264 let item = RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0))
265 .fill(Color::RED)
266 .stroke(Color::BLUE, 2.0);
267 item.paint(&mut canvas, &test_ctx(&theme));
268 let frame = canvas.into_render_frame();
269 assert!(
270 !frame.draw_order.is_empty(),
271 "paint must emit at least one draw command"
272 );
273 }
274
275 #[test]
276 fn rect_item_static_fill_paints_its_colour() {
277 let theme = teksilo_core::presets::intui::light();
278 let mut canvas = Canvas::new();
279 RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0))
280 .fill(Color::RED)
281 .paint(&mut canvas, &test_ctx(&theme));
282 let frame = canvas.into_render_frame();
283 assert!(
284 frame
285 .decorations
286 .iter()
287 .any(|d| d.color == Color::RED.to_array()),
288 "static fill must emit its exact colour"
289 );
290 }
291
292 #[test]
293 fn rect_item_role_fill_resolves_against_theme() {
294 let theme = teksilo_core::presets::intui::light();
297 let expected = ColorProp::from(SurfaceRole::Sunken).resolve(&theme, true);
298 let mut canvas = Canvas::new();
299 RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0))
300 .fill(SurfaceRole::Sunken)
301 .paint(&mut canvas, &test_ctx(&theme));
302 let frame = canvas.into_render_frame();
303 assert!(
304 frame
305 .decorations
306 .iter()
307 .any(|d| d.color == expected.to_array()),
308 "role fill must resolve against ctx.theme"
309 );
310 }
311
312 #[test]
313 fn rect_item_signal_fill_re_resolves_on_change() {
314 let theme = teksilo_core::presets::intui::light();
316 let sig = Signal::new(Color::GREEN);
317 let item = RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)).fill(sig.clone());
318
319 let mut c1 = Canvas::new();
320 item.paint(&mut c1, &test_ctx(&theme));
321 assert!(
322 c1.into_render_frame()
323 .decorations
324 .iter()
325 .any(|d| d.color == Color::GREEN.to_array())
326 );
327
328 sig.set(Color::RED);
329 let mut c2 = Canvas::new();
330 item.paint(&mut c2, &test_ctx(&theme));
331 assert!(
332 c2.into_render_frame()
333 .decorations
334 .iter()
335 .any(|d| d.color == Color::RED.to_array()),
336 "signal fill must re-resolve to the new value"
337 );
338 }
339
340 #[test]
341 fn rect_item_corner_radius_emits_rounded_shape() {
342 let theme = teksilo_core::presets::intui::light();
345 let mut canvas = Canvas::new();
346 RectItem::new(Rect::new(0.0, 0.0, 20.0, 20.0))
347 .fill(Color::RED)
348 .corner_radius(6.0)
349 .paint(&mut canvas, &test_ctx(&theme));
350 let frame = canvas.into_render_frame();
351 assert!(
352 !frame.shapes.is_empty(),
353 "rounded fill must emit an SDF shape"
354 );
355 }
356
357 #[test]
358 fn rect_item_stroke_styled_stores_dash_pattern() {
359 let item = RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0))
361 .stroke_styled(Color::BLUE, StrokeStyle::dashed(2.0, 6.0, 4.0));
362 let (_, style) = item.stroke.as_ref().expect("stroke set");
363 assert!(
364 style.dash_pattern.is_some(),
365 "dashed stroke must keep its dash pattern"
366 );
367 }
368
369 #[test]
370 fn rect_item_set_fill_replaces_colour() {
371 let theme = teksilo_core::presets::intui::light();
373 let mut item = RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)).fill(Color::RED);
374 assert!(item.set_fill(Some(ColorProp::from(Color::BLUE))));
375 let mut canvas = Canvas::new();
376 item.paint(&mut canvas, &test_ctx(&theme));
377 assert!(
378 canvas
379 .into_render_frame()
380 .decorations
381 .iter()
382 .any(|d| d.color == Color::BLUE.to_array())
383 );
384 }
385}