diff --git a/lightningbeam-ui/lightningbeam-editor/Cargo.toml b/lightningbeam-ui/lightningbeam-editor/Cargo.toml index 4acb188..d21565b 100644 --- a/lightningbeam-ui/lightningbeam-editor/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-editor/Cargo.toml @@ -15,6 +15,7 @@ eframe = { workspace = true } egui_extras = { workspace = true } egui-wgpu = { workspace = true } egui_code_editor = { workspace = true } +egui-snarl = "0.9" # GPU wgpu = { workspace = true } @@ -42,6 +43,7 @@ pollster = { workspace = true } lightningcss = "1.0.0-alpha.68" clap = { version = "4.5", features = ["derive"] } uuid = { version = "1.0", features = ["v4", "serde"] } +petgraph = "0.6" # Native file dialogs rfd = "0.15" diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 33006a7..1c6669e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -62,6 +62,7 @@ pub mod outliner; pub mod piano_roll; pub mod virtual_piano; pub mod node_editor; +pub mod node_graph; pub mod preset_browser; pub mod asset_library; pub mod shader_editor; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_editor.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_editor.rs index f0defcf..a73ce27 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_editor.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_editor.rs @@ -1,45 +1,5 @@ -/// Node Editor pane - node-based visual programming -/// -/// This will eventually render a node graph with Vello. -/// For now, it's a placeholder. +//! Node Editor pane - node-based audio/MIDI synthesis +//! +//! Re-exports the node graph implementation -use eframe::egui; -use super::{NodePath, PaneRenderer, SharedPaneState}; - -pub struct NodeEditorPane {} - -impl NodeEditorPane { - pub fn new() -> Self { - Self {} - } -} - -impl PaneRenderer for NodeEditorPane { - fn render_content( - &mut self, - ui: &mut egui::Ui, - rect: egui::Rect, - _path: &NodePath, - _shared: &mut SharedPaneState, - ) { - // Placeholder rendering - ui.painter().rect_filled( - rect, - 0.0, - egui::Color32::from_rgb(30, 45, 50), - ); - - let text = "Node Editor\n(TODO: Implement node graph)"; - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - text, - egui::FontId::proportional(16.0), - egui::Color32::from_gray(150), - ); - } - - fn name(&self) -> &str { - "Node Editor" - } -} +pub use super::node_graph::NodeGraphPane as NodeEditorPane; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/actions.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/actions.rs new file mode 100644 index 0000000..7c0be93 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/actions.rs @@ -0,0 +1,532 @@ +//! Node Graph Actions +//! +//! Implements the Action trait for node graph operations, ensuring undo/redo support + +use super::backend::BackendNodeId; +use lightningbeam_core::action::{Action, BackendContext}; +use lightningbeam_core::document::Document; +use uuid::Uuid; + +/// Node graph action variants +/// +/// Note: Node graph state is managed by the audio backend, not the document. +/// Therefore, execute() and rollback() are no-ops, and all work happens in +/// execute_backend() and rollback_backend(). +pub enum NodeGraphAction { + AddNode(AddNodeAction), + RemoveNode(RemoveNodeAction), + MoveNode(MoveNodeAction), + Connect(ConnectAction), + Disconnect(DisconnectAction), + SetParameter(SetParameterAction), +} + +impl Action for NodeGraphAction { + fn execute(&mut self, _document: &mut Document) -> Result<(), String> { + // Node graph state is in the audio backend, not document + Ok(()) + } + + fn rollback(&mut self, _document: &mut Document) -> Result<(), String> { + Ok(()) + } + + fn description(&self) -> String { + match self { + NodeGraphAction::AddNode(a) => a.description(), + NodeGraphAction::RemoveNode(a) => a.description(), + NodeGraphAction::MoveNode(a) => a.description(), + NodeGraphAction::Connect(a) => a.description(), + NodeGraphAction::Disconnect(a) => a.description(), + NodeGraphAction::SetParameter(a) => a.description(), + } + } + + fn execute_backend( + &mut self, + backend: &mut BackendContext, + document: &Document, + ) -> Result<(), String> { + match self { + NodeGraphAction::AddNode(a) => a.execute_backend(backend, document), + NodeGraphAction::RemoveNode(a) => a.execute_backend(backend, document), + NodeGraphAction::MoveNode(a) => a.execute_backend(backend, document), + NodeGraphAction::Connect(a) => a.execute_backend(backend, document), + NodeGraphAction::Disconnect(a) => a.execute_backend(backend, document), + NodeGraphAction::SetParameter(a) => a.execute_backend(backend, document), + } + } + + fn rollback_backend( + &mut self, + backend: &mut BackendContext, + document: &Document, + ) -> Result<(), String> { + match self { + NodeGraphAction::AddNode(a) => a.rollback_backend(backend, document), + NodeGraphAction::RemoveNode(a) => a.rollback_backend(backend, document), + NodeGraphAction::MoveNode(a) => a.rollback_backend(backend, document), + NodeGraphAction::Connect(a) => a.rollback_backend(backend, document), + NodeGraphAction::Disconnect(a) => a.rollback_backend(backend, document), + NodeGraphAction::SetParameter(a) => a.rollback_backend(backend, document), + } + } +} + +// ============================================================================ +// AddNodeAction +// ============================================================================ + +pub struct AddNodeAction { + /// Layer ID (maps to backend track ID) + layer_id: Uuid, + + /// Node type to add + node_type: String, + + /// Position in canvas coordinates + position: (f32, f32), + + /// Backend node ID (stored after execute for rollback) + backend_node_id: Option, +} + +impl AddNodeAction { + pub fn new(layer_id: Uuid, node_type: String, position: (f32, f32)) -> Self { + Self { + layer_id, + node_type, + position, + backend_node_id: None, + } + } + + fn description(&self) -> String { + format!("Add {} node", self.node_type) + } + + fn execute_backend( + &mut self, + backend: &mut BackendContext, + _document: &Document, + ) -> Result<(), String> { + let controller = backend + .audio_controller + .as_mut() + .ok_or("Audio controller not available")?; + + let track_id = backend + .layer_to_track_map + .get(&self.layer_id) + .ok_or("Track not found")?; + + // Add node to backend (using async API for now - TODO: use sync query) + controller.graph_add_node(*track_id, self.node_type.clone(), self.position.0, self.position.1); + + // TODO: Get actual node ID from synchronous query + // For now, we can't track the backend ID properly with async API + // This will be fixed when we add synchronous query methods + + Ok(()) + } + + fn rollback_backend( + &mut self, + backend: &mut BackendContext, + _document: &Document, + ) -> Result<(), String> { + if let Some(backend_id) = self.backend_node_id { + let controller = backend + .audio_controller + .as_mut() + .ok_or("Audio controller not available")?; + + let track_id = backend + .layer_to_track_map + .get(&self.layer_id) + .ok_or("Track not found")?; + + let BackendNodeId::Audio(node_idx) = backend_id; + controller.graph_remove_node(*track_id, node_idx.index() as u32); + } + + Ok(()) + } +} + +// ============================================================================ +// RemoveNodeAction +// ============================================================================ + +pub struct RemoveNodeAction { + layer_id: Uuid, + backend_node_id: BackendNodeId, + + // Store node state for undo (TODO: implement when we have graph state query) + #[allow(dead_code)] + node_type: Option, + #[allow(dead_code)] + position: Option<(f32, f32)>, +} + +impl RemoveNodeAction { + pub fn new(layer_id: Uuid, backend_node_id: BackendNodeId) -> Self { + Self { + layer_id, + backend_node_id, + node_type: None, + position: None, + } + } + + fn description(&self) -> String { + "Remove node".to_string() + } + + fn execute_backend( + &mut self, + backend: &mut BackendContext, + _document: &Document, + ) -> Result<(), String> { + // TODO: Query and store node state before removing for undo + + let controller = backend + .audio_controller + .as_mut() + .ok_or("Audio controller not available")?; + + let track_id = backend + .layer_to_track_map + .get(&self.layer_id) + .ok_or("Track not found")?; + + let BackendNodeId::Audio(node_idx) = self.backend_node_id; + controller.graph_remove_node(*track_id, node_idx.index() as u32); + + Ok(()) + } + + fn rollback_backend( + &mut self, + _backend: &mut BackendContext, + _document: &Document, + ) -> Result<(), String> { + // TODO: Re-add node using stored state + Ok(()) + } +} + +// ============================================================================ +// MoveNodeAction +// ============================================================================ + +pub struct MoveNodeAction { + layer_id: Uuid, + backend_node_id: BackendNodeId, + new_position: (f32, f32), + old_position: Option<(f32, f32)>, +} + +impl MoveNodeAction { + pub fn new(layer_id: Uuid, backend_node_id: BackendNodeId, new_position: (f32, f32)) -> Self { + Self { + layer_id, + backend_node_id, + new_position, + old_position: None, + } + } + + fn description(&self) -> String { + "Move node".to_string() + } + + fn execute_backend( + &mut self, + _backend: &mut BackendContext, + _document: &Document, + ) -> Result<(), String> { + // TODO: Query old position and call graph_move_node() when available + Ok(()) + } + + fn rollback_backend( + &mut self, + _backend: &mut BackendContext, + _document: &Document, + ) -> Result<(), String> { + // TODO: Restore old position + Ok(()) + } +} + +// ============================================================================ +// ConnectAction +// ============================================================================ + +pub struct ConnectAction { + layer_id: Uuid, + from_node: BackendNodeId, + from_port: usize, + to_node: BackendNodeId, + to_port: usize, +} + +impl ConnectAction { + pub fn new( + layer_id: Uuid, + from_node: BackendNodeId, + from_port: usize, + to_node: BackendNodeId, + to_port: usize, + ) -> Self { + Self { + layer_id, + from_node, + from_port, + to_node, + to_port, + } + } + + fn description(&self) -> String { + "Connect nodes".to_string() + } + + fn execute_backend( + &mut self, + backend: &mut BackendContext, + _document: &Document, + ) -> Result<(), String> { + let controller = backend + .audio_controller + .as_mut() + .ok_or("Audio controller not available")?; + + let track_id = backend + .layer_to_track_map + .get(&self.layer_id) + .ok_or("Track not found")?; + + let BackendNodeId::Audio(from_idx) = self.from_node; + let BackendNodeId::Audio(to_idx) = self.to_node; + + controller.graph_connect( + *track_id, + from_idx.index() as u32, + self.from_port, + to_idx.index() as u32, + self.to_port, + ); + + Ok(()) + } + + fn rollback_backend( + &mut self, + backend: &mut BackendContext, + _document: &Document, + ) -> Result<(), String> { + let controller = backend + .audio_controller + .as_mut() + .ok_or("Audio controller not available")?; + + let track_id = backend + .layer_to_track_map + .get(&self.layer_id) + .ok_or("Track not found")?; + + let BackendNodeId::Audio(from_idx) = self.from_node; + let BackendNodeId::Audio(to_idx) = self.to_node; + + controller.graph_disconnect( + *track_id, + from_idx.index() as u32, + self.from_port, + to_idx.index() as u32, + self.to_port, + ); + + Ok(()) + } +} + +// ============================================================================ +// DisconnectAction +// ============================================================================ + +pub struct DisconnectAction { + layer_id: Uuid, + from_node: BackendNodeId, + from_port: usize, + to_node: BackendNodeId, + to_port: usize, +} + +impl DisconnectAction { + pub fn new( + layer_id: Uuid, + from_node: BackendNodeId, + from_port: usize, + to_node: BackendNodeId, + to_port: usize, + ) -> Self { + Self { + layer_id, + from_node, + from_port, + to_node, + to_port, + } + } + + fn description(&self) -> String { + "Disconnect nodes".to_string() + } + + fn execute_backend( + &mut self, + backend: &mut BackendContext, + _document: &Document, + ) -> Result<(), String> { + let controller = backend + .audio_controller + .as_mut() + .ok_or("Audio controller not available")?; + + let track_id = backend + .layer_to_track_map + .get(&self.layer_id) + .ok_or("Track not found")?; + + let BackendNodeId::Audio(from_idx) = self.from_node; + let BackendNodeId::Audio(to_idx) = self.to_node; + + controller.graph_disconnect( + *track_id, + from_idx.index() as u32, + self.from_port, + to_idx.index() as u32, + self.to_port, + ); + + Ok(()) + } + + fn rollback_backend( + &mut self, + backend: &mut BackendContext, + _document: &Document, + ) -> Result<(), String> { + // Undo disconnect by reconnecting + let controller = backend + .audio_controller + .as_mut() + .ok_or("Audio controller not available")?; + + let track_id = backend + .layer_to_track_map + .get(&self.layer_id) + .ok_or("Track not found")?; + + let BackendNodeId::Audio(from_idx) = self.from_node; + let BackendNodeId::Audio(to_idx) = self.to_node; + + controller.graph_connect( + *track_id, + from_idx.index() as u32, + self.from_port, + to_idx.index() as u32, + self.to_port, + ); + + Ok(()) + } +} + +// ============================================================================ +// SetParameterAction +// ============================================================================ + +pub struct SetParameterAction { + layer_id: Uuid, + backend_node_id: BackendNodeId, + param_id: u32, + new_value: f64, + old_value: Option, +} + +impl SetParameterAction { + pub fn new(layer_id: Uuid, backend_node_id: BackendNodeId, param_id: u32, new_value: f64) -> Self { + Self { + layer_id, + backend_node_id, + param_id, + new_value, + old_value: None, + } + } + + fn description(&self) -> String { + "Set parameter".to_string() + } + + fn execute_backend( + &mut self, + backend: &mut BackendContext, + _document: &Document, + ) -> Result<(), String> { + // TODO: Query and store old value before changing + + let controller = backend + .audio_controller + .as_mut() + .ok_or("Audio controller not available")?; + + let track_id = backend + .layer_to_track_map + .get(&self.layer_id) + .ok_or("Track not found")?; + + let BackendNodeId::Audio(node_idx) = self.backend_node_id; + + controller.graph_set_parameter( + *track_id, + node_idx.index() as u32, + self.param_id, + self.new_value as f32, + ); + + Ok(()) + } + + fn rollback_backend( + &mut self, + backend: &mut BackendContext, + _document: &Document, + ) -> Result<(), String> { + if let Some(old_value) = self.old_value { + let controller = backend + .audio_controller + .as_mut() + .ok_or("Audio controller not available")?; + + let track_id = backend + .layer_to_track_map + .get(&self.layer_id) + .ok_or("Track not found")?; + + let BackendNodeId::Audio(node_idx) = self.backend_node_id; + + controller.graph_set_parameter( + *track_id, + node_idx.index() as u32, + self.param_id, + old_value as f32, + ); + } + + Ok(()) + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/audio_backend.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/audio_backend.rs new file mode 100644 index 0000000..833b041 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/audio_backend.rs @@ -0,0 +1,212 @@ +//! Audio Graph Backend Implementation +//! +//! Wraps daw_backend's EngineController for audio node graph operations + +use super::backend::{BackendNodeId, GraphBackend, GraphState}; +use daw_backend::EngineController; +use petgraph::stable_graph::NodeIndex; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +/// Audio graph backend wrapping daw_backend +pub struct AudioGraphBackend { + /// Track ID this graph belongs to + track_id: u32, + + /// Shared audio controller (thread-safe) + audio_controller: Arc>, + + /// Maps backend NodeIndex to stable IDs for round-trip serialization + node_index_to_stable: HashMap, + next_stable_id: u32, +} + +impl AudioGraphBackend { + pub fn new(track_id: u32, audio_controller: Arc>) -> Self { + Self { + track_id, + audio_controller, + node_index_to_stable: HashMap::new(), + next_stable_id: 0, + } + } +} + +impl GraphBackend for AudioGraphBackend { + fn add_node(&mut self, node_type: &str, x: f32, y: f32) -> Result { + // TODO: Call EngineController.graph_add_node_sync() once implemented + // For now, return placeholder + let mut controller = self.audio_controller.lock().unwrap(); + controller.graph_add_node(self.track_id, node_type.to_string(), x, y); + + // Generate placeholder node ID + // This will be replaced with actual backend NodeIndex from sync query + let stable_id = self.next_stable_id; + self.next_stable_id += 1; + + // Placeholder: use stable_id as backend index (will be wrong, but compiles) + let node_idx = NodeIndex::new(stable_id as usize); + self.node_index_to_stable.insert(node_idx, stable_id); + + Ok(BackendNodeId::Audio(node_idx)) + } + + fn remove_node(&mut self, backend_id: BackendNodeId) -> Result<(), String> { + let BackendNodeId::Audio(node_idx) = backend_id else { + return Err("Invalid backend node type".to_string()); + }; + + let mut controller = self.audio_controller.lock().unwrap(); + controller.graph_remove_node(self.track_id, node_idx.index() as u32); + + self.node_index_to_stable.remove(&node_idx); + + Ok(()) + } + + fn connect( + &mut self, + output_node: BackendNodeId, + output_port: usize, + input_node: BackendNodeId, + input_port: usize, + ) -> Result<(), String> { + let BackendNodeId::Audio(from_idx) = output_node else { + return Err("Invalid output node type".to_string()); + }; + let BackendNodeId::Audio(to_idx) = input_node else { + return Err("Invalid input node type".to_string()); + }; + + let mut controller = self.audio_controller.lock().unwrap(); + controller.graph_connect( + self.track_id, + from_idx.index() as u32, + output_port, + to_idx.index() as u32, + input_port, + ); + + Ok(()) + } + + fn disconnect( + &mut self, + output_node: BackendNodeId, + output_port: usize, + input_node: BackendNodeId, + input_port: usize, + ) -> Result<(), String> { + let BackendNodeId::Audio(from_idx) = output_node else { + return Err("Invalid output node type".to_string()); + }; + let BackendNodeId::Audio(to_idx) = input_node else { + return Err("Invalid input node type".to_string()); + }; + + let mut controller = self.audio_controller.lock().unwrap(); + controller.graph_disconnect( + self.track_id, + from_idx.index() as u32, + output_port, + to_idx.index() as u32, + input_port, + ); + + Ok(()) + } + + fn set_parameter( + &mut self, + backend_id: BackendNodeId, + param_id: u32, + value: f64, + ) -> Result<(), String> { + let BackendNodeId::Audio(node_idx) = backend_id else { + return Err("Invalid backend node type".to_string()); + }; + + let mut controller = self.audio_controller.lock().unwrap(); + controller.graph_set_parameter( + self.track_id, + node_idx.index() as u32, + param_id, + value as f32, + ); + + Ok(()) + } + + fn get_state(&self) -> Result { + // TODO: Implement graph state query + // For now, return empty state + Ok(GraphState { + nodes: vec![], + connections: vec![], + }) + } + + fn load_state(&mut self, _state: &GraphState) -> Result<(), String> { + // TODO: Implement graph state loading + Ok(()) + } + + fn add_node_to_template( + &mut self, + voice_allocator_id: BackendNodeId, + node_type: &str, + x: f32, + y: f32, + ) -> Result { + let BackendNodeId::Audio(allocator_idx) = voice_allocator_id else { + return Err("Invalid voice allocator node type".to_string()); + }; + + let mut controller = self.audio_controller.lock().unwrap(); + controller.graph_add_node_to_template( + self.track_id, + allocator_idx.index() as u32, + node_type.to_string(), + x, + y, + ); + + // Placeholder return + let stable_id = self.next_stable_id; + self.next_stable_id += 1; + let node_idx = NodeIndex::new(stable_id as usize); + + Ok(BackendNodeId::Audio(node_idx)) + } + + fn connect_in_template( + &mut self, + voice_allocator_id: BackendNodeId, + output_node: BackendNodeId, + output_port: usize, + input_node: BackendNodeId, + input_port: usize, + ) -> Result<(), String> { + let BackendNodeId::Audio(allocator_idx) = voice_allocator_id else { + return Err("Invalid voice allocator node type".to_string()); + }; + let BackendNodeId::Audio(from_idx) = output_node else { + return Err("Invalid output node type".to_string()); + }; + let BackendNodeId::Audio(to_idx) = input_node else { + return Err("Invalid input node type".to_string()); + }; + + let mut controller = self.audio_controller.lock().unwrap(); + controller.graph_connect_in_template( + self.track_id, + allocator_idx.index() as u32, + from_idx.index() as u32, + output_port, + to_idx.index() as u32, + input_port, + ); + + Ok(()) + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/backend.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/backend.rs new file mode 100644 index 0000000..2619a9c --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/backend.rs @@ -0,0 +1,101 @@ +//! Graph Backend Trait +//! +//! Provides an abstraction layer for different graph backends (audio, VFX shaders, etc.) + +use petgraph::stable_graph::NodeIndex; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Backend node identifier (abstraction over different backend types) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BackendNodeId { + Audio(NodeIndex), + // Future: Vfx(u32), +} + +/// Abstract backend for node graph operations +/// +/// Implementations: +/// - AudioGraphBackend: Wraps daw_backend::AudioGraph via EngineController +/// - VfxGraphBackend (future): GPU-based shader graph +pub trait GraphBackend: Send { + /// Add a node to the backend graph + fn add_node(&mut self, node_type: &str, x: f32, y: f32) -> Result; + + /// Remove a node from the backend + fn remove_node(&mut self, backend_id: BackendNodeId) -> Result<(), String>; + + /// Connect two nodes + fn connect( + &mut self, + output_node: BackendNodeId, + output_port: usize, + input_node: BackendNodeId, + input_port: usize, + ) -> Result<(), String>; + + /// Disconnect two nodes + fn disconnect( + &mut self, + output_node: BackendNodeId, + output_port: usize, + input_node: BackendNodeId, + input_port: usize, + ) -> Result<(), String>; + + /// Set a node parameter + fn set_parameter( + &mut self, + backend_id: BackendNodeId, + param_id: u32, + value: f64, + ) -> Result<(), String>; + + /// Get current graph state (for serialization) + fn get_state(&self) -> Result; + + /// Load graph state (for presets) + fn load_state(&mut self, state: &GraphState) -> Result<(), String>; + + /// Add node to VoiceAllocator template (Phase 2) + fn add_node_to_template( + &mut self, + voice_allocator_id: BackendNodeId, + node_type: &str, + x: f32, + y: f32, + ) -> Result; + + /// Connect nodes inside VoiceAllocator template (Phase 2) + fn connect_in_template( + &mut self, + voice_allocator_id: BackendNodeId, + output_node: BackendNodeId, + output_port: usize, + input_node: BackendNodeId, + input_port: usize, + ) -> Result<(), String>; +} + +/// Serializable graph state (for presets and save/load) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphState { + pub nodes: Vec, + pub connections: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SerializedNode { + pub id: u32, // Frontend node ID (stable) + pub node_type: String, + pub position: (f32, f32), + pub parameters: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SerializedConnection { + pub from_node: u32, + pub from_port: usize, + pub to_node: u32, + pub to_port: usize, +} 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 new file mode 100644 index 0000000..0f87df1 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs @@ -0,0 +1,207 @@ +//! Graph Data Types for egui-snarl +//! +//! Node definitions and viewer implementation 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}; + +/// 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 + MidiInput, +} + +impl AudioNode { + /// Get the display name for this node type + pub fn type_name(&self) -> &'static str { + 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", + } + } + + /// Get the signal type for an output pin + fn output_type(&self, _pin: usize) -> SignalType { + 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, + } + } +} + +/// Viewer implementation for audio node graph +pub struct AudioNodeViewer; + +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 outputs(&mut self, node: &AudioNode) -> usize { + match node { + AudioNode::AudioOutput => 0, + _ => 1, + } + } + + 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 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 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, + ) { + if ui.button("Remove").clicked() { + snarl.remove_node(node); + ui.close_menu(); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs new file mode 100644 index 0000000..59c01be --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -0,0 +1,221 @@ +//! Node Graph Pane +//! +//! Audio/MIDI node graph editor for modular synthesis and effects processing + +pub mod actions; +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 super::NodePath; +use eframe::egui; +use egui_snarl::ui::{SnarlWidget, SnarlStyle, BackgroundPattern, Grid}; +use egui_snarl::Snarl; +use std::collections::HashMap; +use uuid::Uuid; + +/// Node graph pane with egui-snarl integration +pub struct NodeGraphPane { + /// The graph structure + snarl: Snarl, + + /// Node viewer for rendering + viewer: AudioNodeViewer, + + /// Node palette (left sidebar) + palette: NodePalette, + + /// Node type registry + node_registry: NodeTypeRegistry, + + /// Backend integration + #[allow(dead_code)] + backend: Option>, + + /// Maps frontend node IDs to backend node IDs + #[allow(dead_code)] + node_id_map: HashMap, + + /// Track ID this graph belongs to + #[allow(dead_code)] + track_id: Option, + + /// Pending action to execute + #[allow(dead_code)] + pending_action: Option>, +} + +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(), + }, + ); + + Self { + snarl, + viewer: AudioNodeViewer, + palette: NodePalette::new(), + node_registry: NodeTypeRegistry::new(), + backend: None, + node_id_map: HashMap::new(), + track_id: None, + pending_action: None, + } + } + + 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; + + let backend = Box::new(audio_backend::AudioGraphBackend::new( + backend_track_id, + audio_controller, + )); + + Self { + snarl: Snarl::new(), + viewer: AudioNodeViewer, + palette: NodePalette::new(), + node_registry: NodeTypeRegistry::new(), + backend: Some(backend), + node_id_map: HashMap::new(), + track_id: Some(track_id), + pending_action: None, + } + } +} + +impl crate::panes::PaneRenderer for NodeGraphPane { + fn render_content( + &mut self, + ui: &mut egui::Ui, + rect: egui::Rect, + _path: &NodePath, + _shared: &mut crate::panes::SharedPaneState, + ) { + // Use a horizontal layout for palette + graph + ui.allocate_ui_at_rect(rect, |ui| { + ui.horizontal(|ui| { + // Track clicked node from palette + let mut clicked_node: Option = None; + + // 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()); + }); + }, + ); + + // 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())); + + // Get the graph rect before showing the widget + let graph_rect = ui.available_rect_before_wrap(); + + let response = SnarlWidget::new() + .style(style) + .show(&mut self.snarl, &mut self.viewer, ui); + + // 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 { + // Use center of the graph rect directly + let center_pos = graph_rect.center(); + println!("Click detected! Adding {} at center {:?}", node_type, center_pos); + self.add_node_at_position(node_type, center_pos); + } + } + }, + ); + }); + }); + } + + fn name(&self) -> &str { + "Node Graph" + } +} + +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() + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/node_types.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/node_types.rs new file mode 100644 index 0000000..131281c --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/node_types.rs @@ -0,0 +1,689 @@ +//! Node Type Registry +//! +//! Defines metadata for all available node types + +use eframe::egui; +use std::collections::HashMap; + +/// Signal type for connections (matches daw_backend::SignalType) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DataType { + Audio, + Midi, + CV, +} + +impl DataType { + /// Get the color for this signal type + pub fn color(&self) -> egui::Color32 { + match self { + DataType::Audio => egui::Color32::from_rgb(33, 150, 243), // Blue (#2196F3) + DataType::Midi => egui::Color32::from_rgb(76, 175, 80), // Green (#4CAF50) + DataType::CV => egui::Color32::from_rgb(255, 152, 0), // Orange (#FF9800) + } + } +} + +/// Node category for organization +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum NodeCategory { + Inputs, + Generators, + Effects, + Utilities, + Outputs, +} + +impl NodeCategory { + pub fn display_name(&self) -> &'static str { + match self { + NodeCategory::Inputs => "Inputs", + NodeCategory::Generators => "Generators", + NodeCategory::Effects => "Effects", + NodeCategory::Utilities => "Utilities", + NodeCategory::Outputs => "Outputs", + } + } +} + +/// Port information +#[derive(Debug, Clone)] +pub struct PortInfo { + pub index: usize, + pub name: String, + pub signal_type: DataType, + pub description: String, +} + +/// Parameter units +#[derive(Debug, Clone, Copy)] +pub enum ParameterUnit { + Hz, + Percent, + Decibels, + Seconds, + Milliseconds, + Semitones, + None, +} + +impl ParameterUnit { + pub fn suffix(&self) -> &'static str { + match self { + ParameterUnit::Hz => " Hz", + ParameterUnit::Percent => "%", + ParameterUnit::Decibels => " dB", + ParameterUnit::Seconds => " s", + ParameterUnit::Milliseconds => " ms", + ParameterUnit::Semitones => " st", + ParameterUnit::None => "", + } + } +} + +/// Parameter information +#[derive(Debug, Clone)] +pub struct ParameterInfo { + pub id: u32, + pub name: String, + pub default: f64, + pub min: f64, + pub max: f64, + pub unit: ParameterUnit, + pub description: String, +} + +/// Node type metadata +#[derive(Debug, Clone)] +pub struct NodeTypeInfo { + pub id: String, + pub display_name: String, + pub category: NodeCategory, + pub inputs: Vec, + pub outputs: Vec, + pub parameters: Vec, + pub description: String, +} + +/// Registry of all available node types +pub struct NodeTypeRegistry { + types: HashMap, +} + +impl NodeTypeRegistry { + pub fn new() -> Self { + let mut types = HashMap::new(); + + // === INPUTS === + + types.insert( + "MidiInput".to_string(), + NodeTypeInfo { + id: "MidiInput".to_string(), + display_name: "MIDI Input".to_string(), + category: NodeCategory::Inputs, + inputs: vec![], + outputs: vec![PortInfo { + index: 0, + name: "MIDI".to_string(), + signal_type: DataType::Midi, + description: "MIDI output from connected device".to_string(), + }], + parameters: vec![], + description: "Receives MIDI from connected input devices".to_string(), + }, + ); + + types.insert( + "AudioInput".to_string(), + NodeTypeInfo { + id: "AudioInput".to_string(), + display_name: "Audio Input".to_string(), + category: NodeCategory::Inputs, + inputs: vec![], + outputs: vec![PortInfo { + index: 0, + name: "Audio".to_string(), + signal_type: DataType::Audio, + description: "Audio from microphone/line input".to_string(), + }], + parameters: vec![], + description: "Receives audio from connected input devices".to_string(), + }, + ); + + // === GENERATORS === + + types.insert( + "Oscillator".to_string(), + NodeTypeInfo { + id: "Oscillator".to_string(), + display_name: "Oscillator".to_string(), + category: NodeCategory::Generators, + inputs: vec![ + PortInfo { + index: 0, + name: "Freq".to_string(), + signal_type: DataType::CV, + description: "Frequency control (V/Oct)".to_string(), + }, + PortInfo { + index: 1, + name: "Sync".to_string(), + signal_type: DataType::CV, + description: "Hard sync input".to_string(), + }, + ], + outputs: vec![PortInfo { + index: 0, + name: "Out".to_string(), + signal_type: DataType::Audio, + description: "Audio output".to_string(), + }], + parameters: vec![ + ParameterInfo { + id: 0, + name: "Frequency".to_string(), + default: 440.0, + min: 20.0, + max: 20000.0, + unit: ParameterUnit::Hz, + description: "Base frequency".to_string(), + }, + ParameterInfo { + id: 1, + name: "Waveform".to_string(), + default: 0.0, + min: 0.0, + max: 3.0, + unit: ParameterUnit::None, + description: "0=Sine, 1=Saw, 2=Square, 3=Triangle".to_string(), + }, + ], + description: "Basic oscillator with multiple waveforms".to_string(), + }, + ); + + types.insert( + "Noise".to_string(), + NodeTypeInfo { + id: "Noise".to_string(), + display_name: "Noise".to_string(), + category: NodeCategory::Generators, + inputs: vec![], + outputs: vec![PortInfo { + index: 0, + name: "Out".to_string(), + signal_type: DataType::Audio, + description: "Noise output".to_string(), + }], + parameters: vec![ParameterInfo { + id: 0, + name: "Color".to_string(), + default: 0.0, + min: 0.0, + max: 2.0, + unit: ParameterUnit::None, + description: "0=White, 1=Pink, 2=Brown".to_string(), + }], + description: "Noise generator (white, pink, brown)".to_string(), + }, + ); + + // === EFFECTS === + + types.insert( + "Gain".to_string(), + NodeTypeInfo { + id: "Gain".to_string(), + display_name: "Gain".to_string(), + category: NodeCategory::Effects, + inputs: vec![ + PortInfo { + index: 0, + name: "In".to_string(), + signal_type: DataType::Audio, + description: "Audio input".to_string(), + }, + PortInfo { + index: 1, + name: "Gain".to_string(), + signal_type: DataType::CV, + description: "Gain control CV".to_string(), + }, + ], + outputs: vec![PortInfo { + index: 0, + name: "Out".to_string(), + signal_type: DataType::Audio, + description: "Gained audio output".to_string(), + }], + parameters: vec![ParameterInfo { + id: 0, + name: "Gain".to_string(), + default: 0.0, + min: -60.0, + max: 12.0, + unit: ParameterUnit::Decibels, + description: "Gain amount in dB".to_string(), + }], + description: "Amplifies or attenuates audio signal".to_string(), + }, + ); + + types.insert( + "Filter".to_string(), + NodeTypeInfo { + id: "Filter".to_string(), + display_name: "Filter".to_string(), + category: NodeCategory::Effects, + inputs: vec![ + PortInfo { + index: 0, + name: "In".to_string(), + signal_type: DataType::Audio, + description: "Audio input".to_string(), + }, + PortInfo { + index: 1, + name: "Cutoff".to_string(), + signal_type: DataType::CV, + description: "Cutoff frequency CV".to_string(), + }, + ], + outputs: vec![PortInfo { + index: 0, + name: "Out".to_string(), + signal_type: DataType::Audio, + description: "Filtered audio output".to_string(), + }], + parameters: vec![ + ParameterInfo { + id: 0, + name: "Cutoff".to_string(), + default: 1000.0, + min: 20.0, + max: 20000.0, + unit: ParameterUnit::Hz, + description: "Cutoff frequency".to_string(), + }, + ParameterInfo { + id: 1, + name: "Resonance".to_string(), + default: 0.0, + min: 0.0, + max: 1.0, + unit: ParameterUnit::None, + description: "Filter resonance".to_string(), + }, + ParameterInfo { + id: 2, + name: "Type".to_string(), + default: 0.0, + min: 0.0, + max: 3.0, + unit: ParameterUnit::None, + description: "0=LPF, 1=HPF, 2=BPF, 3=Notch".to_string(), + }, + ], + description: "Multi-mode filter (lowpass, highpass, bandpass, notch)".to_string(), + }, + ); + + types.insert( + "Delay".to_string(), + NodeTypeInfo { + id: "Delay".to_string(), + display_name: "Delay".to_string(), + category: NodeCategory::Effects, + inputs: vec![PortInfo { + index: 0, + name: "In".to_string(), + signal_type: DataType::Audio, + description: "Audio input".to_string(), + }], + outputs: vec![PortInfo { + index: 0, + name: "Out".to_string(), + signal_type: DataType::Audio, + description: "Delayed audio output".to_string(), + }], + parameters: vec![ + ParameterInfo { + id: 0, + name: "Time".to_string(), + default: 250.0, + min: 1.0, + max: 2000.0, + unit: ParameterUnit::Milliseconds, + description: "Delay time".to_string(), + }, + ParameterInfo { + id: 1, + name: "Feedback".to_string(), + default: 0.3, + min: 0.0, + max: 0.95, + unit: ParameterUnit::None, + description: "Feedback amount".to_string(), + }, + ParameterInfo { + id: 2, + name: "Mix".to_string(), + default: 0.5, + min: 0.0, + max: 1.0, + unit: ParameterUnit::None, + description: "Dry/wet mix".to_string(), + }, + ], + description: "Time-based delay effect".to_string(), + }, + ); + + // === UTILITIES === + + types.insert( + "ADSR".to_string(), + NodeTypeInfo { + id: "ADSR".to_string(), + display_name: "ADSR".to_string(), + category: NodeCategory::Utilities, + inputs: vec![PortInfo { + index: 0, + name: "Gate".to_string(), + signal_type: DataType::CV, + description: "Gate input (triggers envelope)".to_string(), + }], + outputs: vec![PortInfo { + index: 0, + name: "Out".to_string(), + signal_type: DataType::CV, + description: "Envelope CV output (0-1)".to_string(), + }], + parameters: vec![ + ParameterInfo { + id: 0, + name: "Attack".to_string(), + default: 10.0, + min: 0.1, + max: 2000.0, + unit: ParameterUnit::Milliseconds, + description: "Attack time".to_string(), + }, + ParameterInfo { + id: 1, + name: "Decay".to_string(), + default: 100.0, + min: 0.1, + max: 2000.0, + unit: ParameterUnit::Milliseconds, + description: "Decay time".to_string(), + }, + ParameterInfo { + id: 2, + name: "Sustain".to_string(), + default: 0.7, + min: 0.0, + max: 1.0, + unit: ParameterUnit::None, + description: "Sustain level".to_string(), + }, + ParameterInfo { + id: 3, + name: "Release".to_string(), + default: 200.0, + min: 0.1, + max: 5000.0, + unit: ParameterUnit::Milliseconds, + description: "Release time".to_string(), + }, + ], + description: "ADSR envelope generator".to_string(), + }, + ); + + types.insert( + "LFO".to_string(), + NodeTypeInfo { + id: "LFO".to_string(), + display_name: "LFO".to_string(), + category: NodeCategory::Utilities, + inputs: vec![], + outputs: vec![PortInfo { + index: 0, + name: "Out".to_string(), + signal_type: DataType::CV, + description: "LFO CV output".to_string(), + }], + parameters: vec![ + ParameterInfo { + id: 0, + name: "Rate".to_string(), + default: 1.0, + min: 0.01, + max: 20.0, + unit: ParameterUnit::Hz, + description: "LFO rate".to_string(), + }, + ParameterInfo { + id: 1, + name: "Waveform".to_string(), + default: 0.0, + min: 0.0, + max: 3.0, + unit: ParameterUnit::None, + description: "0=Sine, 1=Triangle, 2=Square, 3=Saw".to_string(), + }, + ], + description: "Low-frequency oscillator for modulation".to_string(), + }, + ); + + types.insert( + "Mixer".to_string(), + NodeTypeInfo { + id: "Mixer".to_string(), + display_name: "Mixer".to_string(), + category: NodeCategory::Utilities, + inputs: vec![ + PortInfo { + index: 0, + name: "In 1".to_string(), + signal_type: DataType::Audio, + description: "Audio input 1".to_string(), + }, + PortInfo { + index: 1, + name: "In 2".to_string(), + signal_type: DataType::Audio, + description: "Audio input 2".to_string(), + }, + PortInfo { + index: 2, + name: "In 3".to_string(), + signal_type: DataType::Audio, + description: "Audio input 3".to_string(), + }, + PortInfo { + index: 3, + name: "In 4".to_string(), + signal_type: DataType::Audio, + description: "Audio input 4".to_string(), + }, + ], + outputs: vec![PortInfo { + index: 0, + name: "Out".to_string(), + signal_type: DataType::Audio, + description: "Mixed audio output".to_string(), + }], + parameters: vec![ + ParameterInfo { + id: 0, + name: "Level 1".to_string(), + default: 1.0, + min: 0.0, + max: 1.0, + unit: ParameterUnit::None, + description: "Input 1 level".to_string(), + }, + ParameterInfo { + id: 1, + name: "Level 2".to_string(), + default: 1.0, + min: 0.0, + max: 1.0, + unit: ParameterUnit::None, + description: "Input 2 level".to_string(), + }, + ParameterInfo { + id: 2, + name: "Level 3".to_string(), + default: 1.0, + min: 0.0, + max: 1.0, + unit: ParameterUnit::None, + description: "Input 3 level".to_string(), + }, + ParameterInfo { + id: 3, + name: "Level 4".to_string(), + default: 1.0, + min: 0.0, + max: 1.0, + unit: ParameterUnit::None, + description: "Input 4 level".to_string(), + }, + ], + description: "4-channel audio mixer".to_string(), + }, + ); + + types.insert( + "Splitter".to_string(), + NodeTypeInfo { + id: "Splitter".to_string(), + display_name: "Splitter".to_string(), + category: NodeCategory::Utilities, + inputs: vec![PortInfo { + index: 0, + name: "In".to_string(), + signal_type: DataType::Audio, + description: "Audio input".to_string(), + }], + outputs: vec![ + PortInfo { + index: 0, + name: "Out 1".to_string(), + signal_type: DataType::Audio, + description: "Audio output 1".to_string(), + }, + PortInfo { + index: 1, + name: "Out 2".to_string(), + signal_type: DataType::Audio, + description: "Audio output 2".to_string(), + }, + PortInfo { + index: 2, + name: "Out 3".to_string(), + signal_type: DataType::Audio, + description: "Audio output 3".to_string(), + }, + PortInfo { + index: 3, + name: "Out 4".to_string(), + signal_type: DataType::Audio, + description: "Audio output 4".to_string(), + }, + ], + parameters: vec![], + description: "Splits one audio signal into four outputs".to_string(), + }, + ); + + types.insert( + "Constant".to_string(), + NodeTypeInfo { + id: "Constant".to_string(), + display_name: "Constant".to_string(), + category: NodeCategory::Utilities, + inputs: vec![], + outputs: vec![PortInfo { + index: 0, + name: "Out".to_string(), + signal_type: DataType::CV, + description: "Constant CV output".to_string(), + }], + parameters: vec![ParameterInfo { + id: 0, + name: "Value".to_string(), + default: 0.0, + min: -1.0, + max: 1.0, + unit: ParameterUnit::None, + description: "Constant value".to_string(), + }], + description: "Outputs a constant CV value".to_string(), + }, + ); + + // === OUTPUTS === + + types.insert( + "AudioOutput".to_string(), + NodeTypeInfo { + id: "AudioOutput".to_string(), + display_name: "Audio Output".to_string(), + category: NodeCategory::Outputs, + inputs: vec![ + PortInfo { + index: 0, + name: "Left".to_string(), + signal_type: DataType::Audio, + description: "Left channel input".to_string(), + }, + PortInfo { + index: 1, + name: "Right".to_string(), + signal_type: DataType::Audio, + description: "Right channel input".to_string(), + }, + ], + outputs: vec![], + parameters: vec![], + description: "Sends audio to the track output".to_string(), + }, + ); + + Self { types } + } + + pub fn get(&self, node_type: &str) -> Option<&NodeTypeInfo> { + self.types.get(node_type) + } + + pub fn get_by_category(&self, category: NodeCategory) -> Vec<&NodeTypeInfo> { + self.types + .values() + .filter(|info| info.category == category) + .collect() + } + + pub fn all_categories(&self) -> Vec { + vec![ + NodeCategory::Inputs, + NodeCategory::Generators, + NodeCategory::Effects, + NodeCategory::Utilities, + NodeCategory::Outputs, + ] + } +} + +impl Default for NodeTypeRegistry { + fn default() -> Self { + Self::new() + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/palette.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/palette.rs new file mode 100644 index 0000000..e143141 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/palette.rs @@ -0,0 +1,171 @@ +//! Node Palette UI +//! +//! Left sidebar showing available node types organized by category + +use super::node_types::{NodeCategory, NodeTypeRegistry}; +use eframe::egui; + +/// Node palette state +pub struct NodePalette { + /// Node type registry + registry: NodeTypeRegistry, + + /// Category collapse states + collapsed_categories: std::collections::HashSet, + + /// Search filter text + search_filter: String, +} + +impl NodePalette { + pub fn new() -> Self { + Self { + registry: NodeTypeRegistry::new(), + collapsed_categories: std::collections::HashSet::new(), + search_filter: String::new(), + } + } + + /// Render the palette UI + /// + /// The `on_node_clicked` callback is called when the user clicks a node type to add it + pub fn render(&mut self, ui: &mut egui::Ui, rect: egui::Rect, mut on_node_clicked: F) + where + F: FnMut(&str), + { + // Draw background + ui.painter() + .rect_filled(rect, 0.0, egui::Color32::from_rgb(30, 30, 30)); + + // Create UI within the palette rect + ui.allocate_ui_at_rect(rect, |ui| { + ui.vertical(|ui| { + ui.add_space(8.0); + + // Title + ui.heading("Node Palette"); + ui.add_space(4.0); + + // Search box + ui.horizontal(|ui| { + ui.label("Search:"); + ui.text_edit_singleline(&mut self.search_filter); + }); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // Scrollable node list + egui::ScrollArea::vertical() + .id_salt("node_palette_scroll") + .show(ui, |ui| { + self.render_categories(ui, &mut on_node_clicked); + }); + }); + }); + } + + fn render_categories(&mut self, ui: &mut egui::Ui, on_node_clicked: &mut F) + where + F: FnMut(&str), + { + let search_lower = self.search_filter.to_lowercase(); + + for category in self.registry.all_categories() { + // Get nodes in this category + let mut nodes = self.registry.get_by_category(category); + + // Filter by search text (node names only) + if !search_lower.is_empty() { + nodes.retain(|node| { + node.display_name.to_lowercase().contains(&search_lower) + }); + } + + // Skip empty categories + if nodes.is_empty() { + continue; + } + + // Sort nodes by name + nodes.sort_by(|a, b| a.display_name.cmp(&b.display_name)); + + // Render category header + let is_collapsed = self.collapsed_categories.contains(&category); + let arrow = if is_collapsed { ">" } else { "v" }; + let label = format!("{} {} ({})", arrow, category.display_name(), nodes.len()); + + let header_response = ui.selectable_label(false, label); + + // Toggle collapse on click + if header_response.clicked() { + if is_collapsed { + self.collapsed_categories.remove(&category); + } else { + self.collapsed_categories.insert(category); + } + } + + // Render nodes if not collapsed + if !is_collapsed { + ui.indent(category.display_name(), |ui| { + for node in nodes { + self.render_node_button(ui, node.id.as_str(), &node.display_name, on_node_clicked); + } + }); + } + + ui.add_space(4.0); + } + } + + fn render_node_button( + &self, + ui: &mut egui::Ui, + node_id: &str, + display_name: &str, + on_node_clicked: &mut F, + ) where + F: FnMut(&str), + { + // Use drag source to enable dragging + let drag_id = egui::Id::new(format!("node_palette_{}", node_id)); + let response = ui.dnd_drag_source( + drag_id, + node_id.to_string(), + |ui| { + let button = egui::Button::new(display_name) + .min_size(egui::vec2(ui.available_width() - 8.0, 24.0)) + .fill(egui::Color32::from_rgb(50, 50, 50)); + ui.add(button) + }, + ); + + // Handle click: detect clicks by checking if drag stopped with minimal movement + // dnd_drag_source always sets is_being_dragged=true on press, so we can't use that + if response.response.drag_stopped() { + // Check if this was actually a drag or just a click (minimal movement) + if let Some(start_pos) = response.response.interact_pointer_pos() { + if let Some(current_pos) = ui.input(|i| i.pointer.interact_pos()) { + let drag_distance = (current_pos - start_pos).length(); + if drag_distance < 5.0 { + // This was a click, not a drag + on_node_clicked(node_id); + } + } + } + } + + // Show tooltip with description + if let Some(node_info) = self.registry.get(node_id) { + response.response.on_hover_text(&node_info.description); + } + } +} + +impl Default for NodePalette { + fn default() -> Self { + Self::new() + } +}