Browse / github.com/storytold/soundcraft / patch
Mix: Alt+Shift-drag on a selected track's fader moves every selected fader by the same amount (#865)
Mix: Alt+Shift-drag on a selected track's fader moves every selected fader by the same amount (#865) Alt+Shift-dragging the fader of a selected track in the Mix window now moves every selected track's fader by the same amount, for #865. Avid's Pro Tools Shortcuts Guide (2023.6, Global Keyboard Shortcuts) lists Option+Shift (Alt+Shift) as the modifier that changes a parameter on all selected channel strips, while a plain move touches one track; the Reference Guide describes grouped faders as moving relative to each other and keeping their offsets when one of them reaches the end of its travel. That list doesn't name the volume fader itself, so this extends the convention to it, the same way t
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- marcoarnulfo (imported from pull request #1041, open)
- Base
aa74bffac1aebfeedee0a3b60729ad8ceefc2195- Size
- +215 −4 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
0a997997b30fc6744ce154cf
How it went
No other operator has reported using it yet.
Diff
raw · crates/ui-egui/src/mix_window.rs crates/ui-egui/tests/it/main.rs crates/ui-egui/tests/it/mix_selected_faders.rs docs/ui-parity.md
@@ -64,6 +64,42 @@ pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) { }); } +/// The tracks an Alt+Shift fader move on `id` applies to, as Alt+Shift applies a change to the+/// selected channel strips in Pro Tools: the selected tracks that have a strip here, when `id` is one.+fn selected_strips(s: &soundcraft_model::Session, id: TrackId) -> Vec<TrackId> {+ let selected = &s.edit.selected_tracks;+ if !selected.contains(&id) {+ return Vec::new();+ }+ s.tracks.iter().filter(|t| selected.contains(&t.id) && !t.hidden && t.kind != TrackKind::Video).map(|t| t.id).collect()+}++/// Where each selected strip goes while the dragged one (`lead`) is at `db`: its distance from the+/// lead when the drag began (`starts`), clamped to the fader's travel. Measured from the start and+/// not frame to frame, so a strip held at +12 dB or -inf gets its distance back when the lead+/// returns, as grouped faders do in Pro Tools. A strip whose volume follows an earlier one through+/// an active mix group is left out: `mix.volume` moves it with that group already.+fn follow_from(s: &soundcraft_model::Session, starts: &[(TrackId, f32)], lead: TrackId, db: f32) -> Vec<(TrackId, f32)> {+ let lead_start = starts.iter().find(|(t, _)| *t == lead).map_or(db, |(_, v)| *v);+ let delta = db - lead_start;+ let mut grouped: Vec<TrackId> = Vec::new();+ let mut out = Vec::new();+ let lead_first = starts.iter().filter(|(t, _)| *t == lead).chain(starts.iter().filter(|(t, _)| *t != lead));+ for (t, start) in lead_first {+ if grouped.contains(t) {+ continue;+ }+ for g in &s.groups {+ if g.active && g.mix && g.members.contains(t) && g.attributes.iter().any(|a| a == "volume") {+ grouped.extend(g.members.iter().filter(|m| *m != t));+ }+ }+ let v = if *t == lead { db } else { (start + delta).clamp(-144.0, 12.0) };+ out.push((*t, v));+ }+ out+}+ /// Format of the destination a track's output feeds (the main mix, a bus, or stereo). fn output_format(app: &SoundApp, track: &Track) -> ChannelFormat { let s = app.engine.session();@@ -419,9 +455,36 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let fh = (r.max.y - bottom_h - y).max(120.0); let wide_meter = (if track.kind == TrackKind::Master { app.engine.session().main_format() } else { track.format }).channels() > 2; let fr = Rect::from_min_size(pos2(x0, y), vec2(inner_w * if wide_meter { 0.45 } else { 0.6 }, fh));- if let Some(db) = fader(ui, fr, track.mixer.volume_db, ui.id().with(("fader", id.0))) {+ ui.interact(fr, ui.id().with(("fader-label", id.0)), Sense::hover())+ .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, format!("{} volume", track.name)));+ let fader_id = ui.id().with(("fader", id.0));+ // Where each selected strip stood when an Alt+Shift drag of this fader began.+ let starts_id = fader_id.with("starts");+ let dragging = ui.ctx().is_being_dragged(fader_id);+ if let Some(db) = fader(ui, fr, track.mixer.volume_db, fader_id) { app.gesture = Some(crate::Gesture::Fader { track: id, start_db: track.mixer.volume_db });- let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": db}), &format!("fader:{}", id.0));+ let mods = ui.input(|i| i.modifiers);+ let together = if mods.alt && mods.shift { selected_strips(&session, id) } else { Vec::new() };+ let key = format!("fader:{}", id.0);+ if together.len() < 2 {+ ui.data_mut(|d| d.remove::<Vec<(TrackId, f32)>>(starts_id));+ let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": db}), &key);+ } else if dragging {+ let starts = ui.data_mut(|d| {+ d.get_temp_mut_or_insert_with(starts_id, || {+ together.iter().map(|t| (*t, session.track(*t).map_or(0.0, |tr| tr.mixer.volume_db))).collect::<Vec<_>>()+ })+ .clone()+ });+ for (t, v) in follow_from(&session, &starts, id, db) {+ let _ = app.engine.execute_merged("mix.volume", &json!({"track": t.0, "db": v}), &key);+ }+ } else {+ let ids: Vec<u64> = together.iter().map(|t| t.0).collect();+ let _ = app.engine.execute_merged("mix.volume", &json!({"tracks": ids, "db": db}), &key);+ }+ } else if !dragging {+ ui.data_mut(|d| d.remove::<Vec<(TrackId, f32)>>(starts_id)); } let md = app.meters.get(&id).copied().unwrap_or_default(); let slot = fader_slot(fr);@@ -27,6 +27,7 @@ mod midi_front_visibility; mod midi_panel; mod midi_quantize; mod mix_narrow_headers;+mod mix_selected_faders; mod mix_separate; mod new_midi_clip; mod new_tracks;new file mode 100644@@ -0,0 +1,146 @@+//! Alt+Shift on a selected track's fader moves every selected track's fader by the same amount, as+//! Alt+Shift applies a change to the selected channel strips in Pro Tools; a plain drag moves one.++use egui::{Event, Modifiers, PointerButton, Pos2, vec2};+use egui_kittest::Harness;+use egui_kittest::kittest::Queryable;+use serde_json::json;+use soundcraft_model::TrackId;+use soundcraft_ui_egui::{MainWindow, Services, SoundApp};++const START: [f32; 4] = [-10.0, -4.0, -20.0, -7.0];++/// Kick, Snare and Bass selected (Bass hidden), Keys not; every track at a different level.+fn harness() -> (Harness<'static, SoundApp>, [TrackId; 4]) {+ let mut engine = soundcraft_engine::Engine::default();+ let mut ids = Vec::new();+ for (name, db) in ["Kick", "Snare", "Bass", "Keys"].iter().zip(START) {+ engine.execute("track.new", &json!({"name": name})).unwrap();+ let id = engine.session().tracks.last().unwrap().id;+ engine.execute("mix.volume", &json!({"track": id.0, "db": db})).unwrap();+ ids.push(id);+ }+ let ids: [TrackId; 4] = ids.try_into().unwrap();+ engine.session_mut().track_mut(ids[2]).unwrap().hidden = true;+ engine.session_mut().edit.selected_tracks = vec![ids[0], ids[1], ids[2]];+ let mut app = SoundApp::new(engine, None, Services::default());+ app.ui.window = MainWindow::Mix;+ let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(+ |ui, app: &mut SoundApp| {+ app.logic(&ui.ctx().clone());+ app.ui(ui);+ },+ app,+ );+ h.run_steps(4);+ (h, ids)+}++fn volumes(h: &Harness<SoundApp>, ids: &[TrackId; 4]) -> [f32; 4] {+ ids.map(|id| h.state().engine.session().track(id).unwrap().mixer.volume_db)+}++fn press(h: &mut Harness<SoundApp>, at: Pos2, pressed: bool, modifiers: Modifiers) {+ h.event(Event::ModifiersChanged(modifiers));+ h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers });+ h.step();+}++/// Drags Kick's fader cap up by `dy` points with `modifiers` held, in a few moves.+fn drag_kick(h: &mut Harness<SoundApp>, dy: f32, modifiers: Modifiers) {+ let fader = h.get_by_label("Kick volume").rect();+ let from = fader.center() - vec2(0.0, fader.height() * 0.25);+ h.hover_at(from);+ h.step();+ press(h, from, true, modifiers);+ for f in [0.25, 0.5, 1.0] {+ h.hover_at(from - vec2(0.0, dy * f));+ h.step();+ }+ press(h, from - vec2(0.0, dy), false, modifiers);+ h.event(Event::ModifiersChanged(Modifiers::NONE));+ h.run_steps(2);+}++/// Drags Kick's fader with Alt+Shift through `path` (points above where the drag starts, in one+/// gesture) and returns the volumes after each move.+fn drag_kick_through(h: &mut Harness<SoundApp>, ids: &[TrackId; 4], path: &[f32]) -> Vec<[f32; 4]> {+ let both = Modifiers::ALT | Modifiers::SHIFT;+ let fader = h.get_by_label("Kick volume").rect();+ let from = fader.center() - vec2(0.0, fader.height() * 0.25);+ h.hover_at(from);+ h.step();+ press(h, from, true, both);+ let mut seen = Vec::new();+ for dy in path {+ h.hover_at(from - vec2(0.0, *dy));+ h.step();+ seen.push(volumes(h, ids));+ }+ press(h, from - vec2(0.0, *path.last().unwrap_or(&0.0)), false, both);+ h.event(Event::ModifiersChanged(Modifiers::NONE));+ h.run_steps(2);+ seen+}++#[test]+fn a_selected_fader_held_at_the_top_gets_its_distance_back_on_the_way_down() {+ let (mut h, ids) = harness();+ let seen = drag_kick_through(&mut h, &ids, &[40.0, 80.0, 110.0, 130.0, 150.0, 130.0, 80.0, 40.0, 0.0]);+ assert!(seen.iter().any(|v| v[1] == 12.0 && v[0] < 12.0), "Snare stopped at the top while Kick went on: {seen:?}");+ let v = volumes(&h, &ids);+ assert!((v[1] - v[0] - (START[1] - START[0])).abs() < 1e-3, "back down, Snare is {} dB above Kick again: {v:?}", START[1] - START[0]);+ assert_eq!(v[3], START[3]);+}++#[test]+fn a_drag_up_from_minus_infinity_and_back_returns_the_selected_faders() {+ let (mut h, ids) = harness();+ h.state_mut().engine.session_mut().track_mut(ids[0]).unwrap().mixer.volume_db = -144.0;+ h.run_steps(2);+ let seen = drag_kick_through(&mut h, &ids, &[12.0, 24.0, 12.0, 0.0]);+ for v in &seen {+ let want = (START[1] + v[0] + 144.0).min(12.0);+ assert!((v[1] - want).abs() < 1e-3, "Snare follows Kick's dB change from where it began: {v:?}");+ }+ let v = volumes(&h, &ids);+ assert_eq!((v[0], v[1], v[3]), (-144.0, START[1], START[3]), "back at the bottom, every fader is where it began: {v:?}");+}++#[test]+fn alt_shift_drag_moves_the_selected_faders_by_the_same_amount_in_one_undo_step() {+ let (mut h, ids) = harness();+ drag_kick(&mut h, 40.0, Modifiers::ALT | Modifiers::SHIFT);+ let v = volumes(&h, &ids);+ let d = v[0] - START[0];+ assert!(d > 1.0, "the dragged fader moved: {v:?}");+ assert!((v[1] - (START[1] + d)).abs() < 1e-3, "the other selected strip keeps its distance: {v:?}");+ assert_eq!(v[2], START[2], "a hidden track has no strip, so it stays where it was");+ assert_eq!(v[3], START[3], "an unselected track stays where it was");++ assert!(h.state_mut().engine.undo());+ assert_eq!(volumes(&h, &ids), START, "one undo puts every fader back");+}++#[test]+fn a_plain_drag_moves_only_its_own_fader() {+ let (mut h, ids) = harness();+ drag_kick(&mut h, 40.0, Modifiers::NONE);+ let v = volumes(&h, &ids);+ assert!(v[0] - START[0] > 1.0, "the dragged fader moved: {v:?}");+ assert_eq!(v[1..], START[1..]);+}++#[test]+fn alt_shift_click_sets_the_selected_faders_to_zero() {+ let (mut h, ids) = harness();+ let fader = h.get_by_label("Kick volume").rect();+ h.hover_at(fader.center());+ h.step();+ let both = Modifiers::ALT | Modifiers::SHIFT;+ press(&mut h, fader.center(), true, both);+ press(&mut h, fader.center(), false, both);+ h.event(Event::ModifiersChanged(Modifiers::NONE));+ h.run_steps(2);+ assert_eq!(volumes(&h, &ids), [0.0, 0.0, START[2], START[3]]);+}@@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-16 · **Change:** minor (Scroll Into View in the track name menu) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (Shift-Alt-drag on a selected fader moves the selected faders) · **Target:** Avid Pro Tools Ultimate 2026.4.1 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command@@ -32,7 +32,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Default shortcuts | 🟡 146 bound (measured: distinct `Some("…")` shortcuts in the registry) | Pro Tools' default set is several hundred incl. numeric keypad and focus keys. Collisions: Mix/Edit toggle vs zoom (#144), Heal (#140), Export (#7) | | Ctrl as Cmd on Windows/Linux | ✅ since #15, #41 still open in some builds | #62 shows Ctrl in labels | | Numeric keypad transport and memory-location recall | 🟡 | Classic / Transport / Shuttle modes not all present |-| Modifier gestures (Alt for all tracks, Shift-Alt for selected, Cmd-click fine faders, Ctrl-click defaults) | 🟡 | Main ones on faders and pan; not across every control |+| Modifier gestures (Alt for all tracks, Shift-Alt for selected, Cmd-click fine faders, Ctrl-click defaults) | 🟡 | Main ones on faders and pan; Shift-Alt on a selected track's fader moves every selected fader by the same amount (#865); not across every control | | Scroll/zoom: wheel, trackpad pinch | 🟡 | Anchored trackpad zoom in open PRs #112, #137 | | Drag and drop: Clip List → tracks, files → tracks | ✅ macOS/Windows; 🟡 Wayland (PR #150) | | | Context menus (clip, track name, ruler, Clip List) | 🟡 | Clip and track menus exist; depth lower than Pro Tools'; Clip List delete (#4); track name ▸ Scroll Into View (Edit top, Mix left) |@@ -69,6 +69,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | Mix window: Shift-Alt-drag on a selected track's fader moves every selected fader by the same dB, one undo step (#865). Readiness estimate unchanged | | 2026-10-16 | minor | Track name menu: Scroll Into View brings the track to the top of the Edit window and the left of the Mix. Readiness estimate unchanged | | 2026-10-13 | minor | A plugin opens when it's inserted (#486) | | 2026-10-13 | minor | Drag across checkboxes (#399) |