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};
const MAX_ROWS: usize = 64;
#[derive(Clone, Debug)]
pub struct PairItem {
pub id: String,
pub name: String,
pub detail: Option<String>,
pub icon: Option<String>,
}
impl PairItem {
pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
Self {
id: id.into(),
name: name.into(),
detail: None,
icon: None,
}
}
pub fn detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
pub fn icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
}
#[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,
}
}
}
#[derive(Clone, Debug, Default)]
struct State {
selection: Option<(Side, String)>,
}
impl State {
fn clone_for_storage(&self) -> Self {
self.clone()
}
}
#[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> {
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,
}
}
pub fn left_label(mut self, label: impl Into<String>) -> Self {
self.left_label = Some(label.into());
self
}
pub fn right_label(mut self, label: impl Into<String>) -> Self {
self.right_label = Some(label.into());
self
}
pub fn height(mut self, height: f32) -> Self {
self.height = Some(height);
self
}
pub fn align_left(mut self) -> Self {
self.align = Some(Side::Left);
self
}
pub fn align_right(mut self) -> Self {
self.align = Some(Side::Right);
self
}
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;
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);
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()
};
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;
}
}
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
};
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,
});
}
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()))
});
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 {
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;
}
if ui.is_rect_visible(outer_rect) {
let painter = ui.painter();
let palette = &theme.palette;
let typo = &theme.typography;
painter.rect(
outer_rect,
CornerRadius::same(theme.card_radius as u8),
palette.card,
Stroke::new(1.0, palette.border),
StrokeKind::Inside,
);
paint_grid(painter, outer_rect, palette);
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,
);
}
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);
}
}
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));
}
}
ui.ctx().request_repaint();
}
}
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,
);
}
}
ui.ctx()
.data_mut(|d| d.insert_temp(id_salt, state.clone_for_storage()));
response
}
}
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)
}
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());
for p in positions.iter_mut().take(n_aligned) {
*p = usize::MAX;
}
let mut slot_taken = [false; MAX_ROWS];
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;
}
}
}
}
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();
if let Some((s, sid)) = &sel {
if *s == side && sid == id {
state.selection = None;
return;
}
}
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;
}
}
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);
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,
);
let pad_x = 14.0;
let mut content_x = rect.left() + pad_x;
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;
}
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,
);
}
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;
}
const SAMPLES: usize = 40;
const DASH_N: usize = 2; 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)
}