use egui::PointerButton;
use super::{
layout::{NodeLayout, STACK_HEADER_H, StackLayout, port_grab_radius_world},
response::GraphAction,
state::{CanvasDrag, CanvasItem, DragItem, GraphView, RIGHT_CLICK_MAX_SECS, ReorderDrag},
transform::{Transform, WorldPos, WorldRect},
viewer::{GraphViewer, Link, LinkVerdict, NodeId, PortAddr, PortSide, StackId},
};
fn node_at(layouts: &[NodeLayout], w: WorldPos) -> Option<NodeId> {
layouts
.iter()
.rev()
.find(|n| n.rect.contains(w))
.map(|n| n.id)
}
fn stack_header_at(stacks: &[StackLayout], w: WorldPos) -> Option<(StackId, WorldPos)> {
stacks.iter().rev().find_map(|s| {
let header = WorldRect::new(s.rect.min, s.rect.width, STACK_HEADER_H);
header.contains(w).then_some((s.id, s.rect.min))
})
}
enum Grab {
Node(NodeId),
Header(StackId, WorldPos),
}
fn node_or_header_at(
view: &GraphView,
layouts: &[NodeLayout],
stacks: &[StackLayout],
w: WorldPos,
) -> Option<Grab> {
let node = node_at(layouts, w);
let header = stack_header_at(stacks, w);
let node_z = node.map(|n| {
let item = layouts
.iter()
.find(|l| l.id == n)
.and_then(|l| l.stack)
.map(CanvasItem::Stack)
.unwrap_or(CanvasItem::Node(n));
view.z_key(item)
});
let header_z = header.map(|(s, _)| view.z_key(CanvasItem::Stack(s)));
match (node, header) {
(Some(n), Some((s, o))) => {
if header_z > node_z {
Some(Grab::Header(s, o))
} else {
Some(Grab::Node(n))
}
}
(Some(n), None) => Some(Grab::Node(n)),
(None, Some((s, o))) => Some(Grab::Header(s, o)),
(None, None) => None,
}
}
fn stack_add_button_at(stacks: &[StackLayout], w: WorldPos) -> Option<StackId> {
stacks
.iter()
.rev()
.find_map(|s| s.add_button.contains(w).then_some(s.id))
}
fn stack_collapse_all_at(stacks: &[StackLayout], w: WorldPos) -> Option<StackId> {
stacks
.iter()
.rev()
.find_map(|s| s.collapse_all_button.contains(w).then_some(s.id))
}
fn close_button_at(layouts: &[NodeLayout], w: WorldPos) -> Option<NodeId> {
layouts
.iter()
.rev()
.find_map(|n| n.close_button.filter(|r| r.contains(w)).map(|_| n.id))
}
fn collapse_toggle_at(layouts: &[NodeLayout], w: WorldPos) -> Option<NodeId> {
layouts
.iter()
.rev()
.find_map(|n| n.collapse_toggle.filter(|r| r.contains(w)).map(|_| n.id))
}
fn reorder_target_index(
layouts: &[NodeLayout],
stack: StackId,
dragged: NodeId,
cursor_y: f64,
) -> usize {
layouts
.iter()
.filter(|n| n.stack == Some(stack) && n.id != dragged)
.filter(|n| n.rect.center().y < cursor_y)
.count()
}
fn port_at(
layouts: &[NodeLayout],
t: &Transform,
w: WorldPos,
side: PortSide,
) -> Option<(PortAddr, WorldPos)> {
let radius = port_grab_radius_world(t);
let r2 = radius * radius;
for node in layouts.iter().rev() {
if node.collapsed {
continue;
}
let ports = match side {
PortSide::Input => &node.inputs,
PortSide::Output => &node.outputs,
};
for p in ports {
if !p.connectable {
continue;
}
if p.center.distance_squared(w) <= r2 {
return Some((PortAddr::new(node.id, p.id), p.center));
}
}
}
None
}
fn port_at_any(layouts: &[NodeLayout], t: &Transform, w: WorldPos) -> Option<(PortAddr, WorldPos)> {
port_at(layouts, t, w, PortSide::Output).or_else(|| port_at(layouts, t, w, PortSide::Input))
}
fn link_curve_points(layouts: &[NodeLayout], link: &Link) -> Option<[WorldPos; 4]> {
let by = |id: NodeId| layouts.iter().find(|n| n.id == id);
let from = by(link.from.node)?.port_center(link.from.port)?;
let to = by(link.to.node)?.port_center(link.to.port)?;
let handle = ((to.x - from.x).abs() * 0.5).clamp(24.0, 160.0);
Some([
from,
from + WorldPos::new(handle, 0.0),
to - WorldPos::new(handle, 0.0),
to,
])
}
fn bezier_at(p: &[WorldPos; 4], s: f64) -> WorldPos {
let mt = 1.0 - s;
p[0] * (mt * mt * mt)
+ p[1] * (3.0 * mt * mt * s)
+ p[2] * (3.0 * mt * s * s)
+ p[3] * (s * s * s)
}
fn link_distance(layouts: &[NodeLayout], link: &Link, w: WorldPos) -> Option<f64> {
let p = link_curve_points(layouts, link)?;
let mut best = f64::INFINITY;
let steps = 18;
for i in 0..=steps {
let s = i as f64 / steps as f64;
best = best.min(bezier_at(&p, s).distance_squared(w));
}
Some(best.sqrt())
}
fn link_in_rect(layouts: &[NodeLayout], link: &Link, rect: WorldRect) -> bool {
let Some(p) = link_curve_points(layouts, link) else {
return false;
};
let steps = 18;
(0..=steps).any(|i| rect.contains(bezier_at(&p, i as f64 / steps as f64)))
}
fn link_at(
layouts: &[NodeLayout],
viewer: &dyn GraphViewer,
t: &Transform,
w: WorldPos,
) -> Option<Link> {
let threshold = t.screen_len_to_world(6.0);
viewer
.links()
.into_iter()
.filter_map(|l| link_distance(layouts, &l, w).map(|d| (l, d)))
.filter(|(_, d)| *d <= threshold)
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(l, _)| l)
}
#[derive(Debug, Clone)]
pub struct LinkTarget {
pub addr: PortAddr,
pub center: WorldPos,
pub verdict: LinkVerdict,
}
#[derive(Debug, Clone, Default)]
pub struct Hover {
pub node_body: Option<NodeId>,
pub node: Option<NodeId>,
pub stack: Option<StackId>,
pub add_button: Option<StackId>,
pub collapse_all: Option<StackId>,
pub close: Option<NodeId>,
pub port: Option<WorldPos>,
pub link_target: Option<LinkTarget>,
pub marquee: Vec<NodeId>,
pub marquee_stacks: Vec<StackId>,
pub marquee_links: Vec<Link>,
}
pub fn handle(
ui: &egui::Ui,
response: &egui::Response,
t: &Transform,
layouts: &[NodeLayout],
stacks: &[StackLayout],
viewer: &dyn GraphViewer,
view: &mut GraphView,
actions: &mut Vec<GraphAction>,
) -> Hover {
let hover_world = response.hover_pos().map(|p| t.screen_to_world(p));
let node_under_pointer = ui
.input(|input| input.pointer.hover_pos())
.filter(|pointer| response.rect.contains(*pointer))
.map(|pointer| t.screen_to_world(pointer))
.and_then(|world| node_at(layouts, world));
let hovered_port = hover_world.and_then(|w| port_at_any(layouts, t, w));
let hovered_node = if hovered_port.is_some() {
None
} else {
hover_world.and_then(|world| node_at(layouts, world))
};
let link_target = view.interaction.pending_link_from.and_then(|anchor| {
let w = hover_world?;
let from_input = view.interaction.pending_from_input;
let (target_side, anchor_side) = if from_input {
(PortSide::Output, PortSide::Input)
} else {
(PortSide::Input, PortSide::Output)
};
if let Some((cand, center)) = port_at(layouts, t, w, target_side) {
let (from, to) = if from_input {
(cand, anchor)
} else {
(anchor, cand)
};
return Some(LinkTarget {
addr: cand,
center,
verdict: viewer.validate_link(from, to),
});
}
if let Some((cand, center)) = port_at(layouts, t, w, anchor_side) {
if cand != anchor {
let reason = if from_input {
"can't connect two inputs"
} else {
"can't connect two outputs"
};
return Some(LinkTarget {
addr: cand,
center,
verdict: Err(reason.into()),
});
}
}
None
});
let dragging = view.interaction.canvas_drag.is_some() || view.interaction.reordering.is_some();
let hovered_stack = if dragging {
None
} else {
hover_world.and_then(|w| stack_header_at(stacks, w).map(|(id, _)| id))
};
let hovered_add_button = if dragging {
None
} else {
hover_world.and_then(|w| stack_add_button_at(stacks, w))
};
let hovered_collapse_all = if dragging {
None
} else {
hover_world.and_then(|w| stack_collapse_all_at(stacks, w))
};
let hovered_close = if dragging {
None
} else {
hover_world.and_then(|w| close_button_at(layouts, w))
};
let hovered_toggle = if dragging {
None
} else {
hover_world.and_then(|w| collapse_toggle_at(layouts, w))
};
if dragging {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
} else if matches!(&link_target, Some(lt) if lt.verdict.is_err()) {
ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed);
} else if hovered_close.is_some() || hovered_toggle.is_some() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
} else if hovered_port.is_some() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
} else if hovered_add_button.is_some() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
} else if hovered_collapse_all.is_some() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
} else if hovered_stack.is_some() || hovered_node.is_some() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
let pointer = ui.input(|i| i.pointer.hover_pos());
if let Some(cursor) = pointer.filter(|p| {
response.rect.contains(*p) && canvas_accepts_navigation(ui.ctx(), response.layer_id, *p)
}) {
let (scroll, zoom) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta()));
if scroll != egui::Vec2::ZERO {
view.pan -= t.screen_vec_to_world(scroll);
}
if zoom != 1.0 {
let w_before = t.screen_to_world(cursor);
view.set_zoom_clamped(view.zoom * zoom as f64);
let t2 = Transform::new(t.origin, view.pan, view.zoom);
let w_after = t2.screen_to_world(cursor);
view.pan += w_before - w_after;
}
}
if response.dragged_by(PointerButton::Secondary) || response.dragged_by(PointerButton::Middle) {
let d = response.drag_delta();
view.pan -= t.screen_vec_to_world(d);
}
if response.drag_started_by(PointerButton::Primary) {
if let Some(p) = ui
.input(|i| i.pointer.press_origin())
.or_else(|| response.interact_pointer_pos())
{
let w = t.screen_to_world(p);
let shift = ui.input(|i| i.modifiers.shift);
if stack_add_button_at(stacks, w).is_some() {
} else if stack_collapse_all_at(stacks, w).is_some() {
} else if collapse_toggle_at(layouts, w).is_some() {
} else if close_button_at(layouts, w).is_some() {
} else if let Some((addr, _)) = port_at(layouts, t, w, PortSide::Output) {
view.interaction.pending_link_from = Some(addr);
} else if let Some((addr, _)) = port_at(layouts, t, w, PortSide::Input) {
view.interaction.pending_link_from = Some(addr);
view.interaction.pending_from_input = true;
view.interaction.detaching_link = viewer.links().into_iter().find(|l| l.to == addr);
} else {
match node_or_header_at(view, layouts, stacks, w) {
Some(Grab::Node(node)) => {
let layout = layouts.iter().find(|n| n.id == node);
match layout.and_then(|n| n.stack) {
Some(sid) => {
view.raise(CanvasItem::Stack(sid));
if let Some((from_index, grab_offset)) = stacks
.iter()
.find(|s| s.id == sid)
.and_then(|s| s.members.iter().position(|m| *m == node))
.zip(layout.map(|n| w - n.rect.min))
{
view.interaction.reordering = Some(ReorderDrag {
stack: sid,
node,
from_index,
target_index: from_index,
grab_offset,
});
}
}
None => {
view.raise(CanvasItem::Node(node));
if !view.selection.contains(&node) {
if !shift {
view.clear_selection();
}
view.selection.insert(node);
actions.push(GraphAction::SelectionChanged);
}
view.interaction.canvas_drag =
Some(begin_canvas_drag(view, DragItem::Node(node), w));
}
}
}
Some(Grab::Header(stack, _origin)) => {
view.raise(CanvasItem::Stack(stack));
if !view.selected_stacks.contains(&stack) {
if !shift {
view.clear_selection();
}
view.selected_stacks.insert(stack);
actions.push(GraphAction::SelectionChanged);
}
view.interaction.canvas_drag =
Some(begin_canvas_drag(view, DragItem::Stack(stack), w));
}
None => {
if !shift {
if view.clear_selection() {
actions.push(GraphAction::SelectionChanged);
}
}
view.interaction.box_select_start = Some(w);
}
}
}
}
}
if response.dragged_by(PointerButton::Primary) {
if let (Some(drag), Some(p)) = (
view.interaction.canvas_drag.clone(),
response.interact_pointer_pos(),
) {
let mut new_primary = t.screen_to_world(p) - drag.grab_offset;
if view.grid.snap {
new_primary = view.grid.snap_pos(new_primary);
}
let delta = new_primary - drag.primary_origin;
for (id, origin) in &drag.nodes {
view.positions.insert(*id, *origin + delta);
}
for (id, origin) in &drag.stacks {
view.stack_positions.insert(*id, *origin + delta);
}
}
if let (Some(mut rd), Some(p)) =
(view.interaction.reordering, response.interact_pointer_pos())
{
let cursor_y = t.screen_to_world(p).y;
rd.target_index = reorder_target_index(layouts, rd.stack, rd.node, cursor_y);
view.interaction.reordering = Some(rd);
}
}
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
view.interaction.pending_link_from = None;
view.interaction.pending_from_input = false;
view.interaction.detaching_link = None;
view.interaction.box_select_start = None;
view.interaction.reordering = None;
if let Some(drag) = view.interaction.canvas_drag.take() {
for (id, origin) in &drag.nodes {
view.positions.insert(*id, *origin);
}
for (id, origin) in &drag.stacks {
view.stack_positions.insert(*id, *origin);
}
}
}
if response.drag_stopped_by(PointerButton::Primary) {
if let Some(drag) = view.interaction.canvas_drag.take() {
for (node, origin) in &drag.nodes {
actions.push(GraphAction::NodeMoved {
node: *node,
from: *origin,
to: view.position(*node),
});
}
for (stack, origin) in &drag.stacks {
actions.push(GraphAction::StackMoved {
stack: *stack,
from: *origin,
to: view.stack_position(*stack),
});
}
}
if let Some(rd) = view.interaction.reordering.take() {
if rd.target_index != rd.from_index {
actions.push(GraphAction::StackMemberMoved {
stack: rd.stack,
from_index: rd.from_index,
to_index: rd.target_index,
});
}
}
if let Some(from) = view.interaction.pending_link_from.take() {
let detached = view.interaction.detaching_link.take();
let from_input = std::mem::take(&mut view.interaction.pending_from_input);
let drop_world = response
.interact_pointer_pos()
.map(|p| t.screen_to_world(p));
let target_present = link_target.is_some();
let accepted = link_target
.as_ref()
.filter(|lt| lt.verdict.is_ok())
.map(|lt| lt.addr);
if from_input {
if let Some(out) = accepted {
actions.push(GraphAction::LinkRequested {
from: out,
to: from,
});
} else if let Some(old) = detached {
actions.push(GraphAction::LinkDeleteRequested { link: old });
} else if !target_present && let Some(at) = drop_world {
actions.push(GraphAction::LinkDropped {
source: from,
source_is_output: false,
at,
});
}
} else {
match (accepted, target_present) {
(Some(to), _) => {
actions.push(GraphAction::LinkRequested { from, to });
}
(None, true) => {}
(None, false) => {
if let Some(at) = drop_world {
actions.push(GraphAction::LinkDropped {
source: from,
source_is_output: true,
at,
});
}
}
}
}
}
if let Some(start) = view.interaction.box_select_start.take() {
if let Some(p) = response.interact_pointer_pos() {
let end = t.screen_to_world(p);
let size = (end - start).abs();
let rect = WorldRect::new(start.min(end), size.x, size.y);
for node in layouts {
if node.stack.is_none() && rects_intersect(rect, node.rect) {
view.selection.insert(node.id);
}
}
for stack in stacks {
if rects_intersect(rect, stack.rect) {
view.selected_stacks.insert(stack.id);
}
}
for link in viewer.links() {
if link_in_rect(layouts, &link, rect) {
view.selected_links.insert(link);
}
}
actions.push(GraphAction::SelectionChanged);
}
}
}
if response.clicked_by(PointerButton::Primary) {
if let Some(p) = response.interact_pointer_pos() {
let w = t.screen_to_world(p);
let shift = ui.input(|i| i.modifiers.shift);
if let Some(stack) = stack_add_button_at(stacks, w) {
actions.push(GraphAction::StackAddRequested { stack });
} else if let Some(sid) = stack_collapse_all_at(stacks, w) {
let members: Vec<NodeId> = layouts
.iter()
.filter(|n| n.stack == Some(sid) && n.collapse_toggle.is_some())
.map(|n| n.id)
.collect();
let collapse = members.iter().any(|id| !view.is_collapsed(*id));
for id in members {
if collapse {
view.collapsed.insert(id);
} else {
view.collapsed.remove(&id);
}
}
} else if let Some(node) = collapse_toggle_at(layouts, w) {
view.toggle_collapsed(node);
} else if let Some(node) = close_button_at(layouts, w) {
actions.push(GraphAction::NodesDeleteRequested { nodes: vec![node] });
} else if port_at(layouts, t, w, PortSide::Output).is_some()
|| port_at(layouts, t, w, PortSide::Input).is_some()
{
} else if let Some(grab) = node_or_header_at(view, layouts, stacks, w) {
match grab {
Grab::Node(node) => {
match layouts.iter().find(|n| n.id == node).and_then(|n| n.stack) {
Some(sid) => click_select_stack(view, sid, shift, actions),
None => click_select_node(view, node, shift, actions),
}
}
Grab::Header(stack, _) => click_select_stack(view, stack, shift, actions),
}
} else if let Some(link) = link_at(layouts, viewer, t, w) {
if shift {
if !view.selected_links.insert(link) {
view.selected_links.remove(&link);
}
} else {
view.clear_selection();
view.selected_links.insert(link);
}
actions.push(GraphAction::SelectionChanged);
} else if view.clear_selection() {
actions.push(GraphAction::SelectionChanged);
}
}
}
if response.contains_pointer()
&& ui.input(|i| i.pointer.button_pressed(PointerButton::Secondary))
{
let pos = ui.input(|i| i.pointer.interact_pos().or_else(|| i.pointer.latest_pos()));
if let Some(pos) = pos {
view.interaction.secondary_press = Some((pos, ui.input(|i| i.time)));
}
}
if ui.input(|i| i.pointer.button_released(PointerButton::Secondary)) {
if let Some((press_pos, press_time)) = view.interaction.secondary_press.take() {
let release_pos = ui
.input(|i| i.pointer.interact_pos().or_else(|| i.pointer.latest_pos()))
.unwrap_or(press_pos);
if ui.input(|i| i.time) - press_time <= RIGHT_CLICK_MAX_SECS {
actions.push(GraphAction::ContextMenu {
at: t.screen_to_world(release_pos),
});
}
}
}
let has_selection = !view.selection.is_empty()
|| !view.selected_links.is_empty()
|| !view.selected_stacks.is_empty();
if (response.hovered() || response.has_focus()) && has_selection {
let del =
ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace));
if del {
if !view.selected_links.is_empty() {
for link in view.selected_links.drain() {
actions.push(GraphAction::LinkDeleteRequested { link });
}
}
if !view.selection.is_empty() {
actions.push(GraphAction::NodesDeleteRequested {
nodes: view.selection.drain().collect(),
});
}
if !view.selected_stacks.is_empty() {
actions.push(GraphAction::StacksDeleteRequested {
stacks: view.selected_stacks.drain().collect(),
});
}
}
}
let (marquee, marquee_stacks, marquee_links) = match (
view.interaction.box_select_start,
response.interact_pointer_pos(),
) {
(Some(start), Some(p)) => {
let end = t.screen_to_world(p);
let size = (end - start).abs();
let rect = WorldRect::new(start.min(end), size.x, size.y);
let nodes = layouts
.iter()
.filter(|n| n.stack.is_none() && rects_intersect(rect, n.rect))
.map(|n| n.id)
.collect();
let m_stacks = stacks
.iter()
.filter(|s| rects_intersect(rect, s.rect))
.map(|s| s.id)
.collect();
let links = viewer
.links()
.into_iter()
.filter(|l| link_in_rect(layouts, l, rect))
.collect();
(nodes, m_stacks, links)
}
_ => (Vec::new(), Vec::new(), Vec::new()),
};
Hover {
node_body: node_under_pointer,
node: hovered_node,
stack: hovered_stack,
add_button: hovered_add_button,
collapse_all: hovered_collapse_all,
close: hovered_close,
port: hovered_port.map(|(_, c)| c),
link_target,
marquee,
marquee_stacks,
marquee_links,
}
}
fn canvas_accepts_navigation(
context: &egui::Context,
canvas_layer: egui::LayerId,
pointer: egui::Pos2,
) -> bool {
context
.layer_id_at(pointer)
.is_none_or(|top_layer| top_layer == canvas_layer)
}
fn click_select_node(
view: &mut GraphView,
node: NodeId,
shift: bool,
actions: &mut Vec<GraphAction>,
) {
view.raise(CanvasItem::Node(node));
if shift {
if !view.selection.insert(node) {
view.selection.remove(&node);
}
} else {
view.clear_selection();
view.selection.insert(node);
}
actions.push(GraphAction::SelectionChanged);
}
fn click_select_stack(
view: &mut GraphView,
stack: StackId,
shift: bool,
actions: &mut Vec<GraphAction>,
) {
view.raise(CanvasItem::Stack(stack));
if shift {
if !view.selected_stacks.insert(stack) {
view.selected_stacks.remove(&stack);
}
} else {
view.clear_selection();
view.selected_stacks.insert(stack);
}
actions.push(GraphAction::SelectionChanged);
}
fn begin_canvas_drag(view: &GraphView, primary: DragItem, grab_world: WorldPos) -> CanvasDrag {
let nodes = view
.selection
.iter()
.map(|&id| (id, view.position(id)))
.collect();
let stacks = view
.selected_stacks
.iter()
.map(|&id| (id, view.stack_position(id)))
.collect();
let primary_origin = match primary {
DragItem::Node(id) => view.position(id),
DragItem::Stack(id) => view.stack_position(id),
};
CanvasDrag {
primary_origin,
grab_offset: grab_world - primary_origin,
nodes,
stacks,
}
}
fn rects_intersect(a: WorldRect, b: WorldRect) -> bool {
a.min.cmple(b.max()).all() && a.max().cmpge(b.min).all()
}