mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-24 02:26:46 -06:00
Drag automation breakpoints and Alt-click to delete them in the Edit window (fixes #601)
This commit is contained in:
@@ -1798,23 +1798,13 @@ fn crossfade_partner<'a>(track: &'a Track, clip: &Clip) -> Option<&'a Clip> {
|
||||
|
||||
fn draw_automation(painter: &egui::Painter, s: &Session, track: &Track, p: &AutoParam, tl: Rect, lane: Rect) {
|
||||
let t = Tokens::current();
|
||||
let (lo, hi, _) = p.range();
|
||||
let (lo, hi) = if matches!(p, AutoParam::Volume | AutoParam::SendLevel(_)) {
|
||||
(-60.0f32, 12.0f32)
|
||||
} else if matches!(p, AutoParam::Plugin { .. }) {
|
||||
plugin_range(track, p)
|
||||
} else {
|
||||
(lo, hi)
|
||||
};
|
||||
let range = lane_range(track, p);
|
||||
let def = match p {
|
||||
AutoParam::Volume => track.mixer.volume_db,
|
||||
AutoParam::Pan(i) => track.mixer.pan.get(usize::from(*i)).copied().unwrap_or(0.0),
|
||||
_ => 0.0,
|
||||
};
|
||||
let yv = |v: f32| -> f32 {
|
||||
let k = ((v.clamp(lo, hi) - lo) / (hi - lo).max(1e-6)).clamp(0.0, 1.0);
|
||||
lane.max.y - 4.0 - k * (lane.height() - 8.0)
|
||||
};
|
||||
let yv = |v: f32| auto_y(lane, range, v);
|
||||
let lanes = track.lane(p);
|
||||
let pts: Vec<(f32, f32)> = match lanes {
|
||||
Some(l) if !l.points.is_empty() => {
|
||||
@@ -1844,6 +1834,47 @@ fn draw_automation(painter: &egui::Painter, s: &Session, track: &Track, p: &Auto
|
||||
painter.text(pos2(lane.min.x + 6.0, lane.min.y + 9.0), Align2::LEFT_CENTER, p.label(), regular(10.0), t.text_dim);
|
||||
}
|
||||
|
||||
/// The values an automation lane spans, bottom to top.
|
||||
fn lane_range(track: &Track, p: &AutoParam) -> (f32, f32) {
|
||||
if matches!(p, AutoParam::Volume | AutoParam::SendLevel(_)) {
|
||||
(-60.0, 12.0)
|
||||
} else if matches!(p, AutoParam::Plugin { .. }) {
|
||||
plugin_range(track, p)
|
||||
} else {
|
||||
let (lo, hi, _) = p.range();
|
||||
(lo, hi)
|
||||
}
|
||||
}
|
||||
|
||||
fn auto_y(lane: Rect, (lo, hi): (f32, f32), v: f32) -> f32 {
|
||||
let k = ((v.clamp(lo, hi) - lo) / (hi - lo).max(1e-6)).clamp(0.0, 1.0);
|
||||
lane.max.y - 4.0 - k * (lane.height() - 8.0)
|
||||
}
|
||||
|
||||
fn auto_value(lane: Rect, (lo, hi): (f32, f32), y: f32) -> f32 {
|
||||
let k = ((lane.max.y - 4.0 - y) / (lane.height() - 8.0)).clamp(0.0, 1.0);
|
||||
lo + (hi - lo) * k
|
||||
}
|
||||
|
||||
/// The automation a track's lane draws: its view's, or volume on the tracks that always show it.
|
||||
fn shown_automation(track: &Track) -> Option<AutoParam> {
|
||||
AutoParam::parse(&track.view).or_else(|| matches!(track.kind, TrackKind::Master | TrackKind::Aux | TrackKind::Vca).then_some(AutoParam::Volume))
|
||||
}
|
||||
|
||||
/// The drawn breakpoint nearest `pos`, within grabbing distance.
|
||||
fn breakpoint_at(s: &Session, tl: Rect, lane: Rect, track: &Track, p: &AutoParam, pos: Pos2) -> Option<Samples> {
|
||||
let range = lane_range(track, p);
|
||||
let dist = |at: Samples, v: f32| pos2(x_of(s, tl, at), auto_y(lane, range, v)).distance(pos);
|
||||
track
|
||||
.lane(p)?
|
||||
.points
|
||||
.iter()
|
||||
.map(|pt| (pt.at, dist(pt.at, pt.value)))
|
||||
.filter(|(_, d)| *d <= 6.0)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(at, _)| at)
|
||||
}
|
||||
|
||||
fn plugin_range(track: &Track, p: &AutoParam) -> (f32, f32) {
|
||||
if let AutoParam::Plugin { slot, param } = p
|
||||
&& let Some(Some(ins)) = track.mixer.inserts.get(usize::from(*slot))
|
||||
@@ -1977,6 +2008,11 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
|
||||
let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(p) } else { p };
|
||||
let at_origin = sample_at(&s, tl, origin.x).max(0);
|
||||
let auto_view = AutoParam::parse(&track.view);
|
||||
let point_hit = if matches!(tool, Tool::Smart | Tool::Grabber | Tool::Pencil) {
|
||||
shown_automation(track).and_then(|param| breakpoint_at(&s, tl, lane, track, ¶m, origin).map(|at| (param, at)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let body_hit = clip_at(track, at_origin);
|
||||
// Check fade endpoints before clip bodies: a full-length fade can end exactly
|
||||
// at the next clip's start, where `clip_at` would otherwise choose that neighbour.
|
||||
@@ -2008,8 +2044,9 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
|
||||
None
|
||||
}
|
||||
};
|
||||
// Effective tool under the Smart tool.
|
||||
// Effective tool under the Smart tool; on a breakpoint every tool that edits one grabs it.
|
||||
let eff = match tool {
|
||||
_ if point_hit.is_some() => Tool::Grabber,
|
||||
Tool::Smart => {
|
||||
if let Some(c) = &hit {
|
||||
if near_edge(c).is_some() {
|
||||
@@ -2036,7 +2073,7 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
|
||||
};
|
||||
ui.ctx().set_cursor_icon(icon);
|
||||
}
|
||||
let fade_corner = if tool == Tool::Smart {
|
||||
let fade_corner = if tool == Tool::Smart && point_hit.is_none() {
|
||||
fade_handle.map(|(_, side)| side).or_else(|| {
|
||||
hit.as_ref().and_then(|c| {
|
||||
let xs = x_of(&s, tl, c.start);
|
||||
@@ -2074,7 +2111,11 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
|
||||
place_insertion(app, ui, &s, track.id, at, mods.shift);
|
||||
}
|
||||
let gain_drag = if resp.drag_started() { gain_icon_at(origin) } else { None };
|
||||
if let Some(c) = gain_drag {
|
||||
if resp.drag_started()
|
||||
&& let Some((param, at)) = &point_hit
|
||||
{
|
||||
app.gesture = Some(Gesture::Breakpoint { track: track.id, param: param.clone(), at: *at });
|
||||
} else if let Some(c) = gain_drag {
|
||||
// `clip.gain` refuses to run with nothing selected.
|
||||
if s.edit.selected_tracks.is_empty() && s.edit.selected_clips.is_empty() {
|
||||
let _ = app.engine.execute("edit.select", &json!({"clips": [c.id.0]}));
|
||||
@@ -2141,7 +2182,15 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
|
||||
clip_gain_popup(ui.ctx(), *fader_bottom, *pos);
|
||||
}
|
||||
if resp.clicked() && curve_handle.is_none() {
|
||||
if resp.clicked()
|
||||
&& let Some((param, at)) = &point_hit
|
||||
{
|
||||
// Alt-click deletes a breakpoint, as in Pro Tools.
|
||||
if mods.alt {
|
||||
let _ =
|
||||
app.run("automation.clear", json!({"track": track.id.0, "param": automation_id(param), "start": at, "end": at.saturating_add(1)}));
|
||||
}
|
||||
} else if resp.clicked() && curve_handle.is_none() {
|
||||
match eff {
|
||||
Tool::Grabber => {
|
||||
if let Some(c) = &hit {
|
||||
@@ -2187,6 +2236,7 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
|
||||
}
|
||||
if resp.double_clicked()
|
||||
&& curve_handle.is_none()
|
||||
&& point_hit.is_none()
|
||||
&& eff != Tool::Pencil
|
||||
&& let Some(c) = &hit
|
||||
{
|
||||
@@ -2300,6 +2350,29 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
*last = at;
|
||||
let _ = app.engine.execute("transport.locate", &json!({"at": at}));
|
||||
}
|
||||
Some(Gesture::Breakpoint { track, param, at: from }) => {
|
||||
if let Some(t) = s.track(*track)
|
||||
&& let Some((_, row)) = rows.iter().find(|(i, _)| *i == t.id.0)
|
||||
&& let Some(points) = t.lane(param).map(|l| l.points.as_slice())
|
||||
&& let Some(i) = points.iter().position(|pt| pt.at == *from)
|
||||
{
|
||||
let lane = lane_rect(t, rect(*row), tl);
|
||||
let after = i.checked_sub(1).and_then(|j| points.get(j)).map_or(0, |pt| pt.at.saturating_add(1));
|
||||
let before = points.get(i + 1).map_or(Samples::MAX, |pt| pt.at.saturating_sub(1));
|
||||
let to = snap(&s, at).clamp(after, before.max(after));
|
||||
let value = auto_value(lane, lane_range(t, param), p.y);
|
||||
if to != *from || points.get(i).is_some_and(|pt| (pt.value - value).abs() > 1e-6) {
|
||||
let (key, pid) = (format!("breakpoint:{}", track.0), automation_id(param));
|
||||
if to != *from {
|
||||
let clear = json!({"track": track.0, "param": pid, "start": *from, "end": from.saturating_add(1)});
|
||||
let _ = app.engine.execute_merged("automation.clear", &clear, &key);
|
||||
}
|
||||
let _ =
|
||||
app.engine.execute_merged("automation.set_point", &json!({"track": track.0, "param": pid, "at": to, "value": value}), &key);
|
||||
*from = to;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Gesture::Pencil { track, points }) => {
|
||||
if let Some(t) = s.track(*track)
|
||||
&& let Some(param) = AutoParam::parse(&t.view)
|
||||
@@ -3893,4 +3966,69 @@ mod tests {
|
||||
assert_eq!(points.len(), 1, "status: {}", h.state().ui.status);
|
||||
assert!((points[0].value + 12.0).abs() < 0.01, "{}", points[0].value);
|
||||
}
|
||||
|
||||
/// A volume lane over a clip, with two breakpoints low enough that the Smart tool would grab
|
||||
/// the clip there: the screen position of each, and the clips as they were.
|
||||
fn breakpoints_over_a_clip(tool: &str) -> (Harness<'static, SoundApp>, TrackId, [Pos2; 2], Vec<Clip>) {
|
||||
let mut engine = soundcraft_engine::demo::demo_engine();
|
||||
engine.execute("edit.tool", &json!({"tool": tool})).unwrap();
|
||||
engine.execute("edit.mode", &json!({"mode": "shuffle"})).unwrap();
|
||||
let mut h = harness_with(engine);
|
||||
let tl = timeline(&h);
|
||||
let (clip, _) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 200.0);
|
||||
let track = h.state().engine.session().find_clip(clip.id).unwrap().0;
|
||||
let (xs, xe) = (x_of(h.state().engine.session(), tl, clip.start), x_of(h.state().engine.session(), tl, clip.end()));
|
||||
let xs_at = [xs + (xe - xs) * 0.3, xs + (xe - xs) * 0.6];
|
||||
for x in xs_at {
|
||||
let at = sample_at(h.state().engine.session(), tl, x);
|
||||
h.state_mut().engine.execute("automation.set_point", &json!({"track": track.0, "param": "volume", "at": at, "value": -40.0})).unwrap();
|
||||
}
|
||||
h.state_mut().engine.execute("track.view", &json!({"track": track.0, "view": "volume"})).unwrap();
|
||||
h.run_steps(2);
|
||||
let s = h.state().engine.session_arc();
|
||||
let t = s.track(track).unwrap();
|
||||
let i = h.state().edit_layout.rows.iter().position(|(id, _)| *id == track.0).unwrap();
|
||||
let lane = lane_rect(t, row(&h, i), tl);
|
||||
let y = lane.max.y - 4.0 - (lane.height() - 8.0) * (20.0 / 72.0);
|
||||
assert!(y > lane.center().y, "the breakpoints sit where the Smart tool grabs");
|
||||
let at = t.lane(&AutoParam::Volume).unwrap().points.iter().map(|p| pos2(x_of(&s, tl, p.at), y)).collect::<Vec<_>>();
|
||||
(h, track, [at[0], at[1]], t.clips().to_vec())
|
||||
}
|
||||
|
||||
fn volume_points(h: &Harness<SoundApp>, track: TrackId) -> Vec<(Samples, f32)> {
|
||||
let s = h.state().engine.session();
|
||||
s.track(track).and_then(|t| t.lane(&AutoParam::Volume)).map(|l| l.points.iter().map(|p| (p.at, p.value)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_breakpoint_moves_it_not_the_clip_under_it() {
|
||||
for tool in ["smart", "grabber"] {
|
||||
let (mut h, track, [a, b], clips) = breakpoints_over_a_clip(tool);
|
||||
let before = volume_points(&h, track);
|
||||
let to = b + vec2(-40.0, -15.0);
|
||||
drag(&mut h, b, to);
|
||||
let s = h.state().engine.session_arc();
|
||||
assert_eq!(s.track(track).unwrap().clips(), clips.as_slice(), "{tool}: the clip stays");
|
||||
let points = volume_points(&h, track);
|
||||
assert_eq!(points.len(), 2, "{tool}: {points:?}");
|
||||
assert_eq!(points[0], before[0], "{tool}: the other breakpoint stays");
|
||||
let want = sample_at(&s, timeline(&h), to.x);
|
||||
assert!((points[1].0 - want).abs() as f64 <= s.edit.zoom.samples_per_px, "{tool}: {} vs {want}", points[1].0);
|
||||
assert!(points[1].1 > -40.0 + 1.0, "{tool}: dragged up, {}", points[1].1);
|
||||
assert!(x_of(&s, timeline(&h), points[1].0) > a.x, "{tool}");
|
||||
h.state_mut().engine.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(volume_points(&h, track), before, "{tool}: one undo step");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_on_a_breakpoint_deletes_it() {
|
||||
for tool in ["smart", "grabber", "pencil"] {
|
||||
let (mut h, track, [_, b], clips) = breakpoints_over_a_clip(tool);
|
||||
let before = volume_points(&h, track);
|
||||
click_with(&mut h, b, egui::Modifiers::ALT);
|
||||
assert_eq!(volume_points(&h, track), before[..1], "{tool}");
|
||||
assert_eq!(h.state().engine.session().track(track).unwrap().clips(), clips.as_slice(), "{tool}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +240,7 @@ pub enum Gesture {
|
||||
Fader { track: TrackId, start_db: f32 },
|
||||
ClipGain { clip: soundcraft_model::ClipId, track: TrackId, pos: f32, anchor: (f32, f32), fine: bool, fader_bottom: egui::Pos2 },
|
||||
Pencil { track: TrackId, points: Vec<(Samples, f32)> },
|
||||
Breakpoint { track: TrackId, param: soundcraft_model::AutoParam, at: Samples },
|
||||
MidiClip { track: TrackId, from: Samples, to: Samples },
|
||||
ZoomBox { start: f32 },
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ use std::path::Path;
|
||||
/// Known gaps: commands with no menu, shortcut or literal reference in the UI source today.
|
||||
/// Give each one a UI path and remove it from this list; don't add to it.
|
||||
const KNOWN_UNREACHED: &[&str] = &[
|
||||
"automation.clear",
|
||||
"edit.paste_clip_effects",
|
||||
"edit.paste_clip_gain",
|
||||
"event.midi_note",
|
||||
|
||||
Reference in New Issue
Block a user