Merge pull request #75 from grovebit/clip-list-delete
Clear clips and audio files from the Clip List (#4)
This commit is contained in:
commit
4e846b8f02
|
|
@ -185,10 +185,7 @@ fn elastic(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
let elastic = s.track(t).and_then(|x| x.elastic.clone()).filter(|a| a != "varispeed");
|
||||
// Start from the unwarped original if this clip was warped before.
|
||||
let key = |k: &str| format!("elastic.{}.{k}", id.0);
|
||||
let orig = match (s.edit.values.get(&key("src")), s.edit.values.get(&key("off")), s.edit.values.get(&key("len"))) {
|
||||
(Some(src), Some(off), Some(len)) => Some((soundcraft_model::SourceId(*src as u64), *off as i64, *len as i64)),
|
||||
_ => None,
|
||||
};
|
||||
let orig = warp_original(s, *id);
|
||||
let mut base = c.clone();
|
||||
if let Some((src, off, len)) = orig {
|
||||
base.content = soundcraft_model::ClipContent::Audio { source: src, offset: off };
|
||||
|
|
@ -229,12 +226,12 @@ fn remove_warp(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
let mut n = 0;
|
||||
for id in &ids {
|
||||
let key = |k: &str| format!("elastic.{}.{k}", id.0);
|
||||
let orig = (s.edit.values.get(&key("src")).copied(), s.edit.values.get(&key("off")).copied(), s.edit.values.get(&key("len")).copied());
|
||||
let orig = warp_original(s, *id);
|
||||
if let Some(c) = s.find_clip_mut(*id) {
|
||||
c.stretch = 1.0;
|
||||
if let (Some(src), Some(off), Some(len)) = orig {
|
||||
c.content = soundcraft_model::ClipContent::Audio { source: soundcraft_model::SourceId(src as u64), offset: off as i64 };
|
||||
c.length = (len as i64).max(1);
|
||||
if let Some((src, off, len)) = orig {
|
||||
c.content = soundcraft_model::ClipContent::Audio { source: src, offset: off };
|
||||
c.length = len.max(1);
|
||||
c.fade_out.len = c.fade_out.len.min(c.length);
|
||||
n += 1;
|
||||
}
|
||||
|
|
@ -246,6 +243,14 @@ fn remove_warp(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"restored": n}))
|
||||
}
|
||||
|
||||
/// The audio a warped clip was rendered from, while that file is still in the session.
|
||||
fn warp_original(s: &soundcraft_model::Session, id: ClipId) -> Option<(soundcraft_model::SourceId, Samples, Samples)> {
|
||||
let v = |k: &str| s.edit.values.get(&format!("elastic.{}.{k}", id.0)).copied();
|
||||
let src = soundcraft_model::SourceId(v("src")? as u64);
|
||||
s.source(src)?;
|
||||
Some((src, v("off")? as i64, v("len")? as i64))
|
||||
}
|
||||
|
||||
fn flag(e: &mut Engine, p: &Value, get: fn(&soundcraft_model::Clip) -> bool, set: fn(&mut soundcraft_model::Clip, bool)) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
|
|
@ -373,6 +378,20 @@ mod tests {
|
|||
assert_eq!(e.session().find_clip(ClipId(cid)).unwrap().1.length, len0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_warp_keeps_the_audio_when_the_original_file_is_gone() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let pad = e.session().track_by_name("Pad").map(|t| t.id).unwrap();
|
||||
e.execute("track.elastic", &json!({"track": pad, "algorithm": "polyphonic"})).unwrap();
|
||||
let clip = e.session().track(pad).unwrap().clips()[0].clone();
|
||||
e.execute("clip.elastic_properties", &json!({"clips": [clip.id], "ratio": 1.5})).unwrap();
|
||||
e.execute("clip.clear", &json!({"sources": [clip.source()]})).unwrap();
|
||||
let r = e.execute("clip.remove_warp", &json!({"clips": [clip.id]})).unwrap();
|
||||
assert_eq!(r["restored"], 0);
|
||||
let src = e.session().find_clip(clip.id).and_then(|(_, c)| c.source()).unwrap();
|
||||
assert!(e.session().source(src).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gain_nudges_one_db_at_a_time_up_to_the_ceiling() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
//! Clip List commands.
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use soundcraft_model::SourceId;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
const CLEAR: &str = "clip.clear";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"clip.clear",
|
||||
"Clear Clips",
|
||||
[],
|
||||
Some("Cmd+Shift+B"),
|
||||
"{clips?: [id], sources?: [id], with_clips?: bool} — removes clips and audio files from the session (files stay on disk); default: the Clip List selection. A file still used on the timeline needs `with_clips: true`, which removes those clips too",
|
||||
has_list_selection,
|
||||
clear
|
||||
)]
|
||||
}
|
||||
|
||||
fn has_list_selection(e: &Engine) -> std::result::Result<(), String> {
|
||||
let ed = &e.session().edit;
|
||||
if ed.selected_clips.is_empty() && ed.selected_sources.is_empty() { Err("select clips or files in the Clip List".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn ids<'a, T: serde::Deserialize<'a>>(p: &'a Value, key: &str) -> Result<Option<T>> {
|
||||
p.get(key).map(|v| T::deserialize(v).map_err(|_| bad(CLEAR, format!("`{key}` must be an array of ids")))).transpose()
|
||||
}
|
||||
|
||||
fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session();
|
||||
let (clips, sources): (BTreeSet<ClipId>, BTreeSet<SourceId>) = match (ids(p, "clips")?, ids(p, "sources")?) {
|
||||
(None, None) => (s.edit.selected_clips.iter().copied().collect(), s.edit.selected_sources.iter().copied().collect()),
|
||||
(c, f) => (c.unwrap_or_default(), f.unwrap_or_default()),
|
||||
};
|
||||
let known: BTreeSet<ClipId> = s.all_clips().map(|c| c.id).collect();
|
||||
if let Some(c) = clips.iter().find(|c| !known.contains(c)) {
|
||||
return Err(bad(CLEAR, format!("no clip with id {c}")));
|
||||
}
|
||||
let files = sources.iter().map(|f| s.source(*f).ok_or_else(|| bad(CLEAR, format!("no audio file with id {f}")))).collect::<Result<Vec<_>>>()?;
|
||||
if clips.is_empty() && files.is_empty() {
|
||||
return Err(bad(CLEAR, "nothing to clear: pass `clips` or `sources`, or select them in the Clip List"));
|
||||
}
|
||||
if !bool_or(p, "with_clips", false)
|
||||
&& let Some((f, n)) = files.iter().map(|f| (f, s.clips_using(f.id).filter(|c| !clips.contains(&c.id)).count())).find(|(_, n)| *n > 0)
|
||||
{
|
||||
let plural = if n == 1 { "" } else { "s" };
|
||||
return Err(bad(CLEAR, format!("`{}` is used by {n} clip{plural} on the timeline; pass `with_clips: true` to remove them too", f.name)));
|
||||
}
|
||||
let gone: BTreeSet<ClipId> = clips.iter().copied().chain(sources.iter().flat_map(|f| s.clips_using(*f).map(|c| c.id))).collect();
|
||||
let s = e.session_mut();
|
||||
for pl in s.tracks.iter_mut().flat_map(|t| t.playlists.iter_mut()) {
|
||||
pl.clips.retain(|c| !gone.contains(&c.id));
|
||||
}
|
||||
s.sources.retain(|f| !sources.contains(&f.id));
|
||||
for f in &sources {
|
||||
s.pool.remove(*f);
|
||||
}
|
||||
s.edit.selected_clips.retain(|c| !gone.contains(c));
|
||||
s.edit.selected_sources.retain(|f| !sources.contains(f));
|
||||
Ok(json!({"clips": gone.len(), "sources": sources.len()}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use soundcraft_audio_io::{AudioBuffer, FileFormat};
|
||||
|
||||
fn kick(e: &Engine) -> (SourceId, Vec<ClipId>) {
|
||||
let s = e.session();
|
||||
let f = s.sources.iter().find(|x| x.name == "Kick").map(|x| x.id).unwrap();
|
||||
(f, s.clips_using(f).map(|c| c.id).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_clip_takes_it_off_the_timeline_and_keeps_its_file() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let (f, clips) = kick(&e);
|
||||
let r = e.execute("clip.clear", &json!({"clips": [clips[0]]})).unwrap();
|
||||
assert_eq!(r, json!({"clips": 1, "sources": 0}));
|
||||
assert!(e.session().find_clip(clips[0]).is_none());
|
||||
assert!(e.session().source(f).is_some());
|
||||
assert!(e.undo());
|
||||
assert!(e.session().find_clip(clips[0]).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_in_use_stays_unless_its_clips_go_too() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
e.execute("track.playlist_duplicate", &json!({"track": "Kick"})).unwrap();
|
||||
let (f, clips) = kick(&e);
|
||||
assert_eq!(clips.len(), 6);
|
||||
let err = e.execute("clip.clear", &json!({"sources": [f]})).unwrap_err();
|
||||
assert!(err.to_string().contains("used by 6 clips"), "{err}");
|
||||
let r = e.execute("clip.clear", &json!({"sources": [f], "with_clips": true})).unwrap();
|
||||
assert_eq!(r, json!({"clips": 6, "sources": 1}));
|
||||
let s = e.session();
|
||||
assert!(s.source(f).is_none() && !s.pool.contains(f));
|
||||
assert_eq!(s.clips_using(f).count(), 0);
|
||||
assert_eq!(e.undo_label(), Some("Clear Clips"));
|
||||
assert!(e.undo());
|
||||
assert!(e.session().pool.contains(f));
|
||||
assert_eq!(e.session().clips_using(f).count(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_a_files_clips_too_is_enough() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let (f, clips) = kick(&e);
|
||||
let r = e.execute("clip.clear", &json!({"clips": clips, "sources": [f]})).unwrap();
|
||||
assert_eq!(r, json!({"clips": 3, "sources": 1}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_clip_list_selection_is_the_default_target() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 4_800]] };
|
||||
let f = crate::io::add_source(e.session_mut(), "Spare", buf, None, FileFormat::Wav);
|
||||
let (_, clips) = kick(&e);
|
||||
e.execute("edit.select", &json!({"clips": [clips[0]]})).unwrap();
|
||||
let r = e.execute("edit.select", &json!({"sources": [f, f, 999_999]})).unwrap();
|
||||
assert_eq!(r["sources"], json!([f]));
|
||||
assert_eq!(r["clips"], json!([]));
|
||||
let r = e.execute("edit.select", &json!({"clips": [clips[0]]})).unwrap();
|
||||
assert_eq!(r["sources"], json!([]));
|
||||
e.execute("clip.clear", &json!({})).unwrap();
|
||||
assert!(e.session().find_clip(clips[0]).is_none());
|
||||
assert!(e.session().edit.selected_clips.is_empty());
|
||||
e.execute("edit.select", &json!({"sources": [f]})).unwrap();
|
||||
e.execute("clip.clear", &json!({})).unwrap();
|
||||
assert!(e.session().source(f).is_none());
|
||||
assert!(e.session().edit.selected_sources.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_targets_are_errors() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"clips": []}),
|
||||
json!({"clips": [999_999]}),
|
||||
json!({"sources": [999_999]}),
|
||||
json!({"sources": "Kick"}),
|
||||
json!({"clips": [-1]}),
|
||||
] {
|
||||
assert!(e.execute("clip.clear", &p).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deselect_all_clears_the_clip_list_selection() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let (f, _) = kick(&e);
|
||||
e.execute("edit.select", &json!({"sources": [f]})).unwrap();
|
||||
e.execute("edit.select_none", &json!({})).unwrap();
|
||||
assert!(e.session().edit.selected_sources.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -49,11 +49,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
repeat_to_fill
|
||||
),
|
||||
cmd!(noundo "edit.select_all", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, select_all),
|
||||
cmd!(noundo "edit.select", "Set Edit Selection", [], None, "{tracks?: [id|name], start?, end?, clips?: [id], exact?: bool (ignore edit groups)}", always, select),
|
||||
cmd!(noundo "edit.select", "Set Edit Selection", [], None, "{tracks?: [id|name], start?, end?, clips?: [id], sources?: [id] (Clip List files), exact?: bool (ignore edit groups)}", always, select),
|
||||
cmd!(noundo "edit.select_none", "Deselect All", [], None, "{}", always, |e, _| {
|
||||
let s = e.session_mut();
|
||||
s.edit.selected_tracks.clear();
|
||||
s.edit.selected_clips.clear();
|
||||
s.edit.selected_sources.clear();
|
||||
s.edit.selection.end = s.edit.selection.start;
|
||||
Ok(json!({}))
|
||||
}),
|
||||
|
|
@ -351,6 +352,9 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
let en = position_param(e, "edit.select", p, "end")?;
|
||||
let clips =
|
||||
p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::ClipId).collect::<Vec<_>>());
|
||||
let sources =
|
||||
p.get("sources").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::SourceId).collect::<Vec<_>>());
|
||||
let list_changes = clips.is_some() || sources.is_some() || st.is_some();
|
||||
let exact = bool_or(p, "exact", false);
|
||||
let s = e.session_mut();
|
||||
if let Some(mut t) = tracks {
|
||||
|
|
@ -390,13 +394,17 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
s.edit.selected_tracks = trs;
|
||||
}
|
||||
s.edit.selected_clips = c;
|
||||
} else if st.is_some() {
|
||||
} else if list_changes {
|
||||
s.edit.selected_clips.clear();
|
||||
}
|
||||
if list_changes {
|
||||
let mut known: std::collections::BTreeSet<_> = s.sources.iter().map(|f| f.id).collect();
|
||||
s.edit.selected_sources = sources.unwrap_or_default().into_iter().filter(|id| known.remove(id)).collect();
|
||||
}
|
||||
if s.edit.link_timeline_edit {
|
||||
s.edit.timeline_selection = s.edit.selection;
|
||||
}
|
||||
Ok(json!({"selection": s.edit.selection, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips}))
|
||||
Ok(json!({"selection": s.edit.selection, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips, "sources": s.edit.selected_sources}))
|
||||
}
|
||||
|
||||
fn shift_selection(e: &mut Engine, dir: i64) -> Result<Value> {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ mod audio_midi;
|
|||
mod audiosuite;
|
||||
mod clap;
|
||||
mod clip;
|
||||
mod clip_list;
|
||||
mod clip_more;
|
||||
mod edit;
|
||||
mod edit_more;
|
||||
|
|
@ -116,6 +117,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(view_more::specs());
|
||||
v.extend(track_more::specs());
|
||||
v.extend(clip_more::specs());
|
||||
v.extend(clip_list::specs());
|
||||
v.extend(event_more::specs());
|
||||
v.extend(setup_more::specs());
|
||||
v.extend(clap::specs());
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ impl Engine {
|
|||
if let Err(reason) = (spec.enabled)(self) {
|
||||
// A call that names its targets (tracks, clips, a range) does not need a selection;
|
||||
// the command itself validates them.
|
||||
let names_targets = ["track", "tracks", "clip", "clips", "start", "end", "at"].iter().any(|k| params.get(k).is_some());
|
||||
let names_targets = ["track", "tracks", "clip", "clips", "sources", "start", "end", "at"].iter().any(|k| params.get(k).is_some());
|
||||
if !names_targets {
|
||||
return Err(EngineError::Disabled(id.to_string(), reason));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,6 +160,9 @@ pub struct EditState {
|
|||
/// Clips explicitly selected (Grabber selection).
|
||||
#[serde(default)]
|
||||
pub selected_clips: Vec<ClipId>,
|
||||
/// Audio files selected in the Clip List.
|
||||
#[serde(default)]
|
||||
pub selected_sources: Vec<SourceId>,
|
||||
/// Playhead position when stopped.
|
||||
pub playhead: Samples,
|
||||
pub grid: GridValue,
|
||||
|
|
@ -234,6 +237,7 @@ impl Default for EditState {
|
|||
selection: Range::default(),
|
||||
selected_tracks: Vec::new(),
|
||||
selected_clips: Vec::new(),
|
||||
selected_sources: Vec::new(),
|
||||
playhead: 0,
|
||||
grid: GridValue::default(),
|
||||
nudge: GridValue::Seconds(1.0),
|
||||
|
|
@ -403,6 +407,15 @@ impl Session {
|
|||
self.tracks.iter_mut().find_map(|t| t.playlist_mut().and_then(|p| p.clip_mut(id)))
|
||||
}
|
||||
|
||||
/// Clips on every playlist, alternates included.
|
||||
pub fn all_clips(&self) -> impl Iterator<Item = &Clip> {
|
||||
self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| &p.clips))
|
||||
}
|
||||
|
||||
pub fn clips_using(&self, src: SourceId) -> impl Iterator<Item = &Clip> {
|
||||
self.all_clips().filter(move |c| c.source() == Some(src))
|
||||
}
|
||||
|
||||
/// End of the last clip on any track (session length).
|
||||
pub fn content_end(&self) -> Samples {
|
||||
self.tracks.iter().filter_map(|t| t.playlist().map(|p| p.end())).max().unwrap_or(0)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use crate::theme::{Tokens, bold};
|
|||
use egui::{Align2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_model::{ClipId, TrackId};
|
||||
use soundcraft_model::{ClipId, Session, SourceId, TrackId};
|
||||
use soundcraft_time::Samples;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -15,6 +15,7 @@ pub enum Dialog {
|
|||
Bounce { path: String, format: String, bit_depth: String, normalize: bool },
|
||||
RenameTrack { id: TrackId, name: String },
|
||||
RenameClip { id: ClipId, name: String },
|
||||
ClearClips { clips: Vec<ClipId>, sources: Vec<SourceId>, message: String },
|
||||
PathPrompt { cmd: String, title: String, path: String, key: String },
|
||||
Number { cmd: String, title: String, key: String, value: f64, suffix: String },
|
||||
TempoChange { at: Samples, bpm: f64 }, // from double-clicking the tempo ruler
|
||||
|
|
@ -38,6 +39,7 @@ impl Dialogs {
|
|||
Dialog::Bounce { .. } => "bounce",
|
||||
Dialog::RenameTrack { .. } => "rename_track",
|
||||
Dialog::RenameClip { .. } => "rename_clip",
|
||||
Dialog::ClearClips { .. } => "clear_clips",
|
||||
Dialog::PathPrompt { .. } => "path",
|
||||
Dialog::Number { .. } => "number",
|
||||
Dialog::TempoChange { .. } => "tempo_change",
|
||||
|
|
@ -57,11 +59,22 @@ impl Dialogs {
|
|||
self.open = Some(Dialog::RenameClip { id, name: name.to_string() });
|
||||
}
|
||||
|
||||
/// Clip List › Clear: confirm before clips and files leave the session. False when there is nothing to clear.
|
||||
pub fn open_clear(&mut self, e: &Engine, clips: Vec<ClipId>, sources: Vec<SourceId>) -> bool {
|
||||
let Some(message) = clear_message(e.session(), &clips, &sources) else { return false };
|
||||
self.open = Some(Dialog::ClearClips { clips, sources, message });
|
||||
true
|
||||
}
|
||||
|
||||
/// Open the dialog for a command; false when the command has no dialog.
|
||||
pub fn open_for_command(&mut self, e: &Engine, id: &str) -> bool {
|
||||
let home = default_dir();
|
||||
let name = e.session().name.clone();
|
||||
self.open = Some(match id {
|
||||
"clip.clear" => {
|
||||
let ed = &e.session().edit;
|
||||
return self.open_clear(e, ed.selected_clips.clone(), ed.selected_sources.clone());
|
||||
}
|
||||
"track.new" => {
|
||||
Dialog::NewTracks { count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() }
|
||||
}
|
||||
|
|
@ -146,6 +159,41 @@ fn path_dialog(id: &str, home: &str, name: &str) -> Option<Dialog> {
|
|||
Some(Dialog::PathPrompt { cmd: id.into(), title, path, key: key.into() })
|
||||
}
|
||||
|
||||
fn clear_message(s: &Session, clips: &[ClipId], sources: &[SourceId]) -> Option<String> {
|
||||
let listed: std::collections::BTreeSet<ClipId> = clips.iter().copied().collect();
|
||||
let names: Vec<&str> = s.tracks.iter().flat_map(|t| t.clips()).filter(|c| listed.contains(&c.id)).map(|c| c.name.as_str()).collect();
|
||||
let files: Vec<_> = sources.iter().filter_map(|id| s.source(*id)).collect();
|
||||
let count = |n: usize, what: &str| if n == 1 { format!("1 {what}") } else { format!("{n} {what}s") };
|
||||
let what = match (names.as_slice(), files.as_slice()) {
|
||||
([], []) => return None,
|
||||
([name], []) => format!("the clip \"{name}\""),
|
||||
([], [f]) => format!("the file \"{}\"", f.name),
|
||||
(c, []) => count(c.len(), "clip"),
|
||||
([], f) => count(f.len(), "file"),
|
||||
(c, f) => format!("{} and {}", count(c.len(), "clip"), count(f.len(), "file")),
|
||||
};
|
||||
let mut text = format!("Remove {what} from the session?");
|
||||
let used: Vec<(&str, usize)> =
|
||||
files.iter().map(|f| (f.name.as_str(), s.clips_using(f.id).filter(|c| !listed.contains(&c.id)).count())).filter(|(_, n)| *n > 0).collect();
|
||||
let n: usize = used.iter().map(|u| u.1).sum();
|
||||
let users = if n == 1 {
|
||||
"a clip on the timeline, which is removed too.".to_string()
|
||||
} else {
|
||||
format!("{n} clips on the timeline, which are removed too.")
|
||||
};
|
||||
match used.as_slice() {
|
||||
[] => {}
|
||||
[_] if files.len() == 1 => text += &format!(" It is used by {users}"),
|
||||
u if u.len() == files.len() => text += &format!(" They are used by {users}"),
|
||||
[(name, _)] => text += &format!(" \"{name}\" is used by {users}"),
|
||||
many => text += &format!(" {} of the files are used by {users}", many.len()),
|
||||
}
|
||||
if !files.is_empty() && files.iter().all(|f| !f.unsaved) {
|
||||
text += if files.len() == 1 { " The file stays on disk." } else { " The files stay on disk." };
|
||||
}
|
||||
Some(text)
|
||||
}
|
||||
|
||||
fn default_dir() -> String {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
|
|
@ -169,6 +217,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
Dialog::Bounce { .. } => "Bounce Mix",
|
||||
Dialog::RenameTrack { .. } => "Rename Track",
|
||||
Dialog::RenameClip { .. } => "Rename Clip",
|
||||
Dialog::ClearClips { .. } => "Clear",
|
||||
Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(),
|
||||
Dialog::TempoChange { .. } => "Tempo Change (BPM)",
|
||||
Dialog::Fades { .. } => "Fades",
|
||||
|
|
@ -286,6 +335,13 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
action = Some(("clip.rename".into(), json!({"clip": id.0, "name": name})));
|
||||
}
|
||||
}
|
||||
Dialog::ClearClips { clips, sources, message } => {
|
||||
ui.set_max_width(340.0);
|
||||
ui.label(message.as_str());
|
||||
if buttons(ui, "Remove", enter) {
|
||||
action = Some(("clip.clear".into(), json!({"clips": clips, "sources": sources, "with_clips": true})));
|
||||
}
|
||||
}
|
||||
Dialog::PathPrompt { cmd, path, key, .. } => {
|
||||
ui.add(egui::TextEdit::singleline(path).desired_width(420.0));
|
||||
if let Some(pick) = &app.services.pick_open
|
||||
|
|
@ -542,4 +598,36 @@ mod tests {
|
|||
assert_eq!(params["at"], 96_000);
|
||||
assert_eq!(params["bpm"], 133.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_always_asks_and_names_the_timeline_clips_that_go_too() {
|
||||
let mut e = soundcraft_engine::demo::demo_engine();
|
||||
let file = |e: &Engine, name: &str| e.session().sources.iter().find(|f| f.name == name).map(|f| f.id).unwrap();
|
||||
let (kick, hats) = (file(&e, "Kick"), file(&e, "Hats"));
|
||||
let kick_clips: Vec<ClipId> = e.session().clips_using(kick).map(|c| c.id).collect();
|
||||
let snare = e.session().track_by_name("Snare").unwrap().clips()[0].id;
|
||||
let message = |d: &Dialogs| match &d.open {
|
||||
Some(Dialog::ClearClips { message, .. }) => message.clone(),
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
let mut d = Dialogs::default();
|
||||
assert!(!d.open_for_command(&e, "clip.clear"));
|
||||
e.execute("edit.select", &json!({"clips": [snare]})).unwrap();
|
||||
assert!(d.open_for_command(&e, "clip.clear"));
|
||||
assert_eq!(message(&d), "Remove the clip \"Snare\" from the session?");
|
||||
e.execute("edit.select", &json!({"sources": [kick]})).unwrap();
|
||||
assert!(d.open_for_command(&e, "clip.clear"));
|
||||
assert!(matches!(&d.open, Some(Dialog::ClearClips { clips, sources, .. }) if clips.is_empty() && *sources == vec![kick]));
|
||||
assert_eq!(message(&d), "Remove the file \"Kick\" from the session? It is used by 3 clips on the timeline, which are removed too.");
|
||||
assert!(d.open_clear(&e, kick_clips.clone(), vec![kick]));
|
||||
assert_eq!(message(&d), "Remove 3 clips and 1 file from the session?");
|
||||
assert!(d.open_clear(&e, kick_clips, vec![kick, hats]));
|
||||
assert_eq!(message(&d), "Remove 3 clips and 2 files from the session? \"Hats\" is used by a clip on the timeline, which is removed too.");
|
||||
e.session_mut().sources.iter_mut().for_each(|f| f.unsaved = false);
|
||||
assert!(d.open_clear(&e, vec![], vec![kick, hats]));
|
||||
assert_eq!(
|
||||
message(&d),
|
||||
"Remove 2 files from the session? They are used by 4 clips on the timeline, which are removed too. The files stay on disk."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ pub const AUDIOSUITE: &[(&str, &str)] = &[
|
|||
|
||||
/// Commands that open a dialog when invoked from a menu (programmatic calls never do).
|
||||
const DIALOG_COMMANDS: &[&str] = &[
|
||||
"clip.clear",
|
||||
"track.new",
|
||||
"file.bounce_mix",
|
||||
"session.new",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use crate::SoundApp;
|
|||
use crate::theme::{Tokens, bold, mono, regular, rgb};
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{ClipId, SourceId};
|
||||
use soundcraft_time::format_position;
|
||||
|
||||
fn panel_header(ui: &mut Ui, title: &str) {
|
||||
|
|
@ -100,51 +101,58 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
|
|||
pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let t = Tokens::current();
|
||||
panel_header(ui, "CLIPS");
|
||||
let items: Vec<(Option<u64>, String, bool, [u8; 3])> = {
|
||||
let items: Vec<(u64, String, bool, [u8; 3], bool)> = {
|
||||
let s = app.engine.session();
|
||||
let mut v: Vec<(Option<u64>, String, bool, [u8; 3])> = Vec::new();
|
||||
let mut v = Vec::new();
|
||||
// Whole files first (bold), then clips on tracks.
|
||||
for src in &s.sources {
|
||||
v.push((Some(src.id.0), format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150]));
|
||||
v.push((src.id.0, format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150], s.edit.selected_sources.contains(&src.id)));
|
||||
}
|
||||
for tr in &s.tracks {
|
||||
for c in tr.clips() {
|
||||
v.push((Some(c.id.0), c.name.clone(), false, c.color.unwrap_or(tr.color)));
|
||||
v.push((c.id.0, c.name.clone(), false, c.color.unwrap_or(tr.color), s.edit.selected_clips.contains(&c.id)));
|
||||
}
|
||||
}
|
||||
v
|
||||
};
|
||||
let selected: Vec<u64> = app.engine.session().edit.selected_clips.iter().map(|c| c.0).collect();
|
||||
let fkey = egui::Id::new("clip_filter");
|
||||
let mut filter: String = ui.ctx().memory(|m| m.data.get_temp(fkey)).unwrap_or_default();
|
||||
ui.add(egui::TextEdit::singleline(&mut filter).hint_text("🔍 Find clips").desired_width(f32::INFINITY));
|
||||
ui.ctx().memory_mut(|m| m.data.insert_temp(fkey, filter.clone()));
|
||||
let needle = filter.to_lowercase();
|
||||
let items: Vec<_> = items.into_iter().filter(|(_, n, _, _)| needle.is_empty() || n.to_lowercase().contains(&needle)).collect();
|
||||
let items: Vec<_> = items.into_iter().filter(|(_, n, _, _, _)| needle.is_empty() || n.to_lowercase().contains(&needle)).collect();
|
||||
egui::ScrollArea::vertical().id_salt("clips_scroll").auto_shrink([false, false]).show(ui, |ui| {
|
||||
for (id, name, whole, color) in items {
|
||||
for (id, name, whole, color, selected) in items {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), if whole { Sense::click_and_drag() } else { Sense::click() });
|
||||
if whole && let Some(src) = id {
|
||||
resp.context_menu(|ui| {
|
||||
resp.context_menu(|ui| {
|
||||
if whole {
|
||||
if ui.button("Place on New Track").clicked() {
|
||||
let _ = app.run("clip.place_source", json!({"source": src}));
|
||||
let _ = app.run("clip.place_source", json!({"source": id}));
|
||||
ui.close();
|
||||
}
|
||||
let sel_track = app.engine.session().edit.selected_tracks.first().map(|t| t.0);
|
||||
if let Some(t) = sel_track
|
||||
&& ui.button("Place on Selected Track").clicked()
|
||||
{
|
||||
let _ = app.run("clip.place_source", json!({"source": src, "track": t}));
|
||||
let _ = app.run("clip.place_source", json!({"source": id, "track": t}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
}
|
||||
if ui.button("Clear…").clicked() {
|
||||
let (clips, sources) = if whole { (vec![], vec![SourceId(id)]) } else { (vec![ClipId(id)], vec![]) };
|
||||
app.dialogs.open_clear(&app.engine, clips, sources);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if whole {
|
||||
// Whole files drag onto tracks.
|
||||
resp.dnd_set_drag_payload(crate::DragSource(src));
|
||||
resp.dnd_set_drag_payload(crate::DragSource(id));
|
||||
if resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
}
|
||||
}
|
||||
if !whole && id.is_some_and(|i| selected.contains(&i)) {
|
||||
if selected {
|
||||
ui.painter().rect_filled(r, 0.0, t.row_selected);
|
||||
}
|
||||
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 4.0), vec2(9.0, 9.0)), 1.0, rgb(color));
|
||||
|
|
@ -155,11 +163,8 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
|
|||
if whole { bold(11.0) } else { regular(11.0) },
|
||||
t.text,
|
||||
);
|
||||
if resp.clicked()
|
||||
&& !whole
|
||||
&& let Some(i) = id
|
||||
{
|
||||
let _ = app.run("edit.select", json!({"clips": [i]}));
|
||||
if resp.clicked() {
|
||||
let _ = app.run("edit.select", if whole { json!({"sources": [id]}) } else { json!({"clips": [id]}) });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue