diff --git a/crates/engine/src/cmd/audio_midi.rs b/crates/engine/src/cmd/audio_midi.rs index 786e66d..218e395 100644 --- a/crates/engine/src/cmd/audio_midi.rs +++ b/crates/engine/src/cmd/audio_midi.rs @@ -40,7 +40,7 @@ fn audio_as_midi(e: &mut Engine, p: &Value) -> Result { seq.sort(); let count = seq.notes.len(); let clip = Clip::midi(ClipId(0), "Audio as MIDI", 0, r.len(), seq); - e.clipboard = Clipboard { length: r.len(), tracks: vec![vec![clip]], automation: vec![Vec::new()], ..Clipboard::default() }; + e.clipboard = Clipboard { tracks: vec![vec![clip]], automation: vec![Vec::new()], ..Clipboard::of_range(e.session(), r) }; Ok(json!({"notes": count, "length": r.len()})) } diff --git a/crates/engine/src/cmd/clip.rs b/crates/engine/src/cmd/clip.rs index 06d9cd5..9d68dc2 100644 --- a/crates/engine/src/cmd/clip.rs +++ b/crates/engine/src/cmd/clip.rs @@ -153,8 +153,13 @@ pub fn specs() -> Vec { Some(_) => return Err(bad("clip.place_source", "audio files go on audio tracks")), None => s.add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::for_channels(chans.min(2)), Some(&name)), }; + let (sh, len) = (s.edit.edit_mode == soundcraft_model::EditMode::Shuffle, frames.max(1)); + let at = if sh { crate::edit::shuffle_slot(s, t, at, &[]) } else { at }; + if sh { + crate::edit::make_room(s, t, at, len).map_err(|why| bad("clip.place_source", why))?; + } let id = s.new_clip_id(); - crate::edit::place_clip(s, t, soundcraft_model::Clip::audio(id, name, src, 0, at, frames.max(1))); + crate::edit::place_clip(s, t, soundcraft_model::Clip::audio(id, name, src, 0, at, len)); Ok(json!({"clip": id, "track": t})) } ), @@ -302,7 +307,7 @@ fn loop_clips(e: &mut Engine, p: &Value) -> Result { copies.push((c.start + c.length * k, nc)); } for (at, nc) in copies { - crate::edit::paste(s, tid, at, &[nc], c.length, false); + let _ = crate::edit::paste(s, tid, at, &[nc], c.length, false); made += 1; } } @@ -346,14 +351,11 @@ fn nudge_gain(e: &mut Engine, p: &Value, db: f64) -> Result { fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result { let ids = clip_ids_param(e, p); let s = e.session(); - let moves: Vec<(soundcraft_model::ClipId, i64)> = ids - .iter() - .filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, c.start + c.sync_point))) - .map(|(id, anchor)| (id, s.edit.grid.snap(anchor, s.sample_rate, &s.tempo, s.frame_rate) - anchor)) - .collect(); + let moves: Vec<(soundcraft_model::ClipId, i64)> = + ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, crate::edit::grid_delta(s, c, 0, false)))).collect(); let s = e.session_mut(); for (id, d) in &moves { - crate::edit::move_clips(s, &[*id], *d, None); + crate::edit::move_clips(s, &[*id], *d, &[]); } Ok(json!({"clips": moves.len()})) } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 99dea42..641596b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1,5 +1,6 @@ //! Edit menu commands. +use super::more_util::toggle_flag; use super::*; use crate::edit as ops; use crate::{Clipboard, cmd}; @@ -133,11 +134,33 @@ pub fn specs() -> Vec { cmd!("edit.fade_to_start", "Fade to Start", ["Edit", "Fades"], Some("Cmd+Ctrl+D"), "{}", has_selection, |e, p| fade_to(e, p, true)), cmd!("edit.fade_to_end", "Fade to End", ["Edit", "Fades"], Some("Cmd+Ctrl+G"), "{}", has_selection, |e, p| fade_to(e, p, false)), cmd!("edit.nudge", "Nudge", [], Some("Plus/Minus"), "{direction: 1|-1, count?: n, next?: bool (next larger value)}", has_selection, nudge), - cmd!(noundo "edit.mode", "Edit Mode", [], Some("F1-F4"), "{mode: shuffle|slip|spot|grid|relative_grid}", always, |e, p| { - let m = str_param(p, "mode").and_then(EditMode::from_id).ok_or_else(|| bad("edit.mode", "mode must be shuffle|slip|spot|grid|relative_grid"))?; - e.session_mut().edit.edit_mode = m; - Ok(json!({"mode": m})) - }), + cmd!( + noundo "edit.mode", + "Edit Mode", + [], + Some("F1-F4, Alt+1-4, `"), + "{mode: shuffle|slip|spot|grid|relative_grid, snap_to_grid?: bool (Shuffle, Slip and Spot)}", + always, + set_mode + ), + cmd!( + noundo "edit.snap_to_grid", + "Snap to Grid", + [], + Some("Shift+F4"), + "{value?: bool} (toggles when omitted) — in Shuffle, Slip and Spot the Edit cursor and selections snap to the grid", + always, + snap_to_grid + ), + cmd!( + noundo "edit.shuffle_lock", + "Shuffle Lock", + [], + None, + "{value?: bool} (toggles when omitted) — keeps Shuffle mode from being chosen", + always, + shuffle_lock + ), cmd!(noundo "edit.tool", "Edit Tool", [], Some("F5-F10"), "{tool: zoom|trim|selector|grabber|scrubber|pencil|smart}", always, |e, p| { let t = str_param(p, "tool").and_then(soundcraft_model::Tool::from_id).ok_or_else(|| bad("edit.tool", "unknown tool"))?; e.session_mut().edit.tool = t; @@ -148,10 +171,19 @@ pub fn specs() -> Vec { "Move Clips", [], None, - "{clips?: [id], by?: samples, to?: position (first clip start), track?: target}", + "{clips?: [id], by?: samples, to?: position for the first clip that can move (Grid modes snap its sync point), track?: where the first clip's track goes; the others keep their distance}", has_selection, move_clips ), + cmd!( + "edit.trim_clip", + "Trim Clip", + [], + None, + "{clip: id, start?: position, end?: position} — moves the clip's edges like the Trim tool, in the current Edit mode", + always, + trim_clip + ), cmd!("edit.consolidate", "Consolidate", ["Edit"], Some("Alt+Shift+3"), "{tracks?, start?, end?}", has_range, consolidate), ] } @@ -160,6 +192,35 @@ fn shuffle(s: &Session) -> bool { s.edit.edit_mode == EditMode::Shuffle } +fn set_mode(e: &mut Engine, p: &Value) -> Result { + let m = str_param(p, "mode").and_then(EditMode::from_id).ok_or_else(|| bad("edit.mode", "mode must be shuffle|slip|spot|grid|relative_grid"))?; + if m == EditMode::Shuffle && e.session().edit.flag(ops::SHUFFLE_LOCK) { + return Err(bad("edit.mode", "Shuffle is locked")); + } + let snap = bool_or(p, "snap_to_grid", false) && !m.is_grid(); + let ed = &mut e.session_mut().edit; + ed.edit_mode = m; + ed.set_flag(ops::SNAP_TO_GRID, snap); + if m.is_grid() { + ed.set_flag(ops::RELATIVE_GRID, m == EditMode::GridRelative); + } + Ok(json!({"mode": m, "snap_to_grid": snap})) +} + +fn snap_to_grid(e: &mut Engine, p: &Value) -> Result { + if e.session().edit.edit_mode.is_grid() { + return Err(bad("edit.snap_to_grid", "Grid mode always snaps")); + } + toggle_flag(e, p, ops::SNAP_TO_GRID) +} + +fn shuffle_lock(e: &mut Engine, p: &Value) -> Result { + if shuffle(e.session()) && bool_or(p, "value", true) { + return Err(bad("edit.shuffle_lock", "leave Shuffle mode before locking it")); + } + toggle_flag(e, p, ops::SHUFFLE_LOCK) +} + fn has_last_selection(e: &Engine) -> std::result::Result<(), String> { if e.last_selection.is_some() { Ok(()) } else { Err("no previous selection to restore".into()) } } @@ -190,7 +251,7 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result { if range.is_empty() { return Err(bad(id, "selection is empty")); } - let mut cb = Clipboard { length: range.len(), ..Clipboard::default() }; + let mut cb = Clipboard::of_range(e.session(), range); let follow = e.session().edit.automation_follows_edit; for t in &tracks { cb.tracks.push(ops::copy_range(e.session(), *t, range)); @@ -232,15 +293,7 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result { let sh = shuffle(e.session()); let s = e.session_mut(); for t in &tracks { - ops::clear_range(s, *t, range, sh); - if follow && let Some(tr) = s.track_mut(*t) { - for l in &mut tr.automation { - l.clear_range(range.start, range.end); - if sh { - l.shift_from(range.end, -range.len()); - } - } - } + cut_track(s, *t, range, sh, follow); } s.edit.selection = soundcraft_time::Range::point(range.start); } @@ -249,17 +302,39 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result { Ok(json!({"clips": n, "length": range.len()})) } +/// Remove `r` from a track, closing the gap in Shuffle mode; automation follows when `follow`. +fn cut_track(s: &mut Session, t: TrackId, r: soundcraft_time::Range, sh: bool, follow: bool) { + if follow && let Some(tr) = s.track_mut(t) { + for l in &mut tr.automation { + l.clear_range(r.start, r.end); + } + } + ops::clear_range(s, t, r, sh); +} + fn paste(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "edit.paste", p)?; - let at = position_param(e, "edit.paste", p, "at")?.unwrap_or(e.session().edit.selection.start); + let given = position_param(e, "edit.paste", p, "at")?; let cb = e.clipboard.clone(); - let sh = shuffle(e.session()); - let follow = e.session().edit.automation_follows_edit; + let s = e.session(); + let at = match given { + Some(at) => at, + None if s.edit.edit_mode == EditMode::GridRelative => { + s.edit.grid.line_at_or_before(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate).saturating_add(cb.grid_offset) + } + None => s.edit.selection.start, + }; + let sh = shuffle(s) && !cb.automation_only; + let follow = s.edit.automation_follows_edit; + let replace = Some(s.edit.selection).filter(|r| sh && given.is_none() && !r.is_empty()); let s = e.session_mut(); let mut pasted = 0; for (i, t) in tracks.iter().enumerate() { let Some(clips) = cb.tracks.get(i % cb.tracks.len().max(1)) else { continue }; - pasted += ops::paste(s, *t, at, clips, cb.length, sh).len(); + if let Some(r) = replace { + cut_track(s, *t, r, true, follow); + } + pasted += ops::paste(s, *t, at, clips, cb.length, sh).map_err(|why| bad("edit.paste", why))?.len(); if follow && let (Some(lanes), Some(tr)) = (cb.automation.get(i % cb.automation.len().max(1)), s.track_mut(*t)) { for l in lanes { let dst = tr.lane_mut(&l.param); @@ -356,7 +431,7 @@ fn repeat_to_fill(e: &mut Engine, p: &Value) -> Result { c }) .collect(); - ops::paste(s, *t, at, &trimmed, (range.end - at).min(len), false); + let _ = ops::paste(s, *t, at, &trimmed, (range.end - at).min(len), false); } } at += len; @@ -485,12 +560,14 @@ fn duplicate(e: &mut Engine, p: &Value) -> Result { if range.is_empty() { return Err(bad("edit.duplicate", "selection is empty")); } + let sh = shuffle(e.session()); + let follow = e.session().edit.automation_follows_edit; let s = e.session_mut(); let mut n = 0; for t in &tracks { let clips = ops::copy_range(s, *t, range); - n += ops::paste(s, *t, range.end, &clips, range.len(), false).len(); - if let Some(tr) = s.track_mut(*t) { + n += ops::paste(s, *t, range.end, &clips, range.len(), sh).map_err(|why| bad("edit.duplicate", why))?.len(); + if follow && let Some(tr) = s.track_mut(*t) { for l in &mut tr.automation { let pts: Vec<_> = l.points.iter().filter(|p| range.contains(p.at)).copied().collect(); for pt in pts { @@ -510,11 +587,13 @@ fn repeat(e: &mut Engine, p: &Value) -> Result { if range.is_empty() { return Err(bad("edit.repeat", "selection is empty")); } + let sh = shuffle(e.session()); let s = e.session_mut(); for t in &tracks { let clips = ops::copy_range(s, *t, range); for k in 1..=count { - ops::paste(s, *t, range.start + range.len() * k, &clips, range.len(), false); + let at = range.start.saturating_add(range.len().saturating_mul(k)); + ops::paste(s, *t, at, &clips, range.len(), sh).map_err(|why| bad("edit.repeat", why))?; } } Ok(json!({"repeats": count})) @@ -525,19 +604,25 @@ fn shift(e: &mut Engine, p: &Value) -> Result { let later = bool_or(p, "later", true); let ids = clip_ids_param(e, p); let s = e.session_mut(); - let n = ops::move_clips(s, &ids, if later { by } else { -by }, None); + let n = ops::move_clips(s, &ids, if later { by } else { -by }, &[]); Ok(json!({"moved": n})) } fn insert_silence(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "edit.insert_silence", p)?; let range = range_param(e, "edit.insert_silence", p)?; + let sh = shuffle(e.session()); + let follow = e.session().edit.automation_follows_edit; let s = e.session_mut(); for t in &tracks { - ops::insert_silence(s, *t, range); - if let Some(tr) = s.track_mut(*t) { - for l in &mut tr.automation { - l.shift_from(range.start, range.len()); + if sh { + ops::make_room(s, *t, range.start, range.len()).map_err(|why| bad("edit.insert_silence", why))?; + } else { + ops::clear_range(s, *t, range, false); + if follow && let Some(tr) = s.track_mut(*t) { + for l in &mut tr.automation { + l.clear_range(range.start, range.end); + } } } } @@ -764,7 +849,7 @@ fn nudge(e: &mut Engine, p: &Value) -> Result { let step = value.step_samples(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate) * count * if dir == 0 { 1 } else { dir }; let ids = clip_ids_param(e, p); let s = e.session_mut(); - let n = if ids.is_empty() { 0 } else { ops::move_clips(s, &ids, step, None) }; + let n = if ids.is_empty() { 0 } else { ops::move_clips(s, &ids, step, &[]) }; s.edit.selection = s.edit.selection.shifted(step); if s.edit.selection.start < 0 { s.edit.selection = s.edit.selection.shifted(-s.edit.selection.start); @@ -777,26 +862,48 @@ fn move_clips(e: &mut Engine, p: &Value) -> Result { if ids.is_empty() { return Err(bad("edit.move_clips", "no clips")); } - let to_track = track_param(e, "edit.move_clips", p, "track")?; - let first = ids.iter().filter_map(|id| e.session().find_clip(*id).map(|(_, c)| c.start)).min().unwrap_or(0); + let s = e.session(); + let dest = match track_param(e, "edit.move_clips", p, "track")? { + Some(to) => ids + .iter() + .find_map(|c| s.find_clip(*c)) + .and_then(|(from, _)| ops::destinations(s, &ids, from, to)) + .ok_or_else(|| bad("edit.move_clips", "the clips can't land there: past the last track, or on a track that can't hold them"))?, + None => Vec::new(), + }; + let first = ops::first_movable(s, &ids); let delta = match (position_param(e, "edit.move_clips", p, "by")?, position_param(e, "edit.move_clips", p, "to")?) { (Some(by), _) => by, - (None, Some(to)) => to - first, + (None, Some(to)) => to.saturating_sub(first.map_or(0, |c| c.start)), _ => 0, }; - // Grid mode snaps the destination. - let s = e.session(); - let delta = if s.edit.edit_mode.is_grid() && p.get("to").is_some() { - s.edit.grid.snap(first + delta, s.sample_rate, &s.tempo, s.frame_rate) - first - } else { - delta + let mode = s.edit.edit_mode; + let delta = match first { + Some(c) if mode.is_grid() && p.get("to").is_some() => ops::grid_delta(s, c, delta, mode == EditMode::GridRelative), + _ => delta, }; let s = e.session_mut(); - let n = ops::move_clips(s, &ids, delta, to_track); + let n = if mode == EditMode::Shuffle { + ops::shuffle_clips(s, &ids, delta, &dest).map_err(|why| bad("edit.move_clips", why))? + } else { + ops::move_clips(s, &ids, delta, &dest) + }; s.edit.selected_clips = ids; Ok(json!({"moved": n, "by": delta})) } +fn trim_clip(e: &mut Engine, p: &Value) -> Result { + let id = clip_id(p, "edit.trim_clip")?; + let start = position_param(e, "edit.trim_clip", p, "start")?; + let end = position_param(e, "edit.trim_clip", p, "end")?; + if start.is_none() && end.is_none() { + return Err(bad("edit.trim_clip", "pass `start` and/or `end`")); + } + let sh = shuffle(e.session()); + let c = ops::trim_clip(e.session_mut(), id, start, end, sh).ok_or_else(|| bad("edit.trim_clip", "no such clip, or it is edit-locked"))?; + Ok(json!({"start": c.start, "end": c.end()})) +} + fn consolidate(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "edit.consolidate", p)?; let r = range_param(e, "edit.consolidate", p)?; diff --git a/crates/engine/src/cmd/edit_more.rs b/crates/engine/src/cmd/edit_more.rs index fba10d5..fdd265c 100644 --- a/crates/engine/src/cmd/edit_more.rs +++ b/crates/engine/src/cmd/edit_more.rs @@ -290,7 +290,7 @@ pub(super) fn cut_all_automation(e: &mut Engine, p: &Value) -> Result { fn auto_copy(e: &mut Engine, p: &Value, which: Lanes, cut: bool, id: &str) -> Result { let tracks = tracks_required(e, id, p)?; let r = nonempty_range(e, p, id)?; - let mut cb = Clipboard { length: r.len(), ..Clipboard::default() }; + let mut cb = Clipboard { automation_only: true, ..Clipboard::of_range(e.session(), r) }; let mut lanes_n = 0; for t in &tracks { cb.tracks.push(Vec::new()); @@ -551,12 +551,13 @@ fn duplicate_extend(e: &mut Engine, p: &Value) -> Result { let id = "edit.duplicate_extend"; let tracks = tracks_required(e, id, p)?; let r = nonempty_range(e, p, id)?; + let (sh, follow) = (shuffle(e.session()), e.session().edit.automation_follows_edit); let s = e.session_mut(); let mut n = 0; for t in &tracks { let clips = ops::copy_range(s, *t, r); - n += ops::paste(s, *t, r.end, &clips, r.len(), false).len(); - if let Some(tr) = s.track_mut(*t) { + n += ops::paste(s, *t, r.end, &clips, r.len(), sh).map_err(|why| bad(id, why))?.len(); + if follow && let Some(tr) = s.track_mut(*t) { for l in &mut tr.automation { let pts: Vec<_> = l.points.iter().filter(|pt| r.contains(pt.at)).copied().collect(); for pt in pts { @@ -683,7 +684,7 @@ fn snap(e: &mut Engine, p: &Value, next: bool) -> Result { others.iter().filter(|x| x.end() <= c.start).map(Clip::end).max().map(|en| en - c.start) }; if let Some(d) = delta.filter(|d| *d != 0) { - moved += ops::move_clips(s, &[id], d, None); + moved += ops::move_clips(s, &[id], d, &[]); } } Ok(json!({"moved": moved})) diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs index e4b8123..205f038 100644 --- a/crates/engine/src/cmd/event.rs +++ b/crates/engine/src/cmd/event.rs @@ -176,7 +176,8 @@ fn cut_time(e: &mut Engine, p: &Value) -> Result { let s = e.session_mut(); let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect(); for t in ids { - crate::edit::clear_range(s, t, r, true); + crate::edit::clear_range(s, t, r, false); + crate::edit::shift_from(s, t, r.end, -r.len()); if let Some(tr) = s.track_mut(t) { for l in &mut tr.automation { l.clear_range(r.start, r.end); diff --git a/crates/engine/src/cmd/midi.rs b/crates/engine/src/cmd/midi.rs index c442ecb..10bcd76 100644 --- a/crates/engine/src/cmd/midi.rs +++ b/crates/engine/src/cmd/midi.rs @@ -26,10 +26,6 @@ pub fn specs() -> Vec { ] } -fn clip_id(p: &Value, cmd: &str) -> Result { - p.get("clip").and_then(Value::as_u64).map(ClipId).ok_or_else(|| bad(cmd, "`clip` id required")) -} - fn seq_mut<'a>(e: &'a mut Engine, id: ClipId, cmd: &str) -> Result<&'a mut Sequence> { match e.session_mut().find_clip_mut(id).map(|c| &mut c.content) { Some(ClipContent::Midi { sequence }) => Ok(sequence), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 2c50f30..68bc459 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -301,6 +301,10 @@ pub fn range_param(e: &Engine, cmd: &str, p: &Value) -> Result { } } +pub fn clip_id(p: &Value, cmd: &str) -> Result { + p.get("clip").and_then(Value::as_u64).map(ClipId).ok_or_else(|| bad(cmd, "`clip` id required")) +} + pub fn clip_ids_param(e: &Engine, p: &Value) -> Vec { if let Some(arr) = p.get("clips").and_then(Value::as_array) { return arr.iter().filter_map(Value::as_u64).map(ClipId).collect(); diff --git a/crates/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs index 00c80db..eeaa3c1 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -177,7 +177,7 @@ pub fn specs() -> Vec { tr.active_playlist = active; } let _ = src; - let ids = crate::edit::paste(s, t, r.start, &clips, r.len(), false); + let ids = crate::edit::paste(s, t, r.start, &clips, r.len(), false).unwrap_or_default(); Ok(json!({"clips": ids})) } ), diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 74806d4..5fb435c 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -5,8 +5,16 @@ //! Placing a clip overwrites whatever is under it (like the incumbent's Slip mode); Shuffle //! mode also ripples later clips. -use soundcraft_model::{Clip, ClipContent, ClipId, Fade, FadeShape, Session, TrackId}; +use soundcraft_model::{AutoParam, AutomationPoint, Clip, ClipContent, ClipId, Fade, FadeShape, Session, Track, TrackId}; use soundcraft_time::{Range, SampleRate, Samples, TempoMap}; +use std::collections::{HashMap, HashSet}; + +/// Edit-state flag: the Edit cursor and selections snap to the grid in Shuffle, Slip and Spot. +pub const SNAP_TO_GRID: &str = "edit.snap_to_grid"; +/// Edit-state flag: Shuffle mode can't be chosen. +pub const SHUFFLE_LOCK: &str = "edit.shuffle_lock"; +/// Edit-state flag: the Grid mode last used was Relative, so choosing Grid again picks it. +pub const RELATIVE_GRID: &str = "edit.relative_grid"; /// Context needed to split MIDI clips in musical time. #[derive(Clone, Copy)] @@ -101,8 +109,9 @@ pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize { count } -/// Remove material inside `range` on a track. In shuffle mode later clips move left. -/// Returns the removed material as clips positioned relative to `range.start`. +/// Remove material inside `range` on a track. In Shuffle mode later clips move left, up to the +/// first locked clip (see [`ripple`]). Returns the removed material as clips positioned relative +/// to `range.start`. pub fn clear_range(s: &mut Session, track: TrackId, range: Range, shuffle: bool) -> Vec { if range.is_empty() { return Vec::new(); @@ -122,14 +131,9 @@ pub fn clear_range(s: &mut Session, track: TrackId, range: Range, shuffle: bool) } }); if shuffle { - let len = range.len(); - for c in &mut pl.clips { - if c.start >= range.end { - c.start -= len; - } - } + let moved = ripple(s, track, range.end, -range.len()); + follow_automation(s, track, range.end, moved); } - pl.sort(); removed } @@ -156,18 +160,11 @@ pub fn copy_range(s: &Session, track: TrackId, range: Range) -> Vec { } /// Insert `clips` (positions relative to 0) at `at`. Overwrites what's underneath unless -/// `shuffle`, which first pushes later material right by `length`. -pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], length: Samples, shuffle: bool) -> Vec { +/// `shuffle`, which first makes room for `length` (see [`make_room`], whose refusal it returns). +pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], length: Samples, shuffle: bool) -> Result, &'static str> { let length = length.max(clips.iter().map(Clip::end).max().unwrap_or(0)); if shuffle { - separate_at(s, track, at); - if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) { - for c in &mut pl.clips { - if c.start >= at { - c.start += length; - } - } - } + make_room(s, track, at, length)?; } let mut ids = Vec::new(); for c in clips { @@ -177,7 +174,7 @@ pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], lengt ids.push(nc.id); place_clip(s, track, nc); } - ids + Ok(ids) } /// Place one clip, overwriting whatever it covers. @@ -189,21 +186,146 @@ pub fn place_clip(s: &mut Session, track: TrackId, clip: Clip) { } } -/// Insert silence: shift material at/after `range.start` right by the range length. +/// Insert silence: shift material at/after `range.start` right by the range length (Insert Time; +/// Shuffle mode edits use [`make_room`]). pub fn insert_silence(s: &mut Session, track: TrackId, range: Range) { if range.is_empty() { return; } separate_at(s, track, range.start); + shift_from(s, track, range.start, range.len()); +} + +/// Move every clip that starts at or after `from` by `by`, locked or not. +pub fn shift_from(s: &mut Session, track: TrackId, from: Samples, by: Samples) { if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) { - for c in &mut pl.clips { - if c.start >= range.start { - c.start += range.len(); - } + for c in pl.clips.iter_mut().filter(|c| c.start >= from) { + c.start = c.start.saturating_add(by); } } } +/// Time- or edit-locked: the clip doesn't move, and Shuffle stops at it. +pub fn locked(c: &Clip) -> bool { + c.time_locked || c.edit_locked +} + +/// A clip's place on its track, for working out Shuffle layouts without touching the clips. +#[derive(Clone, Copy)] +struct Span { + id: ClipId, + start: Samples, + end: Samples, + locked: bool, +} + +fn spans(clips: &[Clip]) -> Vec { + clips.iter().map(|c| Span { id: c.id, start: c.start, end: c.end(), locked: locked(c) }).collect() +} + +/// Shuffle mode's ripple: the spans from `from` on move by `by`, up to the first locked one, which +/// stays put with everything after it. A move left stops at the session start, keeping their +/// spacing. Returns how far they moved and where the lock is (`Samples::MAX` when none). +fn ripple_spans(spans: &mut [Span], from: Samples, by: Samples) -> (Samples, Samples) { + let lock = spans.iter().filter(|c| c.locked && c.start >= from).map(|c| c.start).min().unwrap_or(Samples::MAX); + let moves = |c: &Span| c.start >= from && c.start < lock; + let floor = spans.iter().filter(|c| moves(c)).map(|c| c.start).min().unwrap_or(0).max(0); + let by = by.max(-floor); + for c in spans.iter_mut().filter(|c| moves(c)) { + c.start = c.start.saturating_add(by); + c.end = c.end.saturating_add(by); + } + (by, lock) +} + +/// [`ripple_spans`] on a track's clips. +fn ripple(s: &mut Session, track: TrackId, from: Samples, by: Samples) -> (Samples, Samples) { + let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return (0, Samples::MAX) }; + let mut sp = spans(&pl.clips); + let moved = ripple_spans(&mut sp, from, by); + for (c, n) in pl.clips.iter_mut().zip(&sp) { + c.start = n.start; + } + pl.sort(); + moved +} + +/// Automation Follows Edit for a Shuffle ripple (`moved` from [`ripple`]): the points from `from` +/// up to the lock move with the clips, and when material closes up the points in the span that +/// goes away go too. +fn follow_automation(s: &mut Session, track: TrackId, from: Samples, (by, lock): (Samples, Samples)) { + if !s.edit.automation_follows_edit || by == 0 { + return; + } + let Some(t) = s.track_mut(track) else { return }; + for l in &mut t.automation { + if by < 0 { + l.clear_range(from.saturating_add(by), from); + } + for p in l.points.iter_mut().filter(|p| p.at >= from && p.at < lock) { + p.at = p.at.saturating_add(by); + } + l.points.sort_by_key(|p| p.at); + l.points.dedup_by_key(|p| p.at); + } +} + +/// With Automation Follows Edit, take the points in `[start, end)` off a track, timed from `origin`. +fn take_automation(s: &mut Session, track: TrackId, start: Samples, end: Samples, origin: Samples) -> Vec<(AutoParam, AutomationPoint)> { + let mut out = Vec::new(); + if !s.edit.automation_follows_edit || end <= start { + return out; + } + let Some(t) = s.track_mut(track) else { return out }; + for l in &mut t.automation { + let taken = l.points.iter().filter(|p| p.at >= start && p.at < end); + out.extend(taken.map(|p| (l.param.clone(), AutomationPoint { at: p.at.saturating_sub(origin), value: p.value }))); + l.clear_range(start, end); + } + out +} + +fn put_automation(s: &mut Session, track: TrackId, origin: Samples, points: &[(AutoParam, AutomationPoint)]) { + let Some(t) = s.track_mut(track) else { return }; + for (param, p) in points { + t.lane_mut(param).set_point(origin.saturating_add(p.at), p.value); + } +} + +/// How far Shuffle mode can push the material from `at` on before it runs into a locked clip +/// (`Samples::MAX` when none follows). +pub fn shuffle_room(s: &Session, track: TrackId, at: Samples) -> Samples { + let clips = s.track(track).map(|t| t.clips()).unwrap_or_default(); + let Some(lock) = clips.iter().filter(|c| locked(c) && c.end() > at).map(|c| c.start).min() else { return Samples::MAX }; + let end = clips.iter().filter(|c| c.end() > at && c.start < lock).map(Clip::end).fold(at, Samples::max); + lock.saturating_sub(end) +} + +/// Why Shuffle can't make room: a locked clip follows too closely. +pub const NO_ROOM: &str = "not enough room before a locked clip"; +/// Why Shuffle can't make room: the point is inside a crossfade, which can't be pulled apart. +pub const IN_CROSSFADE: &str = "can't make room inside a crossfade"; + +/// Shuffle mode's room for `len` at `at`: later clips (and with Automation Follows Edit their +/// automation) move right. Refused, with nothing changed, when a locked clip is in the way or +/// `at` is inside a crossfade. +pub fn make_room(s: &mut Session, track: TrackId, at: Samples, len: Samples) -> Result<(), &'static str> { + if len <= 0 { + return Ok(()); + } + let clips = s.track(track).map(|t| t.clips()).unwrap_or_default(); + if clips.iter().filter(|c| c.start <= at && at < c.end()).count() > 1 { + return Err(IN_CROSSFADE); + } + if shuffle_room(s, track, at) < len { + return Err(NO_ROOM); + } + separate_at(s, track, at); + let moved = ripple(s, track, at, len); + follow_automation(s, track, at, moved); + Ok(()) +} + /// Trim clips to keep only material inside `range`. pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize { let tempo = s.tempo.clone(); @@ -280,14 +402,16 @@ pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize { healed } -/// Move clips by `delta` (and optionally to another track), overwriting what they land on. -pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, to_track: Option) -> usize { +/// Move clips by `delta`, overwriting what they land on; `dest` sends whole tracks of them +/// elsewhere (see [`destinations`]). +pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(TrackId, TrackId)]) -> usize { + let ids: HashSet = ids.iter().copied().collect(); let mut moving: Vec<(TrackId, Clip)> = Vec::new(); for t in &mut s.tracks { let tid = t.id; if let Some(pl) = t.playlist_mut() { pl.clips.retain(|c| { - if ids.contains(&c.id) && !c.time_locked && !c.edit_locked { + if ids.contains(&c.id) && !locked(c) { moving.push((tid, c.clone())); false } else { @@ -297,13 +421,298 @@ pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, to_track: Opt } } let n = moving.len(); - for (tid, mut c) in moving { + let placed = moving.into_iter().map(|(tid, mut c)| { c.start = c.start.saturating_add(delta).max(0); - place_clip(s, to_track.unwrap_or(tid), c); - } + (lands(dest, tid), c) + }); + put_clips(s, placed.collect()); n } +/// Place moved clips, overwriting what they cover but not each other, so a crossfaded pair +/// moved together stays a crossfade. +fn put_clips(s: &mut Session, placed: Vec<(TrackId, Clip)>) { + for (t, c) in &placed { + clear_range(s, *t, c.range(), false); + } + for (t, c) in placed { + if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) { + pl.clips.push(c); + pl.sort(); + } + } +} + +/// Whether the Edit window shows track `t`: not hidden, and not inside a closed folder. +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) +} + +/// Where the tracks of clips `ids` go when track `from` goes to `to`: each keeps its distance +/// from it among the tracks the Edit window shows, or among all tracks when a hidden one is +/// involved. None when one would land past either end, or on a track that can't hold its clips. +pub fn destinations(s: &Session, ids: &[ClipId], from: TrackId, to: TrackId) -> Option> { + let ids: HashSet = ids.iter().copied().collect(); + let holds = |t: &Track| t.clips().iter().any(|c| ids.contains(&c.id)); + let visible = s.tracks.iter().filter(|t| t.id == from || t.id == to || holds(t)).all(|t| shown(s, t)); + let rows: Vec<&Track> = s.tracks.iter().filter(|t| !visible || shown(s, t)).collect(); + let row = |id: TrackId| rows.iter().position(|t| t.id == id).and_then(|i| isize::try_from(i).ok()); + let shift = row(to)? - row(from)?; + s.tracks + .iter() + .filter(|t| holds(t)) + .map(|t| { + let d = rows.get(usize::try_from(row(t.id)? + shift).ok()?)?; + (d.kind.has_playlist() && (d.kind == t.kind || d.kind.is_midi() && t.kind.is_midi())).then_some((t.id, d.id)) + }) + .collect() +} + +/// The track that clips on `t` land on under a [`destinations`] map. +pub fn lands(dest: &[(TrackId, TrackId)], t: TrackId) -> TrackId { + dest.iter().find(|(from, _)| *from == t).map_or(t, |(_, d)| *d) +} + +/// The earliest of clips `ids` that can move (neither time- nor edit-locked). +pub fn first_movable<'a>(s: &'a Session, ids: &[ClipId]) -> Option<&'a Clip> { + ids.iter().filter_map(|id| s.find_clip(*id)).map(|(_, c)| c).filter(|c| !locked(c)).min_by_key(|c| c.start) +} + +/// One Shuffle lift: where the hole the clip leaves starts and how long it is, how far the clips +/// after it actually close up (none past a lock, none before the session start) and where the +/// first locked clip after it is. +#[derive(Clone, Copy)] +struct Lift { + id: ClipId, + start: Samples, + from: Samples, + shift: Samples, + by: Samples, + lock: Samples, +} + +/// Lifting the movable `moving` clips off a track's `spans` the Shuffle way, right to left, in +/// one pass: the lifts in that order, and the spans that stay at the starts they close up to. +/// A hole starts where the clips before it end (a crossfade partner keeps its material) and +/// closes up to the next clip, so silence after the lifted clip stays. Clips are ordered by +/// start, then id, so of two stacked clips the later one counts as after the other. +fn lift_plan(mut sp: Vec, moving: &HashSet) -> (Vec, Vec) { + sp.sort_by_key(|c| (c.start, c.id)); + let lifted = |c: &Span| !c.locked && moving.contains(&c.id); + let reach: Vec = std::iter::once(Samples::MIN) + .chain(sp.iter().scan(Samples::MIN, |far, c| { + *far = (*far).max(c.end); + Some(*far) + })) + .collect(); + let mut lifts = Vec::new(); + let (mut next, mut next_moves, mut lock) = (None, false, Samples::MAX); + for (i, c) in sp.iter().enumerate().rev() { + if !lifted(c) { + (next, next_moves) = (Some(c.start), !c.locked); + if c.locked { + lock = c.start; + } + continue; + } + let from = reach.get(i).map_or(c.start, |far| c.start.max(*far)); + let shift = next.unwrap_or(c.end).min(c.end).saturating_sub(from).max(0); + let by = if next_moves { -shift.min(next.unwrap_or(0).max(0)) } else { 0 }; + next = next.map(|n| n.saturating_add(by)); + lifts.push(Lift { id: c.id, start: c.start, from, shift, by, lock }); + } + let (mut by, mut pending) = (0, lifts.iter().rev()); + let mut staying = Vec::with_capacity(sp.len() - lifts.len()); + for c in sp { + if lifted(&c) { + by += pending.next().map_or(0, |l| l.by); + } else if c.locked { + by = 0; + staying.push(c); + } else { + staying.push(Span { start: c.start.saturating_add(by), end: c.end.saturating_add(by), ..c }); + } + } + (lifts, staying) +} + +/// Where Shuffle can land material on `track` once the movable `moving` clips have left it (later +/// clips close each gap): the clip boundaries, never inside a clip or a crossfade, sorted. Empty +/// for an empty track. +pub fn shuffle_slots(s: &Session, track: TrackId, moving: &[ClipId]) -> Vec { + let clips = s.track(track).map(|t| t.clips()).unwrap_or_default(); + let (_, mut sp) = lift_plan(spans(clips), &moving.iter().copied().collect()); + sp.sort_by_key(|c| c.start); + let reach: Vec = sp + .iter() + .scan(Samples::MIN, |far, c| { + *far = (*far).max(c.end); + Some(*far) + }) + .collect(); + let inside = |b: Samples| reach.get(sp.partition_point(|c| c.start < b).wrapping_sub(1)).is_some_and(|far| *far > b); + let mut slots: Vec = sp.iter().flat_map(|c| [c.start, c.end]).filter(|b| !inside(*b)).collect(); + slots.sort_unstable(); + slots.dedup(); + slots +} + +/// The slot nearest `at` (the earlier one on a tie), or the session start when there are none. +pub fn nearest_slot(slots: &[Samples], at: Samples) -> Samples { + let i = slots.partition_point(|b| *b < at); + [i.checked_sub(1), Some(i)].into_iter().flatten().filter_map(|i| slots.get(i)).copied().min_by_key(|b| b.abs_diff(at)).unwrap_or(0) +} + +/// Where Shuffle mode lands material dropped at `at` on `track` (see [`shuffle_slots`]). +pub fn shuffle_slot(s: &Session, track: TrackId, at: Samples, moving: &[ClipId]) -> Samples { + nearest_slot(&shuffle_slots(s, track, moving), at) +} + +/// Lift the movable `moving` clips off a track the Shuffle way (see [`lift_plan`]); with +/// Automation Follows Edit their automation leaves with them. Returns the clips, each with that +/// automation timed from its start. +fn lift_all(s: &mut Session, track: TrackId, moving: &HashSet) -> Vec<(Clip, Vec<(AutoParam, AutomationPoint)>)> { + let Some(clips) = s.track(track).map(|t| spans(t.clips())) else { return Vec::new() }; + let (lifts, staying) = lift_plan(clips, moving); + let mut points: HashMap> = HashMap::new(); + for l in &lifts { + points.insert(l.id, take_automation(s, track, l.from, l.from.saturating_add(l.shift), l.start)); + follow_automation(s, track, l.from.saturating_add(l.shift), (l.by, l.lock)); + } + let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return Vec::new() }; + let starts: HashMap = staying.iter().map(|c| (c.id, c.start)).collect(); + let (out, kept): (Vec, Vec) = std::mem::take(&mut pl.clips).into_iter().partition(|c| points.contains_key(&c.id)); + pl.clips = kept; + for c in &mut pl.clips { + if let Some(start) = starts.get(&c.id) { + c.start = *start; + } + } + pl.sort(); + out.into_iter() + .map(|c| { + let p = points.remove(&c.id).unwrap_or_default(); + (c, p) + }) + .collect() +} + +/// The movable clips `ids` grouped by the track they land on (per `dest`): each destination and +/// the start of its earliest clip, which Shuffle lands on a slot; the others keep their distance. +pub fn shuffle_groups(s: &Session, ids: &[ClipId], dest: &[(TrackId, TrackId)]) -> Vec<(TrackId, Samples)> { + let ids: HashSet = ids.iter().copied().collect(); + let mut groups: Vec<(TrackId, Samples)> = Vec::new(); + for t in &s.tracks { + for c in t.clips().iter().filter(|c| ids.contains(&c.id) && !locked(c)) { + let d = lands(dest, t.id); + match groups.iter_mut().find(|g| g.0 == d) { + Some(g) => g.1 = g.1.min(c.start), + None => groups.push((d, c.start)), + } + } + } + groups +} + +/// Move clips in Shuffle mode: each leaves its track (later clips close the gap, see +/// [`lift_plan`]), and the clips bound for each track land together, the earliest on the +/// [`shuffle_slot`] nearest `delta` away, pushing later clips right. With Automation Follows Edit +/// their automation travels with them. Refused, with the reason, when a locked clip or a +/// crossfade leaves no room. +pub fn shuffle_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(TrackId, TrackId)]) -> Result { + let groups: Vec<(TrackId, Samples, Samples)> = + shuffle_groups(s, ids, dest).into_iter().map(|(d, first)| (d, first, shuffle_slot(s, d, first.saturating_add(delta), ids))).collect(); + let moving: HashSet = ids.iter().copied().collect(); + let sources: Vec = s.tracks.iter().filter(|t| t.clips().iter().any(|c| moving.contains(&c.id) && !locked(c))).map(|t| t.id).collect(); + let mut lifted = Vec::new(); + for t in sources { + lifted.extend(lift_all(s, t, &moving).into_iter().map(|(c, points)| (lands(dest, t), c, points))); + } + for (d, first, at) in groups { + let block: Vec<&(TrackId, Clip, Vec<(AutoParam, AutomationPoint)>)> = lifted.iter().filter(|l| l.0 == d).collect(); + let len = block.iter().map(|l| l.1.end()).max().unwrap_or(first).saturating_sub(first); + make_room(s, d, at, len)?; + let mut placed = Vec::new(); + for (_, c, points) in block { + let mut c = c.clone(); + c.start = at.saturating_add(c.start.saturating_sub(first)); + put_automation(s, d, c.start, points); + placed.push(c); + } + if let Some(pl) = s.track_mut(d).and_then(|t| t.playlist_mut()) { + pl.clips.extend(placed); + pl.sort(); + } + } + Ok(lifted.len()) +} + +/// How far a Grabber drag by `raw` moves `clip` in Grid mode: Absolute puts its sync point (its +/// start when it has none inside the clip) on the nearest grid line; Relative moves it by whole +/// grid steps, keeping its offset from the grid. +pub fn grid_delta(s: &Session, clip: &Clip, raw: Samples, relative: bool) -> Samples { + let (g, sr, map, rate) = (&s.edit.grid, s.sample_rate, &s.tempo, s.frame_rate); + let sync = if (0..clip.length).contains(&clip.sync_point) { clip.sync_point } else { 0 }; + let anchor = clip.start.saturating_add(sync); + let from = if relative { g.line_at_or_before(anchor, sr, map, rate) } else { anchor }; + g.snap(from.saturating_add(raw), sr, map, rate).saturating_sub(from) +} + +/// Trim a clip's edges to `start` and/or `end` like the Trim tool, never past its file. In +/// Shuffle mode the start stays put (the material slides under it) and later clips follow the +/// change, up to a locked clip; otherwise a trim stops at the neighbouring clips. +pub fn trim_clip(s: &mut Session, id: ClipId, start: Option, end: Option, shuffle: bool) -> Option { + let (track, old) = s.find_clip(id).filter(|(_, c)| !c.edit_locked).map(|(t, c)| (t, c.clone()))?; + let (mut lo, mut hi) = (Samples::MIN, Samples::MAX); + if shuffle { + let room = shuffle_room(s, track, old.end()).max(0); + lo = old.start.saturating_sub(room); + hi = old.end().saturating_add(room); + } else { + let others = s.track(track).map(|t| t.clips()).unwrap_or_default().iter().filter(|c| c.id != id); + if let Some(prev) = others.clone().filter(|c| c.start < old.start).map(Clip::end).max() { + lo = lo.max(prev.min(old.start)); + } + if let Some(next) = others.filter(|c| c.start > old.start).map(|c| c.start).min() { + hi = hi.min(next.max(old.end())); + } + lo = lo.max(0); + } + let file = |c: &Clip| { + let start = c.start.saturating_sub(c.source_offset()); + material_len(s, c).map(|n| (start.min(c.start), start.saturating_add(n).max(c.end()))) + }; + let t = TimeCtx::of(s); + let mut c = old.clone(); + if let Some(at) = start { + c.trim_start_to(at.max(file(&c).map_or(lo, |(f, _)| lo.max(f)))); + rebase_midi_start(&mut c, old.start, t); + if shuffle { + c.start = old.start; + } + } + if let Some(at) = end { + c.trim_end_to(at.min(file(&c).map_or(hi, |(_, f)| hi.min(f)))); + } + let pl = s.track_mut(track).and_then(|t| t.playlist_mut())?; + *pl.clip_mut(id)? = c.clone(); + pl.sort(); + if shuffle { + let moved = ripple(s, track, old.start.saturating_add(1), c.end().saturating_sub(old.end())); + follow_automation(s, track, old.end(), moved); + } + Some(c) +} + +/// Length of the file or movie under a clip; None for MIDI, which has no end. +fn material_len(s: &Session, c: &Clip) -> Option { + match c.content { + ClipContent::Audio { source, .. } => s.source(source).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX)), + ClipContent::Video { source, .. } => s.video(source).map(|v| s.sample_rate.samples(v.duration.clamp(0.0, 1e7))), + ClipContent::Midi { .. } => None, + } +} + /// Create fades from a selection (the incumbent's Edit › Fades › Create semantics): /// a range across a clip boundary makes a crossfade; at a clip start a fade-in; at its end a fade-out. pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeShape) -> usize { @@ -312,23 +721,7 @@ pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeSh } let handles: Vec<(ClipId, Samples)> = { let Some(tr) = s.track(track) else { return 0 }; - tr.clips() - .iter() - .map(|c| { - let avail = match c.content { - ClipContent::Audio { source, offset } => { - let total = s.source(source).map_or(0, |src| i64::try_from(src.frames).unwrap_or(i64::MAX)); - total.saturating_sub(offset).saturating_sub(c.length) - } - ClipContent::Video { source, offset } => { - let total = s.video(source).map_or(0, |v| s.sample_rate.samples(v.duration.clamp(0.0, 1e7))); - total.saturating_sub(offset).saturating_sub(c.length) - } - ClipContent::Midi { .. } => 0, - }; - (c.id, avail) - }) - .collect() + tr.clips().iter().map(|c| (c.id, material_len(s, c).map_or(0, |n| n.saturating_sub(c.source_offset()).saturating_sub(c.length)))).collect() }; let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 }; pl.sort(); @@ -461,7 +854,7 @@ mod tests { let copied = copy_range(&s, t, Range::new(1500, 2500)); assert_eq!(copied.len(), 1); assert_eq!((copied[0].start, copied[0].length, copied[0].source_offset()), (0, 1000, 1500)); - paste(&mut s, t, 4000, &copied, 1000, false); + paste(&mut s, t, 4000, &copied, 1000, false).unwrap(); assert_eq!(clips(&s, t), vec![(1000, 3000, 1000), (4000, 1000, 1500)]); } @@ -469,7 +862,7 @@ mod tests { fn paste_shuffle_ripples() { let (mut s, t) = session_with_clip(); let copied = copy_range(&s, t, Range::new(1000, 2000)); - paste(&mut s, t, 1000, &copied, 1000, true); + paste(&mut s, t, 1000, &copied, 1000, true).unwrap(); assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2000, 4000, 1000)]); } @@ -501,13 +894,109 @@ mod tests { assert_eq!((c[0].end(), c[1].start), (3000, 3000)); } + fn three_clips() -> (Session, TrackId, [ClipId; 3]) { + let (mut s, t) = session_with_clip(); + let ids = [s.new_clip_id(), s.new_clip_id(), s.new_clip_id()]; + if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) { + pl.clips = vec![ + Clip::audio(ids[0], "a", SourceId(500), 0, 0, 1000), + Clip::audio(ids[1], "b", SourceId(500), 1000, 1000, 2000), + Clip::audio(ids[2], "c", SourceId(500), 3000, 5000, 1000), + ]; + } + (s, t, ids) + } + + #[test] + fn shuffle_moves_reorder_clips_and_keep_the_silence() { + let (mut s, t, [_, b, _]) = three_clips(); + assert_eq!(shuffle_clips(&mut s, &[b], -900, &[]), Ok(1)); + assert_eq!(clips(&s, t), vec![(0, 2000, 1000), (2000, 1000, 0), (5000, 1000, 3000)]); + let (mut s, t, [a, ..]) = three_clips(); + assert_eq!(shuffle_slot(&s, t, 900, &[a]), 0); + assert_eq!(shuffle_slot(&s, t, 1100, &[a]), 2000); + shuffle_clips(&mut s, &[a], 2500, &[]).unwrap(); + assert_eq!(clips(&s, t), vec![(0, 2000, 1000), (2000, 1000, 0), (5000, 1000, 3000)]); + } + + #[test] + fn shuffling_onto_an_empty_track_starts_it_at_zero() { + let (mut s, t, [_, _, c]) = three_clips(); + let other = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + shuffle_clips(&mut s, &[c], 40_000, &[(t, other)]).unwrap(); + assert_eq!(clips(&s, other), vec![(0, 1000, 3000)]); + assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 2000, 1000)]); + } + + #[test] + fn shuffle_trims_move_later_clips_and_slip_trims_stop_at_neighbours() { + let (mut s, t, [a, b, c]) = three_clips(); + trim_clip(&mut s, b, None, Some(2500), true); + trim_clip(&mut s, b, Some(1500), None, true); + assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 1000, 1500), (4000, 1000, 3000)]); + let (mut s, t, _) = three_clips(); + trim_clip(&mut s, a, None, Some(2500), false); + trim_clip(&mut s, c, Some(2000), None, false); + assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 2000, 1000), (3000, 3000, 1000)]); + trim_clip(&mut s, c, None, Some(99_999), false); + assert_eq!(clips(&s, t)[2], (3000, 9000, 1000)); + } + + #[test] + fn shuffle_leaves_locked_clips_and_what_follows_them_in_place() { + let (mut s, t, [a, b, c]) = three_clips(); + s.find_clip_mut(c).unwrap().time_locked = true; + clear_range(&mut s, t, Range::new(1000, 3000), true); + assert_eq!(clips(&s, t), vec![(0, 1000, 0), (5000, 1000, 3000)]); + let (mut s, t, [a2, ..]) = three_clips(); + s.find_clip_mut(c).unwrap().edit_locked = true; + assert_eq!(shuffle_room(&s, t, 0), 2000); + assert_eq!(make_room(&mut s, t, 0, 2500), Err(NO_ROOM)); + assert_eq!(make_room(&mut s, t, 0, 2000), Ok(())); + assert_eq!(clips(&s, t), vec![(2000, 1000, 0), (3000, 2000, 1000), (5000, 1000, 3000)]); + assert_eq!(shuffle_clips(&mut s, &[a2], 2500, &[]), Ok(1)); + assert_eq!(clips(&s, t)[2], (5000, 1000, 3000)); + let (mut s, t, _) = three_clips(); + s.find_clip_mut(c).unwrap().time_locked = true; + trim_clip(&mut s, b, None, Some(9000), true); + assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 4000, 1000), (5000, 1000, 3000)]); + assert_eq!(shuffle_slot(&s, t, 4000, &[a]), 4000); + } + + #[test] + fn multi_track_moves_keep_the_tracks_apart() { + let (mut s, t1, [a, ..]) = three_clips(); + let t2 = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let t3 = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let m = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None); + let d = s.new_clip_id(); + s.track_mut(t2).and_then(|t| t.playlist_mut()).unwrap().clips.push(Clip::audio(d, "d", SourceId(500), 0, 0, 500)); + let dest = destinations(&s, &[a, d], t1, t2); + assert_eq!(dest, Some(vec![(t1, t2), (t2, t3)])); + assert_eq!(destinations(&s, &[a, d], t1, m), None); + assert_eq!(destinations(&s, &[a], t1, m), None); + assert_eq!(move_clips(&mut s, &[a, d], 0, &dest.unwrap()), 2); + assert_eq!(clips(&s, t2), vec![(0, 1000, 0)]); + assert_eq!(clips(&s, t3), vec![(0, 500, 0)]); + } + + #[test] + fn grid_moves_snap_the_clip_start_or_keep_its_offset() { + let (s, t) = session_with_clip(); + let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate); + let c = s.track(t).unwrap().clips()[0].clone(); + assert_eq!(c.start + grid_delta(&s, &c, step + 200, false), step); + assert_eq!(c.start + grid_delta(&s, &c, step + 200, true), step + 1000); + assert_eq!(grid_delta(&s, &c, step / 3, true), 0); + } + #[test] fn moves_overwrite_and_respect_locks() { let (mut s, t) = session_with_clip(); let id = s.track(t).unwrap().clips()[0].id; - assert_eq!(move_clips(&mut s, &[id], 500, None), 1); + assert_eq!(move_clips(&mut s, &[id], 500, &[]), 1); assert_eq!(clips(&s, t), vec![(1500, 4000, 1000)]); s.find_clip_mut(id).unwrap().time_locked = true; - assert_eq!(move_clips(&mut s, &[id], 500, None), 0); + assert_eq!(move_clips(&mut s, &[id], 500, &[]), 0); } } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 13c483d..f0cbf87 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -98,6 +98,15 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio id } +/// A BWF time stamp (samples at the file's rate) at the session's rate, as the audio is resampled. +fn stamp_at_rate(stamp: u64, file_rate: u32, session_rate: u32) -> u64 { + if file_rate == 0 || file_rate == session_rate { + return stamp; + } + let (from, to) = (u128::from(file_rate), u128::from(session_rate)); + u64::try_from((u128::from(stamp) * to + from / 2) / from).unwrap_or(u64::MAX) +} + /// Decode and place an audio file. With no target track, a new track of matching width is made. pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option, at: Samples) -> Result { let ext = Path::new(name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase); @@ -109,10 +118,12 @@ pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option return Err(EngineError::Io(format!("{name}: no audio"))); } let channels = buf.num_channels(); + let file_rate = info.sample_rate; let s = e.session_mut(); + let session_rate = s.sample_rate.hz(); let src = add_source(s, name, buf, path, info.format); if let (Some(bwf), Some(sx)) = (info.bwf.as_ref(), s.sources.last_mut()) { - sx.time_reference = bwf.time_reference; + sx.time_reference = stamp_at_rate(bwf.time_reference, file_rate, session_rate); } let frames = s.source(src).map_or(0, |x| i64::try_from(x.frames).unwrap_or(0)); let track = match target { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ef17f08..6cc1c90 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -90,6 +90,18 @@ pub struct Clipboard { pub clip_effects: Vec<(String, f64)>, /// Markers inside the copied range (starts relative to it), for Paste Special › Merge Markers. pub markers: Vec, + /// How far the copied range started after a grid line; Relative Grid pastes keep it. + pub grid_offset: Samples, + /// Copy Special › Automation: pasting it leaves the clips alone, in Shuffle mode too. + pub automation_only: bool, +} + +impl Clipboard { + /// An empty clipboard for material copied from `range`. + pub fn of_range(s: &Session, range: soundcraft_time::Range) -> Clipboard { + let line = s.edit.grid.line_at_or_before(range.start, s.sample_rate, &s.tempo, s.frame_rate); + Clipboard { length: range.len(), grid_offset: range.start.saturating_sub(line), ..Clipboard::default() } + } } #[derive(Debug, Clone)] @@ -233,6 +245,7 @@ impl Engine { pub fn undo(&mut self) -> bool { let Some(h) = self.undo.pop() else { return false }; let cur = std::mem::replace(&mut self.doc, h.doc); + self.keep_edit_mode(&cur); self.redo.push(HistoryEntry { label: h.label, doc: cur }); self.revision = self.revision.wrapping_add(1); self.dirty = true; @@ -242,12 +255,27 @@ impl Engine { pub fn redo(&mut self) -> bool { let Some(h) = self.redo.pop() else { return false }; let cur = std::mem::replace(&mut self.doc, h.doc); + self.keep_edit_mode(&cur); self.undo.push(HistoryEntry { label: h.label, doc: cur }); self.revision = self.revision.wrapping_add(1); self.dirty = true; true } + /// Undo and Redo never change the Edit mode or its settings (Shuffle Lock above all). + fn keep_edit_mode(&mut self, from: &Session) { + use edit::{RELATIVE_GRID, SHUFFLE_LOCK, SNAP_TO_GRID}; + let flags = [SNAP_TO_GRID, SHUFFLE_LOCK, RELATIVE_GRID]; + let ed = &self.doc.edit; + if ed.edit_mode != from.edit.edit_mode || flags.iter().any(|f| ed.flag(f) != from.edit.flag(f)) { + let ed = &mut Arc::make_mut(&mut self.doc).edit; + ed.edit_mode = from.edit.edit_mode; + for f in flags { + ed.set_flag(f, from.edit.flag(f)); + } + } + } + /// Record an undo point manually (for interactive gestures committed by the UI). pub fn push_undo(&mut self, label: &str, before: Arc) { if Arc::ptr_eq(&before, &self.doc) { diff --git a/crates/engine/tests/edit_modes.rs b/crates/engine/tests/edit_modes.rs new file mode 100644 index 0000000..27d4a64 --- /dev/null +++ b/crates/engine/tests/edit_modes.rs @@ -0,0 +1,415 @@ +use serde_json::{Value, json}; +use soundcraft_engine::Engine; +use soundcraft_engine::edit::SHUFFLE_LOCK; +use soundcraft_model::{AutoParam, Clip, ClipId, EditMode, Source, SourceId, TrackId}; + +fn source(id: u64, frames: u64) -> Source { + Source { + id: SourceId(id), + name: "src".into(), + path: "src.wav".into(), + channels: 1, + frames, + sample_rate: 48_000, + format: soundcraft_audio_io::FileFormat::Wav, + time_reference: 0, + unsaved: false, + } +} + +fn new_track(e: &mut Engine) -> TrackId { + TrackId(e.execute("track.new", &json!({"count": 1})).unwrap()["tracks"][0].as_u64().unwrap()) +} + +fn engine(mode: &str) -> (Engine, TrackId, [ClipId; 2]) { + let mut e = Engine::default(); + let t = new_track(&mut e); + let s = e.session_mut(); + s.sources.push(source(7, 10_000)); + let ids = [s.new_clip_id(), s.new_clip_id()]; + s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap().clips = + vec![Clip::audio(ids[0], "a", SourceId(7), 0, 0, 1000), Clip::audio(ids[1], "b", SourceId(7), 1000, 2000, 1000)]; + e.execute("edit.mode", &json!({"mode": mode})).unwrap(); + (e, t, ids) +} + +/// A track of clips (start, length, offset) on a long file, source 8. +fn long(mode: &str, spans: &[(i64, i64, i64)]) -> (Engine, TrackId, Vec) { + let mut e = Engine::default(); + let t = new_track(&mut e); + let s = e.session_mut(); + s.sources.push(source(8, 1_000_000)); + let ids: Vec = spans.iter().map(|_| s.new_clip_id()).collect(); + s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap().clips = + spans.iter().zip(&ids).map(|(&(start, len, offset), id)| Clip::audio(*id, "c", SourceId(8), offset, start, len)).collect(); + e.execute("edit.mode", &json!({"mode": mode})).unwrap(); + (e, t, ids) +} + +fn clips(e: &Engine, t: TrackId) -> Vec<(i64, i64)> { + e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.length)).collect() +} + +/// Each clip's id, length and file offset, in id order: what a move must never change. +fn material(e: &Engine, t: TrackId) -> Vec<(u64, i64, i64)> { + let mut v: Vec<_> = e.session().track(t).unwrap().clips().iter().map(|c| (c.id.0, c.length, c.source_offset())).collect(); + v.sort_unstable(); + v +} + +fn start(e: &Engine, id: ClipId) -> i64 { + e.session().find_clip(id).unwrap().1.start +} + +fn points(e: &Engine, t: TrackId) -> Vec { + e.session().track(t).unwrap().lane(&AutoParam::Volume).map(|l| l.points.iter().map(|p| p.at).collect()).unwrap_or_default() +} + +fn set_points(e: &mut Engine, t: TrackId, at: &[i64]) { + for a in at { + run(e, "automation.set_point", json!({"track": t.0, "param": "volume", "at": a, "value": -6.0})); + } +} + +fn run(e: &mut Engine, id: &str, p: Value) { + e.execute(id, &p).unwrap(); +} + +#[test] +fn insert_silence_pushes_in_shuffle_and_clears_otherwise() { + for (mode, want) in [("shuffle", vec![(0, 500), (700, 500), (2200, 1000)]), ("slip", vec![(0, 500), (700, 300), (2000, 1000)])] { + let (mut e, t, _) = engine(mode); + run(&mut e, "edit.insert_silence", json!({"tracks": [t.0], "start": 500, "end": 700})); + assert_eq!(clips(&e, t), want, "{mode}"); + } +} + +#[test] +fn duplicate_and_repeat_make_room_in_shuffle() { + let (mut e, t, _) = engine("shuffle"); + run(&mut e, "edit.duplicate", json!({"tracks": [t.0], "start": 0, "end": 1000})); + assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (3000, 1000)]); + let (mut e, t, _) = engine("shuffle"); + run(&mut e, "edit.repeat", json!({"tracks": [t.0], "start": 0, "end": 1000, "count": 2})); + assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (2000, 1000), (4000, 1000)]); + let (mut e, t, _) = engine("slip"); + run(&mut e, "edit.duplicate", json!({"tracks": [t.0], "start": 0, "end": 1000})); + assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (2000, 1000)]); +} + +#[test] +fn shuffle_paste_replaces_the_selection() { + let (mut e, t, _) = engine("shuffle"); + run(&mut e, "edit.copy", json!({"tracks": [t.0], "start": 0, "end": 1000})); + run(&mut e, "edit.select", json!({"tracks": [t.0], "start": 2000, "end": 2500})); + run(&mut e, "edit.paste", json!({})); + assert_eq!(clips(&e, t), vec![(0, 1000), (2000, 1000), (3000, 500)]); +} + +#[test] +fn clip_list_drops_land_on_a_clip_boundary_in_shuffle() { + let (mut e, t, [_, b]) = engine("shuffle"); + run(&mut e, "clip.place_source", json!({"source": 7, "track": t.0, "at": 1900})); + assert_eq!(clips(&e, t), vec![(0, 1000), (2000, 10_000), (12_000, 1000)]); + e.session_mut().find_clip_mut(b).unwrap().time_locked = true; + assert!(e.execute("clip.place_source", &json!({"source": 7, "track": t.0, "at": 0})).is_err()); + assert_eq!(clips(&e, t).len(), 3); +} + +#[test] +fn grid_moves_to_a_position_snap_or_keep_their_offset() { + let (mut e, t, [_, b]) = engine("grid"); + let s = e.session(); + let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate); + run(&mut e, "edit.move_clips", json!({"clips": [b.0], "to": step + 1000})); + assert_eq!(clips(&e, t)[1].0, step); + run(&mut e, "edit.mode", json!({"mode": "relative_grid"})); + run(&mut e, "edit.move_clips", json!({"clips": [b.0], "to": 2 * step + 2000})); + assert_eq!(clips(&e, t)[1].0, 2 * step); + run(&mut e, "edit.move_clips", json!({"clips": [b.0], "by": 200})); + run(&mut e, "edit.move_clips", json!({"clips": [b.0], "to": 3 * step})); + assert_eq!(clips(&e, t)[1].0, 3 * step + 200); +} + +#[test] +fn clips_cannot_move_onto_tracks_without_a_playlist() { + let (mut e, t, [a, _]) = engine("slip"); + let aux = e.execute("track.new", &json!({"kind": "aux"})).unwrap()["tracks"][0].clone(); + assert!(e.execute("edit.move_clips", &json!({"clips": [a.0], "by": 0, "track": aux})).is_err()); + assert_eq!(clips(&e, t).len(), 2); +} + +#[test] +fn trim_clip_needs_an_edge_and_an_unlocked_clip() { + let (mut e, t, [a, b]) = engine("slip"); + assert!(e.execute("edit.trim_clip", &json!({"clip": a.0})).is_err()); + e.session_mut().find_clip_mut(b).unwrap().edit_locked = true; + assert!(e.execute("edit.trim_clip", &json!({"clip": b.0, "end": 2500})).is_err()); + run(&mut e, "edit.trim_clip", json!({"clip": a.0, "end": 5000})); + assert_eq!(clips(&e, t), vec![(0, 2000), (2000, 1000)]); +} + +#[test] +fn shuffle_lock_keeps_shuffle_mode_out() { + let (mut e, _, _) = engine("slip"); + run(&mut e, "edit.shuffle_lock", json!({})); + assert!(e.execute("edit.mode", &json!({"mode": "shuffle"})).is_err()); + run(&mut e, "edit.mode", json!({"mode": "grid"})); + run(&mut e, "edit.shuffle_lock", json!({"value": false})); + run(&mut e, "edit.mode", json!({"mode": "shuffle"})); + assert!(e.execute("edit.shuffle_lock", &json!({})).is_err()); +} + +#[test] +fn snap_to_grid_rides_along_with_the_other_modes() { + let (mut e, _, _) = engine("slip"); + let snap = |e: &Engine| e.session().edit.flag(soundcraft_engine::edit::SNAP_TO_GRID); + run(&mut e, "edit.snap_to_grid", json!({})); + assert!(snap(&e)); + run(&mut e, "edit.mode", json!({"mode": "spot", "snap_to_grid": true})); + assert!(snap(&e)); + run(&mut e, "edit.mode", json!({"mode": "shuffle"})); + assert!(!snap(&e)); + run(&mut e, "edit.mode", json!({"mode": "grid", "snap_to_grid": true})); + assert!(!snap(&e)); + assert!(e.execute("edit.snap_to_grid", &json!({})).is_err()); +} + +#[test] +fn shuffle_edits_that_would_push_a_locked_clip_are_refused() { + let (mut e, t, [_, b]) = engine("shuffle"); + e.session_mut().find_clip_mut(b).unwrap().time_locked = true; + for id in ["edit.insert_silence", "edit.duplicate"] { + let err = e.execute(id, &json!({"tracks": [t.0], "start": 0, "end": 1500})).unwrap_err().to_string(); + assert!(err.contains("locked clip"), "{id}: {err}"); + } + run(&mut e, "edit.insert_silence", json!({"tracks": [t.0], "start": 0, "end": 1000})); + assert_eq!(clips(&e, t), vec![(1000, 1000), (2000, 1000)]); + run(&mut e, "event.cut_time", json!({"start": 0, "end": 1000})); + assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000)]); +} + +#[test] +fn clips_dragged_from_several_tracks_keep_their_tracks_apart() { + let (mut e, t, [a, _]) = engine("slip"); + let new = e.execute("track.new", &json!({"count": 2})).unwrap()["tracks"].clone(); + let [t2, t3] = [0, 1].map(|i| TrackId(new[i].as_u64().unwrap())); + let s = e.session_mut(); + let d = s.new_clip_id(); + s.track_mut(t2).and_then(|t| t.playlist_mut()).unwrap().clips.push(Clip::audio(d, "d", SourceId(7), 0, 5000, 500)); + run(&mut e, "edit.move_clips", json!({"clips": [a.0, d.0], "by": 0, "track": t2.0})); + assert_eq!(clips(&e, t), vec![(2000, 1000)]); + assert_eq!(clips(&e, t2), vec![(0, 1000)]); + assert_eq!(clips(&e, t3), vec![(5000, 500)]); + assert!(e.execute("edit.move_clips", &json!({"clips": [a.0, d.0], "by": 0, "track": t3.0})).is_err()); +} + +#[test] +fn relative_grid_pastes_keep_the_offset_from_the_grid() { + let (mut e, t, [_, b]) = engine("relative_grid"); + let s = e.session(); + let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate); + run(&mut e, "edit.select", json!({"clips": [b.0]})); + run(&mut e, "edit.copy", json!({})); + run(&mut e, "edit.select", json!({"tracks": [t.0], "start": 3 * step})); + run(&mut e, "edit.paste", json!({})); + assert_eq!(clips(&e, t).last(), Some(&(3 * step + 2000, 1000))); + run(&mut e, "edit.mode", json!({"mode": "slip"})); + run(&mut e, "edit.select", json!({"tracks": [t.0], "start": 5 * step})); + run(&mut e, "edit.paste", json!({})); + assert_eq!(clips(&e, t).last(), Some(&(5 * step, 1000))); +} + +#[test] +fn shuffle_moves_keep_crossfade_partners_whole() { + let (mut e, t, ids) = long("slip", &[(0, 1000, 10_000), (1000, 1000, 20_000), (2000, 1000, 30_000)]); + run(&mut e, "edit.fades_create", json!({"tracks": [t.0], "start": 900, "end": 1100})); + run(&mut e, "edit.mode", json!({"mode": "shuffle"})); + let whole = material(&e, t); + let [a, b, c] = [ids[0], ids[1], ids[2]]; + run(&mut e, "edit.move_clips", json!({"clips": [a.0], "by": 5000})); + assert_eq!(material(&e, t), whole); + assert_eq!([start(&e, b), start(&e, c), start(&e, a)], [0, 1100, 2100]); + let (mut e, t, ids) = long("slip", &[(0, 1000, 10_000), (1000, 1000, 20_000), (2000, 1000, 30_000)]); + run(&mut e, "edit.fades_create", json!({"tracks": [t.0], "start": 900, "end": 1100})); + run(&mut e, "edit.mode", json!({"mode": "shuffle"})); + for by in [2500, -2500] { + run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "by": by})); + assert_eq!(material(&e, t), whole, "{by}"); + assert_eq!(start(&e, ids[1]) - start(&e, ids[0]), 900, "{by}"); + } + assert_eq!([start(&e, ids[0]), start(&e, ids[2])], [0, 2000]); + run(&mut e, "edit.mode", json!({"mode": "slip"})); + run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "by": 5000})); + assert_eq!(material(&e, t), whole); +} + +#[test] +fn pasting_copied_automation_in_shuffle_leaves_the_clips_alone() { + let (mut e, t, ids) = long("slip", &[(0, 1000, 0), (1000, 1000, 0), (2000, 1000, 0)]); + set_points(&mut e, t, &[200]); + run(&mut e, "edit.copy_all_automation", json!({"tracks": [t.0], "start": 0, "end": 500})); + run(&mut e, "edit.mode", json!({"mode": "shuffle"})); + run(&mut e, "edit.select", json!({"clips": [ids[1].0]})); + run(&mut e, "edit.paste", json!({})); + assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (2000, 1000)]); +} + +#[test] +fn automation_follows_shuffle_moves_trims_and_drops() { + let setup = || { + let (mut e, t, ids) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0), (2000, 1000, 0)]); + set_points(&mut e, t, &[500, 1500, 2500]); + (e, t, ids) + }; + let (mut e, t, ids) = setup(); + run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0], "by": 3000})); + assert_eq!(points(&e, t), [500, 1500, 2500]); + assert_eq!([start(&e, ids[1]), start(&e, ids[2]), start(&e, ids[0])], [0, 1000, 2000]); + let (mut e, t, ids) = setup(); + run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "end": 500})); + assert_eq!(points(&e, t), [1000, 2000]); + let (mut e, t, _) = setup(); + run(&mut e, "clip.place_source", json!({"source": 8, "track": t.0, "at": 1000})); + assert_eq!(points(&e, t), [500, 1_001_500, 1_002_500]); +} + +#[test] +fn shuffle_automation_stops_at_locked_clips_and_needs_automation_follows_edit() { + let (mut e, t, ids) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0), (3000, 1000, 0)]); + e.session_mut().find_clip_mut(ids[2]).unwrap().time_locked = true; + set_points(&mut e, t, &[3500]); + run(&mut e, "edit.insert_silence", json!({"tracks": [t.0], "start": 0, "end": 500})); + run(&mut e, "edit.cut", json!({"tracks": [t.0], "start": 0, "end": 500})); + assert_eq!((start(&e, ids[2]), points(&e, t)), (3000, vec![3500])); + let (mut e, t, _) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0)]); + set_points(&mut e, t, &[300, 1500, 5000]); + e.session_mut().edit.automation_follows_edit = false; + run(&mut e, "edit.duplicate", json!({"tracks": [t.0], "start": 0, "end": 1000})); + run(&mut e, "edit.insert_silence", json!({"tracks": [t.0], "start": 0, "end": 100})); + assert_eq!(points(&e, t), [300, 1500, 5000]); +} + +#[test] +fn shuffle_clear_moves_automation_with_the_clips() { + let (mut e, t, _) = long("shuffle", &[(0, 10_000, 0), (20_000, 10_000, 0)]); + set_points(&mut e, t, &[0, 9_000, 20_000, 29_000]); + run(&mut e, "edit.clear", json!({"track": t.0, "start": 4_000, "end": 6_000})); + assert_eq!(clips(&e, t), [(0, 4_000), (4_000, 4_000), (18_000, 10_000)]); + assert_eq!(points(&e, t), [0, 7_000, 18_000, 27_000]); +} + +#[test] +fn relative_grid_pastes_land_on_grid_lines_at_any_tempo() { + let (mut e, t, [a, _]) = engine("relative_grid"); + run(&mut e, "event.tempo", json!({"bpm": 90, "at": 0})); + run(&mut e, "edit.select", json!({"clips": [a.0]})); + run(&mut e, "edit.copy", json!({})); + run(&mut e, "edit.select", json!({"tracks": [t.0], "start": 128_000})); + run(&mut e, "edit.paste", json!({})); + assert!(clips(&e, t).contains(&(128_000, 1000)), "{:?}", clips(&e, t)); + run(&mut e, "edit.paste", json!({"tracks": [t.0], "at": 50_000})); + assert!(clips(&e, t).contains(&(50_000, 1000)), "{:?}", clips(&e, t)); +} + +#[test] +fn start_trims_keep_the_sync_point_on_its_audio() { + let (mut e, t, ids) = long("slip", &[(0, 10_000, 0)]); + let sync = |e: &Engine| e.session().find_clip(ids[0]).unwrap().1.sync_point; + e.session_mut().find_clip_mut(ids[0]).unwrap().sync_point = 5000; + run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "start": 2000})); + assert_eq!(sync(&e), 3000); + run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "start": 8000})); + assert_eq!(sync(&e), 0); + run(&mut e, "edit.mode", json!({"mode": "grid"})); + run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0], "to": 30_000})); + assert_eq!(clips(&e, t), vec![(30_000, 2000)]); +} + +#[test] +fn imported_time_stamps_count_at_the_session_rate() { + use soundcraft_audio_io::{AudioBuffer, BwfInfo, EncodeOptions, FileFormat}; + let mut e = Engine::default(); + let bwf = BwfInfo { time_reference: 1_587_600_000, ..BwfInfo::default() }; + let opts = EncodeOptions { format: FileFormat::Wav, bwf: Some(bwf), ..EncodeOptions::default() }; + let bytes = soundcraft_audio_io::encode(&AudioBuffer::new(44_100, 1, 441), &opts).unwrap(); + soundcraft_engine::io::import_audio_bytes(&mut e, "stamped.wav", &bytes, None, None, 0).unwrap(); + assert_eq!(e.session().sources[0].time_reference, 1_728_000_000); +} + +#[test] +fn shuffle_trims_stop_at_a_locked_crossfade_and_stay_inside_the_file() { + let (mut e, t, ids) = long("slip", &[(0, 1000, 500), (1000, 1000, 5000)]); + run(&mut e, "edit.fades_create", json!({"tracks": [t.0], "start": 950, "end": 1050})); + e.session_mut().find_clip_mut(ids[1]).unwrap().time_locked = true; + run(&mut e, "edit.mode", json!({"mode": "shuffle"})); + let before = material(&e, t); + run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "end": 1500})); + run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "start": 0})); + assert_eq!(material(&e, t), before); + let (mut e, _, [_, b]) = engine("shuffle"); + run(&mut e, "edit.trim_clip", json!({"clip": b.0, "start": 2500, "end": 20_000})); + let c = e.session().find_clip(b).unwrap().1; + assert_eq!((c.source_offset(), c.source_offset() + c.length), (1500, 10_000)); +} + +#[test] +fn duplicate_and_extend_makes_room_in_shuffle() { + let (mut e, t, _) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0)]); + run(&mut e, "edit.duplicate_extend", json!({"tracks": [t.0], "start": 0, "end": 1000})); + assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (2000, 1000)]); + let (mut e, t, ids) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0)]); + e.session_mut().find_clip_mut(ids[1]).unwrap().time_locked = true; + assert!(e.execute("edit.duplicate_extend", &json!({"tracks": [t.0], "start": 0, "end": 1000})).is_err()); + assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000)]); +} + +#[test] +fn undo_leaves_the_edit_mode_and_shuffle_lock_alone() { + let (mut e, t, _) = engine("shuffle"); + run(&mut e, "edit.cut", json!({"tracks": [t.0], "start": 0, "end": 500})); + run(&mut e, "edit.mode", json!({"mode": "slip"})); + run(&mut e, "edit.shuffle_lock", json!({})); + for step in [Engine::undo, Engine::redo] { + assert!(step(&mut e)); + assert_eq!((e.session().edit.edit_mode, e.session().edit.flag(SHUFFLE_LOCK)), (EditMode::Slip, true)); + } +} + +#[test] +fn shuffle_cuts_reaching_before_the_session_start_keep_the_spacing() { + let (mut e, t, _) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0), (2000, 1000, 0)]); + run(&mut e, "edit.cut", json!({"tracks": [t.0], "start": -5000, "end": 500})); + assert_eq!(clips(&e, t), vec![(0, 500), (500, 1000), (1500, 1000)]); +} + +#[test] +fn shuffle_never_pulls_a_crossfade_apart() { + let (mut e, t, ids) = long("slip", &[(0, 9000, 0), (9000, 11_000, 100_000), (20_000, 5000, 200_000)]); + run(&mut e, "edit.fades_create", json!({"tracks": [t.0], "start": 8000, "end": 10_000})); + run(&mut e, "edit.mode", json!({"mode": "shuffle"})); + let err = e.execute("edit.duplicate", &json!({"tracks": [t.0], "start": 5000, "end": 9000})).unwrap_err().to_string(); + assert!(err.contains("crossfade"), "{err}"); + run(&mut e, "clip.place_source", json!({"source": 8, "track": t.0, "at": 7900})); + assert_eq!(clips(&e, t)[0], (0, 1_000_000)); + assert_eq!([start(&e, ids[0]), start(&e, ids[1])], [1_000_000, 1_008_000]); +} + +#[test] +fn clips_move_to_and_from_hidden_tracks() { + let (mut e, t, [a, _]) = engine("slip"); + let hidden = new_track(&mut e); + run(&mut e, "track.hide", json!({"tracks": [hidden.0], "hidden": true})); + run(&mut e, "edit.move_clips", json!({"clips": [a.0], "by": 0, "track": hidden.0})); + assert_eq!(clips(&e, hidden), vec![(0, 1000)]); + run(&mut e, "edit.move_clips", json!({"clips": [a.0], "by": 0, "track": t.0})); + assert_eq!(clips(&e, t).len(), 2); +} + +#[test] +fn shuffle_lock_and_snap_rules_hold_when_a_call_names_tracks() { + let (mut e, t, _) = engine("shuffle"); + assert!(e.execute("edit.shuffle_lock", &json!({"value": true, "tracks": [t.0]})).is_err()); + run(&mut e, "edit.mode", json!({"mode": "grid"})); + assert!(e.execute("edit.snap_to_grid", &json!({"tracks": [t.0]})).is_err()); +} diff --git a/crates/engine/tests/random_edits.rs b/crates/engine/tests/random_edits.rs index fc89179..8d90ccf 100644 --- a/crates/engine/tests/random_edits.rs +++ b/crates/engine/tests/random_edits.rs @@ -54,6 +54,8 @@ const CMDS: &[&str] = &[ "event.beat_detective", "automation.thin", "clip.remove_warp", + "clip.time_lock", + "clip.edit_lock", ]; fn params(i: usize, a: i64, b: i64, track: &str) -> Value { @@ -119,7 +121,7 @@ fn check_invariants(e: &Engine) -> Result<(), String> { proptest! { #![proptest_config(ProptestConfig { cases: 48, .. ProptestConfig::default() })] #[test] - fn random_edits_keep_the_session_sane(ops in proptest::collection::vec((0usize..48, -100_000i64..3_000_000, -100_000i64..3_000_000, 0usize..5), 1..25)) { + fn random_edits_keep_the_session_sane(ops in proptest::collection::vec((0usize..CMDS.len(), -100_000i64..3_000_000, -100_000i64..3_000_000, 0usize..5), 1..25)) { let mut e = soundcraft_engine::demo::demo_engine(); let original = serde_json::to_value(e.session()).unwrap(); let names = ["Kick", "Snare", "Bass", "Pad", "Keys"]; diff --git a/crates/model/src/clip.rs b/crates/model/src/clip.rs index 3a3a2e2..52fef5c 100644 --- a/crates/model/src/clip.rs +++ b/crates/model/src/clip.rs @@ -219,6 +219,10 @@ impl Clip { // MIDI note ticks are re-anchored by the engine, which knows the tempo map. self.length -= delta; self.start = new_start; + if self.sync_point != 0 { + self.sync_point = self.sync_point.saturating_sub(delta); + } + self.drop_stray_sync(); self.clamp_fades(); // Keep the gain at the cut when earlier breakpoints are dropped. if self.gain_env.iter().any(|&(t, _)| t < delta) && self.gain_env.iter().all(|&(t, _)| t != delta) { @@ -232,6 +236,7 @@ impl Clip { pub fn trim_end_to(&mut self, new_end: Samples) { let new_end = new_end.max(self.start + 1); self.length = new_end - self.start; + self.drop_stray_sync(); self.clamp_fades(); } @@ -240,6 +245,13 @@ impl Clip { self.fade_in.len = self.fade_in.len.clamp(0, self.length); self.fade_out.len = self.fade_out.len.clamp(0, self.length); } + + /// A sync point (0 means none) that a trim left outside the clip no longer marks anything. + fn drop_stray_sync(&mut self) { + if !(0..self.length).contains(&self.sync_point) { + self.sync_point = 0; + } + } } fn env_value(env: &[(Samples, f32)], at: Samples) -> f32 { diff --git a/crates/time/src/format.rs b/crates/time/src/format.rs index e193151..bdd2fcb 100644 --- a/crates/time/src/format.rs +++ b/crates/time/src/format.rs @@ -142,6 +142,18 @@ mod tests { (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps30) } + #[test] + fn huge_typed_positions_parse_without_overflowing() { + let (sr, map, rate) = ctx(); + for (text, fmt) in [ + ("999999999999999999:0", TimeFormat::Timecode), + ("100000000000000:00:00:00", TimeFormat::Timecode), + ("-9223372036854775808", TimeFormat::BarsBeats), + ] { + let _ = parse_position(text, fmt, sr, &map, rate, 0, 0); + } + } + #[test] fn formats_like_pro_tools_counters() { let (sr, map, rate) = ctx(); diff --git a/crates/time/src/grid.rs b/crates/time/src/grid.rs index dea9053..b4e56a5 100644 --- a/crates/time/src/grid.rs +++ b/crates/time/src/grid.rs @@ -141,7 +141,13 @@ impl GridValue { pub fn line_at_or_before(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples { match *self { GridValue::Note { value, dotted, triplet } => { - let t = map.samples_to_ticks_f(pos, sr).floor() as i64; + // Float ticks can land a hair either side of a line's own tick. + let mut t = map.samples_to_ticks_f(pos, sr).floor() as i64; + if map.tick_to_samples(t, sr) > pos { + t = t.saturating_sub(1); + } else if map.tick_to_samples(t.saturating_add(1), sr) <= pos { + t = t.saturating_add(1); + } // Grid lines restart at each bar line so odd meters stay aligned. let bb = map.bar_beat_at_tick(t); let bar_tick = map.bar_start_tick(bb.bar); @@ -151,7 +157,7 @@ impl GridValue { } _ => { let step = self.step_samples(pos, sr, map, rate); - pos.div_euclid(step) * step + pos.div_euclid(step).saturating_mul(step) } } } @@ -185,6 +191,19 @@ impl GridValue { mod tests { use super::*; + #[test] + fn grid_lines_are_their_own_line_at_odd_tempos() { + let (g, sr, rate) = (GridValue::default(), SampleRate::HZ_48000, FrameRate::Fps30); + for bpm in [90.0, 95.0, 110.0, 130.0, 140.0] { + let map = TempoMap::new(bpm, 4, 4).unwrap(); + let mut line = 0; + for _ in 0..64 { + assert_eq!(g.line_at_or_before(line, sr, &map, rate), line, "{bpm} bpm"); + line = g.next_line(line, sr, &map, rate); + } + } + } + #[test] fn sixteenth_grid_at_120() { let g = GridValue::default(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 185149d..f916c26 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -20,6 +20,7 @@ pub enum Dialog { 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 + Spot(crate::spot::Spot), Fades { shape: String }, StripSilence { threshold: f64, min_ms: f64, pre_ms: f64, post_ms: f64 }, Group { name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> }, @@ -48,6 +49,7 @@ impl Dialogs { Dialog::PathPrompt { .. } => "path", Dialog::Number { .. } => "number", Dialog::TempoChange { .. } => "tempo_change", + Dialog::Spot(_) => "spot", Dialog::Fades { .. } => "fades", Dialog::StripSilence { .. } => "strip_silence", Dialog::Group { .. } => "group", @@ -278,6 +280,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::ClearClips { .. } => "Clear", Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(), Dialog::TempoChange { .. } => "Tempo Change (BPM)", + Dialog::Spot(_) => "Spot", Dialog::Fades { .. } => "Fades", Dialog::StripSilence { .. } => "Strip Silence", Dialog::Group { .. } => "Create Group", @@ -478,6 +481,13 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { action = Some(tempo_change_action(*at, *bpm)); } } + Dialog::Spot(d) => { + crate::spot::fields(ui, app.engine.session(), d); + if buttons(ui, "OK", enter) { + keep = false; + action = d.command(app.engine.session()); + } + } Dialog::Fades { shape } => { for s in soundcraft_model::FadeShape::ALL { ui.radio_value(shape, s.label().to_lowercase(), s.label()); diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 6008c06..b1cfaed 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1,12 +1,17 @@ //! The Edit window: toolbar, rulers, track headers and playlists. +use crate::dialogs::Dialog; +use crate::spot::Spot; use crate::theme::{Tokens, bold, clip_colors, regular, rgb}; use crate::widgets::{groove, level_pos, meter_pos, raised_key, shade}; use crate::{Gesture, SoundApp, toolbar}; use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; -use soundcraft_model::{AutoParam, Clip, ClipContent, ClipId, Session, Tool, Track, TrackHeight, TrackId, TrackKind}; +use soundcraft_engine::edit as ops; +use soundcraft_model::{AutoParam, Clip, ClipContent, ClipId, EditMode, Session, Tool, Track, TrackHeight, TrackId, TrackKind}; use soundcraft_time::{GridValue, NoteValue, Range, Samples, TimeFormat, format_position}; +use std::collections::HashSet; +use std::sync::Arc; pub const HEADER_W: f32 = 136.0; pub const COLUMN_W: f32 = 112.0; @@ -281,7 +286,7 @@ 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) + ops::shown(s, t) } /// Pro Tools' track-name click: Shift adds the tracks up to the nearest selected one, Cmd/Ctrl adds or removes one. @@ -1587,7 +1592,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track } else if s.edit.flag("view.clip.time.original") || s.edit.flag("view.clip.time.user") { clip.source().and_then(|src| s.source(src)).map(|src| { format_position( - i64::try_from(src.time_reference).unwrap_or(0) + clip.source_offset(), + i64::try_from(src.time_reference).unwrap_or(0).saturating_add(clip.source_offset()).saturating_sub(s.timecode_start), TimeFormat::Timecode, s.sample_rate, &s.tempo, @@ -1694,7 +1699,8 @@ fn plugin_range(track: &Track, p: &AutoParam) -> (f32, f32) { // ---- interaction ----------------------------------------------------------------------------- fn snap(s: &Session, at: Samples) -> Samples { - if s.edit.edit_mode.is_grid() { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at } + let on = s.edit.edit_mode.is_grid() || s.edit.flag(ops::SNAP_TO_GRID); + if on { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at } } fn clip_at(track: &Track, at: Samples) -> Option<&Clip> { @@ -1748,8 +1754,9 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, && let Some(p) = ui.ctx().pointer_latest_pos() { let s = app.engine.session(); - let at = snap(s, sample_at(s, tl, p.x).max(0)); - let _ = app.run("clip.place_source", json!({"source": src.0, "track": track.id.0, "at": at})); + let at = drag_snap(s, sample_at(s, tl, p.x).max(0), ui.input(|i| i.modifiers.command)); + let spot = Spot::source(s, soundcraft_model::SourceId(src.0), track.id, at); + spot_or_run(app, spot); } if resp.dnd_hover_payload::().is_some() { ui.painter().rect_stroke(lane, 0.0, Stroke::new(2.0, Tokens::current().accent), StrokeKind::Inside); @@ -1893,6 +1900,15 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, let mut ids = if mods.shift { s.edit.selected_clips.iter().map(|x| x.0).collect::>() } else { Vec::new() }; ids.push(c.id.0); let _ = app.run("edit.select", json!({"clips": ids})); + if s.edit.edit_mode == EditMode::Spot { + app.dialogs.open = Spot::clips(&s, vec![c.id], None, 0).map(Dialog::Spot); + } + } + } + Tool::Trim if s.edit.edit_mode == EditMode::Spot && hit.is_some() => { + if let Some(c) = &hit { + let start = at - c.start < c.end() - at; + app.dialogs.open = Spot::trim(&s, c.id, start, if start { c.start } else { c.end() }).map(Dialog::Spot); } } Tool::Zoom => { @@ -1970,22 +1986,14 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { *tracks = rows.get(a.min(b)..=a.max(b)).unwrap_or_default().iter().map(|(i, _)| TrackId(*i)).collect(); } } - Some(Gesture::MoveClips { grab_at, delta, from_track, to_track: tt, .. }) => { - let raw = at - *grab_at; - *delta = if s.edit.edit_mode.is_grid() { - let base = s.track(*from_track).and_then(|t| clip_at(t, *grab_at)).map_or(*grab_at, |c| c.start); - snap(&s, base + raw) - base - } else { - raw - }; - if s.edit.edit_mode == soundcraft_model::EditMode::Spot { - *delta = 0; - } + Some(Gesture::MoveClips { clips, grab_at, delta, from_track, to_track: tt }) => { if let Some(t2) = to_track { *tt = t2; } + let plan = drag_plan(ctx, &s, app.engine.revision, clips, (*from_track, *tt), *grab_at); + *delta = move_delta(&plan, &s, *from_track, at - *grab_at, ctx.input(|i| i.modifiers.command)); } - Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = snap(&s, at), + Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = drag_snap(&s, at, ctx.input(|i| i.modifiers.command)), Some(Gesture::Fade { to, .. }) => *to = at, Some(Gesture::Scrub { last }) => { *last = at; @@ -2033,16 +2041,7 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { fn finish_drag(app: &mut SoundApp, s: &Session, tl: Rect, p: Option) { match app.gesture.take() { - Some(Gesture::MoveClips { clips, delta, from_track, to_track, .. }) => { - let ids: Vec = clips.iter().map(|c| c.0).collect(); - let mut params = json!({"clips": ids, "by": delta}); - if to_track != from_track { - params["track"] = json!(to_track.0); - } - if delta != 0 || to_track != from_track { - let _ = app.run("edit.move_clips", params); - } - } + Some(Gesture::MoveClips { clips, delta, from_track, to_track, .. }) => drop_clips(app, clips, delta, from_track, to_track), Some(Gesture::Fade { clip, track, fade_in, to }) => { if let Some((_, c)) = s.find_clip(clip) { // Not clamp: it panics on a zero-length clip. @@ -2050,8 +2049,8 @@ fn finish_drag(app: &mut SoundApp, s: &Session, tl: Rect, p: Option) { let _ = app.run("edit.fades_create", json!({"tracks": [track.0], "start": a, "end": b, "shape": "s-curve"})); } } - Some(Gesture::TrimStart { clip, track, to }) => trim_clip(app, clip, track, to, true), - Some(Gesture::TrimEnd { clip, track, to }) => trim_clip(app, clip, track, to, false), + Some(Gesture::TrimStart { clip, to, .. }) => trim_clip(app, clip, to, true), + Some(Gesture::TrimEnd { clip, to, .. }) => trim_clip(app, clip, to, false), Some(Gesture::Pencil { track, points }) => { if let Some(param) = s.track(track).and_then(|t| AutoParam::parse(&t.view)) { let pid = automation_id(¶m); @@ -2087,22 +2086,96 @@ fn automation_id(p: &AutoParam) -> String { } } -fn trim_clip(app: &mut SoundApp, clip: ClipId, track: TrackId, to: Samples, start: bool) { - let cmd = if start { "edit.trim_start_to_insertion" } else { "edit.trim_end_to_insertion" }; - // Trims through the command layer: select the clip's track and position the insertion. - let sel_before = app.engine.session().edit.selection; - let tracks_before: Vec = app.engine.session().edit.selected_tracks.iter().map(|x| x.0).collect(); - let c = app.engine.session().find_clip(clip).map(|(_, c)| c.clone()); - if let Some(c) = c { - if start && to < c.start || !start && to > c.end() { - // Extending: use trim to fill selection with handles. - let r = if start { Range::new(to, c.end()) } else { Range::new(c.start, to) }; - let _ = app.run("edit.trim_to_fill_selection", json!({"clips": [clip.0], "start": r.start, "end": r.end})); - } else { - let _ = app.run(cmd, json!({"tracks": [track.0], "at": to})); - } +/// Grid snapping while dragging; holding Cmd/Ctrl swaps Grid and Slip. +fn drag_snap(s: &Session, at: Samples, flip: bool) -> Samples { + if s.edit.edit_mode.is_grid() != flip { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at } +} + +/// What a clip drag works out once per target track rather than every frame: where the tracks go +/// (None when the clips can't land there), each destination's earliest clip and Shuffle slots, +/// the clips that move (track, start, length) and the clip Grid snaps. +struct DragPlan { + dest: Option>, + groups: Vec<(TrackId, Samples, Vec)>, + moving: Vec<(TrackId, Samples, Samples)>, + grabbed: Option, +} + +/// The [`DragPlan`] for `clips` grabbed at `grab_at` on track `from` and dragged onto `to`, kept +/// until the document, the Edit mode or the target changes. +fn drag_plan(ctx: &egui::Context, s: &Session, revision: u64, clips: &[ClipId], (from, to): (TrackId, TrackId), grab_at: Samples) -> Arc { + type Key = (u64, EditMode, Vec, TrackId, TrackId, Samples); + let id = egui::Id::new("clip_drag_plan"); + let key: Key = (revision, s.edit.edit_mode, clips.to_vec(), from, to, grab_at); + if let Some((k, plan)) = ctx.data(|d| d.get_temp::<(Key, Arc)>(id)) + && k == key + { + return plan; + } + let dest = if to == from { Some(Vec::new()) } else { ops::destinations(s, clips, from, to) }; + let groups = match &dest { + Some(d) if s.edit.edit_mode == EditMode::Shuffle => { + ops::shuffle_groups(s, clips, d).into_iter().map(|(t, first)| (t, first, ops::shuffle_slots(s, t, clips))).collect() + } + _ => Vec::new(), + }; + let ids: HashSet = clips.iter().copied().collect(); + let movable = |c: &&Clip| ids.contains(&c.id) && !ops::locked(c); + let moving = s.tracks.iter().flat_map(|t| t.clips().iter().filter(movable).map(|c| (t.id, c.start, c.length))).collect(); + let mine: Vec<&Clip> = s.track(from).map(|t| t.clips().iter().filter(movable).collect()).unwrap_or_default(); + let grabbed = mine.iter().rev().find(|c| c.range().contains(grab_at)).or_else(|| mine.iter().min_by_key(|c| c.start)).map(|c| (*c).clone()); + let plan = Arc::new(DragPlan { dest, groups, moving, grabbed }); + ctx.data_mut(|d| d.insert_temp(id, (key, Arc::clone(&plan)))); + plan +} + +/// How far a Grabber drag by `raw` from track `from` takes the clips in the current Edit mode. +/// Shuffle lands the earliest clip bound for the target track on a slot there, as +/// `edit.move_clips` does; Grid snaps the grabbed clip. +fn move_delta(plan: &DragPlan, s: &Session, from: TrackId, raw: Samples, flip: bool) -> Samples { + let mode = s.edit.edit_mode; + if mode == EditMode::Shuffle { + let to = plan.dest.as_ref().map_or(from, |d| ops::lands(d, from)); + plan.groups + .iter() + .find(|g| g.0 == to) + .map_or(raw, |(_, first, slots)| ops::nearest_slot(slots, first.saturating_add(raw)).saturating_sub(*first)) + } else if mode.is_grid() != flip + && let Some(c) = &plan.grabbed + { + ops::grid_delta(s, c, raw, mode == EditMode::GridRelative) + } else { + raw + } +} + +/// The dragged clips with one from the grabbed track first: `edit.move_clips` sends that track to +/// the target and the others along with it. +fn grabbed_first(s: &Session, clips: &[ClipId], from: TrackId) -> Vec { + let mut v = clips.to_vec(); + v.sort_by_key(|c| s.find_clip(*c).is_none_or(|(t, _)| t != from)); + v +} + +fn drop_clips(app: &mut SoundApp, clips: Vec, delta: Samples, from: TrackId, to: TrackId) { + let s = app.engine.session(); + let spot = Spot::clips(s, grabbed_first(s, &clips, from), (to != from).then_some(to), delta); + spot_or_run(app, spot); +} + +fn trim_clip(app: &mut SoundApp, clip: ClipId, to: Samples, start: bool) { + let spot = Spot::trim(app.engine.session(), clip, start, to); + spot_or_run(app, spot); +} + +/// Spot mode asks for exact positions first; the other modes make the edit straight away. +fn spot_or_run(app: &mut SoundApp, spot: Option) { + let Some(spot) = spot else { return }; + if app.engine.session().edit.edit_mode == EditMode::Spot { + app.dialogs.open = Some(Dialog::Spot(spot)); + } else if let Some((id, params)) = spot.command(app.engine.session()) { + let _ = app.run(&id, params); } - let _ = app.engine.execute("edit.select", &json!({"tracks": tracks_before, "start": sel_before.start, "end": sel_before.end})); } fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option) { @@ -2240,12 +2313,20 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { scroll_to = follow_to; } // Ghost of a clip move. - if let Some(Gesture::MoveClips { clips, delta, to_track, .. }) = &app.gesture { - let row = app.edit_layout.rows.iter().find(|(i, _)| *i == to_track.0).map(|(_, r)| *r); - for c in clips { - if let (Some((_, clip)), Some(r)) = (s.find_clip(*c), row) { - let x0 = x_of(s, tl, clip.start + delta); - let x1 = x_of(s, tl, clip.end() + delta); + if let Some(Gesture::MoveClips { clips, delta, from_track, to_track, grab_at }) = &app.gesture + && let plan = drag_plan(ui.ctx(), s, app.engine.revision, clips, (*from_track, *to_track), *grab_at) + && let Some(dest) = &plan.dest + { + for &(t, start, len) in &plan.moving { + let d = ops::lands(dest, t); + let start = plan + .groups + .iter() + .find(|g| g.0 == d) + .map_or(start + delta, |(_, first, slots)| ops::nearest_slot(slots, first + delta) + (start - first)); + if let Some((_, r)) = app.edit_layout.rows.iter().find(|(i, _)| *i == d.0) { + let x0 = x_of(s, tl, start); + let x1 = x_of(s, tl, start + len); painter.rect_stroke( Rect::from_min_max(pos2(x0, r[1] + 1.0), pos2(x1, r[3] - 1.0)), 2.0, diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 885824d..bc16b79 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -137,6 +137,13 @@ pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) { painter.line_segment([c0, c1], Stroke::new(s.width * 1.4, c)); } } + "lock" => { + painter.rect_filled(Rect::from_min_max(p(r, 0.22, 0.46), p(r, 0.78, 0.88)), r.width() * 0.08, c); + let (x, y, w) = (r.center().x, r.min.y + r.height() * 0.46, r.width() * 0.19); + let arc: Vec = + (0..=12).map(|i| std::f32::consts::PI * i as f32 / 12.0).map(|a| pos2(x - w * a.cos(), y - w * 1.4 * a.sin())).collect(); + painter.add(Shape::line(arc, s)); + } "wave" => { let n = 24; let pts: Vec = (0..=n) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0778412..ba7c2b9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -22,6 +22,7 @@ pub mod panels; pub mod recovery; pub mod score_editor; pub mod shortcuts; +pub mod spot; pub mod theme; pub mod toolbar; pub mod video_track; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f5414c4..0a815ce 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -133,13 +133,21 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { true } Key::F1 | Key::F2 | Key::F3 | Key::F4 if plain => { - let mode = match key { - Key::F1 => "shuffle", - Key::F2 => "slip", - Key::F3 => "spot", - _ => "grid", + edit_mode_key(app, key); + true + } + Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 if m.alt && !m.command && !m.ctrl && !m.shift => { + let f = match key { + Key::Num1 => Key::F1, + Key::Num2 => Key::F2, + Key::Num3 => Key::F3, + _ => Key::F4, }; - let _ = app.run("edit.mode", json!({"mode": mode})); + edit_mode_key(app, f); + true + } + Key::Backtick if plain => { + cycle_edit_mode(app); true } Key::F5 | Key::F6 | Key::F7 | Key::F8 | Key::F9 | Key::F10 if plain => { @@ -233,6 +241,68 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { } } +/// F1–F4 (and Alt+1–4) choose Shuffle, Slip, Spot and Grid; F4 in Grid switches between Absolute and +/// Relative unless Edit/Tool Mode Keyboard Lock is on. Holding F4 with F1–F3, in either order, adds Snap to Grid. +fn edit_mode_key(app: &mut SoundApp, pressed: Key) { + let ctx = app.docking_context.clone(); + let held = |k: Key| ctx.as_ref().is_some_and(|c| c.input(|i| i.key_down(k))); + let (key, snap) = match [Key::F1, Key::F2, Key::F3].into_iter().find(|k| *k != pressed && held(*k)) { + Some(k) if pressed == Key::F4 => (k, true), + _ => (pressed, pressed != Key::F4 && held(Key::F4)), + }; + let ed = &app.engine.session().edit; + let keep_grid = ed.edit_mode.is_grid() && ed.flag("options.edit_tool_mode_keyboard_lock"); + // F4 pressed first in an F4+F1–F3 chord switched the Grid mode on its own; put that back. + let flipped = egui::Id::new("edit_mode_key_grid_before_f4"); + let before = + (ed.edit_mode.is_grid() && !keep_grid).then(|| if ed.flag(soundcraft_engine::edit::RELATIVE_GRID) { "relative_grid" } else { "grid" }); + let restore = match &ctx { + Some(c) if pressed == Key::F4 => { + c.data_mut(|d| d.insert_temp(flipped, before)); + None + } + Some(c) if snap => c.data_mut(|d| d.remove_temp::>(flipped)).flatten(), + _ => None, + }; + let mode = match key { + Key::F1 => "shuffle", + Key::F2 => "slip", + Key::F3 => "spot", + _ if keep_grid => return, + _ => grid_mode(app), + }; + if let Some(grid) = restore { + let _ = app.run("edit.mode", json!({"mode": grid})); + } + let _ = app.run("edit.mode", json!({"mode": mode, "snap_to_grid": snap})); +} + +/// What choosing Grid picks: in Grid the other Grid mode, from another mode the one used last. +pub(crate) fn grid_mode(app: &SoundApp) -> &'static str { + use soundcraft_model::EditMode; + let ed = &app.engine.session().edit; + match ed.edit_mode { + EditMode::Grid => "relative_grid", + EditMode::GridRelative => "grid", + _ if ed.flag(soundcraft_engine::edit::RELATIVE_GRID) => "relative_grid", + _ => "grid", + } +} + +/// ` steps through the Edit modes in F-key order, past a locked Shuffle. +fn cycle_edit_mode(app: &mut SoundApp) { + use soundcraft_model::EditMode; + let ed = &app.engine.session().edit; + let mode = match ed.edit_mode { + EditMode::Shuffle => "slip", + EditMode::Slip => "spot", + EditMode::Spot => grid_mode(app), + _ if ed.flag(soundcraft_engine::edit::SHUFFLE_LOCK) => "slip", + _ => "shuffle", + }; + let _ = app.run("edit.mode", json!({"mode": mode})); +} + /// Single-key commands. Returns true when `key` was handled. fn commands_focus(app: &mut SoundApp, key: Key, shift: bool) -> bool { let id = match key { diff --git a/crates/ui-egui/src/spot.rs b/crates/ui-egui/src/spot.rs new file mode 100644 index 0000000..c9b4102 --- /dev/null +++ b/crates/ui-egui/src/spot.rs @@ -0,0 +1,173 @@ +//! The Spot dialog: exact positions for clips in Spot mode. + +use crate::theme::mono; +use egui::{Id, RichText, TextEdit, Ui, vec2}; +use serde_json::{Value, json}; +use soundcraft_engine::cmd::MAX_POSITION; +use soundcraft_engine::edit::first_movable; +use soundcraft_model::{ClipId, Session, SourceId, TrackId}; +use soundcraft_time::{Samples, TimeFormat, format_length}; + +#[derive(Debug, Clone)] +pub enum Target { + /// Clips moving together; the fields follow the earliest. + Clips { clips: Vec, track: Option }, + /// The edge of a clip the Trim tool took. + Trim { clip: ClipId, start: bool }, + /// An audio file dropped from the Clip List. + Source { source: SourceId, track: TrackId }, +} + +#[derive(Debug, Clone)] +pub struct Spot { + pub target: Target, + pub start: Samples, + pub len: Samples, + sync: Samples, + stamp: Option, + format: TimeFormat, + text: [String; 3], + fresh: bool, +} + +const FIELDS: [&str; 3] = ["Start", "Sync Point", "End"]; + +impl Spot { + fn new(s: &Session, target: Target, start: Samples, len: Samples, sync: Samples, stamp: Option) -> Spot { + Spot { target, start, len, sync, stamp, format: s.edit.main_counter, text: Default::default(), fresh: true } + } + + /// Clicked or dragged clips, moved `delta` so far. + pub fn clips(s: &Session, clips: Vec, track: Option, delta: Samples) -> Option { + let c = first_movable(s, &clips)?; + let stamp = c.source().and_then(|id| s.source(id)).map(|src| recorded_at(s, src.time_reference).saturating_add(c.source_offset())); + Some(Spot::new(s, Target::Clips { clips, track }, c.start.saturating_add(delta), c.length, c.sync_point, stamp)) + } + + /// A clip's start or end edge, dragged to `to`. + pub fn trim(s: &Session, clip: ClipId, start: bool, to: Samples) -> Option { + let (_, c) = s.find_clip(clip)?; + let (a, b) = if start { (to.min(c.end().saturating_sub(1)), c.end()) } else { (c.start, to.max(c.start.saturating_add(1))) }; + Some(Spot::new(s, Target::Trim { clip, start }, a, b.saturating_sub(a), 0, None)) + } + + /// An audio file from the Clip List, dropped at `at`. + pub fn source(s: &Session, source: SourceId, track: TrackId, at: Samples) -> Option { + let src = s.source(source)?; + let len = i64::try_from(src.frames).unwrap_or(0); + Some(Spot::new(s, Target::Source { source, track }, at, len, 0, Some(recorded_at(s, src.time_reference)))) + } + + fn value(&self, field: usize) -> Samples { + match field { + 0 => self.start, + 1 => self.start.saturating_add(self.sync), + _ => self.start.saturating_add(self.len), + } + } + + fn set(&mut self, field: usize, v: Samples) { + match (&self.target, field) { + (Target::Trim { start: true, .. }, _) => { + self.len = self.start.saturating_add(self.len).saturating_sub(v); + self.start = v; + } + (Target::Trim { .. }, _) => self.len = v.saturating_sub(self.start), + (_, 0) => self.start = v, + (_, 1) => self.start = v.saturating_sub(self.sync), + _ => self.start = v.saturating_sub(self.len), + } + } + + fn editable(&self, field: usize) -> bool { + match self.target { + Target::Trim { start, .. } => field == if start { 0 } else { 2 }, + _ => true, + } + } + + /// The command OK runs; None when nothing would change. + pub fn command(&self, s: &Session) -> Option<(String, Value)> { + Some(match &self.target { + Target::Clips { clips, track } => { + let first = first_movable(s, clips)?.start; + let by = self.start.saturating_sub(first); + if by == 0 && track.is_none() { + return None; + } + let mut p = json!({"clips": clips.iter().map(|c| c.0).collect::>(), "by": by}); + if let Some(t) = track { + p["track"] = json!(t.0); + } + ("edit.move_clips".into(), p) + } + Target::Trim { clip, start: true } => ("edit.trim_clip".into(), json!({"clip": clip.0, "start": self.start})), + Target::Trim { clip, .. } => ("edit.trim_clip".into(), json!({"clip": clip.0, "end": self.value(2)})), + Target::Source { source, track } => ("clip.place_source".into(), json!({"source": source.0, "track": track.0, "at": self.start})), + }) + } +} + +/// Timeline position of a BWF time stamp (samples since midnight). +fn recorded_at(s: &Session, time_reference: u64) -> Samples { + i64::try_from(time_reference).unwrap_or(0).saturating_sub(s.timecode_start) +} + +/// The dialog's fields; the caller adds OK and Cancel. +pub fn fields(ui: &mut Ui, s: &Session, d: &mut Spot) { + let show = |at: Samples, f: TimeFormat| s.format_position(at, f); + egui::Grid::new("spot").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label("Time Scale"); + egui::ComboBox::from_id_salt("spot_scale").selected_text(d.format.label()).show_ui(ui, |ui| { + for f in TimeFormat::ALL { + ui.selectable_value(&mut d.format, f, f.label()); + } + }); + ui.end_row(); + for (i, label) in FIELDS.into_iter().enumerate() { + if i == 1 && matches!(d.target, Target::Trim { .. }) { + continue; + } + ui.label(label); + if d.editable(i) { + let id = Id::new(("spot_field", i)); + if !ui.memory(|m| m.has_focus(id)) { + d.text[i] = show(d.value(i), d.format); + } + let r = ui.add(TextEdit::singleline(&mut d.text[i]).id(id).font(mono(13.0)).desired_width(150.0)); + if r.changed() + && let Ok(v) = s.parse_position(&d.text[i], d.format) + && (-MAX_POSITION..=MAX_POSITION).contains(&v) + { + d.set(i, v); + } + if std::mem::take(&mut d.fresh) { + r.request_focus(); + select_all(ui, id, &d.text[i]); + } + } else { + ui.label(RichText::new(show(d.value(i), d.format)).font(mono(13.0))); + } + ui.end_row(); + } + ui.label("Duration"); + ui.label(RichText::new(format_length(d.start, d.len, d.format, s.sample_rate, &s.tempo, s.frame_rate)).font(mono(13.0))); + ui.end_row(); + if let Some(at) = d.stamp { + ui.label("Original Time Stamp"); + ui.horizontal(|ui| { + ui.label(RichText::new(show(at, TimeFormat::Timecode)).font(mono(13.0))); + if ui.button("Use").on_hover_text("Spot to where it was recorded").clicked() { + d.start = at; + } + }); + ui.end_row(); + } + }); +} + +fn select_all(ui: &Ui, id: Id, text: &str) { + let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::default(), egui::text::CCursor::new(text.chars().count())))); + state.store(ui.ctx(), id); +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 4b53e7d..6eeb3f5 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -5,6 +5,7 @@ use crate::widgets::{icon_button, key, key_fill}; use crate::{SoundApp, icons}; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; +use soundcraft_engine::edit::{SHUFFLE_LOCK, SNAP_TO_GRID}; use soundcraft_model::{EditMode, Tool}; use soundcraft_time::{TimeFormat, format_length}; @@ -44,7 +45,9 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { fn edit_modes(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); - let cur = app.engine.session().edit.edit_mode; + let ed = &app.engine.session().edit; + let (cur, snap, locked) = (ed.edit_mode, ed.flag(SNAP_TO_GRID), ed.flag(SHUFFLE_LOCK)); + let cmd = crate::shortcuts::shortcut_label("Cmd", ui.ctx().os() == egui::os::OperatingSystem::Mac); let modes = [ (EditMode::Shuffle, 0, 0, "SHUFFLE", "F1"), (EditMode::Spot, 1, 0, "SPOT", "F3"), @@ -62,23 +65,49 @@ fn edit_modes(app: &mut SoundApp, ui: &mut Ui) { let resp = ui.interact(cell, ui.id().with(("mode", label)), Sense::click()); let on = cur == m || (m == EditMode::Grid && cur == EditMode::GridRelative); key(ui.painter(), cell, &resp, if on { t.mode_on } else { t.mode_off }); - let txt = if m == EditMode::Grid && cur == EditMode::GridRelative { "REL GRID" } else { label }; - ui.painter().text(cell.center(), Align2::CENTER_CENTER, txt, bold(10.5), if on { t.mode_on_text } else { t.mode_off_text }); - if resp.clicked() { - let id = match m { - EditMode::Grid if cur == EditMode::Grid => "relative_grid", - EditMode::Shuffle => "shuffle", - EditMode::Spot => "spot", - EditMode::Slip => "slip", - _ => "grid", - }; - let _ = app.run("edit.mode", json!({"mode": id})); + let fg = if on { t.mode_on_text } else { t.mode_off_text }; + if m == EditMode::Grid && snap { + ui.painter().rect_stroke(cell, 1.0, Stroke::new(1.5, t.mode_on), StrokeKind::Inside); } - resp.on_hover_text(format!("{} mode ({fkey})", label.to_lowercase())); + if m == EditMode::Shuffle && locked { + icons::draw(ui.painter(), Rect::from_center_size(cell.center(), vec2(15.0, 15.0)), "lock", fg); + } else { + let txt = if m == EditMode::Grid && cur == EditMode::GridRelative { "REL GRID" } else { label }; + ui.painter().text(cell.center(), Align2::CENTER_CENTER, txt, bold(10.5), fg); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, label)); + if resp.clicked() { + mode_click(app, m, ui.input(|i| i.modifiers)); + } + resp.on_hover_text(match m { + EditMode::Shuffle if locked => format!("Shuffle is locked ({cmd}-click to unlock)"), + EditMode::Shuffle => format!("Shuffle mode ({fkey}); {cmd}-click locks it out"), + EditMode::Grid => format!("Grid mode ({fkey}; again for Relative); Shift-click: Snap to Grid in the other modes"), + _ => format!("{} mode ({fkey}); Shift-click adds Snap to Grid", if m == EditMode::Slip { "Slip" } else { "Spot" }), + }); } }); } +/// Shift adds Snap to Grid to Shuffle, Slip and Spot (on Grid it toggles it for the current mode); +/// Cmd/Ctrl on Shuffle toggles Shuffle Lock. +fn mode_click(app: &mut SoundApp, m: EditMode, mods: egui::Modifiers) { + let cur = app.engine.session().edit.edit_mode; + let _ = if m == EditMode::Shuffle && mods.command { + app.run("edit.shuffle_lock", json!({})) + } else if m == EditMode::Grid && mods.shift && !cur.is_grid() { + app.run("edit.snap_to_grid", json!({})) + } else { + let id = match m { + EditMode::Shuffle => "shuffle", + EditMode::Spot => "spot", + EditMode::Slip => "slip", + _ => crate::shortcuts::grid_mode(app), + }; + app.run("edit.mode", json!({"mode": id, "snap_to_grid": mods.shift})) + }; +} + fn zoom_group(app: &mut SoundApp, ui: &mut Ui) { group(ui, 128.0, 52.0, |ui, _| { ui.vertical(|ui| { diff --git a/crates/ui-egui/tests/edit_modes.rs b/crates/ui-egui/tests/edit_modes.rs new file mode 100644 index 0000000..ab7e05e --- /dev/null +++ b/crates/ui-egui/tests/edit_modes.rs @@ -0,0 +1,293 @@ +use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; +use egui_kittest::kittest::Queryable; +use serde_json::json; +use soundcraft_engine::Engine; +use soundcraft_engine::edit::{RELATIVE_GRID, SHUFFLE_LOCK, SNAP_TO_GRID}; +use soundcraft_model::{Clip, ClipId, EditMode, Source, SourceId, TrackId}; +use soundcraft_ui_egui::{Services, SoundApp, edit_window}; + +const SEC: i64 = 48_000; + +fn frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec) { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +fn key(key: Key) -> Vec { + chord(key, Modifiers::NONE) +} + +fn chord(key: Key, modifiers: Modifiers) -> Vec { + [true, false].map(|pressed| key_event(key, pressed, modifiers)).to_vec() +} + +fn key_event(key: Key, pressed: bool, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: None, pressed, repeat: false, modifiers } +} + +fn app(mode: &str, tool: &str, spans: &[(i64, i64)]) -> (SoundApp, egui::Context, TrackId, Vec) { + let (app, ctx, tracks, ids) = tracks(mode, tool, &[spans]); + (app, ctx, tracks[0], ids) +} + +fn tracks(mode: &str, tool: &str, lanes: &[&[(i64, i64)]]) -> (SoundApp, egui::Context, Vec, Vec) { + let mut e = Engine::default(); + let made = e.execute("track.new", &json!({"count": lanes.len()})).unwrap()["tracks"].clone(); + let tracks: Vec = (0..lanes.len()).map(|i| TrackId(made[i].as_u64().unwrap())).collect(); + let s = e.session_mut(); + s.sources.push(Source { + id: SourceId(7), + name: "src".into(), + path: "src.wav".into(), + channels: 1, + frames: 20 * SEC as u64, + sample_rate: 48_000, + format: soundcraft_audio_io::FileFormat::Wav, + time_reference: 0, + unsaved: false, + }); + let mut ids = Vec::new(); + for (t, spans) in tracks.iter().zip(lanes) { + for &(start, len) in *spans { + let id = s.new_clip_id(); + s.track_mut(*t).and_then(|t| t.playlist_mut()).unwrap().clips.push(Clip::audio(id, "c", SourceId(7), start, start, len)); + ids.push(id); + } + } + e.execute("edit.mode", &json!({"mode": mode})).unwrap(); + e.execute("edit.tool", &json!({"tool": tool})).unwrap(); + e.execute("view.zoom_set", &json!({"samples_per_px": 400.0})).unwrap(); + let mut app = SoundApp::new(e, None, Services::default()); + let ctx = egui::Context::default(); + frame(&ctx, &mut app, vec![]); + frame(&ctx, &mut app, vec![]); + (app, ctx, tracks, ids) +} + +fn point(app: &SoundApp, t: TrackId, at: i64) -> Pos2 { + let s = app.engine.session(); + let [x0, y0, x1, y1] = app.edit_layout.timeline; + let row = app.edit_layout.rows.iter().find(|(id, _)| *id == t.0).unwrap().1; + let x = edit_window::x_of(s, Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)), at); + pos2(x, row[1] + s.track(t).unwrap().height.points() * 0.75) +} + +fn drag(ctx: &egui::Context, app: &mut SoundApp, t: TrackId, from: i64, to: i64) { + let (from, to) = (point(app, t, from), point(app, t, to)); + drag_between(ctx, app, from, to); +} + +fn drag_between(ctx: &egui::Context, app: &mut SoundApp, from: Pos2, to: Pos2) { + frame(ctx, app, vec![Event::PointerMoved(from)]); + frame(ctx, app, vec![button(from, true)]); + let started = from + vec2(7.0_f32.copysign(to.x - from.x), 0.0); + for k in 0..=5 { + frame(ctx, app, vec![Event::PointerMoved(started + (to - started) * (k as f32 / 5.0))]); + } + frame(ctx, app, vec![button(to, false)]); + frame(ctx, app, vec![]); +} + +fn click(ctx: &egui::Context, app: &mut SoundApp, at: Pos2) { + frame(ctx, app, vec![Event::PointerMoved(at)]); + frame(ctx, app, vec![button(at, true)]); + frame(ctx, app, vec![button(at, false)]); +} + +fn starts(app: &SoundApp, ids: &[ClipId]) -> Vec { + ids.iter().map(|id| app.engine.session().find_clip(*id).unwrap().1.start).collect() +} + +#[test] +fn shuffle_drags_swap_clips() { + let (mut app, ctx, t, ids) = app("shuffle", "grabber", &[(0, SEC), (SEC, SEC)]); + drag(&ctx, &mut app, t, SEC * 3 / 2, SEC / 2 - 2000); + assert_eq!(starts(&app, &ids), vec![SEC, 0]); +} + +#[test] +fn grid_drags_snap_the_dragged_clip_not_the_one_under_the_pointer() { + let (mut app, ctx, t, ids) = app("grid", "grabber", &[(1000, SEC), (3 * SEC + 777, SEC)]); + let s = app.engine.session(); + let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate); + drag(&ctx, &mut app, t, SEC / 2, 3 * SEC + SEC / 2); + assert_eq!(starts(&app, &ids[..1])[0] % step, 0); +} + +#[test] +fn spot_clicks_open_the_spot_dialog() { + let (mut app, ctx, t, ids) = app("spot", "grabber", &[(SEC, SEC)]); + let at = point(&app, t, SEC * 3 / 2); + click(&ctx, &mut app, at); + frame(&ctx, &mut app, vec![]); + assert_eq!(app.dialogs.open_name(), Some("spot")); + frame(&ctx, &mut app, vec![Event::Text("4.5".into())]); + frame(&ctx, &mut app, key(Key::Enter)); + assert!(app.dialogs.open.is_none()); + assert_eq!(starts(&app, &ids), vec![SEC * 9 / 2]); +} + +#[test] +fn spot_drags_ask_where_before_moving() { + let (mut app, ctx, t, ids) = app("spot", "grabber", &[(SEC, SEC)]); + drag(&ctx, &mut app, t, SEC * 3 / 2, SEC * 5 / 2); + assert_eq!(starts(&app, &ids), vec![SEC]); + assert_eq!(app.dialogs.open_name(), Some("spot")); + frame(&ctx, &mut app, key(Key::Enter)); + assert!((starts(&app, &ids)[0] - 2 * SEC).abs() < 4000); +} + +#[test] +fn slip_trims_stop_at_the_next_clip() { + let (mut app, ctx, t, ids) = app("slip", "trim", &[(0, SEC), (2 * SEC, SEC)]); + drag(&ctx, &mut app, t, SEC * 3 / 4, 4 * SEC); + let s = app.engine.session(); + assert_eq!(s.find_clip(ids[0]).unwrap().1.end(), 2 * SEC); + assert_eq!(starts(&app, &ids), vec![0, 2 * SEC]); +} + +#[test] +fn f4_toggles_absolute_and_relative_grid_and_comes_back_to_the_last_one() { + let (mut app, ctx, _, _) = app("slip", "grabber", &[]); + let mode = |app: &SoundApp| app.engine.session().edit.edit_mode; + let mut seen = Vec::new(); + for k in [Key::F4, Key::F4, Key::F4, Key::F4, Key::F2, Key::F4, Key::F1, Key::Backtick, Key::Backtick, Key::Backtick] { + frame(&ctx, &mut app, key(k)); + seen.push(mode(&app)); + } + use EditMode::*; + assert_eq!(seen, [Grid, GridRelative, Grid, GridRelative, Slip, GridRelative, Shuffle, Slip, Spot, GridRelative]); + app.engine.execute("options.keyboard_lock", &json!({})).unwrap(); + frame(&ctx, &mut app, key(Key::F4)); + frame(&ctx, &mut app, chord(Key::Num4, Modifiers::ALT)); + assert_eq!(mode(&app), GridRelative); +} + +#[test] +fn snap_to_grid_snaps_the_edit_cursor_while_slip_moves_stay_free() { + let (mut app, ctx, t, ids) = app("slip", "grabber", &[(1000, SEC)]); + app.engine.execute("edit.snap_to_grid", &json!({})).unwrap(); + let s = app.engine.session(); + let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate); + drag(&ctx, &mut app, t, SEC / 2, SEC / 2 + 10_000); + let moved = starts(&app, &ids)[0]; + assert!(moved > 1000 && moved % step != 0, "{moved}"); + app.engine.execute("edit.tool", &json!({"tool": "selector"})).unwrap(); + let at = point(&app, t, 3 * SEC + 1234); + click(&ctx, &mut app, at); + assert_eq!(app.engine.session().edit.selection.start % step, 0); +} + +#[test] +fn mode_keys_add_snap_to_grid_and_cycle_past_a_locked_shuffle() { + let (mut app, ctx, _, _) = app("slip", "grabber", &[]); + let state = |app: &SoundApp| (app.engine.session().edit.edit_mode, app.engine.session().edit.flag(SNAP_TO_GRID)); + frame(&ctx, &mut app, chord(Key::F4, Modifiers::SHIFT)); + assert_eq!(state(&app), (EditMode::Slip, true)); + let f4 = |pressed| key_event(Key::F4, pressed, Modifiers::NONE); + frame(&ctx, &mut app, vec![f4(true)]); + assert_eq!(state(&app), (EditMode::Grid, false)); + frame(&ctx, &mut app, key(Key::F1)); + frame(&ctx, &mut app, vec![f4(false)]); + assert_eq!(state(&app), (EditMode::Shuffle, true)); + frame(&ctx, &mut app, chord(Key::Num3, Modifiers::ALT)); + assert_eq!(state(&app), (EditMode::Spot, false)); + app.engine.execute("edit.shuffle_lock", &json!({})).unwrap(); + let mut seen = Vec::new(); + for _ in 0..4 { + frame(&ctx, &mut app, key(Key::Backtick)); + seen.push(state(&app).0); + } + assert_eq!(seen, [EditMode::Grid, EditMode::Slip, EditMode::Spot, EditMode::Grid]); + frame(&ctx, &mut app, key(Key::F1)); + assert_eq!(state(&app).0, EditMode::Grid); +} + +#[test] +fn toolbar_clicks_add_snap_to_grid_lock_shuffle_and_switch_grid_modes_despite_the_keyboard_lock() { + let mut e = Engine::default(); + e.execute("edit.mode", &json!({"mode": "slip"})).unwrap(); + let app = SoundApp::new(e, None, Services::default()); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + let click = |h: &mut egui_kittest::Harness<'_, SoundApp>, label: &str, modifiers: Modifiers| { + h.get_by_label(label).click_modifiers(modifiers); + h.step(); + }; + let state = |h: &egui_kittest::Harness<'_, SoundApp>| { + let ed = &h.state().engine.session().edit; + (ed.edit_mode, ed.flag(SNAP_TO_GRID), ed.flag(SHUFFLE_LOCK)) + }; + click(&mut h, "GRID", Modifiers::SHIFT); + assert_eq!(state(&h), (EditMode::Slip, true, false)); + click(&mut h, "SPOT", Modifiers::NONE); + assert_eq!(state(&h), (EditMode::Spot, false, false)); + click(&mut h, "SHUFFLE", Modifiers::COMMAND); + click(&mut h, "SHUFFLE", Modifiers::NONE); + assert_eq!(state(&h), (EditMode::Spot, false, true)); + assert!(h.state().ui.status.contains("locked"), "{}", h.state().ui.status); + click(&mut h, "SLIP", Modifiers::SHIFT); + assert_eq!(state(&h), (EditMode::Slip, true, true)); + h.state_mut().engine.execute("options.keyboard_lock", &json!({})).unwrap(); + for (label, want) in [("GRID", EditMode::Grid), ("GRID", EditMode::GridRelative), ("SLIP", EditMode::Slip), ("GRID", EditMode::GridRelative)] { + click(&mut h, label, Modifiers::NONE); + assert_eq!(state(&h).0, want, "{label}"); + } +} + +#[test] +fn dragging_clips_on_two_tracks_moves_both_down_a_track() { + let (mut app, ctx, t, ids) = tracks("slip", "grabber", &[&[(0, SEC)], &[(2 * SEC, SEC)], &[]]); + app.engine.execute("edit.select", &json!({"clips": [ids[0].0, ids[1].0]})).unwrap(); + let (from, to) = (point(&app, t[0], SEC / 2), point(&app, t[1], SEC / 2)); + drag_between(&ctx, &mut app, from, to); + let s = app.engine.session(); + assert_eq!(s.find_clip(ids[0]).map(|(t, _)| t), Some(t[1])); + assert_eq!(s.find_clip(ids[1]).map(|(t, _)| t), Some(t[2])); +} + +#[test] +fn shuffle_drags_of_clips_on_two_tracks_land_on_the_grabbed_tracks_boundaries() { + let lanes: [&[(i64, i64)]; 2] = [&[(4 * SEC, SEC), (5 * SEC, 3 * SEC), (17 * SEC / 2, SEC / 2)], &[(0, SEC), (SEC, 6 * SEC)]]; + let (mut app, ctx, t, ids) = tracks("shuffle", "grabber", &lanes); + app.engine.execute("edit.select", &json!({"clips": [ids[0].0, ids[3].0]})).unwrap(); + let before = starts(&app, &ids); + drag(&ctx, &mut app, t[0], 9 * SEC / 2, 9 * SEC / 2 + 3 * SEC / 10); + assert_eq!(starts(&app, &ids), before); +} + +#[test] +fn shuffle_drags_leave_locked_clips_in_the_selection_out_of_it() { + let (mut app, ctx, t, ids) = app("shuffle", "grabber", &[(0, SEC), (3 * SEC, SEC)]); + app.engine.session_mut().find_clip_mut(ids[0]).unwrap().time_locked = true; + app.engine.execute("edit.select", &json!({"clips": [ids[0].0, ids[1].0]})).unwrap(); + drag(&ctx, &mut app, t, 7 * SEC / 2, 2 * SEC); + assert_eq!(starts(&app, &ids), vec![0, SEC]); +} + +#[test] +fn f4_pressed_before_f1_leaves_the_kind_of_grid_alone() { + let (mut app, ctx, _, _) = app("grid", "grabber", &[]); + let f4 = |pressed| key_event(Key::F4, pressed, Modifiers::NONE); + frame(&ctx, &mut app, vec![f4(true)]); + frame(&ctx, &mut app, key(Key::F1)); + frame(&ctx, &mut app, vec![f4(false)]); + let ed = &app.engine.session().edit; + assert_eq!((ed.edit_mode, ed.flag(SNAP_TO_GRID), ed.flag(RELATIVE_GRID)), (EditMode::Shuffle, true, false)); +}