Browse / github.com/storytold/soundcraft / patch
Plugins: parameter sliders move a tenth as far while Ctrl or Command is held
Plugins: parameter sliders move a tenth as far while Ctrl or Command is held Builds on #1056; until it merges, this diff also shows its commits. On `main`, a drag on a plugin parameter slider always moves the value as far as the pointer. The reference guide (Pro Tools Reference Guide 2020.12, Chapter 51, "Editing Plug-In Parameters Using a Mouse", page 1126) says: "For finer adjustments, Command-drag (Mac) or Control-drag (Windows) the control." Chapter 3's shortcut table (page 26) gives "Fine adjustment of sliders, knobs, and breakpoints" as "Hold Command while clicking the item" (Mac) and "Hold Control while clicking the item" (Windows). The guide is at https://web.
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dotCooCoo (imported from pull request #1059, open)
- Base
eec559f057aed910ceec0f169e7cb54b1c1473ff- Size
- +518 −16 in 11 files
- Review
- pending
- Shared
- 2026-10-28
- Id
3e125fe746d36530e0cf3177
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/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_fine_sliders.rs crates/ui-egui/tests/it/save_changes.rs crates/ui-egui/tests/it/strip_silence_dialog.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 (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 (Auto Pan stereo balance, fine adjustment on plugin parameter sliders, Strip Silence fine adjustment, 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,6 +123,8 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log - **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:** 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.@@ -176,6 +178,8 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| | 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; 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 {@@ -814,7 +814,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { if ui.button("Don't Save").clicked() { action = Some(then.clone()); }- if buttons(ui, "Save", enter) {+ // Enter on a focused Don't Save clicks it, so Enter presses Save only when it was not clicked.+ if buttons(ui, "Save", enter) && action.is_none() { save_first = Some(then.clone()); } });@@ -822,16 +823,15 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } } });+ // Enter on a focused Cancel also presses OK, so Cancel drops the command and the save that OK asked for. if esc || ctx.memory(|m| m.data.get_temp::<bool>(egui::Id::unique("dlg_cancel")).unwrap_or(false)) { keep = false;+ action = None;+ save_first = None; app.dialogs.after_save = None; app.dialogs.pending_session = None; ctx.memory_mut(|m| m.data.remove::<bool>(egui::Id::unique("dlg_cancel"))); }- // Enter on a focused Cancel also presses OK. In Auto Rename, the dialog then closes without running the command.- if !keep && matches!(d, Dialog::AutoRename { .. }) {- action = None;- } if let Some(strip) = rename_from && keep {@@ -1090,7 +1090,9 @@ 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::fine_slider(ui, &mut wide, f64::from(p.min)..=f64::from(p.max), p.name);+ *v = wide as f32; } } else { match process.as_str() {@@ -604,14 +604,18 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { x = idx as f32; 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); 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.@@ -424,6 +424,147 @@ 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,+}++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 }+ }++ /// 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 } = 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)));+ // 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 {+ *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 = 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)+ .text(text);+ if integer {+ slider = slider.integer();+ }+ if fine {+ slider = slider.drag_value_speed(speed);+ }+ let resp = ui.add(slider);+ 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,@@ -726,7 +726,9 @@ 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::fine_slider(col, &mut wide, f64::from(p.min)..=f64::from(p.max), p.name);+ v = wide as f32; if (v - before).abs() > f32::EPSILON { let mut m = serde_json::Map::new(); m.insert(key.clone(), json!(v));@@ -33,6 +33,7 @@ mod mix_separate; mod new_midi_clip; mod new_tracks; mod open_on_insert;+mod plugin_fine_sliders; mod plugin_window; mod pro_tools_keys; mod record_hint;@@ -50,6 +51,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,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);+ }+}@@ -96,6 +96,37 @@ fn save_in_the_question_saves_before_closing() { let _ = std::fs::remove_dir_all(&dir); } +/// Focuses the button and presses Enter, which clicks it.+fn enter_on(h: &mut Harness<'_, SoundApp>, label: &str) {+ h.get_by_label(label).focus();+ steps(h);+ h.event(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE });+ steps(h);+}++#[test]+fn enter_on_a_focused_cancel_neither_saves_nor_closes() {+ let mut h = harness();+ let tracks = h.state().engine.session().tracks.len();+ mute_kick(&mut h);+ close_session(&mut h);+ assert_eq!(h.state().dialogs.open_name(), Some("save_changes"));+ enter_on(&mut h, "Cancel");+ assert!(h.state().dialogs.open.is_none(), "no Save As dialog follows");+ assert_eq!(h.state().engine.session().tracks.len(), tracks);+ assert!(h.state().engine.is_dirty());+}++#[test]+fn enter_on_a_focused_dont_save_closes_without_saving() {+ let mut h = harness();+ mute_kick(&mut h);+ close_session(&mut h);+ enter_on(&mut h, "Don't Save");+ assert!(h.state().dialogs.open.is_none(), "no Save As dialog follows");+ assert!(h.state().engine.session().tracks.is_empty());+}+ /// A session the browser read: an empty one, so opening it is easy to see. fn session_bytes() -> Vec<u8> { soundcraft_engine::Engine::default().session().to_json().unwrap().into_bytes()@@ -79,6 +79,17 @@ fn enter_on_a_focused_separate_separates_and_does_not_strip() { assert_eq!(covered(&separated), covered(&before), "Enter on Separate keeps all the audio"); } +#[test]+fn enter_on_a_focused_cancel_closes_the_dialog_and_strips_nothing() {+ let (mut h, track, before) = strip_silence_dialog();+ h.get_by_role_and_label(Role::Button, "Cancel").focus();+ h.run_steps(2);+ h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });+ h.run_steps(2);+ assert_eq!(h.state().dialogs.open_name(), None);+ assert_eq!(clips(&h, track), before, "Cancel leaves the clips as they were");+}+ #[test] fn strip_silence_strip_removes_the_silence() { let (mut h, track, before) = strip_silence_dialog();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-16 · **Last updated:** 2026-10-16 · **Change:** minor (Scroll Into View in the track name menu) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-24 · **Change:** minor (fine adjustment on plugin parameter sliders) · **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@@ -62,13 +62,15 @@ 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 and plugin parameter sliders a tenth as far | | macOS system menu bar | ✅ | #10 | ## Revision history | Date | Change | Summary | |---|---|---|+| 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) |