Browse / github.com/storytold/soundcraft / patch
Parity: fine adjustment on Strip Silence, plugin, send and pan controls, Alt-click parameter reset, Unloop dialog, Peak Hold and Clip Indication, Cancel Zoom Toggle
Parity: fine adjustment on Strip Silence, plugin, send and pan controls, Alt-click parameter reset, Unloop dialog, Peak Hold and Clip Indication, Cancel Zoom Toggle This branch brings in the pull requests below so they can land together; each one stays open for its own review. - #1056 Strip Silence: the sliders move a tenth as far while Ctrl or Command is held: a drag on the rail or value box of Threshold, Min Strip Duration, Clip Start Pad or Clip End Pad moves the value a tenth as far while Ctrl (Command on a Mac) is held, pressing or releasing the key mid-drag keeps the value, and Threshold and Clip Start Pad show one decimal place. Evidence: https://web.archive.org/web/20210805202452/https://resources.avid.com/SupportFiles/PT/Pro_Tools_Reference_Guid
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dotCooCoo (imported from pull request #1107, open)
- Base
1e6310f85ba4e289d9078b52202948c9d398ff44- Size
- +1609 −50 in 31 files
- Review
- pending
- Shared
- 2026-10-28
- Id
29e72694e5eaf3e7c8b4a6b7
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/engine/src/cmd.rs crates/engine/src/cmd/clip_more.rs crates/engine/src/cmd/zoom_toggle.rs crates/ui-egui/src/app.rs crates/ui-egui/src/app/audio.rs crates/ui-egui/src/app/commands.rs crates/ui-egui/src/app/playback.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/dialogs/audiosuite.rs crates/ui-egui/src/edit_window/layout.rs crates/ui-egui/src/edit_window/scroll.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.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/tests.rs crates/ui-egui/src/widgets.rs crates/ui-egui/tests/it/focused_dialog_buttons.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/save_changes.rs crates/ui-egui/tests/it/send_fine_adjustment.rs crates/ui-egui/tests/it/strip_silence_dialog.rs crates/ui-egui/tests/it/strip_silence_sliders.rs crates/ui-egui/tests/it/unloop_dialog.rs crates/ui-egui/tests/it/zoom_toggle.rs docs/control-protocol.md 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, Cancel Zoom Toggle, Auto Pan stereo balance, fine adjustment of sends, Alt-click returns a plugin parameter to its default, fine adjustment on plugin parameter sliders, Strip Silence fine adjustment, Unloop dialog, Peak Hold and Clip Indication options, 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,14 @@ 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:** Alt+Shift+E (`view.zoom_toggle_cancel`) and an Alt-click on the Zoom Toggle button cancel Zoom Toggle and keep the current view: the zoom, the scroll and the track heights stay as they are, and the button is no longer lit. The next Zoom Toggle zooms in from that view and goes back to it. 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-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-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 › Unloop… opens the Unloop dialog. Remove deletes the loop iterations and keeps the source clip, and Flatten keeps each loop iteration as its own clip. No readiness percentage change.+- **2026-10-24:** Setup › Metering… sets Peak Hold and Clip Indication to 3 Seconds, Infinite or None, as `ui.peak_hold` and `ui.clip_indication`, kept with the preferences. Meters held a peak for 2 s and kept a clip lit until cleared; the defaults are now 3 Seconds and Infinite. Track › Clear All Clip Indicators also clears held peaks. 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 +184,14 @@ 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: Cancel Zoom Toggle; readiness estimates unchanged | | 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged |+| 2026-10-25 | trivial | Progress log: fine adjustment of sends; 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: Unloop dialog; readiness estimates unchanged |+| 2026-10-24 | trivial | Progress log: Peak Hold and Clip Indication options; 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 |@@ -60,6 +60,7 @@ mod panic_probe { } } +pub use clip_more::is_looped; pub use file::bounce_job; pub(crate) use edit::remember_selection;@@ -150,6 +150,11 @@ fn loop_chain(s: &Session, t: TrackId, id: ClipId) -> Vec<ClipId> { chain } +/// Whether a clip belongs to a loop that `clip.unloop` changes: two or more back-to-back copies.+pub fn is_looped(s: &Session, id: ClipId) -> bool {+ s.find_clip(id).is_some_and(|(t, _)| loop_chain(s, t, id).len() >= 2)+}+ fn unloop(e: &mut Engine, p: &Value) -> Result<Value> { let id = "clip.unloop"; let remove = match str_param(p, "mode") {@@ -1,4 +1,5 @@-//! Zoom Toggle: zoom in on the edit selection, then back out to the view before.+//! Zoom Toggle: zoom in on the edit selection, then back out to the view before. Cancel Zoom Toggle turns it off+//! and keeps the current view. use super::{CommandSpec, always, f64_or}; use soundcraft_model::{TrackHeight, ZoomToggle}; use soundcraft_time::MAX_POSITION;@@ -7,7 +8,16 @@ use crate::{Engine, Result, cmd}; use serde_json::{Value, json}; pub fn specs() -> Vec<CommandSpec> {- vec![cmd!(noundo "view.zoom_toggle", "Zoom Toggle", [], None, "{width_px?: 1200, height_px?: 600}", always, toggle)]+ vec![+ cmd!(noundo "view.zoom_toggle", "Zoom Toggle", [], None, "{width_px?: 1200, height_px?: 600}", always, toggle),+ cmd!(noundo "view.zoom_toggle_cancel", "Cancel Zoom Toggle", [], Some("Alt+Shift+E"), "{}", always, cancel),+ ]+}++/// Forget the view Zoom Toggle would go back to, leaving the zoom, scroll and track heights as they are.+fn cancel(e: &mut Engine, _: &Value) -> Result<Value> {+ let was_on = e.session_mut().edit.zoom_toggle.take().is_some();+ Ok(json!({"on": false, "canceled": was_on})) } fn toggle(e: &mut Engine, p: &Value) -> Result<Value> {@@ -73,6 +83,24 @@ mod tests { assert_eq!(view(&e), (500.0, 1000, vec![Medium, Fit(299), Medium])); } + #[test]+ fn cancel_keeps_the_zoomed_view_and_the_next_toggle_zooms_in_again() {+ let mut e = engine(json!({"tracks": ["Audio 1", "Audio 3"], "start": 48_000, "end": 96_000}));+ e.execute("view.zoom_toggle", &json!({"width_px": 1000.0, "height_px": 500.0})).unwrap();+ let zoomed = view(&e);+ assert_eq!(e.execute("view.zoom_toggle_cancel", &json!({})).unwrap(), json!({"on": false, "canceled": true}));+ assert_eq!(e.session().edit.zoom_toggle, None);+ assert_eq!(view(&e), zoomed, "cancel keeps the view Zoom Toggle zoomed in to");+ assert_eq!(e.execute("view.zoom_toggle_cancel", &json!({})).unwrap(), json!({"on": false, "canceled": false}));+ assert_eq!(view(&e), zoomed);+ e.execute("view.zoom_set", &json!({"samples_per_px": 200.0})).unwrap();+ assert_eq!(e.execute("view.zoom_toggle", &json!({"width_px": 1000.0, "height_px": 500.0})).unwrap(), json!({"on": true}));+ e.execute("view.zoom_toggle", &json!({})).unwrap();+ assert_eq!(view(&e), (200.0, 48_000, vec![Fit(249), Medium, Fit(249)]), "the toggle goes back to the view it saw after the cancel");+ let spec = crate::cmd::command_specs().iter().find(|c| c.id == "view.zoom_toggle_cancel").unwrap();+ assert_eq!(spec.shortcut, Some("Alt+Shift+E"));+ }+ #[test] fn hostile_sizes_and_stored_views_stay_in_range() { let mut e = engine(json!({"tracks": ["Audio 1"], "start": 0, "end": MAX_POSITION}));@@ -10,6 +10,8 @@ mod input; mod playback; mod plugins; +#[cfg(test)]+pub(crate) use audio::feed_meter; #[cfg(test)] pub(crate) use playback::automation_step_due; #[cfg(test)]@@ -125,9 +125,10 @@ impl SoundApp { } } -pub(super) fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) {- // Instant attack, ~26 dB/s release, 2 s peak hold.+pub(crate) fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32, now: f64, holds: (MeterHold, MeterHold)) {+ let (peak_hold, clip_indication) = holds; let fall = 10f32.powf(-26.0 * dt / 20.0);+ let mut clipped = false; for c in 0..2 { let v = peak.get(c).copied().unwrap_or(0.0); let lvl = d.level.get(c).copied().unwrap_or(0.0);@@ -135,19 +136,35 @@ pub(super) fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) if let Some(x) = d.level.get_mut(c) { *x = if nv < 1e-5 { 0.0 } else { nv }; }- if v >= d.hold.get(c).copied().unwrap_or(0.0) {+ if v > 0.0 && v >= d.hold.get(c).copied().unwrap_or(0.0) { if let Some(h) = d.hold.get_mut(c) { *h = v; }- d.hold_age = 0.0;+ if let Some(t) = d.hold_at.get_mut(c) {+ *t = now;+ } } if v >= 1.0 {- d.clip = true;+ clipped = true;+ }+ let hold_over = match peak_hold {+ MeterHold::ThreeSeconds => now - d.hold_at.get(c).copied().unwrap_or(now) > METER_HOLD_SECS,+ MeterHold::Infinite => false,+ MeterHold::Off => true,+ };+ if hold_over && let (Some(h), Some(l)) = (d.hold.get_mut(c), d.level.get(c)) {+ *h = *l; } }- d.hold_age += dt;- if d.hold_age > 2.0 {- d.hold = d.level;+ if clipped {+ d.clip = true;+ d.clip_at = now;+ } else {+ d.clip &= match clip_indication {+ MeterHold::ThreeSeconds => now - d.clip_at <= METER_HOLD_SECS,+ MeterHold::Infinite => true,+ MeterHold::Off => false,+ }; } d.gr = if gr > d.gr { gr } else { d.gr * fall }; }@@ -31,7 +31,13 @@ impl SoundApp { self.edit_layout.follow_hold = false; } if id == "track.clear_clip_indicators" {- self.meters.values_mut().chain([&mut self.main_meter]).for_each(|m| m.clip = false);+ self.meters.values_mut().chain([&mut self.main_meter]).for_each(|m| {+ m.clip = false;+ m.hold = m.level;+ });+ }+ if id == "view.zoom_toggle_cancel" {+ self.edit_layout.forget_zoom_back(); } Ok(v) }@@ -364,9 +364,10 @@ impl SoundApp { self.last_meter_poll = now; } let snap = &self.meter_snapshot;+ let holds = (self.ui.peak_hold, self.ui.clip_indication); for (id, m) in &snap.tracks { let d = self.meters.entry(*id).or_default();- feed_meter(d, m.peak, m.gain_reduction_db, dt);+ feed_meter(d, m.peak, m.gain_reduction_db, dt, now, holds); if m.plugin_failures.count != d.plugin_failures { d.plugin_failures = m.plugin_failures.count; if let Some(msg) = failed_plugin_status(self.engine.session(), *id, m.plugin_failures.last) {@@ -375,7 +376,7 @@ impl SoundApp { } } let main = snap.main.peak;- feed_meter(&mut self.main_meter, main, 0.0, dt);+ feed_meter(&mut self.main_meter, main, 0.0, dt, now, holds); if ended { self.stop_play(); }@@ -394,10 +395,12 @@ impl SoundApp { } } if !self.meters_live() {+ let holds = (self.ui.peak_hold, self.ui.clip_indication);+ let now = self.uptime; for d in self.meters.values_mut() {- feed_meter(d, [0.0, 0.0], 0.0, dt);+ feed_meter(d, [0.0, 0.0], 0.0, dt, now, holds); }- feed_meter(&mut self.main_meter, [0.0, 0.0], 0.0, dt);+ feed_meter(&mut self.main_meter, [0.0, 0.0], 0.0, dt, now, holds); } } }@@ -36,6 +36,8 @@ pub enum Dialog { RenameClip { id: ClipId, name: String }, RenameMarker { id: soundcraft_model::MarkerId, name: String }, ClearClips { clips: Vec<ClipId>, sources: Vec<SourceId>, message: String },+ // Clip › Unloop…: the clips `clip.unloop` would take when it opened.+ Unloop { clips: Vec<ClipId> }, PathPrompt { cmd: String, title: String, path: String, key: String }, Number { cmd: String, title: String, key: String, value: f64, suffix: String }, TempoChange { at: Samples, bpm: f64 }, // from double-clicking the tempo ruler@@ -90,6 +92,7 @@ impl Dialogs { Dialog::RenameClip { .. } => "rename_clip", Dialog::RenameMarker { .. } => "rename_marker", Dialog::ClearClips { .. } => "clear_clips",+ Dialog::Unloop { .. } => "unloop", Dialog::PathPrompt { .. } => "path", Dialog::Number { .. } => "number", Dialog::TempoChange { .. } => "tempo_change",@@ -165,6 +168,13 @@ impl Dialogs { let ed = &e.session().edit; return self.open_clear(e, ed.selected_clips.clone(), ed.selected_sources.clone()); }+ "clip.unloop" => {+ let clips = soundcraft_engine::cmd::clip_ids_param(e, &json!({}));+ if !clips.iter().any(|&c| soundcraft_engine::cmd::is_looped(e.session(), c)) {+ return false;+ }+ Dialog::Unloop { clips }+ } "track.new" => { Dialog::NewTracks { count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() } }@@ -302,6 +312,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::RenameClip { .. } => "Rename Clip", Dialog::RenameMarker { .. } => "Rename Marker", Dialog::ClearClips { .. } => "Clear",+ Dialog::Unloop { .. } => "Unloop", Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(), Dialog::TempoChange { .. } => "Tempo Change (BPM)", Dialog::Spot(_) => "Spot",@@ -317,7 +328,9 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::SaveChanges { .. } => "Unsaved Changes", } .to_string();- let enter = ctx.input(|i| i.key_pressed(egui::Key::Enter));+ // egui treats Enter on a focused button, checkbox, radio button or combo box as a click, so Enter presses the default+ // button only when no widget has the keyboard focus or a text field has it.+ let enter = ctx.input(|i| i.key_pressed(egui::Key::Enter)) && (ctx.memory(|m| m.focused().is_none()) || ctx.text_edit_focused()); let esc = ctx.input(|i| i.key_pressed(egui::Key::Escape)); egui::Window::new(egui::RichText::new(&title).font(bold(13.0))) .collapsible(false)@@ -459,6 +472,19 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { action = Some(("clip.clear".into(), json!({"clips": clips, "sources": sources, "with_clips": true}))); } }+ Dialog::Unloop { clips } => {+ ui.set_max_width(340.0);+ ui.label("Remove deletes the loop iterations and keeps the source clip. Flatten keeps each loop iteration as its own clip.");+ ui.separator();+ ui.horizontal(|ui| {+ if ui.button("Flatten").clicked() {+ action = Some(unloop_action(clips, false));+ }+ if buttons(ui, "Remove", enter) {+ action = Some(unloop_action(clips, true));+ }+ });+ } Dialog::PathPrompt { cmd, path, key, .. } => { crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(path).desired_width(420.0)), "Path"); if let Some(pick) = &app.services.pick_open@@ -535,10 +561,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 {@@ -693,7 +719,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()); } });@@ -701,16 +728,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 {@@ -815,6 +841,11 @@ fn name_field(ui: &mut egui::Ui, name: &mut String) { } } +/// The command the Unloop dialog runs: Remove (`remove`) or Flatten.+fn unloop_action(clips: &[ClipId], remove: bool) -> (String, Value) {+ ("clip.unloop".into(), json!({"clips": clips, "mode": if remove { "remove" } else { "flatten" }}))+}+ /// The command the tempo-change dialog runs on OK. fn tempo_change_action(at: Samples, bpm: f64) -> (String, Value) { ("event.tempo".into(), json!({"bpm": bpm, "at": at}))@@ -883,6 +914,37 @@ mod tests { assert!(matches!(d.open, Some(Dialog::ScoreSetup { bars_per_system: 4, .. }))); } + #[test]+ fn clip_unloop_opens_unloop_and_sends_the_chosen_mode() {+ let mut e = soundcraft_engine::demo::demo_engine();+ let mut d = Dialogs::default();+ e.execute("edit.select_none", &json!({})).unwrap();+ assert!(!d.open_for_command(&e, "clip.unloop"));+ let track = e.session().track_by_name("Snare").unwrap().id;+ let clip = e.session().track(track).unwrap().clips()[0].id;+ e.execute("edit.select", &json!({"clips": [clip.0]})).unwrap();+ assert!(!d.open_for_command(&e, "clip.unloop"), "a clip that is not looped opens no dialog");+ assert!(d.open.is_none());+ e.execute("clip.loop", &json!({"clip": clip.0, "count": 3})).unwrap();+ e.execute("edit.select", &json!({"clips": [clip.0]})).unwrap();+ assert!(d.open_for_command(&e, "clip.unloop"));+ assert_eq!(d.open_name(), Some("unloop"));+ let Some(Dialog::Unloop { clips }) = d.open.take() else { panic!("Unloop… did not open the Unloop dialog") };+ assert_eq!(clips, vec![clip]);+ let count = |e: &Engine| e.session().track(track).map_or(0, |t| t.clips().len());+ let before = count(&e);+ let (cmd, params) = unloop_action(&clips, false);+ assert_eq!(params["mode"], "flatten");+ e.execute(&cmd, ¶ms).unwrap();+ assert_eq!(count(&e), before, "Flatten keeps every iteration");+ assert!(e.session().track(track).unwrap().clips().iter().all(|c| c.loop_id.is_none()));+ e.execute("edit.undo", &json!({})).unwrap();+ let (cmd, params) = unloop_action(&clips, true);+ assert_eq!(params["mode"], "remove");+ e.execute(&cmd, ¶ms).unwrap();+ assert_eq!(count(&e), before - 2, "Remove keeps only the source clip");+ }+ #[test] fn clip_rename_opens_the_rename_dialog_for_the_selected_clip() { let mut e = soundcraft_engine::demo::demo_engine();@@ -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 {@@ -70,6 +70,10 @@ impl EditLayout { pub fn scroll_by(&mut self, dy: f32, view_h: f32) { self.scroll_y = (self.scroll_y + dy).clamp(0.0, (self.content_h - view_h + 40.0).max(0.0)); }++ pub(crate) fn forget_zoom_back(&mut self) {+ self.zoom_back = None;+ } } pub fn rect([x0, y0, x1, y1]: [f32; 4]) -> Rect {@@ -77,6 +77,10 @@ pub(super) fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) { /// Zoom Toggle sized to the track area on screen. pub fn zoom_toggle(app: &mut SoundApp) {+ if app.docking_context.as_ref().is_some_and(|c| c.input(|i| i.modifiers.alt)) {+ let _ = app.run("view.zoom_toggle_cancel", json!({}));+ return;+ } let [x0, y0, x1, y1] = app.edit_layout.timeline; let _ = app.run("view.zoom_toggle", json!({"width_px": x1 - x0, "height_px": y1 - y0})); }@@ -52,7 +52,7 @@ mod services; mod state; #[cfg(test)]-use app::{automation_step_due, editor_changes, editor_states_due, failed_plugin_status};+use app::{automation_step_due, editor_changes, editor_states_due, failed_plugin_status, feed_meter}; pub use services::Services; pub use state::{DragSource, MainWindow}; @@ -87,6 +87,12 @@ pub struct UiState { pub edit_views: Vec<String>, /// How strongly Edit window tracks take on their colour, 0..1 (0: only the colour strip). pub track_tint: f32,+ /// How long meters show their last peak (`ui.peak_hold`) and their last clip+ /// (`ui.clip_indication`).+ pub peak_hold: MeterHold,+ pub clip_indication: MeterHold,+ /// The Metering window, which sets both (`window.metering`).+ pub show_metering: bool, /// Open plugin windows (track, slot). pub plugin_windows: Vec<(TrackId, usize)>, /// The send in the Send window (track, slot).@@ -158,6 +164,9 @@ impl Default for UiState { mix_views: vec!["inserts_ae".into(), "sends_ae".into(), "io".into(), "color".into()], edit_views: vec!["color".into()], track_tint: 0.5,+ peak_hold: MeterHold::ThreeSeconds,+ clip_indication: MeterHold::Infinite,+ show_metering: false, plugin_windows: Vec::new(), send_window: None, audiosuite: None,@@ -212,13 +221,56 @@ pub fn fit_window_to_screen(ctx: &egui::Context) { } } +/// How long a meter shows its last peak or its last clip: three seconds, until it is cleared+/// (Track › Clear All Clip Indicators), or not at all.+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]+#[serde(rename_all = "snake_case")]+pub enum MeterHold {+ #[default]+ ThreeSeconds,+ Infinite,+ #[serde(rename = "none")]+ Off,+}++impl MeterHold {+ pub const ALL: [Self; 3] = [Self::ThreeSeconds, Self::Infinite, Self::Off];++ pub const fn label(self) -> &'static str {+ match self {+ Self::ThreeSeconds => "3 Seconds",+ Self::Infinite => "Infinite",+ Self::Off => "None",+ }+ }++ /// The id used in `ui.peak_hold {"mode": …}`, `ui.clip_indication {"mode": …}` and in the+ /// saved UI prefs.+ pub const fn id(self) -> &'static str {+ match self {+ Self::ThreeSeconds => "three_seconds",+ Self::Infinite => "infinite",+ Self::Off => "none",+ }+ }++ pub fn parse(s: &str) -> Option<Self> {+ Self::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s.trim()))+ }+}++/// Seconds a [`MeterHold::ThreeSeconds`] meter keeps its peak or clip.+const METER_HOLD_SECS: f64 = 3.0; /// Ballistic meter display state per strip. #[derive(Debug, Clone, Copy, Default)] pub struct MeterDisplay { pub level: [f32; 2], pub hold: [f32; 2],- pub hold_age: f32,+ /// Frame time (egui seconds) when each channel's `hold` was last reached.+ pub hold_at: [f64; 2], pub clip: bool,+ /// Frame time (egui seconds) when the meter last clipped.+ pub clip_at: f64, pub gr: f32, /// [`soundcraft_mix::PluginFailures::count`] as last seen. pub plugin_failures: u32,@@ -67,6 +67,9 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[ ("window.rtp", "MIDI Real-Time Properties", "", None), ("window.playback_engine", "Playback Engine", "", None), ("ui.audio_device", "Audio Device", "", None),+ ("window.metering", "Metering", "", None),+ ("ui.peak_hold", "Peak Hold", "", None),+ ("ui.clip_indication", "Clip Indication", "", None), ("window.io_setup", "I/O Setup", "", None), ("window.shortcuts", "Keyboard Shortcuts", "", None), ("view.mix_section", "Mix Window View", "", None),@@ -150,6 +153,7 @@ pub const AUDIOSUITE: &[(&str, &str)] = &[ /// Commands that open a dialog when invoked from a menu (programmatic calls never do). const DIALOG_COMMANDS: &[&str] = &[ "clip.clear",+ "clip.unloop", "track.new", "file.bounce_mix", "session.new",@@ -443,6 +447,7 @@ const MENU_WINDOWS: &[(&str, &str)] = &[ ("Setup > Hardware...", "window.playback_engine"), ("Setup > Playback Engine...", "window.playback_engine"), ("Setup > I/O...", "window.io_setup"),+ ("Setup > Metering...", "window.metering"), ("Setup > Keyboard Shortcuts...", "window.shortcuts"), ("Setup > Session", "setup.session"), ("Event > Beat Detective", "window.beat_detective"),@@ -679,6 +684,18 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result< "ui.bounce_dialog" => { return Some(Err("ui.bounce_dialog opens a dialog from the menu only; call file.bounce_mix with a path".into())); }+ "window.metering" => toggle(&mut app.ui.show_metering),+ "ui.peak_hold" | "ui.clip_indication" => {+ let setting = if id == "ui.peak_hold" { &mut app.ui.peak_hold } else { &mut app.ui.clip_indication };+ let Some(mode) = p.get("mode").and_then(Value::as_str) else {+ return Some(Ok(json!({"mode": setting.id()})));+ };+ let Some(mode) = crate::MeterHold::parse(mode) else {+ return Some(Err(format!("unknown mode `{mode}` (three_seconds, infinite or none)")));+ };+ *setting = mode;+ json!({"mode": mode.id()})+ } "ui.theme" => { let Some(mode) = p.get("mode").and_then(Value::as_str) else { return Some(Ok(json!({"mode": app.ui.theme.id()})));@@ -41,13 +41,14 @@ 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));+ 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));@@ -152,8 +153,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);@@ -171,8 +175,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| {@@ -330,7 +336,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 +355,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 +394,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.@@ -21,6 +21,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { midi_keyboard(app, ctx); workspace(app, ctx); configurations(app, ctx);+ metering(app, ctx); playback_engine(app, ctx); io_setup(app, ctx); shortcuts_window(app, ctx);@@ -422,6 +423,34 @@ pub fn recall_configuration(app: &mut SoundApp, saved: &Value) -> Result<(), Str Ok(()) } +/// Metering preferences: how long meters show their last peak (Peak Hold) and their last clip+/// (Clip Indication).+fn metering(app: &mut SoundApp, ctx: &egui::Context) {+ let mut open = app.ui.show_metering;+ let mut chosen = None;+ win(ctx, &mut open, "Metering", vec2(280.0, 140.0), |ui| {+ egui::Grid::new("metering").num_columns(2).spacing(vec2(12.0, 8.0)).show(ui, |ui| {+ for (cmd, label, value) in+ [("ui.peak_hold", "Peak Hold", app.ui.peak_hold), ("ui.clip_indication", "Clip Indication", app.ui.clip_indication)]+ {+ ui.label(label);+ ui.horizontal(|ui| {+ for mode in crate::MeterHold::ALL {+ if ui.radio(value == mode, mode.label()).clicked() {+ chosen = Some((cmd, mode));+ }+ }+ });+ ui.end_row();+ }+ });+ });+ if let Some((cmd, mode)) = chosen {+ let _ = app.run(cmd, json!({"mode": mode.id()}));+ }+ app.ui.show_metering = open;+}+ /// The audio systems as last enumerated (enumerating probes devices: done when the window opens /// and on Refresh, not every frame). fn audio_systems(ctx: &egui::Context, refresh: bool) -> Arc<Vec<AudioSystem>> {@@ -734,7 +763,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 +783,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));@@ -1,4 +1,4 @@-use super::{Services, SoundApp, automation_step_due};+use super::{MeterDisplay, MeterHold, Services, SoundApp, automation_step_due, feed_meter}; use serde_json::json; use soundcraft_model::TrackId; @@ -159,8 +159,95 @@ fn clear_all_clip_indicators_clears_every_meter() { let kick = app.engine.session().track_by_name("Kick").unwrap().id; app.meters.entry(kick).or_default().clip = true; app.main_meter.clip = true;+ app.meters.get_mut(&kick).unwrap().hold = [0.9, 0.8];+ app.main_meter.hold = [1.0, 1.0]; app.run("track.clear_clip_indicators", json!({})).unwrap(); assert!(!app.meters[&kick].clip && !app.main_meter.clip);+ assert_eq!(app.meters[&kick].hold, [0.0, 0.0]);+ assert_eq!(app.main_meter.hold, [0.0, 0.0]);+}++fn after_peak(peak: f32, secs: f32, holds: (MeterHold, MeterHold)) -> (f32, bool) {+ let mut d = MeterDisplay::default();+ feed_meter(&mut d, [peak, peak], 0.0, 0.1, 0.0, holds);+ for i in 1..=(secs * 10.0).round() as usize {+ feed_meter(&mut d, [0.0, 0.0], 0.0, 0.1, i as f64 / 10.0, holds);+ }+ (d.hold[0], d.clip)+}++#[test]+fn three_second_holds_follow_the_clock_on_slow_idle_frames() {+ // After playback stops, idle frames come about once a second, each frame's step is capped+ // at 0.25 s, and a meter is fed twice per frame.+ let holds = (MeterHold::ThreeSeconds, MeterHold::ThreeSeconds);+ let mut d = MeterDisplay::default();+ feed_meter(&mut d, [1.0, 1.0], 0.0, 0.25, 10.0, holds);+ for now in [11.0, 12.0, 12.9] {+ feed_meter(&mut d, [0.0, 0.0], 0.0, 0.25, now, holds);+ feed_meter(&mut d, [0.0, 0.0], 0.0, 0.25, now, holds);+ }+ assert!(d.clip && d.hold[0] == 1.0, "still held 2.9 s after the clip");+ feed_meter(&mut d, [0.0, 0.0], 0.0, 0.25, 13.1, holds);+ assert!(!d.clip && d.hold[0] < 1.0, "let go 3.1 s after the clip");+}++#[test]+fn peak_hold_keeps_the_peak_for_three_seconds_until_cleared_or_not_at_all() {+ use MeterHold::{Infinite, Off, ThreeSeconds};+ assert_eq!(after_peak(0.5, 2.5, (ThreeSeconds, Off)).0, 0.5, "3 Seconds still holds at 2.5 s");+ assert!(after_peak(0.5, 3.5, (ThreeSeconds, Off)).0 < 0.01, "3 Seconds lets go after 3 s");+ assert_eq!(after_peak(0.5, 60.0, (Infinite, Off)).0, 0.5, "Infinite holds until cleared");+ assert!(after_peak(0.5, 0.1, (Off, Off)).0 < 0.5, "None follows the level");+}++#[test]+fn a_silent_channel_does_not_keep_the_other_channels_peak_held() {+ let holds = (MeterHold::ThreeSeconds, MeterHold::Off);+ let mut d = MeterDisplay::default();+ feed_meter(&mut d, [0.5, 0.0], 0.0, 0.1, 0.0, holds);+ for i in 1..=25 {+ feed_meter(&mut d, [0.0, 0.0], 0.0, 0.1, f64::from(i) / 10.0, holds);+ }+ assert_eq!(d.hold[0], 0.5, "still held at 2.5 s");+ for i in 26..=35 {+ feed_meter(&mut d, [0.0, 0.0], 0.0, 0.1, f64::from(i) / 10.0, holds);+ }+ assert!(d.hold[0] < 0.01, "let go after 3 s although the right channel stayed silent");+ let mut d = MeterDisplay::default();+ feed_meter(&mut d, [0.5, 0.3], 0.0, 0.1, 0.0, holds);+ for i in 1..=35 {+ feed_meter(&mut d, [0.0, 0.3], 0.0, 0.1, f64::from(i) / 10.0, holds);+ }+ assert!(d.hold[0] < 0.01, "a steady right channel does not keep the left peak held");+ assert_eq!(d.hold[1], 0.3, "the steady channel keeps its own peak");+}++#[test]+fn clip_indication_stays_lit_for_three_seconds_until_cleared_or_not_at_all() {+ use MeterHold::{Infinite, Off, ThreeSeconds};+ assert!(after_peak(1.0, 2.5, (Off, ThreeSeconds)).1, "3 Seconds still lit at 2.5 s");+ assert!(!after_peak(1.0, 3.5, (Off, ThreeSeconds)).1, "3 Seconds goes out after 3 s");+ assert!(after_peak(1.0, 60.0, (Off, Infinite)).1, "Infinite stays lit until cleared");+ assert!(!after_peak(1.0, 0.1, (Off, Off)).1, "None goes out on the next update");+ assert!(!after_peak(0.99, 0.0, (Off, Infinite)).1, "below full scale is not a clip");+}++#[test]+fn peak_hold_and_clip_indication_are_ui_commands_kept_with_the_preferences() {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ assert_eq!(app.run("ui.peak_hold", json!({})), Ok(json!({"mode": "three_seconds"})));+ assert_eq!(app.run("ui.clip_indication", json!({})), Ok(json!({"mode": "infinite"})));+ assert_eq!(app.run("ui.peak_hold", json!({"mode": "infinite"})), Ok(json!({"mode": "infinite"})));+ assert_eq!(app.run("ui.clip_indication", json!({"mode": "none"})), Ok(json!({"mode": "none"})));+ assert!(app.run("ui.peak_hold", json!({"mode": "forever"})).is_err());+ assert_eq!((app.ui.peak_hold, app.ui.clip_indication), (MeterHold::Infinite, MeterHold::Off));+ let saved = serde_json::to_value(&app.ui).unwrap();+ assert_eq!((&saved["peak_hold"], &saved["clip_indication"]), (&json!("infinite"), &json!("none")));+ let back: super::UiState = serde_json::from_value(saved).unwrap();+ assert_eq!((back.peak_hold, back.clip_indication), (MeterHold::Infinite, MeterHold::Off));+ assert_eq!(app.run("window.metering", json!({})), Ok(json!({"value": true})));+ assert_eq!(crate::menus::command_for_path("Setup > Metering...", &[]).as_deref(), Some("window.metering")); } #[test]@@ -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,new file mode 100644@@ -0,0 +1,127 @@+//! Enter on a focused dialog button, checkbox, radio button or combo box acts on that widget and does not press the+//! dialog's default button.++use egui::accesskit::Role;+use egui::{Event, Key, Modifiers, vec2};+use egui_kittest::{Harness, kittest::Queryable};+use serde_json::json;+use soundcraft_ui_egui::dialogs::Dialog;+use soundcraft_ui_egui::{Services, SoundApp, menus};++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(3);+ h+}++/// Gives the button the keyboard focus, then presses Enter.+fn enter_on(h: &mut Harness<'_, SoundApp>, button: &str) {+ enter_on_widget(h, Role::Button, button);+}++/// Gives the widget the keyboard focus, then presses Enter.+fn enter_on_widget(h: &mut Harness<'_, SoundApp>, role: Role, label: &str) {+ h.get_by_role_and_label(role, label).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(1);+ h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });+ h.run_steps(2);+}++#[test]+fn enter_on_a_focused_cancel_closes_the_dialog_without_its_action() {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ let groups = app.engine.session().groups.len();+ menus::invoke_menu(&mut app, "track.group", "Track > Group...");+ let mut h = harness(app);+ assert_eq!(h.state().dialogs.open_name(), Some("group"));+ enter_on(&mut h, "Cancel");+ assert_eq!(h.state().dialogs.open_name(), None);+ assert_eq!(h.state().engine.session().groups.len(), groups);+}++#[test]+fn enter_on_a_focused_dont_save_closes_without_saving() {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ app.run("mix.mute", json!({"track": "Kick"})).unwrap();+ menus::invoke_menu(&mut app, "session.close", "File > Close Session");+ let mut h = harness(app);+ assert_eq!(h.state().dialogs.open_name(), Some("save_changes"));+ enter_on(&mut h, "Don't Save");+ assert_eq!(h.state().dialogs.open_name(), None, "no Save As prompt");+ assert!(h.state().engine.session().tracks.is_empty());+}++#[test]+fn enter_on_a_focused_browse_browses_without_pressing_ok() {+ let picked = "picked/Song.scraft";+ let services = Services { pick_open: Some(Box::new(move |_, _| Some(picked.to_string()))), ..Services::default() };+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, services);+ menus::invoke_menu(&mut app, "session.open", "File > Open Session...");+ let mut h = harness(app);+ enter_on(&mut h, "Browse…");+ assert!(matches!(&h.state().dialogs.open, Some(Dialog::PathPrompt { path, .. }) if path == picked));+ assert!(h.state().engine.session().track_by_name("Kick").is_some(), "the demo session is still open");+}++#[test]+fn enter_on_a_focused_browse_in_bounce_mix_does_not_bounce() {+ let picked = "picked/Mix.flac";+ let services = Services { pick_save: Some(Box::new(move |_, _| Some(picked.to_string()))), ..Services::default() };+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, services);+ menus::invoke_menu(&mut app, "file.bounce_mix", "File > Bounce Mix...");+ let mut h = harness(app);+ enter_on(&mut h, "Browse…");+ assert!(matches!(&h.state().dialogs.open, Some(Dialog::Bounce { path, format, .. }) if path == picked && format == "flac"));+}++#[test]+fn enter_on_a_focused_checkbox_toggles_it_and_keeps_the_dialog_open() {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ menus::invoke_menu(&mut app, "file.score_setup", "File > Score Setup...");+ let mut h = harness(app);+ let Some(Dialog::ScoreSetup { show_track_names: before, .. }) = h.state().dialogs.open.clone() else { panic!("Score Setup did not open") };+ enter_on_widget(&mut h, Role::CheckBox, "Show track names");+ assert!(matches!(&h.state().dialogs.open, Some(Dialog::ScoreSetup { show_track_names, .. }) if *show_track_names != before));+}++#[test]+fn enter_on_a_focused_radio_button_picks_it_and_keeps_the_dialog_open() {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ menus::invoke_menu(&mut app, "edit.fades_create", "Edit > Fades > Create...");+ let mut h = harness(app);+ assert_eq!(h.state().dialogs.open_name(), Some("fades"));+ enter_on_widget(&mut h, Role::RadioButton, "Linear");+ assert!(matches!(&h.state().dialogs.open, Some(Dialog::Fades { shape }) if shape == "linear"));+}++#[test]+fn enter_with_nothing_focused_presses_the_default_button() {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ menus::invoke_menu(&mut app, "edit.fades_create", "Edit > Fades > Create...");+ let mut h = harness(app);+ assert_eq!(h.state().dialogs.open_name(), Some("fades"));+ 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);+}++#[test]+fn enter_on_a_focused_combo_box_opens_it_and_keeps_the_dialog_open() {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ menus::invoke_menu(&mut app, "setup.session", "Setup > Session");+ let mut h = harness(app);+ assert_eq!(h.state().dialogs.open_name(), Some("session"));+ assert!(h.query_by_label("47.952").is_none());+ enter_on_widget(&mut h, Role::ComboBox, "Timecode Rate");+ assert_eq!(h.state().dialogs.open_name(), Some("session"));+ assert!(h.query_by_label("47.952").is_some(), "the Timecode Rate list is open");+}@@ -18,6 +18,7 @@ mod docking_workflows; mod edit_menu; mod edit_modes; mod edit_window_headers;+mod focused_dialog_buttons; mod group_dialog; mod hide_floating; mod idle;@@ -34,6 +35,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 +46,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 +55,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;@@ -59,6 +64,7 @@ mod track_name_menu; mod track_preset_state; mod trackpad; mod tracks_scroll_bar;+mod unloop_dialog; mod waveform; mod window_shortcuts; mod zoom_toggle;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);+ }+}@@ -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()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:?}");+}@@ -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");+}new file mode 100644@@ -0,0 +1,62 @@+use egui::accesskit::Role;+use egui::{Event, Key, Modifiers, vec2};+use egui_kittest::{Harness, kittest::Queryable};+use serde_json::json;+use soundcraft_model::TrackId;+use soundcraft_ui_egui::{Services, SoundApp, menus};++/// Loops the Snare clip three times, selects it and opens Clip > Unloop... from the menu.+fn unloop_dialog() -> (Harness<'static, SoundApp>, TrackId, usize) {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ let track = app.engine.session().track_by_name("Snare").unwrap().id;+ let clip = app.engine.session().track(track).unwrap().clips()[0].id;+ app.run("clip.loop", json!({"clip": clip.0, "count": 3})).unwrap();+ app.run("edit.select", json!({"clips": [clip.0]})).unwrap();+ let looped = app.engine.session().track(track).unwrap().clips().len();+ menus::invoke_menu(&mut app, "clip.unloop", "Clip > Unloop...");+ assert_eq!(app.dialogs.open_name(), Some("unloop"));+ 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, track, looped)+}++fn clips(h: &Harness<'_, SoundApp>, track: TrackId) -> Vec<Option<u64>> {+ h.state().engine.session().track(track).map(|t| t.clips().iter().map(|c| c.loop_id).collect()).unwrap_or_default()+}++#[test]+fn unloop_dialogs_flatten_keeps_each_iteration_as_its_own_clip() {+ let (mut h, track, looped) = unloop_dialog();+ assert_eq!(clips(&h, track).len(), looped, "opening the dialog does not unloop yet");+ h.get_by_role_and_label(Role::Button, "Flatten").click();+ h.run_steps(2);+ assert_eq!(h.state().dialogs.open_name(), None);+ assert_eq!(clips(&h, track), vec![None; looped]);+}++#[test]+fn enter_on_a_focused_flatten_flattens() {+ let (mut h, track, looped) = unloop_dialog();+ h.get_by_role_and_label(Role::Button, "Flatten").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), vec![None; looped]);+}++#[test]+fn unloop_dialogs_remove_keeps_only_the_source_clip() {+ let (mut h, track, looped) = unloop_dialog();+ h.get_by_role_and_label(Role::Button, "Remove").click();+ h.run_steps(2);+ assert_eq!(h.state().dialogs.open_name(), None);+ assert_eq!(clips(&h, track).len(), looped - 2);+}@@ -1,5 +1,5 @@ use egui::os::OperatingSystem;-use egui::{Key, vec2};+use egui::{Key, Modifiers, vec2}; use egui_kittest::{Harness, kittest::Queryable}; use serde_json::json; use soundcraft_ui_egui::{Services, SoundApp};@@ -41,3 +41,46 @@ fn e_fits_the_selection_to_the_edit_window_and_the_button_goes_back() { assert_eq!(view(h.state()), before, "{os:?}"); } }++#[test]+fn alt_shift_e_and_an_alt_click_cancel_zoom_toggle_and_keep_the_zoomed_view() {+ for os in [OperatingSystem::Mac, OperatingSystem::Windows, OperatingSystem::Nix] {+ for alt_click in [false, true] {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ let bass = app.engine.session().track_by_name("Bass").unwrap().id.0;+ app.engine.execute("edit.select", &json!({"tracks": [bass], "start": 96_000, "end": 192_000})).unwrap();+ let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_os(os).build_ui_state(+ |ui, app: &mut SoundApp| {+ app.logic(&ui.ctx().clone());+ app.ui(ui);+ },+ app,+ );+ h.run_steps(5);+ let view = |a: &SoundApp| (a.engine.session().edit.zoom, a.edit_layout.rows.clone(), a.edit_layout.scroll_y);+ let before = view(h.state());+ h.get_by_label("Zoom Toggle (E)").click();+ h.run_steps(1);+ let zoomed = view(h.state());+ assert_ne!(zoomed, before, "{os:?}");++ if alt_click {+ h.get_by_label("Zoom Toggle (E)").click_modifiers(Modifiers::ALT);+ } else {+ h.key_press_modifiers(Modifiers::ALT | Modifiers::SHIFT, Key::E);+ }+ h.run_steps(2);+ let how = if alt_click { "Alt-click" } else { "Alt+Shift+E" };+ assert_eq!(h.state().engine.session().edit.zoom_toggle, None, "{os:?} {how}");+ assert_eq!(view(h.state()), zoomed, "{os:?} {how} keeps the zoomed view");++ // The next toggle zooms in from here and goes back here, not to the view before the cancel.+ h.get_by_label("Zoom Toggle (E)").click();+ h.run_steps(1);+ assert!(h.state().engine.session().edit.zoom_toggle.is_some(), "{os:?} {how}");+ h.get_by_label("Zoom Toggle (E)").click();+ h.run_steps(1);+ assert_eq!(view(h.state()), zoomed, "{os:?} {how}");+ }+ }+}@@ -91,6 +91,12 @@ a portal answer it uses Dark. Manual Light and Dark stay fixed when the OS appea An offscreen render without `--system-theme` supplies no OS appearance and therefore resolves System to Dark, or on Linux to the desktop's portal setting when one is available. +`ui.peak_hold {"mode":"three_seconds"|"infinite"|"none"}` sets how long meters show their last+peak, and `ui.clip_indication` takes the same modes for the clip lights; without `mode`, each+returns the current setting. The defaults are `three_seconds` and `infinite`. Both are saved with+the UI preferences, and Setup › Metering… (`window.metering`) shows them.+`track.clear_clip_indicators` clears every clip light and held peak.+ `view.scroll_into_view {"track":"Bass"}` (a track id or name; without it, the first selected track) brings the track to the top of the Edit window and the left of the Mix window, the same as Scroll Into View in the track name menu. A window not on screen catches up when it is next shown.@@ -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; fine adjustment on the Strip Silence sliders and plugin parameter sliders; Alt-click returns a plugin parameter to its default; fine adjustment of sends; Peak Hold and Clip Indication options; Cancel Zoom Toggle) · **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@@ -21,6 +21,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Live waveform fade preview | ✅ | Waveform amplitude updates during fade-length and midpoint-curvature drags, sharing the curve's temporary envelope. Mono/stereo, waveform styles, zoom and clip gain remain effective; release preserves the displayed waveform with one undo step and cached peaks | | Edit modes: Shuffle, Slip, Spot, Grid (absolute/relative) | ✅ | Spot dialog present | | Grid and nudge values, all timebases | ✅ | Bars/Beats, Min:Secs, Timecode, Feet+Frames, Samples |+| Zoom Toggle | 🟡 | E (Commands focus) and the button zoom to the edit selection with Fit To Window heights and back; Alt+Shift+E and Alt-click cancel it and keep the view. The Zoom Toggle preferences (Vertical Zoom, Horizontal Zoom, Track Height, Track View, Separate Grid Settings When Zoomed In, Zoom Toggle Follows Edit Selection, Remove Range Selection After Zooming In) are not present | | Keyboard focus modes (Commands, Clip List, Groups) | 🟡 | Commands focus single-key editing ✅; list focus partial | | Link timeline/edit, link track/edit, Tab to transient, insertion follows playback | ✅ | | | Mirrored MIDI, MIDI merge, layered editing | 🟡 | Stored and partly honoured |@@ -50,7 +51,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 +63,22 @@ 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 |+| Peak Hold and Clip Indication: 3 Seconds, Infinite, None | 🟡 | Setup › Metering… (`ui.peak_hold`, `ui.clip_indication`), kept with the preferences; Track › Clear All Clip Indicators also clears held peaks; clicking a meter does not clear it yet, and there are no plug-in or send meters | | 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-25 | minor | Zoom Toggle row: Alt+Shift+E and Alt-click cancel Zoom Toggle and keep the view. 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-24 | minor | Meters: Peak Hold and Clip Indication can be set to 3 Seconds, Infinite or None (Setup › Metering…). 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) |