Merge pull request #384 from grovebit/track-height-drag

Track height: resize live while dragging, Pro Tools modifiers and Ctrl+Up/Down
This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-10-13 06:30:40 -04:00
commit 69aea8fad3
6 files changed
+274 -15

No files matched your search

+2 -2
View File
@@ -72,8 +72,8 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "edit.move_selection_right", "Move Edit Right", ["Edit", "Selection"], Some("Cmd+Alt+'"), "{}", always, |e, _| shift_selection(e, 1)),
cmd!(noundo "edit.halve_selection", "Halve Edit", ["Edit", "Selection"], None, "{}", always, |e, _| scale_selection(e, 0.5)),
cmd!(noundo "edit.double_selection", "Double Edit", ["Edit", "Selection"], None, "{}", always, |e, _| scale_selection(e, 2.0)),
cmd!(noundo "edit.extend_selection_up", "Extend Edit Up", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| extend_tracks(e, -1)),
cmd!(noundo "edit.extend_selection_down", "Extend Edit Down", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| extend_tracks(e, 1)),
cmd!(noundo "edit.extend_selection_up", "Extend Edit Up", ["Edit", "Selection"], Some("Ctrl+Shift+P"), "{}", has_tracks, |e, _| extend_tracks(e, -1)),
cmd!(noundo "edit.extend_selection_down", "Extend Edit Down", ["Edit", "Selection"], Some("Ctrl+Shift+;"), "{}", has_tracks, |e, _| extend_tracks(e, 1)),
cmd!(noundo "edit.remove_selection_top", "Remove Edit from Top", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| {
let s = e.session_mut();
if !s.edit.selected_tracks.is_empty() {
+55 -2
View File
@@ -58,7 +58,21 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("track.rename", "Rename Track", [], None, "{track, name}", has_tracks, rename),
cmd!("track.comments", "Track Comments", [], None, "{track, comments}", has_tracks, comments),
cmd!("track.color", "Track Color", [], None, "{tracks?, color: [r,g,b] | palette index}", has_selection, color),
cmd!("track.height", "Track Height", [], None, "{tracks?, height: micro|mini|small|medium|large|jumbo|extreme}", has_selection, height),
cmd!(
"track.height",
"Track Height",
[],
None,
"{tracks?, height: micro|mini|small|medium|large|jumbo|extreme | points}",
has_selection,
height
),
cmd!("track.height_up", "Increase Track Height", [], Some("Ctrl+Up"), "{tracks?} — one preset taller", has_selection, |e, p| height_step(
e, p, true
)),
cmd!("track.height_down", "Decrease Track Height", [], Some("Ctrl+Down"), "{tracks?} — one preset shorter", has_selection, |e, p| {
height_step(e, p, false)
}),
cmd!("track.view", "Track View", [], None, "{tracks?, view: waveform|blocks|notes|volume|pan|mute|<automation id>}", has_selection, view),
cmd!("track.hide", "Hide Track", [], None, "{tracks?, hidden?: bool}", has_selection, |e, p| flag(
e,
@@ -488,7 +502,10 @@ fn color(e: &mut Engine, p: &Value) -> Result<Value> {
fn height(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.height", p)?;
let h = str_param(p, "height").and_then(TrackHeight::from_id).ok_or_else(|| bad("track.height", "unknown height"))?;
let h = match p.get("height").and_then(Value::as_f64) {
Some(points) => TrackHeight::from_points(points as f32),
None => str_param(p, "height").and_then(TrackHeight::from_id).ok_or_else(|| bad("track.height", "unknown height"))?,
};
let s = e.session_mut();
for t in &tracks {
if let Some(tr) = s.track_mut(*t) {
@@ -498,6 +515,26 @@ fn height(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"height": h}))
}
fn height_step(e: &mut Engine, p: &Value, taller: bool) -> Result<Value> {
let tracks = tracks_required(e, if taller { "track.height_up" } else { "track.height_down" }, p)?;
let mut steps = Vec::new();
for tr in tracks.iter().filter_map(|t| e.session().track(*t)) {
let h = tr.height.step(taller);
if h != tr.height {
steps.push((tr.id, h));
}
}
if !steps.is_empty() {
let s = e.session_mut();
for (t, h) in &steps {
if let Some(tr) = s.track_mut(*t) {
tr.height = *h;
}
}
}
Ok(json!({"changed": steps.len()}))
}
fn view(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.view", p)?;
let v = str_param(p, "view").ok_or_else(|| bad("track.view", "`view` required"))?.to_string();
@@ -684,6 +721,22 @@ mod tests {
assert!(s.tracks.iter().any(|t| t.kind == TrackKind::Folder && t.mixer.input == kick.mixer.output));
}
#[test]
fn heights_take_points_and_step_through_the_presets() {
let mut e = crate::demo::demo_engine();
let height = |e: &Engine, name: &str| e.session().track_by_name(name).map(|t| t.height);
e.execute("track.height", &json!({"tracks": ["Kick"], "height": 57})).unwrap();
assert_eq!(height(&e, "Kick"), Some(TrackHeight::Custom(57)));
e.execute("track.height_up", &json!({"tracks": ["Kick", "Snare"]})).unwrap();
assert_eq!((height(&e, "Kick"), height(&e, "Snare")), (Some(TrackHeight::Medium), Some(TrackHeight::Large)));
e.execute("track.height", &json!({"tracks": ["Kick"], "height": "extreme"})).unwrap();
let steps = e.undo_history().len();
assert_eq!(e.execute("track.height_up", &json!({"tracks": ["Kick"]})).unwrap()["changed"], 0);
assert_eq!(e.undo_history().len(), steps);
e.execute("track.height_down", &json!({"tracks": ["Kick"]})).unwrap();
assert_eq!(height(&e, "Kick"), Some(TrackHeight::Jumbo));
}
#[test]
fn vca_null_clears_the_assignment() {
let mut e = Engine::default();
+32
View File
@@ -85,6 +85,8 @@ impl TrackKind {
/// Track heights (View › Track Height presets).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
pub enum TrackHeight {
/// Any height from Micro to Extreme, in points.
Custom(u16),
Micro,
Mini,
Small,
@@ -108,6 +110,7 @@ impl TrackHeight {
];
pub fn points(self) -> f32 {
match self {
TrackHeight::Custom(points) => f32::from(points).clamp(TrackHeight::Micro.points(), TrackHeight::Extreme.points()),
TrackHeight::Micro => 16.0,
TrackHeight::Mini => 24.0,
TrackHeight::Small => 46.0,
@@ -120,6 +123,7 @@ impl TrackHeight {
}
pub fn label(self) -> &'static str {
match self {
TrackHeight::Custom(_) => "custom",
TrackHeight::Micro => "micro",
TrackHeight::Mini => "mini",
TrackHeight::Small => "small",
@@ -133,6 +137,18 @@ impl TrackHeight {
pub fn from_id(s: &str) -> Option<TrackHeight> {
TrackHeight::ALL.into_iter().find(|h| h.label().eq_ignore_ascii_case(s))
}
/// The preset this many points tall, else a custom height clamped to Micro..Extreme.
pub fn from_points(points: f32) -> TrackHeight {
let p = points.max(TrackHeight::Micro.points()).min(TrackHeight::Extreme.points()).round() as u16;
TrackHeight::ALL.into_iter().find(|h| h.points() == f32::from(p)).unwrap_or(TrackHeight::Custom(p))
}
/// The next preset taller (or shorter) than this height; Extreme (or Micro) stays.
pub fn step(self, taller: bool) -> TrackHeight {
let p = self.points();
let next =
if taller { TrackHeight::ALL.into_iter().find(|h| h.points() > p) } else { TrackHeight::ALL.into_iter().rev().find(|h| h.points() < p) };
next.unwrap_or(self)
}
}
/// The default track colour palette (original, chosen for SoundCraft).
@@ -337,4 +353,20 @@ mod tests {
assert_eq!(TrackKind::from_id(k.id()), Some(k));
}
}
#[test]
fn custom_heights_round_to_presets_and_step_through_them() {
assert_eq!(TrackHeight::from_points(88.0), TrackHeight::Medium);
assert_eq!(TrackHeight::from_points(57.4), TrackHeight::Custom(57));
assert_eq!(TrackHeight::from_points(9999.0), TrackHeight::Extreme);
assert_eq!(TrackHeight::from_points(f32::NAN), TrackHeight::Micro);
assert_eq!(TrackHeight::Custom(u16::MAX).points(), 440.0);
assert_eq!(TrackHeight::Custom(57).step(true), TrackHeight::Medium);
assert_eq!(TrackHeight::Custom(57).step(false), TrackHeight::Small);
assert_eq!(TrackHeight::Extreme.step(true), TrackHeight::Extreme);
assert_eq!(TrackHeight::Micro.step(false), TrackHeight::Micro);
let json = serde_json::to_string(&TrackHeight::Custom(57)).unwrap();
assert_eq!(serde_json::from_str::<TrackHeight>(&json).unwrap(), TrackHeight::Custom(57));
assert_eq!(serde_json::from_str::<TrackHeight>("\"Large\"").unwrap(), TrackHeight::Large);
}
}
+26 -6
View File
@@ -289,6 +289,12 @@ fn in_edit_window(s: &Session, t: &Track) -> bool {
ops::shown(s, t)
}
/// Pro Tools' rule for track height: Alt reaches every shown track, Alt+Shift the selected ones too.
pub(crate) fn height_targets(s: &Session, m: egui::Modifiers, own: &[TrackId]) -> Vec<u64> {
let extra = |t: &Track| if m.shift { s.edit.selected_tracks.contains(&t.id) } else { in_edit_window(s, t) };
s.tracks.iter().filter(|t| own.contains(&t.id) || (m.alt && extra(t))).map(|t| t.id.0).collect()
}
/// Pro Tools' track-name click: Shift adds the tracks up to the nearest selected one, Cmd/Ctrl adds or removes one.
pub(crate) fn click_track(app: &mut SoundApp, id: TrackId, mods: egui::Modifiers, shown: impl Fn(&Session, &Track) -> bool) {
let s = app.engine.session();
@@ -860,13 +866,26 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
if eresp.hovered() || eresp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
}
if eresp.drag_stopped()
if eresp.drag_started() {
ui.data_mut(|d| d.insert_temp(eresp.id, track.height));
}
if eresp.dragged()
&& let Some(p) = ui.ctx().pointer_latest_pos()
&& let Some(origin) = ui.input(|i| i.pointer.press_origin()).filter(|o| o.y != p.y)
{
let want = (p.y - head.min.y).max(10.0);
let best = TrackHeight::ALL.into_iter().min_by_key(|h| ((h.points() - want).abs() * 10.0) as i64).unwrap_or(TrackHeight::Medium);
if best != track.height {
let _ = app.run("track.height", json!({"tracks": [id.0], "height": best.label()}));
let start = ui.data(|d| d.get_temp::<TrackHeight>(eresp.id)).unwrap_or(track.height);
let want = start.points() + p.y - origin.y;
let m = ui.input(|i| i.modifiers);
let best = if m.command {
TrackHeight::from_points(want)
} else {
TrackHeight::ALL.into_iter().min_by_key(|h| ((h.points() - want).abs() * 10.0) as i64).unwrap_or(start)
};
let s = app.engine.session();
let tracks = height_targets(s, m, &[id]);
if tracks.iter().any(|t| s.track(TrackId(*t)).is_some_and(|tr| tr.height != best)) {
let _ = app.engine.execute_merged("track.height", &json!({"tracks": tracks, "height": best.points()}), &format!("track_height:{}", id.0));
ui.ctx().request_repaint();
}
}
let h = head.height();
@@ -1363,7 +1382,8 @@ pub(crate) fn track_context_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {
ui.menu_button("Track Height", |ui| {
for h in TrackHeight::ALL {
if ui.button(h.label()).clicked() {
let _ = app.run("track.height", json!({"tracks": [tid], "height": h.label()}));
let tracks = height_targets(app.engine.session(), ui.input(|i| i.modifiers), &[id]);
let _ = app.run("track.height", json!({"tracks": tracks, "height": h.label()}));
}
}
});
+21 -5
View File
@@ -210,9 +210,17 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
let _ = app.run("view.zoom_preset", json!({"preset": digit(key)}));
true
}
Key::ArrowUp | Key::ArrowDown if m.ctrl && !m.shift => {
let id = if key == Key::ArrowUp { "edit.extend_selection_up" } else { "edit.extend_selection_down" };
let _ = app.run(id, json!({}));
// Pro Tools' Control+Up/Down track height, on Ctrl off macOS too (its Start key isn't available there).
Key::ArrowUp | Key::ArrowDown if m.ctrl && (m.alt || !m.shift) => {
let s = app.engine.session();
let tracks = crate::edit_window::height_targets(s, m, &s.edit.selected_tracks);
let id = if key == Key::ArrowUp { "track.height_up" } else { "track.height_down" };
let _ = app.run(id, if tracks.is_empty() { json!({}) } else { json!({"tracks": tracks}) });
true
}
// Extend Edit Down is Control+Shift+;, which macOS sends as Control+:.
Key::Semicolon | Key::Colon if mac && m.ctrl && m.shift && !m.alt && !m.command => {
let _ = app.run("edit.extend_selection_down", json!({}));
true
}
// Ctrl+Shift+Up/Down nudge clip gain off macOS too (the registry's Ctrl is the Mac Control key).
@@ -385,15 +393,19 @@ fn digit(key: Key) -> u8 {
/// Shortcut text for display. The registry stores macOS-style strings ("Cmd+N"); off macOS the Cmd
/// modifier is the Ctrl key. Mac-only Control chords ("Cmd+Ctrl+S") have no key there, so they show nothing.
pub fn shortcut_label(s: &str, mac: bool) -> String {
if mac || !s.split('+').any(|p| p == "Cmd") {
let has = |m: &str| s.split('+').any(|p| p == m);
if mac || (!has("Cmd") && !has("Ctrl")) || PC_CTRL_CHORDS.contains(&s) {
return s.to_string();
}
if s.split('+').any(|p| p == "Ctrl") {
if has("Ctrl") {
return String::new();
}
s.split('+').map(|p| if p == "Cmd" { "Ctrl" } else { p }).collect::<Vec<_>>().join("+")
}
/// Mac Control chords that `fixed` also maps to Ctrl off macOS.
const PC_CTRL_CHORDS: [&str; 4] = ["Ctrl+Up", "Ctrl+Down", "Ctrl+Shift+Up", "Ctrl+Shift+Down"];
#[cfg(test)]
mod label_tests {
use super::shortcut_label;
@@ -407,6 +419,10 @@ mod label_tests {
assert_eq!(shortcut_label("Shift+R", false), "Shift+R");
assert_eq!(shortcut_label("Cmd+Ctrl+S", false), "");
assert_eq!(shortcut_label("", false), "");
assert_eq!(shortcut_label("Ctrl+Up", false), "Ctrl+Up");
assert_eq!(shortcut_label("Ctrl+Shift+Down", false), "Ctrl+Shift+Down");
assert_eq!(shortcut_label("Ctrl+Shift+P", false), "");
assert_eq!(shortcut_label("Ctrl+Alt+L", false), "");
}
#[test]
+138
View File
@@ -0,0 +1,138 @@
use egui::os::OperatingSystem;
use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, pos2, vec2};
use serde_json::json;
use soundcraft_model::TrackHeight;
use soundcraft_ui_egui::{Services, SoundApp, shortcuts};
fn frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec<Event>) {
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 press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers) {
let events = [true, false].map(|pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec();
ctx.run_ui(RawInput { events, ..Default::default() }, |ui| shortcuts::handle(app, ui.ctx())).textures_delta.clear();
}
fn button(pos: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }
}
fn rig() -> (egui::Context, SoundApp) {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let ctx = egui::Context::default();
frame(&ctx, &mut app, vec![]);
frame(&ctx, &mut app, vec![]);
(ctx, app)
}
fn id(app: &SoundApp, name: &str) -> u64 {
app.engine.session().track_by_name(name).unwrap().id.0
}
fn height(app: &SoundApp, name: &str) -> TrackHeight {
app.engine.session().track_by_name(name).unwrap().height
}
fn row(app: &SoundApp, name: &str) -> [f32; 4] {
let id = id(app, name);
app.edit_layout.rows.iter().find(|(t, _)| *t == id).unwrap().1
}
fn select(app: &mut SoundApp, names: &[&str]) {
let ids: Vec<u64> = names.iter().map(|n| id(app, n)).collect();
app.engine.execute("edit.select", &json!({"tracks": ids})).unwrap();
}
/// Drags `name`'s bottom edge by `dy` with `m` held, holding still for a few frames before letting go.
fn drag_edge(ctx: &egui::Context, app: &mut SoundApp, name: &str, dy: f32, m: Modifiers) {
let [x, _, _, bottom] = row(app, name);
let at = pos2(x + 40.0, bottom - 1.0);
frame(ctx, app, vec![Event::ModifiersChanged(m), Event::PointerMoved(at)]);
frame(ctx, app, vec![button(at, true)]);
frame(ctx, app, vec![Event::PointerMoved(at + vec2(0.0, dy))]);
let held: Vec<TrackHeight> = app.engine.session().tracks.iter().map(|t| t.height).collect();
for _ in 0..3 {
frame(ctx, app, vec![]);
assert!(app.engine.session().tracks.iter().map(|t| t.height).eq(held.iter().copied()), "the heights hold while the pointer does");
}
frame(ctx, app, vec![button(at + vec2(0.0, dy), false), Event::ModifiersChanged(Modifiers::NONE)]);
frame(ctx, app, vec![]);
}
#[test]
fn dragging_a_track_edge_resizes_it_live_in_one_undo_step() {
let (ctx, mut app) = rig();
assert_eq!(height(&app, "Kick"), TrackHeight::Medium);
let [x, _, _, bottom] = row(&app, "Kick");
let at = |dy: f32| pos2(x + 40.0, bottom + dy);
frame(&ctx, &mut app, vec![Event::PointerMoved(at(-1.0))]);
frame(&ctx, &mut app, vec![button(at(-1.0), true)]);
for (dy, want) in [(20.0, TrackHeight::Medium), (44.0, TrackHeight::Large), (132.0, TrackHeight::Jumbo), (40.0, TrackHeight::Large)] {
frame(&ctx, &mut app, vec![Event::PointerMoved(at(dy))]);
frame(&ctx, &mut app, vec![]);
let [_, y0, _, y1] = row(&app, "Kick");
assert_eq!((height(&app, "Kick"), y1 - y0), (want, want.points()), "{dy}");
}
frame(&ctx, &mut app, vec![button(at(40.0), false)]);
frame(&ctx, &mut app, vec![]);
app.engine.execute("edit.undo", &json!({})).unwrap();
assert_eq!(height(&app, "Kick"), TrackHeight::Medium);
}
#[test]
fn cmd_drags_to_any_height_alt_resizes_every_track_and_alt_shift_the_selected_ones() {
let (ctx, mut app) = rig();
drag_edge(&ctx, &mut app, "Kick", 20.0, Modifiers::COMMAND);
assert_eq!((height(&app, "Kick"), height(&app, "Snare")), (TrackHeight::Custom(108), TrackHeight::Medium));
drag_edge(&ctx, &mut app, "Snare", 44.0, Modifiers::ALT);
assert!(
app.edit_layout.rows.iter().all(|(t, _)| app.engine.session().track(soundcraft_model::TrackId(*t)).unwrap().height == TrackHeight::Large)
);
select(&mut app, &["Hats", "Bass"]);
drag_edge(&ctx, &mut app, "Kick", -86.0, Modifiers { alt: true, shift: true, ..Modifiers::NONE });
let small = [TrackHeight::Small, TrackHeight::Large, TrackHeight::Small, TrackHeight::Small];
assert_eq!(["Kick", "Snare", "Hats", "Bass"].map(|n| height(&app, n)), small);
app.engine.execute("edit.undo", &json!({})).unwrap();
assert_eq!(["Kick", "Snare", "Hats", "Bass"].map(|n| height(&app, n)), [TrackHeight::Large; 4]);
}
#[test]
fn ctrl_arrows_step_the_selected_tracks_and_ctrl_alt_arrows_every_track() {
let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
for (os, ctrl) in [(OperatingSystem::Mac, Modifiers::CTRL), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] {
let (ctx, mut app) = rig();
ctx.set_os(os);
select(&mut app, &["Kick"]);
press(&ctx, &mut app, Key::ArrowUp, ctrl);
assert_eq!((height(&app, "Kick"), height(&app, "Snare")), (TrackHeight::Large, TrackHeight::Medium), "{os:?}");
press(&ctx, &mut app, Key::ArrowDown, ctrl);
press(&ctx, &mut app, Key::ArrowDown, ctrl);
assert_eq!(height(&app, "Kick"), TrackHeight::Small, "{os:?}");
press(&ctx, &mut app, Key::ArrowUp, Modifiers { alt: true, ..ctrl });
assert_eq!((height(&app, "Kick"), height(&app, "Snare")), (TrackHeight::Medium, TrackHeight::Large), "{os:?}");
assert_eq!(app.engine.session().edit.selected_tracks.len(), 1, "{os:?}");
}
}
#[test]
fn control_shift_p_and_semicolon_extend_the_edit_on_macos() {
let (ctx, mut app) = rig();
ctx.set_os(OperatingSystem::Mac);
select(&mut app, &["Snare"]);
let ctrl_shift = Modifiers { ctrl: true, shift: true, ..Modifiers::NONE };
press(&ctx, &mut app, Key::P, ctrl_shift);
press(&ctx, &mut app, Key::Colon, ctrl_shift);
let selected: Vec<u64> = app.engine.session().edit.selected_tracks.iter().map(|t| t.0).collect();
assert_eq!(selected, ["Kick", "Snare", "Hats"].map(|n| id(&app, n)));
}