Track height: resize live while dragging, Pro Tools modifiers and Ctrl+Up/Down

Dragging a track's bottom edge showed nothing until release; then the
track jumped to the nearest height preset. Pro Tools' track height
modifiers and keys were missing too.

Dragging:
- The edge follows the pointer as in Pro Tools: the track steps through
  Micro…Extreme while the pointer moves, and the tracks below move with
  it. The whole drag is one undo step (execute_merged, ended on release
  like fader moves).
- Cmd (Ctrl on Windows and Linux) drags to any height from Micro to
  Extreme instead of snapping. The model gains TrackHeight::Custom
  (points), and track.height takes a number of points as well as a
  preset name.
- Alt resizes every track the Edit window shows; Alt+Shift resizes the
  selected tracks too. The track menu's Track Height items follow the
  same rule.
- The drag is measured from where it started, not from the edge's
  current place: when the tracks above the dragged one resize they move
  its edge, and edge-relative math flipped between two heights on every
  frame. This also fixes rows showing playlist lanes, which a plain
  click on the edge used to make taller.

Keys:
- New commands track.height_up and track.height_down move each track
  one preset taller or shorter; a track already at Extreme or Micro is
  left alone, with no undo step. Control+Up/Down runs them on macOS, as
  in Pro Tools. Windows and Linux use Ctrl+Up/Down, because the Start
  key Pro Tools uses there isn't available. Alt steps every shown track,
  Alt+Shift the selected ones.
- Ctrl+Up/Down used to run Extend Edit Up/Down, keys Pro Tools doesn't
  use for them. Those commands now have Pro Tools' Control+Shift+P and
  Control+Shift+; on macOS (macOS sends the latter as Control+:).
  Windows and Linux reach them from Edit > Selection.
- Off macOS, menus no longer show Mac Control chords that can't be
  pressed there (Time Lock's Ctrl+Alt+L, Toggle Recent Playlist's
  Ctrl+Alt+Shift+Left, the new Extend Edit keys); the arrow chords that
  fixed() maps to Ctrl keep their labels.

Track headers show each row of controls only once it fits: the keys
from 46 points and the view and automation selectors from 86, where
each row ends. The old 30 and 60 point cutoffs suited the presets only
and cut controls off at heights in between. Every preset draws exactly
as before.

Tests: the model's custom heights, steps and JSON; the engine's point
heights and steps, with no undo step when nothing changes; in the UI,
the live drag and its single undo step, Cmd, Alt and Alt+Shift drags
that hold steady while the pointer does, the height keys on macOS,
Windows and Linux, and the Extend Edit keys on macOS.
This commit is contained in:
grovebit 2026-10-12 22:11:34 +01:00
parent 6cf896e76c
commit 1953f2ecc6
6 changed files with 276 additions and 17 deletions

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() {

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,
@ -485,7 +499,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) {
@ -495,6 +512,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();
@ -680,4 +717,20 @@ mod tests {
assert!(matches!(kick.mixer.output, Route::Bus(_)));
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));
}
}

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);
}
}

View File

@ -259,6 +259,12 @@ 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)
}
/// 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();
@ -777,13 +783,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 x0 = head.min.x + 12.0;
@ -896,7 +915,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
egui::Popup::menu(&resp).show(|ui| crate::mix_window::instrument_menu(app, ui, id));
}
}
if h < 30.0 {
if h < 46.0 {
return;
}
// Buttons row.
@ -926,7 +945,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
let _ = app.run("mix.mute", json!({"track": id.0}));
}
}
if h < 60.0 {
if h < 86.0 {
return;
}
// View selector.
@ -1107,7 +1126,8 @@ 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()}));
}
}
});

View File

@ -175,9 +175,17 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
}
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).
@ -258,15 +266,19 @@ fn tab_clip(app: &mut SoundApp, back: bool) {
/// 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;
@ -280,6 +292,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]

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)));
}