Skip to main content

teksilo_scene/items/
path.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`PathItem`] — vector path with optional fill and stroke.
5//!
6//! `PathItem` renders an arbitrary vector path in local item coordinates.
7//! The path can be filled, stroked, or both. Stroke-only paths use a
8//! per-segment distance hit-test so users can click precisely along the
9//! stroke even when the axis-aligned bounding box is huge — making this
10//! the natural workhorse for connector lines between cards in a node graph
11//! or story corkboard.
12//!
13//! Strokes come in two flavours: a **logical** stroke (`.stroke`) scales
14//! with the view zoom, making thick scene-space edges; a **cosmetic** stroke
15//! (`.stroke_cosmetic`) holds a constant device-pixel width at any zoom,
16//! ideal for hairline connector wires that should stay crisp and thin.
17//!
18//! ## When to use
19//!
20//! Use `PathItem` for connector lines, polygon overlays, freehand shapes,
21//! or any vector decoration that needs exact-shape click detection along its
22//! stroke. For solid rectangular regions, prefer the cheaper [`RectItem`](crate::RectItem).
23//!
24//! ## Example
25//!
26//! ```ignore
27//! use teksilo_scene::{SceneModel, PathItem};
28//! use teksilo_canvas::{Path, Point, Rect};
29//! use teksilo_tokens::Color;
30//!
31//! let model = SceneModel::new();
32//!
33//! let mut path = Path::new();
34//! path.move_to(Point::new(0.0, 0.0))
35//!     .line_to(Point::new(200.0, 0.0))
36//!     .line_to(Point::new(200.0, 100.0));
37//!
38//! let item = PathItem::new(path, Rect::new(0.0, 0.0, 200.0, 100.0))
39//!     .stroke_cosmetic(Color::new(0.3, 0.3, 0.3, 1.0), 1.5);
40//!
41//! model.add_item(item, Point::new(50.0, 50.0));
42//! ```
43
44use 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/// An arbitrary vector path with optional fill and stroke, in local
59/// item coordinates.
60///
61/// The path's commands are evaluated in local space. A logical stroke scales
62/// with the view zoom; a [`stroke_cosmetic`](Self::stroke_cosmetic) stroke
63/// holds a constant device-pixel width at any zoom (crisp connectors). The
64/// caller-provided `local_bounds` AABB is what the spatial index buckets on;
65/// it must enclose the path's strokes (including stroke half-width on each
66/// side).
67#[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    /// A path with a caller-provided AABB in local coordinates. The
80    /// path's points are interpreted as local — `(0, 0)` is the
81    /// item's anchor.
82    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    /// Fill colour. Accepts a plain [`Color`], a theme role, a
95    /// `Signal<Color>`, or a `Signal<Role>` — resolved against the active
96    /// theme at paint time.
97    pub fn fill(mut self, color: impl Into<ColorProp>) -> Self {
98        self.fill = Some(color.into());
99        self
100    }
101
102    /// Stroke colour and width in **scene-coordinate** pixels — the stroke
103    /// scales with the view zoom.
104    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    /// Cosmetic stroke: the connector holds a constant **device-pixel** width
110    /// at any zoom (it never thins out or thickens). The renderer keeps the
111    /// path body sharp at the current zoom, so joins/caps stay correct.
112    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    /// Stroke with an explicit [`StrokeStyle`] — dashed, dotted, or custom caps
118    /// / joins. E.g. `.stroke_styled(color, StrokeStyle::dashed(2.0, 6.0, 4.0))`
119    /// distinguishes a pending connector from a solid confirmed one. The style
120    /// is stored verbatim (dash pattern/offset, `Logical` vs `Device` space).
121    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    /// Human-readable label.
127    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    /// Opt the path into drag-to-move.
134    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        // The path's geometry is in local coords and stays fixed; only
149        // the AABB tracks. Apps that want to *move* a path move the
150        // item via `Scene::set_local_pos`. Apps that want to *resize*
151        // a path rebuild the item from scratch.
152        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        // Capture the data needed for hit-test without holding a
200        // borrow on `self`. The `SceneView` snapshot stores the
201        // returned closure and consults it on every pointer event,
202        // so we have to be cloneable and `'static`. `Path` is
203        // `Clone`; the rest of the captured state is `Copy`.
204        let path = self.path.clone();
205        let local_bounds = self.local_bounds;
206        let has_fill = self.fill.is_some();
207        // (stroke width, is-cosmetic). A cosmetic stroke's width is in DEVICE
208        // pixels, so its visual half-width in scene coordinates shrinks as the
209        // view zooms in (and grows as it zooms out). Convert per-event using
210        // the live view scale so the clickable band tracks the rendered line
211        // at any zoom; a logical stroke's width is already in scene units.
212        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        // Connector-line and outline use cases dominate stroke-only
230        // paths; fill takes precedence when present. Role-based colours
231        // have no theme here, so they fall through to the neutral grey.
232        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
257/// Hit-test logic shared between [`PathItem::shape_contains`] and
258/// the snapshotted closure returned by
259/// [`PathItem::clone_shape_test`]. Stroke-only paths walk each
260/// segment and test point-to-segment distance against
261/// `stroke_width/2 + 2px` tolerance; filled or mixed-fill paths
262/// fall through to AABB; non-line segments (quad / cubic / arc)
263/// fall through to AABB.
264fn 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
305/// Shortest distance from a point to a line segment.
306fn 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        // #5: a dashed connector keeps its dash pattern verbatim.
388        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        // #1/#2: fill + stroke resolve against the ctx theme and emit.
400        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        // A cosmetic stroke's width is in device px, so its scene-coord hit
414        // band must shrink as the view zooms in. A point 3 scene-units off a
415        // cosmetic 4px line is inside the band at 1× but outside at 4×.
416        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        // A LOGICAL stroke's width is already in scene units, so its band is
433        // unaffected by the view scale (regression guard).
434        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}