2691 lines
125 KiB
Rust
2691 lines
125 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, 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),
|
||
"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()
|
||
}
|
||
}
|