egui-elegance 0.1.2

Elegant, opinionated widgets for egui: buttons, inputs, selects, cards, tabs and more. Paired dark/light themes.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
//! Node-editor-style one-to-one pairing widget.
//!
//! See [`Pairing`] for the full interaction model and an example.

use egui::{
    epaint::CubicBezierShape, Align2, Color32, CornerRadius, FontId, Id, Pos2, Rect, Response,
    Sense, Shape, Stroke, StrokeKind, Ui, Vec2,
};
use std::hash::Hash;

use crate::theme::{Palette, Theme, Typography};

/// Maximum number of items supported per side. Layout uses fixed-size stack
/// buffers of this length so no heap allocation happens per frame; exceeding
/// this cap panics with a clear message.
const MAX_ROWS: usize = 64;

/// A single item rendered in either column of a [`Pairing`] widget.
#[derive(Clone, Debug)]
pub struct PairItem {
    /// Stable identifier. Used as the link key when pairing items across sides.
    pub id: String,
    /// Primary label shown on the node.
    pub name: String,
    /// Optional secondary text rendered below the name.
    pub detail: Option<String>,
    /// Optional leading-edge glyph rendered in a small rounded box.
    pub icon: Option<String>,
}

impl PairItem {
    /// Create a new item with a stable id and display name.
    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
        Self {
            id: id.into(),
            name: name.into(),
            detail: None,
            icon: None,
        }
    }

    /// Set the secondary detail line.
    pub fn detail(mut self, detail: impl Into<String>) -> Self {
        self.detail = Some(detail.into());
        self
    }

    /// Set the leading icon glyph.
    ///
    /// Rendered with the default proportional font. The bundled
    /// `Elegance Symbols` fallback font only covers arrows (`← ↑ → ↓ ↩ ↲ ↵`),
    /// ellipses (`⋮ ⋯`), modifier keys (`⌃ ⌘ ⌥ ⌫ ⌦`), triangles (`▴ ▸ ▾ ◂`)
    /// and `✓ ✗`. Glyphs outside that set (e.g. `◈`, `↗`) may render as tofu
    /// unless the host app has registered a font that covers them.
    pub fn icon(mut self, icon: impl Into<String>) -> Self {
        self.icon = Some(icon.into());
        self
    }
}

/// Which column a node lives in.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Side {
    Left,
    Right,
}

impl Side {
    fn opposite(self) -> Self {
        match self {
            Side::Left => Side::Right,
            Side::Right => Side::Left,
        }
    }
}

/// Cross-frame state — just the currently-selected source node, if any.
/// Snap target is recomputed every frame from hover responses.
#[derive(Clone, Debug, Default)]
struct State {
    selection: Option<(Side, String)>,
}

impl State {
    fn clone_for_storage(&self) -> Self {
        self.clone()
    }
}

/// A widget that lets users connect items in two lists with 1:1 pairings,
/// drawn as bezier curves between ports on each node.
///
/// # Interaction
///
/// - **Click a port** to enter pairing mode — a dashed ghost line follows the cursor.
/// - **Click an opposite-side port** to complete the pairing. If that target was
///   already paired, its previous pairing is broken first (swap).
/// - **Hover an opposite-side node** while pairing and the ghost line latches
///   to that node's port.
/// - **Click a paired node** to break its connection *and* immediately start a
///   new pairing from it, so reconnecting is a single click.
/// - **Click a pair's line** to remove it.
/// - **Escape** or **click the background** cancels a pending selection.
///
/// # State
///
/// Pairings are stored in the caller-owned `Vec<(String, String)>` passed to
/// [`Pairing::new`]. Each element is `(left_id, right_id)`. The transient
/// selection state is stored in egui memory keyed by the widget's `id_salt`.
///
/// # Limits
///
/// Each side supports up to 64 items. Exceeding this panics — the widget
/// uses fixed-size stack buffers for zero-allocation layout. For larger
/// data sets, split the lists across multiple `Pairing` widgets.
///
/// # Example
///
/// ```no_run
/// # use elegance::{Pairing, PairItem};
/// # egui::__run_test_ui(|ui| {
/// let clients = vec![
///     PairItem::new("c1", "worker-pool-a").detail("24 instances"),
///     PairItem::new("c2", "edge-proxy-01").detail("8 instances"),
/// ];
/// let servers = vec![
///     PairItem::new("s1", "api-east-01").detail("10.0.1.5 · us-east"),
///     PairItem::new("s2", "api-west-01").detail("10.0.2.4 · us-west"),
/// ];
/// let mut pairs: Vec<(String, String)> = vec![];
/// Pairing::new("client-server", &clients, &servers, &mut pairs)
///     .left_label("Clients")
///     .right_label("Servers")
///     .show(ui);
/// # });
/// ```
#[must_use = "Call `.show(ui)` to render the pairing widget."]
pub struct Pairing<'a> {
    id_salt: Id,
    left: &'a [PairItem],
    right: &'a [PairItem],
    pairs: &'a mut Vec<(String, String)>,
    left_label: Option<String>,
    right_label: Option<String>,
    height: Option<f32>,
    align: Option<Side>,
}

impl<'a> std::fmt::Debug for Pairing<'a> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Pairing")
            .field("id_salt", &self.id_salt)
            .field("left", &self.left.len())
            .field("right", &self.right.len())
            .field("pairs", &self.pairs.len())
            .field("left_label", &self.left_label)
            .field("right_label", &self.right_label)
            .field("height", &self.height)
            .field("align", &self.align)
            .finish()
    }
}

impl<'a> Pairing<'a> {
    /// Create a new pairing widget.
    ///
    /// * `id_salt` — a stable salt for this widget's memory state. Different
    ///   `Pairing` widgets on the same window must use distinct salts.
    /// * `left`, `right` — items shown in each column.
    /// * `pairs` — the caller-owned list of `(left_id, right_id)` tuples. It
    ///   is mutated when the user creates or removes a pairing.
    pub fn new(
        id_salt: impl Hash,
        left: &'a [PairItem],
        right: &'a [PairItem],
        pairs: &'a mut Vec<(String, String)>,
    ) -> Self {
        Self {
            id_salt: Id::new(("elegance_pairing", id_salt)),
            left,
            right,
            pairs,
            left_label: None,
            right_label: None,
            height: None,
            align: None,
        }
    }

    /// Set the label shown above the left column.
    pub fn left_label(mut self, label: impl Into<String>) -> Self {
        self.left_label = Some(label.into());
        self
    }

    /// Set the label shown above the right column.
    pub fn right_label(mut self, label: impl Into<String>) -> Self {
        self.right_label = Some(label.into());
        self
    }

    /// Override the total widget height. By default the widget sizes itself
    /// to fit the longer of the two columns.
    pub fn height(mut self, height: f32) -> Self {
        self.height = Some(height);
        self
    }

    /// Auto-arrange the left column so every pairing renders as a straight
    /// horizontal line. The right column keeps its caller-given order;
    /// unpaired items on the left fill the remaining slots in input order.
    pub fn align_left(mut self) -> Self {
        self.align = Some(Side::Left);
        self
    }

    /// Auto-arrange the right column so every pairing renders as a straight
    /// horizontal line. The left column keeps its caller-given order;
    /// unpaired items on the right fill the remaining slots in input order.
    pub fn align_right(mut self) -> Self {
        self.align = Some(Side::Right);
        self
    }

    /// Render the widget and handle interaction.
    pub fn show(self, ui: &mut Ui) -> Response {
        let Pairing {
            id_salt,
            left,
            right,
            pairs,
            left_label,
            right_label,
            height,
            align,
        } = self;

        assert!(
            left.len() <= MAX_ROWS && right.len() <= MAX_ROWS,
            "Pairing widget supports up to {} items per side (got left={}, right={})",
            MAX_ROWS,
            left.len(),
            right.len()
        );

        let theme = Theme::current(ui.ctx());

        const NODE_HEIGHT: f32 = 56.0;
        const NODE_GAP: f32 = 8.0;
        const LABEL_HEIGHT: f32 = 26.0;
        const PORT_RADIUS: f32 = 5.0;
        const MIN_COL_GAP: f32 = 80.0;
        const LINE_HIT_THRESHOLD: f32 = 6.0;

        // Layout.
        let has_label = left_label.is_some() || right_label.is_some();
        let rows = left.len().max(right.len());
        let content_h = (if has_label { LABEL_HEIGHT } else { 0.0 })
            + if rows > 0 {
                rows as f32 * (NODE_HEIGHT + NODE_GAP) - NODE_GAP
            } else {
                0.0
            };
        let widget_h = height.unwrap_or(content_h + theme.card_padding * 2.0);

        // Allocate the outer rect. Click on this (when no child consumes) =
        // background click = cancel.
        let (outer_rect, response) =
            ui.allocate_exact_size(Vec2::new(ui.available_width(), widget_h), Sense::click());

        let inner = outer_rect.shrink(theme.card_padding);
        let col_gap = MIN_COL_GAP.max(inner.width() * 0.12);
        let col_w = ((inner.width() - col_gap) * 0.5).max(120.0);
        let left_x = inner.left();
        let right_x = inner.right() - col_w;
        let nodes_top = if has_label {
            inner.top() + LABEL_HEIGHT
        } else {
            inner.top()
        };

        // Load persistent state and prune stale selections.
        let mut state: State = ui.ctx().data(|d| d.get_temp(id_salt).unwrap_or_default());
        if let Some((side, id)) = state.selection.clone() {
            let exists = match side {
                Side::Left => left.iter().any(|i| i.id == id),
                Side::Right => right.iter().any(|i| i.id == id),
            };
            if !exists {
                state.selection = None;
            }
        }

        // Compute visual row positions. When no alignment is set we use
        // identity (row i → row i) without any buffer. When aligned, one
        // side writes its reordered positions into a stack buffer. Neither
        // path touches the heap.
        let mut left_buf = [0usize; MAX_ROWS];
        let mut right_buf = [0usize; MAX_ROWS];
        let left_positions: Option<&[usize]> = if align == Some(Side::Left) {
            compute_aligned_positions(left, right, pairs, false, &mut left_buf);
            Some(&left_buf[..left.len()])
        } else {
            None
        };
        let right_positions: Option<&[usize]> = if align == Some(Side::Right) {
            compute_aligned_positions(right, left, pairs, true, &mut right_buf);
            Some(&right_buf[..right.len()])
        } else {
            None
        };

        // Allocate node rects and collect interaction responses.
        let mut hits: Vec<NodeHit> = Vec::with_capacity(left.len() + right.len());
        for (i, item) in left.iter().enumerate() {
            let vis = left_positions.map_or(i, |p| p[i]);
            let top = nodes_top + vis as f32 * (NODE_HEIGHT + NODE_GAP);
            let r = Rect::from_min_size(Pos2::new(left_x, top), Vec2::new(col_w, NODE_HEIGHT));
            let port = Pos2::new(r.right(), r.center().y);
            let resp = ui.interact(r, id_salt.with(("L", &item.id)), Sense::click());
            hits.push(NodeHit {
                side: Side::Left,
                id: item.id.clone(),
                rect: r,
                port,
                resp,
            });
        }
        for (i, item) in right.iter().enumerate() {
            let vis = right_positions.map_or(i, |p| p[i]);
            let top = nodes_top + vis as f32 * (NODE_HEIGHT + NODE_GAP);
            let r = Rect::from_min_size(Pos2::new(right_x, top), Vec2::new(col_w, NODE_HEIGHT));
            let port = Pos2::new(r.left(), r.center().y);
            let resp = ui.interact(r, id_salt.with(("R", &item.id)), Sense::click());
            hits.push(NodeHit {
                side: Side::Right,
                id: item.id.clone(),
                rect: r,
                port,
                resp,
            });
        }

        // Snap target = opposite-side hovered node (only while a selection is active).
        let snap_target: Option<(Side, String)> = state.selection.as_ref().and_then(|(ss, _)| {
            let opp = ss.opposite();
            hits.iter()
                .find(|h| h.side == opp && h.resp.hovered())
                .map(|h| (h.side, h.id.clone()))
        });

        // Clicks.
        let node_click = hits
            .iter()
            .find(|h| h.resp.clicked())
            .map(|h| (h.side, h.id.clone()));
        if let Some((side, id)) = node_click {
            handle_node_click(&mut state, side, &id, pairs);
        } else {
            // Check for clicks on existing pair lines; if none, fall back to
            // background-click cancel.
            let pointer = ui.input(|i| i.pointer.hover_pos());
            let pressed = ui.input(|i| i.pointer.primary_clicked());
            let mut consumed = false;
            if pressed {
                if let Some(m) = pointer {
                    if outer_rect.contains(m) {
                        let mut remove = None;
                        for (idx, (lid, rid)) in pairs.iter().enumerate() {
                            if let (Some(lp), Some(rp)) = (
                                port_of(&hits, Side::Left, lid),
                                port_of(&hits, Side::Right, rid),
                            ) {
                                if bezier_hit(m, lp, rp, LINE_HIT_THRESHOLD) {
                                    remove = Some(idx);
                                    break;
                                }
                            }
                        }
                        if let Some(i) = remove {
                            pairs.remove(i);
                            state.selection = None;
                            consumed = true;
                        }
                    }
                }
            }
            if !consumed && response.clicked() {
                state.selection = None;
            }
        }

        if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
            state.selection = None;
        }

        // Paint.
        if ui.is_rect_visible(outer_rect) {
            let painter = ui.painter();
            let palette = &theme.palette;
            let typo = &theme.typography;

            // Card background + border.
            painter.rect(
                outer_rect,
                CornerRadius::same(theme.card_radius as u8),
                palette.card,
                Stroke::new(1.0, palette.border),
                StrokeKind::Inside,
            );

            // Dot grid.
            paint_grid(painter, outer_rect, palette);

            // Column labels.
            if let Some(lbl) = &left_label {
                painter.text(
                    Pos2::new(left_x + 2.0, inner.top()),
                    Align2::LEFT_TOP,
                    lbl,
                    FontId::proportional(typo.label),
                    palette.text_muted,
                );
            }
            if let Some(lbl) = &right_label {
                painter.text(
                    Pos2::new(right_x + 2.0, inner.top()),
                    Align2::LEFT_TOP,
                    lbl,
                    FontId::proportional(typo.label),
                    palette.text_muted,
                );
            }

            // Existing pair lines (solid, sky).
            let line_stroke = Stroke::new(2.0, palette.sky);
            for (lid, rid) in pairs.iter() {
                if let (Some(lp), Some(rp)) = (
                    port_of(&hits, Side::Left, lid),
                    port_of(&hits, Side::Right, rid),
                ) {
                    paint_bezier(painter, lp, rp, line_stroke, false);
                }
            }

            // Ghost line while selecting.
            if let Some((sel_side, sel_id)) = &state.selection {
                if let Some(src) = port_of(&hits, *sel_side, sel_id) {
                    let end = snap_target
                        .as_ref()
                        .and_then(|(s, i)| port_of(&hits, *s, i))
                        .or_else(|| {
                            ui.input(|i| i.pointer.hover_pos())
                                .filter(|p| outer_rect.contains(*p))
                        });
                    if let Some(e) = end {
                        let ghost_stroke = Stroke::new(1.75, with_alpha(palette.sky, 140));
                        paint_bezier(painter, src, e, ghost_stroke, true);
                        if snap_target.is_none() {
                            painter.circle_filled(e, 3.5, with_alpha(palette.text_muted, 165));
                        }
                    }
                    // Keep the ghost tracking the cursor.
                    ui.ctx().request_repaint();
                }
            }

            // Nodes on top of lines.
            for h in &hits {
                let item = match h.side {
                    Side::Left => left.iter().find(|i| i.id == h.id),
                    Side::Right => right.iter().find(|i| i.id == h.id),
                };
                let Some(item) = item else {
                    continue;
                };
                let selected = state
                    .selection
                    .as_ref()
                    .is_some_and(|(s, i)| *s == h.side && i == &h.id);
                let is_snap = snap_target
                    .as_ref()
                    .is_some_and(|(s, i)| *s == h.side && i == &h.id);
                let paired = is_paired(pairs, h.side, &h.id);
                paint_node(
                    painter,
                    h.rect,
                    h.port,
                    item,
                    selected,
                    is_snap,
                    paired,
                    h.resp.hovered(),
                    palette,
                    typo,
                    theme.control_radius,
                    PORT_RADIUS,
                );
            }
        }

        // Save state.
        ui.ctx()
            .data_mut(|d| d.insert_temp(id_salt, state.clone_for_storage()));
        response
    }
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

struct NodeHit {
    side: Side,
    id: String,
    rect: Rect,
    port: Pos2,
    resp: Response,
}

fn port_of(hits: &[NodeHit], side: Side, id: &str) -> Option<Pos2> {
    hits.iter()
        .find(|h| h.side == side && h.id == id)
        .map(|h| h.port)
}

/// Assign each aligned-side item to a visual row so paired items land on
/// the same row as their partner on the *other* side. Unpaired items on the
/// aligned side fill the remaining rows in their input order.
///
/// Writes the result into the first `aligned.len()` entries of `positions`.
/// Stack-only — uses no heap allocation.
fn compute_aligned_positions(
    aligned: &[PairItem],
    other: &[PairItem],
    pairs: &[(String, String)],
    aligned_is_right: bool,
    positions: &mut [usize; MAX_ROWS],
) {
    let n_aligned = aligned.len();
    let max_pos = n_aligned.max(other.len());

    // Sentinel for "not yet placed".
    for p in positions.iter_mut().take(n_aligned) {
        *p = usize::MAX;
    }

    let mut slot_taken = [false; MAX_ROWS];

    // Anchor paired aligned items to their partner's visual row.
    for (other_idx, other_item) in other.iter().enumerate() {
        let partner_id: Option<&String> = pairs.iter().find_map(|(l, r)| {
            if aligned_is_right {
                (l == &other_item.id).then_some(r)
            } else {
                (r == &other_item.id).then_some(l)
            }
        });
        if let Some(pid) = partner_id {
            if let Some(ai) = aligned.iter().position(|a| &a.id == pid) {
                if other_idx < max_pos && !slot_taken[other_idx] && positions[ai] == usize::MAX {
                    positions[ai] = other_idx;
                    slot_taken[other_idx] = true;
                }
            }
        }
    }

    // Fill remaining aligned items into free rows, preserving input order.
    let mut free_slots = (0..max_pos).filter(|s| !slot_taken[*s]);
    for pos in positions.iter_mut().take(n_aligned) {
        if *pos == usize::MAX {
            *pos = free_slots.next().unwrap_or(0);
        }
    }
}

fn is_paired(pairs: &[(String, String)], side: Side, id: &str) -> bool {
    match side {
        Side::Left => pairs.iter().any(|(l, _)| l == id),
        Side::Right => pairs.iter().any(|(_, r)| r == id),
    }
}

fn handle_node_click(state: &mut State, side: Side, id: &str, pairs: &mut Vec<(String, String)>) {
    let paired = is_paired(pairs, side, id);
    let sel = state.selection.clone();

    // Re-clicking the selected node cancels.
    if let Some((s, sid)) = &sel {
        if *s == side && sid == id {
            state.selection = None;
            return;
        }
    }

    // Opposite-side click → pair (swap if target was already paired).
    if let Some((sel_side, sel_id)) = &sel {
        if *sel_side != side {
            if paired {
                pairs.retain(|(l, r)| match side {
                    Side::Left => l != id,
                    Side::Right => r != id,
                });
            }
            let pair = match side {
                Side::Left => (id.to_string(), sel_id.clone()),
                Side::Right => (sel_id.clone(), id.to_string()),
            };
            pairs.push(pair);
            state.selection = None;
            return;
        }
    }

    // Otherwise (no selection, or same-side click): unpair if needed, then select.
    // One click breaks the existing connection AND starts a new one.
    if paired {
        pairs.retain(|(l, r)| match side {
            Side::Left => l != id,
            Side::Right => r != id,
        });
    }
    state.selection = Some((side, id.to_string()));
}

fn paint_grid(painter: &egui::Painter, rect: Rect, palette: &Palette) {
    let step = 22.0;
    let dot = with_alpha(palette.text, 12);
    let mut y = rect.top() + step;
    while y < rect.bottom() {
        let mut x = rect.left() + step;
        while x < rect.right() {
            painter.circle_filled(Pos2::new(x, y), 0.75, dot);
            x += step;
        }
        y += step;
    }
}

#[allow(clippy::too_many_arguments)]
fn paint_node(
    painter: &egui::Painter,
    rect: Rect,
    port: Pos2,
    item: &PairItem,
    selected: bool,
    snap_target: bool,
    paired: bool,
    hovered: bool,
    palette: &Palette,
    typo: &Typography,
    radius: f32,
    port_radius: f32,
) {
    let r = CornerRadius::same(radius as u8);

    // Background + border (fold into one rect() call).
    let border = if selected || snap_target {
        palette.sky
    } else if hovered {
        palette.text_muted
    } else {
        palette.border
    };
    painter.rect(
        rect,
        r,
        palette.input_bg,
        Stroke::new(1.0, border),
        StrokeKind::Inside,
    );

    // Content.
    let pad_x = 14.0;
    let mut content_x = rect.left() + pad_x;

    // Optional icon box.
    if let Some(icon) = &item.icon {
        let box_size = 28.0;
        let icon_rect = Rect::from_min_size(
            Pos2::new(content_x, rect.center().y - box_size * 0.5),
            Vec2::splat(box_size),
        );
        painter.rect(
            icon_rect,
            r,
            palette.card,
            Stroke::new(1.0, palette.border),
            StrokeKind::Inside,
        );
        painter.text(
            icon_rect.center(),
            Align2::CENTER_CENTER,
            icon,
            FontId::proportional(13.0),
            palette.text_muted,
        );
        content_x += box_size + 12.0;
    }

    // Name + optional detail.
    if let Some(detail) = &item.detail {
        painter.text(
            Pos2::new(content_x, rect.top() + 11.0),
            Align2::LEFT_TOP,
            &item.name,
            FontId::proportional(typo.body),
            palette.text,
        );
        painter.text(
            Pos2::new(content_x, rect.top() + 31.0),
            Align2::LEFT_TOP,
            detail,
            FontId::proportional(typo.small),
            palette.text_faint,
        );
    } else {
        painter.text(
            Pos2::new(content_x, rect.center().y),
            Align2::LEFT_CENTER,
            &item.name,
            FontId::proportional(typo.body),
            palette.text,
        );
    }

    // Port.
    let active = selected || snap_target || paired;
    let port_fill = if active {
        palette.sky
    } else {
        palette.input_bg
    };
    let port_stroke = if active || hovered {
        palette.sky
    } else {
        palette.border
    };
    painter.circle_filled(port, port_radius, port_fill);
    painter.circle_stroke(port, port_radius, Stroke::new(1.5, port_stroke));
    if active {
        painter.circle_stroke(
            port,
            port_radius + 3.0,
            Stroke::new(3.0, with_alpha(palette.sky, 46)),
        );
    }
}

fn paint_bezier(painter: &egui::Painter, start: Pos2, end: Pos2, stroke: Stroke, dashed: bool) {
    let mid_x = (start.x + end.x) * 0.5;
    let c1 = Pos2::new(mid_x, start.y);
    let c2 = Pos2::new(mid_x, end.y);

    if !dashed {
        let shape = CubicBezierShape::from_points_stroke(
            [start, c1, c2, end],
            false,
            Color32::TRANSPARENT,
            stroke,
        );
        painter.add(Shape::CubicBezier(shape));
        return;
    }

    // Dashed: sample the curve and draw short, alternating segment groups.
    const SAMPLES: usize = 40;
    const DASH_N: usize = 2; // segments per dash; gap = DASH_N segments
    let pts: Vec<Pos2> = (0..=SAMPLES)
        .map(|i| cubic_bezier(i as f32 / SAMPLES as f32, start, c1, c2, end))
        .collect();
    let period = DASH_N * 2;
    let mut i = 0;
    while i + DASH_N < pts.len() {
        for j in 0..DASH_N {
            painter.line_segment([pts[i + j], pts[i + j + 1]], stroke);
        }
        i += period;
    }
}

fn bezier_hit(point: Pos2, start: Pos2, end: Pos2, threshold: f32) -> bool {
    let mid_x = (start.x + end.x) * 0.5;
    let c1 = Pos2::new(mid_x, start.y);
    let c2 = Pos2::new(mid_x, end.y);
    const SAMPLES: usize = 30;
    let mut prev = start;
    for i in 1..=SAMPLES {
        let t = i as f32 / SAMPLES as f32;
        let p = cubic_bezier(t, start, c1, c2, end);
        if dist_to_segment(point, prev, p) < threshold {
            return true;
        }
        prev = p;
    }
    false
}

fn cubic_bezier(t: f32, p0: Pos2, p1: Pos2, p2: Pos2, p3: Pos2) -> Pos2 {
    let mt = 1.0 - t;
    let mt2 = mt * mt;
    let t2 = t * t;
    Pos2::new(
        mt2 * mt * p0.x + 3.0 * mt2 * t * p1.x + 3.0 * mt * t2 * p2.x + t2 * t * p3.x,
        mt2 * mt * p0.y + 3.0 * mt2 * t * p1.y + 3.0 * mt * t2 * p2.y + t2 * t * p3.y,
    )
}

fn dist_to_segment(p: Pos2, a: Pos2, b: Pos2) -> f32 {
    let ab = b - a;
    let len_sq = ab.length_sq();
    if len_sq < 1e-6 {
        return (p - a).length();
    }
    let t = ((p - a).dot(ab) / len_sq).clamp(0.0, 1.0);
    let closest = a + ab * t;
    (p - closest).length()
}

fn with_alpha(c: Color32, a: u8) -> Color32 {
    Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), a)
}