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