1use accesskit::Role;
45use teksilo_canvas::{Canvas, Path, Point, Rect, StrokeSpace, StrokeStyle};
46use teksilo_core::accessibility::AccessNodeBuilder;
47use teksilo_core::binding::BindingLevel;
48use teksilo_core::build_context::BuildContext;
49use teksilo_core::color_prop::ColorProp;
50use teksilo_core::widget_id::WidgetId;
51use teksilo_tokens::Color;
52
53use crate::flags::ItemFlags;
54use crate::item::{SceneItem, SceneItemA11yContext, SceneItemPaintContext};
55use crate::items::{AccessSubtreeMode, ItemA11yOverrides};
56use teksilo_i18n::LocalizedString;
57
58#[derive(Debug)]
68pub struct PathItem {
69 path: Path,
70 local_bounds: Rect,
71 fill: Option<ColorProp>,
72 stroke: Option<(ColorProp, StrokeStyle)>,
73 label: Option<String>,
74 flags: ItemFlags,
75 a11y: ItemA11yOverrides,
76}
77
78impl PathItem {
79 pub fn new(path: Path, local_bounds: Rect) -> Self {
83 Self {
84 path,
85 local_bounds,
86 fill: None,
87 stroke: None,
88 label: None,
89 flags: ItemFlags::default(),
90 a11y: ItemA11yOverrides::default(),
91 }
92 }
93
94 pub fn fill(mut self, color: impl Into<ColorProp>) -> Self {
98 self.fill = Some(color.into());
99 self
100 }
101
102 pub fn stroke(mut self, color: impl Into<ColorProp>, width: f32) -> Self {
105 self.stroke = Some((color.into(), StrokeStyle::solid(width.max(0.0))));
106 self
107 }
108
109 pub fn stroke_cosmetic(mut self, color: impl Into<ColorProp>, width: f32) -> Self {
113 self.stroke = Some((color.into(), StrokeStyle::hairline(width.max(0.0))));
114 self
115 }
116
117 pub fn stroke_styled(mut self, color: impl Into<ColorProp>, style: StrokeStyle) -> Self {
122 self.stroke = Some((color.into(), style));
123 self
124 }
125
126 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
128 let ls: LocalizedString = label.into();
129 self.label = Some(ls.resolve_now());
130 self
131 }
132
133 pub fn draggable(mut self, draggable: bool) -> Self {
135 self.flags.set(ItemFlags::IS_DRAGGABLE, draggable);
136 self
137 }
138
139 crate::items::item_a11y_builders!();
140}
141
142impl SceneItem for PathItem {
143 fn local_bounds(&self) -> Rect {
144 self.local_bounds
145 }
146
147 fn set_local_bounds(&mut self, bounds: Rect) {
148 self.local_bounds = bounds;
153 }
154
155 fn paint(&self, canvas: &mut Canvas, ctx: &SceneItemPaintContext<'_>) {
156 if let Some(prop) = &self.fill {
157 canvas.fill_path(&self.path, prop.resolve(ctx.theme, ctx.enabled));
158 }
159 if let Some((prop, style)) = &self.stroke {
160 canvas.stroke_path(
161 &self.path,
162 prop.resolve(ctx.theme, ctx.enabled),
163 style.clone(),
164 );
165 }
166 }
167
168 fn set_fill(&mut self, fill: Option<ColorProp>) -> bool {
169 self.fill = fill;
170 true
171 }
172
173 fn set_stroke(&mut self, stroke: Option<(ColorProp, StrokeStyle)>) -> bool {
174 self.stroke = stroke;
175 true
176 }
177
178 fn register_bindings(&self, ctx: &mut BuildContext, view_id: WidgetId) {
179 let registry = ctx.binding_registry();
180 if let Some(p) = &self.fill {
181 p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
182 }
183 if let Some((p, _)) = &self.stroke {
184 p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
185 }
186 }
187
188 fn shape_contains(&self, local_pt: Point) -> bool {
189 path_shape_contains(
190 &self.path,
191 self.local_bounds,
192 self.fill.is_some(),
193 self.stroke.as_ref().map(|(_, s)| s.width),
194 local_pt,
195 )
196 }
197
198 fn clone_shape_test(&self) -> Box<dyn Fn(Point, f32) -> bool + 'static> {
199 let path = self.path.clone();
205 let local_bounds = self.local_bounds;
206 let has_fill = self.fill.is_some();
207 let stroke = self
213 .stroke
214 .as_ref()
215 .map(|(_, s)| (s.width, s.space == StrokeSpace::Device));
216 Box::new(move |local_pt, view_scale| {
217 let scene_width = stroke.map(|(w, cosmetic)| {
218 if cosmetic && view_scale > 1e-3 {
219 w / view_scale
220 } else {
221 w
222 }
223 });
224 path_shape_contains(&path, local_bounds, has_fill, scene_width, local_pt)
225 })
226 }
227
228 fn thumbnail_color(&self) -> Color {
229 crate::items::fill_or_stroke_hint(self.fill.as_ref(), self.stroke.as_ref())
233 .unwrap_or_else(|| Color::new(0.6, 0.6, 0.6, 1.0))
234 }
235
236 fn label(&self) -> Option<String> {
237 self.label.clone()
238 }
239
240 fn initial_flags(&self) -> ItemFlags {
241 self.flags
242 }
243
244 fn access_subtree_mode(&self) -> AccessSubtreeMode {
245 self.a11y.subtree_mode()
246 }
247
248 fn accessibility(&self, builder: &mut AccessNodeBuilder, _ctx: &SceneItemA11yContext) {
249 builder.set_role(Role::GraphicsObject);
250 if let Some(label) = self.label() {
251 builder.set_name(label);
252 }
253 self.a11y.apply(builder);
254 }
255}
256
257fn path_shape_contains(
265 path: &Path,
266 local_bounds: Rect,
267 has_fill: bool,
268 stroke_width: Option<f32>,
269 local_pt: Point,
270) -> bool {
271 let stroke_width = match stroke_width {
272 Some(w) => w,
273 None => return local_bounds.contains(local_pt),
274 };
275 if has_fill {
276 return local_bounds.contains(local_pt);
277 }
278 let tolerance = stroke_width.max(0.0) * 0.5 + 2.0;
279 let mut current = Point::ZERO;
280 let mut start = Point::ZERO;
281 for cmd in &path.commands {
282 match cmd {
283 teksilo_canvas::PathCommand::MoveTo(p) => {
284 current = *p;
285 start = *p;
286 }
287 teksilo_canvas::PathCommand::LineTo(p) => {
288 if point_to_segment_distance(local_pt, current, *p) <= tolerance {
289 return true;
290 }
291 current = *p;
292 }
293 teksilo_canvas::PathCommand::Close => {
294 if point_to_segment_distance(local_pt, current, start) <= tolerance {
295 return true;
296 }
297 current = start;
298 }
299 _ => return local_bounds.contains(local_pt),
300 }
301 }
302 false
303}
304
305fn point_to_segment_distance(p: Point, a: Point, b: Point) -> f32 {
307 let abx = b.x - a.x;
308 let aby = b.y - a.y;
309 let len2 = abx * abx + aby * aby;
310 if len2 < 1e-6 {
311 let dx = p.x - a.x;
312 let dy = p.y - a.y;
313 return (dx * dx + dy * dy).sqrt();
314 }
315 let apx = p.x - a.x;
316 let apy = p.y - a.y;
317 let t = ((apx * abx + apy * aby) / len2).clamp(0.0, 1.0);
318 let cx = a.x + t * abx;
319 let cy = a.y + t * aby;
320 let dx = p.x - cx;
321 let dy = p.y - cy;
322 (dx * dx + dy * dy).sqrt()
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
330 fn path_item_holds_path_and_local_bounds() {
331 let mut path = Path::new();
332 path.move_to(Point::new(0.0, 0.0))
333 .line_to(Point::new(100.0, 0.0))
334 .line_to(Point::new(100.0, 50.0));
335 let item = PathItem::new(path, Rect::new(0.0, 0.0, 100.0, 50.0)).stroke(Color::BLACK, 1.5);
336 assert_eq!(item.local_bounds(), Rect::new(0.0, 0.0, 100.0, 50.0));
337 }
338
339 #[test]
340 fn path_item_per_segment_shape_contains_stroke_only() {
341 let mut path = Path::new();
342 path.move_to(Point::new(0.0, 0.0))
343 .line_to(Point::new(100.0, 100.0));
344 let item = PathItem::new(path, Rect::new(0.0, 0.0, 100.0, 100.0)).stroke(Color::BLACK, 2.0);
345
346 assert!(item.shape_contains(Point::new(50.0, 50.0)));
347 assert!(item.shape_contains(Point::new(52.0, 50.0)));
348 assert!(!item.shape_contains(Point::new(80.0, 20.0)));
349 assert!(!item.shape_contains(Point::new(200.0, 200.0)));
350 }
351
352 #[test]
353 fn path_item_filled_uses_aabb_shape_contains() {
354 let mut path = Path::new();
355 path.move_to(Point::new(0.0, 0.0))
356 .line_to(Point::new(100.0, 0.0))
357 .line_to(Point::new(100.0, 100.0))
358 .line_to(Point::new(0.0, 100.0))
359 .close();
360 let item = PathItem::new(path, Rect::new(0.0, 0.0, 100.0, 100.0)).fill(Color::RED);
361 assert!(item.shape_contains(Point::new(50.0, 50.0)));
362 assert!(!item.shape_contains(Point::new(200.0, 50.0)));
363 }
364
365 #[test]
366 fn path_item_close_segment_hit_tested() {
367 let mut path = Path::new();
368 path.move_to(Point::new(0.0, 0.0))
369 .line_to(Point::new(100.0, 0.0))
370 .line_to(Point::new(50.0, 100.0))
371 .close();
372 let item = PathItem::new(path, Rect::new(0.0, 0.0, 100.0, 100.0)).stroke(Color::BLACK, 2.0);
373 assert!(item.shape_contains(Point::new(25.0, 50.0)));
374 }
375
376 #[test]
377 fn path_item_curve_falls_back_to_aabb() {
378 let mut path = Path::new();
379 path.move_to(Point::new(0.0, 0.0))
380 .quad_to(Point::new(50.0, 100.0), Point::new(100.0, 0.0));
381 let item = PathItem::new(path, Rect::new(0.0, 0.0, 100.0, 100.0)).stroke(Color::BLACK, 2.0);
382 assert!(item.shape_contains(Point::new(50.0, 99.0)));
383 }
384
385 #[test]
386 fn path_item_stroke_styled_stores_dash_pattern() {
387 let mut path = Path::new();
389 path.move_to(Point::new(0.0, 0.0))
390 .line_to(Point::new(100.0, 0.0));
391 let item = PathItem::new(path, Rect::new(0.0, 0.0, 100.0, 4.0))
392 .stroke_styled(Color::BLACK, StrokeStyle::dashed(2.0, 6.0, 4.0));
393 let (_, style) = item.stroke.as_ref().expect("stroke set");
394 assert!(style.dash_pattern.is_some(), "dashed stroke keeps pattern");
395 }
396
397 #[test]
398 fn path_item_paint_resolves_colours() {
399 let theme = teksilo_core::presets::intui::light();
401 let mut path = Path::new();
402 path.move_to(Point::new(0.0, 0.0))
403 .line_to(Point::new(50.0, 50.0));
404 let item = PathItem::new(path, Rect::new(0.0, 0.0, 50.0, 50.0)).stroke(Color::RED, 2.0);
405 let mut canvas = teksilo_canvas::Canvas::new();
406 let ctx = SceneItemPaintContext::new(teksilo_canvas::Transform2D::identity(), None, &theme);
407 item.paint(&mut canvas, &ctx);
408 assert!(!canvas.into_render_frame().draw_order.is_empty());
409 }
410
411 #[test]
412 fn cosmetic_path_hit_band_tracks_zoom() {
413 let mut path = Path::new();
417 path.move_to(Point::new(0.0, 0.0))
418 .line_to(Point::new(100.0, 0.0));
419 let item =
420 PathItem::new(path, Rect::new(0.0, 0.0, 100.0, 8.0)).stroke_cosmetic(Color::BLACK, 4.0);
421 let test = item.clone_shape_test();
422 let p = Point::new(50.0, 3.0);
423 assert!(
424 test(p, 1.0),
425 "cosmetic band at 1x: width 4 → tolerance 4 → hit"
426 );
427 assert!(
428 !test(p, 4.0),
429 "cosmetic band shrinks at 4x: width 1 → tolerance 2.5 → miss"
430 );
431
432 let mut path2 = Path::new();
435 path2
436 .move_to(Point::new(0.0, 0.0))
437 .line_to(Point::new(100.0, 0.0));
438 let logical =
439 PathItem::new(path2, Rect::new(0.0, 0.0, 100.0, 8.0)).stroke(Color::BLACK, 4.0);
440 let test_l = logical.clone_shape_test();
441 assert!(test_l(p, 1.0), "logical band hit at 1x");
442 assert!(test_l(p, 4.0), "logical band unchanged by zoom");
443 }
444}