node graph fixes

This commit is contained in:
Skyler Lehmkuhl 2026-02-15 09:11:39 -05:00
parent 1e7001b291
commit 7387299b52
29 changed files with 3277 additions and 77 deletions

View File

@ -74,6 +74,9 @@ pub struct ReadAheadBuffer {
/// The disk reader uses this instead of the global playhead to know /// The disk reader uses this instead of the global playhead to know
/// where in the file to buffer around. /// where in the file to buffer around.
target_frame: AtomicU64, target_frame: AtomicU64,
/// When true, `render_from_file` will block-wait for frames instead of
/// returning silence on buffer miss. Used during offline export.
export_mode: AtomicBool,
} }
// SAFETY: See the doc comment on ReadAheadBuffer for the full safety argument. // SAFETY: See the doc comment on ReadAheadBuffer for the full safety argument.
@ -108,6 +111,7 @@ impl ReadAheadBuffer {
channels, channels,
sample_rate, sample_rate,
target_frame: AtomicU64::new(0), target_frame: AtomicU64::new(0),
export_mode: AtomicBool::new(false),
} }
} }
@ -200,6 +204,18 @@ impl ReadAheadBuffer {
self.target_frame.load(Ordering::Relaxed) != u64::MAX self.target_frame.load(Ordering::Relaxed) != u64::MAX
} }
/// Enable or disable export (blocking) mode. When enabled,
/// `render_from_file` will spin-wait for frames instead of returning
/// silence on buffer miss.
pub fn set_export_mode(&self, export: bool) {
self.export_mode.store(export, Ordering::Release);
}
/// Check if export (blocking) mode is active.
pub fn is_export_mode(&self) -> bool {
self.export_mode.load(Ordering::Acquire)
}
/// Reset the buffer to start at `new_start` with zero valid frames. /// Reset the buffer to start at `new_start` with zero valid frames.
/// Called by the **disk reader thread** (producer) after a seek. /// Called by the **disk reader thread** (producer) after a seek.
pub fn reset(&self, new_start: u64) { pub fn reset(&self, new_start: u64) {
@ -614,8 +630,12 @@ impl DiskReader {
} }
} }
// Sleep briefly to avoid busy-spinning when all buffers are full. // In export mode, skip the sleep so decoding runs at full speed.
std::thread::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS)); // Otherwise sleep briefly to avoid busy-spinning.
let any_exporting = active_files.values().any(|(_, buf)| buf.is_export_mode());
if !any_exporting {
std::thread::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS));
}
} }
} }
} }

View File

@ -1334,6 +1334,18 @@ impl Engine {
} }
} }
Command::GraphSetNodePosition(track_id, node_index, x, y) => {
let graph = match self.project.get_track_mut(track_id) {
Some(TrackNode::Midi(track)) => Some(&mut track.instrument_graph),
Some(TrackNode::Audio(track)) => Some(&mut track.effects_graph),
_ => None,
};
if let Some(graph) = graph {
let node_idx = NodeIndex::new(node_index as usize);
graph.set_node_position(node_idx, x, y);
}
}
Command::GraphSetMidiTarget(track_id, node_index, enabled) => { Command::GraphSetMidiTarget(track_id, node_index, enabled) => {
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
let graph = &mut track.instrument_graph; let graph = &mut track.instrument_graph;
@ -2245,7 +2257,10 @@ impl Engine {
QueryResponse::AudioImportedSync(self.do_import_audio(&path)) QueryResponse::AudioImportedSync(self.do_import_audio(&path))
} }
Query::GetProject => { Query::GetProject => {
// Clone the entire project for serialization // Save graph presets before cloning — AudioTrack::clone() creates
// a fresh default graph (not a copy), so the preset must be populated
// first so the clone carries the serialized graph data.
self.project.prepare_for_save();
QueryResponse::ProjectRetrieved(Ok(Box::new(self.project.clone()))) QueryResponse::ProjectRetrieved(Ok(Box::new(self.project.clone())))
} }
Query::SetProject(new_project) => { Query::SetProject(new_project) => {
@ -2950,6 +2965,11 @@ impl EngineController {
let _ = self.command_tx.push(Command::GraphSetParameter(track_id, node_id, param_id, value)); let _ = self.command_tx.push(Command::GraphSetParameter(track_id, node_id, param_id, value));
} }
/// Set the UI position of a node in a track's graph
pub fn graph_set_node_position(&mut self, track_id: TrackId, node_id: u32, x: f32, y: f32) {
let _ = self.command_tx.push(Command::GraphSetNodePosition(track_id, node_id, x, y));
}
/// Set which node receives MIDI events in a track's instrument graph /// Set which node receives MIDI events in a track's instrument graph
pub fn graph_set_midi_target(&mut self, track_id: TrackId, node_id: u32, enabled: bool) { pub fn graph_set_midi_target(&mut self, track_id: TrackId, node_id: u32, enabled: bool) {
let _ = self.command_tx.push(Command::GraphSetMidiTarget(track_id, node_id, enabled)); let _ = self.command_tx.push(Command::GraphSetMidiTarget(track_id, node_id, enabled));

View File

@ -72,29 +72,44 @@ pub fn export_audio<P: AsRef<Path>>(
midi_pool: &MidiClipPool, midi_pool: &MidiClipPool,
settings: &ExportSettings, settings: &ExportSettings,
output_path: P, output_path: P,
event_tx: Option<&mut rtrb::Producer<AudioEvent>>, mut event_tx: Option<&mut rtrb::Producer<AudioEvent>>,
) -> Result<(), String> ) -> Result<(), String>
{ {
// Route to appropriate export implementation based on format // Reset all node graphs to clear stale effect buffers (echo, reverb, etc.)
match settings.format { project.reset_all_graphs();
// Enable blocking mode on all read-ahead buffers so compressed audio
// streams block until decoded frames are available (instead of returning
// silence when the disk reader hasn't caught up with offline rendering).
project.set_export_mode(true);
// Route to appropriate export implementation based on format.
// Ensure export mode is disabled even if an error occurs.
let result = match settings.format {
ExportFormat::Wav | ExportFormat::Flac => { ExportFormat::Wav | ExportFormat::Flac => {
// Render to memory then write (existing path) let samples = render_to_memory(project, pool, midi_pool, settings, event_tx.as_mut().map(|tx| &mut **tx))?;
let samples = render_to_memory(project, pool, midi_pool, settings, event_tx)?; // Signal that rendering is done and we're now writing the file
if let Some(ref mut tx) = event_tx {
let _ = tx.push(AudioEvent::ExportFinalizing);
}
match settings.format { match settings.format {
ExportFormat::Wav => write_wav(&samples, settings, output_path)?, ExportFormat::Wav => write_wav(&samples, settings, &output_path),
ExportFormat::Flac => write_flac(&samples, settings, output_path)?, ExportFormat::Flac => write_flac(&samples, settings, &output_path),
_ => unreachable!(), _ => unreachable!(),
} }
} }
ExportFormat::Mp3 => { ExportFormat::Mp3 => {
export_mp3(project, pool, midi_pool, settings, output_path, event_tx)?; export_mp3(project, pool, midi_pool, settings, output_path, event_tx)
} }
ExportFormat::Aac => { ExportFormat::Aac => {
export_aac(project, pool, midi_pool, settings, output_path, event_tx)?; export_aac(project, pool, midi_pool, settings, output_path, event_tx)
} }
} };
Ok(()) // Always disable export mode, even on error
project.set_export_mode(false);
result
} }
/// Render the project to memory /// Render the project to memory
@ -437,6 +452,11 @@ fn export_mp3<P: AsRef<Path>>(
)?; )?;
} }
// Signal that rendering is done and we're now flushing/finalizing
if let Some(ref mut tx) = event_tx {
let _ = tx.push(AudioEvent::ExportFinalizing);
}
// Flush encoder // Flush encoder
encoder.send_eof() encoder.send_eof()
.map_err(|e| format!("Failed to send EOF: {}", e))?; .map_err(|e| format!("Failed to send EOF: {}", e))?;
@ -602,6 +622,11 @@ fn export_aac<P: AsRef<Path>>(
)?; )?;
} }
// Signal that rendering is done and we're now flushing/finalizing
if let Some(ref mut tx) = event_tx {
let _ = tx.push(AudioEvent::ExportFinalizing);
}
// Flush encoder // Flush encoder
encoder.send_eof() encoder.send_eof()
.map_err(|e| format!("Failed to send EOF: {}", e))?; .map_err(|e| format!("Failed to send EOF: {}", e))?;

View File

@ -161,6 +161,17 @@ impl AudioGraph {
// Validate the connection // Validate the connection
self.validate_connection(from, from_port, to, to_port)?; self.validate_connection(from, from_port, to, to_port)?;
// Remove any existing connection to the same input port (replace semantics).
// The frontend UI enforces single-connection inputs, so when a new connection
// targets the same port, the old one should be replaced.
let edges_to_remove: Vec<_> = self.graph.edges_directed(to, petgraph::Direction::Incoming)
.filter(|e| e.weight().to_port == to_port)
.map(|e| e.id())
.collect();
for edge_id in edges_to_remove {
self.graph.remove_edge(edge_id);
}
// Add the edge // Add the edge
self.graph.add_edge(from, to, Connection { from_port, to_port }); self.graph.add_edge(from, to, Connection { from_port, to_port });
self.topo_cache = None; self.topo_cache = None;

View File

@ -95,6 +95,8 @@ pub struct AudioFile {
/// Original file format (mp3, ogg, wav, flac, etc.) /// Original file format (mp3, ogg, wav, flac, etc.)
/// Used to determine if we should preserve lossy encoding during save /// Used to determine if we should preserve lossy encoding during save
pub original_format: Option<String>, pub original_format: Option<String>,
/// Original compressed file bytes (preserved across save/load to avoid re-encoding)
pub original_bytes: Option<Vec<u8>>,
} }
impl AudioFile { impl AudioFile {
@ -108,6 +110,7 @@ impl AudioFile {
sample_rate, sample_rate,
frames, frames,
original_format: None, original_format: None,
original_bytes: None,
} }
} }
@ -121,6 +124,7 @@ impl AudioFile {
sample_rate, sample_rate,
frames, frames,
original_format, original_format,
original_bytes: None,
} }
} }
@ -152,6 +156,7 @@ impl AudioFile {
sample_rate, sample_rate,
frames: total_frames, frames: total_frames,
original_format: Some("wav".to_string()), original_format: Some("wav".to_string()),
original_bytes: None,
} }
} }
@ -174,6 +179,7 @@ impl AudioFile {
sample_rate, sample_rate,
frames: total_frames, frames: total_frames,
original_format, original_format,
original_bytes: None,
} }
} }
@ -470,6 +476,31 @@ impl AudioClipPool {
return 0; return 0;
} }
// In export mode, block-wait until the disk reader has filled the
// frames we need, so offline rendering never gets buffer misses.
if use_read_ahead {
let ra = read_ahead.unwrap();
if ra.is_export_mode() {
let src_start = (start_time_seconds * audio_file.sample_rate as f64) as u64;
// Tell the disk reader where we need data BEFORE waiting
ra.set_target_frame(src_start);
// Pad by 64 frames for sinc interpolation taps
let frames_needed = (output.len() / engine_channels as usize) as u64 + 64;
// Spin-wait with small sleeps until the disk reader fills the buffer
let mut wait_iters = 0u64;
while !ra.has_range(src_start, frames_needed) {
std::thread::sleep(std::time::Duration::from_micros(100));
wait_iters += 1;
if wait_iters > 100_000 {
// Safety valve: 10 seconds of waiting
eprintln!("[EXPORT] Timed out waiting for disk reader (need frames {}..{})",
src_start, src_start + frames_needed);
break;
}
}
}
}
// Snapshot the read-ahead buffer range once for the entire render call. // Snapshot the read-ahead buffer range once for the entire render call.
// This ensures all sinc interpolation taps within a single callback // This ensures all sinc interpolation taps within a single callback
// see a consistent range, preventing crackle from concurrent updates. // see a consistent range, preventing crackle from concurrent updates.
@ -834,6 +865,15 @@ impl AudioClipPool {
|| fmt_lower == "m4a" || fmt_lower == "opus" || fmt_lower == "m4a" || fmt_lower == "opus"
}); });
// Check for preserved original bytes first (from previous load cycle)
if let Some(ref original_bytes) = audio_file.original_bytes {
let data_base64 = general_purpose::STANDARD.encode(original_bytes);
return EmbeddedAudioData {
data_base64,
format: audio_file.original_format.clone().unwrap_or_else(|| "wav".to_string()),
};
}
if is_lossy { if is_lossy {
// For lossy formats, read the original file bytes (if it still exists) // For lossy formats, read the original file bytes (if it still exists)
if let Ok(original_bytes) = std::fs::read(&audio_file.path) { if let Ok(original_bytes) = std::fs::read(&audio_file.path) {
@ -1012,9 +1052,12 @@ impl AudioClipPool {
// Clean up temporary file // Clean up temporary file
let _ = std::fs::remove_file(&temp_path); let _ = std::fs::remove_file(&temp_path);
// Update the path to reflect it was embedded // Update the path to reflect it was embedded, and preserve original bytes
if result.is_ok() && pool_index < self.files.len() { if result.is_ok() && pool_index < self.files.len() {
self.files[pool_index].path = PathBuf::from(format!("<embedded: {}>", name)); self.files[pool_index].path = PathBuf::from(format!("<embedded: {}>", name));
// Preserve the original compressed/encoded bytes so re-save doesn't need to re-encode
self.files[pool_index].original_bytes = Some(data);
self.files[pool_index].original_format = Some(embedded.format.clone());
} }
eprintln!("📊 [POOL] ✅ Total load_from_embedded time: {:.2}ms", fn_start.elapsed().as_secs_f64() * 1000.0); eprintln!("📊 [POOL] ✅ Total load_from_embedded time: {:.2}ms", fn_start.elapsed().as_secs_f64() * 1000.0);

View File

@ -506,6 +506,21 @@ impl Project {
} }
} }
/// Set export (blocking) mode on all clip read-ahead buffers.
/// When enabled, `render_from_file` blocks until the disk reader
/// has filled the needed frames instead of returning silence.
pub fn set_export_mode(&self, export: bool) {
for track in self.tracks.values() {
if let TrackNode::Audio(t) = track {
for clip in &t.clips {
if let Some(ref ra) = clip.read_ahead {
ra.set_export_mode(export);
}
}
}
}
}
/// Reset all node graphs (clears effect buffers on seek) /// Reset all node graphs (clears effect buffers on seek)
pub fn reset_all_graphs(&mut self) { pub fn reset_all_graphs(&mut self) {
for track in self.tracks.values_mut() { for track in self.tracks.values_mut() {

View File

@ -148,6 +148,8 @@ pub enum Command {
GraphDisconnect(TrackId, u32, usize, u32, usize), GraphDisconnect(TrackId, u32, usize, u32, usize),
/// Set a parameter on a node (track_id, node_index, param_id, value) /// Set a parameter on a node (track_id, node_index, param_id, value)
GraphSetParameter(TrackId, u32, u32, f32), GraphSetParameter(TrackId, u32, u32, f32),
/// Set the UI position of a node (track_id, node_index, x, y)
GraphSetNodePosition(TrackId, u32, f32, f32),
/// Set which node receives MIDI events (track_id, node_index, enabled) /// Set which node receives MIDI events (track_id, node_index, enabled)
GraphSetMidiTarget(TrackId, u32, bool), GraphSetMidiTarget(TrackId, u32, bool),
/// Set which node is the audio output (track_id, node_index) /// Set which node is the audio output (track_id, node_index)
@ -250,6 +252,8 @@ pub enum AudioEvent {
frames_rendered: usize, frames_rendered: usize,
total_frames: usize, total_frames: usize,
}, },
/// Export rendering complete, now writing/encoding the output file
ExportFinalizing,
/// Waveform generated for audio pool file (pool_index, waveform) /// Waveform generated for audio pool file (pool_index, waveform)
WaveformGenerated(usize, Vec<WaveformPeak>), WaveformGenerated(usize, Vec<WaveformPeak>),

View File

@ -0,0 +1,21 @@
[package]
name = "egui_node_graph2"
description = "A helper library to create interactive node graphs using egui"
homepage = "https://github.com/trevyn/egui_node_graph2"
repository = "https://github.com/trevyn/egui_node_graph2"
license = "MIT"
version = "0.7.0"
keywords = ["egui_node_graph", "ui", "egui", "graph", "node"]
edition = "2021"
readme = "../README.md"
workspace = ".."
[features]
persistence = ["serde", "slotmap/serde", "smallvec/serde", "egui/persistence"]
[dependencies]
egui = "0.33.3"
slotmap = { version = "1.0" }
smallvec = { version = "1.10.0" }
serde = { version = "1.0", optional = true, features = ["derive"] }
thiserror = "1.0"

View File

@ -0,0 +1,94 @@
use egui::Color32;
/// Converts a hex string with a leading '#' into a egui::Color32.
/// - The first three channels are interpreted as R, G, B.
/// - The fourth channel, if present, is used as the alpha value.
/// - Both upper and lowercase characters can be used for the hex values.
///
/// *Adapted from: https://docs.rs/raster/0.1.0/src/raster/lib.rs.html#425-725.
/// Credit goes to original authors.*
pub fn color_from_hex(hex: &str) -> Result<Color32, String> {
// Convert a hex string to decimal. Eg. "00" -> 0. "FF" -> 255.
fn _hex_dec(hex_string: &str) -> Result<u8, String> {
match u8::from_str_radix(hex_string, 16) {
Ok(o) => Ok(o),
Err(e) => Err(format!("Error parsing hex: {}", e)),
}
}
if hex.len() == 9 && hex.starts_with('#') {
// #FFFFFFFF (Red Green Blue Alpha)
return Ok(Color32::from_rgba_premultiplied(
_hex_dec(&hex[1..3])?,
_hex_dec(&hex[3..5])?,
_hex_dec(&hex[5..7])?,
_hex_dec(&hex[7..9])?,
));
} else if hex.len() == 7 && hex.starts_with('#') {
// #FFFFFF (Red Green Blue)
return Ok(Color32::from_rgb(
_hex_dec(&hex[1..3])?,
_hex_dec(&hex[3..5])?,
_hex_dec(&hex[5..7])?,
));
}
Err(format!(
"Error parsing hex: {}. Example of valid formats: #FFFFFF or #ffffffff",
hex
))
}
/// Converts a Color32 into its canonical hexadecimal representation.
/// - The color string will be preceded by '#'.
/// - If the alpha channel is completely opaque, it will be ommitted.
/// - Characters from 'a' to 'f' will be written in lowercase.
#[allow(dead_code)]
pub fn color_to_hex(color: Color32) -> String {
if color.a() < 255 {
format!(
"#{:02x?}{:02x?}{:02x?}{:02x?}",
color.r(),
color.g(),
color.b(),
color.a()
)
} else {
format!("#{:02x?}{:02x?}{:02x?}", color.r(), color.g(), color.b())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
pub fn test_color_from_and_to_hex() {
assert_eq!(
color_from_hex("#00ff00").unwrap(),
Color32::from_rgb(0, 255, 0)
);
assert_eq!(
color_from_hex("#5577AA").unwrap(),
Color32::from_rgb(85, 119, 170)
);
assert_eq!(
color_from_hex("#E2e2e277").unwrap(),
Color32::from_rgba_premultiplied(226, 226, 226, 119)
);
assert!(color_from_hex("abcdefgh").is_err());
assert_eq!(
color_to_hex(Color32::from_rgb(0, 255, 0)),
"#00ff00".to_string()
);
assert_eq!(
color_to_hex(Color32::from_rgb(85, 119, 170)),
"#5577aa".to_string()
);
assert_eq!(
color_to_hex(Color32::from_rgba_premultiplied(226, 226, 226, 119)),
"#e2e2e277".to_string()
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,10 @@
use super::*;
#[derive(Debug, thiserror::Error)]
pub enum EguiGraphError {
#[error("Node {0:?} has no parameter named {1}")]
NoParameterNamed(NodeId, String),
#[error("Parameter {0:?} was not found in the graph.")]
InvalidParameterId(AnyParameterId),
}

View File

@ -0,0 +1,95 @@
use std::num::NonZeroU32;
use super::*;
#[cfg(feature = "persistence")]
use serde::{Deserialize, Serialize};
/// A node inside the [`Graph`]. Nodes have input and output parameters, stored
/// as ids. They also contain a custom `NodeData` struct with whatever data the
/// user wants to store per-node.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))]
pub struct Node<NodeData> {
pub id: NodeId,
pub label: String,
pub inputs: Vec<(String, InputId)>,
pub outputs: Vec<(String, OutputId)>,
pub user_data: NodeData,
}
/// The three kinds of input params. These describe how the graph must behave
/// with respect to inline widgets and connections for this parameter.
#[derive(Debug, Clone, Copy)]
#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))]
pub enum InputParamKind {
/// No constant value can be set. Only incoming connections can produce it
ConnectionOnly,
/// Only a constant value can be set. No incoming connections accepted.
ConstantOnly,
/// Both incoming connections and constants are accepted. Connections take
/// precedence over the constant values.
ConnectionOrConstant,
}
#[cfg(feature = "persistence")]
fn shown_inline_default() -> bool {
true
}
/// An input parameter. Input parameters are inside a node, and represent data
/// that this node receives. Unlike their [`OutputParam`] counterparts, input
/// parameters also display an inline widget which allows setting its "value".
/// The `DataType` generic parameter is used to restrict the range of input
/// connections for this parameter, and the `ValueType` is use to represent the
/// data for the inline widget (i.e. constant) value.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))]
pub struct InputParam<DataType, ValueType> {
pub id: InputId,
/// The data type of this node. Used to determine incoming connections. This
/// should always match the type of the InputParamValue, but the property is
/// not actually enforced.
pub typ: DataType,
/// The constant value stored in this parameter.
pub value: ValueType,
/// The input kind. See [`InputParamKind`]
pub kind: InputParamKind,
/// Back-reference to the node containing this parameter.
pub node: NodeId,
/// How many connections can be made with this input. `None` means no limit.
pub max_connections: Option<NonZeroU32>,
/// When true, the node is shown inline inside the node graph.
#[cfg_attr(feature = "persistence", serde(default = "shown_inline_default"))]
pub shown_inline: bool,
}
/// An output parameter. Output parameters are inside a node, and represent the
/// data that the node produces. Output parameters can be linked to the input
/// parameters of other nodes. Unlike an [`InputParam`], output parameters
/// cannot have a constant inline value.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))]
pub struct OutputParam<DataType> {
pub id: OutputId,
/// Back-reference to the node containing this parameter.
pub node: NodeId,
pub typ: DataType,
}
/// The graph, containing nodes, input parameters and output parameters. Because
/// graphs are full of self-referential structures, this type uses the `slotmap`
/// crate to represent all the inner references in the data.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))]
pub struct Graph<NodeData, DataType, ValueType> {
/// The [`Node`]s of the graph
pub nodes: SlotMap<NodeId, Node<NodeData>>,
/// The [`InputParam`]s of the graph
pub inputs: SlotMap<InputId, InputParam<DataType, ValueType>>,
/// The [`OutputParam`]s of the graph
pub outputs: SlotMap<OutputId, OutputParam<DataType>>,
// Connects the input of a node, to the output of its predecessor that
// produces it
pub connections: SecondaryMap<InputId, Vec<OutputId>>,
}

View File

@ -0,0 +1,292 @@
use std::num::NonZeroU32;
use super::*;
impl<NodeData, DataType, ValueType> Graph<NodeData, DataType, ValueType> {
pub fn new() -> Self {
Self {
nodes: SlotMap::default(),
inputs: SlotMap::default(),
outputs: SlotMap::default(),
connections: SecondaryMap::default(),
}
}
pub fn add_node(
&mut self,
label: String,
user_data: NodeData,
f: impl FnOnce(&mut Graph<NodeData, DataType, ValueType>, NodeId),
) -> NodeId {
let node_id = self.nodes.insert_with_key(|node_id| {
Node {
id: node_id,
label,
// These get filled in later by the user function
inputs: Vec::default(),
outputs: Vec::default(),
user_data,
}
});
f(self, node_id);
node_id
}
#[allow(clippy::too_many_arguments)]
pub fn add_wide_input_param(
&mut self,
node_id: NodeId,
name: String,
typ: DataType,
value: ValueType,
kind: InputParamKind,
max_connections: Option<NonZeroU32>,
shown_inline: bool,
) -> InputId {
let input_id = self.inputs.insert_with_key(|input_id| InputParam {
id: input_id,
typ,
value,
kind,
node: node_id,
max_connections,
shown_inline,
});
self.nodes[node_id].inputs.push((name, input_id));
input_id
}
pub fn add_input_param(
&mut self,
node_id: NodeId,
name: String,
typ: DataType,
value: ValueType,
kind: InputParamKind,
shown_inline: bool,
) -> InputId {
self.add_wide_input_param(
node_id,
name,
typ,
value,
kind,
NonZeroU32::new(1),
shown_inline,
)
}
pub fn remove_input_param(&mut self, param: InputId) {
let node = self[param].node;
self[node].inputs.retain(|(_, id)| *id != param);
self.inputs.remove(param);
self.connections.retain(|i, _| i != param);
}
pub fn remove_output_param(&mut self, param: OutputId) {
let node = self[param].node;
self[node].outputs.retain(|(_, id)| *id != param);
self.outputs.remove(param);
for (_, conns) in &mut self.connections {
conns.retain(|o| *o != param);
}
}
pub fn add_output_param(&mut self, node_id: NodeId, name: String, typ: DataType) -> OutputId {
let output_id = self.outputs.insert_with_key(|output_id| OutputParam {
id: output_id,
node: node_id,
typ,
});
self.nodes[node_id].outputs.push((name, output_id));
output_id
}
/// Removes a node from the graph with given `node_id`. This also removes
/// any incoming or outgoing connections from that node
///
/// This function returns the list of connections that has been removed
/// after deleting this node as input-output pairs. Note that one of the two
/// ids in the pair (the one on `node_id`'s end) will be invalid after
/// calling this function.
pub fn remove_node(&mut self, node_id: NodeId) -> (Node<NodeData>, Vec<(InputId, OutputId)>) {
let mut disconnect_events = vec![];
for (i, conns) in &mut self.connections {
conns.retain(|o| {
if self.outputs[*o].node == node_id || self.inputs[i].node == node_id {
disconnect_events.push((i, *o));
false
} else {
true
}
});
}
// NOTE: Collect is needed because we can't borrow the input ids while
// we remove them inside the loop.
for input in self[node_id].input_ids().collect::<SVec<_>>() {
self.inputs.remove(input);
}
for output in self[node_id].output_ids().collect::<SVec<_>>() {
self.outputs.remove(output);
}
let removed_node = self.nodes.remove(node_id).expect("Node should exist");
(removed_node, disconnect_events)
}
pub fn remove_connection(&mut self, input_id: InputId, output_id: OutputId) -> bool {
self.connections
.get_mut(input_id)
.map(|conns| {
let old_size = conns.len();
conns.retain(|id| id != &output_id);
// connection removed if `conn` size changes
old_size != conns.len()
})
.unwrap_or(false)
}
pub fn iter_nodes(&self) -> impl Iterator<Item = NodeId> + '_ {
self.nodes.iter().map(|(id, _)| id)
}
pub fn add_connection(&mut self, output: OutputId, input: InputId, pos: usize) {
if !self.connections.contains_key(input) {
self.connections.insert(input, Vec::default());
}
let max_connections = self
.get_input(input)
.max_connections
.map(NonZeroU32::get)
.unwrap_or(std::u32::MAX) as usize;
let already_in = self.connections[input].contains(&output);
// connecting twice to the same port is a no-op
// even for wide ports.
if already_in {
return;
}
if self.connections[input].len() == max_connections {
// if full, replace the connected output
self.connections[input][pos] = output;
} else {
// otherwise, insert at a selected position
self.connections[input].insert(pos, output);
}
}
pub fn iter_connection_groups(&self) -> impl Iterator<Item = (InputId, Vec<OutputId>)> + '_ {
self.connections.iter().map(|(i, conns)| (i, conns.clone()))
}
pub fn iter_connections(&self) -> impl Iterator<Item = (InputId, OutputId)> + '_ {
self.iter_connection_groups()
.flat_map(|(i, conns)| conns.into_iter().map(move |o| (i, o)))
}
pub fn connections(&self, input: InputId) -> Vec<OutputId> {
self.connections.get(input).cloned().unwrap_or_default()
}
pub fn connection(&self, input: InputId) -> Option<OutputId> {
let is_limit_1 = self.get_input(input).max_connections == NonZeroU32::new(1);
let connections = self.connections(input);
if is_limit_1 && connections.len() == 1 {
connections.into_iter().next()
} else {
None
}
}
pub fn any_param_type(&self, param: AnyParameterId) -> Result<&DataType, EguiGraphError> {
match param {
AnyParameterId::Input(input) => self.inputs.get(input).map(|x| &x.typ),
AnyParameterId::Output(output) => self.outputs.get(output).map(|x| &x.typ),
}
.ok_or(EguiGraphError::InvalidParameterId(param))
}
pub fn try_get_input(&self, input: InputId) -> Option<&InputParam<DataType, ValueType>> {
self.inputs.get(input)
}
pub fn get_input(&self, input: InputId) -> &InputParam<DataType, ValueType> {
&self.inputs[input]
}
pub fn try_get_output(&self, output: OutputId) -> Option<&OutputParam<DataType>> {
self.outputs.get(output)
}
pub fn get_output(&self, output: OutputId) -> &OutputParam<DataType> {
&self.outputs[output]
}
}
impl<NodeData, DataType, ValueType> Default for Graph<NodeData, DataType, ValueType> {
fn default() -> Self {
Self::new()
}
}
impl<NodeData> Node<NodeData> {
pub fn inputs<'a, DataType, DataValue>(
&'a self,
graph: &'a Graph<NodeData, DataType, DataValue>,
) -> impl Iterator<Item = &InputParam<DataType, DataValue>> + 'a {
self.input_ids().map(|id| graph.get_input(id))
}
pub fn outputs<'a, DataType, DataValue>(
&'a self,
graph: &'a Graph<NodeData, DataType, DataValue>,
) -> impl Iterator<Item = &OutputParam<DataType>> + 'a {
self.output_ids().map(|id| graph.get_output(id))
}
pub fn input_ids(&self) -> impl Iterator<Item = InputId> + '_ {
self.inputs.iter().map(|(_name, id)| *id)
}
pub fn output_ids(&self) -> impl Iterator<Item = OutputId> + '_ {
self.outputs.iter().map(|(_name, id)| *id)
}
pub fn get_input(&self, name: &str) -> Result<InputId, EguiGraphError> {
self.inputs
.iter()
.find(|(param_name, _id)| param_name == name)
.map(|x| x.1)
.ok_or_else(|| EguiGraphError::NoParameterNamed(self.id, name.into()))
}
pub fn get_output(&self, name: &str) -> Result<OutputId, EguiGraphError> {
self.outputs
.iter()
.find(|(param_name, _id)| param_name == name)
.map(|x| x.1)
.ok_or_else(|| EguiGraphError::NoParameterNamed(self.id, name.into()))
}
}
impl<DataType, ValueType> InputParam<DataType, ValueType> {
pub fn value(&self) -> &ValueType {
&self.value
}
pub fn kind(&self) -> InputParamKind {
self.kind
}
pub fn node(&self) -> NodeId {
self.node
}
}

View File

@ -0,0 +1,37 @@
slotmap::new_key_type! { pub struct NodeId; }
slotmap::new_key_type! { pub struct InputId; }
slotmap::new_key_type! { pub struct OutputId; }
#[cfg_attr(feature = "persistence", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum AnyParameterId {
Input(InputId),
Output(OutputId),
}
impl AnyParameterId {
pub fn assume_input(&self) -> InputId {
match self {
AnyParameterId::Input(input) => *input,
AnyParameterId::Output(output) => panic!("{:?} is not an InputId", output),
}
}
pub fn assume_output(&self) -> OutputId {
match self {
AnyParameterId::Output(output) => *output,
AnyParameterId::Input(input) => panic!("{:?} is not an OutputId", input),
}
}
}
impl From<OutputId> for AnyParameterId {
fn from(output: OutputId) -> Self {
Self::Output(output)
}
}
impl From<InputId> for AnyParameterId {
fn from(input: InputId) -> Self {
Self::Input(input)
}
}

View File

@ -0,0 +1,35 @@
use super::*;
macro_rules! impl_index_traits {
($id_type:ty, $output_type:ty, $arena:ident) => {
impl<A, B, C> std::ops::Index<$id_type> for Graph<A, B, C> {
type Output = $output_type;
fn index(&self, index: $id_type) -> &Self::Output {
self.$arena.get(index).unwrap_or_else(|| {
panic!(
"{} index error for {:?}. Has the value been deleted?",
stringify!($id_type),
index
)
})
}
}
impl<A, B, C> std::ops::IndexMut<$id_type> for Graph<A, B, C> {
fn index_mut(&mut self, index: $id_type) -> &mut Self::Output {
self.$arena.get_mut(index).unwrap_or_else(|| {
panic!(
"{} index error for {:?}. Has the value been deleted?",
stringify!($id_type),
index
)
})
}
}
};
}
impl_index_traits!(NodeId, Node<A>, nodes);
impl_index_traits!(InputId, InputParam<B, C>, inputs);
impl_index_traits!(OutputId, OutputParam<B>, outputs);

View File

@ -0,0 +1,47 @@
#![forbid(unsafe_code)]
use slotmap::{SecondaryMap, SlotMap};
pub type SVec<T> = smallvec::SmallVec<[T; 4]>;
/// Contains the main definitions for the node graph model.
pub mod graph;
pub use graph::*;
/// Type declarations for the different id types (node, input, output)
pub mod id_type;
pub use id_type::*;
/// Implements the index trait for the Graph type, allowing indexing by all
/// three id types
pub mod index_impls;
/// Implementing the main methods for the `Graph`
pub mod graph_impls;
/// Custom error types, crate-wide
pub mod error;
pub use error::*;
/// The main struct in the library, contains all the necessary state to draw the
/// UI graph
pub mod ui_state;
pub use ui_state::*;
/// The node finder is a tiny widget allowing to create new node types
pub mod node_finder;
pub use node_finder::*;
/// The inner details of the egui implementation. Most egui code lives here.
pub mod editor_ui;
pub use editor_ui::*;
/// Several traits that must be implemented by the user to customize the
/// behavior of this library.
pub mod traits;
pub use traits::*;
mod utils;
mod color_hex_utils;
mod scale;

View File

@ -0,0 +1,152 @@
use std::{collections::BTreeMap, marker::PhantomData};
use crate::{color_hex_utils::*, CategoryTrait, NodeTemplateIter, NodeTemplateTrait};
use egui::*;
#[derive(Clone)]
#[cfg_attr(feature = "persistence", derive(serde::Serialize, serde::Deserialize))]
pub struct NodeFinder<NodeTemplate> {
pub query: String,
/// Reset every frame. When set, the node finder will be moved at that position
pub position: Option<Pos2>,
pub just_spawned: bool,
_phantom: PhantomData<NodeTemplate>,
}
impl<NodeTemplate, NodeData, UserState, CategoryType> NodeFinder<NodeTemplate>
where
NodeTemplate:
NodeTemplateTrait<NodeData = NodeData, UserState = UserState, CategoryType = CategoryType>,
CategoryType: CategoryTrait,
{
pub fn new_at(pos: Pos2) -> Self {
NodeFinder {
query: "".into(),
position: Some(pos),
just_spawned: true,
_phantom: Default::default(),
}
}
/// Shows the node selector panel with a search bar. Returns whether a node
/// archetype was selected and, in that case, the finder should be hidden on
/// the next frame.
pub fn show(
&mut self,
ui: &mut Ui,
all_kinds: impl NodeTemplateIter<Item = NodeTemplate>,
user_state: &mut UserState,
) -> Option<NodeTemplate> {
let background_color;
let text_color;
if ui.visuals().dark_mode {
background_color = color_from_hex("#3f3f3f").unwrap();
text_color = color_from_hex("#fefefe").unwrap();
} else {
background_color = color_from_hex("#fefefe").unwrap();
text_color = color_from_hex("#3f3f3f").unwrap();
}
ui.visuals_mut().widgets.noninteractive.fg_stroke = Stroke::new(2.0, text_color);
let frame = Frame::dark_canvas(ui.style())
.fill(background_color)
.inner_margin(vec2(5.0, 5.0));
// The archetype that will be returned.
let mut submitted_archetype = None;
frame.show(ui, |ui| {
ui.vertical(|ui| {
let resp = ui.text_edit_singleline(&mut self.query);
if self.just_spawned {
resp.request_focus();
self.just_spawned = false;
}
let update_open = resp.changed();
let mut query_submit = resp.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter));
let max_height = ui.input(|i| i.content_rect().height() * 0.5);
let scroll_area_width = resp.rect.width() - 30.0;
let all_kinds = all_kinds.all_kinds();
let mut categories: BTreeMap<String, Vec<&NodeTemplate>> = Default::default();
let mut orphan_kinds = Vec::new();
for kind in &all_kinds {
let kind_categories = kind.node_finder_categories(user_state);
if kind_categories.is_empty() {
orphan_kinds.push(kind);
} else {
for category in kind_categories {
categories.entry(category.name()).or_default().push(kind);
}
}
}
Frame::default()
.inner_margin(vec2(10.0, 10.0))
.show(ui, |ui| {
ScrollArea::vertical()
.max_height(max_height)
.show(ui, |ui| {
ui.set_width(scroll_area_width);
ui.set_min_height(1000.);
for (category, kinds) in categories {
let filtered_kinds: Vec<_> = kinds
.into_iter()
.map(|kind| {
let kind_name =
kind.node_finder_label(user_state).to_string();
(kind, kind_name)
})
.filter(|(_kind, kind_name)| {
kind_name
.to_lowercase()
.contains(self.query.to_lowercase().as_str())
})
.collect();
if !filtered_kinds.is_empty() {
let default_open = !self.query.is_empty();
CollapsingHeader::new(&category)
.default_open(default_open)
.open(update_open.then_some(default_open))
.show(ui, |ui| {
for (kind, kind_name) in filtered_kinds {
if ui
.selectable_label(false, kind_name)
.clicked()
{
submitted_archetype = Some(kind.clone());
} else if query_submit {
submitted_archetype = Some(kind.clone());
query_submit = false;
}
}
});
}
}
for kind in orphan_kinds {
let kind_name = kind.node_finder_label(user_state).to_string();
if ui.selectable_label(false, kind_name).clicked() {
submitted_archetype = Some(kind.clone());
} else if query_submit {
submitted_archetype = Some(kind.clone());
query_submit = false;
}
}
});
});
});
});
submitted_archetype
}
}

View File

@ -0,0 +1,109 @@
use egui::epaint::Shadow;
use egui::{style::WidgetVisuals, CornerRadius, Margin, Stroke, Style, Vec2};
// Copied from https://github.com/gzp-crey/shine
pub trait Scale {
fn scale(&mut self, amount: f32);
fn scaled(&self, amount: f32) -> Self
where
Self: Clone,
{
let mut scaled = self.clone();
scaled.scale(amount);
scaled
}
}
impl Scale for Vec2 {
fn scale(&mut self, amount: f32) {
self.x *= amount;
self.y *= amount;
}
}
impl Scale for Margin {
fn scale(&mut self, amount: f32) {
self.left = (self.left as f32 * amount) as i8;
self.right = (self.right as f32 * amount) as i8;
self.top = (self.top as f32 * amount) as i8;
self.bottom = (self.bottom as f32 * amount) as i8;
}
}
impl Scale for CornerRadius {
fn scale(&mut self, amount: f32) {
self.ne = (self.ne as f32 * amount) as u8;
self.nw = (self.nw as f32 * amount) as u8;
self.se = (self.se as f32 * amount) as u8;
self.sw = (self.sw as f32 * amount) as u8;
}
}
impl Scale for Stroke {
fn scale(&mut self, amount: f32) {
self.width *= amount;
}
}
impl Scale for Shadow {
fn scale(&mut self, amount: f32) {
self.spread = (self.spread as f32 * amount.clamp(0.4, 1.)) as u8;
}
}
impl Scale for WidgetVisuals {
fn scale(&mut self, amount: f32) {
self.bg_stroke.scale(amount);
self.fg_stroke.scale(amount);
self.corner_radius.scale(amount);
self.expansion *= amount.clamp(0.4, 1.);
}
}
impl Scale for Style {
fn scale(&mut self, amount: f32) {
if let Some(ov_font_id) = &mut self.override_font_id {
ov_font_id.size *= amount;
}
for text_style in self.text_styles.values_mut() {
text_style.size *= amount;
}
self.spacing.item_spacing.scale(amount);
self.spacing.window_margin.scale(amount);
self.spacing.button_padding.scale(amount);
self.spacing.indent *= amount;
self.spacing.interact_size.scale(amount);
self.spacing.slider_width *= amount;
self.spacing.text_edit_width *= amount;
self.spacing.icon_width *= amount;
self.spacing.icon_width_inner *= amount;
self.spacing.icon_spacing *= amount;
self.spacing.tooltip_width *= amount;
self.spacing.combo_height *= amount;
self.spacing.scroll.bar_width *= amount;
self.spacing.scroll.floating_allocated_width *= amount;
self.spacing.scroll.floating_width *= amount;
self.interaction.resize_grab_radius_side *= amount;
self.interaction.resize_grab_radius_corner *= amount;
self.visuals.widgets.noninteractive.scale(amount);
self.visuals.widgets.inactive.scale(amount);
self.visuals.widgets.hovered.scale(amount);
self.visuals.widgets.active.scale(amount);
self.visuals.widgets.open.scale(amount);
self.visuals.selection.stroke.scale(amount);
self.visuals.resize_corner_size *= amount;
self.visuals.text_cursor.stroke.width *= amount;
self.visuals.clip_rect_margin *= amount;
self.visuals.window_corner_radius.scale(amount);
self.visuals.window_shadow.scale(amount);
self.visuals.popup_shadow.scale(amount);
}
}

View File

@ -0,0 +1,284 @@
use super::*;
/// This trait must be implemented by the `ValueType` generic parameter of the
/// [`Graph`]. The trait allows drawing custom inline widgets for the different
/// types of the node graph.
///
/// The [`Default`] trait bound is required to circumvent borrow checker issues
/// using `std::mem::take` Otherwise, it would be impossible to pass the
/// `node_data` parameter during `value_widget`. The default value is never
/// used, so the implementation is not important, but it should be reasonably
/// cheap to construct.
pub trait WidgetValueTrait: Default {
type Response;
type UserState;
type NodeData;
/// This method will be called for each input parameter with a widget with an disconnected
/// input only. To display UI for connected inputs use [`WidgetValueTrait::value_widget_connected`].
/// The return value is a vector of custom response objects which can be used
/// to implement handling of side effects. If unsure, the response Vec can
/// be empty.
fn value_widget(
&mut self,
param_name: &str,
node_id: NodeId,
ui: &mut egui::Ui,
user_state: &mut Self::UserState,
node_data: &Self::NodeData,
) -> Vec<Self::Response>;
/// This method will be called for each input parameter with a widget with a connected
/// input only. To display UI for diconnected inputs use [`WidgetValueTrait::value_widget`].
/// The return value is a vector of custom response objects which can be used
/// to implement handling of side effects. If unsure, the response Vec can
/// be empty.
///
/// Shows the input name label by default.
fn value_widget_connected(
&mut self,
param_name: &str,
_node_id: NodeId,
ui: &mut egui::Ui,
_user_state: &mut Self::UserState,
_node_data: &Self::NodeData,
) -> Vec<Self::Response> {
ui.label(param_name);
Default::default()
}
}
/// This trait must be implemented by the `DataType` generic parameter of the
/// [`Graph`]. This trait tells the library how to visually expose data types
/// to the user.
pub trait DataTypeTrait<UserState>: PartialEq + Eq {
/// The associated port color of this datatype
fn data_type_color(&self, user_state: &mut UserState) -> egui::Color32;
/// The name of this datatype. Return type is specified as Cow<str> because
/// some implementations will need to allocate a new string to provide an
/// answer while others won't.
///
/// ## Example (borrowed value)
/// Use this when you can get the name of the datatype from its fields or as
/// a &'static str. Prefer this method when possible.
/// ```ignore
/// pub struct DataType { name: String }
///
/// impl DataTypeTrait<()> for DataType {
/// fn name(&self) -> std::borrow::Cow<str> {
/// Cow::Borrowed(&self.name)
/// }
/// }
/// ```
///
/// ## Example (owned value)
/// Use this when you can't derive the name of the datatype from its fields.
/// ```ignore
/// pub struct DataType { some_tag: i32 }
///
/// impl DataTypeTrait<()> for DataType {
/// fn name(&self) -> std::borrow::Cow<str> {
/// Cow::Owned(format!("Super amazing type #{}", self.some_tag))
/// }
/// }
/// ```
fn name(&self) -> std::borrow::Cow<str>;
}
/// This trait must be implemented for the `NodeData` generic parameter of the
/// [`Graph`]. This trait allows customizing some aspects of the node drawing.
pub trait NodeDataTrait
where
Self: Sized,
{
/// Must be set to the custom user `NodeResponse` type
type Response;
/// Must be set to the custom user `UserState` type
type UserState;
/// Must be set to the custom user `DataType` type
type DataType;
/// Must be set to the custom user `ValueType` type
type ValueType;
/// Additional UI elements to draw in the nodes, after the parameters.
fn bottom_ui(
&self,
ui: &mut egui::Ui,
node_id: NodeId,
graph: &Graph<Self, Self::DataType, Self::ValueType>,
user_state: &mut Self::UserState,
) -> Vec<NodeResponse<Self::Response, Self>>
where
Self::Response: UserResponseTrait;
/// UI to draw on the top bar of the node.
fn top_bar_ui(
&self,
_ui: &mut egui::Ui,
_node_id: NodeId,
_graph: &Graph<Self, Self::DataType, Self::ValueType>,
_user_state: &mut Self::UserState,
) -> Vec<NodeResponse<Self::Response, Self>>
where
Self::Response: UserResponseTrait,
{
Default::default()
}
/// UI to draw for each output
///
/// Defaults to showing param_name as a simple label.
fn output_ui(
&self,
ui: &mut egui::Ui,
_node_id: NodeId,
_graph: &Graph<Self, Self::DataType, Self::ValueType>,
_user_state: &mut Self::UserState,
param_name: &str,
) -> Vec<NodeResponse<Self::Response, Self>>
where
Self::Response: UserResponseTrait,
{
ui.label(param_name);
Default::default()
}
/// Set background color on titlebar
/// If the return value is None, the default color is set.
fn titlebar_color(
&self,
_ui: &egui::Ui,
_node_id: NodeId,
_graph: &Graph<Self, Self::DataType, Self::ValueType>,
_user_state: &mut Self::UserState,
) -> Option<egui::Color32> {
None
}
/// Separator to put between elements in the node.
///
/// Invoked between inputs, outputs and bottom UI. Useful for
/// complicated UIs that start to lose structure without explicit
/// separators. The `param_id` argument is the id of input or output
/// *preceeding* the separator.
///
/// Default implementation does nothing.
fn separator(
&self,
_ui: &mut egui::Ui,
_node_id: NodeId,
_param_id: AnyParameterId,
_graph: &Graph<Self, Self::DataType, Self::ValueType>,
_user_state: &mut Self::UserState,
) {
}
fn can_delete(
&self,
_node_id: NodeId,
_graph: &Graph<Self, Self::DataType, Self::ValueType>,
_user_state: &mut Self::UserState,
) -> bool {
true
}
}
/// This trait can be implemented by any user type. The trait tells the library
/// how to enumerate the node templates it will present to the user as part of
/// the node finder.
pub trait NodeTemplateIter {
type Item;
fn all_kinds(&self) -> Vec<Self::Item>;
}
/// Describes a category of nodes.
///
/// Used by [`NodeTemplateTrait::node_finder_categories`] to categorize nodes
/// templates into groups.
///
/// If all nodes in a program are known beforehand, it's usefult to define
/// an enum containing all categories and implement [`CategoryTrait`] for it. This will
/// make it impossible to accidentally create a new category by mis-typing an existing
/// one, like in the case of using string types.
pub trait CategoryTrait {
/// Name of the category.
fn name(&self) -> String;
}
impl CategoryTrait for () {
fn name(&self) -> String {
String::new()
}
}
impl<'a> CategoryTrait for &'a str {
fn name(&self) -> String {
self.to_string()
}
}
impl CategoryTrait for String {
fn name(&self) -> String {
self.clone()
}
}
/// This trait must be implemented by the `NodeTemplate` generic parameter of
/// the [`GraphEditorState`]. It allows the customization of node templates. A
/// node template is what describes what kinds of nodes can be added to the
/// graph, what is their name, and what are their input / output parameters.
pub trait NodeTemplateTrait: Clone {
/// Must be set to the custom user `NodeData` type
type NodeData;
/// Must be set to the custom user `DataType` type
type DataType;
/// Must be set to the custom user `ValueType` type
type ValueType;
/// Must be set to the custom user `UserState` type
type UserState;
/// Must be a type that implements the [`CategoryTrait`] trait.
///
/// `&'static str` is a good default if you intend to simply type out
/// the categories of your node. Use `()` if you don't need categories
/// at all.
type CategoryType;
/// Returns a descriptive name for the node kind, used in the node finder.
///
/// The return type is Cow<str> to allow returning owned or borrowed values
/// more flexibly. Refer to the documentation for `DataTypeTrait::name` for
/// more information
fn node_finder_label(&self, user_state: &mut Self::UserState) -> std::borrow::Cow<str>;
/// Vec of categories to which the node belongs.
///
/// It's often useful to organize similar nodes into categories, which will
/// then be used by the node finder to show a more manageable UI, especially
/// if the node template are numerous.
fn node_finder_categories(&self, _user_state: &mut Self::UserState) -> Vec<Self::CategoryType> {
Vec::default()
}
/// Returns a descriptive name for the node kind, used in the graph.
fn node_graph_label(&self, user_state: &mut Self::UserState) -> String;
/// Returns the user data for this node kind.
fn user_data(&self, user_state: &mut Self::UserState) -> Self::NodeData;
/// This function is run when this node kind gets added to the graph. The
/// node will be empty by default, and this function can be used to fill its
/// parameters.
fn build_node(
&self,
graph: &mut Graph<Self::NodeData, Self::DataType, Self::ValueType>,
user_state: &mut Self::UserState,
node_id: NodeId,
);
}
/// The custom user response types when drawing nodes in the graph must
/// implement this trait.
pub trait UserResponseTrait: Clone + std::fmt::Debug {}

View File

@ -0,0 +1,129 @@
use super::*;
use egui::{Rect, Style, Ui, Vec2};
use std::marker::PhantomData;
use std::sync::Arc;
use crate::scale::Scale;
#[cfg(feature = "persistence")]
use serde::{Deserialize, Serialize};
const MIN_ZOOM: f32 = 0.2;
const MAX_ZOOM: f32 = 2.0;
#[derive(Clone)]
#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))]
pub struct GraphEditorState<NodeData, DataType, ValueType, NodeTemplate, UserState> {
pub graph: Graph<NodeData, DataType, ValueType>,
/// Nodes are drawn in this order. Draw order is important because nodes
/// that are drawn last are on top.
pub node_order: Vec<NodeId>,
/// An ongoing connection interaction: The mouse has dragged away from a
/// port and the user is holding the click
pub connection_in_progress: Option<(NodeId, AnyParameterId)>,
/// The currently selected node. Some interface actions depend on the
/// currently selected node.
pub selected_nodes: Vec<NodeId>,
/// The mouse drag start position for an ongoing box selection.
pub ongoing_box_selection: Option<egui::Pos2>,
/// The position of each node.
pub node_positions: SecondaryMap<NodeId, egui::Pos2>,
/// The node finder is used to create new nodes.
pub node_finder: Option<NodeFinder<NodeTemplate>>,
/// The panning of the graph viewport.
pub pan_zoom: PanZoom,
pub _user_state: PhantomData<fn() -> UserState>,
}
impl<NodeData, DataType, ValueType, NodeKind, UserState>
GraphEditorState<NodeData, DataType, ValueType, NodeKind, UserState>
{
pub fn new(default_zoom: f32) -> Self {
Self {
pan_zoom: PanZoom::new(default_zoom),
..Default::default()
}
}
}
impl<NodeData, DataType, ValueType, NodeKind, UserState> Default
for GraphEditorState<NodeData, DataType, ValueType, NodeKind, UserState>
{
fn default() -> Self {
Self {
graph: Default::default(),
node_order: Default::default(),
connection_in_progress: Default::default(),
selected_nodes: Default::default(),
ongoing_box_selection: Default::default(),
node_positions: Default::default(),
node_finder: Default::default(),
pan_zoom: Default::default(),
_user_state: Default::default(),
}
}
}
#[cfg(feature = "persistence")]
fn _default_clip_rect() -> Rect {
Rect::NOTHING
}
#[derive(Clone)]
#[cfg_attr(feature = "persistence", derive(Serialize, Deserialize))]
pub struct PanZoom {
pub pan: Vec2,
pub zoom: f32,
#[cfg_attr(feature = "persistence", serde(skip, default = "_default_clip_rect"))]
pub clip_rect: Rect,
#[cfg_attr(feature = "persistence", serde(skip, default))]
pub zoomed_style: Arc<Style>,
#[cfg_attr(feature = "persistence", serde(skip, default))]
pub started: bool,
}
impl Default for PanZoom {
fn default() -> Self {
PanZoom {
pan: Vec2::ZERO,
zoom: 1.0,
clip_rect: Rect::NOTHING,
zoomed_style: Default::default(),
started: false,
}
}
}
impl PanZoom {
pub fn new(zoom: f32) -> PanZoom {
let style: Style = Default::default();
PanZoom {
pan: Vec2::ZERO,
zoom,
clip_rect: Rect::NOTHING,
zoomed_style: Arc::new(style.scaled(1.0)),
started: false,
}
}
pub fn zoom(&mut self, clip_rect: Rect, style: &Arc<Style>, zoom_delta: f32) {
self.clip_rect = clip_rect;
let new_zoom = (self.zoom * zoom_delta).clamp(MIN_ZOOM, MAX_ZOOM);
self.zoomed_style = Arc::new(style.scaled(new_zoom));
self.zoom = new_zoom;
}
}
pub fn show_zoomed<R, F>(
default_style: Arc<Style>,
zoomed_style: Arc<Style>,
ui: &mut Ui,
add_content: F,
) -> R
where
F: FnOnce(&mut Ui) -> R,
{
*ui.style_mut() = (*zoomed_style).clone();
let response = add_content(ui);
*ui.style_mut() = (*default_style).clone();
response
}

View File

@ -0,0 +1,15 @@
pub trait ColorUtils {
/// Multiplies the color rgb values by `factor`, keeping alpha untouched.
fn lighten(&self, factor: f32) -> Self;
}
impl ColorUtils for egui::Color32 {
fn lighten(&self, factor: f32) -> Self {
egui::Color32::from_rgba_premultiplied(
(self.r() as f32 * factor) as u8,
(self.g() as f32 * factor) as u8,
(self.b() as f32 * factor) as u8,
self.a(),
)
}
}

View File

@ -55,6 +55,11 @@ pub struct SerializedAudioBackend {
/// Note: embedded_data field from daw-backend is ignored; embedded files /// Note: embedded_data field from daw-backend is ignored; embedded files
/// are stored as FLAC in the ZIP's media/audio/ directory instead /// are stored as FLAC in the ZIP's media/audio/ directory instead
pub audio_pool_entries: Vec<AudioPoolEntry>, pub audio_pool_entries: Vec<AudioPoolEntry>,
/// Mapping from UI layer UUIDs to backend TrackIds
/// Preserves the connection between UI layers and audio engine tracks across save/load
#[serde(default)]
pub layer_to_track_map: std::collections::HashMap<uuid::Uuid, u32>,
} }
/// Settings for saving a project /// Settings for saving a project
@ -88,6 +93,9 @@ pub struct LoadedProject {
/// Deserialized audio project /// Deserialized audio project
pub audio_project: AudioProject, pub audio_project: AudioProject,
/// Mapping from UI layer UUIDs to backend TrackIds (empty for old files)
pub layer_to_track_map: std::collections::HashMap<uuid::Uuid, u32>,
/// Loaded audio pool entries /// Loaded audio pool entries
pub audio_pool_entries: Vec<AudioPoolEntry>, pub audio_pool_entries: Vec<AudioPoolEntry>,
@ -138,6 +146,7 @@ pub fn save_beam(
document: &Document, document: &Document,
audio_project: &mut AudioProject, audio_project: &mut AudioProject,
audio_pool_entries: Vec<AudioPoolEntry>, audio_pool_entries: Vec<AudioPoolEntry>,
layer_to_track_map: &std::collections::HashMap<uuid::Uuid, u32>,
_settings: &SaveSettings, _settings: &SaveSettings,
) -> Result<(), String> { ) -> Result<(), String> {
let fn_start = std::time::Instant::now(); let fn_start = std::time::Instant::now();
@ -174,10 +183,12 @@ pub fn save_beam(
None None
}; };
// 2. Prepare audio project for serialization (save AudioGraph presets) // 2. Graph presets are already populated by the engine thread (in GetProject handler)
// before cloning. Do NOT call prepare_for_save() here — the cloned project has
// default empty graphs (AudioTrack::clone() doesn't copy the graph), so calling
// prepare_for_save() would overwrite the good presets with empty ones.
let step2_start = std::time::Instant::now(); let step2_start = std::time::Instant::now();
audio_project.prepare_for_save(); eprintln!("📊 [SAVE_BEAM] Step 2: (graph presets already prepared) took {:.2}ms", step2_start.elapsed().as_secs_f64() * 1000.0);
eprintln!("📊 [SAVE_BEAM] Step 2: Prepare audio project took {:.2}ms", step2_start.elapsed().as_secs_f64() * 1000.0);
// 3. Create ZIP writer // 3. Create ZIP writer
let step3_start = std::time::Instant::now(); let step3_start = std::time::Instant::now();
@ -363,6 +374,7 @@ pub fn save_beam(
sample_rate: 48000, // TODO: Get from audio engine sample_rate: 48000, // TODO: Get from audio engine
project: audio_project.clone(), project: audio_project.clone(),
audio_pool_entries: modified_entries, audio_pool_entries: modified_entries,
layer_to_track_map: layer_to_track_map.clone(),
}, },
}; };
eprintln!("📊 [SAVE_BEAM] Step 5: Build BeamProject structure took {:.2}ms", step5_start.elapsed().as_secs_f64() * 1000.0); eprintln!("📊 [SAVE_BEAM] Step 5: Build BeamProject structure took {:.2}ms", step5_start.elapsed().as_secs_f64() * 1000.0);
@ -449,6 +461,7 @@ pub fn load_beam(path: &Path) -> Result<LoadedProject, String> {
let document = beam_project.ui_state; let document = beam_project.ui_state;
let mut audio_project = beam_project.audio_backend.project; let mut audio_project = beam_project.audio_backend.project;
let audio_pool_entries = beam_project.audio_backend.audio_pool_entries; let audio_pool_entries = beam_project.audio_backend.audio_pool_entries;
let layer_to_track_map = beam_project.audio_backend.layer_to_track_map;
eprintln!("📊 [LOAD_BEAM] Step 5: Extract document and audio state took {:.2}ms", step5_start.elapsed().as_secs_f64() * 1000.0); eprintln!("📊 [LOAD_BEAM] Step 5: Extract document and audio state took {:.2}ms", step5_start.elapsed().as_secs_f64() * 1000.0);
// 6. Rebuild AudioGraphs from presets // 6. Rebuild AudioGraphs from presets
@ -503,17 +516,37 @@ pub fn load_beam(path: &Path) -> Result<LoadedProject, String> {
samples_f32.push(sample as f32 / max_value); samples_f32.push(sample as f32 / max_value);
} }
// Convert f32 samples to bytes (little-endian) // Encode f32 samples as a proper WAV file (with RIFF header)
let mut pcm_bytes = Vec::new(); let channels = entry.channels;
for sample in samples_f32 { let sample_rate = entry.sample_rate;
pcm_bytes.extend_from_slice(&sample.to_le_bytes()); let num_samples = samples_f32.len();
let bytes_per_sample = 4u32; // 32-bit float
let data_size = num_samples * bytes_per_sample as usize;
let file_size = 36 + data_size;
let mut wav_data = Vec::with_capacity(44 + data_size);
wav_data.extend_from_slice(b"RIFF");
wav_data.extend_from_slice(&(file_size as u32).to_le_bytes());
wav_data.extend_from_slice(b"WAVE");
wav_data.extend_from_slice(b"fmt ");
wav_data.extend_from_slice(&16u32.to_le_bytes());
wav_data.extend_from_slice(&3u16.to_le_bytes()); // IEEE float
wav_data.extend_from_slice(&(channels as u16).to_le_bytes());
wav_data.extend_from_slice(&sample_rate.to_le_bytes());
wav_data.extend_from_slice(&(sample_rate * channels * bytes_per_sample).to_le_bytes());
wav_data.extend_from_slice(&((channels * bytes_per_sample) as u16).to_le_bytes());
wav_data.extend_from_slice(&32u16.to_le_bytes());
wav_data.extend_from_slice(b"data");
wav_data.extend_from_slice(&(data_size as u32).to_le_bytes());
for &sample in &samples_f32 {
wav_data.extend_from_slice(&sample.to_le_bytes());
} }
flac_decode_time += flac_decode_start.elapsed().as_secs_f64() * 1000.0; flac_decode_time += flac_decode_start.elapsed().as_secs_f64() * 1000.0;
Some(daw_backend::audio::pool::EmbeddedAudioData { Some(daw_backend::audio::pool::EmbeddedAudioData {
data_base64: BASE64_STANDARD.encode(&pcm_bytes), data_base64: BASE64_STANDARD.encode(&wav_data),
format: "wav".to_string(), // Mark as WAV since it's now PCM format: "wav".to_string(),
}) })
} else { } else {
// Lossy format - store as-is // Lossy format - store as-is
@ -573,6 +606,7 @@ pub fn load_beam(path: &Path) -> Result<LoadedProject, String> {
Ok(LoadedProject { Ok(LoadedProject {
document, document,
audio_project, audio_project,
layer_to_track_map,
audio_pool_entries: restored_entries, audio_pool_entries: restored_entries,
missing_files, missing_files,
}) })

View File

@ -15,7 +15,7 @@ eframe = { workspace = true }
egui_extras = { workspace = true } egui_extras = { workspace = true }
egui-wgpu = { workspace = true } egui-wgpu = { workspace = true }
egui_code_editor = { workspace = true } egui_code_editor = { workspace = true }
egui_node_graph2 = { git = "https://github.com/PVDoriginal/egui_node_graph2" } egui_node_graph2 = { path = "../egui_node_graph2" }
# GPU # GPU
wgpu = { workspace = true } wgpu = { workspace = true }

View File

@ -393,6 +393,7 @@ enum FileCommand {
Save { Save {
path: std::path::PathBuf, path: std::path::PathBuf,
document: lightningbeam_core::document::Document, document: lightningbeam_core::document::Document,
layer_to_track_map: std::collections::HashMap<uuid::Uuid, u32>,
progress_tx: std::sync::mpsc::Sender<FileProgress>, progress_tx: std::sync::mpsc::Sender<FileProgress>,
}, },
Load { Load {
@ -467,8 +468,8 @@ impl FileOperationsWorker {
fn run(self) { fn run(self) {
while let Ok(command) = self.command_rx.recv() { while let Ok(command) = self.command_rx.recv() {
match command { match command {
FileCommand::Save { path, document, progress_tx } => { FileCommand::Save { path, document, layer_to_track_map, progress_tx } => {
self.handle_save(path, document, progress_tx); self.handle_save(path, document, &layer_to_track_map, progress_tx);
} }
FileCommand::Load { path, progress_tx } => { FileCommand::Load { path, progress_tx } => {
self.handle_load(path, progress_tx); self.handle_load(path, progress_tx);
@ -482,6 +483,7 @@ impl FileOperationsWorker {
&self, &self,
path: std::path::PathBuf, path: std::path::PathBuf,
document: lightningbeam_core::document::Document, document: lightningbeam_core::document::Document,
layer_to_track_map: &std::collections::HashMap<uuid::Uuid, u32>,
progress_tx: std::sync::mpsc::Sender<FileProgress>, progress_tx: std::sync::mpsc::Sender<FileProgress>,
) { ) {
use lightningbeam_core::file_io::{save_beam, SaveSettings}; use lightningbeam_core::file_io::{save_beam, SaveSettings};
@ -524,7 +526,7 @@ impl FileOperationsWorker {
let step3_start = std::time::Instant::now(); let step3_start = std::time::Instant::now();
let settings = SaveSettings::default(); let settings = SaveSettings::default();
match save_beam(&path, &document, &mut audio_project, audio_pool_entries, &settings) { match save_beam(&path, &document, &mut audio_project, audio_pool_entries, layer_to_track_map, &settings) {
Ok(()) => { Ok(()) => {
eprintln!("📊 [SAVE] Step 3: save_beam() took {:.2}ms", step3_start.elapsed().as_secs_f64() * 1000.0); eprintln!("📊 [SAVE] Step 3: save_beam() took {:.2}ms", step3_start.elapsed().as_secs_f64() * 1000.0);
eprintln!("📊 [SAVE] ✅ Total save time: {:.2}ms", save_start.elapsed().as_secs_f64() * 1000.0); eprintln!("📊 [SAVE] ✅ Total save time: {:.2}ms", save_start.elapsed().as_secs_f64() * 1000.0);
@ -666,6 +668,8 @@ struct EditorApp {
// Track ID mapping (Document layer UUIDs <-> daw-backend TrackIds) // Track ID mapping (Document layer UUIDs <-> daw-backend TrackIds)
layer_to_track_map: HashMap<Uuid, daw_backend::TrackId>, layer_to_track_map: HashMap<Uuid, daw_backend::TrackId>,
track_to_layer_map: HashMap<daw_backend::TrackId, Uuid>, track_to_layer_map: HashMap<daw_backend::TrackId, Uuid>,
/// Generation counter - incremented on project load to force UI components to reload
project_generation: u64,
// Clip instance ID mapping (Document clip instance UUIDs <-> backend clip instance IDs) // Clip instance ID mapping (Document clip instance UUIDs <-> backend clip instance IDs)
clip_instance_to_backend_map: HashMap<Uuid, lightningbeam_core::action::BackendClipInstanceId>, clip_instance_to_backend_map: HashMap<Uuid, lightningbeam_core::action::BackendClipInstanceId>,
// Playback state (global for all panes) // Playback state (global for all panes)
@ -888,6 +892,7 @@ impl EditorApp {
)), )),
layer_to_track_map: HashMap::new(), layer_to_track_map: HashMap::new(),
track_to_layer_map: HashMap::new(), track_to_layer_map: HashMap::new(),
project_generation: 0,
clip_instance_to_backend_map: HashMap::new(), clip_instance_to_backend_map: HashMap::new(),
playback_time: 0.0, // Start at beginning playback_time: 0.0, // Start at beginning
is_playing: false, // Start paused is_playing: false, // Start paused
@ -2557,6 +2562,7 @@ impl EditorApp {
let command = FileCommand::Save { let command = FileCommand::Save {
path: path.clone(), path: path.clone(),
document, document,
layer_to_track_map: self.layer_to_track_map.clone(),
progress_tx, progress_tx,
}; };
@ -2689,17 +2695,31 @@ impl EditorApp {
eprintln!("📊 [APPLY] Step 4: Set audio project took {:.2}ms", step4_start.elapsed().as_secs_f64() * 1000.0); eprintln!("📊 [APPLY] Step 4: Set audio project took {:.2}ms", step4_start.elapsed().as_secs_f64() * 1000.0);
} }
// Reset state // Reset state and restore track mappings
let step5_start = std::time::Instant::now(); let step5_start = std::time::Instant::now();
self.layer_to_track_map.clear(); self.layer_to_track_map.clear();
self.track_to_layer_map.clear(); self.track_to_layer_map.clear();
eprintln!("📊 [APPLY] Step 5: Clear track maps took {:.2}ms", step5_start.elapsed().as_secs_f64() * 1000.0);
// Sync audio layers (MIDI and Sampled) if !loaded_project.layer_to_track_map.is_empty() {
// Restore saved mapping (connects UI layers to loaded backend tracks with effects graphs)
for (&layer_id, &track_id) in &loaded_project.layer_to_track_map {
self.layer_to_track_map.insert(layer_id, track_id);
self.track_to_layer_map.insert(track_id, layer_id);
}
eprintln!("📊 [APPLY] Step 5: Restored {} track mappings from file in {:.2}ms",
loaded_project.layer_to_track_map.len(), step5_start.elapsed().as_secs_f64() * 1000.0);
} else {
eprintln!("📊 [APPLY] Step 5: No saved track mappings (old file format)");
}
// Sync any audio layers that don't have a mapping yet (new layers, or old file format)
let step6_start = std::time::Instant::now(); let step6_start = std::time::Instant::now();
self.sync_audio_layers_to_backend(); self.sync_audio_layers_to_backend();
eprintln!("📊 [APPLY] Step 6: Sync audio layers took {:.2}ms", step6_start.elapsed().as_secs_f64() * 1000.0); eprintln!("📊 [APPLY] Step 6: Sync audio layers took {:.2}ms", step6_start.elapsed().as_secs_f64() * 1000.0);
// Increment project generation to force node graph pane reload
self.project_generation += 1;
// Fetch raw audio for all audio clips in the loaded project // Fetch raw audio for all audio clips in the loaded project
let step7_start = std::time::Instant::now(); let step7_start = std::time::Instant::now();
let pool_indices: Vec<usize> = self.action_executor.document() let pool_indices: Vec<usize> = self.action_executor.document()
@ -3744,9 +3764,19 @@ impl eframe::App for EditorApp {
); );
ctx.request_repaint(); ctx.request_repaint();
} }
AudioEvent::ExportFinalizing => {
self.export_progress_dialog.update_progress(
"Finalizing...".to_string(),
1.0,
);
ctx.request_repaint();
}
AudioEvent::WaveformChunksReady { pool_index, .. } => { AudioEvent::WaveformChunksReady { pool_index, .. } => {
// Fetch raw audio for GPU waveform if not already cached // Skip synchronous audio queries during export (audio thread is blocked)
if !self.raw_audio_cache.contains_key(&pool_index) { let is_exporting = self.export_orchestrator.as_ref()
.map_or(false, |o| o.is_exporting());
if !is_exporting && !self.raw_audio_cache.contains_key(&pool_index) {
if let Some(ref controller_arc) = self.audio_controller { if let Some(ref controller_arc) = self.audio_controller {
let mut controller = controller_arc.lock().unwrap(); let mut controller = controller_arc.lock().unwrap();
match controller.get_pool_audio_samples(pool_index) { match controller.get_pool_audio_samples(pool_index) {
@ -4316,6 +4346,14 @@ impl eframe::App for EditorApp {
return; // Skip editor rendering return; // Skip editor rendering
} }
// Skip rendering the editor while a file is loading — the loading dialog
// (rendered earlier) is all the user needs to see. This avoids showing
// the default empty layout behind the dialog for several seconds.
if matches!(self.file_operation, Some(FileOperation::Loading { .. })) {
egui::CentralPanel::default().show(ctx, |_ui| {});
return;
}
// Main pane area (editor mode) // Main pane area (editor mode)
let mut layout_action: Option<LayoutAction> = None; let mut layout_action: Option<LayoutAction> = None;
egui::CentralPanel::default().show(ctx, |ui| { egui::CentralPanel::default().show(ctx, |ui| {
@ -4390,6 +4428,7 @@ impl eframe::App for EditorApp {
pending_menu_actions: &mut pending_menu_actions, pending_menu_actions: &mut pending_menu_actions,
clipboard_manager: &mut self.clipboard_manager, clipboard_manager: &mut self.clipboard_manager,
waveform_stereo: self.config.waveform_stereo, waveform_stereo: self.config.waveform_stereo,
project_generation: self.project_generation,
}; };
render_layout_node( render_layout_node(
@ -4664,6 +4703,8 @@ struct RenderContext<'a> {
clipboard_manager: &'a mut lightningbeam_core::clipboard::ClipboardManager, clipboard_manager: &'a mut lightningbeam_core::clipboard::ClipboardManager,
/// Whether to show waveforms as stacked stereo /// Whether to show waveforms as stacked stereo
waveform_stereo: bool, waveform_stereo: bool,
/// Project generation counter (incremented on load)
project_generation: u64,
} }
/// Recursively render a layout node with drag support /// Recursively render a layout node with drag support
@ -5145,6 +5186,7 @@ fn render_pane(
pending_menu_actions: ctx.pending_menu_actions, pending_menu_actions: ctx.pending_menu_actions,
clipboard_manager: ctx.clipboard_manager, clipboard_manager: ctx.clipboard_manager,
waveform_stereo: ctx.waveform_stereo, waveform_stereo: ctx.waveform_stereo,
project_generation: ctx.project_generation,
}; };
pane_instance.render_header(&mut header_ui, &mut shared); pane_instance.render_header(&mut header_ui, &mut shared);
} }
@ -5215,6 +5257,7 @@ fn render_pane(
pending_menu_actions: ctx.pending_menu_actions, pending_menu_actions: ctx.pending_menu_actions,
clipboard_manager: ctx.clipboard_manager, clipboard_manager: ctx.clipboard_manager,
waveform_stereo: ctx.waveform_stereo, waveform_stereo: ctx.waveform_stereo,
project_generation: ctx.project_generation,
}; };
// Render pane content (header was already rendered above) // Render pane content (header was already rendered above)

View File

@ -217,6 +217,8 @@ pub struct SharedPaneState<'a> {
pub clipboard_manager: &'a mut lightningbeam_core::clipboard::ClipboardManager, pub clipboard_manager: &'a mut lightningbeam_core::clipboard::ClipboardManager,
/// Whether to show waveforms as stacked stereo (true) or combined mono (false) /// Whether to show waveforms as stacked stereo (true) or combined mono (false)
pub waveform_stereo: bool, pub waveform_stereo: bool,
/// Generation counter - incremented on project load to force reloads
pub project_generation: u64,
} }
/// Trait for pane rendering /// Trait for pane rendering

View File

@ -127,25 +127,35 @@ impl AddNodeAction {
serde_json::from_str(&before_json) serde_json::from_str(&before_json)
.map_err(|e| format!("Failed to parse before graph state: {}", e))?; .map_err(|e| format!("Failed to parse before graph state: {}", e))?;
// Add node to backend (using async API) // Add node to backend (async command via ring buffer)
controller.graph_add_node(*track_id, self.node_type.clone(), self.position.0, self.position.1); controller.graph_add_node(*track_id, self.node_type.clone(), self.position.0, self.position.1);
// Query graph state after to find the new node ID // The command is in the ring buffer. The next query_graph_state call will go through
let after_json = controller.query_graph_state(*track_id)?; // the audio thread's process(), which drains commands BEFORE processing queries.
let after_state: daw_backend::audio::node_graph::GraphPreset = // So a single query after the push should see the new node.
serde_json::from_str(&after_json) // We retry a few times in case the audio thread hasn't woken up yet.
.map_err(|e| format!("Failed to parse after graph state: {}", e))?;
// Find the new node by comparing before and after states
// The new node should have an ID that wasn't in the before state
let before_ids: std::collections::HashSet<_> = before_state.nodes.iter().map(|n| n.id).collect(); let before_ids: std::collections::HashSet<_> = before_state.nodes.iter().map(|n| n.id).collect();
let new_node = after_state.nodes.iter() let mut new_node = None;
.find(|n| !before_ids.contains(&n.id)) for attempt in 0..10 {
.ok_or("Failed to find newly added node in graph state")?; // Sleep longer on first attempt to ensure audio callback has run
std::thread::sleep(std::time::Duration::from_millis(if attempt == 0 { 10 } else { 5 }));
match controller.query_graph_state(*track_id) {
Ok(after_json) => {
if let Ok(after_state) = serde_json::from_str::<daw_backend::audio::node_graph::GraphPreset>(&after_json) {
if let Some(node) = after_state.nodes.iter().find(|n| !before_ids.contains(&n.id)) {
new_node = Some((node.id, node.node_type.clone()));
break;
}
}
}
Err(_) => {}
}
}
let (new_node_id, _) = new_node.ok_or("Failed to find newly added node in graph state after retries")?;
// Store the backend node ID // Store the backend node ID
self.backend_node_id = Some(BackendNodeId::Audio( self.backend_node_id = Some(BackendNodeId::Audio(
petgraph::stable_graph::NodeIndex::new(new_node.id as usize) petgraph::stable_graph::NodeIndex::new(new_node_id as usize)
)); ));
Ok(()) Ok(())

View File

@ -73,9 +73,60 @@ pub enum NodeTemplate {
AudioOutput, AudioOutput,
} }
/// Custom node data - empty for now, can be extended impl NodeTemplate {
/// Returns the backend-compatible type name string (matches daw-backend match arms)
pub fn backend_type_name(&self) -> &'static str {
match self {
NodeTemplate::MidiInput => "MidiInput",
NodeTemplate::AudioInput => "AudioInput",
NodeTemplate::AutomationInput => "AutomationInput",
NodeTemplate::Oscillator => "Oscillator",
NodeTemplate::WavetableOscillator => "WavetableOscillator",
NodeTemplate::FmSynth => "FMSynth",
NodeTemplate::Noise => "NoiseGenerator",
NodeTemplate::SimpleSampler => "SimpleSampler",
NodeTemplate::MultiSampler => "MultiSampler",
NodeTemplate::Filter => "Filter",
NodeTemplate::Gain => "Gain",
NodeTemplate::Echo => "Echo",
NodeTemplate::Reverb => "Reverb",
NodeTemplate::Chorus => "Chorus",
NodeTemplate::Flanger => "Flanger",
NodeTemplate::Phaser => "Phaser",
NodeTemplate::Distortion => "Distortion",
NodeTemplate::BitCrusher => "BitCrusher",
NodeTemplate::Compressor => "Compressor",
NodeTemplate::Limiter => "Limiter",
NodeTemplate::Eq => "EQ",
NodeTemplate::Pan => "Pan",
NodeTemplate::RingModulator => "RingModulator",
NodeTemplate::Vocoder => "Vocoder",
NodeTemplate::Adsr => "ADSR",
NodeTemplate::Lfo => "LFO",
NodeTemplate::Mixer => "Mixer",
NodeTemplate::Splitter => "Splitter",
NodeTemplate::Constant => "Constant",
NodeTemplate::MidiToCv => "MidiToCV",
NodeTemplate::AudioToCv => "AudioToCV",
NodeTemplate::Math => "Math",
NodeTemplate::SampleHold => "SampleHold",
NodeTemplate::SlewLimiter => "SlewLimiter",
NodeTemplate::Quantizer => "Quantizer",
NodeTemplate::EnvelopeFollower => "EnvelopeFollower",
NodeTemplate::BpmDetector => "BpmDetector",
NodeTemplate::Mod => "Mod",
NodeTemplate::Oscilloscope => "Oscilloscope",
NodeTemplate::VoiceAllocator => "VoiceAllocator",
NodeTemplate::AudioOutput => "AudioOutput",
}
}
}
/// Custom node data
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NodeData; pub struct NodeData {
pub template: NodeTemplate,
}
/// Custom graph state - can track selected nodes, etc. /// Custom graph state - can track selected nodes, etc.
#[derive(Default)] #[derive(Default)]
@ -260,7 +311,7 @@ impl NodeTemplateTrait for NodeTemplate {
} }
fn user_data(&self, _user_state: &mut Self::UserState) -> Self::NodeData { fn user_data(&self, _user_state: &mut Self::UserState) -> Self::NodeData {
NodeData NodeData { template: *self }
} }
fn build_node( fn build_node(
@ -312,13 +363,13 @@ impl NodeTemplateTrait for NodeTemplate {
graph.add_input_param(node_id, "Gate".into(), DataType::CV, ValueType::float(0.0), InputParamKind::ConnectionOnly, true); graph.add_input_param(node_id, "Gate".into(), DataType::CV, ValueType::float(0.0), InputParamKind::ConnectionOnly, true);
// Parameters // Parameters
graph.add_input_param(node_id, "Attack".into(), DataType::CV, graph.add_input_param(node_id, "Attack".into(), DataType::CV,
ValueType::float_param(10.0, 0.1, 2000.0, " ms", 0, None), InputParamKind::ConstantOnly, true); ValueType::float_param(0.01, 0.001, 5.0, " s", 0, None), InputParamKind::ConstantOnly, true);
graph.add_input_param(node_id, "Decay".into(), DataType::CV, graph.add_input_param(node_id, "Decay".into(), DataType::CV,
ValueType::float_param(100.0, 0.1, 2000.0, " ms", 1, None), InputParamKind::ConstantOnly, true); ValueType::float_param(0.1, 0.001, 5.0, " s", 1, None), InputParamKind::ConstantOnly, true);
graph.add_input_param(node_id, "Sustain".into(), DataType::CV, graph.add_input_param(node_id, "Sustain".into(), DataType::CV,
ValueType::float_param(0.7, 0.0, 1.0, "", 2, None), InputParamKind::ConstantOnly, true); ValueType::float_param(0.7, 0.0, 1.0, "", 2, None), InputParamKind::ConstantOnly, true);
graph.add_input_param(node_id, "Release".into(), DataType::CV, graph.add_input_param(node_id, "Release".into(), DataType::CV,
ValueType::float_param(200.0, 0.1, 5000.0, " ms", 3, None), InputParamKind::ConstantOnly, true); ValueType::float_param(0.2, 0.001, 5.0, " s", 3, None), InputParamKind::ConstantOnly, true);
graph.add_output_param(node_id, "Envelope Out".into(), DataType::CV); graph.add_output_param(node_id, "Envelope Out".into(), DataType::CV);
} }
NodeTemplate::Lfo => { NodeTemplate::Lfo => {
@ -342,7 +393,7 @@ impl NodeTemplateTrait for NodeTemplate {
graph.add_input_param(node_id, "Audio In".into(), DataType::Audio, ValueType::float(0.0), InputParamKind::ConnectionOnly, true); graph.add_input_param(node_id, "Audio In".into(), DataType::Audio, ValueType::float(0.0), InputParamKind::ConnectionOnly, true);
// Parameters // Parameters
graph.add_input_param(node_id, "Delay Time".into(), DataType::CV, graph.add_input_param(node_id, "Delay Time".into(), DataType::CV,
ValueType::float_param(250.0, 1.0, 2000.0, " ms", 0, None), InputParamKind::ConstantOnly, true); ValueType::float_param(0.5, 0.01, 2.0, " s", 0, None), InputParamKind::ConstantOnly, true);
graph.add_input_param(node_id, "Feedback".into(), DataType::CV, graph.add_input_param(node_id, "Feedback".into(), DataType::CV,
ValueType::float_param(0.3, 0.0, 0.95, "", 1, None), InputParamKind::ConstantOnly, true); ValueType::float_param(0.3, 0.0, 0.95, "", 1, None), InputParamKind::ConstantOnly, true);
graph.add_input_param(node_id, "Mix".into(), DataType::CV, graph.add_input_param(node_id, "Mix".into(), DataType::CV,

View File

@ -48,6 +48,14 @@ pub struct NodeGraphPane {
/// Track parameter values to detect changes /// Track parameter values to detect changes
/// Maps InputId -> last known value /// Maps InputId -> last known value
parameter_values: HashMap<InputId, f32>, 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)>,
} }
impl NodeGraphPane { impl NodeGraphPane {
@ -64,6 +72,10 @@ impl NodeGraphPane {
pending_action: None, pending_action: None,
pending_node_addition: None, pending_node_addition: None,
parameter_values: HashMap::new(), parameter_values: HashMap::new(),
last_project_generation: 0,
dragging_node: None,
insert_target: None,
} }
} }
@ -88,6 +100,10 @@ impl NodeGraphPane {
pending_action: None, pending_action: None,
pending_node_addition: None, pending_node_addition: None,
parameter_values: HashMap::new(), parameter_values: HashMap::new(),
last_project_generation: 0,
dragging_node: None,
insert_target: None,
}; };
// Load existing graph from backend // Load existing graph from backend
@ -184,7 +200,7 @@ impl NodeGraphPane {
label: node.node_type.clone(), label: node.node_type.clone(),
inputs: vec![], inputs: vec![],
outputs: vec![], outputs: vec![],
user_data: graph_data::NodeData, user_data: graph_data::NodeData { template: node_template },
}); });
// Build the node's inputs and outputs (this adds them to graph.inputs and graph.outputs) // Build the node's inputs and outputs (this adds them to graph.inputs and graph.outputs)
@ -235,11 +251,22 @@ impl NodeGraphPane {
// Find input param on to_node // Find input param on to_node
if let Some(to_node) = self.state.graph.nodes.get(to_id) { if let Some(to_node) = self.state.graph.nodes.get(to_id) {
if let Some((_name, input_id)) = to_node.inputs.get(conn.to_port) { if let Some((_name, input_id)) = to_node.inputs.get(conn.to_port) {
// Add connection to graph - connections map is InputId -> Vec<OutputId> // Check max_connections to avoid panic in egui_node_graph2 rendering
if let Some(connections) = self.state.graph.connections.get_mut(*input_id) { let max_conns = self.state.graph.inputs.get(*input_id)
connections.push(*output_id); .and_then(|p| p.max_connections)
} else { .map(|n| n.get() as usize)
self.state.graph.connections.insert(*input_id, vec![*output_id]); .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]);
}
} }
} }
} }
@ -258,6 +285,7 @@ impl NodeGraphPane {
graph_data::NodeData, graph_data::NodeData,
>, >,
shared: &mut crate::panes::SharedPaneState, shared: &mut crate::panes::SharedPaneState,
pane_rect: egui::Rect,
) { ) {
use egui_node_graph2::NodeResponse; use egui_node_graph2::NodeResponse;
@ -265,12 +293,16 @@ impl NodeGraphPane {
match node_response { match node_response {
NodeResponse::CreatedNode(node_id) => { NodeResponse::CreatedNode(node_id) => {
// Node was created from the node finder // Node was created from the node finder
// Get node label which is the node type string // 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) { if let Some(node) = self.state.graph.nodes.get(node_id) {
let node_type = node.label.clone(); let node_type = node.user_data.template.backend_type_name().to_string();
let position = self.state.node_positions.get(node_id) let position = (center_graph.x, center_graph.y);
.map(|pos| (pos.x, pos.y))
.unwrap_or((0.0, 0.0));
if let Some(track_id) = self.track_id { if let Some(track_id) = self.track_id {
let action = Box::new(actions::NodeGraphAction::AddNode( let action = Box::new(actions::NodeGraphAction::AddNode(
@ -368,9 +400,28 @@ impl NodeGraphPane {
} }
} }
NodeResponse::MoveNode { node, drag_delta: _ } => { NodeResponse::MoveNode { node, drag_delta: _ } => {
// Node was moved - we'll handle this on drag end
// For now, just update the position (no action needed during drag)
self.user_state.active_node = Some(node); self.user_state.active_node = Some(node);
self.dragging_node = Some(node);
// 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();
controller.graph_set_node_position(
backend_track_id,
node_index,
pos.x,
pos.y,
);
}
}
}
}
} }
_ => { _ => {
// Ignore other events (SelectNode, RaiseNode, etc.) // Ignore other events (SelectNode, RaiseNode, etc.)
@ -417,7 +468,6 @@ impl NodeGraphPane {
); );
self.node_id_map.insert(frontend_id, backend_id); self.node_id_map.insert(frontend_id, backend_id);
self.backend_to_frontend_map.insert(backend_id, frontend_id); self.backend_to_frontend_map.insert(backend_id, frontend_id);
eprintln!("[DEBUG] Mapped new node: frontend {:?} -> backend {:?}", frontend_id, backend_id);
} }
} }
} }
@ -552,6 +602,301 @@ impl NodeGraphPane {
y += 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]);
}
}
} }
impl crate::panes::PaneRenderer for NodeGraphPane { impl crate::panes::PaneRenderer for NodeGraphPane {
@ -562,11 +907,15 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
_path: &NodePath, _path: &NodePath,
shared: &mut crate::panes::SharedPaneState, shared: &mut crate::panes::SharedPaneState,
) { ) {
// Check if we need to reload for a different track // Check if we need to reload for a different track or project reload
let current_track = *shared.active_layer_id; 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, reload the graph // If selected track changed or project was reloaded, reload the graph
if self.track_id != current_track { if self.track_id != current_track || (generation_changed && current_track.is_some()) {
if let Some(new_track_id) = current_track { if let Some(new_track_id) = current_track {
// Get backend track ID // Get backend track ID
if let Some(&backend_track_id) = shared.layer_to_track_map.get(&new_track_id) { if let Some(&backend_track_id) = shared.layer_to_track_map.get(&new_track_id) {
@ -669,7 +1018,7 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
); );
// Handle graph events and create actions // Handle graph events and create actions
self.handle_graph_response(graph_response, shared); self.handle_graph_response(graph_response, shared, rect);
// Check for parameter value changes and send updates to backend // Check for parameter value changes and send updates to backend
self.check_parameter_changes(); self.check_parameter_changes();
@ -677,6 +1026,33 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
// Execute any parameter change actions // Execute any parameter change actions
self.execute_pending_action(shared); 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: // Override library's default scroll behavior:
// - Library uses scroll for zoom // - Library uses scroll for zoom
// - We want: scroll = pan, ctrl+scroll = zoom // - We want: scroll = pan, ctrl+scroll = zoom

View File

@ -1813,6 +1813,11 @@ impl TimelinePane {
let new_time = self.x_to_time(x).max(0.0); let new_time = self.x_to_time(x).max(0.0);
*playback_time = new_time; *playback_time = new_time;
self.is_scrubbing = true; self.is_scrubbing = true;
// Seek immediately so it works while playing
if let Some(controller_arc) = audio_controller {
let mut controller = controller_arc.lock().unwrap();
controller.seek(new_time);
}
} }
} }
// Continue scrubbing while dragging, even if cursor leaves ruler // Continue scrubbing while dragging, even if cursor leaves ruler
@ -1821,16 +1826,15 @@ impl TimelinePane {
let x = (pos.x - content_rect.min.x).max(0.0); let x = (pos.x - content_rect.min.x).max(0.0);
let new_time = self.x_to_time(x).max(0.0); let new_time = self.x_to_time(x).max(0.0);
*playback_time = new_time; *playback_time = new_time;
if let Some(controller_arc) = audio_controller {
let mut controller = controller_arc.lock().unwrap();
controller.seek(new_time);
}
} }
} }
// Stop scrubbing when drag ends - seek the audio engine // Stop scrubbing when drag ends
else if !response.dragged() && self.is_scrubbing { else if !response.dragged() && self.is_scrubbing {
self.is_scrubbing = false; self.is_scrubbing = false;
// Seek the audio engine to the new position
if let Some(controller_arc) = audio_controller {
let mut controller = controller_arc.lock().unwrap();
controller.seek(*playback_time);
}
} }
// Distinguish between mouse wheel (discrete) and trackpad (smooth) // Distinguish between mouse wheel (discrete) and trackpad (smooth)