From 72288fc77eeb7153d9fe7407e6e0d36a67412cb4 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:20:48 +0100 Subject: [PATCH 1/2] Make the Shuffle, Slip, Spot and Grid edit modes work like Pro Tools The edit modes only partly worked. Shuffle changed Cut, Clear and Paste but not moves or trims. Spot froze drags and had no dialog. Grid snapped relative to whichever clip was under the pointer, and Relative Grid acted like Absolute. Behaviour now follows the Edit Modes chapter of the Pro Tools Reference Guide. Shuffle - Grabber drags lift the clips out (later clips close the gap) and land them at the nearest clip boundary, pushing later clips right; an empty track takes them at the session start. The drag outline shows where each track's clips will land. - Trims move the later clips (a trimmed start stays put and the material slides under it). Duplicate, Duplicate and Extend, Repeat and Clip List drops make room, and Paste over a selection replaces it. - Moves lift each clip by id and close the hole it leaves, so crossfade partners and unselected clips keep their audio, and a crossfaded pair moved together stays a crossfade. Ripples never push clips past the session start, never split a crossfade (refused with a reason), and drops never land inside a clip or crossfade. - A time- or edit-locked clip and everything after it stay put when Shuffle closes or opens gaps. Edits that would push into one are refused ("not enough room before a locked clip"), and trims stop short of it. Insert Time and Cut Time are timeline operations and still move everything. - With Automation Follows Edit, automation moves with every ripple, up to a locked clip, and travels with moved clips; with it off, Shuffle edits leave automation alone. Shuffle Clear used to move the clips but leave their automation behind (#232). - Shuffle Lock: Cmd-click (Ctrl-click on Windows and Linux) the Shuffle button while in another mode. While it's on, the button shows a padlock and every way into Shuffle (keys, button, commands) is refused. - A drag works out every lift and landing point once per target track (`lift_plan`, `shuffle_slots`, and a `DragPlan` kept for the drag). In a release build, dragging 100 clips on a track of 20,000 took 29 ms a frame and a 135 ms drop; now it's under a microsecond a frame and a 5 ms drop. Slip, Spot and Grid - Slip: trims stop at the neighbouring clips instead of overlapping them. - Spot: a Grabber click or drag, a Trim click or drag and a Clip List drop open a Spot dialog: Start, Sync Point and End in any time scale, the Duration, and the clip's Original Time Stamp. Trim clicks pick the nearer edge. - Grid: a drag snaps the dragged clip's start (its sync point, when it has one inside the clip) to the grid. Relative Grid moves by whole grid steps and keeps the clip's offset from the grid; a cut or copy pasted in Relative Grid lands as far after a grid line as the copied range started after one. Holding Cmd (Ctrl on Windows and Linux) while dragging or dropping from the Clip List swaps Grid and Slip. Grid lines no longer fall a step short at tempos where a line isn't a whole number of samples. - Choosing Grid from another mode picks whichever of Absolute and Relative Grid was used last; F4 in Grid toggles them, like clicking the Grid button again. Options > Edit/Tool Mode Keyboard Lock, which did nothing, now stops F4 and Alt+4 from switching them (toolbar clicks still do). - Snap to Grid in Shuffle, Slip and Spot: Shift+F4, Shift-click on the Grid button or on another mode's button, or hold F4 with F1-F3. The Edit cursor and selections snap to the grid while clip moves and trims follow the main mode; the Grid button is outlined while it's on. - Option+1-4 (Alt+1-4 on Windows and Linux) work like F1-F4, and the ` key cycles Shuffle, Slip, Spot and Grid, skipping a locked Shuffle. - Insert Silence pushes later material only in Shuffle; in the other modes it clears the selection, as in Pro Tools. Also - Dragging clips from several tracks keeps each track's distance from the grabbed clip's track. Drops past the last track, or onto a track that can't play the clips (audio onto MIDI, an aux or the master), are refused; clips moved onto an aux used to vanish into its unused playlist. - Undo and Redo keep the Edit mode, Shuffle Lock, Snap to Grid and the Grid kind. Start trims keep a clip's sync point on its audio, or drop it. Imported BWF time stamps are converted from the file's rate (read from its header) to the session's, and the Original Time Stamp no longer adds the session start twice. Typed positions that overflow are ignored instead of panicking. Commands: `edit.move_clips` follows the mode, and its `track` moves the first clip's track there and keeps the others' distances. `edit.trim_clip`, `edit.snap_to_grid` (Shift+F4) and `edit.shuffle_lock` are new, and `edit.mode` takes `snap_to_grid`. Mode buttons expose their names to accessibility tools. Tests: crates/{engine,ui-egui}/tests/edit_modes.rs and the op tests in edit.rs; the random-edits property test also toggles clip locks. --- crates/engine/src/cmd/audio_midi.rs | 2 +- crates/engine/src/cmd/clip.rs | 18 +- crates/engine/src/cmd/edit.rs | 187 +++++++-- crates/engine/src/cmd/edit_more.rs | 9 +- crates/engine/src/cmd/event.rs | 3 +- crates/engine/src/cmd/midi.rs | 4 - crates/engine/src/cmd/mod.rs | 4 + crates/engine/src/cmd/track.rs | 2 +- crates/engine/src/edit.rs | 595 +++++++++++++++++++++++++--- crates/engine/src/io.rs | 13 +- crates/engine/src/lib.rs | 28 ++ crates/engine/tests/edit_modes.rs | 415 +++++++++++++++++++ crates/engine/tests/random_edits.rs | 4 +- crates/model/src/clip.rs | 12 + crates/time/src/format.rs | 12 + crates/time/src/grid.rs | 23 +- crates/time/src/tempo.rs | 2 +- crates/time/src/timecode.rs | 10 +- crates/ui-egui/src/dialogs.rs | 10 + crates/ui-egui/src/edit_window.rs | 183 ++++++--- crates/ui-egui/src/icons.rs | 7 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/shortcuts.rs | 82 +++- crates/ui-egui/src/spot.rs | 173 ++++++++ crates/ui-egui/src/toolbar.rs | 55 ++- crates/ui-egui/tests/edit_modes.rs | 293 ++++++++++++++ 26 files changed, 1955 insertions(+), 192 deletions(-) create mode 100644 crates/engine/tests/edit_modes.rs create mode 100644 crates/ui-egui/src/spot.rs create mode 100644 crates/ui-egui/tests/edit_modes.rs 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 520e537..ec4b47a 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})) } ), @@ -297,7 +302,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; } } @@ -341,14 +346,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 776fe02..2d6fddf 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}; @@ -126,11 +127,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}", 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; @@ -141,10 +164,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), ] } @@ -153,6 +185,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 restore_selection(e: &mut Engine, _: &Value) -> Result { Ok(json!({"selection": e.session().edit.selection})) } @@ -163,7 +224,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)); @@ -205,15 +266,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); } @@ -222,17 +275,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); @@ -329,7 +404,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; @@ -453,12 +528,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 { @@ -478,11 +555,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})) @@ -493,19 +572,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); + } } } } @@ -718,7 +803,7 @@ fn nudge(e: &mut Engine, p: &Value) -> Result { let step = s.edit.nudge.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); @@ -731,26 +816,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 c5c1082..25dcde0 100644 --- a/crates/engine/src/cmd/edit_more.rs +++ b/crates/engine/src/cmd/edit_more.rs @@ -284,7 +284,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()); @@ -545,12 +545,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 { @@ -677,7 +678,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 34fb355..ba63c3f 100644 --- a/crates/engine/src/cmd/event.rs +++ b/crates/engine/src/cmd/event.rs @@ -152,7 +152,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 fe9fdc2..1a369a8 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 d0a453c..6286804 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -297,6 +297,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 2f231f4..fb60d57 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -174,7 +174,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 05cc0e6..b852c50 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(); @@ -279,14 +401,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 { @@ -296,13 +420,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 { @@ -311,23 +720,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(); @@ -460,7 +853,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)]); } @@ -468,7 +861,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)]); } @@ -500,13 +893,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 108330e..aab3043 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -54,6 +54,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); @@ -65,10 +74,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 b607534..173262d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -88,6 +88,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)] @@ -223,6 +235,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; @@ -232,12 +245,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 4d30bea..eb8c8a9 100644 --- a/crates/model/src/clip.rs +++ b/crates/model/src/clip.rs @@ -214,6 +214,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(); self.gain_env = self.gain_env.iter().filter_map(|&(t, v)| (t - delta >= 0).then_some((t - delta, v))).collect(); } @@ -222,6 +226,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(); } @@ -230,6 +235,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 a5f4a1a..b10f82b 100644 --- a/crates/time/src/format.rs +++ b/crates/time/src/format.rs @@ -133,6 +133,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); + } + } + #[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 30e8f63..e7c391e 100644 --- a/crates/time/src/grid.rs +++ b/crates/time/src/grid.rs @@ -123,7 +123,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); @@ -133,7 +139,7 @@ impl GridValue { } _ => { let step = self.step_samples(pos, sr, map, rate); - pos.div_euclid(step) * step + pos.div_euclid(step).saturating_mul(step) } } } @@ -167,6 +173,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/time/src/tempo.rs b/crates/time/src/tempo.rs index 5b87a3a..22b3d82 100644 --- a/crates/time/src/tempo.rs +++ b/crates/time/src/tempo.rs @@ -200,7 +200,7 @@ impl TempoMap { let mut cur_tick = 0i64; let first = self.meter_at_tick(0); if bar < 1 { - return (bar - 1).saturating_mul(first.ticks_per_bar()); + return bar.saturating_sub(1).saturating_mul(first.ticks_per_bar()); } for (i, m) in self.meters.iter().enumerate() { let next_tick = self.meters.get(i + 1).map(|n| n.tick); diff --git a/crates/time/src/timecode.rs b/crates/time/src/timecode.rs index c0db20f..0b34329 100644 --- a/crates/time/src/timecode.rs +++ b/crates/time/src/timecode.rs @@ -109,7 +109,7 @@ impl FrameRate { return 0; } let v = (i128::from(f) * i128::from(sr.hz()) * i128::from(d) + i128::from(n) / 2) / i128::from(n); - i64::try_from(v).unwrap_or(0) + i64::try_from(v).unwrap_or(if v < 0 { i64::MIN } else { i64::MAX }) } /// Samples per frame (fractional). @@ -153,11 +153,11 @@ impl Timecode { /// Absolute frame count of this label. pub fn to_frames(&self, rate: FrameRate) -> i64 { let base = rate.nominal().max(1); - let total_min = self.hours * 60 + self.minutes; - let mut f = (total_min * 60 + self.seconds) * base + self.frames; + let total_min = self.hours.saturating_mul(60).saturating_add(self.minutes); + let mut f = total_min.saturating_mul(60).saturating_add(self.seconds).saturating_mul(base).saturating_add(self.frames); if rate.is_drop() { - let drop = if base >= 60 { 4 } else { 2 }; - f -= drop * (total_min - total_min / 10); + let drop: i64 = if base >= 60 { 4 } else { 2 }; + f = f.saturating_sub(drop.saturating_mul(total_min - total_min / 10)); } if self.negative { -f } else { f } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2f1a34b..36ae7d5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -19,6 +19,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)> }, @@ -46,6 +47,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", @@ -264,6 +266,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", @@ -445,6 +448,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 f13bc78..2dc63f3 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::{rec_toggle, selector_box, text_toggle}; use crate::{Gesture, SoundApp, icons, 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 = 236.0; pub const COLUMN_W: f32 = 112.0; @@ -256,7 +261,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. @@ -1329,7 +1334,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, @@ -1426,7 +1431,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> { @@ -1480,8 +1486,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); @@ -1625,6 +1632,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 => { @@ -1696,22 +1712,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; @@ -1759,16 +1767,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. @@ -1776,8 +1775,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); @@ -1813,22 +1812,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) { @@ -1966,12 +2039,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 7de4119..914061d 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -140,6 +140,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 5c4a751..b2309be 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -20,6 +20,7 @@ pub mod palette; pub mod panels; 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 4d4417b..6f1525a 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -121,13 +121,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 => { @@ -198,6 +206,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) -> 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..77c4c37 --- /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, format_position, parse_position}; + +#[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| format_position(at, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + 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) = parse_position(&d.text[i], d.format, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start) + && (-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 e47df13..d0a809f 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -5,6 +5,7 @@ use crate::widgets::icon_button; 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, format_position}; @@ -39,7 +40,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); group(ui, 112.0, 52.0, |ui, r| { let inner = r.shrink(6.0); let cw = inner.width() / 2.0; @@ -55,23 +58,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); ui.painter().rect_filled(cell, 1.0, 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 ({key})", 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 ({key}); {cmd}-click locks it out"), + EditMode::Grid => format!("Grid mode ({key}; again for Relative); Shift-click: Snap to Grid in the other modes"), + _ => format!("{} mode ({key}); 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)); +} From bb3a6bdd9aef57ba8816285bba7ccaa194b70c7a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:17:58 -0400 Subject: [PATCH 2/2] Fix build after merging main into #365 Co-Authored-By: Claude Opus 5.5 --- crates/time/src/format.rs | 2 +- crates/ui-egui/src/spot.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/time/src/format.rs b/crates/time/src/format.rs index 29f30a0..bdd2fcb 100644 --- a/crates/time/src/format.rs +++ b/crates/time/src/format.rs @@ -150,7 +150,7 @@ mod tests { ("100000000000000:00:00:00", TimeFormat::Timecode), ("-9223372036854775808", TimeFormat::BarsBeats), ] { - let _ = parse_position(text, fmt, sr, &map, rate, 0); + let _ = parse_position(text, fmt, sr, &map, rate, 0, 0); } } diff --git a/crates/ui-egui/src/spot.rs b/crates/ui-egui/src/spot.rs index 77c4c37..c9b4102 100644 --- a/crates/ui-egui/src/spot.rs +++ b/crates/ui-egui/src/spot.rs @@ -6,7 +6,7 @@ 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, format_position, parse_position}; +use soundcraft_time::{Samples, TimeFormat, format_length}; #[derive(Debug, Clone)] pub enum Target { @@ -115,7 +115,7 @@ fn recorded_at(s: &Session, time_reference: u64) -> Samples { /// 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| format_position(at, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + 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| { @@ -136,7 +136,7 @@ pub fn fields(ui: &mut Ui, s: &Session, d: &mut Spot) { } 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) = parse_position(&d.text[i], d.format, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start) + && let Ok(v) = s.parse_position(&d.text[i], d.format) && (-MAX_POSITION..=MAX_POSITION).contains(&v) { d.set(i, v);