1use 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
13const MAX_ROWS: usize = 64;
17
18#[derive(Clone, Debug)]
20pub struct PairItem {
21 pub id: String,
23 pub name: String,
25 pub detail: Option<String>,
27 pub icon: Option<String>,
29}
30
31impl PairItem {
32 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 pub fn detail(mut self, detail: impl Into<String>) -> Self {
44 self.detail = Some(detail.into());
45 self
46 }
47
48 pub fn icon(mut self, icon: impl Into<String>) -> Self {
56 self.icon = Some(icon.into());
57 self
58 }
59}
60
61#[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#[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#[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 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 pub fn left_label(mut self, label: impl Into<String>) -> Self {
192 self.left_label = Some(label.into());
193 self
194 }
195
196 pub fn right_label(mut self, label: impl Into<String>) -> Self {
198 self.right_label = Some(label.into());
199 self
200 }
201
202 pub fn height(mut self, height: f32) -> Self {
205 self.height = Some(height);
206 self
207 }
208
209 pub fn align_left(mut self) -> Self {
213 self.align = Some(Side::Left);
214 self
215 }
216
217 pub fn align_right(mut self) -> Self {
221 self.align = Some(Side::Right);
222 self
223 }
224
225 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 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 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 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 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 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 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 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 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 if ui.is_rect_visible(outer_rect) {
399 let painter = ui.painter();
400 let palette = &theme.palette;
401 let typo = &theme.typography;
402
403 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 paint_grid(painter, outer_rect, palette);
414
415 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 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 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 ui.ctx().request_repaint();
465 }
466 }
467
468 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 ui.ctx()
504 .data_mut(|d| d.insert_temp(id_salt, state.clone_for_storage()));
505 response
506 }
507}
508
509struct 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
527fn 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 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 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 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 if let Some((s, sid)) = &sel {
591 if *s == side && sid == id {
592 state.selection = None;
593 return;
594 }
595 }
596
597 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 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 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 let pad_x = 14.0;
676 let mut content_x = rect.left() + pad_x;
677
678 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 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 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 const SAMPLES: usize = 40;
769 const DASH_N: usize = 2; 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}