use crate::{
CopyNodes, Env, InspectEdge, NestNodes, NodeUi, OpenHead, OpenNodePalette, OpenNodeView, Paste,
PastePos, ResetTilesLayout, SocketDoc,
node::{NodeCodec, NodeInstances},
response::DynResponse,
};
use egui::emath::GuiRounding;
use egui_graph::{self, SocketKind, node::EdgeEvent};
use gantz_ca::{DataGraph, NodeData};
use gantz_core::{Edge, Node, node};
use petgraph::{
self,
visit::{EdgeRef, IntoNodeIdentifiers, NodeIndexable},
};
use std::collections::{HashMap, HashSet};
use steel::steel_vm::engine::Engine;
pub struct GraphSceneResponse {
pub scene: egui::Response,
pub nodes: Vec<(NodeIndex, NodeResponse)>,
pub changed: bool,
pub responses: Vec<DynResponse>,
pub reindex: crate::ops::Reindex,
}
pub type NodeResponse = egui_graph::node::NodeResponse<egui::Response>;
impl GraphSceneResponse {
pub fn any_node_clicked(&self) -> bool {
self.nodes.iter().any(|(_, r)| r.clicked())
}
pub fn any_node_interacted(&self) -> bool {
self.nodes
.iter()
.any(|(_, r)| r.clicked() || r.dragged() || r.changed())
}
}
pub type EdgeIndex = petgraph::graph::EdgeIndex<usize>;
pub type NodeIndex = petgraph::graph::NodeIndex<usize>;
pub struct GraphScene<'a> {
registry: &'a Env<'a>,
codec: &'a NodeCodec,
graph: &'a mut DataGraph,
instances: &'a mut NodeInstances,
id: egui::Id,
layout_params: egui_graph::LayoutParams,
scene_config: crate::widget::gantz::SceneConfig,
immutable: bool,
validate: bool,
view_toggles: Option<&'a mut crate::widget::gantz::ViewToggles>,
ext_panes: &'a [crate::widget::ExtPaneEntry],
edge_styles: Option<(&'a gantz_ca::Head, &'a [&'a dyn crate::widget::EdgeStyle])>,
}
#[derive(Default, serde::Deserialize, serde::Serialize)]
pub struct GraphSceneState {
pub interaction: Interaction,
#[serde(default, skip)]
pub pending_auto_layout: bool,
#[serde(default, skip)]
pub pending_center_view: bool,
#[serde(default, skip)]
pub pending_align: Option<egui_graph::AlignBy>,
}
#[derive(Default, serde::Deserialize, serde::Serialize)]
pub struct Interaction {
pub selection: Selection,
#[serde(default, skip)]
pub edge_in_progress: Option<(NodeIndex, SocketKind, usize)>,
#[serde(default, skip)]
pub edge_context_menu_pos: Option<egui::Pos2>,
#[serde(default, skip)]
pub last_pointer_pos: Option<egui::Pos2>,
#[serde(default, skip)]
pub live_pointer: Option<egui::Pos2>,
}
#[derive(Default, serde::Deserialize, serde::Serialize)]
pub struct Selection {
pub nodes: HashSet<NodeIndex>,
pub edges: HashSet<EdgeIndex>,
}
impl<'a> GraphScene<'a> {
pub fn new(
registry: &'a Env<'a>,
codec: &'a NodeCodec,
graph: &'a mut DataGraph,
instances: &'a mut NodeInstances,
) -> Self {
Self {
registry,
codec,
graph,
instances,
id: egui::Id::new("gantz-graph-scene"),
layout_params: egui_graph::LayoutParams::new(egui::Direction::TopDown),
scene_config: Default::default(),
immutable: false,
validate: false,
view_toggles: None,
ext_panes: &[],
edge_styles: None,
}
}
pub fn validate_change_tracking(mut self, validate: bool) -> Self {
self.validate = validate;
self
}
pub fn with_id(mut self, id: egui::Id) -> Self {
self.id = id;
self
}
pub fn layout_params(mut self, params: egui_graph::LayoutParams) -> Self {
self.layout_params = params;
self
}
pub fn scene_config(mut self, scene_config: crate::widget::gantz::SceneConfig) -> Self {
self.scene_config = scene_config;
self
}
pub fn immutable(mut self, immutable: bool) -> Self {
self.immutable = immutable;
self
}
pub fn view_toggles(mut self, view_toggles: &'a mut crate::widget::gantz::ViewToggles) -> Self {
self.view_toggles = Some(view_toggles);
self
}
pub fn ext_panes(mut self, ext_panes: &'a [crate::widget::ExtPaneEntry]) -> Self {
self.ext_panes = ext_panes;
self
}
pub fn edge_styles(
mut self,
head: &'a gantz_ca::Head,
styles: &'a [&'a dyn crate::widget::EdgeStyle],
) -> Self {
self.edge_styles = Some((head, styles));
self
}
pub fn show(
self,
scene_view: &mut crate::SceneView,
state: &mut GraphSceneState,
vm: &mut Engine,
ui: &mut egui::Ui,
) -> GraphSceneResponse {
let viewport = ui.available_rect_before_wrap().size();
let mut egui_view = scene_view.take_egui(viewport);
let view = &mut egui_view;
let do_layout = std::mem::take(&mut state.pending_auto_layout);
let do_center = std::mem::take(&mut state.pending_center_view);
if do_layout {
let target: HashSet<NodeIndex> = if state.interaction.selection.nodes.is_empty() {
self.graph.node_indices().collect()
} else {
state.interaction.selection.nodes.clone()
};
let whole = state.interaction.selection.nodes.is_empty();
apply_auto_layout(
self.registry,
self.codec,
&*self.graph,
self.id,
&self.layout_params,
ui.ctx(),
view,
&target,
whole,
);
}
if let Some(by) = std::mem::take(&mut state.pending_align) {
let target = &state.interaction.selection.nodes;
if target.len() > 1 {
apply_align(self.id, ui.ctx(), view, target, by);
}
}
let mut node_responses = Vec::new();
let mut responses: Vec<DynResponse> = Vec::new();
let mut to_delete: Vec<NodeIndex> = Vec::new();
let mut changed = false;
let selected: HashSet<egui_graph::NodeId> = state
.interaction
.selection
.nodes
.iter()
.map(|ix| egui_graph::NodeId::from_u64(ix.index() as u64))
.collect();
let graph_response = self
.scene_config
.apply(
egui_graph::Graph::from_id(self.id)
.center_view(do_center)
.selected_nodes(selected)
.immutable(self.immutable),
)
.show(view, ui, |ui, show| {
let immutable = self.immutable;
let validate = self.validate;
show.nodes(ui, |nctx, ui| {
node_responses = nodes(
self.registry,
self.codec,
self.graph,
self.instances,
nctx,
state,
&mut responses,
&mut changed,
vm,
&mut to_delete,
immutable,
validate,
ui,
);
})
.edges(ui, |ectx, ui| {
edges(
self.graph,
ectx,
state,
&mut responses,
&mut changed,
self.edge_styles,
ui,
)
});
});
if let Some(selected) = graph_response.selection_changed {
state.interaction.selection.nodes = selected
.into_iter()
.map(|id| NodeIndex::new(id.value() as usize))
.collect();
}
let reindex = crate::ops::remove_nodes(
self.graph,
vm,
&mut view.layout,
&mut state.interaction.selection,
self.instances,
to_delete,
);
if !reindex.is_empty() {
changed = true;
}
state.interaction.live_pointer = None;
if graph_response.response.contains_pointer() {
let layer_id = graph_response.response.layer_id;
let ptr = ui
.ctx()
.input(|i| i.pointer.interact_pos().or(i.pointer.hover_pos()));
if let (Some(ptr), Some(t)) = (ptr, ui.ctx().layer_transform_from_global(layer_id)) {
let pos = t.mul_pos(ptr);
state.interaction.last_pointer_pos = Some(pos);
state.interaction.live_pointer = Some(pos);
}
}
let immutable = self.immutable;
let view_toggles = self.view_toggles;
let ext_panes = self.ext_panes;
let mut reset_layout = false;
let mut request_layout = false;
let mut request_center = false;
if !immutable || view_toggles.is_some() {
let layer_id = graph_response.response.layer_id;
graph_response.response.context_menu(|ui| {
if !immutable {
let menu_screen_pos = ui.min_rect().left_top();
if ui.button("add node").clicked() {
responses.push(DynResponse::new(OpenNodePalette));
ui.close();
}
if ui.button("paste").clicked() {
let graph_pos = ui
.ctx()
.layer_transform_from_global(layer_id)
.map(|t| t * menu_screen_pos)
.unwrap_or(menu_screen_pos);
let pos = PastePos::GraphPos(graph_pos);
responses.push(DynResponse::new(Paste { text: None, pos }));
ui.close();
}
if ui
.button("auto-layout")
.on_hover_text(
"lay out the selection, or the whole graph when nothing is selected",
)
.clicked()
{
request_layout = true;
ui.close();
}
if ui
.button("center-view")
.on_hover_text("frame the whole graph in the view")
.clicked()
{
request_center = true;
ui.close();
}
}
if let Some(view) = view_toggles {
ui.menu_button("panes", |ui| {
crate::widget::panes_config(view, ext_panes, ui);
ui.separator();
if crate::widget::reset_layout_button(ui) {
reset_layout = true;
ui.close();
}
});
}
});
}
if request_layout {
state.pending_auto_layout = true;
}
if request_center {
state.pending_center_view = true;
}
if reset_layout {
responses.push(DynResponse::new(ResetTilesLayout));
}
scene_view.restore_egui(egui_view, viewport);
GraphSceneResponse {
scene: graph_response.response,
nodes: node_responses,
changed,
responses,
reindex,
}
}
}
impl Selection {
pub fn clear(&mut self) {
self.edges.clear();
self.nodes.clear();
}
}
pub fn layout(
registry: &Env<'_>,
codec: &NodeCodec,
graph: &DataGraph,
graph_id: egui::Id,
params: &egui_graph::LayoutParams,
ctx: &egui::Context,
subset: Option<&HashSet<NodeIndex>>,
) -> egui_graph::Layout {
let included = |n: NodeIndex| subset.is_none_or(|s| s.contains(&n));
if !graph.node_indices().any(included) {
return Default::default();
}
let get_node = |ca: &gantz_ca::ContentAddr| registry.node(ca);
let meta_ctx = gantz_core::node::MetaCtx::new(&get_node);
let socket_padding = egui_graph::socket_padding(&ctx.global_style());
let nodes_vec = egui_graph::with_graph_memory(ctx, graph_id, |gmem| {
let node_sizes = gmem.node_sizes();
graph
.node_indices()
.filter(|&n| included(n))
.map(|n| {
let node_id = egui_graph::NodeId::from_u64(n.index() as u64);
let size = node_sizes
.get(&node_id)
.cloned()
.unwrap_or_else(|| [200.0, 50.0].into());
let (inputs, outputs) = match codec.reify_ui(&graph[n]) {
Ok(inst) => (inst.node.n_inputs(meta_ctx), inst.node.n_outputs(meta_ctx)),
Err(_) => placeholder_socket_counts(graph, n),
};
let layout_node = egui_graph::LayoutNode::new(size)
.inputs(inputs)
.outputs(outputs)
.socket_padding(socket_padding);
(node_id, layout_node)
})
.collect::<Vec<_>>()
});
let nodes = nodes_vec.into_iter();
let edges = graph.edge_indices().filter_map(|e| {
let (a, b) = graph.edge_endpoints(e)?;
if !included(a) || !included(b) {
return None;
}
let edge = graph.edge_weight(e)?;
Some((
(
egui_graph::NodeId::from_u64(a.index() as u64),
edge.output.0 as usize,
),
(
egui_graph::NodeId::from_u64(b.index() as u64),
edge.input.0 as usize,
),
))
});
egui_graph::layout(nodes, edges, params.clone())
}
pub fn apply_auto_layout(
registry: &Env<'_>,
codec: &NodeCodec,
graph: &DataGraph,
graph_id: egui::Id,
params: &egui_graph::LayoutParams,
ctx: &egui::Context,
view: &mut egui_graph::View,
target: &HashSet<NodeIndex>,
whole: bool,
) {
let new = layout(registry, codec, graph, graph_id, params, ctx, Some(target));
if whole {
view.layout = new;
return;
}
let sizes: HashMap<egui_graph::NodeId, egui::Vec2> =
egui_graph::with_graph_memory(ctx, graph_id, |gmem| {
let node_sizes = gmem.node_sizes();
target
.iter()
.map(|ix| {
let id = egui_graph::NodeId::from_u64(ix.index() as u64);
let size = node_sizes
.get(&id)
.copied()
.unwrap_or_else(|| [200.0, 50.0].into());
(id, size)
})
.collect()
});
let shift = match (
bbox_centre(target, &view.layout, &sizes),
bbox_centre(target, &new, &sizes),
) {
(Some(orig), Some(next)) => orig - next,
_ => egui::Vec2::ZERO,
};
for (id, pos) in new {
view.layout.insert(id, pos + shift);
}
}
fn bbox_centre(
target: &HashSet<NodeIndex>,
layout: &egui_graph::Layout,
sizes: &HashMap<egui_graph::NodeId, egui::Vec2>,
) -> Option<egui::Pos2> {
let mut bb: Option<egui::Rect> = None;
for ix in target {
let id = egui_graph::NodeId::from_u64(ix.index() as u64);
let Some(&tl) = layout.get(&id) else { continue };
let size = sizes
.get(&id)
.copied()
.unwrap_or_else(|| [200.0, 50.0].into());
let rect = egui::Rect::from_min_size(tl, size);
bb = Some(bb.map_or(rect, |b| b.union(rect)));
}
bb.map(|b| b.center())
}
pub fn apply_align(
graph_id: egui::Id,
ctx: &egui::Context,
view: &mut egui_graph::View,
target: &HashSet<NodeIndex>,
by: egui_graph::AlignBy,
) {
let sizes: HashMap<egui_graph::NodeId, egui::Vec2> =
egui_graph::with_graph_memory(ctx, graph_id, |gmem| {
let node_sizes = gmem.node_sizes();
target
.iter()
.map(|ix| {
let id = egui_graph::NodeId::from_u64(ix.index() as u64);
let size = node_sizes
.get(&id)
.copied()
.unwrap_or_else(|| [200.0, 50.0].into());
(id, size)
})
.collect()
});
let ids = target
.iter()
.map(|ix| egui_graph::NodeId::from_u64(ix.index() as u64));
egui_graph::align_nodes(&mut view.layout, ids, &sizes, by, None);
}
fn nodes(
registry: &Env<'_>,
codec: &NodeCodec,
graph: &mut DataGraph,
instances: &mut NodeInstances,
nctx: &mut egui_graph::NodesCtx,
state: &mut GraphSceneState,
responses: &mut Vec<DynResponse>,
changed: &mut bool,
vm: &mut Engine,
nodes_to_delete: &mut Vec<NodeIndex>,
immutable: bool,
validate: bool,
ui: &mut egui::Ui,
) -> Vec<(NodeIndex, NodeResponse)> {
let get_node = |ca: &gantz_ca::ContentAddr| registry.node(ca);
let meta_ctx = gantz_core::node::MetaCtx::new(&get_node);
let node_ids: Vec<_> = graph.node_identifiers().collect();
let (inlets, outlets) = crate::inlet_outlet_ids(registry, graph);
let mut node_responses = Vec::with_capacity(node_ids.len());
let mut nodes_to_reset = Vec::new();
let mut request_layout = false;
let mut request_align: Option<egui_graph::AlignBy> = None;
let mut writes = Vec::new();
for n_id in node_ids {
let n_ix = graph.to_index(n_id);
let node_id = egui_graph::NodeId::from_u64(n_ix as u64);
let mut instance = instances.take(codec, n_ix, &graph[n_id]).ok();
let (inputs, outputs, flow) = match &instance {
Some(entry) => (
entry.inst.node.n_inputs(meta_ctx),
entry.inst.node.n_outputs(meta_ctx),
entry.inst.node.flow(registry),
),
None => {
let (inputs, outputs) = placeholder_socket_counts(graph, n_id);
(inputs, outputs, egui::Direction::TopDown)
}
};
let mut node_changed = false;
let response = egui_graph::node::Node::from_id(node_id)
.inputs(inputs)
.outputs(outputs)
.flow(flow)
.max_width(f32::INFINITY)
.show(nctx, ui, |nui_ctx| match instance.as_mut() {
Some(entry) => {
let node_path = [n_ix];
let node_ctx = crate::NodeCtx::new(
registry,
&node_path,
&inlets,
&outlets,
&[],
vm,
&mut writes,
);
let r = entry.inst.node.ui(node_ctx, nui_ctx);
node_changed |= r.changed;
responses.extend(r.payloads);
r.framed
}
None => placeholder_ui(&graph[n_id], nui_ctx),
});
if let Some(entry) = &instance {
for (ix, sock) in response.sockets().inputs() {
if let Some(doc) = entry.inst.node.socket_doc(registry, SocketKind::Input, ix) {
socket_hover(sock, &doc);
}
}
for (ix, sock) in response.sockets().outputs() {
if let Some(doc) = entry.inst.node.socket_doc(registry, SocketKind::Output, ix) {
socket_hover(sock, &doc);
}
}
}
if response.changed() {
if let Some(ev) = response.edge_event() {
match ev {
EdgeEvent::Started { kind, index } => {
state.interaction.edge_in_progress = Some((n_id, kind, index));
}
EdgeEvent::Ended { kind, index } => {
if let Some((src, _, ix)) = state.interaction.edge_in_progress.take() {
let (index, ix) = (index as u16, ix as u16);
let (a, b, w) = match kind {
SocketKind::Input => (src, n_id, Edge::from((ix, index))),
SocketKind::Output => (n_id, src, Edge::from((index, ix))),
};
if !graph.edges(a).any(|e| e.target() == b && *e.weight() == w) {
graph.add_edge(a, b, w);
*changed = true;
}
}
}
EdgeEvent::Cancelled => {
state.interaction.edge_in_progress = None;
}
}
}
if response.removed() {
nodes_to_delete.push(n_id);
}
}
let instances_ref: &NodeInstances = instances;
response.context_menu(|ui| {
let selected = &state.interaction.selection.nodes;
let target: HashSet<NodeIndex> = if selected.contains(&n_id) {
selected.clone()
} else {
HashSet::from([n_id])
};
let multi = target.len() > 1;
if ui.button("copy").clicked() {
responses.push(DynResponse::new(CopyNodes(target.clone())));
ui.close();
}
if let Some(entry) = &instance {
let demo_name = entry.inst.node.demo_graph(registry);
let demo_btn = ui.add_enabled(demo_name.is_some(), egui::Button::new("demo"));
if let Some(name) = demo_name {
if demo_btn.on_hover_text(format!("opens {name}")).clicked() {
responses.push(DynResponse::new(OpenHead(gantz_ca::Head::Branch(
name.parse().expect("infallible"),
))));
ui.close();
}
} else {
demo_btn.on_disabled_hover_text("no associated demo");
}
if let Some(head) = entry.inst.node.nav_head(registry) {
if ui
.button("open tab")
.on_hover_text("open the referenced graph in a new tab")
.clicked()
{
responses.push(DynResponse::new(OpenHead(head)));
ui.close();
}
}
if ui
.button("open view")
.on_hover_text("open this node's view in the Node Views pane")
.clicked()
{
let ty_name = entry.inst.node.name(registry).to_string();
responses.push(DynResponse::new(OpenNodeView {
path: vec![n_ix],
ty_name,
}));
ui.close();
}
}
if !immutable {
if multi
&& ui
.button("nest")
.on_hover_text("nest the selected nodes into a new nested graph")
.clicked()
{
responses.push(DynResponse::new(NestNodes(target.clone())));
ui.close();
}
let stateful = target.iter().any(|&n| {
graph
.node_weight(n)
.is_some_and(|w| match instances_ref.peek(n.index(), w) {
Some(inst) => inst.node.stateful(meta_ctx),
None => codec
.reify_ui(w)
.is_ok_and(|inst| inst.node.stateful(meta_ctx)),
})
});
let reset_btn = ui.add_enabled(stateful, egui::Button::new("reset"));
if reset_btn
.on_hover_text("reset the node to its default state")
.on_disabled_hover_text("no state to reset")
.clicked()
{
nodes_to_reset.extend(target.iter().copied());
ui.close();
}
if ui.button("delete").clicked() {
nodes_to_delete.extend(target);
ui.close();
}
if multi
&& ui
.button("auto-layout")
.on_hover_text("lay out the selected nodes")
.clicked()
{
request_layout = true;
ui.close();
}
if multi {
ui.menu_button("align", |ui| {
let mut item = |ui: &mut egui::Ui, label, hover, by| {
if ui.button(label).on_hover_text(hover).clicked() {
request_align = Some(by);
ui.close();
}
};
item(
ui,
"min edges",
"align the left (column) or top (row) edges",
egui_graph::AlignBy::Min,
);
item(
ui,
"centres",
"align the node centres",
egui_graph::AlignBy::Center,
);
item(
ui,
"max edges",
"align the right (column) or bottom (row) edges",
egui_graph::AlignBy::Max,
);
});
}
}
if let Some(entry) = instance.as_mut() {
let node_path = [n_ix];
let mut node_ctx = crate::NodeCtx::new(
registry,
&node_path,
&inlets,
&outlets,
&[],
vm,
&mut writes,
);
let cm = entry.inst.node.context_menu(&mut node_ctx, ui);
node_changed |= cm.changed;
responses.extend(cm.payloads);
}
});
if let Some(mut entry) = instance {
if node_changed {
*changed = true;
match entry.inst.erase() {
Ok(node_data) => {
entry.src = node_data.clone();
graph[n_id] = node_data;
instances.put(n_ix, entry);
}
Err(e) => {
log::error!("node {n_ix}: failed to erase edited node, edit dropped: {e}");
}
}
} else if validate {
match entry.inst.erase() {
Ok(node_data) => {
if node_data.content_addr() != graph[n_id].content_addr() {
log::warn!(
"node {n_ix} ({}): CA-affecting state changed without a \
`changed` response; the edit was dropped (see `NodeUi`)",
graph[n_id].tag,
);
} else {
instances.put(n_ix, entry);
}
}
Err(e) => {
log::warn!("node {n_ix}: change-tracking validation failed to erase: {e}");
}
}
} else {
instances.put(n_ix, entry);
}
}
responses.extend(crate::action::state_written(&mut writes));
node_responses.push((n_id, response));
}
if !nodes_to_reset.is_empty() {
for n_id in nodes_to_reset {
if graph.node_weight(n_id).is_some() {
let _ = gantz_core::node::state::remove_value(vm, &[n_id.index()]);
}
}
match codec.reify_graph(graph) {
Ok(g) => gantz_core::graph::register(&get_node, &g, &[], vm),
Err(e) => log::error!("cannot re-register graph after node reset: {e}"),
}
}
if request_layout {
state.pending_auto_layout = true;
}
if let Some(by) = request_align {
state.pending_align = Some(by);
}
node_responses
}
fn placeholder_socket_counts(graph: &DataGraph, n: NodeIndex) -> (usize, usize) {
let inputs = graph
.edges_directed(n, petgraph::Direction::Incoming)
.map(|e| e.weight().input.0 as usize + 1)
.max()
.unwrap_or(0);
let outputs = graph
.edges_directed(n, petgraph::Direction::Outgoing)
.map(|e| e.weight().output.0 as usize + 1)
.max()
.unwrap_or(0);
(inputs, outputs)
}
fn placeholder_ui(
weight: &NodeData,
nui_ctx: egui_graph::NodeCtx,
) -> egui_graph::FramedResponse<egui::Response> {
const MAX_READOUT: usize = 48;
nui_ctx.framed(|ui, _sockets| {
ui.vertical(|ui| {
ui.colored_label(crate::node::missing_color(), &weight.tag)
.on_hover_text(
"this node's type is unknown to this application \
(e.g. from a domain that is not compiled in); its data is \
preserved untouched",
);
let readout = format!("{:?}", weight.data);
let mut line: String = readout.chars().take(MAX_READOUT).collect();
if line.len() < readout.len() {
line.push('…');
}
ui.weak(line);
})
.response
})
}
fn socket_hover(resp: &egui::Response, doc: &SocketDoc) {
resp.clone().on_hover_ui(|ui| {
let max_width = ui.spacing().tooltip_width;
ui.set_max_width(max_width);
if !doc.ty.is_empty() {
ui.strong(doc.ty.as_ref());
}
if let Some(desc) = &doc.description {
ui.label(desc.as_ref());
}
});
}
fn edges(
graph: &mut DataGraph,
ectx: &mut egui_graph::EdgesCtx,
state: &mut GraphSceneState,
responses: &mut Vec<DynResponse>,
changed: &mut bool,
edge_styles: Option<(&gantz_ca::Head, &[&dyn crate::widget::EdgeStyle])>,
ui: &mut egui::Ui,
) {
let mut any_context_menu_open = false;
let mut to_delete: Vec<EdgeIndex> = Vec::new();
for e in graph.edge_indices().collect::<Vec<_>>() {
let (na, nb) = graph.edge_endpoints(e).unwrap();
let edge = *graph.edge_weight(e).unwrap();
let (input, output) = (edge.input.0.into(), edge.output.0.into());
let a = egui_graph::NodeId::from_u64(na.index() as u64);
let b = egui_graph::NodeId::from_u64(nb.index() as u64);
let mut selected = state.interaction.selection.edges.contains(&e);
let styling = edge_styles.and_then(|(head, styles)| {
let ctx =
crate::widget::EdgeStyleCtx::new(head, (na.index(), output), (nb.index(), input));
crate::widget::edge_style::edge_styling(styles, &ctx)
});
let edge = egui_graph::edge::Edge::new((a, output), (b, input), &mut selected);
let response = match &styling {
None => edge.show(ectx, ui),
Some(styling) => edge.show_with(ectx, ui, |ui, pctx| {
paint_styled_edge(styling, &pctx, ui);
}),
};
if let Some(text) = styling.and_then(|s| s.hover_text) {
(*response).clone().on_hover_text(text);
}
if response.deleted() {
to_delete.push(e);
} else if response.changed() {
if selected {
state.interaction.selection.edges.insert(e);
} else {
state.interaction.selection.edges.remove(&e);
}
}
let context_menu_open = response.context_menu_opened();
if context_menu_open {
any_context_menu_open = true;
if state.interaction.edge_context_menu_pos.is_none() {
state.interaction.edge_context_menu_pos = Some(response.closest_point());
}
}
let mut delete_edge = false;
response.context_menu(|ui| {
if ui.button("inspect").clicked() {
if let Some(pos) = state.interaction.edge_context_menu_pos.take() {
responses.push(DynResponse::new(InspectEdge { edge: e, pos }));
}
ui.close();
}
if ui.button("delete").clicked() {
delete_edge = true;
ui.close();
}
});
if delete_edge {
graph.remove_edge(e);
state.interaction.selection.edges.remove(&e);
*changed = true;
}
}
if !to_delete.is_empty() {
to_delete.sort_unstable_by_key(|e| std::cmp::Reverse(e.index()));
to_delete.dedup();
for e in to_delete {
if graph.edge_weight(e).is_none() {
continue;
}
let last = graph.edge_count() - 1;
state.interaction.selection.edges.remove(&e);
graph.remove_edge(e);
if e.index() != last {
let last_e = EdgeIndex::new(last);
if state.interaction.selection.edges.remove(&last_e) {
state.interaction.selection.edges.insert(e);
}
}
*changed = true;
}
}
if !any_context_menu_open {
state.interaction.edge_context_menu_pos = None;
}
if let Some(edge) = ectx.in_progress(ui) {
edge.show(ui, egui_graph::bezier::Cubic::DEFAULT_CURVATURE);
}
}
const STRAND_CAP: usize = 4;
const NOTCH_COVER: f32 = 1.0;
const WRAP_SPACING: f32 = 7.0;
const WRAP_OVERHANG: f32 = 4.0;
const WRAP_LEAN: f32 = 0.7;
fn paint_styled_edge(
styling: &crate::widget::EdgeStyling,
pctx: &egui_graph::edge::EdgePaintCtx,
ui: &egui::Ui,
) {
let mut stroke = pctx.stroke;
if !(pctx.selected || pctx.hovered) {
if let Some(color) = styling.color {
stroke.color = color;
}
}
stroke.width *= styling.width_scale;
let spacing = stroke.width + 1.5;
let strands = styling.strands.max(1).min(STRAND_CAP);
let strand_lines = egui_graph::paint::parallel_polylines(pctx.points, strands, spacing);
for points in &strand_lines {
paint_strand(ui, points, stroke, styling.dash);
}
if let Some(notch) = styling.notch {
let notch_stroke = egui::Stroke {
width: stroke.width + NOTCH_COVER,
color: ui.style().visuals.extreme_bg_color,
};
for points in &strand_lines {
ui.painter()
.extend(egui::Shape::dashed_line(points, notch_stroke, notch, notch));
}
}
if styling.strands > STRAND_CAP {
let band = spacing * (STRAND_CAP as f32 - 1.0);
for [a, b] in wrap_stripes(pctx.points, WRAP_SPACING, band + WRAP_OVERHANG, WRAP_LEAN) {
ui.painter().add(egui::Shape::line(vec![a, b], stroke));
}
}
}
fn paint_strand(
ui: &egui::Ui,
points: &[egui::Pos2],
stroke: egui::Stroke,
dash: Option<(f32, f32)>,
) {
match dash {
None => {
ui.painter().add(egui::Shape::line(points.to_vec(), stroke));
}
Some((dash, gap)) => {
ui.painter()
.extend(egui::Shape::dashed_line(points, stroke, dash, gap));
}
}
}
fn wrap_stripes(
points: &[egui::Pos2],
spacing: f32,
length: f32,
lean: f32,
) -> Vec<[egui::Pos2; 2]> {
let mut stripes = Vec::new();
if points.len() < 2 || spacing <= 0.0 {
return stripes;
}
let half = length * 0.5;
let (sin, cos) = lean.sin_cos();
let mut acc = 0.0;
let mut next = spacing * 0.5;
for w in points.windows(2) {
let (a, b) = (w[0], w[1]);
let seg = b - a;
let seg_len = seg.length();
if seg_len <= f32::EPSILON {
continue;
}
let dir = seg / seg_len;
let perp = egui::vec2(-dir.y, dir.x);
let stripe = perp * cos + dir * sin;
while next <= acc + seg_len {
let center = a + seg * ((next - acc) / seg_len);
stripes.push([center - stripe * half, center + stripe * half]);
next += spacing;
}
acc += seg_len;
}
stripes
}
fn diagnostic_node_at_level(diag_path: &[node::Id], level: &[node::Id]) -> Option<node::Id> {
diag_path.strip_prefix(level)?.first().copied()
}
pub fn paint_diagnostics(
diagnostics: &[gantz_core::Diagnostic],
level: &[node::Id],
response: &GraphSceneResponse,
ui: &egui::Ui,
) {
if diagnostics.is_empty() {
return;
}
let color = ui.visuals().error_fg_color;
let mut unattributed = false;
for diag in diagnostics {
let flagged = diagnostic_node_at_level(&diag.path, level).and_then(|flag| {
let ix = response
.nodes
.iter()
.position(|(ix, _)| ix.index() == flag)?;
Some(&response.nodes[ix].1)
});
let Some(node_response) = flagged else {
unattributed = true;
continue;
};
let to_global = ui
.ctx()
.layer_transform_to_global(node_response.layer_id)
.unwrap_or(egui::emath::TSTransform::IDENTITY);
let inv = to_global.inverse();
let ppp = ui.ctx().pixels_per_point();
let frame = inv.mul_rect(to_global.mul_rect(node_response.rect).round_to_pixels(ppp));
let mut painter = ui.ctx().layer_painter(node_response.layer_id);
let local_clip = inv.mul_rect(ui.clip_rect());
painter.set_clip_rect(local_clip.intersect(frame.expand(16.0)));
let frame_radius = ui.visuals().window_corner_radius;
let rings = [(1.0f32, 1.5, 0.45), (3.0, 2.0, 0.16), (5.5, 2.5, 0.06)];
for (expand, width, alpha) in rings {
painter.add(
egui::epaint::RectShape::stroke(
frame.expand(expand),
frame_radius + expand.round() as u8,
egui::Stroke::new(width, color.gamma_multiply(alpha)),
egui::StrokeKind::Outside,
)
.with_round_to_pixels(false),
);
}
(**node_response).clone().on_hover_text(&diag.message);
}
if unattributed {
ui.painter().rect_stroke(
response.scene.rect,
0,
egui::Stroke::new(2.0, color.gamma_multiply(0.6)),
egui::StrokeKind::Inside,
);
}
}
#[cfg(test)]
mod tests {
use super::{diagnostic_node_at_level, wrap_stripes};
#[test]
fn wrap_stripes_space_and_span() {
let pts = [egui::pos2(0.0, 0.0), egui::pos2(30.0, 0.0)];
let stripes = wrap_stripes(&pts, 10.0, 8.0, 0.0);
assert_eq!(stripes.len(), 3);
for (i, [a, b]) in stripes.iter().enumerate() {
let x = 5.0 + i as f32 * 10.0;
assert!((a.x - x).abs() < 1e-4 && (b.x - x).abs() < 1e-4);
assert!(((b.y - a.y).abs() - 8.0).abs() < 1e-4);
}
let leaned = wrap_stripes(&pts, 10.0, 8.0, 0.7);
assert!(leaned[0][1].x > leaned[0][0].x);
assert!(wrap_stripes(&pts, 0.0, 8.0, 0.0).is_empty());
assert!(wrap_stripes(&pts[..1], 10.0, 8.0, 0.0).is_empty());
}
#[test]
fn diagnostic_level_resolution() {
assert_eq!(diagnostic_node_at_level(&[3], &[]), Some(3));
assert_eq!(diagnostic_node_at_level(&[3, 2, 1], &[]), Some(3));
assert_eq!(diagnostic_node_at_level(&[3, 2, 1], &[3]), Some(2));
assert_eq!(diagnostic_node_at_level(&[3, 2, 1], &[3, 2]), Some(1));
assert_eq!(diagnostic_node_at_level(&[3, 2], &[4]), None);
assert_eq!(diagnostic_node_at_level(&[], &[]), None);
assert_eq!(diagnostic_node_at_level(&[3], &[3]), None);
}
}