Lightningbeam/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs

2693 lines
125 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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, SubgraphNodeTemplates, VoiceAllocatorNodeTemplates, DataType, GraphState, NodeData, NodeTemplate, ValueType};
use super::NodePath;
use eframe::egui;
use egui_node_graph2::*;
use std::collections::{HashMap, HashSet};
use uuid::Uuid;
type GroupId = u32;
/// A connection that crosses a group boundary
#[derive(Clone, Debug)]
struct BoundaryConnection {
/// Node outside the group (backend ID)
external_node: u32,
/// Port index on the external node
external_port: usize,
/// Node inside the group (backend ID)
internal_node: u32,
/// Port index on the internal node
internal_port: usize,
/// Display name for the group port
port_name: String,
/// Signal type for the port
data_type: DataType,
}
/// A group of nodes collapsed into a single placeholder
#[derive(Clone, Debug)]
struct GroupDef {
id: GroupId,
name: String,
/// Backend node IDs of nodes belonging to this group
member_nodes: Vec<u32>,
/// Position of the group placeholder node
position: (f32, f32),
/// Connections from outside → inside the group
boundary_inputs: Vec<BoundaryConnection>,
/// Connections from inside → outside the group
boundary_outputs: Vec<BoundaryConnection>,
/// Parent group ID for nested groups (None = top-level group)
parent_group_id: Option<GroupId>,
}
/// What kind of container we've entered for subgraph editing
#[derive(Clone, Debug)]
enum SubgraphContext {
VoiceAllocator { backend_id: BackendNodeId },
Group { group_id: GroupId, name: String },
}
/// One level of subgraph editing — stores the parent state we'll restore on exit
struct SubgraphFrame {
context: SubgraphContext,
saved_state: SavedGraphState,
}
/// Saved graph editor state for restoring when exiting a subgraph
struct SavedGraphState {
state: GraphEditorState<NodeData, DataType, ValueType, NodeTemplate, GraphState>,
user_state: GraphState,
node_id_map: HashMap<NodeId, BackendNodeId>,
backend_to_frontend_map: HashMap<BackendNodeId, NodeId>,
parameter_values: HashMap<InputId, f32>,
/// Groups are only saved/restored for VA transitions. For Group transitions,
/// groups persist in self (so sub-groups aren't lost on exit).
groups: Option<Vec<GroupDef>>,
next_group_id: Option<GroupId>,
group_placeholder_map: HashMap<NodeId, GroupId>,
}
/// 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>,
/// Last seen project generation (to detect project reloads)
last_project_generation: u64,
/// Node currently being dragged (for insert-on-connection-drop)
dragging_node: Option<NodeId>,
/// Connection that would be targeted for insertion (highlighted during drag)
insert_target: Option<(InputId, OutputId)>,
/// Stack of subgraph contexts — empty = editing track-level graph,
/// non-empty = editing nested subgraph(s). Supports arbitrary nesting depth.
subgraph_stack: Vec<SubgraphFrame>,
/// Group definitions (frontend-only — backend graph stays flat)
groups: Vec<GroupDef>,
/// Next group ID to assign
next_group_id: GroupId,
/// Maps frontend NodeId → GroupId for group placeholder nodes
group_placeholder_map: HashMap<NodeId, GroupId>,
/// Group currently being renamed (shows text edit popup)
renaming_group: Option<(GroupId, String)>,
/// Right-click context menu state: (node_id, screen_pos)
node_context_menu: Option<(NodeId, egui::Pos2)>,
/// Cached node screen rects from last frame (for hit-testing)
last_node_rects: std::collections::HashMap<NodeId, egui::Rect>,
/// Last time we polled oscilloscope data (~20 FPS)
last_oscilloscope_poll: std::time::Instant,
/// Backend track ID (u32) for oscilloscope queries
backend_track_id: Option<u32>,
}
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(),
last_project_generation: 0,
dragging_node: None,
insert_target: None,
subgraph_stack: Vec::new(),
groups: Vec::new(),
next_group_id: 1,
group_placeholder_map: HashMap::new(),
renaming_group: None,
node_context_menu: None,
last_node_rects: HashMap::new(),
last_oscilloscope_poll: std::time::Instant::now(),
backend_track_id: None,
}
}
#[allow(dead_code)]
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(),
last_project_generation: 0,
dragging_node: None,
insert_target: None,
subgraph_stack: Vec::new(),
groups: Vec::new(),
next_group_id: 1,
group_placeholder_map: HashMap::new(),
renaming_group: None,
node_context_menu: None,
last_node_rects: HashMap::new(),
last_oscilloscope_poll: std::time::Instant::now(),
backend_track_id: Some(backend_track_id),
};
// 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 json = if let Some(backend) = &self.backend {
backend.get_state_json()?
} else {
return Err("No backend available".to_string());
};
self.load_graph_from_json(&json)
}
fn handle_graph_response(
&mut self,
response: egui_node_graph2::GraphResponse<
graph_data::UserResponse,
graph_data::NodeData,
>,
shared: &mut crate::panes::SharedPaneState,
pane_rect: egui::Rect,
) {
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
// Reposition to the center of the pane (in graph coordinates)
let center_graph = (pane_rect.center().to_vec2()
- self.state.pan_zoom.pan
- pane_rect.min.to_vec2())
/ self.state.pan_zoom.zoom;
self.state.node_positions.insert(node_id, center_graph.to_pos2());
if let Some(node) = self.state.graph.nodes.get(node_id) {
let node_type = node.user_data.template.backend_type_name().to_string();
let position = (center_graph.x, center_graph.y);
if let Some(track_id) = self.track_id {
if let Some(va_id) = self.va_context() {
// Inside VA template — call template command directly
if let Some(&backend_track_id) = shared.layer_to_track_map.get(&track_id) {
if let Some(audio_controller) = &shared.audio_controller {
let mut controller = audio_controller.lock().unwrap();
controller.graph_add_node_to_template(
backend_track_id, va_id, node_type.clone(),
position.0, position.1,
);
// Query template state to get the new node's backend ID
std::thread::sleep(std::time::Duration::from_millis(10));
if let Ok(json) = controller.query_template_state(backend_track_id, va_id) {
if let Ok(state) = serde_json::from_str::<daw_backend::audio::node_graph::GraphPreset>(&json) {
// Find the new node by type and 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(node_id, backend_id);
self.backend_to_frontend_map.insert(backend_id, node_id);
}
}
}
}
}
} else {
// Normal track graph — use action system
let action = Box::new(actions::NodeGraphAction::AddNode(
actions::AddNodeAction::new(track_id, node_type.clone(), position)
));
self.pending_action = Some(action);
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 BackendNodeId::Audio(from_idx) = from_id;
let BackendNodeId::Audio(to_idx) = to_id;
if let Some(va_id) = self.va_context() {
// Inside VA template
if let Some(&backend_track_id) = shared.layer_to_track_map.get(&track_id) {
if let Some(audio_controller) = &shared.audio_controller {
let mut controller = audio_controller.lock().unwrap();
controller.graph_connect_in_template(
backend_track_id, va_id,
from_idx.index() as u32, from_port,
to_idx.index() as u32, to_port,
);
}
}
} else {
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 {
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) {
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);
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 BackendNodeId::Audio(from_idx) = from_id;
let BackendNodeId::Audio(to_idx) = to_id;
if let Some(va_id) = self.va_context() {
// Inside VA template
if let Some(&backend_track_id) = shared.layer_to_track_map.get(&track_id) {
if let Some(audio_controller) = &shared.audio_controller {
let mut controller = audio_controller.lock().unwrap();
controller.graph_disconnect_in_template(
backend_track_id, va_id,
from_idx.index() as u32, from_port,
to_idx.index() as u32, to_port,
);
}
}
} else {
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, .. } => {
// If this is a group placeholder, ungroup instead of deleting
if let Some(&group_id) = self.group_placeholder_map.get(&node_id) {
self.groups.retain(|g| g.id != group_id);
// Will rebuild view after response handling
self.rebuild_view();
self.sync_groups_to_backend(shared);
continue;
}
// Node was deleted
if let Some(track_id) = self.track_id {
if let Some(&backend_id) = self.node_id_map.get(&node_id) {
let BackendNodeId::Audio(node_idx) = backend_id;
if let Some(va_id) = self.va_context() {
// Inside VA template
if let Some(&backend_track_id) = shared.layer_to_track_map.get(&track_id) {
if let Some(audio_controller) = &shared.audio_controller {
let mut controller = audio_controller.lock().unwrap();
controller.graph_remove_node_from_template(
backend_track_id, va_id, node_idx.index() as u32,
);
}
}
} else {
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: _ } => {
self.user_state.active_node = Some(node);
self.dragging_node = Some(node);
// Update group placeholder position (frontend-only, no backend sync)
if let Some(&group_id) = self.group_placeholder_map.get(&node) {
if let Some(pos) = self.state.node_positions.get(node) {
if let Some(group) = self.groups.iter_mut().find(|g| g.id == group_id) {
group.position = (pos.x, pos.y);
}
}
continue;
}
// Sync updated position to backend
if let Some(&backend_id) = self.node_id_map.get(&node) {
if let Some(pos) = self.state.node_positions.get(node) {
let node_index = match backend_id {
BackendNodeId::Audio(idx) => idx.index() as u32,
};
if let Some(audio_controller) = &shared.audio_controller {
if let Some(&backend_track_id) = self.track_id.and_then(|tid| shared.layer_to_track_map.get(&tid)) {
let mut controller = audio_controller.lock().unwrap();
if let Some(va_id) = self.va_context() {
controller.graph_set_node_position_in_template(
backend_track_id,
va_id,
node_index,
pos.x,
pos.y,
);
} else {
controller.graph_set_node_position(
backend_track_id,
node_index,
pos.x,
pos.y,
);
}
}
}
}
}
}
NodeResponse::DoubleClick(node_id) => {
// Check if this is a container node we can enter
if let Some(node) = self.state.graph.nodes.get(node_id) {
match node.user_data.template {
NodeTemplate::VoiceAllocator => {
// VA can only be entered at track level (depth 0)
if !self.in_subgraph() {
if let Some(&backend_id) = self.node_id_map.get(&node_id) {
self.enter_subgraph(
SubgraphContext::VoiceAllocator {
backend_id,
},
shared,
);
}
}
}
NodeTemplate::Group => {
// Groups can nest arbitrarily deep
if let Some(&group_id) = self.group_placeholder_map.get(&node_id) {
let name = node.label.clone();
self.enter_subgraph(
SubgraphContext::Group {
group_id,
name,
},
shared,
);
}
}
_ => {}
}
}
}
_ => {
// Ignore other events (SelectNode, RaiseNode, etc.)
}
}
}
// Execute any pending action created during response handling
self.execute_pending_action(shared);
}
fn execute_pending_action(&mut self, shared: &mut crate::panes::SharedPaneState) {
// 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);
}
}
}
}
}
}
}
} else {
eprintln!("Cannot execute node graph action: no audio controller");
}
}
}
fn handle_pending_sampler_load(
&mut self,
load: graph_data::PendingSamplerLoad,
shared: &mut crate::panes::SharedPaneState,
) {
let backend_track_id = match self.backend_track_id {
Some(id) => id,
None => return,
};
let controller_arc = match &shared.audio_controller {
Some(c) => std::sync::Arc::clone(c),
None => return,
};
match load {
graph_data::PendingSamplerLoad::SimpleFromPool { node_id, backend_node_id, pool_index, name } => {
let mut controller = controller_arc.lock().unwrap();
controller.sampler_load_from_pool(backend_track_id, backend_node_id, pool_index);
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
node.user_data.sample_display_name = Some(name);
}
}
graph_data::PendingSamplerLoad::SimpleFromFile { node_id, backend_node_id } => {
if let Some(path) = rfd::FileDialog::new()
.add_filter("Audio", &["wav", "flac", "mp3", "ogg", "aiff"])
.pick_file()
{
let file_name = path.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Sample".to_string());
// Import into audio pool + asset library, then load from pool
let mut controller = controller_arc.lock().unwrap();
match controller.import_audio_sync(path.to_path_buf()) {
Ok(pool_index) => {
// Add to document asset library
let metadata = daw_backend::io::read_metadata(&path).ok();
let duration = metadata.as_ref().map(|m| m.duration).unwrap_or(0.0);
let clip = lightningbeam_core::clip::AudioClip::new_sampled(&file_name, pool_index, duration);
shared.action_executor.document_mut().add_audio_clip(clip);
// Load into sampler from pool
controller.sampler_load_from_pool(backend_track_id, backend_node_id, pool_index);
}
Err(e) => {
eprintln!("Failed to import audio '{}': {}", path.display(), e);
}
}
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
node.user_data.sample_display_name = Some(file_name);
}
}
}
graph_data::PendingSamplerLoad::MultiFromPool { node_id, backend_node_id, pool_index, name } => {
let mut controller = controller_arc.lock().unwrap();
// Add as a single layer spanning full key range, root_key = 60 (C4)
controller.multi_sampler_add_layer_from_pool(
backend_track_id, backend_node_id, pool_index,
0, 127, 60,
);
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
node.user_data.sample_display_name = Some(name);
}
}
graph_data::PendingSamplerLoad::MultiFromFolder { node_id, folder_id } => {
// Find folder clips from available_folders
let folder_clips: Vec<(String, usize)> = self.user_state.available_folders.iter()
.find(|f| f.folder_id == folder_id)
.map(|f| f.clip_pool_indices.clone())
.unwrap_or_default();
if !folder_clips.is_empty() {
// TODO: Add MultiSamplerLoadFromPool command to avoid disk re-reads.
// For now, folder loading is a placeholder — the UI is wired up but
// loading multi-sampler layers from pool requires a new backend command.
let folder_name = self.user_state.available_folders.iter()
.find(|f| f.folder_id == folder_id)
.map(|f| f.name.clone())
.unwrap_or_else(|| "Folder".to_string());
eprintln!("MultiSampler folder load not yet implemented for folder: {}", folder_name);
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
node.user_data.sample_display_name = Some(format!("📁 {}", folder_name));
}
}
}
graph_data::PendingSamplerLoad::MultiFromFilesystem { node_id, backend_node_id } => {
if let Some(path) = rfd::FileDialog::new()
.add_filter("Audio", &["wav", "flac", "mp3", "ogg", "aiff"])
.pick_file()
{
let file_name = path.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Sample".to_string());
let mut controller = controller_arc.lock().unwrap();
// Import into audio pool + asset library, then load from pool
match controller.import_audio_sync(path.to_path_buf()) {
Ok(pool_index) => {
let metadata = daw_backend::io::read_metadata(&path).ok();
let duration = metadata.as_ref().map(|m| m.duration).unwrap_or(0.0);
let clip = lightningbeam_core::clip::AudioClip::new_sampled(&file_name, pool_index, duration);
shared.action_executor.document_mut().add_audio_clip(clip);
// Add as layer spanning full key range
controller.multi_sampler_add_layer_from_pool(
backend_track_id, backend_node_id, pool_index,
0, 127, 60,
);
}
Err(e) => {
eprintln!("Failed to import audio '{}': {}", path.display(), e);
}
}
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
node.user_data.sample_display_name = Some(file_name);
}
}
}
}
}
fn check_parameter_changes(&mut self, shared: &mut crate::panes::SharedPaneState) {
// Check all input parameters for value changes
let mut _checked_count = 0;
let mut _connection_only_count = 0;
let mut _non_float_count = 0;
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) {
_connection_only_count += 1;
continue;
}
// Get current value and backend param ID
let (current_value, backend_param_id) = match &input_param.value {
ValueType::Float { value, backend_param_id, .. } => {
_checked_count += 1;
(*value, *backend_param_id)
},
other => {
_non_float_count += 1;
eprintln!("[DEBUG] Non-float parameter type: {:?}", std::mem::discriminant(other));
continue;
}
};
// Check if value has changed
let previous_value = self.parameter_values.get(&input_id).copied();
let has_changed = if let Some(prev) = previous_value {
(prev - current_value).abs() > 0.0001
} else {
// First time seeing this parameter - don't send update, just store it
self.parameter_values.insert(input_id, current_value);
false
};
if has_changed {
// Value has changed — send update to backend
if let Some(track_id) = self.track_id {
let node_id = input_param.node;
if let Some(&backend_id) = self.node_id_map.get(&node_id) {
if let Some(param_id) = backend_param_id {
let BackendNodeId::Audio(node_idx) = backend_id;
if let Some(va_id) = self.va_context() {
// Inside VA template — call template command directly
if let Some(&backend_track_id) = shared.layer_to_track_map.get(&track_id) {
if let Some(audio_controller) = &shared.audio_controller {
let mut controller = audio_controller.lock().unwrap();
controller.graph_set_parameter_in_template(
backend_track_id, va_id,
node_idx.index() as u32, param_id, current_value,
);
}
}
} else {
let action = Box::new(actions::NodeGraphAction::SetParameter(
actions::SetParameterAction::new(
track_id,
backend_id,
param_id,
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;
}
}
/// Evaluate a cubic bezier curve at parameter t ∈ [0, 1]
fn bezier_point(p0: egui::Pos2, p1: egui::Pos2, p2: egui::Pos2, p3: egui::Pos2, t: f32) -> egui::Pos2 {
let u = 1.0 - t;
let tt = t * t;
let uu = u * u;
egui::pos2(
uu * u * p0.x + 3.0 * uu * t * p1.x + 3.0 * u * tt * p2.x + tt * t * p3.x,
uu * u * p0.y + 3.0 * uu * t * p1.y + 3.0 * u * tt * p2.y + tt * t * p3.y,
)
}
/// Find the nearest compatible connection for inserting the dragged node.
/// Returns (input_id, output_id, src_graph_pos, dst_graph_pos) — positions in graph space.
fn find_insert_target(
&self,
dragged_node: NodeId,
) -> Option<(InputId, OutputId, egui::Pos2, egui::Pos2)> {
let node_pos = *self.state.node_positions.get(dragged_node)?;
// Collect which InputIds are connected (to find free ports on dragged node)
let mut connected_inputs: std::collections::HashSet<InputId> = std::collections::HashSet::new();
let mut connected_outputs: std::collections::HashSet<OutputId> = std::collections::HashSet::new();
for (input_id, outputs) in self.state.graph.iter_connection_groups() {
connected_inputs.insert(input_id);
for output_id in outputs {
connected_outputs.insert(output_id);
}
}
// Get dragged node's free input types and free output types
let dragged_data = self.state.graph.nodes.get(dragged_node)?;
let free_input_types: Vec<DataType> = dragged_data.inputs.iter()
.filter(|(_, id)| !connected_inputs.contains(id))
.filter_map(|(_, id)| {
let param = self.state.graph.inputs.get(*id)?;
if matches!(param.kind, InputParamKind::ConstantOnly) { return None; }
Some(param.typ.clone())
})
.collect();
let free_output_types: Vec<DataType> = dragged_data.outputs.iter()
.filter(|(_, id)| !connected_outputs.contains(id))
.filter_map(|(_, id)| Some(self.state.graph.outputs.get(*id)?.typ.clone()))
.collect();
if free_input_types.is_empty() || free_output_types.is_empty() {
return None;
}
let threshold = 50.0; // graph-space distance threshold
let mut best: Option<(InputId, OutputId, egui::Pos2, egui::Pos2, f32)> = None;
for (input_id, outputs) in self.state.graph.iter_connection_groups() {
for output_id in outputs {
// Skip connections involving the dragged node
let input_node = self.state.graph.inputs.get(input_id).map(|p| p.node);
let output_node = self.state.graph.outputs.get(output_id).map(|p| p.node);
if input_node == Some(dragged_node) || output_node == Some(dragged_node) {
continue;
}
// Check data type compatibility
let conn_type = match self.state.graph.outputs.get(output_id) {
Some(p) => p.typ.clone(),
None => continue,
};
let has_matching_input = free_input_types.iter().any(|t| *t == conn_type);
let has_matching_output = free_output_types.iter().any(|t| *t == conn_type);
if !has_matching_input || !has_matching_output {
continue;
}
// Get source and dest node positions (graph space)
let src_node_id = output_node.unwrap();
let dst_node_id = input_node.unwrap();
let src_node_pos = match self.state.node_positions.get(src_node_id) {
Some(p) => *p,
None => continue,
};
let dst_node_pos = match self.state.node_positions.get(dst_node_id) {
Some(p) => *p,
None => continue,
};
// Approximate port positions in graph space (output on right, input on left)
let src_port = egui::pos2(src_node_pos.x + 80.0, src_node_pos.y + 30.0);
let dst_port = egui::pos2(dst_node_pos.x - 10.0, dst_node_pos.y + 30.0);
// Compute bezier in graph space
let control_scale = ((dst_port.x - src_port.x) / 2.0).max(30.0);
let src_ctrl = egui::pos2(src_port.x + control_scale, src_port.y);
let dst_ctrl = egui::pos2(dst_port.x - control_scale, dst_port.y);
// Sample bezier and find min distance to dragged node center
let mut min_dist = f32::MAX;
for i in 0..=20 {
let t = i as f32 / 20.0;
let p = Self::bezier_point(src_port, src_ctrl, dst_ctrl, dst_port, t);
let d = node_pos.distance(p);
if d < min_dist {
min_dist = d;
}
}
if min_dist < threshold {
if best.is_none() || min_dist < best.as_ref().unwrap().4 {
best = Some((input_id, output_id, src_port, dst_port, min_dist));
}
}
}
}
best.map(|(input, output, src, dst, _)| (input, output, src, dst))
}
/// Draw a highlight over a connection to indicate insertion target.
/// src/dst are in graph space — converted to screen space here.
fn draw_connection_highlight(
ui: &egui::Ui,
src_graph: egui::Pos2,
dst_graph: egui::Pos2,
zoom: f32,
pan: egui::Vec2,
editor_offset: egui::Vec2,
) {
// Convert graph space to screen space
let to_screen = |p: egui::Pos2| -> egui::Pos2 {
egui::pos2(p.x * zoom + pan.x + editor_offset.x, p.y * zoom + pan.y + editor_offset.y)
};
let src = to_screen(src_graph);
let dst = to_screen(dst_graph);
let control_scale = ((dst.x - src.x) / 2.0).max(30.0 * zoom);
let src_ctrl = egui::pos2(src.x + control_scale, src.y);
let dst_ctrl = egui::pos2(dst.x - control_scale, dst.y);
let bezier = egui::epaint::CubicBezierShape::from_points_stroke(
[src, src_ctrl, dst_ctrl, dst],
false,
egui::Color32::TRANSPARENT,
egui::Stroke::new(7.0 * zoom, egui::Color32::from_rgb(100, 220, 100)),
);
ui.painter().add(bezier);
}
/// Execute the insert-node-on-connection action
fn execute_insert_on_connection(
&mut self,
dragged_node: NodeId,
target_input: InputId,
target_output: OutputId,
shared: &mut crate::panes::SharedPaneState,
) {
let track_id = match self.track_id {
Some(id) => id,
None => return,
};
let backend_track_id = match shared.layer_to_track_map.get(&track_id) {
Some(&id) => id,
None => return,
};
let audio_controller = match shared.audio_controller {
Some(ref c) => (*c).clone(),
None => return,
};
// Get the connection's data type to find matching ports on dragged node
let conn_type = match self.state.graph.outputs.get(target_output) {
Some(p) => p.typ.clone(),
None => return,
};
// Get the source and dest nodes/ports of the existing connection
let src_frontend_node = match self.state.graph.outputs.get(target_output) {
Some(p) => p.node,
None => return,
};
let dst_frontend_node = match self.state.graph.inputs.get(target_input) {
Some(p) => p.node,
None => return,
};
let src_port_idx = self.state.graph.nodes.get(src_frontend_node)
.and_then(|n| n.outputs.iter().position(|(_, id)| *id == target_output))
.unwrap_or(0);
let dst_port_idx = self.state.graph.nodes.get(dst_frontend_node)
.and_then(|n| n.inputs.iter().position(|(_, id)| *id == target_input))
.unwrap_or(0);
// Find matching free input and output on the dragged node
let dragged_data = match self.state.graph.nodes.get(dragged_node) {
Some(d) => d,
None => return,
};
// Collect connected ports
let mut connected_inputs: std::collections::HashSet<InputId> = std::collections::HashSet::new();
let mut connected_outputs: std::collections::HashSet<OutputId> = std::collections::HashSet::new();
for (input_id, outputs) in self.state.graph.iter_connection_groups() {
connected_inputs.insert(input_id);
for output_id in outputs {
connected_outputs.insert(output_id);
}
}
// Find first free input with matching type
let drag_input = dragged_data.inputs.iter()
.find(|(_, id)| {
if connected_inputs.contains(id) { return false; }
self.state.graph.inputs.get(*id)
.map(|p| {
!matches!(p.kind, InputParamKind::ConstantOnly) && p.typ == conn_type
})
.unwrap_or(false)
})
.map(|(_, id)| *id);
let drag_output = dragged_data.outputs.iter()
.find(|(_, id)| {
if connected_outputs.contains(id) { return false; }
self.state.graph.outputs.get(*id)
.map(|p| p.typ == conn_type)
.unwrap_or(false)
})
.map(|(_, id)| *id);
let (drag_input_id, drag_output_id) = match (drag_input, drag_output) {
(Some(i), Some(o)) => (i, o),
_ => return,
};
let drag_input_port_idx = dragged_data.inputs.iter()
.position(|(_, id)| *id == drag_input_id)
.unwrap_or(0);
let drag_output_port_idx = dragged_data.outputs.iter()
.position(|(_, id)| *id == drag_output_id)
.unwrap_or(0);
// Get backend node IDs
let src_backend = match self.node_id_map.get(&src_frontend_node) {
Some(&id) => id,
None => return,
};
let dst_backend = match self.node_id_map.get(&dst_frontend_node) {
Some(&id) => id,
None => return,
};
let drag_backend = match self.node_id_map.get(&dragged_node) {
Some(&id) => id,
None => return,
};
let BackendNodeId::Audio(src_idx) = src_backend;
let BackendNodeId::Audio(dst_idx) = dst_backend;
let BackendNodeId::Audio(drag_idx) = drag_backend;
// Send commands to backend: disconnect old, connect source→drag, connect drag→dest
{
let mut controller = audio_controller.lock().unwrap();
controller.graph_disconnect(
backend_track_id,
src_idx.index() as u32, src_port_idx,
dst_idx.index() as u32, dst_port_idx,
);
controller.graph_connect(
backend_track_id,
src_idx.index() as u32, src_port_idx,
drag_idx.index() as u32, drag_input_port_idx,
);
controller.graph_connect(
backend_track_id,
drag_idx.index() as u32, drag_output_port_idx,
dst_idx.index() as u32, dst_port_idx,
);
}
// Update frontend connections
// Remove old connection
if let Some(conns) = self.state.graph.connections.get_mut(target_input) {
conns.retain(|&o| o != target_output);
}
// Add source → drag_input
if let Some(conns) = self.state.graph.connections.get_mut(drag_input_id) {
conns.push(target_output);
} else {
self.state.graph.connections.insert(drag_input_id, vec![target_output]);
}
// Add drag_output → dest
if let Some(conns) = self.state.graph.connections.get_mut(target_input) {
conns.push(drag_output_id);
} else {
self.state.graph.connections.insert(target_input, vec![drag_output_id]);
}
}
/// Enter a subgraph for editing (VA template or Group internals)
fn enter_subgraph(
&mut self,
context: SubgraphContext,
shared: &mut crate::panes::SharedPaneState,
) {
let is_va = matches!(context, SubgraphContext::VoiceAllocator { .. });
// Only save/restore groups for VA transitions.
// For Group transitions, groups persist in self so sub-groups aren't lost.
let (saved_groups, saved_next_group_id) = if is_va {
(Some(std::mem::take(&mut self.groups)), Some(std::mem::replace(&mut self.next_group_id, 1)))
} else {
(None, None)
};
// Save current editor state
let saved = SavedGraphState {
state: std::mem::replace(&mut self.state, GraphEditorState::new(1.0)),
user_state: std::mem::replace(&mut self.user_state, GraphState::default()),
node_id_map: std::mem::take(&mut self.node_id_map),
backend_to_frontend_map: std::mem::take(&mut self.backend_to_frontend_map),
parameter_values: std::mem::take(&mut self.parameter_values),
groups: saved_groups,
next_group_id: saved_next_group_id,
group_placeholder_map: std::mem::take(&mut self.group_placeholder_map),
};
self.subgraph_stack.push(SubgraphFrame {
context: context.clone(),
saved_state: saved,
});
// Load the subgraph state from backend
match &context {
SubgraphContext::VoiceAllocator { backend_id, .. } => {
let BackendNodeId::Audio(va_idx) = *backend_id;
if let Some(track_id) = self.track_id {
if let Some(&backend_track_id) = shared.layer_to_track_map.get(&track_id) {
if let Some(audio_controller) = &shared.audio_controller {
let mut controller = audio_controller.lock().unwrap();
match controller.query_template_state(backend_track_id, va_idx.index() as u32) {
Ok(json) => {
if let Err(e) = self.load_graph_from_json(&json) {
eprintln!("Failed to load template state: {}", e);
}
}
Err(e) => {
eprintln!("Failed to query template state: {}", e);
}
}
}
}
}
}
SubgraphContext::Group { .. } => {
// Groups are frontend-only. Rebuild the view scoped to this group,
// showing member nodes, sub-group placeholders, and boundary indicators.
self.rebuild_view();
}
}
}
/// Exit the current subgraph level, restoring parent state
fn exit_subgraph(&mut self) {
if let Some(frame) = self.subgraph_stack.pop() {
self.state = frame.saved_state.state;
self.user_state = frame.saved_state.user_state;
self.node_id_map = frame.saved_state.node_id_map;
self.backend_to_frontend_map = frame.saved_state.backend_to_frontend_map;
self.parameter_values = frame.saved_state.parameter_values;
// Only restore groups if they were saved (VA transitions save them, Group transitions don't)
if let Some(groups) = frame.saved_state.groups {
self.groups = groups;
}
if let Some(next_id) = frame.saved_state.next_group_id {
self.next_group_id = next_id;
}
self.group_placeholder_map = frame.saved_state.group_placeholder_map;
}
}
/// Exit to a specific depth in the subgraph stack (0 = track level)
fn exit_to_level(&mut self, target_depth: usize) {
while self.subgraph_stack.len() > target_depth {
self.exit_subgraph();
}
}
/// Load graph state from a JSON string (used for both track graphs and subgraphs)
fn load_graph_from_json(&mut self, json: &str) -> Result<(), String> {
let graph_state: daw_backend::audio::node_graph::GraphPreset =
serde_json::from_str(json).map_err(|e| format!("Failed to parse graph state: {}", e))?;
// 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 {
let node_template = match Self::backend_type_to_template(&node.node_type) {
Some(t) => t,
None => {
eprintln!("Unknown node type: {}", node.node_type);
continue;
}
};
self.add_node_to_editor(node_template, &node.node_type, node.position, node.id, &node.parameters);
}
// Create connections in frontend
for conn in &graph_state.connections {
self.add_connection_to_editor(conn.from_node, conn.from_port, conn.to_node, conn.to_port);
}
// Restore groups from preset
self.groups.clear();
self.group_placeholder_map.clear();
self.next_group_id = 1;
if !graph_state.groups.is_empty() {
for sg in &graph_state.groups {
let group = GroupDef {
id: sg.id,
name: sg.name.clone(),
member_nodes: sg.member_nodes.clone(),
position: sg.position,
boundary_inputs: sg.boundary_inputs.iter().map(|bc| BoundaryConnection {
external_node: bc.external_node,
external_port: bc.external_port,
internal_node: bc.internal_node,
internal_port: bc.internal_port,
port_name: bc.port_name.clone(),
data_type: match bc.data_type.as_str() {
"Midi" => DataType::Midi,
"CV" => DataType::CV,
_ => DataType::Audio,
},
}).collect(),
boundary_outputs: sg.boundary_outputs.iter().map(|bc| BoundaryConnection {
external_node: bc.external_node,
external_port: bc.external_port,
internal_node: bc.internal_node,
internal_port: bc.internal_port,
port_name: bc.port_name.clone(),
data_type: match bc.data_type.as_str() {
"Midi" => DataType::Midi,
"CV" => DataType::CV,
_ => DataType::Audio,
},
}).collect(),
parent_group_id: sg.parent_group_id,
};
if sg.id >= self.next_group_id {
self.next_group_id = sg.id + 1;
}
self.groups.push(group);
}
// Rebuild the view to show group placeholders instead of member nodes
self.rebuild_view();
}
Ok(())
}
/// Serialize a GroupDef to backend format
fn serialize_group(g: &GroupDef) -> daw_backend::audio::node_graph::SerializedGroup {
daw_backend::audio::node_graph::SerializedGroup {
id: g.id,
name: g.name.clone(),
member_nodes: g.member_nodes.clone(),
position: g.position,
boundary_inputs: g.boundary_inputs.iter().map(|bc| {
daw_backend::audio::node_graph::SerializedBoundaryConnection {
external_node: bc.external_node,
external_port: bc.external_port,
internal_node: bc.internal_node,
internal_port: bc.internal_port,
port_name: bc.port_name.clone(),
data_type: match bc.data_type {
DataType::Audio => "Audio".to_string(),
DataType::Midi => "Midi".to_string(),
DataType::CV => "CV".to_string(),
},
}
}).collect(),
boundary_outputs: g.boundary_outputs.iter().map(|bc| {
daw_backend::audio::node_graph::SerializedBoundaryConnection {
external_node: bc.external_node,
external_port: bc.external_port,
internal_node: bc.internal_node,
internal_port: bc.internal_port,
port_name: bc.port_name.clone(),
data_type: match bc.data_type {
DataType::Audio => "Audio".to_string(),
DataType::Midi => "Midi".to_string(),
DataType::CV => "CV".to_string(),
},
}
}).collect(),
parent_group_id: g.parent_group_id,
}
}
/// Serialize frontend groups to backend format and send to backend for persistence
fn sync_groups_to_backend(&self, shared: &crate::panes::SharedPaneState) {
let Some(track_id) = self.track_id else { return };
let Some(&backend_track_id) = shared.layer_to_track_map.get(&track_id) else { return };
let Some(audio_controller) = &shared.audio_controller else { return };
let serialized: Vec<_> = self.groups.iter().map(Self::serialize_group).collect();
let mut controller = audio_controller.lock().unwrap();
if let Some(va_id) = self.va_context() {
controller.graph_set_groups_in_template(backend_track_id, va_id, serialized);
} else {
controller.graph_set_groups(backend_track_id, serialized);
}
}
/// Get the VA backend node ID if we're editing inside a VoiceAllocator template.
/// Searches the entire subgraph stack, not just the top — so a Group inside a VA
/// still finds the VA context.
fn va_context(&self) -> Option<u32> {
for frame in self.subgraph_stack.iter().rev() {
if let SubgraphContext::VoiceAllocator { backend_id, .. } = &frame.context {
let BackendNodeId::Audio(idx) = *backend_id;
return Some(idx.index() as u32);
}
}
None
}
/// Whether we're currently editing inside a subgraph
fn in_subgraph(&self) -> bool {
!self.subgraph_stack.is_empty()
}
/// True if any frame in the subgraph stack is a VoiceAllocator
fn inside_voice_allocator(&self) -> bool {
self.subgraph_stack.iter().any(|frame| {
matches!(&frame.context, SubgraphContext::VoiceAllocator { .. })
})
}
/// Get the GroupId of the current group scope (if inside a group), for filtering sub-groups.
fn current_group_scope(&self) -> Option<GroupId> {
self.subgraph_stack.last().and_then(|frame| {
if let SubgraphContext::Group { group_id, .. } = &frame.context {
Some(*group_id)
} else {
None
}
})
}
/// Build breadcrumb segments for the current subgraph stack
fn breadcrumb_segments(&self) -> Vec<String> {
let mut segments = vec!["Track Graph".to_string()];
for frame in &self.subgraph_stack {
match &frame.context {
SubgraphContext::VoiceAllocator { .. } => segments.push("Voice Allocator".to_string()),
SubgraphContext::Group { name, .. } => segments.push(format!("Group '{}'", name)),
}
}
segments
}
/// Group the currently selected nodes into a new group
fn group_selected_nodes(&mut self, shared: &mut crate::panes::SharedPaneState) {
if self.state.selected_nodes.len() < 2 {
return;
}
// Don't allow grouping group placeholders
if self.state.selected_nodes.iter().any(|id| self.group_placeholder_map.contains_key(id)) {
return;
}
// Collect selected backend IDs
let selected_backend_ids: Vec<u32> = self.state.selected_nodes.iter()
.filter_map(|fid| self.node_id_map.get(fid))
.map(|bid| { let BackendNodeId::Audio(idx) = *bid; idx.index() as u32 })
.collect();
if selected_backend_ids.is_empty() {
return;
}
let selected_set: HashSet<u32> = selected_backend_ids.iter().copied().collect();
// Find boundary connections by scanning all connections in the editor
let mut boundary_inputs: Vec<BoundaryConnection> = Vec::new();
let mut boundary_outputs: Vec<BoundaryConnection> = Vec::new();
// Collect connection info: (input_id, vec of output_ids)
let connections: Vec<(InputId, Vec<OutputId>)> = self.state.graph.connections.iter()
.map(|(iid, oids)| (iid, oids.clone()))
.collect();
for (input_id, output_ids) in &connections {
let to_node_fid = self.state.graph.inputs.get(*input_id).map(|p| p.node);
for &output_id in output_ids {
let from_node_fid = self.state.graph.outputs.get(output_id).map(|p| p.node);
if let (Some(from_fid), Some(to_fid)) = (from_node_fid, to_node_fid) {
let from_bid = self.node_id_map.get(&from_fid)
.map(|b| { let BackendNodeId::Audio(idx) = *b; idx.index() as u32 });
let to_bid = self.node_id_map.get(&to_fid)
.map(|b| { let BackendNodeId::Audio(idx) = *b; idx.index() as u32 });
if let (Some(from_b), Some(to_b)) = (from_bid, to_bid) {
let from_in_group = selected_set.contains(&from_b);
let to_in_group = selected_set.contains(&to_b);
if !from_in_group && to_in_group {
// Boundary input: external → internal
let from_port = self.state.graph.nodes.get(from_fid)
.and_then(|n| n.outputs.iter().position(|(_, id)| *id == output_id))
.unwrap_or(0);
let to_port = self.state.graph.nodes.get(to_fid)
.and_then(|n| n.inputs.iter().position(|(_, id)| *id == *input_id))
.unwrap_or(0);
// Get port name from the input node's input label, and data type
let (port_name, data_type) = self.state.graph.nodes.get(to_fid)
.and_then(|n| n.inputs.get(to_port))
.map(|(name, iid)| {
let dt = self.state.graph.inputs.get(*iid)
.map(|p| p.typ)
.unwrap_or(DataType::Audio);
(name.clone(), dt)
})
.unwrap_or_else(|| ("In".to_string(), DataType::Audio));
boundary_inputs.push(BoundaryConnection {
external_node: from_b,
external_port: from_port,
internal_node: to_b,
internal_port: to_port,
port_name,
data_type,
});
} else if from_in_group && !to_in_group {
// Boundary output: internal → external
let from_port = self.state.graph.nodes.get(from_fid)
.and_then(|n| n.outputs.iter().position(|(_, id)| *id == output_id))
.unwrap_or(0);
let to_port = self.state.graph.nodes.get(to_fid)
.and_then(|n| n.inputs.iter().position(|(_, id)| *id == *input_id))
.unwrap_or(0);
// Get port name from the output node's output label, and data type
let (port_name, data_type) = self.state.graph.nodes.get(from_fid)
.and_then(|n| n.outputs.get(from_port))
.map(|(name, oid)| {
let dt = self.state.graph.outputs.get(*oid)
.map(|p| p.typ)
.unwrap_or(DataType::Audio);
(name.clone(), dt)
})
.unwrap_or_else(|| ("Out".to_string(), DataType::Audio));
boundary_outputs.push(BoundaryConnection {
external_node: to_b,
external_port: to_port,
internal_node: from_b,
internal_port: from_port,
port_name,
data_type,
});
}
}
}
}
}
// Calculate average position of selected nodes
let mut sum_x = 0.0f32;
let mut sum_y = 0.0f32;
let mut count = 0;
for &fid in &self.state.selected_nodes {
if let Some(pos) = self.state.node_positions.get(fid) {
sum_x += pos.x;
sum_y += pos.y;
count += 1;
}
}
let position = if count > 0 {
(sum_x / count as f32, sum_y / count as f32)
} else {
(0.0, 0.0)
};
// Inherit boundary connections from the parent group for any internal nodes
// that are being included in this sub-group. This handles the case where
// connections pass through the parent's Group Input/Output synthetic nodes
// (which don't have backend IDs and are invisible to the editor scan above).
if let Some(parent_gid) = self.current_group_scope() {
if let Some(parent_group) = self.groups.iter().find(|g| g.id == parent_gid).cloned() {
for bc in &parent_group.boundary_inputs {
if selected_set.contains(&bc.internal_node) {
// Check we don't already have this boundary from the editor scan
let already_exists = boundary_inputs.iter().any(|existing|
existing.internal_node == bc.internal_node &&
existing.internal_port == bc.internal_port &&
existing.external_node == bc.external_node &&
existing.external_port == bc.external_port
);
if !already_exists {
boundary_inputs.push(bc.clone());
}
}
}
for bc in &parent_group.boundary_outputs {
if selected_set.contains(&bc.internal_node) {
let already_exists = boundary_outputs.iter().any(|existing|
existing.internal_node == bc.internal_node &&
existing.internal_port == bc.internal_port &&
existing.external_node == bc.external_node &&
existing.external_port == bc.external_port
);
if !already_exists {
boundary_outputs.push(bc.clone());
}
}
}
}
}
let group = GroupDef {
id: self.next_group_id,
name: format!("Group {}", self.next_group_id),
member_nodes: selected_backend_ids,
position,
boundary_inputs,
boundary_outputs,
parent_group_id: self.current_group_scope(),
};
self.next_group_id += 1;
self.groups.push(group);
// Rebuild the view to show the group placeholder
self.rebuild_view();
// Sync groups to backend for persistence
self.sync_groups_to_backend(shared);
}
/// Ungroup a group, restoring member nodes to the current view.
/// Also promotes any child groups to the current scope.
fn ungroup(&mut self, group_id: GroupId, shared: &crate::panes::SharedPaneState) {
let parent = self.groups.iter().find(|g| g.id == group_id).and_then(|g| g.parent_group_id);
// Promote child groups: any group whose parent was the ungrouped group
// now becomes a child of the ungrouped group's parent
for g in &mut self.groups {
if g.parent_group_id == Some(group_id) {
g.parent_group_id = parent;
}
}
self.groups.retain(|g| g.id != group_id);
self.rebuild_view();
self.sync_groups_to_backend(shared);
}
/// Rebuild the graph view, scope-aware for nested groups.
/// - At top level (no group scope): shows all ungrouped nodes + root group placeholders
/// - Inside a group: shows that group's member nodes (minus sub-group members) + sub-group placeholders + boundary indicators
/// Context-aware: queries the template graph when inside a VA subgraph.
fn rebuild_view(&mut self) {
let backend = match &self.backend {
Some(b) => b,
None => return,
};
let json = if let Some(va_id) = self.va_context() {
match backend.query_template_state(va_id) {
Ok(json) => json,
Err(e) => { eprintln!("Failed to query template state: {}", e); return; }
}
} else {
match backend.get_state_json() {
Ok(json) => json,
Err(e) => { eprintln!("Failed to query backend: {}", e); return; }
}
};
let graph_state: daw_backend::audio::node_graph::GraphPreset = match serde_json::from_str(&json) {
Ok(state) => state,
Err(e) => { eprintln!("Failed to parse graph state: {}", e); return; }
};
let current_scope = self.current_group_scope();
// Determine which nodes are "in scope" (visible universe)
let scope_members: Option<HashSet<u32>> = current_scope.and_then(|gid| {
self.groups.iter().find(|g| g.id == gid)
.map(|g| g.member_nodes.iter().copied().collect())
});
// Get groups relevant to this scope (direct children)
let relevant_groups: Vec<GroupDef> = self.groups.iter()
.filter(|g| g.parent_group_id == current_scope)
.cloned()
.collect();
// Build set of node IDs hidden behind sub-group placeholders
let sub_grouped_ids: HashSet<u32> = relevant_groups.iter()
.flat_map(|g| g.member_nodes.iter().copied())
.collect();
// Clear editor state
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();
self.group_placeholder_map.clear();
self.parameter_values.clear();
// Add visible nodes: in scope, not hidden by sub-groups
for node in &graph_state.nodes {
// If inside a group, only include nodes that are members of that group
if let Some(ref members) = scope_members {
if !members.contains(&node.id) {
continue;
}
}
// Skip nodes hidden behind sub-group placeholders
if sub_grouped_ids.contains(&node.id) {
continue;
}
let node_template = match Self::backend_type_to_template(&node.node_type) {
Some(t) => t,
None => {
eprintln!("Unknown node type: {}", node.node_type);
continue;
}
};
self.add_node_to_editor(node_template, &node.node_type, node.position, node.id, &node.parameters);
}
// Add sub-group placeholder nodes
for group in &relevant_groups {
let frontend_id = self.state.graph.nodes.insert(egui_node_graph2::Node {
id: NodeId::default(),
label: group.name.clone(),
inputs: vec![],
outputs: vec![],
user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69 },
});
// Add dynamic input ports based on boundary inputs
for (i, bc) in group.boundary_inputs.iter().enumerate() {
let name = if group.boundary_inputs.len() == 1 {
bc.port_name.clone()
} else {
format!("{} {}", bc.port_name, i + 1)
};
self.state.graph.add_input_param(
frontend_id,
name.into(),
bc.data_type,
ValueType::float(0.0),
InputParamKind::ConnectionOnly,
true,
);
}
// Add dynamic output ports based on boundary outputs
for (i, bc) in group.boundary_outputs.iter().enumerate() {
let name = if group.boundary_outputs.len() == 1 {
bc.port_name.clone()
} else {
format!("{} {}", bc.port_name, i + 1)
};
self.state.graph.add_output_param(frontend_id, name.into(), bc.data_type);
}
self.state.node_positions.insert(frontend_id, egui::pos2(group.position.0, group.position.1));
self.state.node_order.push(frontend_id);
self.group_placeholder_map.insert(frontend_id, group.id);
}
// Add connections between visible nodes (skip connections involving sub-grouped nodes)
for conn in &graph_state.connections {
// If scoped, both endpoints must be in scope
if let Some(ref members) = scope_members {
if !members.contains(&conn.from_node) || !members.contains(&conn.to_node) {
continue;
}
}
// Skip connections involving sub-grouped nodes
if sub_grouped_ids.contains(&conn.from_node) || sub_grouped_ids.contains(&conn.to_node) {
continue;
}
self.add_connection_to_editor(conn.from_node, conn.from_port, conn.to_node, conn.to_port);
}
// If inside a group, add synthetic Group Input / Group Output boundary indicator nodes
// BEFORE wiring sub-group boundaries, so sub-groups can wire to these nodes.
let mut group_input_fid: Option<NodeId> = None;
let mut group_output_fid: Option<NodeId> = None;
let scope_group = current_scope.and_then(|gid| {
self.groups.iter().find(|g| g.id == gid).cloned()
});
if let Some(ref scope_group) = scope_group {
// Group Input (for boundary inputs)
if !scope_group.boundary_inputs.is_empty() {
let min_x = graph_state.nodes.iter()
.filter(|n| scope_group.member_nodes.contains(&n.id))
.map(|n| n.position.0)
.fold(f32::INFINITY, f32::min);
let gi_fid = self.state.graph.nodes.insert(egui_node_graph2::Node {
id: NodeId::default(),
label: "Group Input".to_string(),
inputs: vec![],
outputs: vec![],
user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69 },
});
for bc in &scope_group.boundary_inputs {
self.state.graph.add_output_param(gi_fid, bc.port_name.clone().into(), bc.data_type);
}
self.state.node_positions.insert(gi_fid, egui::pos2(min_x - 250.0, 0.0));
self.state.node_order.push(gi_fid);
group_input_fid = Some(gi_fid);
// Wire Group Input outputs to visible internal nodes (not sub-grouped)
for (port_idx, bc) in scope_group.boundary_inputs.iter().enumerate() {
if sub_grouped_ids.contains(&bc.internal_node) {
continue; // Will be wired through sub-group placeholder below
}
let to_backend = BackendNodeId::Audio(petgraph::stable_graph::NodeIndex::new(bc.internal_node as usize));
if let Some(&to_fid) = self.backend_to_frontend_map.get(&to_backend) {
if let Some(to_node) = self.state.graph.nodes.get(to_fid) {
if let Some((_name, input_id)) = to_node.inputs.get(bc.internal_port) {
if let Some(gi_node) = self.state.graph.nodes.get(gi_fid) {
if let Some((_name, output_id)) = gi_node.outputs.get(port_idx) {
if let Some(conns) = self.state.graph.connections.get_mut(*input_id) {
conns.push(*output_id);
} else {
self.state.graph.connections.insert(*input_id, vec![*output_id]);
}
}
}
}
}
}
}
}
// Group Output (for boundary outputs)
if !scope_group.boundary_outputs.is_empty() {
let max_x = graph_state.nodes.iter()
.filter(|n| scope_group.member_nodes.contains(&n.id))
.map(|n| n.position.0)
.fold(f32::NEG_INFINITY, f32::max);
let go_fid = self.state.graph.nodes.insert(egui_node_graph2::Node {
id: NodeId::default(),
label: "Group Output".to_string(),
inputs: vec![],
outputs: vec![],
user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69 },
});
for bc in &scope_group.boundary_outputs {
self.state.graph.add_input_param(
go_fid,
bc.port_name.clone().into(),
bc.data_type,
ValueType::float(0.0),
InputParamKind::ConnectionOnly,
true,
);
}
self.state.node_positions.insert(go_fid, egui::pos2(max_x + 250.0, 0.0));
self.state.node_order.push(go_fid);
group_output_fid = Some(go_fid);
// Wire visible internal nodes to Group Output inputs (not sub-grouped)
for (port_idx, bc) in scope_group.boundary_outputs.iter().enumerate() {
if sub_grouped_ids.contains(&bc.internal_node) {
continue; // Will be wired through sub-group placeholder below
}
let from_backend = BackendNodeId::Audio(petgraph::stable_graph::NodeIndex::new(bc.internal_node as usize));
if let Some(&from_fid) = self.backend_to_frontend_map.get(&from_backend) {
if let Some(from_node) = self.state.graph.nodes.get(from_fid) {
if let Some((_name, output_id)) = from_node.outputs.get(bc.internal_port) {
if let Some(go_node) = self.state.graph.nodes.get(go_fid) {
if let Some((_name, input_id)) = go_node.inputs.get(port_idx) {
self.state.graph.connections.insert(*input_id, vec![*output_id]);
}
}
}
}
}
}
}
}
// Add boundary connections to/from sub-group placeholders
for group in &relevant_groups {
let placeholder_fid = self.group_placeholder_map.iter()
.find(|(_, gid)| **gid == group.id)
.map(|(fid, _)| *fid);
if let Some(placeholder_fid) = placeholder_fid {
// Boundary inputs: external_node output → group input port
for (port_idx, bc) in group.boundary_inputs.iter().enumerate() {
let from_backend = BackendNodeId::Audio(petgraph::stable_graph::NodeIndex::new(bc.external_node as usize));
if let Some(&from_fid) = self.backend_to_frontend_map.get(&from_backend) {
// External node is visible in this scope — wire directly
if let Some(from_node) = self.state.graph.nodes.get(from_fid) {
if let Some((_name, output_id)) = from_node.outputs.get(bc.external_port) {
if let Some(placeholder_node) = self.state.graph.nodes.get(placeholder_fid) {
if let Some((_name, input_id)) = placeholder_node.inputs.get(port_idx) {
self.state.graph.connections.insert(*input_id, vec![*output_id]);
}
}
}
}
} else if let (Some(ref sg), Some(gi_fid)) = (&scope_group, group_input_fid) {
// External node is outside scope — wire from Group Input instead.
// Find which Group Input port matches this boundary connection.
if let Some(gi_port_idx) = sg.boundary_inputs.iter().position(|sbc|
sbc.external_node == bc.external_node &&
sbc.external_port == bc.external_port &&
sbc.internal_node == bc.internal_node &&
sbc.internal_port == bc.internal_port
) {
if let Some(gi_node) = self.state.graph.nodes.get(gi_fid) {
if let Some((_name, output_id)) = gi_node.outputs.get(gi_port_idx) {
if let Some(placeholder_node) = self.state.graph.nodes.get(placeholder_fid) {
if let Some((_name, input_id)) = placeholder_node.inputs.get(port_idx) {
self.state.graph.connections.insert(*input_id, vec![*output_id]);
}
}
}
}
}
}
}
// Boundary outputs: group output port → external_node input
for (port_idx, bc) in group.boundary_outputs.iter().enumerate() {
let to_backend = BackendNodeId::Audio(petgraph::stable_graph::NodeIndex::new(bc.external_node as usize));
if let Some(&to_fid) = self.backend_to_frontend_map.get(&to_backend) {
// External node is visible in this scope — wire directly
if let Some(to_node) = self.state.graph.nodes.get(to_fid) {
if let Some((_name, input_id)) = to_node.inputs.get(bc.external_port) {
if let Some(placeholder_node) = self.state.graph.nodes.get(placeholder_fid) {
if let Some((_name, output_id)) = placeholder_node.outputs.get(port_idx) {
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]);
}
}
}
}
}
} else if let (Some(ref sg), Some(go_fid)) = (&scope_group, group_output_fid) {
// External node is outside scope — wire to Group Output instead.
if let Some(go_port_idx) = sg.boundary_outputs.iter().position(|sbc|
sbc.external_node == bc.external_node &&
sbc.external_port == bc.external_port &&
sbc.internal_node == bc.internal_node &&
sbc.internal_port == bc.internal_port
) {
if let Some(placeholder_node) = self.state.graph.nodes.get(placeholder_fid) {
if let Some((_name, output_id)) = placeholder_node.outputs.get(port_idx) {
if let Some(go_node) = self.state.graph.nodes.get(go_fid) {
if let Some((_name, input_id)) = go_node.inputs.get(go_port_idx) {
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]);
}
}
}
}
}
}
}
}
}
}
}
/// Helper: map backend node type string to frontend NodeTemplate
fn backend_type_to_template(node_type: &str) -> Option<NodeTemplate> {
match node_type {
"MidiInput" => Some(NodeTemplate::MidiInput),
"AudioInput" => Some(NodeTemplate::AudioInput),
"AutomationInput" => Some(NodeTemplate::AutomationInput),
"Oscillator" => Some(NodeTemplate::Oscillator),
"WavetableOscillator" => Some(NodeTemplate::WavetableOscillator),
"FMSynth" => Some(NodeTemplate::FmSynth),
"NoiseGenerator" => Some(NodeTemplate::Noise),
"SimpleSampler" => Some(NodeTemplate::SimpleSampler),
"MultiSampler" => Some(NodeTemplate::MultiSampler),
"Filter" => Some(NodeTemplate::Filter),
"Gain" => Some(NodeTemplate::Gain),
"Echo" | "Delay" => Some(NodeTemplate::Echo),
"Reverb" => Some(NodeTemplate::Reverb),
"Chorus" => Some(NodeTemplate::Chorus),
"Flanger" => Some(NodeTemplate::Flanger),
"Phaser" => Some(NodeTemplate::Phaser),
"Distortion" => Some(NodeTemplate::Distortion),
"BitCrusher" => Some(NodeTemplate::BitCrusher),
"Compressor" => Some(NodeTemplate::Compressor),
"Limiter" => Some(NodeTemplate::Limiter),
"EQ" => Some(NodeTemplate::Eq),
"Pan" => Some(NodeTemplate::Pan),
"RingModulator" => Some(NodeTemplate::RingModulator),
"Vocoder" => Some(NodeTemplate::Vocoder),
"ADSR" => Some(NodeTemplate::Adsr),
"LFO" => Some(NodeTemplate::Lfo),
"Mixer" => Some(NodeTemplate::Mixer),
"Splitter" => Some(NodeTemplate::Splitter),
"Constant" => Some(NodeTemplate::Constant),
"MidiToCV" => Some(NodeTemplate::MidiToCv),
"AudioToCV" => Some(NodeTemplate::AudioToCv),
"Math" => Some(NodeTemplate::Math),
"SampleHold" => Some(NodeTemplate::SampleHold),
"SlewLimiter" => Some(NodeTemplate::SlewLimiter),
"Quantizer" => Some(NodeTemplate::Quantizer),
"EnvelopeFollower" => Some(NodeTemplate::EnvelopeFollower),
"BPMDetector" => Some(NodeTemplate::BpmDetector),
"Mod" => Some(NodeTemplate::Mod),
"Oscilloscope" => Some(NodeTemplate::Oscilloscope),
"Arpeggiator" => Some(NodeTemplate::Arpeggiator),
"Beat" => Some(NodeTemplate::Beat),
"VoiceAllocator" => Some(NodeTemplate::VoiceAllocator),
"Group" => Some(NodeTemplate::Group),
"TemplateInput" => Some(NodeTemplate::TemplateInput),
"TemplateOutput" => Some(NodeTemplate::TemplateOutput),
"AudioOutput" => Some(NodeTemplate::AudioOutput),
_ => None,
}
}
/// Helper: add a node to the editor state and return its frontend ID
fn add_node_to_editor(
&mut self,
node_template: NodeTemplate,
label: &str,
position: (f32, f32),
backend_node_id: u32,
parameters: &std::collections::HashMap<u32, f32>,
) -> Option<NodeId> {
let frontend_id = self.state.graph.nodes.insert(egui_node_graph2::Node {
id: NodeId::default(),
label: label.to_string(),
inputs: vec![],
outputs: vec![],
user_data: NodeData { template: node_template, sample_display_name: None, root_note: 69 },
});
node_template.build_node(&mut self.state.graph, &mut self.user_state, frontend_id);
self.state.node_positions.insert(frontend_id, egui::pos2(position.0, position.1));
self.state.node_order.push(frontend_id);
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);
// Set parameter values from backend
if let Some(node_data) = self.state.graph.nodes.get(frontend_id) {
let input_ids: Vec<InputId> = node_data.inputs.iter().map(|(_, id)| *id).collect();
for input_id in input_ids {
if let Some(input_param) = self.state.graph.inputs.get_mut(input_id) {
if let ValueType::Float { value, backend_param_id: Some(pid), .. } = &mut input_param.value {
if let Some(&backend_value) = parameters.get(pid) {
*value = backend_value as f32;
}
}
}
}
}
Some(frontend_id)
}
/// Helper: add a connection to the editor state
fn add_connection_to_editor(&mut self, from_node: u32, from_port: usize, to_node: u32, to_port: usize) {
let from_backend = BackendNodeId::Audio(petgraph::stable_graph::NodeIndex::new(from_node as usize));
let to_backend = BackendNodeId::Audio(petgraph::stable_graph::NodeIndex::new(to_node as usize));
if let (Some(&from_fid), Some(&to_fid)) = (
self.backend_to_frontend_map.get(&from_backend),
self.backend_to_frontend_map.get(&to_backend),
) {
if let Some(from_node) = self.state.graph.nodes.get(from_fid) {
if let Some((_name, output_id)) = from_node.outputs.get(from_port) {
if let Some(to_node) = self.state.graph.nodes.get(to_fid) {
if let Some((_name, input_id)) = to_node.inputs.get(to_port) {
let max_conns = self.state.graph.inputs.get(*input_id)
.and_then(|p| p.max_connections)
.map(|n| n.get() as usize)
.unwrap_or(usize::MAX);
let current_count = self.state.graph.connections.get(*input_id)
.map(|c| c.len())
.unwrap_or(0);
if current_count < max_conns {
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]);
}
}
}
}
}
}
}
}
}
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 or project reload
let current_track = *shared.active_layer_id;
let generation_changed = *shared.project_generation != self.last_project_generation;
if generation_changed {
self.last_project_generation = *shared.project_generation;
}
// If selected track changed or project was reloaded, reload the graph
if self.track_id != current_track || (generation_changed && current_track.is_some()) {
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 — exit any subgraph editing and clear groups
self.subgraph_stack.clear();
self.groups.clear();
self.next_group_id = 1;
self.group_placeholder_map.clear();
self.track_id = Some(new_track_id);
// Recreate backend
self.backend_track_id = Some(backend_track_id);
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;
}
// Poll oscilloscope data at ~20 FPS
let has_oscilloscopes;
if self.last_oscilloscope_poll.elapsed() >= std::time::Duration::from_millis(50) {
self.last_oscilloscope_poll = std::time::Instant::now();
// Find all Oscilloscope nodes in the current graph
let oscilloscope_nodes: Vec<(NodeId, u32)> = self.state.graph.iter_nodes()
.filter(|&node_id| {
self.state.graph.nodes.get(node_id)
.map(|n| n.user_data.template == NodeTemplate::Oscilloscope)
.unwrap_or(false)
})
.filter_map(|node_id| {
self.node_id_map.get(&node_id).and_then(|backend_id| {
match backend_id {
BackendNodeId::Audio(idx) => Some((node_id, idx.index() as u32)),
}
})
})
.collect();
has_oscilloscopes = !oscilloscope_nodes.is_empty();
if has_oscilloscopes {
if let (Some(backend_track_id), Some(audio_controller)) = (self.backend_track_id, &shared.audio_controller) {
// Check if we're inside a VoiceAllocator subgraph
let va_backend_id = self.subgraph_stack.iter().rev().find_map(|frame| {
if let SubgraphContext::VoiceAllocator { backend_id } = &frame.context {
match backend_id {
BackendNodeId::Audio(idx) => Some(idx.index() as u32),
}
} else {
None
}
});
let mut controller = audio_controller.lock().unwrap();
for (node_id, backend_node_id) in oscilloscope_nodes {
// Calculate sample count from per-node time scale (default 100ms)
let time_ms = self.user_state.oscilloscope_time_scale
.get(&node_id).copied().unwrap_or(100.0);
let sample_count = ((time_ms / 1000.0) * 48000.0) as usize;
let result = if let Some(va_id) = va_backend_id {
controller.query_voice_oscilloscope_data(backend_track_id, va_id, backend_node_id, sample_count)
} else {
controller.query_oscilloscope_data(backend_track_id, backend_node_id, sample_count)
};
if let Ok(data) = result {
self.user_state.oscilloscope_data.insert(node_id, graph_data::OscilloscopeCache {
audio: data.audio,
cv: data.cv,
});
}
}
}
}
} else {
// Between polls, check if we have cached oscilloscope data
has_oscilloscopes = !self.user_state.oscilloscope_data.is_empty();
}
// Continuously repaint when oscilloscopes are present
if has_oscilloscopes {
ui.ctx().request_repaint();
}
// 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));
// Draw breadcrumb bar when editing a subgraph
let breadcrumb_height = if self.in_subgraph() { 28.0 } else { 0.0 };
let graph_rect = if self.in_subgraph() {
// Draw breadcrumb bar at top
let breadcrumb_rect = egui::Rect::from_min_size(
rect.min,
egui::vec2(rect.width(), breadcrumb_height),
);
let painter = ui.painter();
painter.rect_filled(breadcrumb_rect, 0.0, egui::Color32::from_gray(35));
painter.line_segment(
[breadcrumb_rect.left_bottom(), breadcrumb_rect.right_bottom()],
egui::Stroke::new(1.0, egui::Color32::from_gray(60)),
);
// Draw clickable breadcrumb segments
let segments = self.breadcrumb_segments();
let mut x = rect.min.x + 8.0;
let y = rect.min.y + 6.0;
let mut clicked_level: Option<usize> = None;
for (i, segment) in segments.iter().enumerate() {
let is_last = i == segments.len() - 1;
let text_color = if is_last {
egui::Color32::from_gray(220)
} else {
egui::Color32::from_rgb(100, 180, 255)
};
let font_id = egui::FontId::proportional(13.0);
let galley = painter.layout_no_wrap(segment.clone(), font_id, text_color);
let text_rect = egui::Rect::from_min_size(egui::pos2(x, y), galley.size());
if !is_last {
let response = ui.interact(text_rect, ui.id().with(("breadcrumb", i)), egui::Sense::click());
if response.clicked() {
clicked_level = Some(i);
}
if response.hovered() {
painter.rect_stroke(text_rect.expand(2.0), 2.0, egui::Stroke::new(1.0, egui::Color32::from_gray(80)), egui::StrokeKind::Outside);
}
}
painter.galley(egui::pos2(x, y), galley, text_color);
x += text_rect.width();
if !is_last {
let sep = " > ";
let sep_galley = painter.layout_no_wrap(sep.to_string(), egui::FontId::proportional(13.0), egui::Color32::from_gray(100));
painter.galley(egui::pos2(x, y), sep_galley, egui::Color32::from_gray(100));
x += 20.0;
}
}
if let Some(level) = clicked_level {
self.exit_to_level(level);
}
// Shrink graph rect to below breadcrumb
egui::Rect::from_min_max(
egui::pos2(rect.min.x, rect.min.y + breadcrumb_height),
rect.max,
)
} else {
rect
};
// Allocate the rect and render the graph editor within it
ui.scope_builder(egui::UiBuilder::new().max_rect(graph_rect), |ui| {
// Check for scroll input to override library's default zoom behavior
// Only handle scroll when mouse is over the node graph area
let pointer_over_graph = ui.rect_contains_pointer(rect);
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 !pointer_over_graph {
egui::Vec2::ZERO
} else 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;
// Populate sampler clip list and node backend ID map for bottom_ui()
{
use lightningbeam_core::clip::AudioClipType;
let doc = shared.action_executor.document();
// Available audio clips
self.user_state.available_clips = doc.audio_clips.values()
.filter_map(|clip| match &clip.clip_type {
AudioClipType::Sampled { audio_pool_index } => Some(graph_data::SamplerClipInfo {
name: clip.name.clone(),
pool_index: *audio_pool_index,
}),
_ => None,
})
.collect();
self.user_state.available_clips.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
// Available folders (with their contained audio clips)
self.user_state.available_folders = doc.audio_folders.folders.values()
.map(|folder| {
let clips_in_folder: Vec<(String, usize)> = doc.audio_clips.values()
.filter(|clip| clip.folder_id == Some(folder.id))
.filter_map(|clip| match &clip.clip_type {
AudioClipType::Sampled { audio_pool_index } => Some((clip.name.clone(), *audio_pool_index)),
_ => None,
})
.collect();
graph_data::SamplerFolderInfo {
folder_id: folder.id,
name: folder.name.clone(),
clip_pool_indices: clips_in_folder,
}
})
.filter(|f| !f.clip_pool_indices.is_empty())
.collect();
self.user_state.available_folders.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
// Node backend ID map
self.user_state.node_backend_ids = self.node_id_map.iter()
.map(|(&node_id, backend_id)| {
let id = match backend_id {
BackendNodeId::Audio(idx) => idx.index() as u32,
};
(node_id, id)
})
.collect();
}
// Draw dot grid background with pan/zoom
let pan_zoom = &self.state.pan_zoom;
Self::draw_dot_grid_background(ui, graph_rect, bg_color, grid_color, pan_zoom);
// Draw the graph editor with context-aware node templates
let graph_response = if self.inside_voice_allocator() {
self.state.draw_graph_editor(
ui,
VoiceAllocatorNodeTemplates,
&mut self.user_state,
Vec::default(),
)
} else if self.in_subgraph() {
self.state.draw_graph_editor(
ui,
SubgraphNodeTemplates,
&mut self.user_state,
Vec::default(),
)
} else {
self.state.draw_graph_editor(
ui,
AllNodeTemplates,
&mut self.user_state,
Vec::default(),
)
};
// Cache node rects for hit-testing, then handle response
self.last_node_rects = graph_response.node_rects.clone();
self.handle_graph_response(graph_response, shared, graph_rect);
// Handle pending sampler load requests from bottom_ui()
if let Some(load) = self.user_state.pending_sampler_load.take() {
self.handle_pending_sampler_load(load, shared);
}
// Handle pending root note changes
if !self.user_state.pending_root_note_changes.is_empty() {
let changes: Vec<_> = self.user_state.pending_root_note_changes.drain(..).collect();
if let Some(backend_track_id) = self.track_id.and_then(|tid| shared.layer_to_track_map.get(&tid).copied()) {
if let Some(controller_arc) = &shared.audio_controller {
let mut controller = controller_arc.lock().unwrap();
for (node_id, backend_node_id, root_note) in changes {
controller.sampler_set_root_note(backend_track_id, backend_node_id, root_note);
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
node.user_data.root_note = root_note;
}
}
}
}
}
// Detect right-click on nodes — intercept the library's node finder and show our context menu instead
{
let secondary_clicked = ui.input(|i| i.pointer.secondary_released());
if secondary_clicked {
if let Some(cursor_pos) = ui.input(|i| i.pointer.latest_pos()) {
// Hit-test against actual rendered node rects
for (&fid, &node_rect) in &self.last_node_rects {
if node_rect.contains(cursor_pos) {
self.state.node_finder = None;
self.node_context_menu = Some((fid, cursor_pos));
break;
}
}
}
}
}
// Draw node context menu
if let Some((ctx_node_id, menu_pos)) = self.node_context_menu {
let is_group = self.group_placeholder_map.contains_key(&ctx_node_id);
let group_id = self.group_placeholder_map.get(&ctx_node_id).copied();
let mut close_menu = false;
let mut action_delete = false;
let mut action_ungroup = false;
let mut action_rename = false;
let menu_response = egui::Area::new(ui.id().with("node_context_menu"))
.fixed_pos(menu_pos)
.order(egui::Order::Foreground)
.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(120.0);
if is_group {
if ui.button("Rename Group").clicked() {
action_rename = true;
close_menu = true;
}
if ui.button("Ungroup").clicked() {
action_ungroup = true;
close_menu = true;
}
ui.separator();
}
if ui.button("Delete").clicked() {
action_delete = true;
close_menu = true;
}
});
});
// Close menu on click outside the menu area
let menu_rect = menu_response.response.rect;
let clicked_outside = ui.input(|i| {
i.pointer.any_pressed()
&& i.pointer.latest_pos()
.map(|p| !menu_rect.contains(p))
.unwrap_or(false)
});
if clicked_outside {
close_menu = true;
}
if action_rename {
if let Some(gid) = group_id {
if let Some(group) = self.groups.iter().find(|g| g.id == gid) {
self.renaming_group = Some((gid, group.name.clone()));
}
}
}
if action_ungroup {
if let Some(gid) = group_id {
self.ungroup(gid, shared);
}
}
if action_delete {
if is_group {
if let Some(gid) = group_id {
self.groups.retain(|g| g.id != gid);
self.rebuild_view();
self.sync_groups_to_backend(shared);
}
} else {
// Delete the node via the graph - queue the deletion
if let Some(track_id) = self.track_id {
if let Some(&backend_id) = self.node_id_map.get(&ctx_node_id) {
let BackendNodeId::Audio(node_idx) = backend_id;
if let Some(va_id) = self.va_context() {
if let Some(&backend_track_id) = shared.layer_to_track_map.get(&track_id) {
if let Some(audio_controller) = &shared.audio_controller {
let mut controller = audio_controller.lock().unwrap();
controller.graph_remove_node_from_template(
backend_track_id, va_id, node_idx.index() as u32,
);
}
}
} else {
let action = Box::new(actions::NodeGraphAction::RemoveNode(
actions::RemoveNodeAction::new(track_id, backend_id)
));
self.pending_action = Some(action);
}
// Remove from editor state
self.state.graph.nodes.remove(ctx_node_id);
self.node_id_map.remove(&ctx_node_id);
self.backend_to_frontend_map.remove(&backend_id);
}
}
}
}
if close_menu {
self.node_context_menu = None;
}
}
// Draw group rename popup
if let Some((group_id, ref mut new_name)) = self.renaming_group.clone() {
let mut close_rename = false;
let mut apply_rename = false;
let mut name_buf = new_name.clone();
let center = rect.center();
egui::Area::new(ui.id().with("group_rename_popup"))
.fixed_pos(egui::pos2(center.x - 100.0, center.y - 30.0))
.order(egui::Order::Foreground)
.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(200.0);
ui.label("Rename Group:");
let response = ui.text_edit_singleline(&mut name_buf);
if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
apply_rename = true;
}
ui.horizontal(|ui| {
if ui.button("OK").clicked() {
apply_rename = true;
}
if ui.button("Cancel").clicked() {
close_rename = true;
}
});
// Auto-focus the text field
response.request_focus();
});
});
if apply_rename {
if let Some(group) = self.groups.iter_mut().find(|g| g.id == group_id) {
group.name = name_buf.clone();
}
// Update the placeholder node's label
for (&fid, &gid) in &self.group_placeholder_map {
if gid == group_id {
if let Some(node) = self.state.graph.nodes.get_mut(fid) {
node.label = name_buf;
}
break;
}
}
self.renaming_group = None;
self.sync_groups_to_backend(shared);
} else if close_rename {
self.renaming_group = None;
} else {
self.renaming_group = Some((group_id, name_buf));
}
}
// Handle pane-local keyboard shortcuts (only when pointer is over this pane)
if ui.rect_contains_pointer(rect) {
let ctrl_g = ui.input(|i| {
i.key_pressed(egui::Key::G) && (i.modifiers.ctrl || i.modifiers.command)
});
if ctrl_g && !self.state.selected_nodes.is_empty() {
self.group_selected_nodes(shared);
}
// Ctrl+Shift+G to ungroup
let ctrl_shift_g = ui.input(|i| {
i.key_pressed(egui::Key::G) && (i.modifiers.ctrl || i.modifiers.command) && i.modifiers.shift
});
if ctrl_shift_g {
// Ungroup any selected group placeholders
let group_ids_to_ungroup: Vec<GroupId> = self.state.selected_nodes.iter()
.filter_map(|fid| self.group_placeholder_map.get(fid).copied())
.collect();
for gid in group_ids_to_ungroup {
self.ungroup(gid, shared);
}
}
// F2 to rename selected group
let f2 = ui.input(|i| i.key_pressed(egui::Key::F2));
if f2 && self.renaming_group.is_none() {
// Find the first selected group placeholder
if let Some(group_id) = self.state.selected_nodes.iter()
.find_map(|fid| self.group_placeholder_map.get(fid).copied())
{
if let Some(group) = self.groups.iter().find(|g| g.id == group_id) {
self.renaming_group = Some((group_id, group.name.clone()));
}
}
}
}
// Check for parameter value changes and send updates to backend
self.check_parameter_changes(shared);
// Execute any parameter change actions
self.execute_pending_action(shared);
// Insert-node-on-connection: find target during drag, highlight, and execute on drop
let primary_down = ui.input(|i| i.pointer.primary_down());
if let Some(dragged) = self.dragging_node {
if primary_down {
// Still dragging — check for nearby compatible connection
if let Some((input_id, output_id, src_graph, dst_graph)) = self.find_insert_target(dragged) {
self.insert_target = Some((input_id, output_id));
Self::draw_connection_highlight(
ui,
src_graph,
dst_graph,
self.state.pan_zoom.zoom,
self.state.pan_zoom.pan,
rect.min.to_vec2(),
);
} else {
self.insert_target = None;
}
} else {
// Drag ended — execute insertion if we have a target
if let Some((target_input, target_output)) = self.insert_target.take() {
self.execute_insert_on_connection(dragged, target_input, target_output, shared);
}
self.dragging_node = None;
}
}
// 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.scope_builder(
egui::UiBuilder::new().max_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()
}
}