From c58192a7dafdbbfa0601353da482a02ebd35a7d3 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Tue, 16 Dec 2025 10:14:34 -0500 Subject: [PATCH] Use egui_node_graph2 for node graph --- lightningbeam-ui/Cargo.toml | 8 +- .../lightningbeam-editor/Cargo.toml | 2 +- .../src/panes/node_graph/graph_data.rs | 451 +++++++++++------- .../src/panes/node_graph/mod.rs | 272 +++++------ 4 files changed, 410 insertions(+), 323 deletions(-) diff --git a/lightningbeam-ui/Cargo.toml b/lightningbeam-ui/Cargo.toml index f52917f..59d1c7f 100644 --- a/lightningbeam-ui/Cargo.toml +++ b/lightningbeam-ui/Cargo.toml @@ -10,10 +10,10 @@ members = [ # Note: Upgraded from 0.29 to 0.31 to fix Linux IME/keyboard input issues # See: https://github.com/emilk/egui/pull/5198 # Upgraded to 0.33 for shader editor (egui_code_editor) and continued bug fixes -egui = "0.33" -eframe = { version = "0.33", default-features = true, features = ["wgpu"] } -egui_extras = { version = "0.33", features = ["image", "svg", "syntect"] } -egui-wgpu = "0.33" +egui = "0.33.3" +eframe = { version = "0.33.3", default-features = true, features = ["wgpu"] } +egui_extras = { version = "0.33.3", features = ["image", "svg", "syntect"] } +egui-wgpu = "0.33.3" egui_code_editor = "0.2" # GPU Rendering diff --git a/lightningbeam-ui/lightningbeam-editor/Cargo.toml b/lightningbeam-ui/lightningbeam-editor/Cargo.toml index d21565b..3896e4d 100644 --- a/lightningbeam-ui/lightningbeam-editor/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-editor/Cargo.toml @@ -15,7 +15,7 @@ eframe = { workspace = true } egui_extras = { workspace = true } egui-wgpu = { workspace = true } egui_code_editor = { workspace = true } -egui-snarl = "0.9" +egui_node_graph2 = { git = "https://github.com/PVDoriginal/egui_node_graph2" } # GPU wgpu = { workspace = true } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs index 0f87df1..1d5fcb5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs @@ -1,207 +1,306 @@ -//! Graph Data Types for egui-snarl +//! Graph Data Types for egui_node_graph2 //! -//! Node definitions and viewer implementation for audio/MIDI node graph +//! Node definitions and trait implementations for audio/MIDI node graph -use super::backend::BackendNodeId; -use super::node_types::DataType as SignalType; use eframe::egui; -use egui_snarl::ui::{PinInfo, SnarlStyle, SnarlViewer}; -use egui_snarl::{InPin, NodeId, OutPin, Snarl}; +use egui_node_graph2::*; +use serde::{Deserialize, Serialize}; -/// Audio/MIDI node types -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -pub enum AudioNode { - /// Oscillator generator - Oscillator { - frequency: f32, - waveform: String, - }, - /// Noise generator - Noise { - color: String, - }, - /// Audio filter - Filter { - cutoff: f32, - resonance: f32, - }, - /// Gain/volume control - Gain { - gain: f32, - }, - /// ADSR envelope - Adsr { - attack: f32, - decay: f32, - sustain: f32, - release: f32, - }, - /// LFO modulator - Lfo { - frequency: f32, - waveform: String, - }, - /// Audio output - AudioOutput, - /// MIDI input +/// Signal types for audio node graph +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum DataType { + Audio, + Midi, + CV, +} + +/// Node templates - types of nodes that can be created +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum NodeTemplate { + // Inputs MidiInput, + AudioInput, + + // Generators + Oscillator, + Noise, + + // Effects + Filter, + Gain, + + // Utilities + Adsr, + Lfo, + + // Outputs + AudioOutput, } -impl AudioNode { - /// Get the display name for this node type - pub fn type_name(&self) -> &'static str { +/// Custom node data - empty for now, can be extended +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct NodeData; + +/// Custom graph state - can track selected nodes, etc. +#[derive(Default)] +pub struct GraphState { + pub active_node: Option, +} + +/// User response type (empty for now) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UserResponse {} + +impl UserResponseTrait for UserResponse {} + +/// Value types for inline parameters +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum ValueType { + Float { value: f32 }, + String { value: String }, +} + +impl Default for ValueType { + fn default() -> Self { + ValueType::Float { value: 0.0 } + } +} + +// Implement DataTypeTrait for our signal types +impl DataTypeTrait for DataType { + fn data_type_color(&self, _user_state: &mut GraphState) -> egui::Color32 { match self { - AudioNode::Oscillator { .. } => "Oscillator", - AudioNode::Noise { .. } => "Noise", - AudioNode::Filter { .. } => "Filter", - AudioNode::Gain { .. } => "Gain", - AudioNode::Adsr { .. } => "ADSR", - AudioNode::Lfo { .. } => "LFO", - AudioNode::AudioOutput => "Audio Output", - AudioNode::MidiInput => "MIDI Input", + DataType::Audio => egui::Color32::from_rgb(100, 150, 255), // Blue + DataType::Midi => egui::Color32::from_rgb(100, 255, 100), // Green + DataType::CV => egui::Color32::from_rgb(255, 150, 100), // Orange } } - /// Get the signal type for an output pin - fn output_type(&self, _pin: usize) -> SignalType { + fn name(&self) -> std::borrow::Cow<'_, str> { match self { - AudioNode::MidiInput => SignalType::Midi, - AudioNode::Lfo { .. } => SignalType::CV, - AudioNode::Adsr { .. } => SignalType::CV, - _ => SignalType::Audio, - } - } - - /// Get the signal type for an input pin - fn input_type(&self, pin: usize) -> SignalType { - match self { - AudioNode::Filter { .. } => { - if pin == 0 { - SignalType::Audio - } else { - SignalType::CV - } - } - AudioNode::Gain { .. } => { - if pin == 0 { - SignalType::Audio - } else { - SignalType::CV - } - } - _ => SignalType::Audio, + DataType::Audio => "Audio".into(), + DataType::Midi => "MIDI".into(), + DataType::CV => "CV".into(), } } } -/// Viewer implementation for audio node graph -pub struct AudioNodeViewer; +// Implement NodeTemplateTrait for our node types +impl NodeTemplateTrait for NodeTemplate { + type NodeData = NodeData; + type DataType = DataType; + type ValueType = ValueType; + type UserState = GraphState; + type CategoryType = &'static str; -impl SnarlViewer for AudioNodeViewer { - fn title(&mut self, node: &AudioNode) -> String { - node.type_name().to_string() - } - - fn inputs(&mut self, node: &AudioNode) -> usize { - match node { - AudioNode::Oscillator { .. } => 1, // FM input - AudioNode::Noise { .. } => 0, - AudioNode::Filter { .. } => 2, // Audio + cutoff CV - AudioNode::Gain { .. } => 2, // Audio + gain CV - AudioNode::Adsr { .. } => 1, // Gate/trigger - AudioNode::Lfo { .. } => 0, - AudioNode::AudioOutput => 1, - AudioNode::MidiInput => 0, + fn node_finder_label(&self, _user_state: &mut Self::UserState) -> std::borrow::Cow<'_, str> { + match self { + NodeTemplate::MidiInput => "MIDI Input".into(), + NodeTemplate::AudioInput => "Audio Input".into(), + NodeTemplate::Oscillator => "Oscillator".into(), + NodeTemplate::Noise => "Noise".into(), + NodeTemplate::Filter => "Filter".into(), + NodeTemplate::Gain => "Gain".into(), + NodeTemplate::Adsr => "ADSR".into(), + NodeTemplate::Lfo => "LFO".into(), + NodeTemplate::AudioOutput => "Audio Output".into(), } } - fn outputs(&mut self, node: &AudioNode) -> usize { - match node { - AudioNode::AudioOutput => 0, - _ => 1, + fn node_finder_categories(&self, _user_state: &mut Self::UserState) -> Vec<&'static str> { + match self { + NodeTemplate::MidiInput | NodeTemplate::AudioInput => vec!["Inputs"], + NodeTemplate::Oscillator | NodeTemplate::Noise => vec!["Generators"], + NodeTemplate::Filter | NodeTemplate::Gain => vec!["Effects"], + NodeTemplate::Adsr | NodeTemplate::Lfo => vec!["Utilities"], + NodeTemplate::AudioOutput => vec!["Outputs"], } } - fn show_input( - &mut self, - pin: &InPin, - ui: &mut egui::Ui, - snarl: &mut Snarl, - ) -> PinInfo { - let node = &snarl[pin.id.node]; - let signal_type = node.input_type(pin.id.input); - - ui.label(match pin.id.input { - 0 => match node { - AudioNode::Oscillator { .. } => "FM", - AudioNode::Filter { .. } => "In", - AudioNode::Gain { .. } => "In", - AudioNode::Adsr { .. } => "Gate", - AudioNode::AudioOutput => "In", - _ => "In", - }, - 1 => match node { - AudioNode::Filter { .. } => "Cutoff", - AudioNode::Gain { .. } => "Gain", - _ => "In", - }, - _ => "In", - }); - - PinInfo::square().with_fill(signal_type.color()) + fn node_graph_label(&self, user_state: &mut Self::UserState) -> String { + self.node_finder_label(user_state).into() } - fn show_output( - &mut self, - pin: &OutPin, - ui: &mut egui::Ui, - snarl: &mut Snarl, - ) -> PinInfo { - let node = &snarl[pin.id.node]; - let signal_type = node.output_type(pin.id.output); - - ui.label("Out"); - - PinInfo::square().with_fill(signal_type.color()) + fn user_data(&self, _user_state: &mut Self::UserState) -> Self::NodeData { + NodeData } - fn connect(&mut self, from: &OutPin, to: &InPin, snarl: &mut Snarl) { - let from_node = &snarl[from.id.node]; - let to_node = &snarl[to.id.node]; - - let from_type = from_node.output_type(from.id.output); - let to_type = to_node.input_type(to.id.input); - - // Only allow connections between compatible signal types - if from_type == to_type { - // Disconnect existing connection to this input - for remote_out in snarl.in_pin(to.id).remotes.iter().copied().collect::>() { - snarl.disconnect(remote_out, to.id); - } - // Create new connection - snarl.connect(from.id, to.id); - } - } - - fn has_graph_menu(&mut self, _pos: egui::Pos2, _snarl: &mut Snarl) -> bool { - false // We use the palette instead - } - - fn has_node_menu(&mut self, _node: &AudioNode) -> bool { - true - } - - fn show_node_menu( - &mut self, - node: NodeId, - _inputs: &[InPin], - _outputs: &[OutPin], - ui: &mut egui::Ui, - snarl: &mut Snarl, + fn build_node( + &self, + graph: &mut Graph, + _user_state: &mut Self::UserState, + node_id: NodeId, ) { - if ui.button("Remove").clicked() { - snarl.remove_node(node); - ui.close_menu(); + match self { + NodeTemplate::Oscillator => { + // FM input + graph.add_input_param( + node_id, + "FM".into(), + DataType::Audio, + ValueType::Float { value: 0.0 }, + InputParamKind::ConnectionOnly, + true, + ); + // Frequency parameter + graph.add_input_param( + node_id, + "Freq".into(), + DataType::CV, + ValueType::Float { value: 440.0 }, + InputParamKind::ConstantOnly, + true, + ); + // Audio output + graph.add_output_param(node_id, "Out".into(), DataType::Audio); + } + NodeTemplate::Noise => { + graph.add_output_param(node_id, "Out".into(), DataType::Audio); + } + NodeTemplate::Filter => { + graph.add_input_param( + node_id, + "In".into(), + DataType::Audio, + ValueType::Float { value: 0.0 }, + InputParamKind::ConnectionOnly, + true, + ); + graph.add_input_param( + node_id, + "Cutoff".into(), + DataType::CV, + ValueType::Float { value: 1000.0 }, + InputParamKind::ConnectionOrConstant, + true, + ); + graph.add_output_param(node_id, "Out".into(), DataType::Audio); + } + NodeTemplate::Gain => { + graph.add_input_param( + node_id, + "In".into(), + DataType::Audio, + ValueType::Float { value: 0.0 }, + InputParamKind::ConnectionOnly, + true, + ); + graph.add_input_param( + node_id, + "Gain".into(), + DataType::CV, + ValueType::Float { value: 1.0 }, + InputParamKind::ConnectionOrConstant, + true, + ); + graph.add_output_param(node_id, "Out".into(), DataType::Audio); + } + NodeTemplate::Adsr => { + graph.add_input_param( + node_id, + "Gate".into(), + DataType::Midi, + ValueType::Float { value: 0.0 }, + InputParamKind::ConnectionOnly, + true, + ); + graph.add_output_param(node_id, "Out".into(), DataType::CV); + } + NodeTemplate::Lfo => { + graph.add_output_param(node_id, "Out".into(), DataType::CV); + } + NodeTemplate::AudioOutput => { + graph.add_input_param( + node_id, + "In".into(), + DataType::Audio, + ValueType::Float { value: 0.0 }, + InputParamKind::ConnectionOnly, + true, + ); + } + NodeTemplate::AudioInput => { + graph.add_output_param(node_id, "Out".into(), DataType::Audio); + } + NodeTemplate::MidiInput => { + graph.add_output_param(node_id, "Out".into(), DataType::Midi); + } } } } + +// Implement WidgetValueTrait for parameter editing +impl WidgetValueTrait for ValueType { + type Response = UserResponse; + type UserState = GraphState; + type NodeData = NodeData; + + fn value_widget( + &mut self, + param_name: &str, + _node_id: NodeId, + ui: &mut egui::Ui, + _user_state: &mut Self::UserState, + _node_data: &Self::NodeData, + ) -> Vec { + match self { + ValueType::Float { value } => { + ui.horizontal(|ui| { + ui.label(param_name); + ui.add(egui::DragValue::new(value).speed(0.1)); + }); + } + ValueType::String { value } => { + ui.horizontal(|ui| { + ui.label(param_name); + ui.text_edit_singleline(value); + }); + } + } + vec![] + } +} + +// Implement NodeDataTrait for custom node UI (optional) +impl NodeDataTrait for NodeData { + type Response = UserResponse; + type UserState = GraphState; + type DataType = DataType; + type ValueType = ValueType; + + fn bottom_ui( + &self, + ui: &mut egui::Ui, + _node_id: NodeId, + _graph: &Graph, + _user_state: &mut Self::UserState, + ) -> Vec> + where + Self::Response: UserResponseTrait, + { + // No custom UI for now + ui.label(""); + vec![] + } +} + +// Iterator for all node templates +pub struct AllNodeTemplates; + +impl NodeTemplateIter for AllNodeTemplates { + type Item = NodeTemplate; + + fn all_kinds(&self) -> Vec { + vec![ + NodeTemplate::MidiInput, + NodeTemplate::AudioInput, + NodeTemplate::Oscillator, + NodeTemplate::Noise, + NodeTemplate::Filter, + NodeTemplate::Gain, + NodeTemplate::Adsr, + NodeTemplate::Lfo, + NodeTemplate::AudioOutput, + ] + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs index 5651269..afc27ed 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -7,32 +7,22 @@ pub mod audio_backend; pub mod backend; pub mod graph_data; pub mod node_types; -pub mod palette; use backend::{BackendNodeId, GraphBackend}; -use graph_data::{AudioNode, AudioNodeViewer}; -use node_types::NodeTypeRegistry; -use palette::NodePalette; +use graph_data::{AllNodeTemplates, DataType, GraphState, NodeData, NodeTemplate, ValueType}; use super::NodePath; use eframe::egui; -use egui_snarl::ui::{SnarlWidget, SnarlStyle, BackgroundPattern, Grid}; -use egui_snarl::Snarl; +use egui_node_graph2::*; use std::collections::HashMap; use uuid::Uuid; -/// Node graph pane with egui-snarl integration +/// Node graph pane with egui_node_graph2 integration pub struct NodeGraphPane { - /// The graph structure - snarl: Snarl, + /// The graph editor state + state: GraphEditorState, - /// Node viewer for rendering - viewer: AudioNodeViewer, - - /// Node palette (left sidebar) - palette: NodePalette, - - /// Node type registry - node_registry: NodeTypeRegistry, + /// User state for the graph + user_state: GraphState, /// Backend integration #[allow(dead_code)] @@ -40,7 +30,7 @@ pub struct NodeGraphPane { /// Maps frontend node IDs to backend node IDs #[allow(dead_code)] - node_id_map: HashMap, + node_id_map: HashMap, /// Track ID this graph belongs to #[allow(dead_code)] @@ -49,38 +39,26 @@ pub struct NodeGraphPane { /// Pending action to execute #[allow(dead_code)] pending_action: Option>, - - /// Counter for offsetting clicked nodes - click_node_offset: f32, } impl NodeGraphPane { pub fn new() -> Self { - let mut snarl = Snarl::new(); - - // Add a test node to verify rendering works - snarl.insert_node( - egui::pos2(300.0, 200.0), - AudioNode::Oscillator { - frequency: 440.0, - waveform: "sine".to_string(), - }, - ); + let state = GraphEditorState::new(1.0); Self { - snarl, - viewer: AudioNodeViewer, - palette: NodePalette::new(), - node_registry: NodeTypeRegistry::new(), + state, + user_state: GraphState::default(), backend: None, node_id_map: HashMap::new(), track_id: None, pending_action: None, - click_node_offset: 0.0, } } - pub fn with_track_id(track_id: Uuid, audio_controller: std::sync::Arc>) -> Self { + pub fn with_track_id( + track_id: Uuid, + audio_controller: std::sync::Arc>, + ) -> Self { // Get backend track ID (placeholder - would need actual mapping) let backend_track_id = 0; @@ -90,15 +68,68 @@ impl NodeGraphPane { )); Self { - snarl: Snarl::new(), - viewer: AudioNodeViewer, - palette: NodePalette::new(), - node_registry: NodeTypeRegistry::new(), + state: GraphEditorState::new(1.0), + user_state: GraphState::default(), backend: Some(backend), node_id_map: HashMap::new(), track_id: Some(track_id), pending_action: None, - click_node_offset: 0.0, + } + } + + fn draw_dot_grid_background( + ui: &mut egui::Ui, + rect: egui::Rect, + bg_color: egui::Color32, + dot_color: egui::Color32, + pan_zoom: &egui_node_graph2::PanZoom, + ) { + let painter = ui.painter(); + + // Draw background + painter.rect_filled(rect, 0.0, bg_color); + + // Draw grid dots with pan/zoom transform + let grid_spacing = 20.0; + let dot_radius = 1.0 * pan_zoom.zoom; + + // Get pan offset and zoom + let pan = pan_zoom.pan; + let zoom = pan_zoom.zoom; + + // Calculate zoom center (same as nodes - they zoom relative to viewport center) + let half_size = rect.size() / 2.0; + let zoom_center = rect.min.to_vec2() + half_size - pan; + + // Calculate grid bounds in graph space + // Screen to graph: (screen_pos - zoom_center) / zoom + let graph_min = egui::pos2( + (rect.min.x - zoom_center.x) / zoom, + (rect.min.y - zoom_center.y) / zoom, + ); + let graph_max = egui::pos2( + (rect.max.x - zoom_center.x) / zoom, + (rect.max.y - zoom_center.y) / zoom, + ); + + let start_x = (graph_min.x / grid_spacing).floor() * grid_spacing; + let start_y = (graph_min.y / grid_spacing).floor() * grid_spacing; + + let mut y = start_y; + while y < graph_max.y { + let mut x = start_x; + while x < graph_max.x { + // Transform to screen space: graph_pos * zoom + zoom_center + let screen_pos = egui::pos2( + x * zoom + zoom_center.x, + y * zoom + zoom_center.y, + ); + if rect.contains(screen_pos) { + painter.circle_filled(screen_pos, dot_radius, dot_color); + } + x += grid_spacing; + } + y += grid_spacing; } } } @@ -109,70 +140,71 @@ impl crate::panes::PaneRenderer for NodeGraphPane { ui: &mut egui::Ui, rect: egui::Rect, _path: &NodePath, - _shared: &mut crate::panes::SharedPaneState, + shared: &mut crate::panes::SharedPaneState, ) { - // Use a horizontal layout for palette + graph + // Get colors from theme + let bg_style = shared.theme.style(".node-graph-background", ui.ctx()); + let grid_style = shared.theme.style(".node-graph-grid", ui.ctx()); + + let bg_color = bg_style.background_color.unwrap_or(egui::Color32::from_gray(45)); + let grid_color = grid_style.background_color.unwrap_or(egui::Color32::from_gray(55)); + + // Allocate the rect and render the graph editor within it ui.allocate_ui_at_rect(rect, |ui| { - ui.horizontal(|ui| { - // Track clicked node from palette - let mut clicked_node: Option = None; + // Check for scroll input to override library's default zoom behavior + let scroll_delta = ui.input(|i| i.smooth_scroll_delta); + let modifiers = ui.input(|i| i.modifiers); + let has_scroll = scroll_delta != egui::Vec2::ZERO; + let has_ctrl = modifiers.ctrl || modifiers.command; - // Left panel: Node palette (fixed width) - ui.allocate_ui_with_layout( - egui::vec2(200.0, rect.height()), - egui::Layout::top_down(egui::Align::Min), - |ui| { - let palette_rect = ui.available_rect_before_wrap(); - self.palette.render(ui, palette_rect, |node_type| { - clicked_node = Some(node_type.to_string()); - }); - }, - ); + // Save current zoom to detect if library changed it + let zoom_before = self.state.pan_zoom.zoom; + let pan_before = self.state.pan_zoom.pan; - // Right panel: Graph area (fill remaining space) - ui.allocate_ui_with_layout( - ui.available_size(), - egui::Layout::top_down(egui::Align::Min), - |ui| { - let mut style = SnarlStyle::new(); - style.bg_pattern = Some(BackgroundPattern::Grid(Grid::default())); + // Draw dot grid background with pan/zoom + let pan_zoom = &self.state.pan_zoom; + Self::draw_dot_grid_background(ui, rect, bg_color, grid_color, pan_zoom); - // Get the graph rect before showing the widget - let graph_rect = ui.available_rect_before_wrap(); + // Draw the graph editor (library will process scroll as zoom by default) + let _graph_response = self.state.draw_graph_editor( + ui, + AllNodeTemplates, + &mut self.user_state, + Vec::default(), + ); - let response = SnarlWidget::new() - .style(style) - .show(&mut self.snarl, &mut self.viewer, ui); + // Override library's default scroll behavior: + // - Library uses scroll for zoom + // - We want: scroll = pan, ctrl+scroll = zoom + if has_scroll && ui.rect_contains_pointer(rect) { + if !has_ctrl { + // Scroll without ctrl: library zoomed, but we want pan instead + // Undo the zoom and apply pan + if self.state.pan_zoom.zoom != zoom_before { + // Library changed zoom - revert it + let undo_zoom = zoom_before / self.state.pan_zoom.zoom; + self.state.zoom(ui, undo_zoom); + } + // Apply pan + self.state.pan_zoom.pan = pan_before + scroll_delta; + } + // If ctrl is held, library already zoomed correctly, so do nothing + } - // Handle drop first - check for released payload - let mut handled_drop = false; - if let Some(payload) = response.dnd_release_payload::() { - // Try using hover_pos from response, which should be in the right coordinate space - if let Some(pos) = response.hover_pos() { - println!("Drop detected! Node type: {} at hover_pos {:?}", payload, pos); - self.add_node_at_position(&payload, pos); - handled_drop = true; - } - } - - // Add clicked node at center only if we didn't handle a drop - if !handled_drop { - if let Some(ref node_type) = clicked_node { - // Place at a fixed graph-space position (origin) with small offset to avoid stacking - // This ensures nodes appear at a predictable location regardless of pan/zoom - let pos = egui::pos2(self.click_node_offset, self.click_node_offset); - self.click_node_offset += 30.0; - if self.click_node_offset > 300.0 { - self.click_node_offset = 0.0; - } - println!("Click detected! Adding {} at graph origin with offset {:?}", node_type, pos); - self.add_node_at_position(node_type, pos); - } - } - }, - ); - }); + // Draw menu button in top-left corner + let button_pos = rect.min + egui::vec2(8.0, 8.0); + ui.allocate_ui_at_rect( + egui::Rect::from_min_size(button_pos, egui::vec2(100.0, 24.0)), + |ui| { + if ui.button("➕ Add Node").clicked() { + // Open node finder at button's top-left position + self.state.node_finder = Some(egui_node_graph2::NodeFinder::new_at(button_pos)); + } + }, + ); }); + + // TODO: Handle node responses and sync with backend } fn name(&self) -> &str { @@ -180,50 +212,6 @@ impl crate::panes::PaneRenderer for NodeGraphPane { } } -impl NodeGraphPane { - /// Add a node at a specific position - fn add_node_at_position(&mut self, node_type: &str, pos: egui::Pos2) { - println!("add_node_at_position called with: {} at {:?}", node_type, pos); - - // Map node type string to AudioNode enum - let node = match node_type { - "Oscillator" => AudioNode::Oscillator { - frequency: 440.0, - waveform: "sine".to_string(), - }, - "Noise" => AudioNode::Noise { - color: "white".to_string(), - }, - "Filter" => AudioNode::Filter { - cutoff: 1000.0, - resonance: 0.5, - }, - "Gain" => AudioNode::Gain { gain: 1.0 }, - "ADSR" => AudioNode::Adsr { - attack: 0.01, - decay: 0.1, - sustain: 0.7, - release: 0.3, - }, - "LFO" => AudioNode::Lfo { - frequency: 1.0, - waveform: "sine".to_string(), - }, - "AudioOutput" => AudioNode::AudioOutput, - "AudioInput" => AudioNode::AudioOutput, // Map to output for now - "MidiInput" => AudioNode::MidiInput, - _ => { - eprintln!("Unknown node type: {}", node_type); - return; - } - }; - - let node_id = self.snarl.insert_node(pos, node); - - println!("Added node: {} (ID: {:?}) at position {:?}", node_type, node_id, pos); - } -} - impl Default for NodeGraphPane { fn default() -> Self { Self::new()