From d8b81a09dbe3bd09251acfa86e6bc4d42e10cfff Mon Sep 17 00:00:00 2001 From: setzer22 Date: Sat, 19 Feb 2022 10:19:31 +0100 Subject: [PATCH] Initial commit --- .gitignore | 2 + Cargo.toml | 20 +++++++ src/error.rs | 10 ++++ src/graph_impls.rs | 130 +++++++++++++++++++++++++++++++++++++++++++++ src/id_type.rs | 24 +++++++++ src/index_impls.rs | 35 ++++++++++++ src/lib.rs | 89 +++++++++++++++++++++++++++++++ src/node_finder.rs | 75 ++++++++++++++++++++++++++ src/ui_state.rs | 71 +++++++++++++++++++++++++ 9 files changed, 456 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 src/error.rs create mode 100644 src/graph_impls.rs create mode 100644 src/id_type.rs create mode 100644 src/index_impls.rs create mode 100644 src/lib.rs create mode 100644 src/node_finder.rs create mode 100644 src/ui_state.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4034492 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "egui_node_graph" +description = "A helper library to create interactive node graphs using egui" +homepage = "https://github.com/setzer22/egui_node_graph" +repository = "https://github.com/setzer22/egui_node_graph" +license = "MIT" +version = "0.1.0" +keywords = ["ui", "egui", "graph", "node"] +authors = ["setzer22"] +edition = "2021" + +[features] +persistence = ["serde", "slotmap/serde", "smallvec/serde"] + +[dependencies] +egui = { version = "0.16" } +slotmap = { version = "1.0" } +smallvec = { version = "1.7.0" } +serde = { version = "1.0", optional = true, features = ["derive"] } +thiserror = "1.0" diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..ca83f3e --- /dev/null +++ b/src/error.rs @@ -0,0 +1,10 @@ +use super::*; + +#[derive(Debug, thiserror::Error)] +pub enum EguiGraphError { + #[error("Node {0:?} has no parameter named {1}")] + NoParameterNamed(NodeId, String), + + #[error("Parameter {0:?} was not found in the graph.")] + InvalidParameterId(AnyParameterId) +} diff --git a/src/graph_impls.rs b/src/graph_impls.rs new file mode 100644 index 0000000..d16b978 --- /dev/null +++ b/src/graph_impls.rs @@ -0,0 +1,130 @@ +use super::*; + +impl Graph { + pub fn new() -> Self { + Self { + nodes: SlotMap::default(), + inputs: SlotMap::default(), + outputs: SlotMap::default(), + connections: SecondaryMap::default(), + } + } + + pub fn add_node( + &mut self, + label: String, + user_data: NodeData, + f: impl FnOnce(NodeId, &mut Node), + ) -> NodeId { + let node_id = self.nodes.insert_with_key(|node_id| { + Node { + id: node_id, + label, + // These get filled in later by the user function + inputs: Vec::default(), + outputs: Vec::default(), + user_data, + } + }); + + f(node_id, self.nodes.get_mut(node_id).unwrap()); + + node_id + } + + pub fn remove_node(&mut self, node_id: NodeId) { + self.connections + .retain(|i, o| !(self.outputs[*o].node == node_id || self.inputs[i].node == node_id)); + let inputs: SVec<_> = self[node_id].input_ids().collect(); + for input in inputs { + self.inputs.remove(input); + } + let outputs: SVec<_> = self[node_id].output_ids().collect(); + for output in outputs { + self.outputs.remove(output); + } + self.nodes.remove(node_id); + } + + pub fn remove_connection(&mut self, input_id: InputId) -> Option { + self.connections.remove(input_id) + } + + pub fn iter_nodes(&self) -> impl Iterator + '_ { + self.nodes.iter().map(|(id, _)| id) + } + + pub fn add_connection(&mut self, output: OutputId, input: InputId) { + self.connections.insert(input, output); + } + + pub fn iter_connections(&self) -> impl Iterator + '_ { + self.connections.iter().map(|(o, i)| (o, *i)) + } + + pub fn connection(&self, input: InputId) -> Option { + self.connections.get(input).copied() + } + + pub fn any_param_type(&self, param: AnyParameterId) -> Result<&DataType, EguiGraphError> { + match param { + AnyParameterId::Input(input) => self.inputs.get(input).map(|x| &x.typ), + AnyParameterId::Output(output) => self.outputs.get(output).map(|x| &x.typ), + } + .ok_or(EguiGraphError::InvalidParameterId(param)) + } + + pub fn get_input(&self, input: InputId) -> &InputParam { + &self.inputs[input] + } + + pub fn get_output(&self, output: OutputId) -> &OutputParam { + &self.outputs[output] + } +} + +impl Default for Graph { + fn default() -> Self { + Self::new() + } +} + +impl Node { + pub fn inputs<'a, DataType, DataValue>( + &'a self, + graph: &'a Graph, + ) -> impl Iterator> + 'a { + self.input_ids().map(|id| graph.get_input(id)) + } + + pub fn outputs<'a, DataType, DataValue>( + &'a self, + graph: &'a Graph, + ) -> impl Iterator> + 'a { + self.output_ids().map(|id| graph.get_output(id)) + } + + pub fn input_ids(&self) -> impl Iterator + '_ { + self.inputs.iter().map(|(_name, id)| *id) + } + + pub fn output_ids(&self) -> impl Iterator + '_ { + self.outputs.iter().map(|(_name, id)| *id) + } + + pub fn get_input(&self, name: &str) -> Result { + self.inputs + .iter() + .find(|(param_name, _id)| param_name == name) + .map(|x| x.1) + .ok_or_else(|| EguiGraphError::NoParameterNamed(self.id, name.into())) + } + + pub fn get_output(&self, name: &str) -> Result { + self.outputs + .iter() + .find(|(param_name, _id)| param_name == name) + .map(|x| x.1) + .ok_or_else(|| EguiGraphError::NoParameterNamed(self.id, name.into())) + } +} \ No newline at end of file diff --git a/src/id_type.rs b/src/id_type.rs new file mode 100644 index 0000000..3ed325e --- /dev/null +++ b/src/id_type.rs @@ -0,0 +1,24 @@ +slotmap::new_key_type! { pub struct NodeId; } +slotmap::new_key_type! { pub struct InputId; } +slotmap::new_key_type! { pub struct OutputId; } + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum AnyParameterId { + Input(InputId), + Output(OutputId), +} + +impl AnyParameterId { + pub fn assume_input(&self) -> InputId { + match self { + AnyParameterId::Input(input) => *input, + AnyParameterId::Output(output) => panic!("{:?} is not an InputId", output), + } + } + pub fn assume_output(&self) -> OutputId { + match self { + AnyParameterId::Output(output) => *output, + AnyParameterId::Input(input) => panic!("{:?} is not an OutputId", input), + } + } +} diff --git a/src/index_impls.rs b/src/index_impls.rs new file mode 100644 index 0000000..f002330 --- /dev/null +++ b/src/index_impls.rs @@ -0,0 +1,35 @@ +use super::*; + +macro_rules! impl_index_traits { + ($id_type:ty, $output_type:ty, $arena:ident) => { + impl std::ops::Index<$id_type> for Graph { + type Output = $output_type; + + fn index(&self, index: $id_type) -> &Self::Output { + self.$arena.get(index).unwrap_or_else(|| { + panic!( + "{} index error for {:?}. Has the value been deleted?", + stringify!($id_type), + index + ) + }) + } + } + + impl std::ops::IndexMut<$id_type> for Graph { + fn index_mut(&mut self, index: $id_type) -> &mut Self::Output { + self.$arena.get_mut(index).unwrap_or_else(|| { + panic!( + "{} index error for {:?}. Has the value been deleted?", + stringify!($id_type), + index + ) + }) + } + } + }; +} + +impl_index_traits!(NodeId, Node, nodes); +impl_index_traits!(InputId, InputParam, inputs); +impl_index_traits!(OutputId, OutputParam, outputs); diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..537774f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,89 @@ +use slotmap::{SecondaryMap, SlotMap}; + +pub mod id_type; +pub use id_type::*; + +pub mod index_impls; + +pub mod graph_impls; + +pub mod error; +pub use error::*; + +pub mod ui_state; +pub use ui_state::*; + +pub mod node_finder; +pub use node_finder::*; + +#[cfg(feature = "persistence")] +use serde::{Deserialize, Serialize}; + +pub type SVec = smallvec::SmallVec<[T; 4]>; + +#[derive(Debug, Clone)] +#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))] +pub struct Node { + pub id: NodeId, + pub label: String, + pub inputs: Vec<(String, InputId)>, + pub outputs: Vec<(String, OutputId)>, + pub user_data: NodeData, +} + +/// There are three kinds of input params +#[derive(Debug, Clone, Copy)] +#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))] +pub enum InputParamKind { + /// No constant value can be set. Only incoming connections can produce it + ConnectionOnly, + /// Only a constant value can be set. No incoming connections accepted. + ConstantOnly, + /// Both incoming connections and constants are accepted. Connections take + /// precedence over the constant values. + ConnectionOrConstant, +} + +#[cfg(feature = "persistence")] +fn shown_inline_default() -> bool { + true +} + +#[derive(Debug, Clone)] +#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))] +pub struct InputParam { + pub id: InputId, + /// The data type of this node. Used to determine incoming connections. This + /// should always match the type of the InputParamValue, but the property is + /// not actually enforced. + pub typ: DataType, + /// The constant value stored in this parameter. + pub value: ValueType, + /// The input kind. See [`InputParamKind`] + pub kind: InputParamKind, + /// Back-reference to the node containing this parameter. + pub node: NodeId, + /// When true, the node is shown inline inside the node graph. + #[cfg_attr(feature = "persistence", serde(default = "shown_inline_default"))] + pub shown_inline: bool, +} + +#[derive(Debug, Clone)] +#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))] +pub struct OutputParam { + pub id: OutputId, + /// Back-reference to the node containing this parameter. + pub node: NodeId, + pub typ: DataType, +} + +#[derive(Debug, Clone)] +#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))] +pub struct Graph { + pub nodes: SlotMap>, + pub inputs: SlotMap>, + pub outputs: SlotMap>, + // Connects the input of a node, to the output of its predecessor that + // produces it + connections: SecondaryMap, +} diff --git a/src/node_finder.rs b/src/node_finder.rs new file mode 100644 index 0000000..d116a8c --- /dev/null +++ b/src/node_finder.rs @@ -0,0 +1,75 @@ +use std::marker::PhantomData; + +use super::*; +use egui::*; + +#[derive(Default)] +pub struct NodeFinder { + query: String, + /// Reset every frame. When set, the node finder will be moved at that position + pub position: Option, + pub just_spawned: bool, + _phantom: PhantomData, +} + +pub trait NodeKindIter { + type Item; + fn all_kinds<'a>() -> Box>; +} + +impl NodeFinder { + pub fn new_at(pos: Pos2) -> Self { + NodeFinder { + position: Some(pos), + just_spawned: true, + _phantom: Default::default(), + ..Default::default() + } + } + + /// Shows the node selector panel with a search bar. Returns whether a node + /// archetype was selected and, in that case, the finder should be hidden on + /// the next frame. + pub fn show(&mut self, ui: &mut Ui, all_kinds: impl NodeKindIter) -> Option { + let background_color = color_from_hex("#3f3f3f").unwrap(); + let _titlebar_color = background_color.linear_multiply(0.8); + let text_color = color_from_hex("#fefefe").unwrap(); + + ui.visuals_mut().widgets.noninteractive.fg_stroke = Stroke::new(2.0, text_color); + + let frame = Frame::dark_canvas(ui.style()) + .fill(background_color) + .margin(vec2(5.0, 5.0)); + + // The archetype that will be returned. + let mut submitted_archetype = None; + frame.show(ui, |ui| { + ui.vertical(|ui| { + let resp = ui.text_edit_singleline(&mut self.query); + if self.just_spawned { + resp.request_focus(); + self.just_spawned = false; + } + + let mut query_submit = resp.lost_focus() && ui.input().key_down(Key::Enter); + + Frame::default().margin(vec2(10.0, 10.0)).show(ui, |ui| { + for archetype in all_kinds.all_kinds() { + let archetype_name = archetype.type_label(); + if archetype_name.contains(self.query.as_str()) { + if query_submit { + submitted_archetype = Some(archetype); + query_submit = false; + } + if ui.selectable_label(false, archetype_name).clicked() { + submitted_archetype = Some(archetype); + } + } + } + }); + }); + }); + + submitted_archetype + } +} diff --git a/src/ui_state.rs b/src/ui_state.rs new file mode 100644 index 0000000..dcd7f89 --- /dev/null +++ b/src/ui_state.rs @@ -0,0 +1,71 @@ +use super::*; + +#[cfg(feature = "persistence")] +use serde::{Serialize, Deserialize}; + +#[derive(Copy, Clone)] +#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))] +pub struct PanZoom { + pub pan: egui::Vec2, + pub zoom: f32, +} + +pub struct GraphEditorState { + pub graph: Graph, + /// Nodes are drawn in this order. Draw order is important because nodes + /// that are drawn last are on top. + pub node_order: Vec, + /// An ongoing connection interaction: The mouse has dragged away from a + /// port and the user is holding the click + pub connection_in_progress: Option<(NodeId, AnyParameterId)>, + /// The currently active node. A program will be compiled to compute the + /// result of this node and constantly updated in real-time. + pub active_node: Option, + /// The currently selected node. Some interface actions depend on the + /// currently selected node. + pub selected_node: Option, + /// The position of each node. + pub node_positions: SecondaryMap, + /// The node finder is used to create new nodes. + pub node_finder: Option, + /// When this option is set by the UI, the side effect encoded by the node + /// will be executed at the start of the next frame. + pub run_side_effect: Option, + /// The panning of the graph viewport. + pub pan_zoom: PanZoom, +} + +impl GraphEditorState { + pub fn new(default_zoom: f32) -> Self { + Self { + graph: Graph::new(), + node_order: Vec::new(), + connection_in_progress: None, + active_node: None, + selected_node: None, + run_side_effect: None, + node_positions: SecondaryMap::new(), + node_finder: None, + pan_zoom: PanZoom { + pan: egui::Vec2::ZERO, + zoom: default_zoom, + }, + } + } +} + +impl PanZoom { + pub fn adjust_zoom( + &mut self, + zoom_delta: f32, + point: egui::Vec2, + zoom_min: f32, + zoom_max: f32, + ) { + let zoom_clamped = (self.zoom + zoom_delta).clamp(zoom_min, zoom_max); + let zoom_delta = zoom_clamped - self.zoom; + + self.zoom += zoom_delta; + self.pan += point * zoom_delta; + } +}