1use std::rc::Rc;
61
62use accesskit::Role;
63use teksilo_canvas::{Canvas, FillRule, Path, Rect, StrokeStyle, Transform2D};
64use teksilo_core::accessibility::AccessNodeBuilder;
65use teksilo_core::binding::BindingLevel;
66use teksilo_core::build_context::BuildContext;
67use teksilo_core::color_prop::ColorProp;
68use teksilo_core::widget_id::WidgetId;
69use teksilo_tokens::Color;
70
71use crate::flags::ItemFlags;
72use crate::item::{AppearanceWrite, SceneItem, SceneItemA11yContext, SceneItemPaintContext};
73use crate::items::{AccessSubtreeMode, ItemA11yOverrides};
74use crate::shape::{HIT_BAND_SLACK, ItemShape, ShapeGeometry};
75use teksilo_i18n::LocalizedString;
76
77#[derive(Debug)]
87pub struct PathItem {
88 geometry: Rc<ShapeGeometry>,
92 local_bounds: Rect,
93 fill: Option<ColorProp>,
94 fill_rule: FillRule,
97 stroke: Option<(ColorProp, StrokeStyle)>,
98 hit_stroke_width: Option<f32>,
100 label: Option<String>,
101 flags: ItemFlags,
102 a11y: ItemA11yOverrides,
103}
104
105impl PathItem {
106 pub fn new(path: Path) -> Self {
111 let mut item = Self {
112 geometry: ShapeGeometry::shared(path),
113 local_bounds: Rect::ZERO,
114 fill: None,
115 fill_rule: FillRule::Winding,
116 stroke: None,
117 hit_stroke_width: None,
118 label: None,
119 flags: ItemFlags::default(),
120 a11y: ItemA11yOverrides::default(),
121 };
122 item.recompute_bounds();
123 item
124 }
125
126 pub fn fill(mut self, color: impl Into<ColorProp>) -> Self {
130 self.fill = Some(color.into());
131 self
132 }
133
134 pub fn fill_rule(mut self, rule: FillRule) -> Self {
143 self.fill_rule = rule;
144 self
145 }
146
147 pub fn stroke(mut self, color: impl Into<ColorProp>, width: f32) -> Self {
150 self.stroke = Some((color.into(), StrokeStyle::solid(width.max(0.0))));
151 self.recompute_bounds();
152 self
153 }
154
155 pub fn stroke_cosmetic(mut self, color: impl Into<ColorProp>, width: f32) -> Self {
159 self.stroke = Some((color.into(), StrokeStyle::hairline(width.max(0.0))));
160 self.recompute_bounds();
161 self
162 }
163
164 pub fn stroke_styled(mut self, color: impl Into<ColorProp>, style: StrokeStyle) -> Self {
169 self.stroke = Some((color.into(), style));
170 self.recompute_bounds();
171 self
172 }
173
174 pub fn hit_stroke_width(mut self, width: f32) -> Self {
179 self.hit_stroke_width = Some(width.max(0.0));
180 self.recompute_bounds();
181 self
182 }
183
184 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
186 let ls: LocalizedString = label.into();
187 self.label = Some(ls.resolve_now());
188 self
189 }
190
191 pub fn draggable(mut self, draggable: bool) -> Self {
193 self.flags.set(ItemFlags::IS_DRAGGABLE, draggable);
194 self
195 }
196
197 pub fn path(&self) -> &Path {
199 self.geometry.path()
200 }
201
202 crate::items::item_a11y_builders!();
203
204 fn band_width(&self) -> Option<f32> {
207 self.hit_stroke_width
208 .or(self.stroke.as_ref().map(|(_, s)| s.width))
209 }
210
211 fn band_inflate(&self) -> f32 {
215 self.band_width()
216 .map(|w| w.max(0.0) * 0.5 + HIT_BAND_SLACK)
217 .unwrap_or(0.0)
218 }
219
220 fn recompute_bounds(&mut self) {
225 self.local_bounds = self.geometry.bounds().expand(self.band_inflate());
226 }
227}
228
229impl SceneItem for PathItem {
230 fn local_bounds(&self) -> Rect {
231 self.local_bounds
232 }
233
234 fn set_local_bounds(&mut self, bounds: Rect) {
260 let inflate = self.band_inflate();
261 let target = Rect::new(
265 bounds.x + inflate,
266 bounds.y + inflate,
267 (bounds.width - 2.0 * inflate).max(0.0),
268 (bounds.height - 2.0 * inflate).max(0.0),
269 );
270 let src = self.geometry.bounds();
271 let sx = if src.width.abs() > 1e-6 {
272 target.width / src.width
273 } else {
274 1.0
275 };
276 let sy = if src.height.abs() > 1e-6 {
277 target.height / src.height
278 } else {
279 1.0
280 };
281 if (sx - 1.0).abs() < 1e-5
284 && (sy - 1.0).abs() < 1e-5
285 && (src.x - target.x).abs() < 1e-4
286 && (src.y - target.y).abs() < 1e-4
287 {
288 return;
289 }
290 let fit = Transform2D::translate(-src.x, -src.y)
291 .then(&Transform2D::scale(sx, sy))
292 .then(&Transform2D::translate(target.x, target.y));
293 self.geometry = ShapeGeometry::shared(self.geometry.path().transformed(&fit));
294 self.recompute_bounds();
295 }
296
297 fn paint(&self, canvas: &mut Canvas, ctx: &SceneItemPaintContext<'_>) {
298 if let Some(prop) = &self.fill {
299 canvas.fill_path_with_rule(
300 self.geometry.path(),
301 prop.resolve(ctx.theme, ctx.enabled),
302 self.fill_rule,
303 );
304 }
305 if let Some((prop, style)) = &self.stroke {
306 canvas.stroke_path(
307 self.geometry.path(),
308 prop.resolve(ctx.theme, ctx.enabled),
309 style.clone(),
310 );
311 }
312 }
313
314 fn set_fill(&mut self, fill: Option<ColorProp>) -> AppearanceWrite<ColorProp> {
315 AppearanceWrite::Accepted {
316 was: std::mem::replace(&mut self.fill, fill),
317 }
318 }
319
320 fn set_stroke(
321 &mut self,
322 stroke: Option<(ColorProp, StrokeStyle)>,
323 ) -> AppearanceWrite<(ColorProp, StrokeStyle)> {
324 let was = std::mem::replace(&mut self.stroke, stroke);
325 self.recompute_bounds();
326 AppearanceWrite::Accepted { was }
327 }
328
329 fn register_bindings(&self, ctx: &mut BuildContext, view_id: WidgetId) {
330 let registry = ctx.binding_registry();
331 if let Some(p) = &self.fill {
332 p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
333 }
334 if let Some((p, _)) = &self.stroke {
335 p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
336 }
337 }
338
339 fn shape(&self) -> ItemShape {
351 if self.fill.is_none() && self.band_width().is_none() {
352 return ItemShape::bounds(self.local_bounds);
353 }
354 let mut shape = ItemShape::path(self.geometry.clone());
355 shape = if self.fill.is_some() {
356 shape.filled(self.fill_rule)
357 } else {
358 shape.unfilled()
359 };
360 match self.hit_stroke_width {
361 Some(w) => shape.hit_stroke_width(w),
362 None => match self.stroke.as_ref() {
363 Some((_, s)) => shape.stroked(s.width, s.space),
364 None => shape,
365 },
366 }
367 }
368
369 fn thumbnail_color(&self) -> Color {
370 crate::items::fill_or_stroke_hint(self.fill.as_ref(), self.stroke.as_ref())
374 .unwrap_or_else(|| Color::new(0.6, 0.6, 0.6, 1.0))
375 }
376
377 fn label(&self) -> Option<String> {
378 self.label.clone()
379 }
380
381 fn initial_flags(&self) -> ItemFlags {
382 self.flags
383 }
384
385 fn access_subtree_mode(&self) -> AccessSubtreeMode {
386 self.a11y.subtree_mode()
387 }
388
389 fn accessibility(&self, builder: &mut AccessNodeBuilder, _ctx: &SceneItemA11yContext) {
390 builder.set_role(Role::GraphicsObject);
391 if let Some(label) = self.label() {
392 builder.set_name(label);
393 }
394 self.a11y.apply(builder);
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use teksilo_canvas::Point;
402
403 #[test]
404 fn path_item_derives_local_bounds_from_geometry() {
405 let mut path = Path::new();
406 path.move_to(Point::new(0.0, 0.0))
407 .line_to(Point::new(100.0, 0.0))
408 .line_to(Point::new(100.0, 50.0));
409 let item = PathItem::new(path).stroke(Color::BLACK, 2.0);
410 assert_eq!(item.local_bounds(), Rect::new(-3.0, -3.0, 106.0, 56.0));
413 }
414
415 #[test]
416 fn path_item_local_bounds_always_enclose_its_shape() {
417 let mut path = Path::new();
421 path.move_to(Point::new(0.0, 0.0))
422 .quad_to(Point::new(50.0, 100.0), Point::new(100.0, 0.0));
423 for item in [
424 PathItem::new(path.clone()),
425 PathItem::new(path.clone()).stroke(Color::BLACK, 6.0),
426 PathItem::new(path.clone()).stroke_cosmetic(Color::BLACK, 1.0),
427 PathItem::new(path.clone()).fill(Color::RED),
428 PathItem::new(path.clone())
429 .stroke(Color::BLACK, 1.0)
430 .hit_stroke_width(20.0),
431 ] {
432 let lb = item.local_bounds();
433 let sb = item.shape().bounding_rect();
434 assert!(
435 lb.x <= sb.x + 1e-3
436 && lb.y <= sb.y + 1e-3
437 && lb.right() >= sb.right() - 1e-3
438 && lb.bottom() >= sb.bottom() - 1e-3,
439 "local_bounds {lb:?} must enclose shape bounds {sb:?}"
440 );
441 }
442 }
443
444 #[test]
445 fn path_item_hit_tests_per_segment_when_stroke_only() {
446 let mut path = Path::new();
447 path.move_to(Point::new(0.0, 0.0))
448 .line_to(Point::new(100.0, 100.0));
449 let item = PathItem::new(path).stroke(Color::BLACK, 2.0);
450 let shape = item.shape();
451
452 assert!(shape.contains(Point::new(50.0, 50.0), 1.0));
453 assert!(shape.contains(Point::new(52.0, 50.0), 1.0));
454 assert!(!shape.contains(Point::new(80.0, 20.0), 1.0));
455 assert!(!shape.contains(Point::new(200.0, 200.0), 1.0));
456 }
457
458 #[test]
459 fn path_item_filled_hit_tests_its_interior_not_its_box() {
460 let mut path = Path::new();
461 path.move_to(Point::new(0.0, 0.0))
462 .line_to(Point::new(100.0, 0.0))
463 .line_to(Point::new(0.0, 100.0))
464 .close();
465 let item = PathItem::new(path).fill(Color::RED);
466 let shape = item.shape();
467 assert!(
468 shape.contains(Point::new(20.0, 20.0), 1.0),
469 "inside triangle"
470 );
471 assert!(
473 !shape.contains(Point::new(90.0, 90.0), 1.0),
474 "the empty corner of a triangle's box is not the triangle"
475 );
476 assert!(!shape.contains(Point::new(200.0, 50.0), 1.0));
477 }
478
479 #[test]
480 fn path_item_fill_rule_drives_paint_and_hit_together() {
481 let mut path = Path::new();
484 path.move_to(Point::new(0.0, 0.0))
485 .line_to(Point::new(100.0, 0.0))
486 .line_to(Point::new(100.0, 100.0))
487 .line_to(Point::new(0.0, 100.0))
488 .close();
489 path.move_to(Point::new(25.0, 25.0))
490 .line_to(Point::new(75.0, 25.0))
491 .line_to(Point::new(75.0, 75.0))
492 .line_to(Point::new(25.0, 75.0))
493 .close();
494
495 let solid = PathItem::new(path.clone()).fill(Color::RED);
496 assert!(solid.shape().contains(Point::new(50.0, 50.0), 1.0));
497
498 let ring = PathItem::new(path)
499 .fill(Color::RED)
500 .fill_rule(FillRule::EvenOdd);
501 assert!(
502 !ring.shape().contains(Point::new(50.0, 50.0), 1.0),
503 "the hole must not be clickable"
504 );
505 assert!(
506 ring.shape().contains(Point::new(10.0, 50.0), 1.0),
507 "the band is"
508 );
509
510 let theme = teksilo_core::presets::intui::light();
512 let mut canvas = Canvas::new();
513 let ctx = SceneItemPaintContext::new(Transform2D::identity(), None, &theme);
514 ring.paint(&mut canvas, &ctx);
515 let frame = canvas.into_render_frame();
516 assert_eq!(frame.paths[0].fill_rule, FillRule::EvenOdd);
517 }
518
519 #[test]
520 fn path_item_close_segment_hit_tested() {
521 let mut path = Path::new();
522 path.move_to(Point::new(0.0, 0.0))
523 .line_to(Point::new(100.0, 0.0))
524 .line_to(Point::new(50.0, 100.0))
525 .close();
526 let item = PathItem::new(path).stroke(Color::BLACK, 2.0);
527 assert!(item.shape().contains(Point::new(25.0, 50.0), 1.0));
528 }
529
530 #[test]
531 fn path_item_curve_hit_tests_exactly() {
532 let mut path = Path::new();
538 path.move_to(Point::new(0.0, 0.0))
539 .quad_to(Point::new(50.0, 100.0), Point::new(100.0, 0.0));
540 let item = PathItem::new(path).stroke(Color::BLACK, 2.0);
541 let shape = item.shape();
542 assert!(!shape.contains(Point::new(50.0, 99.0), 1.0));
543 assert!(shape.contains(Point::new(50.0, 50.0), 1.0), "the apex hits");
544 }
545
546 #[test]
547 fn path_item_with_neither_fill_nor_stroke_keeps_its_box() {
548 let mut path = Path::new();
549 path.move_to(Point::new(0.0, 0.0))
550 .line_to(Point::new(100.0, 100.0));
551 let item = PathItem::new(path);
552 assert!(item.shape().contains(Point::new(90.0, 10.0), 1.0));
553 }
554
555 #[test]
556 fn path_item_hit_stroke_width_widens_the_grab_band() {
557 let mut path = Path::new();
558 path.move_to(Point::new(0.0, 0.0))
559 .line_to(Point::new(100.0, 0.0));
560 let thin = PathItem::new(path.clone()).stroke(Color::BLACK, 1.0);
561 assert!(!thin.shape().contains(Point::new(50.0, 5.0), 1.0));
562 let fat = PathItem::new(path)
563 .stroke(Color::BLACK, 1.0)
564 .hit_stroke_width(12.0);
565 assert!(fat.shape().contains(Point::new(50.0, 5.0), 1.0));
566 }
567
568 #[test]
569 fn path_item_stroke_styled_stores_dash_pattern() {
570 let mut path = Path::new();
572 path.move_to(Point::new(0.0, 0.0))
573 .line_to(Point::new(100.0, 0.0));
574 let item =
575 PathItem::new(path).stroke_styled(Color::BLACK, StrokeStyle::dashed(2.0, 6.0, 4.0));
576 let (_, style) = item.stroke.as_ref().expect("stroke set");
577 assert!(style.dash_pattern.is_some(), "dashed stroke keeps pattern");
578 }
579
580 #[test]
581 fn path_item_paint_resolves_colours() {
582 let theme = teksilo_core::presets::intui::light();
584 let mut path = Path::new();
585 path.move_to(Point::new(0.0, 0.0))
586 .line_to(Point::new(50.0, 50.0));
587 let item = PathItem::new(path).stroke(Color::RED, 2.0);
588 let mut canvas = Canvas::new();
589 let ctx = SceneItemPaintContext::new(Transform2D::identity(), None, &theme);
590 item.paint(&mut canvas, &ctx);
591 assert!(!canvas.into_render_frame().draw_order.is_empty());
592 }
593
594 #[test]
595 fn cosmetic_path_hit_band_tracks_zoom() {
596 let mut path = Path::new();
600 path.move_to(Point::new(0.0, 0.0))
601 .line_to(Point::new(100.0, 0.0));
602 let item = PathItem::new(path).stroke_cosmetic(Color::BLACK, 4.0);
603 let shape = item.shape();
604 let p = Point::new(50.0, 3.0);
605 assert!(
606 shape.contains(p, 1.0),
607 "cosmetic band at 1x: width 4 → tolerance 4 → hit"
608 );
609 assert!(
610 !shape.contains(p, 4.0),
611 "cosmetic band shrinks at 4x: width 1 → tolerance 2.5 → miss"
612 );
613
614 let mut path2 = Path::new();
617 path2
618 .move_to(Point::new(0.0, 0.0))
619 .line_to(Point::new(100.0, 0.0));
620 let logical = PathItem::new(path2).stroke(Color::BLACK, 4.0);
621 assert!(logical.shape().contains(p, 1.0), "logical band hit at 1x");
622 assert!(
623 logical.shape().contains(p, 4.0),
624 "logical band unchanged by zoom"
625 );
626 }
627
628 #[test]
629 fn set_local_bounds_moves_and_scales_the_geometry() {
630 let mut path = Path::new();
634 path.move_to(Point::new(0.0, 0.0))
635 .line_to(Point::new(100.0, 0.0));
636 let mut item = PathItem::new(path).stroke(Color::BLACK, 0.0);
637 let before = item.local_bounds();
638 item.set_local_bounds(Rect::new(
640 before.x + 200.0,
641 before.y,
642 before.width,
643 before.height,
644 ));
645 let shape = item.shape();
646 assert!(
647 shape.contains(Point::new(250.0, 0.0), 1.0),
648 "moved with its box"
649 );
650 assert!(
651 !shape.contains(Point::new(50.0, 0.0), 1.0),
652 "left the old place"
653 );
654 let lb = item.local_bounds();
655 let sb = shape.bounding_rect();
656 assert!((lb.x - sb.x).abs() < 1e-3 && (lb.width - sb.width).abs() < 1e-3);
657 }
658
659 #[test]
660 fn set_local_bounds_on_a_degenerate_box_translates_without_dividing_by_zero() {
661 let mut path = Path::new();
664 path.move_to(Point::new(0.0, 0.0));
665 let mut item = PathItem::new(path);
666 assert_eq!(item.local_bounds(), Rect::new(0.0, 0.0, 0.0, 0.0));
667 item.set_local_bounds(Rect::new(10.0, 20.0, 0.0, 0.0));
668 let lb = item.local_bounds();
669 assert!(lb.width.is_finite() && lb.height.is_finite());
670 assert!((lb.x - 10.0).abs() < 1e-3 && (lb.y - 20.0).abs() < 1e-3);
671 }
672
673 #[test]
674 fn a_long_ink_stroke_hit_tests_exactly() {
675 let mut path = Path::new();
682 path.move_to(Point::new(0.0, 0.0));
683 for i in 1..=500 {
684 let x = i as f32;
685 path.line_to(Point::new(x, (x * 0.2).sin() * 40.0));
686 }
687 let item = PathItem::new(path).stroke(Color::BLACK, 1.0);
688 let shape = item.shape();
689 assert!(!shape.contains(Point::new(250.0, 400.0), 1.0));
691 assert!(!shape.contains(Point::new(250.0, 20.0), 1.0));
693 assert!(shape.contains(Point::new(250.0, (250.0f32 * 0.2).sin() * 40.0), 1.0));
695 }
696}