From 87b348feccdbbc6d040de31ecb9d766e0257ce78 Mon Sep 17 00:00:00 2001 From: Jafz2001 Date: Sun, 11 Oct 2026 23:50:06 -0300 Subject: [PATCH] Complete routing folder management --- crates/engine/src/cmd/track.rs | 247 ++++++++++++++++++++++++++++-- crates/ui-egui/src/edit_window.rs | 62 +++++++- 2 files changed, 293 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs index 2f231f4..071a07d 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -3,7 +3,7 @@ use super::*; use crate::cmd; use serde_json::json; -use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, TrackHeight, TrackKind}; +use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackKind}; pub fn specs() -> Vec { vec![ @@ -45,6 +45,8 @@ pub fn specs() -> Vec { has_selection, move_to_folder ), + cmd!("track.move_to_folder", "Move to Folder", [], None, "{tracks?, folder}", has_selection, move_into_folder), + cmd!("track.remove_from_folder", "Remove from Folder", [], None, "{tracks?}", has_selection, remove_from_folder), cmd!( "track.change_width", "Change Track Width", @@ -239,6 +241,14 @@ fn new_track(e: &mut Engine, p: &Value) -> Result { t.instrument = Some(soundcraft_model::Insert::new(instrument.clone().unwrap_or_else(|| "subtractive_synth".into()))); } } + if kind == TrackKind::Folder { + let folder_name = s.track(id).map(|track| track.name.clone()).unwrap_or_else(|| "Folder".into()); + let bus_name = unique_folder_bus_name(s, &format!("{folder_name} Bus"), None); + let bus = s.add_bus(&bus_name, format); + if let Some(track) = s.track_mut(id) { + track.mixer.input = Route::Bus(bus); + } + } ids.push(id); } s.edit.selected_tracks = ids.clone(); @@ -385,6 +395,17 @@ fn flag( Ok(json!({"value": target, "tracks": tracks})) } +/// A routing folder owns its summing bus; never reuse a user bus with the same name. +fn unique_folder_bus_name(s: &Session, base: &str, own: Option) -> String { + for suffix in 1..=s.busses.len().saturating_add(2) { + let candidate = if suffix == 1 { base.to_string() } else { format!("{base} {suffix}") }; + if !s.busses.iter().any(|bus| bus.name == candidate && Some(bus.id) != own) { + return candidate; + } + } + format!("{base} {}", s.busses.len().saturating_add(3)) +} + fn move_to_folder(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "track.move_to_new_folder", p)?; let name = str_param(p, "name").map(str::to_string); @@ -398,7 +419,8 @@ fn move_to_folder(e: &mut Engine, p: &Value) -> Result { } // A routing folder sums its members through its own bus. let fname = s.track(fid).map(|x| x.name.clone()).unwrap_or_else(|| "Folder".into()); - let bus = s.add_bus(&format!("{fname} Bus"), ChannelFormat::Stereo); + let bus_name = unique_folder_bus_name(s, &format!("{fname} Bus"), None); + let bus = s.add_bus(&bus_name, ChannelFormat::Stereo); if let Some(f) = s.track_mut(fid) { f.mixer.input = Route::Bus(bus); } @@ -410,9 +432,118 @@ fn move_to_folder(e: &mut Engine, p: &Value) -> Result { } } } + place_under(s, fid, &tracks); Ok(json!({"folder": fid, "bus": bus})) } +/// Whether `track` sits inside `folder`, directly or through nested folders. +fn in_folder(s: &Session, track: TrackId, folder: TrackId) -> bool { + let mut current = s.track(track).and_then(|track| track.folder); + // The depth cap guards against a cyclic folder chain in a hostile session file. + for _ in 0..64 { + match current { + Some(parent) if parent == folder => return true, + Some(parent) => current = s.track(parent).and_then(|track| track.folder), + None => return false, + } + } + false +} + +/// Keep a folder's members together immediately below it, preserving their relative order. +fn place_under(s: &mut Session, folder: TrackId, tracks: &[TrackId]) { + // Moving a folder also moves its complete subtree as one stable block. + let moved: Vec<_> = s + .tracks + .iter() + .filter(|track| track.id != folder && (tracks.contains(&track.id) || tracks.iter().any(|root| in_folder(s, track.id, *root)))) + .cloned() + .collect(); + let moved_ids: Vec<_> = moved.iter().map(|track| track.id).collect(); + s.tracks.retain(|track| !moved_ids.contains(&track.id)); + let ids: Vec = s.tracks.iter().map(|track| track.id).collect(); + let Some(folder_index) = ids.iter().position(|track| *track == folder) else { return }; + let mut insert_at = folder_index + 1; + while ids.get(insert_at).is_some_and(|track| in_folder(s, *track, folder)) { + insert_at += 1; + } + for (offset, track) in moved.into_iter().enumerate() { + s.tracks.insert((insert_at + offset).min(s.tracks.len()), track); + } +} + +fn folder_bus(s: &Session, folder: TrackId) -> Option { + s.track(folder).map(|folder| folder.mixer.input.clone()).filter(|route| matches!(route, Route::Bus(_))) +} + +/// Keep only the selected roots so selecting a folder and one of its children preserves the subtree. +fn root_tracks(s: &Session, tracks: Vec) -> Vec { + tracks.iter().copied().filter(|track| !tracks.iter().any(|root| root != track && in_folder(s, *track, *root))).collect() +} + +fn move_into_folder(e: &mut Engine, p: &Value) -> Result { + let folder = track_param(e, "track.move_to_folder", p, "folder")?.ok_or_else(|| bad("track.move_to_folder", "`folder` required"))?; + let tracks = tracks_required(e, "track.move_to_folder", p)?; + let session = e.session_mut(); + if !session.track(folder).is_some_and(Track::is_folder) { + return Err(bad("track.move_to_folder", "`folder` is not a folder track")); + } + // A folder cannot go inside itself or one of its descendants. + let tracks: Vec = tracks.into_iter().filter(|track| *track != folder && !in_folder(session, folder, *track)).collect(); + let tracks = root_tracks(session, tracks); + if tracks.is_empty() { + return Err(bad("track.move_to_folder", "nothing to move into that folder")); + } + let bus = folder_bus(session, folder); + for track in &tracks { + let old_bus = session.track(*track).and_then(|track| track.folder).and_then(|parent| folder_bus(session, parent)); + if let Some(moved) = session.track_mut(*track) { + moved.folder = Some(folder); + if let Some(bus) = &bus + && (moved.mixer.output == Route::Main || Some(&moved.mixer.output) == old_bus.as_ref()) + { + moved.mixer.output = bus.clone(); + } + } + } + place_under(session, folder, &tracks); + Ok(json!({"folder": folder, "moved": tracks.len()})) +} + +fn remove_from_folder(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.remove_from_folder", p)?; + let session = e.session_mut(); + let tracks = root_tracks(session, tracks); + let mut removed = 0; + for track in &tracks { + let Some(folder) = session.track(*track).and_then(|track| track.folder) else { continue }; + let bus = folder_bus(session, folder); + let parent = session.track(folder).and_then(|folder| folder.folder); + let parent_bus = parent.and_then(|parent| folder_bus(session, parent)); + if let Some(moved) = session.track_mut(*track) { + moved.folder = parent; + if bus.as_ref() == Some(&moved.mixer.output) { + moved.mixer.output = parent_bus.unwrap_or(Route::Main); + } + removed += 1; + } + let moved: Vec<_> = + session.tracks.iter().filter(|candidate| candidate.id == *track || in_folder(session, candidate.id, *track)).cloned().collect(); + let moved_ids: Vec<_> = moved.iter().map(|candidate| candidate.id).collect(); + session.tracks.retain(|candidate| !moved_ids.contains(&candidate.id)); + if let Some(folder_index) = session.track_index(folder) { + let mut insert_at = folder_index + 1; + while session.tracks.get(insert_at).is_some_and(|candidate| in_folder(session, candidate.id, folder)) { + insert_at += 1; + } + for (offset, moved) in moved.into_iter().enumerate() { + session.tracks.insert((insert_at + offset).min(session.tracks.len()), moved); + } + } + } + Ok(json!({"removed": removed})) +} + fn change_width(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "track.change_width", p)?; let f = str_param(p, "format").and_then(ChannelFormat::from_id).ok_or_else(|| bad("track.change_width", "`format` required"))?; @@ -429,16 +560,36 @@ fn change_width(e: &mut Engine, p: &Value) -> Result { fn delete(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "track.delete", p)?; let s = e.session_mut(); + // Promote children of deleted folders and repair only routes that used the deleted + // folder's own bus. Custom outputs remain untouched. + let mut promoted = Vec::new(); + for child in &s.tracks { + if tracks.contains(&child.id) { + continue; + } + let Some(folder) = child.folder.filter(|folder| tracks.contains(folder)) else { continue }; + let mut parent = s.track(folder).and_then(|folder| folder.folder); + for _ in 0..64 { + match parent { + Some(candidate) if tracks.contains(&candidate) => parent = s.track(candidate).and_then(|folder| folder.folder), + _ => break, + } + } + promoted.push((child.id, folder_bus(s, folder), parent, parent.and_then(|parent| folder_bus(s, parent)))); + } + for (child, old_bus, parent, parent_bus) in promoted { + if let Some(child) = s.track_mut(child) { + child.folder = parent; + if old_bus.as_ref() == Some(&child.mixer.output) { + child.mixer.output = parent_bus.unwrap_or(Route::Main); + } + } + } s.tracks.retain(|t| !tracks.contains(&t.id)); s.edit.selected_tracks.retain(|t| !tracks.contains(t)); for g in &mut s.groups { g.members.retain(|t| !tracks.contains(t)); } - for t in &mut s.tracks { - if t.folder.is_some_and(|f| tracks.contains(&f)) { - t.folder = None; - } - } Ok(json!({"deleted": tracks.len()})) } @@ -449,8 +600,19 @@ fn rename(e: &mut Engine, p: &Value) -> Result { if s.tracks.iter().any(|x| x.name == name && x.id != t) { return Err(bad("track.rename", format!("a track named `{name}` already exists"))); } - if let Some(tr) = s.track_mut(t) { - tr.name = name.chars().take(64).collect(); + let name: String = name.chars().take(64).collect(); + let bus = s.track(t).and_then(|track| match (track.kind, &track.mixer.input) { + (TrackKind::Folder, Route::Bus(bus)) => Some(*bus), + _ => None, + }); + if let Some(track) = s.track_mut(t) { + track.name.clone_from(&name); + } + if let Some(bus) = bus { + let bus_name = unique_folder_bus_name(s, &format!("{name} Bus"), Some(bus)); + if let Some(bus) = s.busses.iter_mut().find(|candidate| candidate.id == bus) { + bus.name = bus_name; + } } Ok(json!({"name": name})) } @@ -677,7 +839,72 @@ mod tests { assert!(r["bus"].is_u64()); let s = e.session(); let kick = s.track_by_name("Kick").unwrap(); + let snare = s.track_by_name("Snare").unwrap(); + let folder = s.tracks.iter().find(|track| track.kind == TrackKind::Folder).unwrap(); assert!(matches!(kick.mixer.output, Route::Bus(_))); - assert!(s.tracks.iter().any(|t| t.kind == TrackKind::Folder && t.mixer.input == kick.mixer.output)); + assert_eq!(folder.mixer.input, kick.mixer.output); + assert_eq!(folder.mixer.input, snare.mixer.output); + assert_eq!(s.track_index(folder.id).unwrap() + 1, s.track_index(kick.id).unwrap()); + assert_eq!(s.track_index(kick.id).unwrap() + 1, s.track_index(snare.id).unwrap()); + } + + #[test] + fn empty_folder_owns_a_unique_bus_and_renames_it() { + let mut e = crate::demo::demo_engine(); + let first = e.execute("track.new", &json!({"kind": "folder", "name": "Guitars"})).unwrap(); + let second = e.execute("track.new", &json!({"kind": "folder", "name": "Vocals"})).unwrap(); + let first = TrackId(first["tracks"][0].as_u64().unwrap()); + let second = TrackId(second["tracks"][0].as_u64().unwrap()); + let first_bus = match e.session().track(first).unwrap().mixer.input { + Route::Bus(bus) => bus, + _ => panic!("folder did not receive a bus"), + }; + let second_bus = match e.session().track(second).unwrap().mixer.input { + Route::Bus(bus) => bus, + _ => panic!("folder did not receive a bus"), + }; + assert_ne!(first_bus, second_bus); + e.session_mut().add_bus("Percussion Bus", ChannelFormat::Stereo); + e.execute("track.rename", &json!({"track": first.0, "name": "Percussion"})).unwrap(); + let renamed = e.session().busses.iter().find(|bus| bus.id == first_bus).unwrap(); + assert_eq!(renamed.name, "Percussion Bus 2"); + } + + #[test] + fn tracks_can_move_between_nested_folders_and_leave() { + let mut e = crate::demo::demo_engine(); + let outer = e.execute("track.new", &json!({"kind": "folder", "name": "Band"})).unwrap(); + let inner = e.execute("track.new", &json!({"kind": "folder", "name": "Drums"})).unwrap(); + let outer = TrackId(outer["tracks"][0].as_u64().unwrap()); + let inner = TrackId(inner["tracks"][0].as_u64().unwrap()); + + e.execute("track.move_to_folder", &json!({"tracks": ["Kick"], "folder": inner.0})).unwrap(); + e.execute("track.move_to_folder", &json!({"tracks": [inner.0], "folder": outer.0})).unwrap(); + assert!(e.execute("track.move_to_folder", &json!({"tracks": [outer.0], "folder": inner.0})).is_err()); + + let kick = e.session().track_by_name("Kick").unwrap(); + assert_eq!(kick.folder, Some(inner)); + assert_eq!(kick.mixer.output, e.session().track(inner).unwrap().mixer.input); + assert_eq!(e.session().track_index(outer).unwrap() + 1, e.session().track_index(inner).unwrap()); + assert_eq!(e.session().track_index(inner).unwrap() + 1, e.session().track_index(kick.id).unwrap()); + + e.execute("track.remove_from_folder", &json!({"tracks": ["Kick"]})).unwrap(); + let kick = e.session().track_by_name("Kick").unwrap(); + assert_eq!(kick.folder, Some(outer)); + assert_eq!(kick.mixer.output, e.session().track(outer).unwrap().mixer.input); + + e.execute("track.move_to_folder", &json!({"tracks": ["Kick"], "folder": inner.0})).unwrap(); + e.execute("track.remove_from_folder", &json!({"tracks": [inner.0]})).unwrap(); + let inner_track = e.session().track(inner).unwrap(); + let kick = e.session().track_by_name("Kick").unwrap(); + assert_eq!(inner_track.folder, None); + assert_eq!(inner_track.mixer.output, Route::Main); + assert_eq!(kick.folder, Some(inner)); + assert_eq!(e.session().track_index(inner).unwrap() + 1, e.session().track_index(kick.id).unwrap()); + + e.execute("track.delete", &json!({"tracks": [inner.0]})).unwrap(); + let kick = e.session().track_by_name("Kick").unwrap(); + assert_eq!(kick.folder, None); + assert_eq!(kick.mixer.output, Route::Main); } } diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f13bc78..7b38d0a 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -256,7 +256,32 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { } fn in_edit_window(s: &Session, t: &Track) -> bool { - !t.hidden && t.folder.and_then(|f| s.track(f)).is_none_or(|f| f.folder_open) + !t.hidden && !folder_collapsed(s, t) +} + +/// Hidden because a folder at any level above this track is closed. +fn folder_collapsed(s: &Session, track: &Track) -> bool { + let mut current = track.folder; + for _ in 0..64 { + let Some(folder) = current.and_then(|folder| s.track(folder)) else { return false }; + if !folder.folder_open { + return true; + } + current = folder.folder; + } + false +} + +fn folder_depth(s: &Session, track: &Track) -> usize { + let mut current = track.folder; + let mut depth = 0; + while let Some(folder) = current.and_then(|folder| s.track(folder)) + && depth < 6 + { + depth += 1; + current = folder.folder; + } + depth } /// Pro Tools' track-name click: Shift adds the tracks up to the nearest selected one, Cmd/Ctrl adds or removes one. @@ -795,7 +820,8 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele } bg.context_menu(|ui| track_context_menu(app, ui, id)); // Name field. - let name_r = Rect::from_min_size(pos2(x0, head.min.y + 4.0), vec2(150.0, 17.0)); + let indent = folder_depth(app.engine.session(), track) as f32 * 10.0; + let name_r = Rect::from_min_size(pos2(x0 + indent, head.min.y + 4.0), vec2(150.0 - indent, 17.0)); ui.painter().rect( name_r, CornerRadius::same(2), @@ -869,7 +895,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele } }); if track.is_folder() { - let fr = Rect::from_min_size(pos2(head.min.x + 6.0, name_r.min.y), vec2(10.0, 17.0)); + let fr = Rect::from_min_size(pos2(name_r.min.x - 12.0, name_r.min.y), vec2(10.0, 17.0)); icons::draw(ui.painter(), fr, if track.folder_open { "triangle_down" } else { "triangle_right" }, t.text); if ui.interact(fr, ui.id().with(("fold", id.0)), Sense::click()).clicked() { let _ = app.run("track.folder_toggle", json!({"track": id.0})); @@ -1093,21 +1119,45 @@ fn track_context_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { ("Move to New Folder...", "track.move_to_new_folder"), ("Group...", "track.group"), ]; + let selection: Vec = { + let selected = &app.engine.session().edit.selected_tracks; + if selected.contains(&id) { selected.iter().map(|track| track.0).collect() } else { vec![tid] } + }; for (label, cmd) in items { if ui.button(label).clicked() { if cmd == "ui.rename_track" { let name = app.engine.session().track(id).map(|t| t.name.clone()).unwrap_or_default(); app.dialogs.open_rename_track(id, &name); } else { - let _ = app.run(cmd, json!({"tracks": [tid]})); + let _ = app.run(cmd, json!({"tracks": selection})); } } } + let folders: Vec<(u64, String)> = app + .engine + .session() + .tracks + .iter() + .filter(|track| track.is_folder() && !selection.contains(&track.id.0)) + .map(|track| (track.id.0, track.name.clone())) + .collect(); + if !folders.is_empty() { + ui.menu_button("Move to Folder", |ui| { + for (folder, name) in &folders { + if ui.button(name).clicked() { + let _ = app.run("track.move_to_folder", json!({"tracks": selection, "folder": folder})); + } + } + }); + } + if app.engine.session().track(id).is_some_and(|track| track.folder.is_some()) && ui.button("Remove from Folder").clicked() { + let _ = app.run("track.remove_from_folder", json!({"tracks": selection})); + } ui.separator(); ui.menu_button("Track Height", |ui| { for h in TrackHeight::ALL { if ui.button(h.label()).clicked() { - let _ = app.run("track.height", json!({"tracks": [tid], "height": h.label()})); + let _ = app.run("track.height", json!({"tracks": selection, "height": h.label()})); } } }); @@ -1116,7 +1166,7 @@ fn track_context_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { let (r, resp) = ui.allocate_exact_size(vec2(120.0, 14.0), Sense::click()); ui.painter().rect_filled(r, 2.0, rgb(*c)); if resp.clicked() { - let _ = app.run("track.color", json!({"tracks": [tid], "color": i})); + let _ = app.run("track.color", json!({"tracks": selection, "color": i})); } } });