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
|
||||
/// where in the file to buffer around.
|
||||
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.
|
||||
|
|
@ -108,6 +111,7 @@ impl ReadAheadBuffer {
|
|||
channels,
|
||||
sample_rate,
|
||||
target_frame: AtomicU64::new(0),
|
||||
export_mode: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -200,6 +204,18 @@ impl ReadAheadBuffer {
|
|||
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.
|
||||
/// Called by the **disk reader thread** (producer) after a seek.
|
||||
pub fn reset(&self, new_start: u64) {
|
||||
|
|
@ -614,10 +630,14 @@ 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.
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DiskReader {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
||||
let graph = &mut track.instrument_graph;
|
||||
|
|
@ -2245,7 +2257,10 @@ impl Engine {
|
|||
QueryResponse::AudioImportedSync(self.do_import_audio(&path))
|
||||
}
|
||||
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())))
|
||||
}
|
||||
Query::SetProject(new_project) => {
|
||||
|
|
@ -2950,6 +2965,11 @@ impl EngineController {
|
|||
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
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -72,29 +72,44 @@ pub fn export_audio<P: AsRef<Path>>(
|
|||
midi_pool: &MidiClipPool,
|
||||
settings: &ExportSettings,
|
||||
output_path: P,
|
||||
event_tx: Option<&mut rtrb::Producer<AudioEvent>>,
|
||||
mut event_tx: Option<&mut rtrb::Producer<AudioEvent>>,
|
||||
) -> Result<(), String>
|
||||
{
|
||||
// Route to appropriate export implementation based on format
|
||||
match settings.format {
|
||||
// Reset all node graphs to clear stale effect buffers (echo, reverb, etc.)
|
||||
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 => {
|
||||
// Render to memory then write (existing path)
|
||||
let samples = render_to_memory(project, pool, midi_pool, settings, event_tx)?;
|
||||
let samples = render_to_memory(project, pool, midi_pool, settings, event_tx.as_mut().map(|tx| &mut **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 {
|
||||
ExportFormat::Wav => write_wav(&samples, settings, output_path)?,
|
||||
ExportFormat::Flac => write_flac(&samples, settings, output_path)?,
|
||||
ExportFormat::Wav => write_wav(&samples, settings, &output_path),
|
||||
ExportFormat::Flac => write_flac(&samples, settings, &output_path),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
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 => {
|
||||
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
|
||||
|
|
@ -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
|
||||
encoder.send_eof()
|
||||
.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
|
||||
encoder.send_eof()
|
||||
.map_err(|e| format!("Failed to send EOF: {}", e))?;
|
||||
|
|
|
|||
|
|
@ -161,6 +161,17 @@ impl AudioGraph {
|
|||
// Validate the connection
|
||||
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
|
||||
self.graph.add_edge(from, to, Connection { from_port, to_port });
|
||||
self.topo_cache = None;
|
||||
|
|
|
|||
|
|
@ -95,6 +95,8 @@ pub struct AudioFile {
|
|||
/// Original file format (mp3, ogg, wav, flac, etc.)
|
||||
/// Used to determine if we should preserve lossy encoding during save
|
||||
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 {
|
||||
|
|
@ -108,6 +110,7 @@ impl AudioFile {
|
|||
sample_rate,
|
||||
frames,
|
||||
original_format: None,
|
||||
original_bytes: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -121,6 +124,7 @@ impl AudioFile {
|
|||
sample_rate,
|
||||
frames,
|
||||
original_format,
|
||||
original_bytes: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -152,6 +156,7 @@ impl AudioFile {
|
|||
sample_rate,
|
||||
frames: total_frames,
|
||||
original_format: Some("wav".to_string()),
|
||||
original_bytes: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -174,6 +179,7 @@ impl AudioFile {
|
|||
sample_rate,
|
||||
frames: total_frames,
|
||||
original_format,
|
||||
original_bytes: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -470,6 +476,31 @@ impl AudioClipPool {
|
|||
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.
|
||||
// This ensures all sinc interpolation taps within a single callback
|
||||
// see a consistent range, preventing crackle from concurrent updates.
|
||||
|
|
@ -834,6 +865,15 @@ impl AudioClipPool {
|
|||
|| 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 {
|
||||
// For lossy formats, read the original file bytes (if it still exists)
|
||||
if let Ok(original_bytes) = std::fs::read(&audio_file.path) {
|
||||
|
|
@ -1012,9 +1052,12 @@ impl AudioClipPool {
|
|||
// Clean up temporary file
|
||||
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() {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
pub fn reset_all_graphs(&mut self) {
|
||||
for track in self.tracks.values_mut() {
|
||||
|
|
|
|||
|
|
@ -148,6 +148,8 @@ pub enum Command {
|
|||
GraphDisconnect(TrackId, u32, usize, u32, usize),
|
||||
/// Set a parameter on a node (track_id, node_index, param_id, value)
|
||||
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)
|
||||
GraphSetMidiTarget(TrackId, u32, bool),
|
||||
/// Set which node is the audio output (track_id, node_index)
|
||||
|
|
@ -250,6 +252,8 @@ pub enum AudioEvent {
|
|||
frames_rendered: usize,
|
||||
total_frames: usize,
|
||||
},
|
||||
/// Export rendering complete, now writing/encoding the output file
|
||||
ExportFinalizing,
|
||||
/// Waveform generated for audio pool file (pool_index, waveform)
|
||||
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
|
||||
/// are stored as FLAC in the ZIP's media/audio/ directory instead
|
||||
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
|
||||
|
|
@ -88,6 +93,9 @@ pub struct LoadedProject {
|
|||
/// Deserialized audio project
|
||||
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
|
||||
pub audio_pool_entries: Vec<AudioPoolEntry>,
|
||||
|
||||
|
|
@ -138,6 +146,7 @@ pub fn save_beam(
|
|||
document: &Document,
|
||||
audio_project: &mut AudioProject,
|
||||
audio_pool_entries: Vec<AudioPoolEntry>,
|
||||
layer_to_track_map: &std::collections::HashMap<uuid::Uuid, u32>,
|
||||
_settings: &SaveSettings,
|
||||
) -> Result<(), String> {
|
||||
let fn_start = std::time::Instant::now();
|
||||
|
|
@ -174,10 +183,12 @@ pub fn save_beam(
|
|||
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();
|
||||
audio_project.prepare_for_save();
|
||||
eprintln!("📊 [SAVE_BEAM] Step 2: Prepare audio project took {:.2}ms", step2_start.elapsed().as_secs_f64() * 1000.0);
|
||||
eprintln!("📊 [SAVE_BEAM] Step 2: (graph presets already prepared) took {:.2}ms", step2_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// 3. Create ZIP writer
|
||||
let step3_start = std::time::Instant::now();
|
||||
|
|
@ -363,6 +374,7 @@ pub fn save_beam(
|
|||
sample_rate: 48000, // TODO: Get from audio engine
|
||||
project: audio_project.clone(),
|
||||
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);
|
||||
|
|
@ -449,6 +461,7 @@ pub fn load_beam(path: &Path) -> Result<LoadedProject, String> {
|
|||
let document = beam_project.ui_state;
|
||||
let mut audio_project = beam_project.audio_backend.project;
|
||||
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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Convert f32 samples to bytes (little-endian)
|
||||
let mut pcm_bytes = Vec::new();
|
||||
for sample in samples_f32 {
|
||||
pcm_bytes.extend_from_slice(&sample.to_le_bytes());
|
||||
// Encode f32 samples as a proper WAV file (with RIFF header)
|
||||
let channels = entry.channels;
|
||||
let sample_rate = entry.sample_rate;
|
||||
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;
|
||||
|
||||
Some(daw_backend::audio::pool::EmbeddedAudioData {
|
||||
data_base64: BASE64_STANDARD.encode(&pcm_bytes),
|
||||
format: "wav".to_string(), // Mark as WAV since it's now PCM
|
||||
data_base64: BASE64_STANDARD.encode(&wav_data),
|
||||
format: "wav".to_string(),
|
||||
})
|
||||
} else {
|
||||
// Lossy format - store as-is
|
||||
|
|
@ -573,6 +606,7 @@ pub fn load_beam(path: &Path) -> Result<LoadedProject, String> {
|
|||
Ok(LoadedProject {
|
||||
document,
|
||||
audio_project,
|
||||
layer_to_track_map,
|
||||
audio_pool_entries: restored_entries,
|
||||
missing_files,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ eframe = { workspace = true }
|
|||
egui_extras = { workspace = true }
|
||||
egui-wgpu = { 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
|
||||
wgpu = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -393,6 +393,7 @@ enum FileCommand {
|
|||
Save {
|
||||
path: std::path::PathBuf,
|
||||
document: lightningbeam_core::document::Document,
|
||||
layer_to_track_map: std::collections::HashMap<uuid::Uuid, u32>,
|
||||
progress_tx: std::sync::mpsc::Sender<FileProgress>,
|
||||
},
|
||||
Load {
|
||||
|
|
@ -467,8 +468,8 @@ impl FileOperationsWorker {
|
|||
fn run(self) {
|
||||
while let Ok(command) = self.command_rx.recv() {
|
||||
match command {
|
||||
FileCommand::Save { path, document, progress_tx } => {
|
||||
self.handle_save(path, document, progress_tx);
|
||||
FileCommand::Save { path, document, layer_to_track_map, progress_tx } => {
|
||||
self.handle_save(path, document, &layer_to_track_map, progress_tx);
|
||||
}
|
||||
FileCommand::Load { path, progress_tx } => {
|
||||
self.handle_load(path, progress_tx);
|
||||
|
|
@ -482,6 +483,7 @@ impl FileOperationsWorker {
|
|||
&self,
|
||||
path: std::path::PathBuf,
|
||||
document: lightningbeam_core::document::Document,
|
||||
layer_to_track_map: &std::collections::HashMap<uuid::Uuid, u32>,
|
||||
progress_tx: std::sync::mpsc::Sender<FileProgress>,
|
||||
) {
|
||||
use lightningbeam_core::file_io::{save_beam, SaveSettings};
|
||||
|
|
@ -524,7 +526,7 @@ impl FileOperationsWorker {
|
|||
let step3_start = std::time::Instant::now();
|
||||
|
||||
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(()) => {
|
||||
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);
|
||||
|
|
@ -666,6 +668,8 @@ struct EditorApp {
|
|||
// Track ID mapping (Document layer UUIDs <-> daw-backend TrackIds)
|
||||
layer_to_track_map: HashMap<Uuid, daw_backend::TrackId>,
|
||||
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_to_backend_map: HashMap<Uuid, lightningbeam_core::action::BackendClipInstanceId>,
|
||||
// Playback state (global for all panes)
|
||||
|
|
@ -888,6 +892,7 @@ impl EditorApp {
|
|||
)),
|
||||
layer_to_track_map: HashMap::new(),
|
||||
track_to_layer_map: HashMap::new(),
|
||||
project_generation: 0,
|
||||
clip_instance_to_backend_map: HashMap::new(),
|
||||
playback_time: 0.0, // Start at beginning
|
||||
is_playing: false, // Start paused
|
||||
|
|
@ -2557,6 +2562,7 @@ impl EditorApp {
|
|||
let command = FileCommand::Save {
|
||||
path: path.clone(),
|
||||
document,
|
||||
layer_to_track_map: self.layer_to_track_map.clone(),
|
||||
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);
|
||||
}
|
||||
|
||||
// Reset state
|
||||
// Reset state and restore track mappings
|
||||
let step5_start = std::time::Instant::now();
|
||||
self.layer_to_track_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();
|
||||
self.sync_audio_layers_to_backend();
|
||||
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
|
||||
let step7_start = std::time::Instant::now();
|
||||
let pool_indices: Vec<usize> = self.action_executor.document()
|
||||
|
|
@ -3744,9 +3764,19 @@ impl eframe::App for EditorApp {
|
|||
);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
AudioEvent::ExportFinalizing => {
|
||||
self.export_progress_dialog.update_progress(
|
||||
"Finalizing...".to_string(),
|
||||
1.0,
|
||||
);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
AudioEvent::WaveformChunksReady { pool_index, .. } => {
|
||||
// Fetch raw audio for GPU waveform if not already cached
|
||||
if !self.raw_audio_cache.contains_key(&pool_index) {
|
||||
// Skip synchronous audio queries during export (audio thread is blocked)
|
||||
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 {
|
||||
let mut controller = controller_arc.lock().unwrap();
|
||||
match controller.get_pool_audio_samples(pool_index) {
|
||||
|
|
@ -4316,6 +4346,14 @@ impl eframe::App for EditorApp {
|
|||
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)
|
||||
let mut layout_action: Option<LayoutAction> = None;
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
|
|
@ -4390,6 +4428,7 @@ impl eframe::App for EditorApp {
|
|||
pending_menu_actions: &mut pending_menu_actions,
|
||||
clipboard_manager: &mut self.clipboard_manager,
|
||||
waveform_stereo: self.config.waveform_stereo,
|
||||
project_generation: self.project_generation,
|
||||
};
|
||||
|
||||
render_layout_node(
|
||||
|
|
@ -4664,6 +4703,8 @@ struct RenderContext<'a> {
|
|||
clipboard_manager: &'a mut lightningbeam_core::clipboard::ClipboardManager,
|
||||
/// Whether to show waveforms as stacked stereo
|
||||
waveform_stereo: bool,
|
||||
/// Project generation counter (incremented on load)
|
||||
project_generation: u64,
|
||||
}
|
||||
|
||||
/// Recursively render a layout node with drag support
|
||||
|
|
@ -5145,6 +5186,7 @@ fn render_pane(
|
|||
pending_menu_actions: ctx.pending_menu_actions,
|
||||
clipboard_manager: ctx.clipboard_manager,
|
||||
waveform_stereo: ctx.waveform_stereo,
|
||||
project_generation: ctx.project_generation,
|
||||
};
|
||||
pane_instance.render_header(&mut header_ui, &mut shared);
|
||||
}
|
||||
|
|
@ -5215,6 +5257,7 @@ fn render_pane(
|
|||
pending_menu_actions: ctx.pending_menu_actions,
|
||||
clipboard_manager: ctx.clipboard_manager,
|
||||
waveform_stereo: ctx.waveform_stereo,
|
||||
project_generation: ctx.project_generation,
|
||||
};
|
||||
|
||||
// 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,
|
||||
/// Whether to show waveforms as stacked stereo (true) or combined mono (false)
|
||||
pub waveform_stereo: bool,
|
||||
/// Generation counter - incremented on project load to force reloads
|
||||
pub project_generation: u64,
|
||||
}
|
||||
|
||||
/// Trait for pane rendering
|
||||
|
|
|
|||
|
|
@ -127,25 +127,35 @@ impl AddNodeAction {
|
|||
serde_json::from_str(&before_json)
|
||||
.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);
|
||||
|
||||
// Query graph state after to find the new node ID
|
||||
let after_json = controller.query_graph_state(*track_id)?;
|
||||
let after_state: daw_backend::audio::node_graph::GraphPreset =
|
||||
serde_json::from_str(&after_json)
|
||||
.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
|
||||
// The command is in the ring buffer. The next query_graph_state call will go through
|
||||
// the audio thread's process(), which drains commands BEFORE processing queries.
|
||||
// So a single query after the push should see the new node.
|
||||
// We retry a few times in case the audio thread hasn't woken up yet.
|
||||
let before_ids: std::collections::HashSet<_> = before_state.nodes.iter().map(|n| n.id).collect();
|
||||
let new_node = after_state.nodes.iter()
|
||||
.find(|n| !before_ids.contains(&n.id))
|
||||
.ok_or("Failed to find newly added node in graph state")?;
|
||||
let mut new_node = None;
|
||||
for attempt in 0..10 {
|
||||
// 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
|
||||
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(())
|
||||
|
|
|
|||
|
|
@ -73,9 +73,60 @@ pub enum NodeTemplate {
|
|||
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)]
|
||||
pub struct NodeData;
|
||||
pub struct NodeData {
|
||||
pub template: NodeTemplate,
|
||||
}
|
||||
|
||||
/// Custom graph state - can track selected nodes, etc.
|
||||
#[derive(Default)]
|
||||
|
|
@ -260,7 +311,7 @@ impl NodeTemplateTrait for NodeTemplate {
|
|||
}
|
||||
|
||||
fn user_data(&self, _user_state: &mut Self::UserState) -> Self::NodeData {
|
||||
NodeData
|
||||
NodeData { template: *self }
|
||||
}
|
||||
|
||||
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);
|
||||
// Parameters
|
||||
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,
|
||||
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,
|
||||
ValueType::float_param(0.7, 0.0, 1.0, "", 2, None), InputParamKind::ConstantOnly, true);
|
||||
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);
|
||||
}
|
||||
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);
|
||||
// Parameters
|
||||
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,
|
||||
ValueType::float_param(0.3, 0.0, 0.95, "", 1, None), InputParamKind::ConstantOnly, true);
|
||||
graph.add_input_param(node_id, "Mix".into(), DataType::CV,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,14 @@ pub struct NodeGraphPane {
|
|||
/// Track parameter values to detect changes
|
||||
/// Maps InputId -> last known value
|
||||
parameter_values: HashMap<InputId, f32>,
|
||||
|
||||
/// Last seen project generation (to detect project reloads)
|
||||
last_project_generation: u64,
|
||||
|
||||
/// Node currently being dragged (for insert-on-connection-drop)
|
||||
dragging_node: Option<NodeId>,
|
||||
/// Connection that would be targeted for insertion (highlighted during drag)
|
||||
insert_target: Option<(InputId, OutputId)>,
|
||||
}
|
||||
|
||||
impl NodeGraphPane {
|
||||
|
|
@ -64,6 +72,10 @@ impl NodeGraphPane {
|
|||
pending_action: None,
|
||||
pending_node_addition: None,
|
||||
parameter_values: HashMap::new(),
|
||||
last_project_generation: 0,
|
||||
dragging_node: None,
|
||||
|
||||
insert_target: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -88,6 +100,10 @@ impl NodeGraphPane {
|
|||
pending_action: None,
|
||||
pending_node_addition: None,
|
||||
parameter_values: HashMap::new(),
|
||||
last_project_generation: 0,
|
||||
dragging_node: None,
|
||||
|
||||
insert_target: None,
|
||||
};
|
||||
|
||||
// Load existing graph from backend
|
||||
|
|
@ -184,7 +200,7 @@ impl NodeGraphPane {
|
|||
label: node.node_type.clone(),
|
||||
inputs: 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)
|
||||
|
|
@ -235,7 +251,17 @@ impl NodeGraphPane {
|
|||
// Find input param on to_node
|
||||
if let Some(to_node) = self.state.graph.nodes.get(to_id) {
|
||||
if let Some((_name, input_id)) = to_node.inputs.get(conn.to_port) {
|
||||
// Add connection to graph - connections map is InputId -> Vec<OutputId>
|
||||
// Check max_connections to avoid panic in egui_node_graph2 rendering
|
||||
let max_conns = self.state.graph.inputs.get(*input_id)
|
||||
.and_then(|p| p.max_connections)
|
||||
.map(|n| n.get() as usize)
|
||||
.unwrap_or(usize::MAX);
|
||||
|
||||
let current_count = self.state.graph.connections.get(*input_id)
|
||||
.map(|c| c.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
if current_count < max_conns {
|
||||
if let Some(connections) = self.state.graph.connections.get_mut(*input_id) {
|
||||
connections.push(*output_id);
|
||||
} else {
|
||||
|
|
@ -247,6 +273,7 @@ impl NodeGraphPane {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -258,6 +285,7 @@ impl NodeGraphPane {
|
|||
graph_data::NodeData,
|
||||
>,
|
||||
shared: &mut crate::panes::SharedPaneState,
|
||||
pane_rect: egui::Rect,
|
||||
) {
|
||||
use egui_node_graph2::NodeResponse;
|
||||
|
||||
|
|
@ -265,12 +293,16 @@ impl NodeGraphPane {
|
|||
match node_response {
|
||||
NodeResponse::CreatedNode(node_id) => {
|
||||
// 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) {
|
||||
let node_type = node.label.clone();
|
||||
let position = self.state.node_positions.get(node_id)
|
||||
.map(|pos| (pos.x, pos.y))
|
||||
.unwrap_or((0.0, 0.0));
|
||||
let node_type = node.user_data.template.backend_type_name().to_string();
|
||||
let position = (center_graph.x, center_graph.y);
|
||||
|
||||
if let Some(track_id) = self.track_id {
|
||||
let action = Box::new(actions::NodeGraphAction::AddNode(
|
||||
|
|
@ -368,9 +400,28 @@ impl NodeGraphPane {
|
|||
}
|
||||
}
|
||||
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.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.)
|
||||
|
|
@ -417,7 +468,6 @@ impl NodeGraphPane {
|
|||
);
|
||||
self.node_id_map.insert(frontend_id, backend_id);
|
||||
self.backend_to_frontend_map.insert(backend_id, frontend_id);
|
||||
eprintln!("[DEBUG] Mapped new node: frontend {:?} -> backend {:?}", frontend_id, backend_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -552,6 +602,301 @@ impl NodeGraphPane {
|
|||
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 {
|
||||
|
|
@ -562,11 +907,15 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
|||
_path: &NodePath,
|
||||
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 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 self.track_id != current_track {
|
||||
// If selected track changed or project was reloaded, reload the graph
|
||||
if self.track_id != current_track || (generation_changed && current_track.is_some()) {
|
||||
if let Some(new_track_id) = current_track {
|
||||
// Get backend track ID
|
||||
if let Some(&backend_track_id) = shared.layer_to_track_map.get(&new_track_id) {
|
||||
|
|
@ -669,7 +1018,7 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
|||
);
|
||||
|
||||
// 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
|
||||
self.check_parameter_changes();
|
||||
|
|
@ -677,6 +1026,33 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
|||
// Execute any parameter change actions
|
||||
self.execute_pending_action(shared);
|
||||
|
||||
// Insert-node-on-connection: find target during drag, highlight, and execute on drop
|
||||
let primary_down = ui.input(|i| i.pointer.primary_down());
|
||||
if let Some(dragged) = self.dragging_node {
|
||||
if primary_down {
|
||||
// Still dragging — check for nearby compatible connection
|
||||
if let Some((input_id, output_id, src_graph, dst_graph)) = self.find_insert_target(dragged) {
|
||||
self.insert_target = Some((input_id, output_id));
|
||||
Self::draw_connection_highlight(
|
||||
ui,
|
||||
src_graph,
|
||||
dst_graph,
|
||||
self.state.pan_zoom.zoom,
|
||||
self.state.pan_zoom.pan,
|
||||
rect.min.to_vec2(),
|
||||
);
|
||||
} else {
|
||||
self.insert_target = None;
|
||||
}
|
||||
} else {
|
||||
// Drag ended — execute insertion if we have a target
|
||||
if let Some((target_input, target_output)) = self.insert_target.take() {
|
||||
self.execute_insert_on_connection(dragged, target_input, target_output, shared);
|
||||
}
|
||||
self.dragging_node = None;
|
||||
}
|
||||
}
|
||||
|
||||
// Override library's default scroll behavior:
|
||||
// - Library uses scroll for zoom
|
||||
// - We want: scroll = pan, ctrl+scroll = zoom
|
||||
|
|
|
|||
|
|
@ -1813,6 +1813,11 @@ impl TimelinePane {
|
|||
let new_time = self.x_to_time(x).max(0.0);
|
||||
*playback_time = new_time;
|
||||
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
|
||||
|
|
@ -1821,17 +1826,16 @@ impl TimelinePane {
|
|||
let x = (pos.x - content_rect.min.x).max(0.0);
|
||||
let new_time = self.x_to_time(x).max(0.0);
|
||||
*playback_time = new_time;
|
||||
}
|
||||
}
|
||||
// Stop scrubbing when drag ends - seek the audio engine
|
||||
else if !response.dragged() && self.is_scrubbing {
|
||||
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);
|
||||
controller.seek(new_time);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stop scrubbing when drag ends
|
||||
else if !response.dragged() && self.is_scrubbing {
|
||||
self.is_scrubbing = false;
|
||||
}
|
||||
|
||||
// Distinguish between mouse wheel (discrete) and trackpad (smooth)
|
||||
// Only handle scroll when mouse is over the timeline area
|
||||
|
|
|
|||
Loading…
Reference in New Issue