Browse / github.com/storytold/soundcraft / patch
Plugins: Alt-click returns a parameter to its default value
Plugins: Alt-click returns a parameter to its default value Builds on #1059, which builds on #1056; until they merge, this diff also shows their commits. The branch includes `main` at `60087e0f46032118bf309ed9ea161a9b4a872713`. On `main`, an Alt-click on a plugin parameter does what a plain click does: on a slider it moves the value to the pointer, and on a choice list it opens the list. The reference guide (Pro Tools Reference Guide 2020.12, Chapter 51, "Editing Plug-In Parameters Using a Mouse", page 1126) says: "To return a control to its default value, Option-click (Mac) or Alt-click (Windows) the control." The guide is at https://web.archive.org/
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dotCooCoo (imported from pull request #1081, open)
- Base
1e6310f85ba4e289d9078b52202948c9d398ff44- Size
- +788 −17 in 12 files
- Review
- pending
- Shared
- 2026-10-28
- Id
656a0087c713e73bdd53579a
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/dialogs/audiosuite.rs crates/ui-egui/src/mix_window/sends.rs crates/ui-egui/src/panels/plugins.rs crates/ui-egui/src/panels/windows.rs crates/ui-egui/src/widgets.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/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-27 · **Change:** trivial (side panels in dock columns, 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@@ -124,6 +124,9 @@ mutated and truncated movies. Field reports from the first public week are track - **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:** 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.@@ -178,6 +181,9 @@ mutated and truncated movies. Field reports from the first public week are track |---|---|---| | 2026-10-25 | trivial | Progress log: side panels in dock columns; 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 |@@ -535,10 +535,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 {@@ -18,21 +18,32 @@ pub(super) 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() { "normalize" => { let v = params.entry("target_db".into()).or_insert(-0.1);- ui.add(egui::Slider::new(v, -24.0..=0.0).text("Peak dB"));+ let mut wide = f64::from(*v);+ crate::widgets::FineSlider::new(&mut wide, -24.0..=0.0).text("Peak dB").default_value(-0.1).show(ui);+ *v = wide as f32; } "time_stretch" => { let v = params.entry("ratio".into()).or_insert(1.0);- ui.add(egui::Slider::new(v, 0.25..=4.0).text("Length ratio"));+ let mut wide = f64::from(*v);+ crate::widgets::FineSlider::new(&mut wide, 0.25..=4.0).text("Length ratio").default_value(1.0).show(ui);+ *v = wide as f32; } "pitch_shift" => { let v = params.entry("semitones".into()).or_insert(0.0);- ui.add(egui::Slider::new(v, -24.0..=24.0).text("Semitones"));+ let mut wide = f64::from(*v);+ crate::widgets::FineSlider::new(&mut wide, -24.0..=24.0).text("Semitones").default_value(0.0).show(ui);+ *v = wide as f32; } "speed_ramp" => { for (id, label, default, choices) in SPEED_RAMP_CHOICES {@@ -330,7 +330,6 @@ enum SendFooter { /// that scrolls, "new bus..." (and "Remove send" for an existing send) fixed under it. /// `current` is the send's destination and its name, if the slot has a send. fn send_route_menu(app: &mut SoundApp, ui: &Ui, field: &egui::Response, id: TrackId, slot: usize, current: Option<(Route, String)>) {- let busses = app.engine.session().busses.clone(); let which = (b'A' + slot as u8) as char; let has_send = current.is_some(); let header = |ui: &mut Ui| {@@ -350,6 +349,7 @@ fn send_route_menu(app: &mut SoundApp, ui: &Ui, field: &egui::Response, id: Trac } } let search = MenuSearch::show(ui, egui::Id::unique(("send-bus-search", id.0, slot)), "Search buses…");+ let busses = &app.engine.session().busses; let visible: Vec<_> = busses.iter().filter(|bus| search.matches(&bus.name)).collect(); (search, visible) };@@ -388,6 +388,7 @@ fn send_route_menu(app: &mut SoundApp, ui: &Ui, field: &egui::Response, id: Trac } match footer { Some(SendFooter::NewBus) => {+ let busses = &app.engine.session().busses; let mut n = busses.len() + 1; while busses.iter().any(|bus| bus.name.eq_ignore_ascii_case(&format!("Bus {n}"))) || app.engine.session().track_by_name(&format!("Bus {n}")).is_some()@@ -78,16 +78,26 @@ pub(super) fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { } }); x = idx as f32;+ 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.@@ -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));@@ -749,7 +754,9 @@ pub fn clip_effects(app: &mut SoundApp, ctx: &egui::Context) { }); let mut g = get(app, "gain", 0.0); let before = g;- ui.add(egui::Slider::new(&mut g, -36.0..=24.0).text("Clip effects gain (dB)"));+ let mut wide = f64::from(g);+ crate::widgets::FineSlider::new(&mut wide, -36.0..=24.0).text("Clip effects gain (dB)").default_value(0.0).show(ui);+ g = wide as f32; if (g - before).abs() > f32::EPSILON { let _ = app.engine.execute_merged("clip.effects_set", &json!({"clips": [cid.0], "params": {"gain": g}}), &format!("cfx:{}:gain", cid.0));@@ -424,6 +424,169 @@ 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>,+}++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 }+ }++ /// 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 } = 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)+ .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,@@ -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;@@ -51,6 +53,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,234 @@+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 fallback_audiosuite_sliders_reset_with_alt_click() {+ for (process, label, id, default) in [+ ("normalize", "Peak dB", "target_db", -0.1f32),+ ("time_stretch", "Length ratio", "ratio", 1.0),+ ("pitch_shift", "Semitones", "semitones", 0.0),+ ] {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ app.ui.audiosuite = Some(process.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", process.to_string())))).unwrap_or_default()+ };+ let rail = h.get_by_role_and_label(Role::Slider, label).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)[id], default, "{process}: plain click did not move the value");+ alt_press(&mut h, near_left(rail), 0.0);+ assert_eq!(read(&h)[id], default, "{process}: Alt-click did not restore the default");+ }+}++#[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");+}++#[test]+fn clip_effects_gain_resets_with_alt_click_and_undo_restores_the_value() {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ let clip = app.engine.session().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": {"gain": 6.0}})).unwrap();+ app.engine.end_merge();+ app.ui.show_clip_effects = true;+ let mut h = harness(app);+ let key = format!("clip_fx.{}.gain", clip.0);+ let input = h.get_by_role_and_label(Role::Slider, "Input").rect();+ move_to(&mut h, input.center());+ h.event(Event::MouseWheel {+ unit: egui::MouseWheelUnit::Point,+ delta: vec2(0.0, -2000.0),+ modifiers: Modifiers::NONE,+ phase: egui::TouchPhase::Move,+ source: egui::MouseWheelSource::Unknown,+ });+ h.run_steps(30);+ let rail = h.get_by_role_and_label(Role::Slider, "Clip effects gain (dB)").rect();+ let steps = undo_steps(&h);+ alt_press(&mut h, near_left(rail), 0.0);+ assert_eq!(h.state().engine.session().edit.values.get(&key), Some(&0.0));+ assert_eq!(undo_steps(&h), steps + 1);+ h.state_mut().engine.execute("edit.undo", &json!({})).unwrap();+ assert_eq!(h.state().engine.session().edit.values.get(&key), Some(&6.0));+}new file mode 100644@@ -0,0 +1,203 @@+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), ("Clip effects gain (dB)", "gain", 0.0)] {+ if key == "gain" {+ let input = h.get_by_role_and_label(Role::Slider, "Input").rect();+ move_to(&mut h, input.center());+ h.event(Event::MouseWheel {+ unit: egui::MouseWheelUnit::Point,+ delta: vec2(0.0, -2000.0),+ modifiers: Modifiers::NONE,+ phase: egui::TouchPhase::Move,+ source: egui::MouseWheelSource::Unknown,+ });+ h.run_steps(30);+ }+ 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 fallback_audiosuite_sliders_move_a_tenth_as_far_with_ctrl_held() {+ for (process, label, id) in+ [("normalize", "Peak dB", "target_db"), ("time_stretch", "Length ratio", "ratio"), ("pitch_shift", "Semitones", "semitones")]+ {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ app.ui.audiosuite = Some(process.into());+ let mut h = harness(app);+ let read = |h: &Harness<'_, SoundApp>| {+ let params: BTreeMap<String, f32> = h.ctx.data(|d| d.get_temp(Id::unique(("as_params", process.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);+ }+}++#[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,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-27 · **Change:** minor (Tracks and Groups and Clips in dock columns; Alt-click returns a plugin parameter to its default) · **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,14 +62,18 @@ 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-27 | minor | Clip Effects gain supports Alt/Option-click reset to 0 dB and Ctrl/Cmd fine adjustment. Readiness estimate unchanged | | 2026-10-25 | minor | Edit and Mix windows: Tracks and Groups and Clips in full-height dock columns. 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) |