node graph fixes
This commit is contained in:
parent
1e7001b291
commit
7387299b52
|
|
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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))?;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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>),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
|
|
@ -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),
|
||||||
|
}
|
||||||
|
|
@ -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>>,
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue