Skip to main content

elegance/
pairing.rs

1//! Node-editor-style one-to-one pairing widget.
2//!
3//! See [`Pairing`] for the full interaction model and an example.
4
5use egui::{
6    epaint::CubicBezierShape, Align2, Color32, CornerRadius, FontId, Id, Pos2, Rect, Response,
7    Sense, Shape, Stroke, StrokeKind, Ui, Vec2,
8};
9use std::hash::Hash;
10
11use crate::theme::{Palette, Theme, Typography};
12
13/// Maximum number of items supported per side. Layout uses fixed-size stack
14/// buffers of this length so no heap allocation happens per frame; exceeding
15/// this cap panics with a clear message.
16const MAX_ROWS: usize = 64;
17
18/// A single item rendered in either column of a [`Pairing`] widget.
19#[derive(Clone, Debug)]
20pub struct PairItem {
21    /// Stable identifier. Used as the link key when pairing items across sides.
22    pub id: String,
23    /// Primary label shown on the node.
24    pub name: String,
25    /// Optional secondary text rendered below the name.
26    pub detail: Option<String>,
27    /// Optional leading-edge glyph rendered in a small rounded box.
28    pub icon: Option<String>,
29}
30
31impl PairItem {
32    /// Create a new item with a stable id and display name.
33    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
34        Self {
35            id: id.into(),
36            name: name.into(),
37            detail: None,
38            icon: None,
39        }
40    }
41
42    /// Set the secondary detail line.
43    pub fn detail(mut self, detail: impl Into<String>) -> Self {
44        self.detail = Some(detail.into());
45        self
46    }
47
48    /// Set the leading icon glyph.
49    ///
50    /// Rendered with the default proportional font. The bundled
51    /// `Elegance Symbols` fallback font only covers arrows (`← ↑ → ↓ ↩ ↲ ↵`),
52    /// ellipses (`⋮ ⋯`), modifier keys (`⌃ ⌘ ⌥ ⌫ ⌦`), triangles (`▴ ▸ ▾ ◂`)
53    /// and `✓ ✗`. Glyphs outside that set (e.g. `◈`, `↗`) may render as tofu
54    /// unless the host app has registered a font that covers them.
55    pub fn icon(mut self, icon: impl Into<String>) -> Self {
56        self.icon = Some(icon.into());
57        self
58    }
59}
60
61/// Which column a node lives in.
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63enum Side {
64    Left,
65    Right,
66}
67
68impl Side {
69    fn opposite(self) -> Self {
70        match self {
71            Side::Left => Side::Right,
72            Side::Right => Side::Left,
73        }
74    }
75}
76
77/// Cross-frame state — just the currently-selected source node, if any.
78/// Snap target is recomputed every frame from hover responses.
79#[derive(Clone, Debug, Default)]
80struct State {
81    selection: Option<(Side, String)>,
82}
83
84impl State {
85    fn clone_for_storage(&self) -> Self {
86        self.clone()
87    }
88}
89
90/// A widget that lets users connect items in two lists with 1:1 pairings,
91/// drawn as bezier curves between ports on each node.
92///
93/// # Interaction
94///
95/// - **Click a port** to enter pairing mode — a dashed ghost line follows the cursor.
96/// - **Click an opposite-side port** to complete the pairing. If that target was
97///   already paired, its previous pairing is broken first (swap).
98/// - **Hover an opposite-side node** while pairing and the ghost line latches
99///   to that node's port.
100/// - **Click a paired node** to break its connection *and* immediately start a
101///   new pairing from it, so reconnecting is a single click.
102/// - **Click a pair's line** to remove it.
103/// - **Escape** or **click the background** cancels a pending selection.
104///
105/// # State
106///
107/// Pairings are stored in the caller-owned `Vec<(String, String)>` passed to
108/// [`Pairing::new`]. Each element is `(left_id, right_id)`. The transient
109/// selection state is stored in egui memory keyed by the widget's `id_salt`.
110///
111/// # Limits
112///
113/// Each side supports up to 64 items. Exceeding this panics — the widget
114/// uses fixed-size stack buffers for zero-allocation layout. For larger
115/// data sets, split the lists across multiple `Pairing` widgets.
116///
117/// # Example
118///
119/// ```no_run
120/// # use elegance::{Pairing, PairItem};
121/// # egui::__run_test_ui(|ui| {
122/// let clients = vec![
123///     PairItem::new("c1", "worker-pool-a").detail("24 instances"),
124///     PairItem::new("c2", "edge-proxy-01").detail("8 instances"),
125/// ];
126/// let servers = vec![
127///     PairItem::new("s1", "api-east-01").detail("10.0.1.5 · us-east"),
128///     PairItem::new("s2", "api-west-01").detail("10.0.2.4 · us-west"),
129/// ];
130/// let mut pairs: Vec<(String, String)> = vec![];
131/// Pairing::new("client-server", &clients, &servers, &mut pairs)
132///     .left_label("Clients")
133///     .right_label("Servers")
134///     .show(ui);
135/// # });
136/// ```
137#[must_use = "Call `.show(ui)` to render the pairing widget."]
138pub struct Pairing<'a> {
139    id_salt: Id,
140    left: &'a [PairItem],
141    right: &'a [PairItem],
142    pairs: &'a mut Vec<(String, String)>,
143    left_label: Option<String>,
144    right_label: Option<String>,
145    height: Option<f32>,
146    align: Option<Side>,
147}
148
149impl<'a> std::fmt::Debug for Pairing<'a> {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        f.debug_struct("Pairing")
152            .field("id_salt", &self.id_salt)
153            .field("left", &self.left.len())
154            .field("right", &self.right.len())
155            .field("pairs", &self.pairs.len())
156            .field("left_label", &self.left_label)
157            .field("right_label", &self.right_label)
158            .field("height", &self.height)
159            .field("align", &self.align)
160            .finish()
161    }
162}
163
164impl<'a> Pairing<'a> {
165    /// Create a new pairing widget.
166    ///
167    /// * `id_salt` — a stable salt for this widget's memory state. Different
168    ///   `Pairing` widgets on the same window must use distinct salts.
169    /// * `left`, `right` — items shown in each column.
170    /// * `pairs` — the caller-owned list of `(left_id, right_id)` tuples. It
171    ///   is mutated when the user creates or removes a pairing.
172    pub fn new(
173        id_salt: impl Hash,
174        left: &'a [PairItem],
175        right: &'a [PairItem],
176        pairs: &'a mut Vec<(String, String)>,
177    ) -> Self {
178        Self {
179            id_salt: Id::new(("elegance_pairing", id_salt)),
180            left,
181            right,
182            pairs,
183            left_label: None,
184            right_label: None,
185            height: None,
186            align: None,
187        }
188    }
189
190    /// Set the label shown above the left column.
191    pub fn left_label(mut self, label: impl Into<String>) -> Self {
192        self.left_label = Some(label.into());
193        self
194    }
195
196    /// Set the label shown above the right column.
197    pub fn right_label(mut self, label: impl Into<String>) -> Self {
198        self.right_label = Some(label.into());
199        self
200    }
201
202    /// Override the total widget height. By default the widget sizes itself
203    /// to fit the longer of the two columns.
204    pub fn height(mut self, height: f32) -> Self {
205        self.height = Some(height);
206        self
207    }
208
209    /// Auto-arrange the left column so every pairing renders as a straight
210    /// horizontal line. The right column keeps its caller-given order;
211    /// unpaired items on the left fill the remaining slots in input order.
212    pub fn align_left(mut self) -> Self {
213        self.align = Some(Side::Left);
214        self
215    }
216
217    /// Auto-arrange the right column so every pairing renders as a straight
218    /// horizontal line. The left column keeps its caller-given order;
219    /// unpaired items on the right fill the remaining slots in input order.
220    pub fn align_right(mut self) -> Self {
221        self.align = Some(Side::Right);
222        self
223    }
224
225    /// Render the widget and handle interaction.
226    pub fn show(self, ui: &mut Ui) -> Response {
227        let Pairing {
228            id_salt,
229            left,
230            right,
231            pairs,
232            left_label,
233            right_label,
234            height,
235            align,
236        } = self;
237
238        assert!(
239            left.len() <= MAX_ROWS && right.len() <= MAX_ROWS,
240            "Pairing widget supports up to {} items per side (got left={}, right={})",
241            MAX_ROWS,
242            left.len(),
243            right.len()
244        );
245
246        let theme = Theme::current(ui.ctx());
247
248        const NODE_HEIGHT: f32 = 56.0;
249        const NODE_GAP: f32 = 8.0;
250        const LABEL_HEIGHT: f32 = 26.0;
251        const PORT_RADIUS: f32 = 5.0;
252        const MIN_COL_GAP: f32 = 80.0;
253        const LINE_HIT_THRESHOLD: f32 = 6.0;
254
255        // Layout.
256        let has_label = left_label.is_some() || right_label.is_some();
257        let rows = left.len().max(right.len());
258        let content_h = (if has_label { LABEL_HEIGHT } else { 0.0 })
259            + if rows > 0 {
260                rows as f32 * (NODE_HEIGHT + NODE_GAP) - NODE_GAP
261            } else {
262                0.0
263            };
264        let widget_h = height.unwrap_or(content_h + theme.card_padding * 2.0);
265
266        // Allocate the outer rect. Click on this (when no child consumes) =
267        // background click = cancel.
268        let (outer_rect, response) =
269            ui.allocate_exact_size(Vec2::new(ui.available_width(), widget_h), Sense::click());
270
271        let inner = outer_rect.shrink(theme.card_padding);
272        let col_gap = MIN_COL_GAP.max(inner.width() * 0.12);
273        let col_w = ((inner.width() - col_gap) * 0.5).max(120.0);
274        let left_x = inner.left();
275        let right_x = inner.right() - col_w;
276        let nodes_top = if has_label {
277            inner.top() + LABEL_HEIGHT
278        } else {
279            inner.top()
280        };
281
282        // Load persistent state and prune stale selections.
283        let mut state: State = ui.ctx().data(|d| d.get_temp(id_salt).unwrap_or_default());
284        if let Some((side, id)) = state.selection.clone() {
285            let exists = match side {
286                Side::Left => left.iter().any(|i| i.id == id),
287                Side::Right => right.iter().any(|i| i.id == id),
288            };
289            if !exists {
290                state.selection = None;
291            }
292        }
293
294        // Compute visual row positions. When no alignment is set we use
295        // identity (row i → row i) without any buffer. When aligned, one
296        // side writes its reordered positions into a stack buffer. Neither
297        // path touches the heap.
298        let mut left_buf = [0usize; MAX_ROWS];
299        let mut right_buf = [0usize; MAX_ROWS];
300        let left_positions: Option<&[usize]> = if align == Some(Side::Left) {
301            compute_aligned_positions(left, right, pairs, false, &mut left_buf);
302            Some(&left_buf[..left.len()])
303        } else {
304            None
305        };
306        let right_positions: Option<&[usize]> = if align == Some(Side::Right) {
307            compute_aligned_positions(right, left, pairs, true, &mut right_buf);
308            Some(&right_buf[..right.len()])
309        } else {
310            None
311        };
312
313        // Allocate node rects and collect interaction responses.
314        let mut hits: Vec<NodeHit> = Vec::with_capacity(left.len() + right.len());
315        for (i, item) in left.iter().enumerate() {
316            let vis = left_positions.map_or(i, |p| p[i]);
317            let top = nodes_top + vis as f32 * (NODE_HEIGHT + NODE_GAP);
318            let r = Rect::from_min_size(Pos2::new(left_x, top), Vec2::new(col_w, NODE_HEIGHT));
319            let port = Pos2::new(r.right(), r.center().y);
320            let resp = ui.interact(r, id_salt.with(("L", &item.id)), Sense::click());
321            hits.push(NodeHit {
322                side: Side::Left,
323                id: item.id.clone(),
324                rect: r,
325                port,
326                resp,
327            });
328        }
329        for (i, item) in right.iter().enumerate() {
330            let vis = right_positions.map_or(i, |p| p[i]);
331            let top = nodes_top + vis as f32 * (NODE_HEIGHT + NODE_GAP);
332            let r = Rect::from_min_size(Pos2::new(right_x, top), Vec2::new(col_w, NODE_HEIGHT));
333            let port = Pos2::new(r.left(), r.center().y);
334            let resp = ui.interact(r, id_salt.with(("R", &item.id)), Sense::click());
335            hits.push(NodeHit {
336                side: Side::Right,
337                id: item.id.clone(),
338                rect: r,
339                port,
340                resp,
341            });
342        }
343
344        // Snap target = opposite-side hovered node (only while a selection is active).
345        let snap_target: Option<(Side, String)> = state.selection.as_ref().and_then(|(ss, _)| {
346            let opp = ss.opposite();
347            hits.iter()
348                .find(|h| h.side == opp && h.resp.hovered())
349                .map(|h| (h.side, h.id.clone()))
350        });
351
352        // Clicks.
353        let node_click = hits
354            .iter()
355            .find(|h| h.resp.clicked())
356            .map(|h| (h.side, h.id.clone()));
357        if let Some((side, id)) = node_click {
358            handle_node_click(&mut state, side, &id, pairs);
359        } else {
360            // Check for clicks on existing pair lines; if none, fall back to
361            // background-click cancel.
362            let pointer = ui.input(|i| i.pointer.hover_pos());
363            let pressed = ui.input(|i| i.pointer.primary_clicked());
364            let mut consumed = false;
365            if pressed {
366                if let Some(m) = pointer {
367                    if outer_rect.contains(m) {
368                        let mut remove = None;
369                        for (idx, (lid, rid)) in pairs.iter().enumerate() {
370                            if let (Some(lp), Some(rp)) = (
371                                port_of(&hits, Side::Left, lid),
372                                port_of(&hits, Side::Right, rid),
373                            ) {
374                                if bezier_hit(m, lp, rp, LINE_HIT_THRESHOLD) {
375                                    remove = Some(idx);
376                                    break;
377                                }
378                            }
379                        }
380                        if let Some(i) = remove {
381                            pairs.remove(i);
382                            state.selection = None;
383                            consumed = true;
384                        }
385                    }
386                }
387            }
388            if !consumed && response.clicked() {
389                state.selection = None;
390            }
391        }
392
393        if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
394            state.selection = None;
395        }
396
397        // Paint.
398        if ui.is_rect_visible(outer_rect) {
399            let painter = ui.painter();
400            let palette = &theme.palette;
401            let typo = &theme.typography;
402
403            // Card background + border.
404            painter.rect(
405                outer_rect,
406                CornerRadius::same(theme.card_radius as u8),
407                palette.card,
408                Stroke::new(1.0, palette.border),
409                StrokeKind::Inside,
410            );
411
412            // Dot grid.
413            paint_grid(painter, outer_rect, palette);
414
415            // Column labels.
416            if let Some(lbl) = &left_label {
417                painter.text(
418                    Pos2::new(left_x + 2.0, inner.top()),
419                    Align2::LEFT_TOP,
420                    lbl,
421                    FontId::proportional(typo.label),
422                    palette.text_muted,
423                );
424            }
425            if let Some(lbl) = &right_label {
426                painter.text(
427                    Pos2::new(right_x + 2.0, inner.top()),
428                    Align2::LEFT_TOP,
429                    lbl,
430                    FontId::proportional(typo.label),
431                    palette.text_muted,
432                );
433            }
434
435            // Existing pair lines (solid, sky).
436            let line_stroke = Stroke::new(2.0, palette.sky);
437            for (lid, rid) in pairs.iter() {
438                if let (Some(lp), Some(rp)) = (
439                    port_of(&hits, Side::Left, lid),
440                    port_of(&hits, Side::Right, rid),
441                ) {
442                    paint_bezier(painter, lp, rp, line_stroke, false);
443                }
444            }
445
446            // Ghost line while selecting.
447            if let Some((sel_side, sel_id)) = &state.selection {
448                if let Some(src) = port_of(&hits, *sel_side, sel_id) {
449                    let end = snap_target
450                        .as_ref()
451                        .and_then(|(s, i)| port_of(&hits, *s, i))
452                        .or_else(|| {
453                            ui.input(|i| i.pointer.hover_pos())
454                                .filter(|p| outer_rect.contains(*p))
455                        });
456                    if let Some(e) = end {
457                        let ghost_stroke = Stroke::new(1.75, with_alpha(palette.sky, 140));
458                        paint_bezier(painter, src, e, ghost_stroke, true);
459                        if snap_target.is_none() {
460                            painter.circle_filled(e, 3.5, with_alpha(palette.text_muted, 165));
461                        }
462                    }
463                    // Keep the ghost tracking the cursor.
464                    ui.ctx().request_repaint();
465                }
466            }
467
468            // Nodes on top of lines.
469            for h in &hits {
470                let item = match h.side {
471                    Side::Left => left.iter().find(|i| i.id == h.id),
472                    Side::Right => right.iter().find(|i| i.id == h.id),
473                };
474                let Some(item) = item else {
475                    continue;
476                };
477                let selected = state
478                    .selection
479                    .as_ref()
480                    .is_some_and(|(s, i)| *s == h.side && i == &h.id);
481                let is_snap = snap_target
482                    .as_ref()
483                    .is_some_and(|(s, i)| *s == h.side && i == &h.id);
484                let paired = is_paired(pairs, h.side, &h.id);
485                paint_node(
486                    painter,
487                    h.rect,
488                    h.port,
489                    item,
490                    selected,
491                    is_snap,
492                    paired,
493                    h.resp.hovered(),
494                    palette,
495                    typo,
496                    theme.control_radius,
497                    PORT_RADIUS,
498                );
499            }
500        }
501
502        // Save state.
503        ui.ctx()
504            .data_mut(|d| d.insert_temp(id_salt, state.clone_for_storage()));
505        response
506    }
507}
508
509// ---------------------------------------------------------------------------
510// Internal helpers
511// ---------------------------------------------------------------------------
512
513struct NodeHit {
514    side: Side,
515    id: String,
516    rect: Rect,
517    port: Pos2,
518    resp: Response,
519}
520
521fn port_of(hits: &[NodeHit], side: Side, id: &str) -> Option<Pos2> {
522    hits.iter()
523        .find(|h| h.side == side && h.id == id)
524        .map(|h| h.port)
525}
526
527/// Assign each aligned-side item to a visual row so paired items land on
528/// the same row as their partner on the *other* side. Unpaired items on the
529/// aligned side fill the remaining rows in their input order.
530///
531/// Writes the result into the first `aligned.len()` entries of `positions`.
532/// Stack-only — uses no heap allocation.
533fn compute_aligned_positions(
534    aligned: &[PairItem],
535    other: &[PairItem],
536    pairs: &[(String, String)],
537    aligned_is_right: bool,
538    positions: &mut [usize; MAX_ROWS],
539) {
540    let n_aligned = aligned.len();
541    let max_pos = n_aligned.max(other.len());
542
543    // Sentinel for "not yet placed".
544    for p in positions.iter_mut().take(n_aligned) {
545        *p = usize::MAX;
546    }
547
548    let mut slot_taken = [false; MAX_ROWS];
549
550    // Anchor paired aligned items to their partner's visual row.
551    for (other_idx, other_item) in other.iter().enumerate() {
552        let partner_id: Option<&String> = pairs.iter().find_map(|(l, r)| {
553            if aligned_is_right {
554                (l == &other_item.id).then_some(r)
555            } else {
556                (r == &other_item.id).then_some(l)
557            }
558        });
559        if let Some(pid) = partner_id {
560            if let Some(ai) = aligned.iter().position(|a| &a.id == pid) {
561                if other_idx < max_pos && !slot_taken[other_idx] && positions[ai] == usize::MAX {
562                    positions[ai] = other_idx;
563                    slot_taken[other_idx] = true;
564                }
565            }
566        }
567    }
568
569    // Fill remaining aligned items into free rows, preserving input order.
570    let mut free_slots = (0..max_pos).filter(|s| !slot_taken[*s]);
571    for pos in positions.iter_mut().take(n_aligned) {
572        if *pos == usize::MAX {
573            *pos = free_slots.next().unwrap_or(0);
574        }
575    }
576}
577
578fn is_paired(pairs: &[(String, String)], side: Side, id: &str) -> bool {
579    match side {
580        Side::Left => pairs.iter().any(|(l, _)| l == id),
581        Side::Right => pairs.iter().any(|(_, r)| r == id),
582    }
583}
584
585fn handle_node_click(state: &mut State, side: Side, id: &str, pairs: &mut Vec<(String, String)>) {
586    let paired = is_paired(pairs, side, id);
587    let sel = state.selection.clone();
588
589    // Re-clicking the selected node cancels.
590    if let Some((s, sid)) = &sel {
591        if *s == side && sid == id {
592            state.selection = None;
593            return;
594        }
595    }
596
597    // Opposite-side click → pair (swap if target was already paired).
598    if let Some((sel_side, sel_id)) = &sel {
599        if *sel_side != side {
600            if paired {
601                pairs.retain(|(l, r)| match side {
602                    Side::Left => l != id,
603                    Side::Right => r != id,
604                });
605            }
606            let pair = match side {
607                Side::Left => (id.to_string(), sel_id.clone()),
608                Side::Right => (sel_id.clone(), id.to_string()),
609            };
610            pairs.push(pair);
611            state.selection = None;
612            return;
613        }
614    }
615
616    // Otherwise (no selection, or same-side click): unpair if needed, then select.
617    // One click breaks the existing connection AND starts a new one.
618    if paired {
619        pairs.retain(|(l, r)| match side {
620            Side::Left => l != id,
621            Side::Right => r != id,
622        });
623    }
624    state.selection = Some((side, id.to_string()));
625}
626
627fn paint_grid(painter: &egui::Painter, rect: Rect, palette: &Palette) {
628    let step = 22.0;
629    let dot = with_alpha(palette.text, 12);
630    let mut y = rect.top() + step;
631    while y < rect.bottom() {
632        let mut x = rect.left() + step;
633        while x < rect.right() {
634            painter.circle_filled(Pos2::new(x, y), 0.75, dot);
635            x += step;
636        }
637        y += step;
638    }
639}
640
641#[allow(clippy::too_many_arguments)]
642fn paint_node(
643    painter: &egui::Painter,
644    rect: Rect,
645    port: Pos2,
646    item: &PairItem,
647    selected: bool,
648    snap_target: bool,
649    paired: bool,
650    hovered: bool,
651    palette: &Palette,
652    typo: &Typography,
653    radius: f32,
654    port_radius: f32,
655) {
656    let r = CornerRadius::same(radius as u8);
657
658    // Background + border (fold into one rect() call).
659    let border = if selected || snap_target {
660        palette.sky
661    } else if hovered {
662        palette.text_muted
663    } else {
664        palette.border
665    };
666    painter.rect(
667        rect,
668        r,
669        palette.input_bg,
670        Stroke::new(1.0, border),
671        StrokeKind::Inside,
672    );
673
674    // Content.
675    let pad_x = 14.0;
676    let mut content_x = rect.left() + pad_x;
677
678    // Optional icon box.
679    if let Some(icon) = &item.icon {
680        let box_size = 28.0;
681        let icon_rect = Rect::from_min_size(
682            Pos2::new(content_x, rect.center().y - box_size * 0.5),
683            Vec2::splat(box_size),
684        );
685        painter.rect(
686            icon_rect,
687            r,
688            palette.card,
689            Stroke::new(1.0, palette.border),
690            StrokeKind::Inside,
691        );
692        painter.text(
693            icon_rect.center(),
694            Align2::CENTER_CENTER,
695            icon,
696            FontId::proportional(13.0),
697            palette.text_muted,
698        );
699        content_x += box_size + 12.0;
700    }
701
702    // Name + optional detail.
703    if let Some(detail) = &item.detail {
704        painter.text(
705            Pos2::new(content_x, rect.top() + 11.0),
706            Align2::LEFT_TOP,
707            &item.name,
708            FontId::proportional(typo.body),
709            palette.text,
710        );
711        painter.text(
712            Pos2::new(content_x, rect.top() + 31.0),
713            Align2::LEFT_TOP,
714            detail,
715            FontId::proportional(typo.small),
716            palette.text_faint,
717        );
718    } else {
719        painter.text(
720            Pos2::new(content_x, rect.center().y),
721            Align2::LEFT_CENTER,
722            &item.name,
723            FontId::proportional(typo.body),
724            palette.text,
725        );
726    }
727
728    // Port.
729    let active = selected || snap_target || paired;
730    let port_fill = if active {
731        palette.sky
732    } else {
733        palette.input_bg
734    };
735    let port_stroke = if active || hovered {
736        palette.sky
737    } else {
738        palette.border
739    };
740    painter.circle_filled(port, port_radius, port_fill);
741    painter.circle_stroke(port, port_radius, Stroke::new(1.5, port_stroke));
742    if active {
743        painter.circle_stroke(
744            port,
745            port_radius + 3.0,
746            Stroke::new(3.0, with_alpha(palette.sky, 46)),
747        );
748    }
749}
750
751fn paint_bezier(painter: &egui::Painter, start: Pos2, end: Pos2, stroke: Stroke, dashed: bool) {
752    let mid_x = (start.x + end.x) * 0.5;
753    let c1 = Pos2::new(mid_x, start.y);
754    let c2 = Pos2::new(mid_x, end.y);
755
756    if !dashed {
757        let shape = CubicBezierShape::from_points_stroke(
758            [start, c1, c2, end],
759            false,
760            Color32::TRANSPARENT,
761            stroke,
762        );
763        painter.add(Shape::CubicBezier(shape));
764        return;
765    }
766
767    // Dashed: sample the curve and draw short, alternating segment groups.
768    const SAMPLES: usize = 40;
769    const DASH_N: usize = 2; // segments per dash; gap = DASH_N segments
770    let pts: Vec<Pos2> = (0..=SAMPLES)
771        .map(|i| cubic_bezier(i as f32 / SAMPLES as f32, start, c1, c2, end))
772        .collect();
773    let period = DASH_N * 2;
774    let mut i = 0;
775    while i + DASH_N < pts.len() {
776        for j in 0..DASH_N {
777            painter.line_segment([pts[i + j], pts[i + j + 1]], stroke);
778        }
779        i += period;
780    }
781}
782
783fn bezier_hit(point: Pos2, start: Pos2, end: Pos2, threshold: f32) -> bool {
784    let mid_x = (start.x + end.x) * 0.5;
785    let c1 = Pos2::new(mid_x, start.y);
786    let c2 = Pos2::new(mid_x, end.y);
787    const SAMPLES: usize = 30;
788    let mut prev = start;
789    for i in 1..=SAMPLES {
790        let t = i as f32 / SAMPLES as f32;
791        let p = cubic_bezier(t, start, c1, c2, end);
792        if dist_to_segment(point, prev, p) < threshold {
793            return true;
794        }
795        prev = p;
796    }
797    false
798}
799
800fn cubic_bezier(t: f32, p0: Pos2, p1: Pos2, p2: Pos2, p3: Pos2) -> Pos2 {
801    let mt = 1.0 - t;
802    let mt2 = mt * mt;
803    let t2 = t * t;
804    Pos2::new(
805        mt2 * mt * p0.x + 3.0 * mt2 * t * p1.x + 3.0 * mt * t2 * p2.x + t2 * t * p3.x,
806        mt2 * mt * p0.y + 3.0 * mt2 * t * p1.y + 3.0 * mt * t2 * p2.y + t2 * t * p3.y,
807    )
808}
809
810fn dist_to_segment(p: Pos2, a: Pos2, b: Pos2) -> f32 {
811    let ab = b - a;
812    let len_sq = ab.length_sq();
813    if len_sq < 1e-6 {
814        return (p - a).length();
815    }
816    let t = ((p - a).dot(ab) / len_sq).clamp(0.0, 1.0);
817    let closest = a + ab * t;
818    (p - closest).length()
819}
820
821fn with_alpha(c: Color32, a: u8) -> Color32 {
822    Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), a)
823}