collective

Browse / github.com/storytold/soundcraft / patch

Let the Edit window's I/O column set volume and pan

[Feature Request] [SoundCraft] adjust vol and pan from the track window ### Description it would be excellent to be able to edit the volume and pan functions next to the track in the track window. ### Ticket details | | | |---|---| | **App** | SoundCraft | | **Type** | Feature Request | | **Reported by** | deadmew2 — Discord user ID `700505161621045271` | | **Submitted** | 2026-10-17 17:39:33 UTC | | **Discord server** | ArtCraft (`1359579021108842617`) | | **Submitted from channel** | `1558917176453374102` | | **Submission ID** | `TR-QGG0W2K1X4` | <sub>Submitted through Discord by **deadmew2** and created by the Ticket Relay integration on their behalf. The r

Status
open
Score
0.00
Adopters
0 other operators
Author
lucasegen (imported from pull request #972, open)
Base
60087e0f46032118bf309ed9ea161a9b4a872713
Size
+365 −17 in 4 files
Review
pending
Shared
2026-10-28
Id
01053e83f1cc0ac5fdfbfcb6

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/edit_window/header.rs crates/ui-egui/src/mix_window.rs crates/ui-egui/src/mix_window/sends.rs crates/ui-egui/tests/it/edit_window_headers.rs

diff --git a/crates/ui-egui/src/edit_window/header.rs b/crates/ui-egui/src/edit_window/header.rs--- a/crates/ui-egui/src/edit_window/header.rs+++ b/crates/ui-egui/src/edit_window/header.rs@@ -455,11 +455,143 @@ fn io_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) {         let pan = m.pan.first().map_or("-".into(), |p| crate::widgets::pan_text(*p));         // The readout is black in both themes, so it takes the dark palette's green.         let ink = Tokens::DARK.counter_text;-        for (y, label, value) in [(7.5, "vol", crate::widgets::db_text(m.volume_db)), (22.0, "pan", pan)] {+        // The two rows are buttons: a click opens a fader or pan sliders, an Alt-click resets.+        // The Mix window's strip has no stereo pan for a VCA or for a track on a multichannel output (it shows a surround panner), so the pan stays text.+        // ponytail: surround-panned tracks are placed in the Mix window. Upgrade: `widgets::surround_panner` in the pan popup.+        let pannable = !m.pan.is_empty() && track.kind != TrackKind::Vca && !crate::mix_window::surround_panned(app, track);+        let vol_row = Rect::from_x_y_ranges(vr.x_range(), vr.min.y + 1.5..=vr.min.y + 14.0).shrink2(vec2(1.5, 0.0));+        let pan_row = Rect::from_x_y_ranges(vr.x_range(), vr.min.y + 15.0..=vr.max.y - 1.5).shrink2(vec2(1.5, 0.0));+        let vol_text = crate::widgets::db_text(m.volume_db);+        let vol = readout_button(+            &ui,+            vol_row,+            ui.scope_id().with(("vol", id.0)),+            &format!("{} volume", track.name),+            &vol_text,+            "Volume: click for a fader, Alt-click for 0 dB",+        );+        let pan_resp = pannable.then(|| {+            readout_button(+                &ui,+                pan_row,+                ui.scope_id().with(("pan", id.0)),+                &format!("{} pan", track.name),+                &pan,+                "Pan: click for sliders, Alt-click to reset",+            )+        });+        let alt = ui.input(|i| i.modifiers.alt);+        if vol.clicked() && alt && m.volume_db.abs() > f32::EPSILON {+            // Merged, so a reset during an automation pass joins the pass's undo step; the merge ends by itself on the next frame.+            let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": 0.0}), &format!("fader:{}", id.0));+        }+        if let Some(resp) = &pan_resp+            && resp.clicked()+            && alt+        {+            reset_pan(app, track);+        }+        for (y, label, value) in [(7.5, "vol", vol_text), (22.0, "pan", pan)] {             ui.painter().text(pos2(vr.min.x + 7.0, vr.min.y + y), Align2::LEFT_CENTER, label, bold(10.0), ink);             ui.painter().text(pos2(vr.max.x - 6.5, vr.min.y + y), Align2::RIGHT_CENTER, value, bold(10.0), ink);         }         ui.painter().hline(vr.min.x + 7.0..=vr.max.x - 6.0, vr.min.y + 14.5, Stroke::new(1.0, Color32::from_gray(35)));+        readout_popup(&vol, !alt).show(|ui| volume_fader(app, ui, track));+        if let Some(resp) = &pan_resp {+            readout_popup(resp, !alt).show(|ui| pan_sliders(app, ui, track));+        }+    }+}++/// A readout row of the I/O column as a button, named `name` (the track's, so two tracks showing the same value differ) and its value. It lights up under the pointer, with the keyboard focus or while its popup is open,+/// and paints nothing otherwise, so an untouched column looks the same as before.+fn readout_button(ui: &Ui, r: Rect, id: egui::Id, name: &str, value: &str, tip: &str) -> egui::Response {+    let resp = ui.interact(r, id, Sense::click()).on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand);+    resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("{name} {value}")));+    if resp.hovered() || resp.has_focus() || egui::Popup::is_id_open(ui.ctx(), egui::Popup::default_response_id(&resp)) {+        ui.painter().rect_filled(r, 1.5, Color32::from_gray(38));+    }+    resp+}++/// The popup of a readout button, to its right so it doesn't hide the tracks below. An Alt-click+/// resets instead of opening (`toggle` false). Clicks inside it keep it open; Escape or a click outside closes it.+fn readout_popup(resp: &egui::Response, toggle: bool) -> egui::Popup<'static> {+    egui::Popup::from_response(resp)+        .open_memory((toggle && resp.clicked()).then_some(egui::SetOpenCommand::Toggle))+        .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)+        .align(egui::RectAlign::RIGHT_START)+        .gap(2.0)+}++/// What a panner returns to: centre on a mono track, hard left or right on a stereo one, as on a new track.+fn default_pan(track: &Track, index: usize) -> f32 {+    soundcraft_model::Mixer::new(track.format).pan.get(index).copied().unwrap_or(0.0)+}++/// Put every panner of the track (L and R on a stereo one) back to its default, as one undo step. The merge ends by itself on the next frame.+fn reset_pan(app: &mut SoundApp, track: &Track) {+    let key = format!("pan-reset:{}", track.id.0);+    for (i, now) in track.mixer.pan.iter().enumerate().take(2) {+        let to = default_pan(track, i);+        // Only a real change may start the merged step: a no-op first call would leave the next one folded into an older edit.+        if (now - to).abs() > f32::EPSILON {+            let _ = app.engine.execute_merged("mix.pan", &json!({"track": track.id.0, "pan": to, "index": i}), &key);+        }+    }+}++/// The volume popup: a fader like the Mix window's, over a readout that takes a typed value.+/// A drag is one undo step and goes through [`Gesture::Fader`], so Touch and Latch automation write it.+fn volume_fader(app: &mut SoundApp, ui: &mut Ui, track: &Track) {+    let t = Tokens::current();+    let id = track.id;+    let db = track.mixer.volume_db;+    let key = format!("fader:{}", id.0);+    let (well, _) = ui.allocate_exact_size(vec2(56.0, 16.0), Sense::hover());+    crate::widgets::inset(ui.painter(), well, t.counter_bg);+    let readout_id = ui.scope_id().with(("io-vol", id.0));+    let (_, typed) = crate::mix_window::editable_readout(+        ui,+        well,+        readout_id,+        crate::mix_window::Readout {+            name: &format!("{} volume", track.name),+            display: &crate::widgets::db_text(db),+            edit_initial: &format!("{db:.1}"),+            parse: crate::mix_window::parse_db,+            reset: 0.0,+        },+    );+    if let Some(db) = typed {+        let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": db}), &key);+    }+    ui.add_space(4.0);+    // ponytail: the fader is drawn, not focusable, so the keyboard sets the volume in the readout above it. Upgrade: arrow keys in `widgets::fader`.+    let (slot, _) = ui.allocate_exact_size(vec2(56.0, 170.0), Sense::hover());+    if let Some(moved) = crate::widgets::fader(ui, slot, db, ui.scope_id().with(("io-fader", id.0)), &format!("{} level", track.name)) {+        app.gesture = Some(Gesture::Fader { track: id, start_db: db });+        let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": moved}), &key);+    }+}++/// The pan popup: one slider, or L and R on a stereo track. A drag is one undo step per panner.+fn pan_sliders(app: &mut SoundApp, ui: &mut Ui, track: &Track) {+    ui.spacing_mut().slider_width = 120.0;+    let stereo = track.mixer.pan.len() > 1;+    for (i, side) in ["L", "R"].into_iter().enumerate().take(track.mixer.pan.len()) {+        let before = track.mixer.pan.get(i).copied().unwrap_or(0.0);+        let mut pan = before;+        // ponytail: no double-click reset on the sliders; the readout's Alt-click resets every panner at once.+        // No step either: a slider rounds to it when drawn, which would rewrite a pan off the grid just by opening the popup.+        let slider = egui::Slider::new(&mut pan, -1.0..=1.0)+            .text(if stereo { side } else { "Pan" })+            .custom_formatter(|v, _| crate::widgets::pan_text(v as f32))+            .custom_parser(|s| crate::mix_window::parse_pan(s).map(f64::from));+        ui.add(slider);+        if (pan - before).abs() > f32::EPSILON {+            let _ = app.engine.execute_merged("mix.pan", &json!({"track": track.id.0, "pan": pan, "index": i}), &format!("pan:{}:{i}", track.id.0));+        }     } } diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs--- a/crates/ui-egui/src/mix_window.rs+++ b/crates/ui-egui/src/mix_window.rs@@ -20,7 +20,7 @@ pub use plugin_menu::{instrument_menu, plugin_info, plugin_menu}; pub(crate) use routing::{input_name, route_name, route_popup}; #[cfg(test)] use sends::dragged_send_db;-use sends::{Readout, editable_readout, parse_db, parse_pan};+pub(crate) use sends::{Readout, editable_readout, parse_db, parse_pan}; pub(crate) use sends::{send_slot, send_window}; pub(crate) use slots::{insert_slot, instrument_slot}; @@ -90,6 +90,12 @@ fn output_format(app: &SoundApp, track: &Track) -> ChannelFormat {     } } +/// Whether the strip pans a track with the surround panner, because its output is multichannel, instead of the stereo pans.+pub(crate) fn surround_panned(app: &SoundApp, track: &Track) -> bool {+    let out_fmt = output_format(app, track);+    out_fmt.channels() > 2 && !out_fmt.is_ambisonic() && !matches!(track.kind, TrackKind::Master | TrackKind::Vca) && track.channels() <= 2+}+ /// Speaker dots of a format, placed on the panner square. fn panner_speakers(fmt: ChannelFormat) -> Vec<PannerSpeaker> {     let sp = fmt.speakers();@@ -373,9 +379,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh     y += 22.0;     // Pan: a surround panner when the output is multichannel, else the stereo knobs.     let out_fmt = output_format(app, track);-    let surround_out =-        out_fmt.channels() > 2 && !out_fmt.is_ambisonic() && !matches!(track.kind, TrackKind::Master | TrackKind::Vca) && track.channels() <= 2;-    if surround_out {+    if surround_panned(app, track) {         let size = (inner_w - 4.0).min(88.0);         let sp = track.mixer.surround.unwrap_or_else(|| {             SurroundPan::from_stereo(if track.mixer.pan.len() == 1 { track.mixer.pan.first().copied().unwrap_or(0.0) } else { 0.0 })diff --git a/crates/ui-egui/src/mix_window/sends.rs b/crates/ui-egui/src/mix_window/sends.rs--- a/crates/ui-egui/src/mix_window/sends.rs+++ b/crates/ui-egui/src/mix_window/sends.rs@@ -224,22 +224,22 @@ pub(super) fn dragged_send_db(db: f32, dy: f32, fine: bool) -> f32 { }  /// What an [`editable_readout`] shows and how a typed value is parsed.-pub(super) struct Readout<'a> {+pub(crate) struct Readout<'a> {     /// Accessible name of the text field shown while editing.-    pub(super) name: &'a str,+    pub(crate) name: &'a str,     /// The value painted while not editing.-    pub(super) display: &'a str,+    pub(crate) display: &'a str,     /// The editor's initial text.-    pub(super) edit_initial: &'a str,-    pub(super) parse: fn(&str) -> Option<f32>,+    pub(crate) edit_initial: &'a str,+    pub(crate) parse: fn(&str) -> Option<f32>,     /// The value an Alt-click sets.-    pub(super) reset: f32,+    pub(crate) reset: f32, }  /// Paint a value until clicked, then replace it with a focused text field. Enter or clicking away /// commits a valid value; Escape cancels, and so does the field losing focus while hidden. /// Alt-click returns `reset`. The interaction response remains available for dragging.-pub(super) fn editable_readout(ui: &mut Ui, r: Rect, id: egui::Id, readout: Readout<'_>) -> (Option<egui::Response>, Option<f32>) {+pub(crate) fn editable_readout(ui: &mut Ui, r: Rect, id: egui::Id, readout: Readout<'_>) -> (Option<egui::Response>, Option<f32>) {     let Readout { name, display, edit_initial, parse, reset } = readout;     let t = Tokens::current();     let active_id = id.with("active");@@ -296,7 +296,7 @@ pub(super) fn editable_readout(ui: &mut Ui, r: Rect, id: egui::Id, readout: Read     (response, value) } -pub(super) fn parse_db(text: &str) -> Option<f32> {+pub(crate) fn parse_db(text: &str) -> Option<f32> {     let cleaned = text.trim().to_ascii_lowercase().replace("db", "").replace(',', ".");     if matches!(cleaned.trim(), "-inf" | "-infinity" | "off") {         return Some(-144.0);@@ -304,7 +304,7 @@ pub(super) fn parse_db(text: &str) -> Option<f32> {     cleaned.trim().parse::<f32>().ok().filter(|v| v.is_finite()).map(|v| v.clamp(-144.0, 12.0)) } -pub(super) fn parse_pan(text: &str) -> Option<f32> {+pub(crate) fn parse_pan(text: &str) -> Option<f32> {     let cleaned = text.trim().to_ascii_lowercase().replace(',', ".");     let (sign, number) = if let Some(v) = cleaned.strip_prefix('<').or_else(|| cleaned.strip_prefix('l')) {         (-1.0, v)diff --git a/crates/ui-egui/tests/it/edit_window_headers.rs b/crates/ui-egui/tests/it/edit_window_headers.rs--- a/crates/ui-egui/tests/it/edit_window_headers.rs+++ b/crates/ui-egui/tests/it/edit_window_headers.rs@@ -1,12 +1,13 @@-use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2};+use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use serde_json::json;-use soundcraft_model::{Track, TrackId};+use soundcraft_model::{AutoParam, Track, TrackId}; use soundcraft_ui_egui::edit_window::HEADER_W;-use soundcraft_ui_egui::{MainWindow, Services, SoundApp};+use soundcraft_ui_egui::{Gesture, MainWindow, Services, SoundApp};  const CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };+const ALT: Modifiers = Modifiers { alt: true, ..Modifiers::NONE };  /// Feed one harness frame through the raw-input hook before stepping, so scripted input reaches the app. fn step(h: &mut Harness<'_, SoundApp>) {@@ -181,3 +182,214 @@ fn only_a_sends_fader_icon_opens_its_level() {     assert_eq!(h.state().ui.send_window, Some((snare, 0)));     assert!(h.query_by_label("DESTINATION").is_none()); }++/// The I/O column's "vol" and "pan" rows, for a track whose row is `row`.+fn vol_at(row: [f32; 4]) -> Pos2 {+    pos2(row[0] + HEADER_W + 40.0, row[1] + 52.5)+}++fn pan_at(row: [f32; 4]) -> Pos2 {+    pos2(row[0] + HEADER_W + 40.0, row[1] + 66.5)+}++fn undo_steps(h: &Harness<'_, SoundApp>) -> usize {+    h.state().engine.undo_history().len()+}++fn track(h: &Harness<'_, SoundApp>, name: &str) -> Track {+    h.state().engine.session().track_by_name(name).cloned().unwrap()+}++/// The open popup (the only foreground layer while one is open).+fn popup_rect(h: &Harness<'_, SoundApp>) -> Rect {+    let rects: Vec<Rect> = h.ctx.memory(|m| {+        m.areas().visible_layer_ids().into_iter().filter(|l| l.order == egui::Order::Foreground).filter_map(|l| m.area_rect(l.id)).collect()+    });+    assert_eq!(rects.len(), 1, "expected exactly one popup, found {}", rects.len());+    rects[0]+}++/// Press at `from`, drag to `to` in four moves and release. `during` runs after each move, button down.+fn drag(h: &mut Harness<'_, SoundApp>, from: Pos2, to: Pos2, mut during: impl FnMut(&mut Harness<'_, SoundApp>)) {+    let modifiers = Modifiers::NONE;+    h.state_mut()+        .synthetic+        .extend([Event::PointerMoved(from), Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers }]);+    for _ in 0..6 {+        step(h);+    }+    for i in 1..=4 {+        h.state_mut().synthetic.push(Event::PointerMoved(from + (to - from) * (i as f32 / 4.0)));+        for _ in 0..3 {+            step(h);+        }+        during(h);+    }+    h.state_mut().synthetic.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers });+    for _ in 0..6 {+        step(h);+    }+}++#[test]+fn alt_click_on_the_io_readouts_resets_volume_and_pan() {+    let (mut h, id, row) = harness("Kick", "medium", "view.edit_io");+    h.state_mut().run("mix.pan", json!({"track": id.0, "pan": 0.5})).unwrap();+    assert!(kick(&h).mixer.volume_db != 0.0 && kick(&h).mixer.pan == [0.5]);+    let before = undo_steps(&h);++    press(&mut h, vol_at(row), PointerButton::Primary, ALT);+    assert_eq!(kick(&h).mixer.volume_db, 0.0);+    press(&mut h, pan_at(row), PointerButton::Primary, ALT);+    assert_eq!(kick(&h).mixer.pan, [0.0]);++    assert_eq!(undo_steps(&h), before + 2, "each reset is one undo step");+    assert!(!egui::Popup::is_any_open(&h.ctx), "an Alt-click opened a popup instead of resetting");+}++#[test]+fn alt_click_on_a_stereo_tracks_pan_resets_both_panners_in_one_step() {+    let (mut h, id, row) = harness("Pad", "medium", "view.edit_io");+    for (index, pan) in [(0, -0.25), (1, 0.75)] {+        h.state_mut().run("mix.pan", json!({"track": id.0, "pan": pan, "index": index})).unwrap();+    }+    assert_eq!(track(&h, "Pad").mixer.pan, [-0.25, 0.75]);+    let before = undo_steps(&h);++    press(&mut h, pan_at(row), PointerButton::Primary, ALT);+    assert_eq!(track(&h, "Pad").mixer.pan, [-1.0, 1.0], "a stereo track goes back to hard left and right, like a new one");+    assert_eq!(undo_steps(&h), before + 1);+    assert!(!egui::Popup::is_any_open(&h.ctx));+}++#[test]+fn clicking_the_volume_readout_opens_a_fader_whose_drag_is_one_undo_step() {+    let (mut h, id, _) = harness("Kick", "medium", "view.edit_io");+    let db = kick(&h).mixer.volume_db;+    let before = undo_steps(&h);+    assert!(!egui::Popup::is_any_open(&h.ctx));++    // The readout is a button named after its track and its value.+    let readout = h.get_by_label("Kick volume -3.0").rect().center();+    click(&mut h, readout);+    assert!(egui::Popup::is_any_open(&h.ctx), "the click didn't open the fader");+    assert_eq!(kick(&h).mixer.volume_db, db, "opening the fader changed the volume");++    // The popup floats over the timeline: a drag in it must not also select or move anything there.+    let edit = h.state().engine.session().edit.clone();+    // The fader fills the bottom of the popup and moves by drag distance, so any point on it will do.+    let popup = popup_rect(&h);+    let from = pos2(popup.center().x, popup.max.y - 50.0);+    let mut gestures = 0;+    drag(&mut h, from, from - vec2(0.0, 60.0), |h| {+        assert!(matches!(h.state().gesture, Some(Gesture::Fader { track, .. }) if track == id), "the drag isn't a Fader gesture");+        gestures += 1;+    });+    assert_eq!(gestures, 4);+    assert!(kick(&h).mixer.volume_db > db, "dragging up didn't raise the volume");+    assert_eq!(undo_steps(&h), before + 1, "a whole drag is one undo step");+    let after = h.state().engine.session().edit.clone();+    assert!(after.selection == edit.selection && after.selected_tracks == edit.selected_tracks, "the drag leaked through to the timeline");+    assert!(h.state().gesture.is_none(), "the gesture outlived the drag");+    assert!(egui::Popup::is_any_open(&h.ctx), "releasing the fader closed it");++    h.state_mut().engine.undo();+    assert_eq!(kick(&h).mixer.volume_db, db, "one undo should restore the volume from before the drag");++    click(&mut h, pos2(800.0, 990.0));+    assert!(!egui::Popup::is_any_open(&h.ctx), "a click outside left the fader open");+}++#[test]+fn the_popup_fader_writes_touch_automation() {+    let (mut h, id, row) = harness("Kick", "medium", "view.edit_io");+    h.state_mut().run("mix.automation_mode", json!({"track": id.0, "mode": "touch"})).unwrap();+    click(&mut h, vol_at(row));+    let popup = popup_rect(&h);+    let from = pos2(popup.center().x, popup.max.y - 50.0);++    h.state_mut().engine.transport.playing = true;+    drag(&mut h, from, from - vec2(0.0, 60.0), |h| {+        // Touch writes at the playhead, which has to keep moving for a new point to be due.+        h.state_mut().engine.transport.position += 4800;+    });+    h.state_mut().engine.transport.playing = false;+    step(&mut h);++    let points = kick(&h).lane(&AutoParam::Volume).map_or(0, |lane| lane.points.len());+    assert!(points > 0, "dragging the popup fader in Touch mode wrote no automation");+}++#[test]+fn clicking_the_pan_readout_opens_one_slider_on_mono_and_two_on_stereo() {+    for (name, panners) in [("Kick", 1), ("Pad", 2)] {+        let (mut h, _, row) = harness(name, "medium", "view.edit_io");+        let before = undo_steps(&h);+        click(&mut h, pan_at(row));+        assert!(egui::Popup::is_any_open(&h.ctx), "{name}: the click didn't open the sliders");+        let sliders: Vec<_> = h.query_all_by_role(egui::accesskit::Role::Slider).collect();+        assert_eq!(sliders.len(), panners, "{name}: wrong number of pan sliders");+        assert_eq!(undo_steps(&h), before, "{name}: opening the sliders changed the session");++        // Drag the last panner's slider (R on stereo) by 30 points: a slider follows the pointer.+        let slider = sliders.last().unwrap().rect();+        let (from, to) = (slider.center(), slider.center() + vec2(30.0, 0.0));+        let index = panners - 1;+        let pan = track(&h, name).mixer.pan.clone();+        drag(&mut h, from, to, |_| {});+        let moved = track(&h, name).mixer.pan;+        assert_eq!(moved.len(), panners);+        assert!(moved[index] != pan[index], "{name}: the slider didn't move the panner: {pan:?} -> {moved:?}");+        assert_eq!(moved[..index], pan[..index], "{name}: moving R touched L");+        assert_eq!(undo_steps(&h), before + 1, "{name}: a slider drag is one undo step");+    }+}++#[test]+fn a_value_typed_in_the_fader_popup_sets_the_volume() {+    let (mut h, _, row) = harness("Kick", "medium", "view.edit_io");+    click(&mut h, vol_at(row));+    let popup = popup_rect(&h);+    click(&mut h, pos2(popup.center().x, popup.min.y + 14.0));+    let key = |key, modifiers| {+        [+            Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },+            Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },+        ]+    };+    h.state_mut().synthetic.extend(key(egui::Key::A, CMD));+    h.state_mut().synthetic.push(Event::Text("-12".into()));+    h.state_mut().synthetic.extend(key(egui::Key::Enter, Modifiers::NONE));+    for _ in 0..10 {+        step(&mut h);+    }+    assert_eq!(kick(&h).mixer.volume_db, -12.0);+}++#[test]+fn opening_the_pan_popup_leaves_a_pan_off_the_percent_grid_alone() {+    let (mut h, id, row) = harness("Kick", "medium", "view.edit_io");+    h.state_mut().run("mix.pan", json!({"track": id.0, "pan": 0.3337})).unwrap();+    let before = undo_steps(&h);+    click(&mut h, pan_at(row));+    assert!(egui::Popup::is_any_open(&h.ctx));+    assert_eq!(kick(&h).mixer.pan, [0.3337], "opening the sliders rewrote the pan");+    assert_eq!(undo_steps(&h), before, "opening the sliders left an undo step");+    assert!(h.query_by_role_and_label(egui::accesskit::Role::Slider, "Pan").is_some(), "the lone pan slider has no name");+}++#[test]+fn a_track_on_a_surround_output_keeps_a_text_only_pan() {+    let (mut h, id, row) = harness("Kick", "medium", "view.edit_io");+    h.state_mut().run("setup.main_format", json!({"format": "5.1"})).unwrap();+    h.state_mut().run("mix.pan", json!({"track": id.0, "pan": 0.5})).unwrap();+    for _ in 0..4 {+        step(&mut h);+    }+    let before = undo_steps(&h);+    click(&mut h, pan_at(row));+    assert!(!egui::Popup::is_any_open(&h.ctx), "the Mix window pans this track with a surround panner, not these sliders");+    press(&mut h, pan_at(row), PointerButton::Primary, ALT);+    assert_eq!(kick(&h).mixer.pan, [0.5]);+    assert_eq!(undo_steps(&h), before);+}