700 lines
31 KiB
Rust
700 lines
31 KiB
Rust
//! 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;
|
||
|
||
use backend::{BackendNodeId, GraphBackend};
|
||
use graph_data::{AllNodeTemplates, DataType, GraphState, NodeData, NodeTemplate, ValueType};
|
||
use super::NodePath;
|
||
use eframe::egui;
|
||
use egui_node_graph2::*;
|
||
use std::collections::HashMap;
|
||
use uuid::Uuid;
|
||
|
||
/// Node graph pane with egui_node_graph2 integration
|
||
pub struct NodeGraphPane {
|
||
/// The graph editor state
|
||
state: GraphEditorState<NodeData, DataType, ValueType, NodeTemplate, GraphState>,
|
||
|
||
/// User state for the graph
|
||
user_state: GraphState,
|
||
|
||
/// Backend integration
|
||
#[allow(dead_code)]
|
||
backend: Option<Box<dyn GraphBackend>>,
|
||
|
||
/// Maps frontend node IDs to backend node IDs
|
||
node_id_map: HashMap<NodeId, BackendNodeId>,
|
||
|
||
/// Maps backend node IDs to frontend node IDs (reverse mapping)
|
||
backend_to_frontend_map: HashMap<BackendNodeId, NodeId>,
|
||
|
||
/// Track ID this graph belongs to
|
||
track_id: Option<Uuid>,
|
||
|
||
/// Pending action to execute
|
||
#[allow(dead_code)]
|
||
pending_action: Option<Box<dyn lightningbeam_core::action::Action>>,
|
||
|
||
/// Track newly added nodes to update ID mappings after action execution
|
||
/// (frontend_id, node_type, position)
|
||
pending_node_addition: Option<(NodeId, String, (f32, f32))>,
|
||
|
||
/// Track parameter values to detect changes
|
||
/// Maps InputId -> last known value
|
||
parameter_values: HashMap<InputId, f32>,
|
||
}
|
||
|
||
impl NodeGraphPane {
|
||
pub fn new() -> Self {
|
||
let state = GraphEditorState::new(1.0);
|
||
|
||
Self {
|
||
state,
|
||
user_state: GraphState::default(),
|
||
backend: None,
|
||
node_id_map: HashMap::new(),
|
||
backend_to_frontend_map: HashMap::new(),
|
||
track_id: None,
|
||
pending_action: None,
|
||
pending_node_addition: None,
|
||
parameter_values: HashMap::new(),
|
||
}
|
||
}
|
||
|
||
pub fn with_track_id(
|
||
track_id: Uuid,
|
||
audio_controller: std::sync::Arc<std::sync::Mutex<daw_backend::EngineController>>,
|
||
backend_track_id: u32,
|
||
) -> Self {
|
||
let backend = Box::new(audio_backend::AudioGraphBackend::new(
|
||
backend_track_id,
|
||
audio_controller,
|
||
));
|
||
|
||
let mut pane = Self {
|
||
state: GraphEditorState::new(1.0),
|
||
user_state: GraphState::default(),
|
||
backend: Some(backend),
|
||
node_id_map: HashMap::new(),
|
||
backend_to_frontend_map: HashMap::new(),
|
||
track_id: Some(track_id),
|
||
pending_action: None,
|
||
pending_node_addition: None,
|
||
parameter_values: HashMap::new(),
|
||
};
|
||
|
||
// Load existing graph from backend
|
||
if let Err(e) = pane.load_graph_from_backend() {
|
||
eprintln!("Failed to load graph from backend: {}", e);
|
||
}
|
||
|
||
pane
|
||
}
|
||
|
||
/// Load the graph state from the backend and populate the frontend
|
||
fn load_graph_from_backend(&mut self) -> Result<(), String> {
|
||
let graph_state = if let Some(backend) = &self.backend {
|
||
backend.get_state()?
|
||
} else {
|
||
return Err("No backend available".to_string());
|
||
};
|
||
|
||
// Clear existing graph
|
||
self.state.graph.nodes.clear();
|
||
self.state.graph.inputs.clear();
|
||
self.state.graph.outputs.clear();
|
||
self.state.graph.connections.clear();
|
||
self.state.node_order.clear();
|
||
self.state.node_positions.clear();
|
||
self.state.selected_nodes.clear();
|
||
self.state.connection_in_progress = None;
|
||
self.state.ongoing_box_selection = None;
|
||
self.node_id_map.clear();
|
||
self.backend_to_frontend_map.clear();
|
||
|
||
// Create nodes in frontend
|
||
for node in &graph_state.nodes {
|
||
// Parse node type from string (e.g., "Oscillator" -> NodeTemplate::Oscillator)
|
||
let node_template = match node.node_type.as_str() {
|
||
// Inputs
|
||
"MidiInput" => graph_data::NodeTemplate::MidiInput,
|
||
"AudioInput" => graph_data::NodeTemplate::AudioInput,
|
||
"AutomationInput" => graph_data::NodeTemplate::AutomationInput,
|
||
// Generators
|
||
"Oscillator" => graph_data::NodeTemplate::Oscillator,
|
||
"WavetableOscillator" => graph_data::NodeTemplate::WavetableOscillator,
|
||
"FMSynth" => graph_data::NodeTemplate::FmSynth,
|
||
"NoiseGenerator" => graph_data::NodeTemplate::Noise,
|
||
"SimpleSampler" => graph_data::NodeTemplate::SimpleSampler,
|
||
"MultiSampler" => graph_data::NodeTemplate::MultiSampler,
|
||
// Effects
|
||
"Filter" => graph_data::NodeTemplate::Filter,
|
||
"Gain" => graph_data::NodeTemplate::Gain,
|
||
"Delay" => graph_data::NodeTemplate::Delay,
|
||
"Reverb" => graph_data::NodeTemplate::Reverb,
|
||
"Chorus" => graph_data::NodeTemplate::Chorus,
|
||
"Flanger" => graph_data::NodeTemplate::Flanger,
|
||
"Phaser" => graph_data::NodeTemplate::Phaser,
|
||
"Distortion" => graph_data::NodeTemplate::Distortion,
|
||
"BitCrusher" => graph_data::NodeTemplate::BitCrusher,
|
||
"Compressor" => graph_data::NodeTemplate::Compressor,
|
||
"Limiter" => graph_data::NodeTemplate::Limiter,
|
||
"EQ" => graph_data::NodeTemplate::Eq,
|
||
"Pan" => graph_data::NodeTemplate::Pan,
|
||
"RingModulator" => graph_data::NodeTemplate::RingModulator,
|
||
"Vocoder" => graph_data::NodeTemplate::Vocoder,
|
||
// Utilities
|
||
"ADSR" => graph_data::NodeTemplate::Adsr,
|
||
"LFO" => graph_data::NodeTemplate::Lfo,
|
||
"Mixer" => graph_data::NodeTemplate::Mixer,
|
||
"Splitter" => graph_data::NodeTemplate::Splitter,
|
||
"Constant" => graph_data::NodeTemplate::Constant,
|
||
"MidiToCV" => graph_data::NodeTemplate::MidiToCv,
|
||
"AudioToCV" => graph_data::NodeTemplate::AudioToCv,
|
||
"Math" => graph_data::NodeTemplate::Math,
|
||
"SampleHold" => graph_data::NodeTemplate::SampleHold,
|
||
"SlewLimiter" => graph_data::NodeTemplate::SlewLimiter,
|
||
"Quantizer" => graph_data::NodeTemplate::Quantizer,
|
||
"EnvelopeFollower" => graph_data::NodeTemplate::EnvelopeFollower,
|
||
"BPMDetector" => graph_data::NodeTemplate::BpmDetector,
|
||
"Mod" => graph_data::NodeTemplate::Mod,
|
||
// Analysis
|
||
"Oscilloscope" => graph_data::NodeTemplate::Oscilloscope,
|
||
// Advanced
|
||
"VoiceAllocator" => graph_data::NodeTemplate::VoiceAllocator,
|
||
// Outputs
|
||
"AudioOutput" => graph_data::NodeTemplate::AudioOutput,
|
||
_ => {
|
||
eprintln!("Unknown node type: {}", node.node_type);
|
||
continue;
|
||
}
|
||
};
|
||
|
||
// Create node directly in the graph
|
||
use egui_node_graph2::Node;
|
||
let frontend_id = self.state.graph.nodes.insert(Node {
|
||
id: egui_node_graph2::NodeId::default(), // Will be replaced by insert
|
||
label: node.node_type.clone(),
|
||
inputs: vec![],
|
||
outputs: vec![],
|
||
user_data: graph_data::NodeData,
|
||
});
|
||
|
||
// Build the node's inputs and outputs (this adds them to graph.inputs and graph.outputs)
|
||
// build_node() automatically populates the node's inputs/outputs vectors with correct names and order
|
||
node_template.build_node(&mut self.state.graph, &mut self.user_state, frontend_id);
|
||
|
||
// Set position
|
||
self.state.node_positions.insert(
|
||
frontend_id,
|
||
egui::pos2(node.position.0, node.position.1),
|
||
);
|
||
|
||
// Add to node order for rendering
|
||
self.state.node_order.push(frontend_id);
|
||
|
||
// Map frontend ID to backend ID
|
||
let backend_id = BackendNodeId::Audio(petgraph::stable_graph::NodeIndex::new(node.id as usize));
|
||
self.node_id_map.insert(frontend_id, backend_id);
|
||
self.backend_to_frontend_map.insert(backend_id, frontend_id);
|
||
|
||
// Set parameter values
|
||
for (¶m_id, &value) in &node.parameters {
|
||
// Find the input param in the graph and set its value
|
||
if let Some(node_data) = self.state.graph.nodes.get_mut(frontend_id) {
|
||
// TODO: Set parameter values on the node's input params
|
||
// This requires matching param_id to the input param by index
|
||
let _ = (param_id, value); // Silence unused warning for now
|
||
}
|
||
}
|
||
}
|
||
|
||
// Create connections in frontend
|
||
for conn in &graph_state.connections {
|
||
let from_backend = BackendNodeId::Audio(petgraph::stable_graph::NodeIndex::new(conn.from_node as usize));
|
||
let to_backend = BackendNodeId::Audio(petgraph::stable_graph::NodeIndex::new(conn.to_node as usize));
|
||
|
||
if let (Some(&from_id), Some(&to_id)) = (
|
||
self.backend_to_frontend_map.get(&from_backend),
|
||
self.backend_to_frontend_map.get(&to_backend),
|
||
) {
|
||
// Find output param on from_node
|
||
if let Some(from_node) = self.state.graph.nodes.get(from_id) {
|
||
if let Some((_name, output_id)) = from_node.outputs.get(conn.from_port) {
|
||
// Find input param on to_node
|
||
if let Some(to_node) = self.state.graph.nodes.get(to_id) {
|
||
if let Some((_name, input_id)) = to_node.inputs.get(conn.to_port) {
|
||
// Add connection to graph - connections map is InputId -> Vec<OutputId>
|
||
if let Some(connections) = self.state.graph.connections.get_mut(*input_id) {
|
||
connections.push(*output_id);
|
||
} else {
|
||
self.state.graph.connections.insert(*input_id, vec![*output_id]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn handle_graph_response(
|
||
&mut self,
|
||
response: egui_node_graph2::GraphResponse<
|
||
graph_data::UserResponse,
|
||
graph_data::NodeData,
|
||
>,
|
||
shared: &mut crate::panes::SharedPaneState,
|
||
) {
|
||
use egui_node_graph2::NodeResponse;
|
||
|
||
for node_response in response.node_responses {
|
||
match node_response {
|
||
NodeResponse::CreatedNode(node_id) => {
|
||
// Node was created from the node finder
|
||
// Get node label which is the node type string
|
||
if let Some(node) = self.state.graph.nodes.get(node_id) {
|
||
let node_type = node.label.clone();
|
||
let position = self.state.node_positions.get(node_id)
|
||
.map(|pos| (pos.x, pos.y))
|
||
.unwrap_or((0.0, 0.0));
|
||
|
||
if let Some(track_id) = self.track_id {
|
||
let action = Box::new(actions::NodeGraphAction::AddNode(
|
||
actions::AddNodeAction::new(track_id, node_type.clone(), position)
|
||
));
|
||
self.pending_action = Some(action);
|
||
// Track this addition so we can update ID mappings after execution
|
||
self.pending_node_addition = Some((node_id, node_type, position));
|
||
}
|
||
}
|
||
}
|
||
NodeResponse::ConnectEventEnded { output, input, .. } => {
|
||
// Connection was made between output and input
|
||
if let Some(track_id) = self.track_id {
|
||
// Get the nodes that own these params
|
||
let from_node = self.state.graph.outputs.get(output).map(|o| o.node);
|
||
let to_node = self.state.graph.inputs.get(input).map(|i| i.node);
|
||
|
||
if let (Some(from_node_id), Some(to_node_id)) = (from_node, to_node) {
|
||
// Find port indices
|
||
let from_port = self.state.graph.nodes.get(from_node_id)
|
||
.and_then(|n| n.outputs.iter().position(|(_, id)| *id == output))
|
||
.unwrap_or(0);
|
||
let to_port = self.state.graph.nodes.get(to_node_id)
|
||
.and_then(|n| n.inputs.iter().position(|(_, id)| *id == input))
|
||
.unwrap_or(0);
|
||
|
||
// Map frontend IDs to backend IDs
|
||
let from_backend = self.node_id_map.get(&from_node_id);
|
||
let to_backend = self.node_id_map.get(&to_node_id);
|
||
|
||
if let (Some(&from_id), Some(&to_id)) = (from_backend, to_backend) {
|
||
let action = Box::new(actions::NodeGraphAction::Connect(
|
||
actions::ConnectAction::new(
|
||
track_id,
|
||
from_id,
|
||
from_port,
|
||
to_id,
|
||
to_port,
|
||
)
|
||
));
|
||
self.pending_action = Some(action);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
NodeResponse::DisconnectEvent { output, input } => {
|
||
// Connection was removed
|
||
if let Some(track_id) = self.track_id {
|
||
// Get the nodes that own these params
|
||
let from_node = self.state.graph.outputs.get(output).map(|o| o.node);
|
||
let to_node = self.state.graph.inputs.get(input).map(|i| i.node);
|
||
|
||
if let (Some(from_node_id), Some(to_node_id)) = (from_node, to_node) {
|
||
// Find port indices
|
||
let from_port = self.state.graph.nodes.get(from_node_id)
|
||
.and_then(|n| n.outputs.iter().position(|(_, id)| *id == output))
|
||
.unwrap_or(0);
|
||
let to_port = self.state.graph.nodes.get(to_node_id)
|
||
.and_then(|n| n.inputs.iter().position(|(_, id)| *id == input))
|
||
.unwrap_or(0);
|
||
|
||
// Map frontend IDs to backend IDs
|
||
let from_backend = self.node_id_map.get(&from_node_id);
|
||
let to_backend = self.node_id_map.get(&to_node_id);
|
||
|
||
if let (Some(&from_id), Some(&to_id)) = (from_backend, to_backend) {
|
||
let action = Box::new(actions::NodeGraphAction::Disconnect(
|
||
actions::DisconnectAction::new(
|
||
track_id,
|
||
from_id,
|
||
from_port,
|
||
to_id,
|
||
to_port,
|
||
)
|
||
));
|
||
self.pending_action = Some(action);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
NodeResponse::DeleteNodeFull { node_id, .. } => {
|
||
// Node was deleted
|
||
if let Some(track_id) = self.track_id {
|
||
if let Some(&backend_id) = self.node_id_map.get(&node_id) {
|
||
let action = Box::new(actions::NodeGraphAction::RemoveNode(
|
||
actions::RemoveNodeAction::new(track_id, backend_id)
|
||
));
|
||
self.pending_action = Some(action);
|
||
|
||
// Remove from ID map
|
||
self.node_id_map.remove(&node_id);
|
||
self.backend_to_frontend_map.remove(&backend_id);
|
||
}
|
||
}
|
||
}
|
||
NodeResponse::MoveNode { node, drag_delta: _ } => {
|
||
// Node was moved - we'll handle this on drag end
|
||
// For now, just update the position (no action needed during drag)
|
||
self.user_state.active_node = Some(node);
|
||
}
|
||
_ => {
|
||
// Ignore other events (SelectNode, RaiseNode, etc.)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Execute pending action if any
|
||
if let Some(action) = self.pending_action.take() {
|
||
// Node graph actions need to update the backend, so use execute_with_backend
|
||
if let Some(ref audio_controller) = shared.audio_controller {
|
||
let mut controller = audio_controller.lock().unwrap();
|
||
// Node graph actions don't use clip instances, so we use an empty map
|
||
let mut empty_clip_map = std::collections::HashMap::new();
|
||
let mut backend_context = lightningbeam_core::action::BackendContext {
|
||
audio_controller: Some(&mut *controller),
|
||
layer_to_track_map: shared.layer_to_track_map,
|
||
clip_instance_to_backend_map: &mut empty_clip_map,
|
||
};
|
||
|
||
if let Err(e) = shared.action_executor.execute_with_backend(action, &mut backend_context) {
|
||
eprintln!("Failed to execute node graph action: {}", e);
|
||
} else {
|
||
// If this was a node addition, query backend to get the new node's ID
|
||
if let Some((frontend_id, node_type, position)) = self.pending_node_addition.take() {
|
||
if let Some(track_id) = self.track_id {
|
||
if let Some(&backend_track_id) = shared.layer_to_track_map.get(&track_id) {
|
||
// Query graph state to find the new node
|
||
if let Ok(json) = controller.query_graph_state(backend_track_id) {
|
||
if let Ok(state) = serde_json::from_str::<daw_backend::audio::node_graph::GraphPreset>(&json) {
|
||
// Find node by type and position (approximate match for position)
|
||
if let Some(backend_node) = state.nodes.iter().find(|n| {
|
||
n.node_type == node_type &&
|
||
(n.position.0 - position.0).abs() < 1.0 &&
|
||
(n.position.1 - position.1).abs() < 1.0
|
||
}) {
|
||
let backend_id = BackendNodeId::Audio(
|
||
petgraph::stable_graph::NodeIndex::new(backend_node.id as usize)
|
||
);
|
||
self.node_id_map.insert(frontend_id, backend_id);
|
||
self.backend_to_frontend_map.insert(backend_id, frontend_id);
|
||
eprintln!("[DEBUG] Mapped new node: frontend {:?} -> backend {:?}", frontend_id, backend_id);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
eprintln!("Cannot execute node graph action: no audio controller");
|
||
}
|
||
}
|
||
}
|
||
|
||
fn check_parameter_changes(&mut self) {
|
||
// Check all input parameters for value changes
|
||
for (input_id, input_param) in &self.state.graph.inputs {
|
||
// Only check parameters that can have constant values (not ConnectionOnly)
|
||
if matches!(input_param.kind, InputParamKind::ConnectionOnly) {
|
||
continue;
|
||
}
|
||
|
||
// Get current value
|
||
let current_value = match &input_param.value {
|
||
ValueType::Float { value } => *value,
|
||
_ => continue, // Skip non-float values for now
|
||
};
|
||
|
||
// Check if value has changed
|
||
let previous_value = self.parameter_values.get(&input_id).copied();
|
||
if previous_value.is_none() || (previous_value.unwrap() - current_value).abs() > 0.0001 {
|
||
// Value has changed, create SetParameterAction
|
||
if let Some(track_id) = self.track_id {
|
||
let node_id = input_param.node;
|
||
|
||
// Get backend node ID
|
||
if let Some(&backend_id) = self.node_id_map.get(&node_id) {
|
||
// Get parameter index (position in node's inputs array)
|
||
if let Some(node) = self.state.graph.nodes.get(node_id) {
|
||
if let Some(param_index) = node.inputs.iter().position(|(_, id)| *id == input_id) {
|
||
// Create action to update backend
|
||
let action = Box::new(actions::NodeGraphAction::SetParameter(
|
||
actions::SetParameterAction::new(
|
||
track_id,
|
||
backend_id,
|
||
param_index as u32,
|
||
current_value as f64,
|
||
)
|
||
));
|
||
self.pending_action = Some(action);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update stored value
|
||
self.parameter_values.insert(input_id, current_value);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn draw_dot_grid_background(
|
||
ui: &mut egui::Ui,
|
||
rect: egui::Rect,
|
||
bg_color: egui::Color32,
|
||
dot_color: egui::Color32,
|
||
pan_zoom: &egui_node_graph2::PanZoom,
|
||
) {
|
||
let painter = ui.painter();
|
||
|
||
// Draw background
|
||
painter.rect_filled(rect, 0.0, bg_color);
|
||
|
||
// Draw grid dots with pan/zoom transform
|
||
let grid_spacing = 20.0;
|
||
let dot_radius = 1.0 * pan_zoom.zoom;
|
||
|
||
// Get pan offset and zoom
|
||
let pan = pan_zoom.pan;
|
||
let zoom = pan_zoom.zoom;
|
||
|
||
// Calculate zoom center (same as nodes - they zoom relative to viewport center)
|
||
let half_size = rect.size() / 2.0;
|
||
let zoom_center = rect.min.to_vec2() + half_size + pan;
|
||
|
||
// Calculate grid bounds in graph space
|
||
// Screen to graph: (screen_pos - zoom_center) / zoom
|
||
let graph_min = egui::pos2(
|
||
(rect.min.x - zoom_center.x) / zoom,
|
||
(rect.min.y - zoom_center.y) / zoom,
|
||
);
|
||
let graph_max = egui::pos2(
|
||
(rect.max.x - zoom_center.x) / zoom,
|
||
(rect.max.y - zoom_center.y) / zoom,
|
||
);
|
||
|
||
let start_x = (graph_min.x / grid_spacing).floor() * grid_spacing;
|
||
let start_y = (graph_min.y / grid_spacing).floor() * grid_spacing;
|
||
|
||
let mut y = start_y;
|
||
while y < graph_max.y {
|
||
let mut x = start_x;
|
||
while x < graph_max.x {
|
||
// Transform to screen space: graph_pos * zoom + zoom_center
|
||
let screen_pos = egui::pos2(
|
||
x * zoom + zoom_center.x,
|
||
y * zoom + zoom_center.y,
|
||
);
|
||
if rect.contains(screen_pos) {
|
||
painter.circle_filled(screen_pos, dot_radius, dot_color);
|
||
}
|
||
x += grid_spacing;
|
||
}
|
||
y += grid_spacing;
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
) {
|
||
// Check if we need to reload for a different track
|
||
let current_track = *shared.active_layer_id;
|
||
|
||
// If selected track changed, reload the graph
|
||
if self.track_id != current_track {
|
||
if let Some(new_track_id) = current_track {
|
||
// Get backend track ID
|
||
if let Some(&backend_track_id) = shared.layer_to_track_map.get(&new_track_id) {
|
||
// Check if track is MIDI or Audio
|
||
if let Some(audio_controller) = &shared.audio_controller {
|
||
let is_valid_track = {
|
||
let controller = audio_controller.lock().unwrap();
|
||
// TODO: Query track type from backend
|
||
// For now, assume it's valid if we have a track ID mapping
|
||
true
|
||
};
|
||
|
||
if is_valid_track {
|
||
// Reload graph for new track
|
||
self.track_id = Some(new_track_id);
|
||
|
||
// Recreate backend
|
||
self.backend = Some(Box::new(audio_backend::AudioGraphBackend::new(
|
||
backend_track_id,
|
||
(*audio_controller).clone(),
|
||
)));
|
||
|
||
// Load graph from backend
|
||
if let Err(e) = self.load_graph_from_backend() {
|
||
eprintln!("Failed to load graph from backend: {}", e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
self.track_id = None;
|
||
}
|
||
}
|
||
|
||
// Check if we have a valid track
|
||
if self.track_id.is_none() || self.backend.is_none() {
|
||
// Show message that no valid track is selected
|
||
let painter = ui.painter();
|
||
let bg_color = egui::Color32::from_gray(30);
|
||
painter.rect_filled(rect, 0.0, bg_color);
|
||
|
||
let text = "Select a MIDI or Audio track to view its node graph";
|
||
let font_id = egui::FontId::proportional(16.0);
|
||
let text_color = egui::Color32::from_gray(150);
|
||
|
||
let galley = painter.layout_no_wrap(text.to_string(), font_id, text_color);
|
||
let text_pos = rect.center() - galley.size() / 2.0;
|
||
painter.galley(text_pos, galley, text_color);
|
||
return;
|
||
}
|
||
// Get colors from theme
|
||
let bg_style = shared.theme.style(".node-graph-background", ui.ctx());
|
||
let grid_style = shared.theme.style(".node-graph-grid", ui.ctx());
|
||
|
||
let bg_color = bg_style.background_color.unwrap_or(egui::Color32::from_gray(45));
|
||
let grid_color = grid_style.background_color.unwrap_or(egui::Color32::from_gray(55));
|
||
|
||
// Allocate the rect and render the graph editor within it
|
||
ui.allocate_ui_at_rect(rect, |ui| {
|
||
// Disable debug warning for unaligned widgets (happens when zoomed)
|
||
ui.style_mut().debug.show_unaligned = false;
|
||
|
||
// Check for scroll input to override library's default zoom behavior
|
||
let modifiers = ui.input(|i| i.modifiers);
|
||
let has_ctrl = modifiers.ctrl || modifiers.command;
|
||
|
||
// When ctrl is held, check for raw scroll events in the events list
|
||
let scroll_delta = if has_ctrl {
|
||
// Sum up scroll events from the raw event list
|
||
ui.input(|i| {
|
||
let mut total_scroll = egui::Vec2::ZERO;
|
||
for event in &i.events {
|
||
if let egui::Event::MouseWheel { delta, .. } = event {
|
||
total_scroll += *delta;
|
||
}
|
||
}
|
||
total_scroll
|
||
})
|
||
} else {
|
||
ui.input(|i| i.smooth_scroll_delta)
|
||
};
|
||
let has_scroll = scroll_delta != egui::Vec2::ZERO;
|
||
|
||
|
||
// Save current zoom to detect if library changed it
|
||
let zoom_before = self.state.pan_zoom.zoom;
|
||
let pan_before = self.state.pan_zoom.pan;
|
||
|
||
// Draw dot grid background with pan/zoom
|
||
let pan_zoom = &self.state.pan_zoom;
|
||
Self::draw_dot_grid_background(ui, rect, bg_color, grid_color, pan_zoom);
|
||
|
||
// Draw the graph editor (library will process scroll as zoom by default)
|
||
let graph_response = self.state.draw_graph_editor(
|
||
ui,
|
||
AllNodeTemplates,
|
||
&mut self.user_state,
|
||
Vec::default(),
|
||
);
|
||
|
||
// Handle graph events and create actions
|
||
self.handle_graph_response(graph_response, shared);
|
||
|
||
// Check for parameter value changes and send updates to backend
|
||
self.check_parameter_changes();
|
||
|
||
// Override library's default scroll behavior:
|
||
// - Library uses scroll for zoom
|
||
// - We want: scroll = pan, ctrl+scroll = zoom
|
||
if has_scroll {
|
||
if has_ctrl {
|
||
// Ctrl+scroll: zoom (explicitly handle it instead of relying on library)
|
||
// First undo any zoom the library applied
|
||
if self.state.pan_zoom.zoom != zoom_before {
|
||
let undo_zoom = zoom_before / self.state.pan_zoom.zoom;
|
||
self.state.zoom(ui, undo_zoom);
|
||
}
|
||
// Now apply zoom based on scroll
|
||
let zoom_delta = (scroll_delta.y * 0.002).exp();
|
||
self.state.zoom(ui, zoom_delta);
|
||
} else {
|
||
// Scroll without ctrl: library zoomed, but we want pan instead
|
||
// Undo the zoom and apply pan
|
||
if self.state.pan_zoom.zoom != zoom_before {
|
||
// Library changed zoom - revert it
|
||
let undo_zoom = zoom_before / self.state.pan_zoom.zoom;
|
||
self.state.zoom(ui, undo_zoom);
|
||
}
|
||
// Apply pan
|
||
self.state.pan_zoom.pan = pan_before + scroll_delta;
|
||
}
|
||
}
|
||
|
||
// Draw menu button in top-left corner
|
||
let button_pos = rect.min + egui::vec2(8.0, 8.0);
|
||
ui.allocate_ui_at_rect(
|
||
egui::Rect::from_min_size(button_pos, egui::vec2(100.0, 24.0)),
|
||
|ui| {
|
||
if ui.button("➕ Add Node").clicked() {
|
||
// Open node finder at button's top-left position
|
||
self.state.node_finder = Some(egui_node_graph2::NodeFinder::new_at(button_pos));
|
||
}
|
||
},
|
||
);
|
||
});
|
||
|
||
// TODO: Handle node responses and sync with backend
|
||
}
|
||
|
||
fn name(&self) -> &str {
|
||
"Node Graph"
|
||
}
|
||
}
|
||
|
||
impl Default for NodeGraphPane {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|