Skip to main content

dioxus_flow/
edge.rs

1//! Edge rendering: default paths, labels, arrow markers, selection, and the
2//! escape hatch for fully custom edges — for both anchor modes.
3
4use dioxus::prelude::*;
5
6use crate::path::{edge_path, EdgeGeometry, EdgePath};
7use crate::ports;
8use crate::state::{FlowCore, Interaction};
9use crate::types::{Edge, Id, MarkerKind, Point, Rect, Side};
10
11/// Distance from a handle's center to its rim (half the 10px dot). Edge
12/// paths stop here so arrowheads render in front of the dot, not under it.
13pub(crate) const HANDLE_RIM: f64 = 5.0;
14
15/// Everything a custom edge view needs: the edge, its resolved anchors, and
16/// the default path (so custom edges can restyle without redoing the math).
17#[derive(Clone, PartialEq)]
18pub struct EdgeViewCtx {
19    pub edge: Edge,
20    pub source: Point,
21    pub source_side: Side,
22    pub target: Point,
23    pub target_side: Side,
24    /// The path the default renderer would draw (`d` attribute + label
25    /// anchor).
26    pub path: EdgePath,
27    /// `url(#...)` reference for the edge's configured end marker, if any.
28    pub marker_end: Option<String>,
29}
30
31#[component]
32pub(crate) fn EdgeItem(
33    edge: Edge,
34    source: Point,
35    source_side: Side,
36    target: Point,
37    target_side: Side,
38    source_rect: Rect,
39    target_rect: Rect,
40    edge_view: Option<Callback<EdgeViewCtx, Element>>,
41    on_edge_click: Option<EventHandler<Id>>,
42) -> Element {
43    let core = use_context::<FlowCore>();
44    let path = edge_path(
45        edge.kind,
46        &EdgeGeometry::new(source, source_side, target, target_side)
47            .with_rects(source_rect, target_rect),
48    );
49    let marker_end = match edge.marker_end {
50        MarkerKind::ArrowClosed => Some(format!("url(#df-arrowclosed-{})", core.iid)),
51        MarkerKind::Arrow => Some(format!("url(#df-arrow-{})", core.iid)),
52        MarkerKind::None => None,
53    };
54
55    let class = format!(
56        "df-edge{}{}{}",
57        if edge.selected { " df-selected" } else { "" },
58        if edge.animated { " df-animated" } else { "" },
59        edge.class
60            .as_deref()
61            .map(|c| format!(" {c}"))
62            .unwrap_or_default(),
63    );
64
65    if let Some(view) = edge_view {
66        let ctx = EdgeViewCtx {
67            edge: edge.clone(),
68            source,
69            source_side,
70            target,
71            target_side,
72            path: path.clone(),
73            marker_end: marker_end.clone(),
74        };
75        let custom = view.call(ctx);
76        let id = edge.id.clone();
77        let selectable = edge.selectable;
78        return rsx! {
79            g { class,
80                {custom}
81                // Invisible fat path so custom edges stay clickable.
82                path {
83                    class: "df-edge-hit",
84                    d: "{path.d}",
85                    onpointerdown: move |evt| {
86                        edge_pointer_down(core, &id, selectable, &on_edge_click, evt)
87                    },
88                }
89            }
90        };
91    }
92
93    let id = edge.id.clone();
94    let selectable = edge.selectable;
95    rsx! {
96        g { class,
97            path {
98                class: "df-edge-path",
99                d: "{path.d}",
100                "marker-end": marker_end,
101                style: edge.style.as_deref().unwrap_or_default(),
102            }
103            path {
104                class: "df-edge-hit",
105                d: "{path.d}",
106                onpointerdown: move |evt| {
107                    edge_pointer_down(core, &id, selectable, &on_edge_click, evt)
108                },
109            }
110            if let Some(label) = edge.label.as_deref() {
111                text {
112                    class: "df-edge-label",
113                    x: path.label.x,
114                    y: path.label.y,
115                    "{label}"
116                }
117            }
118        }
119    }
120}
121
122/// Everything a seat-anchored custom edge (or label) view needs: the edge,
123/// its solved anchors (point *and* outward normal at each end), and the full
124/// rim-aware geometry — trimmed path, untrimmed outline for halos and grab
125/// bands, arrowhead polygons, the label point, and `point_at`/
126/// `nearest_label_position` for label interactions. Computed once per edge
127/// with the edge's own `weight`, `label_position` and markers, so a custom
128/// view never redoes the math.
129#[derive(Clone, PartialEq)]
130pub struct SeatEdgeViewCtx {
131    pub edge: Edge,
132    pub anchors: ports::EdgeAnchors,
133    pub geometry: ports::EdgeGeometry,
134}
135
136/// The full geometry for one seat-anchored edge, from its own parameters.
137fn seat_geometry(edge: &Edge, anchors: ports::EdgeAnchors) -> ports::EdgeGeometry {
138    let arrows = ports::Arrows::new(
139        edge.marker_start != MarkerKind::None,
140        edge.marker_end != MarkerKind::None,
141    );
142    ports::edge_geometry(
143        anchors.start,
144        anchors.end,
145        arrows,
146        edge.weight,
147        edge.label_position,
148        false,
149    )
150}
151
152/// The curves of seat-anchored edges, as one world-space layer.
153///
154/// Both inputs are the caller's: the edge list, and the solved anchors — so
155/// an application whose gestures decide what the solver sees (transient
156/// preview links, a hidden edge mid-drag) runs [`ports::solve_ports`] itself,
157/// once, and every layer drawn from it — this one, its own beads, its own
158/// previews — agrees. [`crate::Flow`]'s seat mode wires both up from its node
159/// and edge signals.
160///
161/// Labels deliberately do not render here: they belong to
162/// [`SeatEdgeLabels`], a separate layer, so a crossing edge's stroke never
163/// draws through another's words.
164#[component]
165pub fn SeatEdges(
166    /// The edges to draw, in paint order.
167    edges: ReadSignal<Vec<Edge>>,
168    /// Solved anchors by edge id, from [`ports::solve_ports`].
169    anchors: ReadSignal<std::collections::BTreeMap<Id, ports::EdgeAnchors>>,
170    /// Custom renderer for an edge's SVG. Receives the full geometry.
171    edge_view: Option<Callback<SeatEdgeViewCtx, Element>>,
172    /// Pointer down on an edge's default hit path (see `hit_paths`).
173    on_edge_click: Option<EventHandler<Id>>,
174    /// Whether this layer adds an invisible grab band per edge, with the
175    /// default select-on-press behavior. Applications whose custom views
176    /// carry their own hit paths and press semantics turn it off.
177    #[props(default = true)]
178    hit_paths: bool,
179    /// Extra classes for the layer's `<svg>`.
180    class: Option<String>,
181) -> Element {
182    let class = format!(
183        "df-edges{}",
184        class
185            .as_deref()
186            .map(|c| format!(" {c}"))
187            .unwrap_or_default()
188    );
189    let edges = edges.read();
190    let anchors = anchors.read();
191    rsx! {
192        svg { class, "aria-hidden": "true",
193            for edge in edges.iter() {
194                if let Some(pair) = anchors.get(&edge.id) {
195                    SeatEdgeItem {
196                        key: "{edge.id}",
197                        edge: edge.clone(),
198                        anchors: *pair,
199                        edge_view,
200                        on_edge_click,
201                        hit_paths,
202                    }
203                }
204            }
205        }
206    }
207}
208
209/// The labels of seat-anchored edges, as one world-space layer over every
210/// curve — so a connection crossing another never draws through its words.
211///
212/// With no `label_view`, edges that carry a label get it as text at their
213/// `label_position`. With one, it is called for *every* edge — the
214/// application decides which edges say something (a label being edited may
215/// not be in the edge's `label` yet) and returns empty for the rest.
216#[component]
217pub fn SeatEdgeLabels(
218    /// The edges, in paint order.
219    edges: ReadSignal<Vec<Edge>>,
220    /// Solved anchors by edge id, from [`ports::solve_ports`].
221    anchors: ReadSignal<std::collections::BTreeMap<Id, ports::EdgeAnchors>>,
222    /// Custom renderer for one edge's label (SVG content).
223    label_view: Option<Callback<SeatEdgeViewCtx, Element>>,
224    /// Extra classes for the layer's `<svg>`.
225    class: Option<String>,
226) -> Element {
227    let class = format!(
228        "df-edges df-edge-labels{}",
229        class
230            .as_deref()
231            .map(|c| format!(" {c}"))
232            .unwrap_or_default()
233    );
234    let edges = edges.read();
235    let anchors = anchors.read();
236    rsx! {
237        svg { class, "aria-hidden": "true",
238            for edge in edges.iter() {
239                if let Some(pair) = anchors.get(&edge.id) {
240                    if let Some(view) = label_view {
241                        {view.call(SeatEdgeViewCtx {
242                            edge: edge.clone(),
243                            anchors: *pair,
244                            geometry: seat_geometry(edge, *pair),
245                        })}
246                    } else if let Some(label) = edge.label.as_deref() {
247                        {
248                            let at = seat_geometry(edge, *pair).label;
249                            rsx! {
250                                text {
251                                    key: "{edge.id}",
252                                    class: "df-edge-label",
253                                    x: at.x,
254                                    y: at.y,
255                                    "{label}"
256                                }
257                            }
258                        }
259                    }
260                }
261            }
262        }
263    }
264}
265
266/// One edge under [`crate::AnchorMode::Seats`]: the rim-aware curve between
267/// two solved anchors, with arrowheads as filled polygons trimmed from the
268/// stroke (so a two-headed short edge keeps both heads).
269#[component]
270fn SeatEdgeItem(
271    edge: Edge,
272    anchors: ports::EdgeAnchors,
273    edge_view: Option<Callback<SeatEdgeViewCtx, Element>>,
274    on_edge_click: Option<EventHandler<Id>>,
275    hit_paths: bool,
276) -> Element {
277    let core = use_context::<FlowCore>();
278    let geometry = seat_geometry(&edge, anchors);
279
280    let class = format!(
281        "df-edge{}{}{}",
282        if edge.selected { " df-selected" } else { "" },
283        if edge.animated { " df-animated" } else { "" },
284        edge.class
285            .as_deref()
286            .map(|c| format!(" {c}"))
287            .unwrap_or_default(),
288    );
289
290    let id = edge.id.clone();
291    let selectable = edge.selectable;
292    // The invisible grab band, on the untrimmed curve so it runs bead to
293    // bead. Custom views that carry their own hit paths opt out.
294    let hit = hit_paths.then(|| {
295        let d = geometry.outline.clone();
296        rsx! {
297            path {
298                class: "df-edge-hit",
299                d,
300                onpointerdown: move |evt| {
301                    edge_pointer_down(core, &id, selectable, &on_edge_click, evt)
302                },
303            }
304        }
305    });
306
307    if let Some(view) = edge_view {
308        let custom = view.call(SeatEdgeViewCtx {
309            edge: edge.clone(),
310            anchors,
311            geometry,
312        });
313        return rsx! {
314            g { class,
315                {custom}
316                {hit}
317            }
318        };
319    }
320
321    rsx! {
322        g { class,
323            path {
324                class: "df-edge-path",
325                d: "{geometry.path}",
326                style: edge.style.as_deref().unwrap_or_default(),
327            }
328            if let Some(arrow) = geometry.start_arrow.as_deref() {
329                path { class: "df-edge-arrow", d: "{arrow}" }
330            }
331            if let Some(arrow) = geometry.end_arrow.as_deref() {
332                path { class: "df-edge-arrow", d: "{arrow}" }
333            }
334            {hit}
335        }
336    }
337}
338
339fn edge_pointer_down(
340    core: FlowCore,
341    id: &Id,
342    selectable: bool,
343    on_edge_click: &Option<EventHandler<Id>>,
344    evt: Event<PointerData>,
345) {
346    if *core.interaction.peek() != Interaction::None {
347        return;
348    }
349    core.cancel_animations();
350    // Claim the pointer so the pane doesn't start panning or clear the
351    // selection we're about to make.
352    core.interaction.clone().set(Interaction::Pressed);
353    if selectable {
354        let shift = evt.modifiers().shift();
355        let mut edges = core.edges;
356        edges.with_mut(|edges| {
357            for edge in edges.iter_mut() {
358                if &edge.id == id {
359                    edge.selected = if shift { !edge.selected } else { true };
360                } else if !shift {
361                    edge.selected = false;
362                }
363            }
364        });
365        if !shift {
366            core.deselect_nodes.call(());
367        }
368    }
369    if let Some(handler) = on_edge_click {
370        handler.call(id.clone());
371    }
372}
373
374/// Arrowhead marker definitions, namespaced per flow instance. Markers use
375/// `context-stroke` so they match each edge's stroke color.
376#[component]
377pub(crate) fn EdgeMarkers(iid: usize) -> Element {
378    rsx! {
379        marker {
380            id: "df-arrowclosed-{iid}",
381            "markerWidth": "12",
382            "markerHeight": "12",
383            "viewBox": "-10 -10 20 20",
384            "refX": "0",
385            "refY": "0",
386            "markerUnits": "strokeWidth",
387            orient: "auto-start-reverse",
388            path {
389                d: "M-7,-4.5 L0,0 L-7,4.5 Z",
390                fill: "context-stroke",
391                stroke: "none",
392            }
393        }
394        marker {
395            id: "df-arrow-{iid}",
396            "markerWidth": "12",
397            "markerHeight": "12",
398            "viewBox": "-10 -10 20 20",
399            "refX": "0",
400            "refY": "0",
401            "markerUnits": "strokeWidth",
402            orient: "auto-start-reverse",
403            path {
404                d: "M-7,-4.5 L0,0 L-7,4.5",
405                fill: "none",
406                stroke: "context-stroke",
407                stroke_width: "1.5",
408                stroke_linecap: "round",
409            }
410        }
411    }
412}