Node graph initial work

This commit is contained in:
Skyler Lehmkuhl 2025-12-16 07:59:16 -05:00
parent dda1319c42
commit 798d8420af
10 changed files with 2140 additions and 44 deletions

View File

@ -15,6 +15,7 @@ eframe = { workspace = true }
egui_extras = { workspace = true } egui_extras = { workspace = true }
egui-wgpu = { workspace = true } egui-wgpu = { workspace = true }
egui_code_editor = { workspace = true } egui_code_editor = { workspace = true }
egui-snarl = "0.9"
# GPU # GPU
wgpu = { workspace = true } wgpu = { workspace = true }
@ -42,6 +43,7 @@ pollster = { workspace = true }
lightningcss = "1.0.0-alpha.68" lightningcss = "1.0.0-alpha.68"
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
uuid = { version = "1.0", features = ["v4", "serde"] } uuid = { version = "1.0", features = ["v4", "serde"] }
petgraph = "0.6"
# Native file dialogs # Native file dialogs
rfd = "0.15" rfd = "0.15"

View File

@ -62,6 +62,7 @@ pub mod outliner;
pub mod piano_roll; pub mod piano_roll;
pub mod virtual_piano; pub mod virtual_piano;
pub mod node_editor; pub mod node_editor;
pub mod node_graph;
pub mod preset_browser; pub mod preset_browser;
pub mod asset_library; pub mod asset_library;
pub mod shader_editor; pub mod shader_editor;

View File

@ -1,45 +1,5 @@
/// Node Editor pane - node-based visual programming //! Node Editor pane - node-based audio/MIDI synthesis
/// //!
/// This will eventually render a node graph with Vello. //! Re-exports the node graph implementation
/// For now, it's a placeholder.
use eframe::egui; pub use super::node_graph::NodeGraphPane as NodeEditorPane;
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"
}
}

View File

@ -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<BackendNodeId>,
}
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<String>,
#[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<f64>,
}
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(())
}
}

View File

@ -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<Mutex<EngineController>>,
/// Maps backend NodeIndex to stable IDs for round-trip serialization
node_index_to_stable: HashMap<NodeIndex, u32>,
next_stable_id: u32,
}
impl AudioGraphBackend {
pub fn new(track_id: u32, audio_controller: Arc<Mutex<EngineController>>) -> 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<BackendNodeId, String> {
// 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<GraphState, String> {
// 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<BackendNodeId, String> {
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(())
}
}

View File

@ -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<BackendNodeId, String>;
/// 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<GraphState, String>;
/// 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<BackendNodeId, String>;
/// 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<SerializedNode>,
pub connections: Vec<SerializedConnection>,
}
#[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<u32, f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializedConnection {
pub from_node: u32,
pub from_port: usize,
pub to_node: u32,
pub to_port: usize,
}

View File

@ -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<AudioNode> 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<AudioNode>,
) -> 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<AudioNode>,
) -> 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<AudioNode>) {
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::<Vec<_>>() {
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<AudioNode>) -> 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<AudioNode>,
) {
if ui.button("Remove").clicked() {
snarl.remove_node(node);
ui.close_menu();
}
}
}

View File

@ -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<AudioNode>,
/// 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<Box<dyn GraphBackend>>,
/// Maps frontend node IDs to backend node IDs
#[allow(dead_code)]
node_id_map: HashMap<egui_snarl::NodeId, BackendNodeId>,
/// Track ID this graph belongs to
#[allow(dead_code)]
track_id: Option<Uuid>,
/// Pending action to execute
#[allow(dead_code)]
pending_action: Option<Box<dyn lightningbeam_core::action::Action>>,
}
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<std::sync::Mutex<daw_backend::EngineController>>) -> 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<String> = 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::<String>() {
// 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()
}
}

View File

@ -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<PortInfo>,
pub outputs: Vec<PortInfo>,
pub parameters: Vec<ParameterInfo>,
pub description: String,
}
/// Registry of all available node types
pub struct NodeTypeRegistry {
types: HashMap<String, NodeTypeInfo>,
}
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<NodeCategory> {
vec![
NodeCategory::Inputs,
NodeCategory::Generators,
NodeCategory::Effects,
NodeCategory::Utilities,
NodeCategory::Outputs,
]
}
}
impl Default for NodeTypeRegistry {
fn default() -> Self {
Self::new()
}
}

View File

@ -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<NodeCategory>,
/// 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<F>(&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<F>(&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<F>(
&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()
}
}