Browse / github.com/storytold/soundcraft / patch
Sends: Ctrl or Command gives fine adjustment on a send's level, its sliders and the pan knobs
Sends: Ctrl or Command gives fine adjustment on a send's level, its sliders and the pan knobs Builds on #1081, which builds on #1059 and #1056; until they merge, this diff also shows their commits. On `main`, a vertical drag on a send's level moves it finely only while Shift is held. The Level and Pan sliders of a send's options menu (right-click on the send's level) and the pan knobs, in the Send window and on the tracks, have no fine adjustment. The reference guide (Pro Tools Reference Guide 2020.12, Chapter 3, "Controls and Editing Tools", page 26) lists "Fine adjustment of sliders, knobs, and breakpoints" as "Hold Command while clicking the item" on a Mac and "Hold Control while c
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dotCooCoo (imported from pull request #1098, open)
- Base
7dc56a4dbeebf57eacece09536726c57844fc59d- Size
- +882 −20 in 12 files
- Review
- pending
- Shared
- 2026-10-28
- Id
cfa100766fcbe62518559c19
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/ui-egui/src/dialogs.rs crates/ui-egui/src/mix_window.rs crates/ui-egui/src/panels.rs crates/ui-egui/src/widgets.rs crates/ui-egui/src/windows.rs crates/ui-egui/tests/it/main.rs crates/ui-egui/tests/it/plugin_default_reset.rs crates/ui-egui/tests/it/plugin_fine_sliders.rs crates/ui-egui/tests/it/send_fine_adjustment.rs crates/ui-egui/tests/it/strip_silence_sliders.rs docs/ui-parity.md
@@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows) -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (side panels in dock columns, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (side panels in dock columns, fine adjustment of sends, Auto Pan stereo balance, Alt-click returns a plugin parameter to its default, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1 SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is@@ -123,7 +123,11 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log - **2026-10-25:** Tracks and Groups and the Clip List sit in dock columns that run the Edit window's full height at its left and right edges, with the MIDI Editor under the session view between them; Mix docks Tracks and Groups at its left edge. A panel dragged onto a dock group joins it as a tab, stacks above or below it, or makes a new column; one dragged to an empty window edge makes a dock there. A list with no saved place reopens in its dock, and makes the dock again when it is gone. Preferences and window configurations saved with the earlier split-frame layouts load with the new default layouts and keep their other settings. No readiness percentage change.+- **2026-10-25:** A vertical drag on a send's level moves it as finely with Ctrl (Command on a Mac) held as with Shift, as a fader's Ctrl-drag does. The Level and Pan sliders of a send's options menu, the Send window's pan knob and the tracks' pan knobs move a tenth as far for the same drag while Ctrl (Command on a Mac) is held. No readiness percentage change. - **2026-10-25:** Auto Pan moves the balance of a stereo track: the left channel's level falls as the position moves right and the right channel's as it moves left, at constant power, and each channel stays on its own side. At the center the track passes unchanged, so a wide or out-of-phase stereo image keeps its width, and the envelope follower follows the louder channel. No readiness percentage change.+- **2026-10-24:** An Alt-click (Option-click on a Mac) on a parameter in the plugin window, the Clip Effects window or an AudioSuite plugin's window returns it to its default value, on a slider, a slider's value box or a choice list, as an Alt-click returns a fader to 0 dB. In the plugin window and the Clip Effects window the reset is one undo step. While Alt is held, a drag on a slider leaves the value where it is. No readiness percentage change.+- **2026-10-24:** The parameter sliders of the plugin window, the Clip Effects window and AudioSuite plugins move a tenth as far for the same drag while Ctrl (Command on a Mac) is held, as the Strip Silence sliders do. Logarithmic sliders move a tenth as far along the slider, and whole-number sliders stay on whole numbers. No readiness percentage change.+- **2026-10-24:** The Strip Silence window's four sliders move a tenth as far for the same drag while Ctrl (Command on a Mac) is held, on the slider and on its value box. Pressing or releasing the key during a drag leaves the value where it is. The Threshold and Clip Start Pad boxes show one decimal place. No readiness percentage change. - **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change. - **2026-10-24:** While the Strip Silence window is open, the Edit window draws a rectangle around each range that Strip would keep on the selected tracks, padded by Clip Start Pad and Clip End Pad, and the rectangles follow the sliders as they move. They are removed when the window closes. `edit.strip_silence_preview` returns the same ranges without changing the session. No readiness percentage change. - **2026-10-24:** The Strip Silence window has a Rename button. It opens Rename Selected Clips, where a name, a starting number, a count of zeros and a suffix set the names of the clips that Strip, Extract and Separate leave in the selection (for example SFX023.Reel1, SFX024.Reel1). The app keeps the scheme for later uses of Strip Silence until the app quits, or until Clear and then OK empty it. No readiness percentage change.@@ -177,7 +181,11 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| | 2026-10-25 | trivial | Progress log: side panels in dock columns; readiness estimates unchanged |+| 2026-10-25 | trivial | Progress log: fine adjustment of sends; readiness estimates unchanged | | 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged |+| 2026-10-24 | trivial | Progress log: Alt-click returns a plugin parameter to its default; readiness estimates unchanged |+| 2026-10-24 | trivial | Progress log: fine adjustment on plugin parameter sliders; readiness estimates unchanged |+| 2026-10-24 | trivial | Progress log: Strip Silence fine adjustment; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Strip Silence rectangles; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Strip Silence Rename; readiness estimates unchanged |@@ -656,10 +656,10 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms } => { let before = [*threshold, *min_ms, *pre_ms, *post_ms];- ui.add(egui::Slider::new(threshold, -96.0..=0.0).text("Threshold dB"));- ui.add(egui::Slider::new(min_ms, 0.0..=4000.0).text("Min strip duration ms"));- ui.add(egui::Slider::new(pre_ms, 0.0..=500.0).text("Clip start pad ms"));- ui.add(egui::Slider::new(post_ms, 0.0..=2000.0).text("Clip end pad ms"));+ crate::widgets::fine_slider(ui, threshold, -96.0..=0.0, "Threshold dB");+ crate::widgets::fine_slider(ui, min_ms, 0.0..=4000.0, "Min strip duration ms");+ crate::widgets::fine_slider(ui, pre_ms, 0.0..=500.0, "Clip start pad ms");+ crate::widgets::fine_slider(ui, post_ms, 0.0..=2000.0, "Clip end pad ms"); let values = [*threshold, *min_ms, *pre_ms, *post_ms]; // The Edit window is drawn before this window, so it draws the rectangles for new values in the next frame. if values != before {@@ -1090,7 +1090,12 @@ fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { if let Some(info) = soundcraft_dsp::plugin_info(&process) { for p in info.params { let v = params.entry(p.id.to_string()).or_insert(p.default);- ui.add(egui::Slider::new(v, p.min..=p.max).text(p.name));+ let mut wide = f64::from(*v);+ crate::widgets::FineSlider::new(&mut wide, f64::from(p.min)..=f64::from(p.max))+ .text(p.name)+ .default_value(f64::from(p.default))+ .show(ui);+ *v = wide as f32; } } else { match process.as_str() {@@ -1019,13 +1019,15 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us if level_resp.dragged() { // Use this frame's pointer movement. `Response::drag_delta()` is cumulative, so // applying it to the already-updated value made trackpad adjustment accelerate.- let (dy, fine) = ui.input(|i| (i.pointer.delta().y, i.modifiers.shift));+ // Ctrl (Command on a Mac) gives fine control, as on a fader, and so does Shift.+ let (dy, fine) = ui.input(|i| (i.pointer.delta().y, i.modifiers.shift || i.modifiers.command || i.modifiers.ctrl)); let db = dragged_send_db(s.level_db, dy, fine); let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0)); }+ let fine_drag = if ui.ctx().os().is_mac() { "⌘-drag" } else { "Ctrl-drag" }; let _ = level_resp.clone().on_hover_text(format!(- "Send {which} to {name}: {} dB\nClick for the send fader · drag vertically to adjust · Shift-drag for fine control · Alt-click for 0 dB · right-click for options",+ "Send {which} to {name}: {} dB\nClick for the send fader · drag vertically to adjust · {fine_drag} or Shift-drag for fine control · Alt-click for 0 dB · right-click for options", db_text(s.level_db) )); level_resp.context_menu(|ui| send_options_menu(app, ui, id, slot, s));@@ -1152,8 +1154,11 @@ fn send_window_contents(app: &mut SoundApp, ctx: &egui::Context) { fn send_options_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, send: &soundcraft_model::SendSlot) { ui.set_min_width(230.0); ui.strong(format!("Send {} · {}", (b'A' + slot as u8) as char, route_name(app, &send.target)));- let mut db = send.level_db.max(-60.0);- if ui.add(egui::Slider::new(&mut db, -60.0..=12.0).text("Level").suffix(" dB")).changed() {+ let before = send.level_db.max(-60.0);+ let mut db = f64::from(before);+ crate::widgets::FineSlider::new(&mut db, -60.0..=12.0).text("Level").suffix(" dB").show(ui);+ let db = db as f32;+ if (db - before).abs() > f32::EPSILON { let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0)); } send_level_scale(ui, send.level_db);@@ -1171,8 +1176,10 @@ fn send_options_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": -144.0})); } });- let mut pan = send.pan;- if ui.add(egui::Slider::new(&mut pan, -1.0..=1.0).text("Pan")).changed() {+ let mut pan = f64::from(send.pan);+ crate::widgets::FineSlider::new(&mut pan, -1.0..=1.0).text("Pan").show(ui);+ let pan = pan as f32;+ if (pan - send.pan).abs() > f32::EPSILON { let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "pan": pan}), &format!("send-pan:{}:{slot}", id.0)); } ui.horizontal(|ui| {@@ -602,16 +602,28 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { } }); x = idx as f32;+ // An Alt-click (Option-click on a Mac) returns the choice to its+ // default, as on the sliders, instead of opening the list.+ if r.response.clicked() && ui.input(|i| i.modifiers.alt) {+ x = p.default;+ egui::Popup::close_all(ui.ctx());+ } r.response } else {- let mut sl = egui::Slider::new(&mut x, p.min..=p.max).show_value(false);+ let mut wide = f64::from(x);+ let mut sl = crate::widgets::FineSlider::new(&mut wide, f64::from(p.min)..=f64::from(p.max))+ .id_salt(p.id)+ .show_value(false)+ .default_value(f64::from(p.default)); if instrument && crate::sampler_window::whole_number(&ins.plugin, p.id) { sl = sl.integer(); } if p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0 { sl = sl.logarithmic(true); }- ui.add(sl)+ let r = sl.show(ui);+ x = wide as f32;+ r }; // The grid cell's painted label is not an accesskit name; tie the // combo box or slider to it so screen readers and label queries find it.@@ -222,14 +222,16 @@ pub(crate) fn selector_box_named(ui: &mut Ui, width: f32, height: f32, text: &st resp.on_hover_text(labels.tooltip) } -/// Rotary knob for pan / send pan (-1..1). Drag vertically; double-click resets.+/// Rotary knob for pan / send pan (-1..1). Drag vertically; double-click resets. While Ctrl+/// (Command on a Mac) is held, a drag turns it a tenth as far, as on a [`fader`]. pub fn pan_knob(ui: &mut Ui, size: f32, value: &mut f32, tip: &str) -> Response { let t = Tokens::current(); let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click_and_drag()); resp.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(*value), tip)); if resp.dragged() { let d = resp.drag_delta();- *value = (*value + (d.x - d.y) * 0.01).clamp(-1.0, 1.0);+ let fine = ui.input(|i| i.modifiers.command || i.modifiers.ctrl);+ *value = (*value + (d.x - d.y) * if fine { 0.001 } else { 0.01 }).clamp(-1.0, 1.0); } if resp.double_clicked() { *value = 0.0;@@ -424,6 +426,177 @@ pub fn db_text(db: f32) -> String { if db <= -143.9 { "-inf".into() } else { format!("{db:.1}") } } +/// Where a [`fine_slider`] drag is anchored: the pointer's x and the value there, and the pass+/// that last drew the slider during the drag.+#[derive(Clone, Copy)]+struct FineDrag {+ x: f32,+ value: f64,+ fine: bool,+ relative: bool,+ pass: u64,+}++/// A horizontal slider that moves a tenth as far for the same drag while Ctrl (Command on a Mac)+/// is held, as a [`fader`] does, on the rail and on the value box. Pressing or releasing the key+/// during a drag on the rail leaves the value where it is, and the drag goes on from there. The+/// value box shows the decimal places that a tenth of a point's step needs, and a drag on the+/// rail that the key has touched rounds the value to them.+pub fn fine_slider(ui: &mut Ui, value: &mut f64, range: std::ops::RangeInclusive<f64>, text: &str) -> Response {+ FineSlider::new(value, range).text(text).show(ui)+}++/// A [`fine_slider`] with the options of `egui::Slider` that plug-in parameters use.+pub struct FineSlider<'a> {+ value: &'a mut f64,+ range: std::ops::RangeInclusive<f64>,+ text: &'a str,+ id_salt: Option<&'a str>,+ show_value: bool,+ logarithmic: bool,+ integer: bool,+ default: Option<f64>,+ suffix: &'a str,+}++impl<'a> FineSlider<'a> {+ pub fn new(value: &'a mut f64, range: std::ops::RangeInclusive<f64>) -> Self {+ Self { value, range, text: "", id_salt: None, show_value: true, logarithmic: false, integer: false, default: None, suffix: "" }+ }++ /// Text after the number in the value box, as `egui::Slider::suffix`.+ pub fn suffix(mut self, suffix: &'a str) -> Self {+ self.suffix = suffix;+ self+ }++ /// The value that an Alt-click (Option-click on a Mac) on the slider or its value box sets,+ /// as a [`fader`] returns to 0 dB. While Alt is held, a drag leaves the value where it is, and+ /// an Alt-click on the value box does not open it for typing.+ pub fn default_value(mut self, default: f64) -> Self {+ self.default = Some(default);+ self+ }++ /// The label after the slider. It also tells sliders in the same `Ui` apart unless+ /// [`Self::id_salt`] is set.+ pub fn text(mut self, text: &'a str) -> Self {+ self.text = text;+ self+ }++ /// Tells sliders in the same `Ui` apart when they have no label.+ pub fn id_salt(mut self, salt: &'a str) -> Self {+ self.id_salt = Some(salt);+ self+ }++ /// Whether the value box is shown. Without it, a drag that the key has touched rounds the+ /// value only on a whole-number slider.+ pub fn show_value(mut self, show: bool) -> Self {+ self.show_value = show;+ self+ }++ /// A logarithmic rail, as `egui::Slider::logarithmic`, for a range above zero. A range that+ /// reaches zero or below stays linear.+ pub fn logarithmic(mut self, logarithmic: bool) -> Self {+ self.logarithmic = logarithmic;+ self+ }++ /// Whole numbers only, as `egui::Slider::integer`.+ pub fn integer(mut self) -> Self {+ self.integer = true;+ self+ }++ pub fn show(self, ui: &mut Ui) -> Response {+ let Self { value, range, text, id_salt, show_value, logarithmic, integer, default, suffix } = self;+ let id = ui.scope_id().with(("fine_slider", id_salt.unwrap_or(text)));+ let (fine, x) = ui.input(|i| (i.modifiers.command || i.modifiers.ctrl, i.pointer.interact_pos().map(|p| p.x)));+ let reset = default.is_some() && ui.input(|i| i.modifiers.alt);+ // A drag lasts through the pass that releases the button, where egui still sets the value+ // from the pointer. An anchor that the previous pass did not store was left by an earlier+ // drag, and is ignored.+ let pass = ui.ctx().cumulative_pass_nr();+ let mut drag = ui.data(|d| d.get_temp::<FineDrag>(id)).filter(|d| d.pass + 1 == pass);+ let (lo, hi) = (*range.start(), *range.end());+ let log = logarithmic && lo > 0.0 && hi > lo;+ // The handle's travel along the rail, as `egui::Slider` lays it out.+ let thickness = ui.text_style_height(&egui::TextStyle::Body).max(ui.spacing().interact_size.y);+ let travel = f64::from((ui.spacing().slider_width - 2.0 * thickness / 2.5).max(1.0));+ // Where a value sits along the rail (0 to 1), and back, as `egui::Slider` maps them.+ let span = if log { (hi / lo).ln() } else { hi - lo };+ let along = |v: f64| if span > 0.0 { if log { (v / lo).ln() / span } else { (v - lo) / span } } else { 0.0 };+ let at = |n: f64| {+ let n = n.clamp(0.0, 1.0);+ if log { lo * (n * span).exp() } else { lo + n * span }+ };+ // How far the value moves for one point of the rail at `v`.+ let per_point = |v: f64| if log { v * span / travel } else { span / travel };+ let scale = if fine { 0.1 } else { 1.0 };+ let decimals = if integer { 0 } else { (-(per_point(lo) * 0.1).log10()).ceil().clamp(0.0, 3.0) as usize };+ if let (Some(d), Some(x)) = (&mut drag, x) {+ if d.fine != fine || reset {+ *d = FineDrag { x, value: *value, fine, relative: true, pass };+ } else if d.relative {+ let moved = at(along(d.value) + f64::from(x - d.x) / travel * scale);+ let moved = if show_value || integer { egui::emath::round_to_decimals(moved, decimals) } else { moved };+ *value = moved.clamp(lo, hi);+ }+ }+ let hold = reset || drag.is_some_and(|d| d.relative);+ let speed = per_point(*value) * scale;+ let mut slider = egui::Slider::from_get_set(range, |v| {+ if let Some(v) = v+ && !hold+ {+ *value = v;+ }+ *value+ })+ .show_value(show_value)+ .logarithmic(log)+ .min_decimals(decimals)+ .suffix(suffix)+ .text(text);+ if integer {+ slider = slider.integer();+ }+ if fine {+ slider = slider.drag_value_speed(speed);+ }+ let mut resp = ui.add(slider);+ // The rail senses drags only, so a click on it ends as a drag that the pointer did not+ // move far enough to count as one.+ let clicked = resp.clicked() || (resp.drag_stopped() && ui.input(|i| i.pointer.could_any_button_be_click()));+ if let Some(d) = default+ && reset+ && clicked+ {+ let d = if integer { d.round() } else { d };+ *value = d.clamp(lo, hi);+ resp.surrender_focus();+ resp.mark_changed();+ }+ let rail = Rect::from_min_size(resp.rect.min, vec2(ui.spacing().slider_width, resp.rect.height()));+ let on_rail = resp.dragged() && ui.input(|i| i.pointer.press_origin()).is_some_and(|p| rail.contains(p));+ let next = match (on_rail, drag, x) {+ (true, Some(d), _) => Some(FineDrag { pass, ..d }),+ (true, None, Some(x)) => Some(FineDrag { x, value: *value, fine, relative: fine, pass }),+ _ => None,+ };+ ui.data_mut(|m| match next {+ Some(d) => {+ m.insert_temp(id, d);+ }+ None => m.remove::<FineDrag>(id),+ });+ resp+ }+}+ /// A speaker dot on the surround panner: label, square position (-1..1, y up = front), height layer. pub struct PannerSpeaker { pub label: &'static str,@@ -734,7 +734,12 @@ pub fn clip_effects(app: &mut SoundApp, ctx: &egui::Context) { let key = format!("{prefix}{}", p.id); let mut v = get(app, &key, p.default); let before = v;- col.add(egui::Slider::new(&mut v, p.min..=p.max).text(p.name));+ let mut wide = f64::from(v);+ crate::widgets::FineSlider::new(&mut wide, f64::from(p.min)..=f64::from(p.max))+ .text(p.name)+ .default_value(f64::from(p.default))+ .show(col);+ v = wide as f32; if (v - before).abs() > f32::EPSILON { let mut m = serde_json::Map::new(); m.insert(key.clone(), json!(v));@@ -34,6 +34,8 @@ mod mix_separate; mod new_midi_clip; mod new_tracks; mod open_on_insert;+mod plugin_default_reset;+mod plugin_fine_sliders; mod plugin_window; mod pro_tools_keys; mod record_hint;@@ -43,6 +45,7 @@ mod sampler_window; mod save_as_replace; mod save_changes; mod scroll_into_view;+mod send_fine_adjustment; mod send_window; mod shared_controls; mod shortcuts;@@ -51,6 +54,7 @@ mod side_lists_text; mod strip_silence_dialog; mod strip_silence_rectangles; mod strip_silence_rename;+mod strip_silence_sliders; mod toolbar_scroll; mod toolbar_tempo; mod track_height;new file mode 100644@@ -0,0 +1,182 @@+use egui::accesskit::Role;+use egui::{Event, Id, Modifiers, PointerButton, Pos2, Rect, vec2};+use egui_kittest::{Harness, kittest::Queryable};+use serde_json::json;+use soundcraft_dsp::ParamInfo;+use soundcraft_model::TrackId;+use soundcraft_ui_egui::{Services, SoundApp};+use std::collections::BTreeMap;++fn harness(app: SoundApp) -> Harness<'static, SoundApp> {+ let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(+ |ui, app: &mut SoundApp| {+ let ctx = ui.ctx().clone();+ app.logic(&ctx);+ app.ui(ui);+ },+ app,+ );+ h.run_steps(4);+ h+}++fn hold(h: &mut Harness<'_, SoundApp>, modifiers: Modifiers) {+ h.event(Event::ModifiersChanged(modifiers));+ h.run_steps(1);+}++fn press(h: &mut Harness<'_, SoundApp>, pos: Pos2, pressed: bool, modifiers: Modifiers) {+ h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers });+ h.run_steps(1);+}++fn move_to(h: &mut Harness<'_, SoundApp>, pos: Pos2) {+ h.event(Event::PointerMoved(pos));+ h.run_steps(1);+}++/// Presses at `at` with Alt held, moves `dx` points right in six steps, and releases; `dx` of 0+/// is an Alt-click.+fn alt_press(h: &mut Harness<'_, SoundApp>, at: Pos2, dx: f32) {+ hold(h, Modifiers::ALT);+ move_to(h, at);+ press(h, at, true, Modifiers::ALT);+ for i in 1..=6 {+ if dx != 0.0 {+ move_to(h, at + vec2(dx * i as f32 / 6.0, 0.0));+ }+ }+ press(h, at + vec2(dx, 0.0), false, Modifiers::ALT);+ hold(h, Modifiers::NONE);+ h.run_steps(2);+}++/// A point near the left end of `rail`, where a plain click would set a value far from the+/// parameter's default.+fn near_left(rail: Rect) -> Pos2 {+ rail.left_center() + vec2(3.0, 0.0)+}++/// The rails of the unlabeled sliders inside the window with id `window`, top to bottom.+fn window_sliders(h: &Harness<'_, SoundApp>, window: Id) -> Vec<Rect> {+ let area = h.ctx.memory(|m| m.area_rect(window)).unwrap();+ let mut rails: Vec<Rect> = h.query_all_by_role(Role::Slider).map(|n| n.rect()).filter(|r| area.contains_rect(*r)).collect();+ rails.sort_by(|a, b| a.top().total_cmp(&b.top()));+ rails+}++fn param(plugin: &str, id: &str) -> &'static ParamInfo {+ soundcraft_dsp::plugin_info(plugin).unwrap().params.iter().find(|p| p.id == id).unwrap()+}++fn insert_param(h: &Harness<'_, SoundApp>, track: TrackId, slot: usize, p: &ParamInfo) -> f32 {+ let ins = h.state().engine.session().track(track).unwrap().mixer.inserts[slot].as_ref();+ ins.unwrap().params.get(p.id).copied().unwrap_or(p.default)+}++fn undo_steps(h: &Harness<'_, SoundApp>) -> usize {+ h.state().engine.undo_history().len()+}++/// Kick's EQ 7-Band window, with Input at 6 dB and HPF Slope on its last choice.+fn kick_eq() -> (Harness<'static, SoundApp>, TrackId) {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ let kick = app.engine.session().track_by_name("Kick").unwrap().id;+ let slope = param("eq_7band", "hpf_slope");+ app.run("mix.insert_param", json!({"track": kick.0, "slot": 0, "param": "input_gain", "value": 6.0})).unwrap();+ app.run("mix.insert_param", json!({"track": kick.0, "slot": 0, "param": "hpf_slope", "value": slope.max})).unwrap();+ app.engine.end_merge();+ app.ui.plugin_windows = vec![(kick, 0)];+ (harness(app), kick)+}++#[test]+fn alt_click_returns_a_plugin_window_slider_to_its_default_in_one_undo_step() {+ let (mut h, kick) = kick_eq();+ let input = param("eq_7band", "input_gain");+ assert_ne!(input.default, 6.0);+ let rail = window_sliders(&h, Id::unique(("plugin", kick.0, 0usize)))[0];+ let steps = undo_steps(&h);+ alt_press(&mut h, near_left(rail), 0.0);+ assert_eq!(insert_param(&h, kick, 0, input), input.default, "Alt-click did not return Input to its default");+ assert_eq!(undo_steps(&h), steps + 1, "Alt-click is not one undo step");+ h.state_mut().engine.execute("edit.undo", &json!({})).unwrap();+ assert_eq!(insert_param(&h, kick, 0, input), 6.0, "Undo did not bring Input back");+}++#[test]+fn alt_drag_leaves_a_plugin_window_slider_where_it_is() {+ let (mut h, kick) = kick_eq();+ let input = param("eq_7band", "input_gain");+ let rail = window_sliders(&h, Id::unique(("plugin", kick.0, 0usize)))[0];+ let steps = undo_steps(&h);+ alt_press(&mut h, near_left(rail), 40.0);+ assert_eq!(insert_param(&h, kick, 0, input), 6.0, "an Alt-drag moved Input");+ assert_eq!(undo_steps(&h), steps, "an Alt-drag added an undo step");+}++#[test]+fn alt_click_returns_a_plugin_window_choice_to_its_default_without_opening_the_list() {+ let (mut h, kick) = kick_eq();+ let slope = param("eq_7band", "hpf_slope");+ assert_ne!(slope.default, slope.max);+ let row = h.get_by_label("HPF Slope").rect();+ let combo = h.query_all_by_role(Role::ComboBox).map(|n| n.rect()).find(|r| (r.center().y - row.center().y).abs() < 4.0).unwrap();+ alt_press(&mut h, combo.center(), 0.0);+ assert_eq!(insert_param(&h, kick, 0, slope), slope.default, "Alt-click did not return HPF Slope to its default");+ assert!(!egui::Popup::is_any_open(&h.ctx), "Alt-click opened the list");+}++#[test]+fn alt_click_returns_clip_effects_sliders_and_value_boxes_to_their_defaults() {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ let s = app.engine.session();+ let clip = s.track_by_name("Kick").unwrap().clips().iter().find(|c| c.is_audio()).unwrap().id;+ app.run("edit.select", json!({"clips": [clip.0]})).unwrap();+ app.run("clip.effects_set", json!({"clips": [clip.0], "params": {"eq.input_gain": 6.0, "comp.threshold": -6.0}})).unwrap();+ app.engine.end_merge();+ app.ui.show_clip_effects = true;+ let mut h = harness(app);+ for (label, key, p, on_box) in+ [("Input", "eq.input_gain", param("eq_7band", "input_gain"), false), ("Threshold", "comp.threshold", param("compressor", "threshold"), true)]+ {+ let key = format!("clip_fx.{}.{key}", clip.0);+ let read = |h: &Harness<'_, SoundApp>| h.state().engine.session().edit.values.get(&key).copied();+ assert_ne!(read(&h), Some(f64::from(p.default)));+ let rail = h.get_by_role_and_label(Role::Slider, label).rect();+ let at = if on_box {+ h.query_all_by_role(Role::SpinButton)+ .map(|n| n.rect())+ .find(|r| r.left() > rail.right() && (r.center().y - rail.center().y).abs() < 4.0)+ .unwrap()+ .center()+ } else {+ near_left(rail)+ };+ // A plain click on the value box gives it the keyboard focus for typing.+ let focus = h.ctx.memory(|m| m.focused());+ let steps = undo_steps(&h);+ alt_press(&mut h, at, 0.0);+ assert_eq!(read(&h), Some(f64::from(p.default)), "Alt-click did not return {label} to its default");+ assert_eq!(undo_steps(&h), steps + 1, "Alt-click on {label} is not one undo step");+ assert_eq!(h.ctx.memory(|m| m.focused()), focus, "Alt-click opened {label}'s value box for typing");+ }+}++#[test]+fn alt_click_returns_audiosuite_parameter_sliders_to_their_defaults() {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ app.ui.audiosuite = Some("compressor".into());+ let mut h = harness(app);+ let read = |h: &Harness<'_, SoundApp>| -> BTreeMap<String, f32> {+ h.ctx.data(|d| d.get_temp(Id::unique(("as_params", "compressor".to_string())))).unwrap_or_default()+ };+ let threshold = param("compressor", "threshold");+ let rail = h.get_by_role_and_label(Role::Slider, "Threshold").rect();+ move_to(&mut h, near_left(rail));+ press(&mut h, near_left(rail), true, Modifiers::NONE);+ press(&mut h, near_left(rail), false, Modifiers::NONE);+ assert_ne!(read(&h)["threshold"], threshold.default, "a plain click did not move Threshold");+ alt_press(&mut h, near_left(rail), 0.0);+ assert_eq!(read(&h)["threshold"], threshold.default, "Alt-click did not return Threshold to its default");+}new file mode 100644@@ -0,0 +1,174 @@+use egui::accesskit::Role;+use egui::{Event, Id, Modifiers, PointerButton, Pos2, Rect, vec2};+use egui_kittest::{Harness, kittest::Queryable};+use serde_json::json;+use soundcraft_dsp::ParamInfo;+use soundcraft_model::TrackId;+use soundcraft_ui_egui::{Services, SoundApp};+use std::collections::BTreeMap;++fn harness(app: SoundApp) -> Harness<'static, SoundApp> {+ let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(+ |ui, app: &mut SoundApp| {+ let ctx = ui.ctx().clone();+ app.logic(&ctx);+ app.ui(ui);+ },+ app,+ );+ h.run_steps(4);+ h+}++fn hold(h: &mut Harness<'_, SoundApp>, modifiers: Modifiers) {+ h.event(Event::ModifiersChanged(modifiers));+ h.run_steps(1);+}++fn press(h: &mut Harness<'_, SoundApp>, pos: Pos2, pressed: bool, modifiers: Modifiers) {+ h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers });+ h.run_steps(1);+}++fn move_to(h: &mut Harness<'_, SoundApp>, pos: Pos2) {+ h.event(Event::PointerMoved(pos));+ h.run_steps(1);+}++/// Presses the middle of `rail` and drags `dx` points right in six steps with `modifiers` held,+/// calling `read` after each step; returns how far `read` moved from where the press put it.+fn drag(h: &mut Harness<'_, SoundApp>, rail: Rect, dx: f32, modifiers: Modifiers, read: &dyn Fn(&Harness<'_, SoundApp>) -> f64) -> f64 {+ hold(h, modifiers);+ let from = rail.center();+ move_to(h, from);+ press(h, from, true, modifiers);+ let start = read(h);+ for i in 1..=6 {+ move_to(h, from + vec2(dx * i as f32 / 6.0, 0.0));+ read(h);+ }+ let moved = read(h) - start;+ press(h, from + vec2(dx, 0.0), false, modifiers);+ assert_eq!(read(h) - start, moved, "releasing the button moved the value");+ hold(h, Modifiers::NONE);+ moved+}++/// A plain 30-point drag and a Ctrl-drag of the same length on `rail`; returns how far each+/// moved `read`.+fn plain_and_fine(h: &mut Harness<'_, SoundApp>, rail: Rect, read: &dyn Fn(&Harness<'_, SoundApp>) -> f64) -> (f64, f64) {+ let plain = drag(h, rail, 30.0, Modifiers::NONE, read);+ let fine = drag(h, rail, 30.0, Modifiers::COMMAND, read);+ (plain, fine)+}++fn assert_tenth(what: &str, (plain, fine): (f64, f64), within: std::ops::RangeInclusive<f64>) {+ assert!(plain > 0.0, "{what}: a plain drag did not move the value");+ assert!(within.contains(&(fine / plain)), "{what}: Ctrl-drag moved {fine}, a plain drag {plain}");+}++/// The rails of the unlabeled sliders inside the window with id `window`, top to bottom.+fn window_sliders(h: &Harness<'_, SoundApp>, window: Id) -> Vec<Rect> {+ let area = h.ctx.memory(|m| m.area_rect(window)).unwrap();+ let mut rails: Vec<Rect> = h.query_all_by_role(Role::Slider).map(|n| n.rect()).filter(|r| area.contains_rect(*r)).collect();+ rails.sort_by(|a, b| a.top().total_cmp(&b.top()));+ rails+}++/// The plug-in's parameters that its window shows as sliders, in order.+fn slider_params(plugin: &str) -> Vec<&'static ParamInfo> {+ soundcraft_dsp::plugin_info(plugin).unwrap().params.iter().filter(|p| p.choices.is_empty()).collect()+}++fn insert_param(h: &Harness<'_, SoundApp>, track: TrackId, slot: usize, p: &ParamInfo) -> f64 {+ let tr = h.state().engine.session().track(track).unwrap();+ let ins = if slot == soundcraft_mix::INSTRUMENT_SLOT { tr.instrument.as_ref() } else { tr.mixer.inserts[slot].as_ref() };+ f64::from(ins.unwrap().params.get(p.id).copied().unwrap_or(p.default))+}++#[test]+fn plugin_window_sliders_move_a_tenth_as_far_with_ctrl_held() {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ let kick = app.engine.session().track_by_name("Kick").unwrap().id;+ app.ui.plugin_windows = vec![(kick, 0)];+ let mut h = harness(app);+ let rails = window_sliders(&h, Id::unique(("plugin", kick.0, 0usize)));+ let params = slider_params("eq_7band");+ for i in [0, 2] {+ let p = params[i];+ // Along the rail, so that a log-taper parameter compares as a linear one does.+ let read = move |h: &Harness<'_, SoundApp>| f64::from(p.to_normalized(insert_param(h, kick, 0, p) as f32));+ assert_tenth(p.name, plain_and_fine(&mut h, rails[i], &read), 0.08..=0.12);+ }+}++#[test]+fn a_small_ctrl_drag_at_the_low_end_of_a_log_slider_moves_the_value() {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ let kick = app.engine.session().track_by_name("Kick").unwrap().id;+ app.ui.plugin_windows = vec![(kick, 1)];+ let mut h = harness(app);+ let rails = window_sliders(&h, Id::unique(("plugin", kick.0, 1usize)));+ let attack = slider_params("compressor")[2];+ assert_eq!(attack.id, "attack");+ let from = rails[2].left_center() + vec2(1.0, 0.0);+ hold(&mut h, Modifiers::COMMAND);+ move_to(&mut h, from);+ press(&mut h, from, true, Modifiers::COMMAND);+ let start = insert_param(&h, kick, 1, attack);+ assert_eq!(start, f64::from(attack.min), "the press did not put Attack at its lowest value");+ move_to(&mut h, from + vec2(1.0, 0.0));+ let moved = insert_param(&h, kick, 1, attack);+ press(&mut h, from + vec2(1.0, 0.0), false, Modifiers::COMMAND);+ assert!(moved > start, "a one-point Ctrl-drag left Attack at {moved}");+}++#[test]+fn whole_number_instrument_sliders_move_a_tenth_as_far_and_stay_whole() {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ let keys = app.engine.session().track_by_name("Keys").unwrap().id;+ app.run("mix.instrument", json!({"track": keys.0, "plugin": "sampler"})).unwrap();+ app.ui.plugin_windows = vec![(keys, soundcraft_mix::INSTRUMENT_SLOT)];+ let mut h = harness(app);+ let rails = window_sliders(&h, Id::unique(("plugin", keys.0, soundcraft_mix::INSTRUMENT_SLOT)));+ let root = slider_params("sampler")[0];+ assert_eq!(root.id, "root_note");+ // Each read during the drags checks that the instrument was sent a whole number.+ let read = move |h: &Harness<'_, SoundApp>| {+ let v = insert_param(h, keys, soundcraft_mix::INSTRUMENT_SLOT, root);+ assert_eq!(v.fract(), 0.0, "Root Note is {v} during a drag");+ v+ };+ assert_tenth(root.name, plain_and_fine(&mut h, rails[0], &read), 0.06..=0.14);+}++#[test]+fn clip_effects_sliders_move_a_tenth_as_far_with_ctrl_held() {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ let s = app.engine.session();+ let clip = s.track_by_name("Kick").unwrap().clips().iter().find(|c| c.is_audio()).unwrap().id;+ app.run("edit.select", json!({"clips": [clip.0]})).unwrap();+ app.ui.show_clip_effects = true;+ let mut h = harness(app);+ for (label, key, default) in [("Input", "eq.input_gain", 0.0), ("Threshold", "comp.threshold", -20.0)] {+ let key = format!("clip_fx.{}.{key}", clip.0);+ let read = move |h: &Harness<'_, SoundApp>| h.state().engine.session().edit.values.get(&key).copied().unwrap_or(default);+ let rail = h.get_by_role_and_label(Role::Slider, label).rect();+ assert_tenth(label, plain_and_fine(&mut h, rail, &read), 0.08..=0.12);+ }+}++#[test]+fn audiosuite_parameter_sliders_move_a_tenth_as_far_with_ctrl_held() {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ app.ui.audiosuite = Some("compressor".into());+ let mut h = harness(app);+ for (label, id) in [("Threshold", "threshold"), ("Knee", "knee")] {+ let read = move |h: &Harness<'_, SoundApp>| {+ let params: BTreeMap<String, f32> = h.ctx.data(|d| d.get_temp(Id::unique(("as_params", "compressor".to_string())))).unwrap_or_default();+ f64::from(params[id])+ };+ let rail = h.get_by_role_and_label(Role::Slider, label).rect();+ assert_tenth(label, plain_and_fine(&mut h, rail, &read), 0.08..=0.12);+ }+}new file mode 100644@@ -0,0 +1,159 @@+//! Fine adjustment of a send: Ctrl (Command on a Mac) makes a drag on a send's level, on the+//! Level and Pan sliders of its options menu, and on the pan knobs move the value a fraction as far.++use egui::accesskit::Role;+use egui::{Event, Modifiers, PointerButton, Pos2, vec2};+use egui_kittest::Harness;+use egui_kittest::kittest::Queryable;+use serde_json::json;+use soundcraft_model::SendSlot;+use soundcraft_ui_egui::{MainWindow, Services, SoundApp};++fn harness() -> Harness<'static, SoundApp> {+ let mut engine = soundcraft_engine::Engine::default();+ engine.execute("track.new", &json!({"name": "Vox"})).unwrap();+ let vox = engine.session().tracks[0].id;+ engine.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();+ engine.execute("mix.send", &json!({"track": vox.0, "slot": 0, "bus": "Verb", "level_db": -10.0})).unwrap();+ 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+}++fn send(h: &Harness<SoundApp>) -> SendSlot {+ h.state().engine.session().tracks[0].mixer.sends[0].clone().unwrap()+}++/// Puts the send back at -10 dB and centered, so that every drag starts from the same place.+fn reset(h: &mut Harness<SoundApp>) {+ let vox = h.state().engine.session().tracks[0].id;+ h.state_mut().engine.execute("mix.send_level", &json!({"track": vox.0, "slot": 0, "db": -10.0, "pan": 0.0})).unwrap();+ h.state_mut().engine.execute("mix.pan", &json!({"track": vox.0, "pan": 0.0})).unwrap();+ h.run_steps(2);+}++fn hold(h: &mut Harness<SoundApp>, modifiers: Modifiers) {+ h.event(Event::ModifiersChanged(modifiers));+ h.run_steps(1);+}++fn press(h: &mut Harness<SoundApp>, pos: Pos2, pressed: bool, modifiers: Modifiers) {+ h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers });+ h.run_steps(1);+}++fn move_to(h: &mut Harness<SoundApp>, pos: Pos2) {+ h.event(Event::PointerMoved(pos));+ h.run_steps(1);+}++/// Presses `from` and drags by `by` in eight steps with `modifiers` held; returns how far `read`+/// moved from where the press left it.+fn drag(h: &mut Harness<SoundApp>, from: Pos2, by: egui::Vec2, modifiers: Modifiers, read: fn(&Harness<SoundApp>) -> f32) -> f32 {+ reset(h);+ hold(h, modifiers);+ move_to(h, from);+ press(h, from, true, modifiers);+ let start = read(h);+ for i in 1..=8 {+ move_to(h, from + by * (i as f32 / 8.0));+ }+ press(h, from + by, false, modifiers);+ hold(h, Modifiers::NONE);+ read(h) - start+}++fn level(h: &Harness<SoundApp>) -> f32 {+ send(h).level_db+}++fn pan(h: &Harness<SoundApp>) -> f32 {+ send(h).pan+}++/// A vertical drag on a send's level moves it as little with Ctrl or Command held as with Shift.+#[test]+fn ctrl_dragging_a_sends_level_is_as_fine_as_shift_dragging_it() {+ let mut h = harness();+ let at = h.get_by_label("Send A level for Vox").rect().center();+ let by = vec2(0.0, -40.0);+ let plain = drag(&mut h, at, by, Modifiers::NONE, level);+ let shift = drag(&mut h, at, by, Modifiers::SHIFT, level);+ assert!(plain > 0.0, "a plain drag did not raise the send");+ assert!(shift > 0.0 && shift < plain * 0.2, "Shift-drag moved {shift} dB, a plain drag {plain} dB");+ for (name, modifiers) in [("Ctrl", Modifiers::CTRL), ("Command", Modifiers::COMMAND)] {+ let fine = drag(&mut h, at, by, modifiers, level);+ assert!((fine - shift).abs() < 1e-4, "{name}-drag moved {fine} dB, a Shift-drag {shift} dB");+ }+}++/// Opens the send's options menu with a right-click on its level.+fn options_menu(h: &mut Harness<SoundApp>) {+ let at = h.get_by_label("Send A level for Vox").rect().center();+ h.hover_at(at);+ for pressed in [true, false] {+ h.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });+ }+ h.run_steps(3);+ assert!(h.query_by_role_and_label(Role::CheckBox, "Pre-fader").is_some(), "the send's options menu is open");+}++/// The Level and Pan sliders of a send's options menu move a tenth as far while Ctrl or Command is held.+#[test]+fn the_send_options_sliders_move_a_tenth_as_far_with_ctrl_held() {+ let mut h = harness();+ options_menu(&mut h);+ let read: [(&str, fn(&Harness<SoundApp>) -> f32); 2] = [("Level", level), ("Pan", pan)];+ for (label, read) in read {+ let rail = h.get_by_role_and_label(Role::Slider, label).rect().center();+ let plain = drag(&mut h, rail, vec2(30.0, 0.0), Modifiers::NONE, read);+ let fine = drag(&mut h, rail, vec2(30.0, 0.0), Modifiers::COMMAND, read);+ assert!(plain > 0.0, "{label}: a plain drag did not move the value");+ let ratio = fine / plain;+ assert!((0.08..=0.12).contains(&ratio), "{label}: Ctrl-drag moved {fine}, a plain drag {plain}");+ assert!(h.query_by_role_and_label(Role::CheckBox, "Pre-fader").is_some(), "{label}: the drags closed the menu");+ }+}++fn track_pan(h: &Harness<SoundApp>) -> f32 {+ h.state().engine.session().tracks[0].mixer.pan[0]+}++/// The Send window's pan knob, and a track's pan knob, which is the same widget, turn a tenth as+/// far while Ctrl or Command is held.+#[test]+fn pan_knobs_turn_a_tenth_as_far_with_ctrl_held() {+ let mut h = harness();+ h.get_by_label("Send A level for Vox").click();+ h.run_steps(3);+ let knobs: [(&str, fn(&Harness<SoundApp>) -> f32); 2] =+ [("Send pan (drag; double-click centres)", pan), ("Pan (drag; double-click centres)", track_pan)];+ for (label, read) in knobs {+ let at = h.get_by_role_and_label(Role::Slider, label).rect().center();+ let plain = drag(&mut h, at, vec2(0.0, -30.0), Modifiers::NONE, read);+ for (name, modifiers) in [("Ctrl", Modifiers::CTRL), ("Command", Modifiers::COMMAND)] {+ let fine = drag(&mut h, at, vec2(0.0, -30.0), modifiers, read);+ assert!(plain > 0.0, "{label}: a plain drag did not turn the knob");+ let ratio = fine / plain;+ assert!((0.08..=0.12).contains(&ratio), "{label}: {name}-drag turned it {fine}, a plain drag {plain}");+ }+ }+ assert!(h.state().ui.send_window.is_some(), "the drags closed the Send window");+}++/// The Level value box of a send's options menu still shows its value in dB.+#[test]+fn the_send_options_level_box_shows_db() {+ let mut h = harness();+ options_menu(&mut h);+ let shown = h.get_by_role_and_label(Role::SpinButton, "Level").value().unwrap_or_default();+ assert!(shown.starts_with("-10") && shown.ends_with(" dB"), "the Level box shows {shown:?}");+}new file mode 100644@@ -0,0 +1,129 @@+use egui::accesskit::Role;+use egui::{Event, Modifiers, PointerButton, Pos2, vec2};+use egui_kittest::{Harness, kittest::Queryable};+use serde_json::json;+use soundcraft_ui_egui::dialogs::Dialog;+use soundcraft_ui_egui::{Services, SoundApp, menus};++const SLIDERS: [&str; 4] = ["Threshold dB", "Min strip duration ms", "Clip start pad ms", "Clip end pad ms"];++/// Selects the whole Kick track and opens Edit > Strip Silence from the menu.+fn strip_silence_dialog() -> Harness<'static, SoundApp> {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ let track = app.engine.session().track_by_name("Kick").unwrap().id;+ let end = app.engine.session().content_end();+ app.run("edit.select", json!({"tracks": [track.0], "start": 0, "end": end})).unwrap();+ menus::invoke_menu(&mut app, "edit.strip_silence", "Edit > Strip Silence");+ let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(+ |ui, app: &mut SoundApp| {+ let ctx = ui.ctx().clone();+ app.logic(&ctx);+ app.ui(ui);+ },+ app,+ );+ h.run_steps(3);+ h+}++/// The value of the slider labeled `label`, in the order the window shows them.+fn value(h: &Harness<'_, SoundApp>, label: &str) -> f64 {+ let Some(Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms }) = h.state().dialogs.open else { panic!("Strip Silence is not open") };+ let i = SLIDERS.iter().position(|s| *s == label).unwrap();+ [threshold, min_ms, pre_ms, post_ms][i]+}++fn hold(h: &mut Harness<'_, SoundApp>, modifiers: Modifiers) {+ h.event(Event::ModifiersChanged(modifiers));+ h.run_steps(1);+}++fn press(h: &mut Harness<'_, SoundApp>, pos: Pos2, pressed: bool, modifiers: Modifiers) {+ h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers });+ h.run_steps(1);+}++fn move_to(h: &mut Harness<'_, SoundApp>, pos: Pos2) {+ h.event(Event::PointerMoved(pos));+ h.run_steps(1);+}++/// Presses the middle of a slider's rail (`role` `Slider`) or value box (`SpinButton`) and drags+/// `dx` points right in six steps, with `modifiers` held; returns how far the value moved from+/// where the press put it.+fn drag_on(h: &mut Harness<'_, SoundApp>, role: Role, label: &str, dx: f32, modifiers: Modifiers) -> f64 {+ hold(h, modifiers);+ let from = h.get_by_role_and_label(role, label).rect().center();+ move_to(h, from);+ press(h, from, true, modifiers);+ let start = value(h, label);+ for i in 1..=6 {+ move_to(h, from + vec2(dx * i as f32 / 6.0, 0.0));+ }+ let moved = value(h, label) - start;+ press(h, from + vec2(dx, 0.0), false, modifiers);+ assert_eq!(value(h, label) - start, moved, "{label}: releasing the button moved the value");+ hold(h, Modifiers::NONE);+ moved+}++fn drag(h: &mut Harness<'_, SoundApp>, label: &str, dx: f32, modifiers: Modifiers) -> f64 {+ drag_on(h, Role::Slider, label, dx, modifiers)+}++#[test]+fn strip_silence_sliders_move_a_tenth_as_far_with_ctrl_held() {+ let mut h = strip_silence_dialog();+ for label in SLIDERS {+ let plain = drag(&mut h, label, 30.0, Modifiers::NONE);+ let fine = drag(&mut h, label, 30.0, Modifiers::COMMAND);+ assert!(plain > 0.0, "{label}: a plain drag did not move the value");+ let ratio = fine / plain;+ assert!((0.08..=0.12).contains(&ratio), "{label}: Ctrl-drag moved {fine}, a plain drag {plain}");+ }+}++#[test]+fn ctrl_dragging_the_value_box_moves_a_tenth_as_far_too() {+ let mut h = strip_silence_dialog();+ for label in ["Threshold dB", "Clip start pad ms"] {+ let plain = drag_on(&mut h, Role::SpinButton, label, 20.0, Modifiers::NONE);+ let fine = drag_on(&mut h, Role::SpinButton, label, 20.0, Modifiers::COMMAND);+ assert!(plain > 0.0, "{label}: a plain drag on the value box did not move the value");+ let ratio = fine / plain;+ assert!((0.07..=0.13).contains(&ratio), "{label}: Ctrl-drag on the value box moved {fine}, a plain drag {plain}");+ }+}++#[test]+fn the_value_box_shows_the_tenths_a_ctrl_drag_reaches() {+ let mut h = strip_silence_dialog();+ drag(&mut h, "Threshold dB", 7.0, Modifiers::COMMAND);+ let v = value(&h, "Threshold dB");+ let shown = h.get_by_role_and_label(Role::SpinButton, "Threshold dB").value().unwrap_or_default();+ assert_eq!(shown, format!("{v:.1}"));+}++#[test]+fn pressing_and_releasing_ctrl_during_a_drag_keeps_the_value() {+ let mut h = strip_silence_dialog();+ let label = "Threshold dB";+ let plain = drag(&mut h, label, 30.0, Modifiers::NONE);+ let from = h.get_by_role_and_label(Role::Slider, label).rect().center();+ move_to(&mut h, from);+ press(&mut h, from, true, Modifiers::NONE);+ move_to(&mut h, from + vec2(10.0, 0.0));+ let before = value(&h, label);+ hold(&mut h, Modifiers::COMMAND);+ assert_eq!(value(&h, label), before, "pressing Ctrl moved the value");+ for i in 1..=6 {+ move_to(&mut h, from + vec2(10.0 + 5.0 * i as f32, 0.0));+ }+ let fine = value(&h, label) - before;+ assert!((0.08..=0.12).contains(&(fine / plain)), "Ctrl-drag moved {fine}, a plain drag {plain}");+ let held = value(&h, label);+ hold(&mut h, Modifiers::NONE);+ assert_eq!(value(&h, label), held, "releasing Ctrl moved the value back to the pointer");+ press(&mut h, from + vec2(40.0, 0.0), false, Modifiers::NONE);+ assert_eq!(value(&h, label), held, "releasing the button moved the value back to the pointer");+}@@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Tracks and Groups and Clips in dock columns) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Tracks and Groups and Clips in dock columns; fine adjustment of sends) · **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@@ -50,7 +50,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | I/O Setup | 🟡 | Busses and paths; no hardware channel matrix (G-01) | | Preferences | 🟡 | Many settings stored, fewer honoured | | Window configurations, Tile/Cascade | 🟡 | Configurations ✅; Tile/Cascade in PR #151 |-| Plugin windows | 🟡 | Generic parameter sliders; Pro Tools plugins have bespoke GUIs (#138 asks for skeuomorphic design); multi-page plugin views (#6) |+| Plugin windows | 🟡 | Generic parameter sliders; Pro Tools plugins have bespoke GUIs (#138 asks for skeuomorphic design); multi-page plugin views (#6); Alt/Option-click returns a parameter to its default | | A plugin opens when it's inserted | ✅ | #486; a hosted plugin's own editor, else its plugin window | | Command palette (Search) | ✅ | Over every command | @@ -62,7 +62,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Interface scaling | 🟡 | Slider in PR #147 | | Meter types and ballistics (peak, RMS, VU, K-scales, gain reduction) | 🟡 | Peak/RMS; meter type choice and GR meters partial | | Clip indicators, track colours, waveform views (peak/power/rectified/outlines) | ✅ | |-| Fader resolution, fine adjust, numeric entry | 🟡 | |+| Fader resolution, fine adjust, numeric entry | 🟡 | Ctrl/Cmd-drag moves the faders, the Strip Silence sliders, plugin parameter sliders, a send's Level and Pan sliders and the pan knobs a tenth as far, and a send's level as finely as Shift-drag | | macOS system menu bar | ✅ | #10 | ## Revision history@@ -70,6 +70,10 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---| | 2026-10-25 | minor | Edit and Mix windows: Tracks and Groups and Clips in full-height dock columns. Readiness estimate unchanged |+| 2026-10-25 | minor | Sends and pan knobs: Ctrl/Cmd-drag on a send's level, its options menu's Level and Pan sliders and the pan knobs gives fine adjustment. Readiness estimate unchanged |+| 2026-10-24 | minor | Plugin parameters: Alt/Option-click returns one to its default. Readiness estimate unchanged |+| 2026-10-24 | minor | Plugin parameter sliders: Ctrl/Cmd-drag moves them a tenth as far. Readiness estimate unchanged |+| 2026-10-24 | minor | Strip Silence sliders: Ctrl/Cmd-drag moves them a tenth as far. 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) |