Merge pull request #219 from Jafz2001/feat/routing-folders

Complete routing folder management
This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-10-13 06:31:16 -04:00
commit 18dcb7b870
3 files changed
+295 -16

No files matched your search

+237 -10
View File
@@ -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<CommandSpec> {
vec![
@@ -45,6 +45,8 @@ pub fn specs() -> Vec<CommandSpec> {
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",
@@ -256,6 +258,14 @@ fn new_track(e: &mut Engine, p: &Value) -> Result<Value> {
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();
@@ -402,6 +412,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<soundcraft_model::BusId>) -> 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<Value> {
let tracks = tracks_required(e, "track.move_to_new_folder", p)?;
let name = str_param(p, "name").map(str::to_string);
@@ -415,7 +436,8 @@ fn move_to_folder(e: &mut Engine, p: &Value) -> Result<Value> {
}
// 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);
}
@@ -427,9 +449,118 @@ fn move_to_folder(e: &mut Engine, p: &Value) -> Result<Value> {
}
}
}
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<TrackId> = 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<Route> {
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<TrackId>) -> Vec<TrackId> {
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<Value> {
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<TrackId> = 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<Value> {
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<Value> {
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"))?;
@@ -446,16 +577,36 @@ fn change_width(e: &mut Engine, p: &Value) -> Result<Value> {
fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
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()}))
}
@@ -466,8 +617,19 @@ fn rename(e: &mut Engine, p: &Value) -> Result<Value> {
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}))
}
@@ -717,8 +879,73 @@ 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);
}
#[test]
+15 -2
View File
@@ -472,9 +472,22 @@ fn put_clips(s: &mut Session, placed: Vec<(TrackId, Clip)>) {
}
}
/// Whether the Edit window shows track `t`: not hidden, and not inside a closed folder.
/// Whether the Edit window shows track `t`: not hidden, and not inside a closed folder at any level.
pub fn shown(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
}
/// Where the tracks of clips `ids` go when track `from` goes to `to`: each keeps its distance
+43 -4
View File
@@ -299,6 +299,19 @@ pub(crate) fn height_targets(s: &Session, m: egui::Modifiers, own: &[TrackId]) -
s.tracks.iter().filter(|t| own.contains(&t.id) || (m.alt && extra(t))).map(|t| t.id.0).collect()
}
/// How many folders this track sits in, for the name's indent (at most 6 shown).
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.
pub(crate) fn click_track(app: &mut SoundApp, id: TrackId, mods: egui::Modifiers, shown: impl Fn(&Session, &Track) -> bool) {
let s = app.engine.session();
@@ -969,7 +982,8 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
}
}
// Name, highlighted when the track is selected, and the playlist menu beside it.
let name_r = at(16.0, 1.5, 97.0, 15.0);
let indent = folder_depth(app.engine.session(), track) as f32 * 10.0;
let name_r = at(16.0 + indent, 1.5, 97.0 - indent, 15.0);
if selected {
ui.painter().rect_filled(name_r, 2.0, t.name_field_sel);
}
@@ -1408,21 +1422,46 @@ pub(crate) 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<u64> = {
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 tracks = height_targets(app.engine.session(), ui.input(|i| i.modifiers), &[id]);
let own: Vec<TrackId> = selection.iter().map(|&t| TrackId(t)).collect();
let tracks = height_targets(app.engine.session(), ui.input(|i| i.modifiers), &own);
let _ = app.run("track.height", json!({"tracks": tracks, "height": h.label()}));
}
}
@@ -1432,7 +1471,7 @@ pub(crate) 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}));
}
}
});