From d16291081f0c0133b55992d173a165635758f72c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 06:27:00 -0700 Subject: [PATCH 01/19] Meters: Peak Hold and Clip Indication can be set to 3 Seconds, Infinite or None Meters held a peak for a fixed 2 s and kept a clip light on until Track > Clear All Clip Indicators, with no way to change either. Setup > Metering... now sets Peak Hold and Clip Indication to 3 Seconds, Infinite or None, through the UI commands ui.peak_hold and ui.clip_indication. Both are kept with the UI preferences. The defaults are 3 Seconds for Peak Hold and Infinite for Clip Indication. Clear All Clip Indicators also clears the held peaks. The 3 Seconds timers compare frame times. The old hold timer added up each update's step. That step is capped at 0.25 s, and after playback stops each frame applies it twice, so the timer did not follow the clock. Each channel has its own hold timer, which restarts only when the level reaches a held value above zero. The old timer was shared by both channels and restarted on any level equal to the held one, so a silent or steady channel kept the other channel's peak held. --- ROADMAP.md | 4 +- crates/ui-egui/src/lib.rs | 197 +++++++++++++++++++++++++++++++--- crates/ui-egui/src/menus.rs | 16 +++ crates/ui-egui/src/windows.rs | 29 +++++ docs/control-protocol.md | 6 ++ docs/ui-parity.md | 4 +- 6 files changed, 238 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 92d6b06e8..20f92fe6e 100644 --- a/ROADMAP.md +++ b/ROADMAP.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-19 · **Last updated:** 2026-10-23 · **Change:** trivial (Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-24 · **Change:** trivial (Peak Hold and Clip Indication options) · **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 @@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log +- **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-23:** A built-in Ring Modulator (`ring_mod`) applies ring modulation, frequency modulation or a positive or negative resonator, and an LFO, an envelope follower or sample and hold moves its frequency. AudioSuite › Modulation › Sci-Fi now runs it instead of the Flanger. No readiness percentage change. - **2026-10-19:** Float32 WAV and AIFF-C encoding preserve finite samples above full scale. Integer PCM still clips at full scale. Tests cover whole-file and streaming encoders, non-finite samples, and bounce output with normalization on and off. No readiness percentage change. - **2026-10-18:** Mono audio devices receive the average of the stereo channels before output clipping, retaining right-only audio. Surround mixes use the same average after their ITU stereo fold-down. Regression tests cover channel balance, polarity cancellation, clipping, centered mono tracks, each 5.1 channel and several callback sizes. No readiness percentage change. @@ -166,6 +167,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | Progress log: Peak Hold and Clip Indication options; readiness estimates unchanged | | 2026-10-23 | trivial | Progress log: Ring Modulator plugin; readiness estimates unchanged | | 2026-10-19 | trivial | Float32 file encoding preserves finite headroom (#791); readiness estimates unchanged | | 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged | diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 140355c8f..e5130c45b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -81,6 +81,12 @@ pub struct UiState { pub edit_views: Vec, /// 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). @@ -151,6 +157,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, @@ -217,13 +226,57 @@ pub fn fit_window_to_screen(ctx: &egui::Context) { #[derive(Debug, Clone, Copy)] pub struct DragSource(pub u64); +/// 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::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, @@ -481,7 +534,10 @@ 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; + }); } Ok(v) } @@ -1175,9 +1231,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) { @@ -1186,7 +1243,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(); } @@ -1205,10 +1262,12 @@ impl SoundApp { } } if !self.meters_live() { + let holds = (self.ui.peak_hold, self.ui.clip_indication); + let now = self.last_frame.unwrap_or_default(); 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); } } @@ -1371,9 +1430,12 @@ impl SoundApp { } } -fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) { - // Instant attack, ~26 dB/s release, 2 s peak hold. +/// Instant attack and ~26 dB/s release over `dt`; `holds` is the (Peak Hold, Clip Indication) +/// setting, timed against `now`, the frame time in egui seconds. +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); @@ -1381,19 +1443,35 @@ 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; } - } - d.hold_age += dt; - if d.hold_age > 2.0 { - d.hold = d.level; + 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; + } + } + 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 }; } @@ -1409,7 +1487,7 @@ fn failed_plugin_status(s: &soundcraft_model::Session, track: TrackId, slot: usi #[cfg(test)] mod tests { - 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; @@ -1570,8 +1648,95 @@ mod tests { 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.entry(kick).or_default().hold = [0.8, 0.7]; + app.main_meter.hold = [0.9, 0.9]; app.run("track.clear_clip_indicators", json!({})).unwrap(); assert!(!app.meters[&kick].clip && !app.main_meter.clip); + assert_eq!((app.meters[&kick].hold, app.main_meter.hold), ([0.0, 0.0], [0.0, 0.0]), "held peaks clear with the clips"); + } + + /// Feeds `peak` once, then silence for `secs` in 0.1 s steps; returns (held peak, clip lit). + 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] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cd879a72e..bf45f8e47 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -66,6 +66,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), @@ -437,6 +440,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"), @@ -668,6 +672,18 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option { 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()}))); diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 3b60cd755..cdb589d55 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -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); @@ -410,6 +411,34 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) { app.ui.show_configurations = open; } +/// 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> { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 72904533b..3b2f80504 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -89,6 +89,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. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f7d06d576..c1e188fcf 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-16 · **Change:** minor (Scroll Into View in the track name menu) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (Peak Hold and Clip Indication options) · **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 @@ -63,12 +63,14 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | 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 | 🟡 | | +| 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-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) | From f912393ef96a6ab9f9e3e51bf5e7b0e4cb58fdb8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 10:52:07 -0700 Subject: [PATCH 02/19] Clip > Unloop... opens the Unloop dialog: Remove or Flatten Clip > Unloop... removed the loop iterations of the selected clips at once. It now opens the Unloop dialog. Remove deletes the loop iterations and keeps the source clip, as the menu item did before, and Flatten keeps each loop iteration as its own clip. Enter presses Remove. The dialog opens for the clips clip.unloop would take and runs clip.unloop on those clips with the chosen mode. With no clip to take, the menu runs the command as before. Running clip.unloop as a command still unloops at once without opening the dialog. Enter in a dialog now presses the default button only when no widget has the keyboard focus or a text field has it. Before, Enter on a focused button, checkbox, radio button or combo box acted on that widget and pressed the default button too: Enter on a focused Cancel ran the dialog's OK action, Enter on Don't Save saved the session first (or opened Save As when it had never been saved), Enter on Browse... opened the picker and then pressed OK or Bounce, and Enter on a checkbox, radio button or combo box toggled, picked or opened it and then pressed the dialog's default button. --- ROADMAP.md | 4 +- crates/ui-egui/src/dialogs.rs | 61 ++++++++- crates/ui-egui/src/menus.rs | 1 + .../tests/it/focused_dialog_buttons.rs | 127 ++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 2 + crates/ui-egui/tests/it/unloop_dialog.rs | 62 +++++++++ 6 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/it/focused_dialog_buttons.rs create mode 100644 crates/ui-egui/tests/it/unloop_dialog.rs diff --git a/ROADMAP.md b/ROADMAP.md index 92d6b06e8..eb9cb89e8 100644 --- a/ROADMAP.md +++ b/ROADMAP.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-19 · **Last updated:** 2026-10-23 · **Change:** trivial (Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-24 · **Change:** trivial (Unloop dialog) · **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 @@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log +- **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-23:** A built-in Ring Modulator (`ring_mod`) applies ring modulation, frequency modulation or a positive or negative resonator, and an LFO, an envelope follower or sample and hold moves its frequency. AudioSuite › Modulation › Sci-Fi now runs it instead of the Flanger. No readiness percentage change. - **2026-10-19:** Float32 WAV and AIFF-C encoding preserve finite samples above full scale. Integer PCM still clips at full scale. Tests cover whole-file and streaming encoders, non-finite samples, and bounce output with normalization on and off. No readiness percentage change. - **2026-10-18:** Mono audio devices receive the average of the stereo channels before output clipping, retaining right-only audio. Surround mixes use the same average after their ITU stereo fold-down. Regression tests cover channel balance, polarity cancellation, clipping, centered mono tracks, each 5.1 channel and several callback sizes. No readiness percentage change. @@ -166,6 +167,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | Progress log: Unloop dialog; readiness estimates unchanged | | 2026-10-23 | trivial | Progress log: Ring Modulator plugin; readiness estimates unchanged | | 2026-10-19 | trivial | Float32 file encoding preserves finite headroom (#791); readiness estimates unchanged | | 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged | diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 221653f1b..ad897b19a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -17,6 +17,8 @@ pub enum Dialog { RenameClip { id: ClipId, name: String }, RenameMarker { id: soundcraft_model::MarkerId, name: String }, ClearClips { clips: Vec, sources: Vec, message: String }, + // Clip › Unloop…: the clips `clip.unloop` would take when it opened. + Unloop { clips: Vec }, 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 @@ -49,6 +51,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", @@ -102,6 +105,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.is_empty() { + return false; + } + Dialog::Unloop { clips } + } "track.new" => { Dialog::NewTracks { count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() } } @@ -327,6 +337,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", @@ -340,7 +351,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) @@ -479,6 +492,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, .. } => { ui.add(egui::TextEdit::singleline(path).desired_width(420.0)); if let Some(pick) = &app.services.pick_open @@ -746,6 +772,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})) @@ -830,6 +861,34 @@ 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("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(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cd879a72e..4daf48b2e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -149,6 +149,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", diff --git a/crates/ui-egui/tests/it/focused_dialog_buttons.rs b/crates/ui-egui/tests/it/focused_dialog_buttons.rs new file mode 100644 index 000000000..f58c0e3cf --- /dev/null +++ b/crates/ui-egui/tests/it/focused_dialog_buttons.rs @@ -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"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 6214f0a02..c98748de6 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -15,6 +15,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; @@ -53,6 +54,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; diff --git a/crates/ui-egui/tests/it/unloop_dialog.rs b/crates/ui-egui/tests/it/unloop_dialog.rs new file mode 100644 index 000000000..56bc192a7 --- /dev/null +++ b/crates/ui-egui/tests/it/unloop_dialog.rs @@ -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> { + 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); +} From 7b7e63a76044a2269d37e1cf305c3e16a0fd23e5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 13:35:39 -0700 Subject: [PATCH 03/19] Clip > Unloop... runs the command when no selected clip is looped The dialog opened for any selected clip, so an ordinary clip showed Remove and Flatten, and either choice only gave "the selection is not a looped clip". The menu now opens the dialog only when a clip it takes belongs to a loop (`cmd::is_looped`, the loop check `clip.unloop` uses); otherwise it runs the command, and the status line shows that error. --- crates/engine/src/cmd/clip_more.rs | 5 +++++ crates/engine/src/cmd/mod.rs | 1 + crates/ui-egui/src/dialogs.rs | 5 ++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/clip_more.rs b/crates/engine/src/cmd/clip_more.rs index 98bed8c72..9da56e9c4 100644 --- a/crates/engine/src/cmd/clip_more.rs +++ b/crates/engine/src/cmd/clip_more.rs @@ -131,6 +131,11 @@ fn loop_chain(s: &Session, t: TrackId, id: ClipId) -> Vec { 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 { let id = "clip.unloop"; let remove = match str_param(p, "mode") { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 41edf68cb..3b89fc23b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -59,6 +59,7 @@ mod panic_probe { } } +pub use clip_more::is_looped; pub use file::bounce_job; pub(crate) use edit::remember_selection; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ad897b19a..913a0c221 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -107,7 +107,7 @@ impl Dialogs { } "clip.unloop" => { let clips = soundcraft_engine::cmd::clip_ids_param(e, &json!({})); - if clips.is_empty() { + if !clips.iter().any(|&c| soundcraft_engine::cmd::is_looped(e.session(), c)) { return false; } Dialog::Unloop { clips } @@ -869,6 +869,9 @@ mod tests { 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")); From afc93c151bc90046b50cf8f6c050c6782db253cd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 19:11:42 -0700 Subject: [PATCH 04/19] Strip Silence: the sliders move a tenth as far while Ctrl or Command is held While Ctrl (Command on a Mac) is held, a drag on one of the four Strip Silence sliders moves the value a tenth as far as the same drag without the key, on the slider and on its value box. Pressing or releasing the key during a drag leaves the value where it is, and the drag goes on from there. Releasing the mouse button leaves the value too. A drag without the key works as before. The new widgets::fine_slider uses the modifier test and the tenth of widgets::fader. Threshold and Clip Start Pad show one decimal place, and a drag on the slider during which the key was held rounds the value to the places the box shows. --- ROADMAP.md | 4 +- crates/ui-egui/src/dialogs.rs | 8 +- crates/ui-egui/src/widgets.rs | 70 ++++++++++ crates/ui-egui/tests/it/main.rs | 1 + .../ui-egui/tests/it/strip_silence_sliders.rs | 129 ++++++++++++++++++ docs/ui-parity.md | 5 +- 6 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/tests/it/strip_silence_sliders.rs diff --git a/ROADMAP.md b/ROADMAP.md index 4a4d7e043..729bb0aef 100644 --- a/ROADMAP.md +++ b/ROADMAP.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-19 · **Last updated:** 2026-10-24 · **Change:** trivial (Strip Silence rectangles) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-24 · **Change:** trivial (Strip Silence fine adjustment) · **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 @@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log +- **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:** 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. - **2026-10-24:** The Strip Silence window has Extract and Separate buttons. Extract removes the audio and keeps the silent parts of the selection, and Separate separates the clips at the edges of the silence without removing anything. Minimum Strip Duration goes up to 4,000 ms. No readiness percentage change. @@ -169,6 +170,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | Progress log: Strip Silence fine adjustment; 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 | | 2026-10-24 | trivial | Progress log: Strip Silence Extract and Separate; readiness estimates unchanged | diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 72c7ff021..58ed0bca3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -609,10 +609,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 { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index d785c995c..e67a37485 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -368,6 +368,76 @@ 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, text: &str) -> Response { + let id = ui.id().with(("fine_slider", text)); + let (fine, x) = ui.input(|i| (i.modifiers.command || i.modifiers.ctrl, i.pointer.interact_pos().map(|p| p.x))); + // A drag lasts through the pass that releases the button, where egui still sets the value + // from the pointer. An anchor that the previous pass did not store was left by an earlier + // drag, and is ignored. + let pass = ui.ctx().cumulative_pass_nr(); + let mut drag = ui.data(|d| d.get_temp::(id)).filter(|d| d.pass + 1 == pass); + let (lo, hi) = (*range.start(), *range.end()); + // 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 = (ui.spacing().slider_width - 2.0 * thickness / 2.5).max(1.0); + let per_point = (hi - lo) / f64::from(travel); + let scale = if fine { 0.1 } else { 1.0 }; + let decimals = (-(per_point * 0.1).log10()).ceil().clamp(0.0, 3.0) as usize; + if let (Some(d), Some(x)) = (&mut drag, x) { + if d.fine != fine { + *d = FineDrag { x, value: *value, fine, relative: true, pass }; + } else if d.relative { + let moved = d.value + f64::from(x - d.x) * per_point * scale; + *value = egui::emath::round_to_decimals(moved, decimals).clamp(lo, hi); + } + } + let hold = drag.is_some_and(|d| d.relative); + let mut slider = egui::Slider::from_get_set(range, |v| { + if let Some(v) = v + && !hold + { + *value = v; + } + *value + }) + .min_decimals(decimals) + .text(text); + if fine { + slider = slider.drag_value_speed(per_point * scale); + } + let resp = ui.add(slider); + let rail = Rect::from_min_size(resp.rect.min, vec2(ui.spacing().slider_width, resp.rect.height())); + let on_rail = resp.dragged() && ui.input(|i| i.pointer.press_origin()).is_some_and(|p| rail.contains(p)); + let next = match (on_rail, drag, x) { + (true, Some(d), _) => Some(FineDrag { pass, ..d }), + (true, None, Some(x)) => Some(FineDrag { x, value: *value, fine, relative: fine, pass }), + _ => None, + }; + ui.data_mut(|m| match next { + Some(d) => { + m.insert_temp(id, d); + } + None => m.remove::(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, diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index efe1e3669..87e7d8afa 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -48,6 +48,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; diff --git a/crates/ui-egui/tests/it/strip_silence_sliders.rs b/crates/ui-egui/tests/it/strip_silence_sliders.rs new file mode 100644 index 000000000..df1f98aaa --- /dev/null +++ b/crates/ui-egui/tests/it/strip_silence_sliders.rs @@ -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"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f7d06d576..507e46498 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-16 · **Change:** minor (Scroll Into View in the track name menu) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-24 · **Change:** minor (fine adjustment on the Strip Silence sliders) · **Target:** Avid Pro Tools Ultimate 2026.4.1 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command @@ -62,13 +62,14 @@ 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 and the Strip Silence sliders a tenth as far | | macOS system menu bar | ✅ | #10 | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Strip Silence sliders: Ctrl/Cmd-drag moves them a tenth as far. Readiness estimate unchanged | | 2026-10-16 | minor | Track name menu: Scroll Into View brings the track to the top of the Edit window and the left of the Mix. Readiness estimate unchanged | | 2026-10-13 | minor | A plugin opens when it's inserted (#486) | | 2026-10-13 | minor | Drag across checkboxes (#399) | From a9ef108e61286b952eb440e315b7ec0a3485542c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 20:36:13 -0700 Subject: [PATCH 05/19] Plugins: parameter sliders move a tenth as far while Ctrl or Command is held While Ctrl (Command on a Mac) is held, a drag on a parameter slider in the plugin window, the Clip Effects window or an AudioSuite plugin's window moves the value a tenth as far as the same drag without the key, as the Strip Silence sliders do. A logarithmic slider moves a tenth as far along the slider, and a whole-number slider, such as the Sampler's Root Note, stays on whole numbers. A drag without the key works as before. widgets::FineSlider is a new builder with the slider options these windows use (no value box, logarithmic, whole numbers, a salt for unlabeled sliders); widgets::fine_slider keeps its signature and calls it. --- ROADMAP.md | 4 +- crates/ui-egui/src/dialogs.rs | 4 +- crates/ui-egui/src/panels.rs | 8 +- crates/ui-egui/src/widgets.rs | 167 ++++++++++++----- crates/ui-egui/src/windows.rs | 4 +- crates/ui-egui/tests/it/main.rs | 1 + .../ui-egui/tests/it/plugin_fine_sliders.rs | 174 ++++++++++++++++++ docs/ui-parity.md | 5 +- 8 files changed, 312 insertions(+), 55 deletions(-) create mode 100644 crates/ui-egui/tests/it/plugin_fine_sliders.rs diff --git a/ROADMAP.md b/ROADMAP.md index 729bb0aef..79f72f43a 100644 --- a/ROADMAP.md +++ b/ROADMAP.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-19 · **Last updated:** 2026-10-24 · **Change:** trivial (Strip Silence fine adjustment) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-24 · **Change:** trivial (fine adjustment on plugin parameter sliders) · **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 @@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log +- **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:** 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. @@ -170,6 +171,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 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: Strip Silence rectangles; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Strip Silence Rename; readiness estimates unchanged | diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 58ed0bca3..ca5a40500 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -986,7 +986,9 @@ fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { if let Some(info) = soundcraft_dsp::plugin_info(&process) { for p in info.params { let v = params.entry(p.id.to_string()).or_insert(p.default); - ui.add(egui::Slider::new(v, p.min..=p.max).text(p.name)); + let mut wide = f64::from(*v); + crate::widgets::fine_slider(ui, &mut wide, f64::from(p.min)..=f64::from(p.max), p.name); + *v = wide as f32; } } else { match process.as_str() { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 778af47ba..6e991ae11 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -577,14 +577,18 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { x = idx as f32; r.response } else { - let mut sl = egui::Slider::new(&mut x, p.min..=p.max).show_value(false); + let mut wide = f64::from(x); + let mut sl = + crate::widgets::FineSlider::new(&mut wide, f64::from(p.min)..=f64::from(p.max)).id_salt(p.id).show_value(false); if instrument && crate::sampler_window::whole_number(&ins.plugin, p.id) { sl = sl.integer(); } if p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0 { sl = sl.logarithmic(true); } - ui.add(sl) + let r = sl.show(ui); + x = wide as f32; + r }; if (x - v).abs() > f32::EPSILON && instrument { let _ = app.engine.execute_merged( diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e67a37485..f1426722b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -385,57 +385,128 @@ struct FineDrag { /// 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, text: &str) -> Response { - let id = ui.id().with(("fine_slider", text)); - let (fine, x) = ui.input(|i| (i.modifiers.command || i.modifiers.ctrl, i.pointer.interact_pos().map(|p| p.x))); - // A drag lasts through the pass that releases the button, where egui still sets the value - // from the pointer. An anchor that the previous pass did not store was left by an earlier - // drag, and is ignored. - let pass = ui.ctx().cumulative_pass_nr(); - let mut drag = ui.data(|d| d.get_temp::(id)).filter(|d| d.pass + 1 == pass); - let (lo, hi) = (*range.start(), *range.end()); - // 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 = (ui.spacing().slider_width - 2.0 * thickness / 2.5).max(1.0); - let per_point = (hi - lo) / f64::from(travel); - let scale = if fine { 0.1 } else { 1.0 }; - let decimals = (-(per_point * 0.1).log10()).ceil().clamp(0.0, 3.0) as usize; - if let (Some(d), Some(x)) = (&mut drag, x) { - if d.fine != fine { - *d = FineDrag { x, value: *value, fine, relative: true, pass }; - } else if d.relative { - let moved = d.value + f64::from(x - d.x) * per_point * scale; - *value = egui::emath::round_to_decimals(moved, decimals).clamp(lo, hi); + 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, + text: &'a str, + id_salt: Option<&'a str>, + show_value: bool, + logarithmic: bool, + integer: bool, +} + +impl<'a> FineSlider<'a> { + pub fn new(value: &'a mut f64, range: std::ops::RangeInclusive) -> Self { + Self { value, range, text: "", id_salt: None, show_value: true, logarithmic: false, integer: false } + } + + /// The label after the slider. It also tells sliders in the same `Ui` apart unless + /// [`Self::id_salt`] is set. + pub fn text(mut self, text: &'a str) -> Self { + self.text = text; + self + } + + /// Tells sliders in the same `Ui` apart when they have no label. + pub fn id_salt(mut self, salt: &'a str) -> Self { + self.id_salt = Some(salt); + self + } + + /// Whether the value box is shown. Without it, a drag that the key has touched rounds the + /// value only on a whole-number slider. + pub fn show_value(mut self, show: bool) -> Self { + self.show_value = show; + self + } + + /// A logarithmic rail, as `egui::Slider::logarithmic`, for a range above zero. A range that + /// reaches zero or below stays linear. + pub fn logarithmic(mut self, logarithmic: bool) -> Self { + self.logarithmic = logarithmic; + self + } + + /// Whole numbers only, as `egui::Slider::integer`. + pub fn integer(mut self) -> Self { + self.integer = true; + self + } + + pub fn show(self, ui: &mut Ui) -> Response { + let Self { value, range, text, id_salt, show_value, logarithmic, integer } = self; + let id = ui.id().with(("fine_slider", id_salt.unwrap_or(text))); + let (fine, x) = ui.input(|i| (i.modifiers.command || i.modifiers.ctrl, i.pointer.interact_pos().map(|p| p.x))); + // A drag lasts through the pass that releases the button, where egui still sets the value + // from the pointer. An anchor that the previous pass did not store was left by an earlier + // drag, and is ignored. + let pass = ui.ctx().cumulative_pass_nr(); + let mut drag = ui.data(|d| d.get_temp::(id)).filter(|d| d.pass + 1 == pass); + let (lo, hi) = (*range.start(), *range.end()); + let log = logarithmic && lo > 0.0 && hi > lo; + // The handle's travel along the rail, as `egui::Slider` lays it out. + let thickness = ui.text_style_height(&egui::TextStyle::Body).max(ui.spacing().interact_size.y); + let travel = f64::from((ui.spacing().slider_width - 2.0 * thickness / 2.5).max(1.0)); + // Where a value sits along the rail (0 to 1), and back, as `egui::Slider` maps them. + let span = if log { (hi / lo).ln() } else { hi - lo }; + let along = |v: f64| if span > 0.0 { if log { (v / lo).ln() / span } else { (v - lo) / span } } else { 0.0 }; + let at = |n: f64| { + let n = n.clamp(0.0, 1.0); + if log { lo * (n * span).exp() } else { lo + n * span } + }; + // How far the value moves for one point of the rail at `v`. + let per_point = |v: f64| if log { v * span / travel } else { span / travel }; + let scale = if fine { 0.1 } else { 1.0 }; + let decimals = if integer { 0 } else { (-(per_point(lo) * 0.1).log10()).ceil().clamp(0.0, 3.0) as usize }; + if let (Some(d), Some(x)) = (&mut drag, x) { + if d.fine != fine { + *d = FineDrag { x, value: *value, fine, relative: true, pass }; + } else if d.relative { + let moved = at(along(d.value) + f64::from(x - d.x) / travel * scale); + let moved = if show_value || integer { egui::emath::round_to_decimals(moved, decimals) } else { moved }; + *value = moved.clamp(lo, hi); + } } - } - let hold = drag.is_some_and(|d| d.relative); - let mut slider = egui::Slider::from_get_set(range, |v| { - if let Some(v) = v - && !hold - { - *value = v; + let hold = drag.is_some_and(|d| d.relative); + let speed = per_point(*value) * scale; + let mut slider = egui::Slider::from_get_set(range, |v| { + if let Some(v) = v + && !hold + { + *value = v; + } + *value + }) + .show_value(show_value) + .logarithmic(log) + .min_decimals(decimals) + .text(text); + if integer { + slider = slider.integer(); } - *value - }) - .min_decimals(decimals) - .text(text); - if fine { - slider = slider.drag_value_speed(per_point * scale); - } - let resp = ui.add(slider); - let rail = Rect::from_min_size(resp.rect.min, vec2(ui.spacing().slider_width, resp.rect.height())); - let on_rail = resp.dragged() && ui.input(|i| i.pointer.press_origin()).is_some_and(|p| rail.contains(p)); - let next = match (on_rail, drag, x) { - (true, Some(d), _) => Some(FineDrag { pass, ..d }), - (true, None, Some(x)) => Some(FineDrag { x, value: *value, fine, relative: fine, pass }), - _ => None, - }; - ui.data_mut(|m| match next { - Some(d) => { - m.insert_temp(id, d); + if fine { + slider = slider.drag_value_speed(speed); } - None => m.remove::(id), - }); - resp + let resp = ui.add(slider); + let rail = Rect::from_min_size(resp.rect.min, vec2(ui.spacing().slider_width, resp.rect.height())); + let on_rail = resp.dragged() && ui.input(|i| i.pointer.press_origin()).is_some_and(|p| rail.contains(p)); + let next = match (on_rail, drag, x) { + (true, Some(d), _) => Some(FineDrag { pass, ..d }), + (true, None, Some(x)) => Some(FineDrag { x, value: *value, fine, relative: fine, pass }), + _ => None, + }; + ui.data_mut(|m| match next { + Some(d) => { + m.insert_temp(id, d); + } + None => m.remove::(id), + }); + resp + } } /// A speaker dot on the surround panner: label, square position (-1..1, y up = front), height layer. diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 3b60cd755..94e1d79ef 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -716,7 +716,9 @@ pub fn clip_effects(app: &mut SoundApp, ctx: &egui::Context) { let key = format!("{prefix}{}", p.id); let mut v = get(app, &key, p.default); let before = v; - col.add(egui::Slider::new(&mut v, p.min..=p.max).text(p.name)); + let mut wide = f64::from(v); + crate::widgets::fine_slider(col, &mut wide, f64::from(p.min)..=f64::from(p.max), p.name); + v = wide as f32; if (v - before).abs() > f32::EPSILON { let mut m = serde_json::Map::new(); m.insert(key.clone(), json!(v)); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 87e7d8afa..384edbaeb 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -31,6 +31,7 @@ mod mix_separate; mod new_midi_clip; mod new_tracks; mod open_on_insert; +mod plugin_fine_sliders; mod plugin_window; mod pro_tools_keys; mod record_hint; diff --git a/crates/ui-egui/tests/it/plugin_fine_sliders.rs b/crates/ui-egui/tests/it/plugin_fine_sliders.rs new file mode 100644 index 000000000..a9ade4f37 --- /dev/null +++ b/crates/ui-egui/tests/it/plugin_fine_sliders.rs @@ -0,0 +1,174 @@ +use egui::accesskit::Role; +use egui::{Event, Id, Modifiers, PointerButton, Pos2, Rect, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_dsp::ParamInfo; +use soundcraft_model::TrackId; +use soundcraft_ui_egui::{Services, SoundApp}; +use std::collections::BTreeMap; + +fn harness(app: SoundApp) -> Harness<'static, SoundApp> { + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn hold(h: &mut Harness<'_, SoundApp>, modifiers: Modifiers) { + h.event(Event::ModifiersChanged(modifiers)); + h.run_steps(1); +} + +fn press(h: &mut Harness<'_, SoundApp>, pos: Pos2, pressed: bool, modifiers: Modifiers) { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }); + h.run_steps(1); +} + +fn move_to(h: &mut Harness<'_, SoundApp>, pos: Pos2) { + h.event(Event::PointerMoved(pos)); + h.run_steps(1); +} + +/// Presses the middle of `rail` and drags `dx` points right in six steps with `modifiers` held, +/// calling `read` after each step; returns how far `read` moved from where the press put it. +fn drag(h: &mut Harness<'_, SoundApp>, rail: Rect, dx: f32, modifiers: Modifiers, read: &dyn Fn(&Harness<'_, SoundApp>) -> f64) -> f64 { + hold(h, modifiers); + let from = rail.center(); + move_to(h, from); + press(h, from, true, modifiers); + let start = read(h); + for i in 1..=6 { + move_to(h, from + vec2(dx * i as f32 / 6.0, 0.0)); + read(h); + } + let moved = read(h) - start; + press(h, from + vec2(dx, 0.0), false, modifiers); + assert_eq!(read(h) - start, moved, "releasing the button moved the value"); + hold(h, Modifiers::NONE); + moved +} + +/// A plain 30-point drag and a Ctrl-drag of the same length on `rail`; returns how far each +/// moved `read`. +fn plain_and_fine(h: &mut Harness<'_, SoundApp>, rail: Rect, read: &dyn Fn(&Harness<'_, SoundApp>) -> f64) -> (f64, f64) { + let plain = drag(h, rail, 30.0, Modifiers::NONE, read); + let fine = drag(h, rail, 30.0, Modifiers::COMMAND, read); + (plain, fine) +} + +fn assert_tenth(what: &str, (plain, fine): (f64, f64), within: std::ops::RangeInclusive) { + 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 { + let area = h.ctx.memory(|m| m.area_rect(window)).unwrap(); + let mut rails: Vec = 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::new(("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::new(("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::new(("plugin", keys.0, soundcraft_mix::INSTRUMENT_SLOT))); + let root = slider_params("sampler")[0]; + assert_eq!(root.id, "root_note"); + // Each read during the drags checks that the instrument was sent a whole number. + let read = move |h: &Harness<'_, SoundApp>| { + let v = insert_param(h, keys, soundcraft_mix::INSTRUMENT_SLOT, root); + assert_eq!(v.fract(), 0.0, "Root Note is {v} during a drag"); + v + }; + assert_tenth(root.name, plain_and_fine(&mut h, rails[0], &read), 0.06..=0.14); +} + +#[test] +fn clip_effects_sliders_move_a_tenth_as_far_with_ctrl_held() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let s = app.engine.session(); + let clip = s.track_by_name("Kick").unwrap().clips().iter().find(|c| c.is_audio()).unwrap().id; + app.run("edit.select", json!({"clips": [clip.0]})).unwrap(); + app.ui.show_clip_effects = true; + let mut h = harness(app); + for (label, key, default) in [("Input", "eq.input_gain", 0.0), ("Threshold", "comp.threshold", -20.0)] { + let key = format!("clip_fx.{}.{key}", clip.0); + let read = move |h: &Harness<'_, SoundApp>| h.state().engine.session().edit.values.get(&key).copied().unwrap_or(default); + let rail = h.get_by_role_and_label(Role::Slider, label).rect(); + assert_tenth(label, plain_and_fine(&mut h, rail, &read), 0.08..=0.12); + } +} + +#[test] +fn audiosuite_parameter_sliders_move_a_tenth_as_far_with_ctrl_held() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.ui.audiosuite = Some("compressor".into()); + let mut h = harness(app); + for (label, id) in [("Threshold", "threshold"), ("Knee", "knee")] { + let read = move |h: &Harness<'_, SoundApp>| { + let params: BTreeMap = h.ctx.data(|d| d.get_temp(Id::new(("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); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 507e46498..5f16f684c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-24 · **Change:** minor (fine adjustment on the Strip Silence sliders) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-24 · **Change:** minor (fine adjustment on plugin parameter sliders) · **Target:** Avid Pro Tools Ultimate 2026.4.1 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command @@ -62,13 +62,14 @@ 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 | 🟡 | Ctrl/Cmd-drag moves the faders and the Strip Silence sliders a tenth as far | +| Fader resolution, fine adjust, numeric entry | 🟡 | Ctrl/Cmd-drag moves the faders, the Strip Silence sliders and plugin parameter sliders a tenth as far | | macOS system menu bar | ✅ | #10 | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Plugin parameter sliders: Ctrl/Cmd-drag moves them a tenth as far. Readiness estimate unchanged | | 2026-10-24 | minor | Strip Silence sliders: Ctrl/Cmd-drag moves them a tenth as far. Readiness estimate unchanged | | 2026-10-16 | minor | Track name menu: Scroll Into View brings the track to the top of the Edit window and the left of the Mix. Readiness estimate unchanged | | 2026-10-13 | minor | A plugin opens when it's inserted (#486) | From 5c42c6c2f7adb015dab14d31a6aeb0a159ffc1ce Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 21:12:23 -0700 Subject: [PATCH 06/19] Plugins: Alt-click returns a parameter to its default value 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 sets the parameter's default value, as an Alt-click sets a fader to 0 dB. It works on a slider, on a slider's value box and on a choice list. The click on a value box does not open it for typing, and the click on a choice list does not open the list. In the plugin window and the Clip Effects window the reset runs the same command as a drag, so it is one undo step. While Alt is held, a drag on one of these sliders leaves the value where it is. widgets::FineSlider has a new default_value option for this. --- ROADMAP.md | 4 +- crates/ui-egui/src/dialogs.rs | 5 +- crates/ui-egui/src/panels.rs | 12 +- crates/ui-egui/src/widgets.rs | 32 ++- crates/ui-egui/src/windows.rs | 5 +- crates/ui-egui/tests/it/main.rs | 1 + .../ui-egui/tests/it/plugin_default_reset.rs | 182 ++++++++++++++++++ docs/ui-parity.md | 5 +- 8 files changed, 234 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/tests/it/plugin_default_reset.rs diff --git a/ROADMAP.md b/ROADMAP.md index 79f72f43a..3ac2bfe24 100644 --- a/ROADMAP.md +++ b/ROADMAP.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-19 · **Last updated:** 2026-10-24 · **Change:** trivial (fine adjustment on plugin parameter sliders) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-24 · **Change:** trivial (Alt-click returns a plugin parameter to its default) · **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 @@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log +- **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:** 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. @@ -171,6 +172,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 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: Strip Silence rectangles; readiness estimates unchanged | diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ca5a40500..2a22b239a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -987,7 +987,10 @@ fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { for p in info.params { let v = params.entry(p.id.to_string()).or_insert(p.default); let mut wide = f64::from(*v); - crate::widgets::fine_slider(ui, &mut wide, f64::from(p.min)..=f64::from(p.max), p.name); + 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 { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 6e991ae11..a6e77d4ca 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -575,11 +575,19 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { } }); x = idx as f32; + // An Alt-click (Option-click on a Mac) returns the choice to its + // default, as on the sliders, instead of opening the list. + if r.response.clicked() && ui.input(|i| i.modifiers.alt) { + x = p.default; + egui::Popup::close_all(ui.ctx()); + } r.response } else { let mut 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); + 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(); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index f1426722b..a5ee2f60a 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -397,11 +397,20 @@ pub struct FineSlider<'a> { show_value: bool, logarithmic: bool, integer: bool, + default: Option, } impl<'a> FineSlider<'a> { pub fn new(value: &'a mut f64, range: std::ops::RangeInclusive) -> Self { - Self { value, range, text: "", id_salt: None, show_value: true, logarithmic: false, integer: false } + Self { value, range, text: "", id_salt: None, show_value: true, logarithmic: false, integer: false, default: None } + } + + /// The value that an Alt-click (Option-click on a Mac) on the slider or its value box sets, + /// as a [`fader`] returns to 0 dB. While Alt is held, a drag and a click on the rail leave the + /// value where it is, and the click does not open the value box 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 @@ -438,9 +447,10 @@ impl<'a> FineSlider<'a> { } pub fn show(self, ui: &mut Ui) -> Response { - let Self { value, range, text, id_salt, show_value, logarithmic, integer } = self; + let Self { value, range, text, id_salt, show_value, logarithmic, integer, default } = self; let id = ui.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. @@ -463,7 +473,7 @@ impl<'a> FineSlider<'a> { 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 { + 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); @@ -471,7 +481,7 @@ impl<'a> FineSlider<'a> { *value = moved.clamp(lo, hi); } } - let hold = drag.is_some_and(|d| d.relative); + 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 @@ -491,7 +501,19 @@ impl<'a> FineSlider<'a> { if fine { slider = slider.drag_value_speed(speed); } - let resp = ui.add(slider); + 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) { diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 94e1d79ef..f781dc4b4 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -717,7 +717,10 @@ pub fn clip_effects(app: &mut SoundApp, ctx: &egui::Context) { let mut v = get(app, &key, p.default); let before = v; let mut wide = f64::from(v); - crate::widgets::fine_slider(col, &mut wide, f64::from(p.min)..=f64::from(p.max), p.name); + 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(); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 384edbaeb..fb708e57e 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -31,6 +31,7 @@ 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; diff --git a/crates/ui-egui/tests/it/plugin_default_reset.rs b/crates/ui-egui/tests/it/plugin_default_reset.rs new file mode 100644 index 000000000..29cbbfc15 --- /dev/null +++ b/crates/ui-egui/tests/it/plugin_default_reset.rs @@ -0,0 +1,182 @@ +use egui::accesskit::Role; +use egui::{Event, Id, Modifiers, PointerButton, Pos2, Rect, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_dsp::ParamInfo; +use soundcraft_model::TrackId; +use soundcraft_ui_egui::{Services, SoundApp}; +use std::collections::BTreeMap; + +fn harness(app: SoundApp) -> Harness<'static, SoundApp> { + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn hold(h: &mut Harness<'_, SoundApp>, modifiers: Modifiers) { + h.event(Event::ModifiersChanged(modifiers)); + h.run_steps(1); +} + +fn press(h: &mut Harness<'_, SoundApp>, pos: Pos2, pressed: bool, modifiers: Modifiers) { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }); + h.run_steps(1); +} + +fn move_to(h: &mut Harness<'_, SoundApp>, pos: Pos2) { + h.event(Event::PointerMoved(pos)); + h.run_steps(1); +} + +/// Presses at `at` with Alt held, moves `dx` points right in six steps, and releases; `dx` of 0 +/// is an Alt-click. +fn alt_press(h: &mut Harness<'_, SoundApp>, at: Pos2, dx: f32) { + hold(h, Modifiers::ALT); + move_to(h, at); + press(h, at, true, Modifiers::ALT); + for i in 1..=6 { + if dx != 0.0 { + move_to(h, at + vec2(dx * i as f32 / 6.0, 0.0)); + } + } + press(h, at + vec2(dx, 0.0), false, Modifiers::ALT); + hold(h, Modifiers::NONE); + h.run_steps(2); +} + +/// A point near the left end of `rail`, where a plain click would set a value far from the +/// parameter's default. +fn near_left(rail: Rect) -> Pos2 { + rail.left_center() + vec2(3.0, 0.0) +} + +/// The rails of the unlabeled sliders inside the window with id `window`, top to bottom. +fn window_sliders(h: &Harness<'_, SoundApp>, window: Id) -> Vec { + let area = h.ctx.memory(|m| m.area_rect(window)).unwrap(); + let mut rails: Vec = 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::new(("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::new(("plugin", kick.0, 0usize)))[0]; + let steps = undo_steps(&h); + alt_press(&mut h, near_left(rail), 40.0); + assert_eq!(insert_param(&h, kick, 0, input), 6.0, "an Alt-drag moved Input"); + assert_eq!(undo_steps(&h), steps, "an Alt-drag added an undo step"); +} + +#[test] +fn alt_click_returns_a_plugin_window_choice_to_its_default_without_opening_the_list() { + let (mut h, kick) = kick_eq(); + let slope = param("eq_7band", "hpf_slope"); + assert_ne!(slope.default, slope.max); + let row = h.get_by_label("HPF Slope").rect(); + let combo = h.query_all_by_role(Role::ComboBox).map(|n| n.rect()).find(|r| (r.center().y - row.center().y).abs() < 4.0).unwrap(); + alt_press(&mut h, combo.center(), 0.0); + assert_eq!(insert_param(&h, kick, 0, slope), slope.default, "Alt-click did not return HPF Slope to its default"); + assert!(!egui::Popup::is_any_open(&h.ctx), "Alt-click opened the list"); +} + +#[test] +fn alt_click_returns_clip_effects_sliders_and_value_boxes_to_their_defaults() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let s = app.engine.session(); + let clip = s.track_by_name("Kick").unwrap().clips().iter().find(|c| c.is_audio()).unwrap().id; + app.run("edit.select", json!({"clips": [clip.0]})).unwrap(); + app.run("clip.effects_set", json!({"clips": [clip.0], "params": {"eq.input_gain": 6.0, "comp.threshold": -6.0}})).unwrap(); + app.engine.end_merge(); + app.ui.show_clip_effects = true; + let mut h = harness(app); + for (label, key, p, on_box) in + [("Input", "eq.input_gain", param("eq_7band", "input_gain"), false), ("Threshold", "comp.threshold", param("compressor", "threshold"), true)] + { + let key = format!("clip_fx.{}.{key}", clip.0); + let read = |h: &Harness<'_, SoundApp>| h.state().engine.session().edit.values.get(&key).copied(); + assert_ne!(read(&h), Some(f64::from(p.default))); + let rail = h.get_by_role_and_label(Role::Slider, label).rect(); + let at = if on_box { + h.query_all_by_role(Role::SpinButton) + .map(|n| n.rect()) + .find(|r| r.left() > rail.right() && (r.center().y - rail.center().y).abs() < 4.0) + .unwrap() + .center() + } else { + near_left(rail) + }; + // A plain click on the value box gives it the keyboard focus for typing. + let focus = h.ctx.memory(|m| m.focused()); + let steps = undo_steps(&h); + alt_press(&mut h, at, 0.0); + assert_eq!(read(&h), Some(f64::from(p.default)), "Alt-click did not return {label} to its default"); + assert_eq!(undo_steps(&h), steps + 1, "Alt-click on {label} is not one undo step"); + assert_eq!(h.ctx.memory(|m| m.focused()), focus, "Alt-click opened {label}'s value box for typing"); + } +} + +#[test] +fn alt_click_returns_audiosuite_parameter_sliders_to_their_defaults() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.ui.audiosuite = Some("compressor".into()); + let mut h = harness(app); + let read = |h: &Harness<'_, SoundApp>| -> BTreeMap { + h.ctx.data(|d| d.get_temp(Id::new(("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"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5f16f684c..e959938b1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-24 · **Change:** minor (fine adjustment on plugin parameter sliders) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-24 · **Change:** minor (Alt-click returns a plugin parameter to its default) · **Target:** Avid Pro Tools Ultimate 2026.4.1 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command @@ -50,7 +50,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | I/O Setup | 🟡 | Busses and paths; no hardware channel matrix (G-01) | | Preferences | 🟡 | Many settings stored, fewer honoured | | Window configurations, Tile/Cascade | 🟡 | Configurations ✅; Tile/Cascade in PR #151 | -| Plugin windows | 🟡 | Generic parameter sliders; Pro Tools plugins have bespoke GUIs (#138 asks for skeuomorphic design); multi-page plugin views (#6) | +| Plugin windows | 🟡 | Generic parameter sliders; Pro Tools plugins have bespoke GUIs (#138 asks for skeuomorphic design); multi-page plugin views (#6); Alt/Option-click returns a parameter to its default | | A plugin opens when it's inserted | ✅ | #486; a hosted plugin's own editor, else its plugin window | | Command palette (Search) | ✅ | Over every command | @@ -69,6 +69,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Plugin parameters: Alt/Option-click returns one to its default. Readiness estimate unchanged | | 2026-10-24 | minor | Plugin parameter sliders: Ctrl/Cmd-drag moves them a tenth as far. Readiness estimate unchanged | | 2026-10-24 | minor | Strip Silence sliders: Ctrl/Cmd-drag moves them a tenth as far. Readiness estimate unchanged | | 2026-10-16 | minor | Track name menu: Scroll Into View brings the track to the top of the Edit window and the left of the Mix. Readiness estimate unchanged | From 6287acf019c9dbdec3c8a7f696766cfbb66ad743 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 08:43:08 -0700 Subject: [PATCH 07/19] Plugins: FineSlider::default_value describes what a held Alt does 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. An Alt-click on the rail sets the default. --- crates/ui-egui/src/widgets.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e0fa9834b..c037a8da9 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -462,8 +462,8 @@ impl<'a> FineSlider<'a> { } /// 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 and a click on the rail leave the - /// value where it is, and the click does not open the value box for typing. + /// 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 From 92a5ce9a29e12e7f65a17cdeebf68ba8bbe4076a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 13:19:11 -0700 Subject: [PATCH 08/19] Dialogs: Enter on a focused Cancel or Don't Save no longer runs OK Enter on a focused button clicks it and also presses the dialog's OK. In Strip Silence, Enter on Cancel closed the dialog and still stripped the selected clips. Cancel and Escape now drop the command and the save that OK asked for, in every dialog. In Unsaved Changes, Enter on a focused Don't Save closes without saving instead of opening Save. --- crates/ui-egui/src/dialogs.rs | 10 +++--- crates/ui-egui/tests/it/save_changes.rs | 31 +++++++++++++++++++ .../ui-egui/tests/it/strip_silence_dialog.rs | 11 +++++++ 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ad796d107..ca414400a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -814,7 +814,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { if ui.button("Don't Save").clicked() { action = Some(then.clone()); } - if buttons(ui, "Save", enter) { + // Enter on a focused Don't Save clicks it, so Enter presses Save only when it was not clicked. + if buttons(ui, "Save", enter) && action.is_none() { save_first = Some(then.clone()); } }); @@ -822,16 +823,15 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } } }); + // Enter on a focused Cancel also presses OK, so Cancel drops the command and the save that OK asked for. if esc || ctx.memory(|m| m.data.get_temp::(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::(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 { diff --git a/crates/ui-egui/tests/it/save_changes.rs b/crates/ui-egui/tests/it/save_changes.rs index caa0561de..4d3423d5a 100644 --- a/crates/ui-egui/tests/it/save_changes.rs +++ b/crates/ui-egui/tests/it/save_changes.rs @@ -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 { soundcraft_engine::Engine::default().session().to_json().unwrap().into_bytes() diff --git a/crates/ui-egui/tests/it/strip_silence_dialog.rs b/crates/ui-egui/tests/it/strip_silence_dialog.rs index 40c06190c..ff7e19098 100644 --- a/crates/ui-egui/tests/it/strip_silence_dialog.rs +++ b/crates/ui-egui/tests/it/strip_silence_dialog.rs @@ -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(); From bf62aa6db7d220ae6f9ecaa6254bb5f5d67ae2d2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 16:07:41 -0700 Subject: [PATCH 09/19] Sends: Ctrl or Command gives fine adjustment on a send's level, its sliders and the pan knobs A vertical drag on a send's level moved it finely only with Shift held, and the Level and Pan sliders of a send's options menu and the pan knobs had no fine adjustment. Ctrl (Command on a Mac) now gives the send level drag the same fine step as Shift, and Shift keeps working. The options menu's Level and Pan sliders use widgets::FineSlider, so they move a tenth as far while Ctrl or Command is held. FineSlider gains a suffix option so the Level box keeps its " dB". widgets::pan_knob turns a tenth as far while Ctrl or Command is held, in the Send window and on the tracks. --- ROADMAP.md | 4 +- crates/ui-egui/src/mix_window.rs | 19 ++- crates/ui-egui/src/widgets.rs | 18 +- crates/ui-egui/tests/it/main.rs | 1 + .../ui-egui/tests/it/send_fine_adjustment.rs | 159 ++++++++++++++++++ docs/ui-parity.md | 5 +- 6 files changed, 193 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/tests/it/send_fine_adjustment.rs diff --git a/ROADMAP.md b/ROADMAP.md index 31a626b60..737323846 100644 --- a/ROADMAP.md +++ b/ROADMAP.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-24 · **Change:** trivial (Alt-click returns a plugin parameter to its default) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (fine adjustment of sends) · **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 @@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log +- **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. @@ -177,6 +178,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 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 | diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index cacf4699b..15efd72c1 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -1018,13 +1018,15 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us if level_resp.dragged() { // Use this frame's pointer movement. `Response::drag_delta()` is cumulative, so // applying it to the already-updated value made trackpad adjustment accelerate. - let (dy, fine) = ui.input(|i| (i.pointer.delta().y, i.modifiers.shift)); + // Ctrl (Command on a Mac) gives fine control, as on a fader, and so does Shift. + let (dy, fine) = ui.input(|i| (i.pointer.delta().y, i.modifiers.shift || i.modifiers.command || i.modifiers.ctrl)); let db = dragged_send_db(s.level_db, dy, fine); let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0)); } + let fine_drag = if ui.ctx().os().is_mac() { "⌘-drag" } else { "Ctrl-drag" }; let _ = level_resp.clone().on_hover_text(format!( - "Send {which} to {name}: {} dB\nClick for the send fader · drag vertically to adjust · Shift-drag for fine control · Alt-click for 0 dB · right-click for options", + "Send {which} to {name}: {} dB\nClick for the send fader · drag vertically to adjust · {fine_drag} or Shift-drag for fine control · Alt-click for 0 dB · right-click for options", db_text(s.level_db) )); level_resp.context_menu(|ui| send_options_menu(app, ui, id, slot, s)); @@ -1151,8 +1153,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); @@ -1170,8 +1175,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| { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c037a8da9..b2752bf23 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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; @@ -454,11 +456,18 @@ pub struct FineSlider<'a> { logarithmic: bool, integer: bool, default: Option, + suffix: &'a str, } impl<'a> FineSlider<'a> { pub fn new(value: &'a mut f64, range: std::ops::RangeInclusive) -> Self { - Self { value, range, text: "", id_salt: None, show_value: true, logarithmic: false, integer: false, default: None } + 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, @@ -503,7 +512,7 @@ impl<'a> FineSlider<'a> { } pub fn show(self, ui: &mut Ui) -> Response { - let Self { value, range, text, id_salt, show_value, logarithmic, integer, default } = self; + 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); @@ -550,6 +559,7 @@ impl<'a> FineSlider<'a> { .show_value(show_value) .logarithmic(log) .min_decimals(decimals) + .suffix(suffix) .text(text); if integer { slider = slider.integer(); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 71e9fde59..b514df184 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -44,6 +44,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; diff --git a/crates/ui-egui/tests/it/send_fine_adjustment.rs b/crates/ui-egui/tests/it/send_fine_adjustment.rs new file mode 100644 index 000000000..0df759811 --- /dev/null +++ b/crates/ui-egui/tests/it/send_fine_adjustment.rs @@ -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) -> 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) { + 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, modifiers: Modifiers) { + h.event(Event::ModifiersChanged(modifiers)); + h.run_steps(1); +} + +fn press(h: &mut Harness, 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, 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, from: Pos2, by: egui::Vec2, modifiers: Modifiers, read: fn(&Harness) -> 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) -> f32 { + send(h).level_db +} + +fn pan(h: &Harness) -> 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) { + 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) -> 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) -> 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) -> 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:?}"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e959938b1..a42201f55 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-24 · **Change:** minor (Alt-click returns a plugin parameter to its default) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-25 · **Change:** minor (fine adjustment of sends) · **Target:** Avid Pro Tools Ultimate 2026.4.1 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command @@ -62,13 +62,14 @@ 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 | 🟡 | Ctrl/Cmd-drag moves the faders, the Strip Silence sliders and plugin parameter sliders a tenth as far | +| Fader resolution, fine adjust, numeric entry | 🟡 | Ctrl/Cmd-drag moves the faders, the Strip Silence sliders, plugin parameter sliders, a send's Level and Pan sliders and the pan knobs a tenth as far, and a send's level as finely as Shift-drag | | macOS system menu bar | ✅ | #10 | ## Revision history | Date | Change | Summary | |---|---|---| +| 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 | From 84e591e1ca56eb24ea5219739f5416c53c3f5b6b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 17:05:28 -0700 Subject: [PATCH 10/19] Zoom Toggle: Alt+Shift+E and Alt-click cancel it and keep the current view Cancel Zoom Toggle (view.zoom_toggle_cancel, Alt+Shift+E, Option+Shift+E on a Mac) turns Zoom Toggle off without going back to the view it stored: the zoom, the horizontal and vertical scroll and the track heights stay as they are. An Alt-click (Option-click) on the Zoom Toggle button runs the same command. The next Zoom Toggle zooms in from the current view and goes back to it. When the command runs, from a key, the button or the control channel, the Edit window clears the vertical scroll position that Zoom Toggle would restore. --- ROADMAP.md | 4 ++- crates/engine/src/cmd/zoom_toggle.rs | 32 ++++++++++++++++-- crates/ui-egui/src/edit_window.rs | 12 ++++++- crates/ui-egui/src/lib.rs | 3 ++ crates/ui-egui/tests/it/zoom_toggle.rs | 45 +++++++++++++++++++++++++- docs/ui-parity.md | 4 ++- 6 files changed, 94 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index cc80cc784..8eac794f3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows) -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (Cancel Zoom Toggle, 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 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 @@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log +- **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-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. @@ -175,6 +176,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 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-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Strip Silence rectangles; readiness estimates unchanged | diff --git a/crates/engine/src/cmd/zoom_toggle.rs b/crates/engine/src/cmd/zoom_toggle.rs index 247817597..2735133d9 100644 --- a/crates/engine/src/cmd/zoom_toggle.rs +++ b/crates/engine/src/cmd/zoom_toggle.rs @@ -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 { - 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 { + 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 { @@ -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})); diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 1f25fb6f3..749bfada8 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -78,6 +78,11 @@ 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)); } + + /// Clears the vertical scroll position Zoom Toggle would restore, so Cancel Zoom Toggle keeps the current scroll. + pub(crate) fn forget_zoom_back(&mut self) { + self.zoom_back = None; + } } pub fn rect([x0, y0, x1, y1]: [f32; 4]) -> Rect { @@ -469,8 +474,13 @@ fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) { } } -/// Zoom Toggle sized to the track area on screen. +/// Zoom Toggle sized to the track area on screen. With Alt held (an Alt-click on the button) it cancels Zoom Toggle +/// instead and keeps the current view. 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})); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7b564f92e..91694a07c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -597,6 +597,9 @@ impl SoundApp { // Re-selecting a scrolling mode resumes the playhead follow. self.edit_layout.follow_hold = false; } + if id == "view.zoom_toggle_cancel" { + self.edit_layout.forget_zoom_back(); + } if id == "track.clear_clip_indicators" { self.meters.values_mut().chain([&mut self.main_meter]).for_each(|m| m.clip = false); } diff --git a/crates/ui-egui/tests/it/zoom_toggle.rs b/crates/ui-egui/tests/it/zoom_toggle.rs index d3badcff4..2011c8128 100644 --- a/crates/ui-egui/tests/it/zoom_toggle.rs +++ b/crates/ui-egui/tests/it/zoom_toggle.rs @@ -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}"); + } + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f7d06d576..0778d0b18 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-16 · **Change:** minor (Scroll Into View in the track name menu) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-25 · **Change:** minor (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 | @@ -69,6 +70,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---| +| 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-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) | From 817623090a0c7a90bd24a512edf8dd34258cfb72 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 08:10:01 -0700 Subject: [PATCH 11/19] Read send destinations only while the menu is open --- crates/ui-egui/src/mix_window/sends.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/mix_window/sends.rs b/crates/ui-egui/src/mix_window/sends.rs index b1486087f..570c681ac 100644 --- a/crates/ui-egui/src/mix_window/sends.rs +++ b/crates/ui-egui/src/mix_window/sends.rs @@ -330,7 +330,6 @@ enum SendFooter { /// that scrolls, "new bus..." (and "Remove send" for an existing send) fixed under it. /// `current` is the send's destination and its name, if the slot has a send. fn send_route_menu(app: &mut SoundApp, ui: &Ui, field: &egui::Response, id: TrackId, slot: usize, current: Option<(Route, String)>) { - let busses = app.engine.session().busses.clone(); let which = (b'A' + slot as u8) as char; let has_send = current.is_some(); let header = |ui: &mut Ui| { @@ -350,6 +349,7 @@ fn send_route_menu(app: &mut SoundApp, ui: &Ui, field: &egui::Response, id: Trac } } let search = MenuSearch::show(ui, egui::Id::unique(("send-bus-search", id.0, slot)), "Search buses…"); + let busses = &app.engine.session().busses; let visible: Vec<_> = busses.iter().filter(|bus| search.matches(&bus.name)).collect(); (search, visible) }; @@ -388,6 +388,7 @@ fn send_route_menu(app: &mut SoundApp, ui: &Ui, field: &egui::Response, id: Trac } match footer { Some(SendFooter::NewBus) => { + let busses = &app.engine.session().busses; let mut n = busses.len() + 1; while busses.iter().any(|bus| bus.name.eq_ignore_ascii_case(&format!("Bus {n}"))) || app.engine.session().track_by_name(&format!("Bus {n}")).is_some() From 4534c0cecc5b8d7b0f595ef15679215c52dc404d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 01:02:50 -0700 Subject: [PATCH 12/19] Tests: follow main's PathBuf pickers and FileFormat in the focused-button tests --- crates/ui-egui/tests/it/focused_dialog_buttons.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/tests/it/focused_dialog_buttons.rs b/crates/ui-egui/tests/it/focused_dialog_buttons.rs index f58c0e3cf..f48e6bd6b 100644 --- a/crates/ui-egui/tests/it/focused_dialog_buttons.rs +++ b/crates/ui-egui/tests/it/focused_dialog_buttons.rs @@ -1,10 +1,13 @@ //! 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 std::path::PathBuf; + use egui::accesskit::Role; use egui::{Event, Key, Modifiers, vec2}; use egui_kittest::{Harness, kittest::Queryable}; use serde_json::json; +use soundcraft_audio_io::FileFormat; use soundcraft_ui_egui::dialogs::Dialog; use soundcraft_ui_egui::{Services, SoundApp, menus}; @@ -63,7 +66,7 @@ fn enter_on_a_focused_dont_save_closes_without_saving() { #[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 services = Services { pick_open: Some(Box::new(move |_, _| Some(PathBuf::from(picked)))), ..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); @@ -75,12 +78,12 @@ fn enter_on_a_focused_browse_browses_without_pressing_ok() { #[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 services = Services { pick_save: Some(Box::new(move |_, _| Some(PathBuf::from(picked)))), ..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")); + assert!(matches!(&h.state().dialogs.open, Some(Dialog::Bounce { path, format, .. }) if path == picked && *format == FileFormat::Flac)); } #[test] From dcdcca47049238c467ba995d0adb99ec0cc70003 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 03:31:06 -0700 Subject: [PATCH 13/19] Zoom Toggle: a cancel with Zoom Toggle off keeps the scroll to restore Cancel Zoom Toggle clears the saved vertical scroll only when it turns Zoom Toggle off, so a cancel run before the next frame after turning it off no longer drops the scroll position the toggle-off restores. --- crates/ui-egui/src/app/commands.rs | 2 +- crates/ui-egui/src/edit_window/tests.rs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index b569f0f2a..49b2da008 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -30,7 +30,7 @@ impl SoundApp { // Re-selecting a scrolling mode resumes the playhead follow. self.edit_layout.follow_hold = false; } - if id == "view.zoom_toggle_cancel" { + if id == "view.zoom_toggle_cancel" && v["canceled"] == true { self.edit_layout.forget_zoom_back(); } if id == "track.clear_clip_indicators" { diff --git a/crates/ui-egui/src/edit_window/tests.rs b/crates/ui-egui/src/edit_window/tests.rs index f7f94bbe0..3de95f6c8 100644 --- a/crates/ui-egui/src/edit_window/tests.rs +++ b/crates/ui-egui/src/edit_window/tests.rs @@ -246,6 +246,16 @@ fn hold_clears_when_playhead_returns_or_transport_stops() { ); } +/// Cancel Zoom Toggle with Zoom Toggle already off keeps the scroll position a pending toggle-off restores. +#[test] +fn a_cancel_with_zoom_toggle_off_keeps_the_scroll_to_restore() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, crate::Services::default()); + app.edit_layout.zoom_back = Some(120.0); + let v = app.run("view.zoom_toggle_cancel", json!({})).unwrap(); + assert_eq!(v["canceled"], json!(false)); + assert_eq!(app.edit_layout.zoom_back, Some(120.0)); +} + fn harness(n: usize) -> Harness<'static, SoundApp> { let mut engine = soundcraft_engine::Engine::default(); engine.execute("track.new", &json!({"count": n})).unwrap(); From 38694924aa47a2e48df6e659dec3bf1a7ffc0add Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 03:38:39 -0700 Subject: [PATCH 14/19] Tests: pressing Ctrl during a value-box drag keeps the value The rest of the drag on the Threshold value box moves as far as a drag with Ctrl held from the start. --- .../ui-egui/tests/it/strip_silence_sliders.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/ui-egui/tests/it/strip_silence_sliders.rs b/crates/ui-egui/tests/it/strip_silence_sliders.rs index df1f98aaa..e89982691 100644 --- a/crates/ui-egui/tests/it/strip_silence_sliders.rs +++ b/crates/ui-egui/tests/it/strip_silence_sliders.rs @@ -127,3 +127,28 @@ fn pressing_and_releasing_ctrl_during_a_drag_keeps_the_value() { 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"); } + +#[test] +fn pressing_ctrl_during_a_drag_on_the_value_box_keeps_the_value() { + let mut h = strip_silence_dialog(); + let label = "Threshold dB"; + let plain = drag_on(&mut h, Role::SpinButton, label, 30.0, Modifiers::NONE); + let held = drag_on(&mut h, Role::SpinButton, label, 30.0, Modifiers::COMMAND); + assert!(held < plain / 2.0, "a Ctrl-drag on the value box moved {held}, a plain drag {plain}"); + let from = h.get_by_role_and_label(Role::SpinButton, 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; + // The rest of the drag moves as far as a drag with Ctrl held from the start. + assert!((0.8..=1.2).contains(&(fine / held)), "after Ctrl, the value box moved {fine}; a Ctrl-drag moves {held}"); + press(&mut h, from + vec2(40.0, 0.0), true, Modifiers::COMMAND); + press(&mut h, from + vec2(40.0, 0.0), false, Modifiers::COMMAND); + hold(&mut h, Modifiers::NONE); +} From 245523a866eae86642ecb40091fcdd50cd20a12a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 03:38:41 -0700 Subject: [PATCH 15/19] Bounce Mix: Cancel starts no bounce; fine adjust on more AudioSuite sliders Enter on a focused Cancel also pressed Bounce, which started the bounce before the Cancel check ran. The bounce now starts after that check. The Normalize, Time Shift and Pitch Shift sliders in the AudioSuite window move a tenth as far while Ctrl or Command is held. --- crates/ui-egui/src/dialogs.rs | 12 +++++++---- crates/ui-egui/src/dialogs/audiosuite.rs | 12 ++++++++--- crates/ui-egui/tests/it/bounce.rs | 21 +++++++++++++++++++ .../ui-egui/tests/it/plugin_fine_sliders.rs | 17 +++++++++++++++ 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 191ff6aae..1badb62c1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -290,6 +290,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { let mut keep = true; let mut action: Option<(String, Value)> = None; let mut save_first: Option<(String, Value)> = None; + let mut bounce: Option = None; let confirmed = matches!(d, Dialog::SaveChanges { .. }); // Strip Silence's Rename button, and the naming scheme its dialog's OK keeps. let mut rename_from: Option<[f64; 4]> = None; @@ -430,9 +431,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { ); if buttons(ui, "Bounce", enter) { let p = std::path::Path::new(path.as_str()).with_extension(format.default_extension()); - crate::bounce::start( - app, - &json!({"path": p.to_string_lossy(), "format": format.default_extension(), "bit_depth": bit_depth, "normalize": normalize, "dither": *dither && bit_depth.as_str() != "32f"}), + bounce = Some( + json!({"path": p.to_string_lossy(), "format": format.default_extension(), "bit_depth": bit_depth, "normalize": normalize, "dither": *dither && bit_depth.as_str() != "32f"}), ); keep = false; } @@ -711,15 +711,19 @@ 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. + // Enter on a focused Cancel also presses OK, so Cancel drops the command, the bounce and the save that OK asked for. if esc || ctx.memory(|m| m.data.get_temp::(egui::Id::unique("dlg_cancel")).unwrap_or(false)) { keep = false; action = None; save_first = None; + bounce = None; app.dialogs.after_save = None; app.dialogs.pending_session = None; ctx.memory_mut(|m| m.data.remove::(egui::Id::unique("dlg_cancel"))); } + if let Some(params) = bounce { + crate::bounce::start(app, ¶ms); + } if let Some(strip) = rename_from && keep { diff --git a/crates/ui-egui/src/dialogs/audiosuite.rs b/crates/ui-egui/src/dialogs/audiosuite.rs index 244358c5a..828a2bab3 100644 --- a/crates/ui-egui/src/dialogs/audiosuite.rs +++ b/crates/ui-egui/src/dialogs/audiosuite.rs @@ -26,15 +26,21 @@ pub(super) fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { 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::fine_slider(ui, &mut wide, -24.0..=0.0, "Peak dB"); + *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::fine_slider(ui, &mut wide, 0.25..=4.0, "Length ratio"); + *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::fine_slider(ui, &mut wide, -24.0..=24.0, "Semitones"); + *v = wide as f32; } "speed_ramp" => { for (id, label, default, choices) in SPEED_RAMP_CHOICES { diff --git a/crates/ui-egui/tests/it/bounce.rs b/crates/ui-egui/tests/it/bounce.rs index 77e73fff0..658b55f01 100644 --- a/crates/ui-egui/tests/it/bounce.rs +++ b/crates/ui-egui/tests/it/bounce.rs @@ -39,6 +39,27 @@ fn bounce_from_dialog(h: &mut Harness<'_, SoundApp>, file: &Path) { assert!(h.query_by_label("Bounce Mix").is_none()); } +#[test] +fn enter_on_a_focused_cancel_closes_the_dialog_and_starts_no_bounce() { + let dir = scratch("bounce-cancel"); + let file = dir.join("Mix.wav"); + let mut h = harness(); + let app = h.state_mut(); + assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix")); + if let Some(Dialog::Bounce { path, .. }) = &mut app.dialogs.open { + *path = file.to_string_lossy().into_owned(); + } + h.run_steps(2); + h.get_by_label("Cancel").focus(); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + assert!(h.state().dialogs.open.is_none()); + assert!(h.state().bounce.is_none(), "Cancel started a bounce"); + assert!(!file.exists()); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn a_bounce_renders_across_frames_with_progress_until_its_file_is_written() { let dir = scratch("bounce-frames"); diff --git a/crates/ui-egui/tests/it/plugin_fine_sliders.rs b/crates/ui-egui/tests/it/plugin_fine_sliders.rs index dc98f6036..4939f5434 100644 --- a/crates/ui-egui/tests/it/plugin_fine_sliders.rs +++ b/crates/ui-egui/tests/it/plugin_fine_sliders.rs @@ -172,3 +172,20 @@ fn audiosuite_parameter_sliders_move_a_tenth_as_far_with_ctrl_held() { assert_tenth(label, plain_and_fine(&mut h, rail, &read), 0.08..=0.12); } } + +#[test] +fn normalize_time_shift_and_pitch_shift_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 = move |h: &Harness<'_, SoundApp>| { + let params: BTreeMap = 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); + } +} From 633726f56209730913671a2209f4b5d6456fa8cb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 03:51:14 -0700 Subject: [PATCH 16/19] Dialogs: Enter presses the default button after the dialog opens A dialog clears the keyboard focus on the first frame it is drawn, so a button in the main window that still had the focus no longer takes the Enter meant for the dialog. Enter in that first frame, which opened the dialog from a menu item or the command palette, no longer presses its default button. In the Unloop dialog, a Flatten click in the same frame as Enter flattens instead of removing. --- crates/ui-egui/src/dialogs.rs | 18 +++++++-- .../tests/it/focused_dialog_buttons.rs | 39 ++++++++++++++++++- crates/ui-egui/tests/it/unloop_dialog.rs | 39 ++++++++++++++++++- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index dd4281c7e..9ea2955a3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -81,6 +81,8 @@ pub struct Dialogs { pub strip_names: StripNames, /// The Strip Silence rectangles the Edit window draws while Strip Silence is open. strip_rectangles: StripRectangles, + /// True while a dialog was drawn on the previous frame. + shown: bool, } impl Dialogs { @@ -296,7 +298,16 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { audiosuite_window(app, ctx); crate::bounce::show(app, ctx); crate::import::show(app, ctx); - let Some(mut d) = app.dialogs.open.take() else { return }; + let Some(mut d) = app.dialogs.open.take() else { + app.dialogs.shown = false; + return; + }; + // On the first frame a dialog is drawn, clear the keyboard focus a widget outside it may still hold, and ignore Enter: + // an Enter in that frame is the one that opened the dialog from a menu item or the command palette. + let first_frame = !std::mem::replace(&mut app.dialogs.shown, true); + if first_frame { + ctx.memory_mut(|m| m.stop_text_input()); + } let mut keep = true; let mut action: Option<(String, Value)> = None; let mut save_first: Option<(String, Value)> = None; @@ -331,7 +342,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { .to_string(); // 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 enter = !first_frame && 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) @@ -483,7 +494,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { if ui.button("Flatten").clicked() { action = Some(unloop_action(clips, false)); } - if buttons(ui, "Remove", enter) { + // A Flatten click in the same frame as Enter wins. + if buttons(ui, "Remove", enter && action.is_none()) { action = Some(unloop_action(clips, true)); } }); diff --git a/crates/ui-egui/tests/it/focused_dialog_buttons.rs b/crates/ui-egui/tests/it/focused_dialog_buttons.rs index f48e6bd6b..c7f7a1c18 100644 --- a/crates/ui-egui/tests/it/focused_dialog_buttons.rs +++ b/crates/ui-egui/tests/it/focused_dialog_buttons.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use egui::accesskit::Role; -use egui::{Event, Key, Modifiers, vec2}; +use egui::{Event, Key, Modifiers, Pos2, RawInput, Rect, vec2}; use egui_kittest::{Harness, kittest::Queryable}; use serde_json::json; use soundcraft_audio_io::FileFormat; @@ -117,6 +117,43 @@ fn enter_with_nothing_focused_presses_the_default_button() { assert_eq!(h.state().dialogs.open_name(), None); } +#[test] +fn a_dialog_opened_while_a_window_button_has_the_focus_takes_enter() { + let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let mut h = harness(app); + h.get_all_by_role(Role::Button).next().expect("the window has a button").focus(); + h.run_steps(2); + assert!(h.ctx.memory(|m| m.focused().is_some())); + menus::invoke_menu(h.state_mut(), "edit.fades_create", "Edit > Fades > Create..."); + h.run_steps(2); + 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, "Enter pressed Create"); +} + +#[test] +fn the_enter_that_opens_a_dialog_does_not_press_its_default_button() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let ctx = egui::Context::default(); + let frame = |app: &mut SoundApp, events: Vec| { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); + }; + let enter = || Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + frame(&mut app, vec![]); + menus::invoke_menu(&mut app, "edit.fades_create", "Edit > Fades > Create..."); + frame(&mut app, vec![enter()]); + assert_eq!(app.dialogs.open_name(), Some("fades"), "the opening Enter leaves the dialog open"); + frame(&mut app, vec![enter()]); + assert_eq!(app.dialogs.open_name(), None, "the next Enter presses Create"); +} + #[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()); diff --git a/crates/ui-egui/tests/it/unloop_dialog.rs b/crates/ui-egui/tests/it/unloop_dialog.rs index 56bc192a7..7a17bd264 100644 --- a/crates/ui-egui/tests/it/unloop_dialog.rs +++ b/crates/ui-egui/tests/it/unloop_dialog.rs @@ -1,12 +1,12 @@ use egui::accesskit::Role; -use egui::{Event, Key, Modifiers, vec2}; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, 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) { +fn unloop_app() -> (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; @@ -15,6 +15,12 @@ fn unloop_dialog() -> (Harness<'static, SoundApp>, TrackId, usize) { 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")); + (app, track, looped) +} + +/// `unloop_app` in a test harness. +fn unloop_dialog() -> (Harness<'static, SoundApp>, TrackId, usize) { + let (app, track, looped) = unloop_app(); let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( |ui, app: &mut SoundApp| { let ctx = ui.ctx().clone(); @@ -52,6 +58,35 @@ fn enter_on_a_focused_flatten_flattens() { assert_eq!(clips(&h, track), vec![None; looped]); } +#[test] +fn a_flatten_click_with_enter_in_the_same_frame_flattens() { + let (h, _, _) = unloop_dialog(); + let pos = h.get_by_role_and_label(Role::Button, "Flatten").rect().center(); + // The harness sends a pointer release and a key press in separate frames, so the frames are run by hand. + let (mut app, track, looped) = unloop_app(); + let ctx = egui::Context::default(); + let frame = |app: &mut SoundApp, events: Vec| { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); + }; + let button = |pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + for _ in 0..3 { + frame(&mut app, vec![]); + } + frame(&mut app, vec![Event::PointerMoved(pos)]); + frame(&mut app, vec![button(true)]); + let enter = Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + frame(&mut app, vec![button(false), enter]); + assert_eq!(app.dialogs.open_name(), None); + let loops: Vec<_> = app.engine.session().track(track).unwrap().clips().iter().map(|c| c.loop_id).collect(); + assert_eq!(loops, vec![None; looped]); +} + #[test] fn unloop_dialogs_remove_keeps_only_the_source_clip() { let (mut h, track, looped) = unloop_dialog(); From 30058888c7a47d64fd30148db19dcf86aa435ae7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 07:31:19 -0700 Subject: [PATCH 17/19] Tests: give the Enter-on-Cancel bounce test its own scratch folder It shared bounce-cancel with cancel_or_escape_stops_a_bounce_and_leaves_no_file, so the two tests removed each other's folder when run in parallel. --- crates/ui-egui/tests/it/bounce.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/it/bounce.rs b/crates/ui-egui/tests/it/bounce.rs index 658b55f01..b06e0be82 100644 --- a/crates/ui-egui/tests/it/bounce.rs +++ b/crates/ui-egui/tests/it/bounce.rs @@ -41,7 +41,7 @@ fn bounce_from_dialog(h: &mut Harness<'_, SoundApp>, file: &Path) { #[test] fn enter_on_a_focused_cancel_closes_the_dialog_and_starts_no_bounce() { - let dir = scratch("bounce-cancel"); + let dir = scratch("bounce-enter-cancel"); let file = dir.join("Mix.wav"); let mut h = harness(); let app = h.state_mut(); From e83295d8d2fd7f3418b64c5fafbbcb3699b43874 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 08:01:22 -0700 Subject: [PATCH 18/19] Dialogs: a replaced dialog skips the opening Enter, and a held Enter does not press the default A dialog opened in place of another one, or the save prompt opened from a dialog, now gets the same first frame as a dialog opened when none was open: the keyboard focus is cleared and that frame's Enter is ignored. Enter presses the default button only on a new key press, so the repeats of an Enter held down from the menu or palette do not press it. --- crates/ui-egui/src/dialogs.rs | 22 +++++-- crates/ui-egui/src/dialogs/files.rs | 2 +- .../tests/it/focused_dialog_buttons.rs | 65 +++++++++++++++---- 3 files changed, 69 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9ea2955a3..667111551 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -147,17 +147,24 @@ impl Dialogs { /// Clip List › Clear: confirm before clips and files leave the session. False when there is nothing to clear. pub fn open_clear(&mut self, e: &Engine, clips: Vec, sources: Vec) -> bool { let Some(message) = clear_message(e.session(), &clips, &sources) else { return false }; - self.open = Some(Dialog::ClearClips { clips, sources, message }); + self.set(Dialog::ClearClips { clips, sources, message }); true } + /// Opens `d` in place of any open dialog. Its first frame clears the keyboard focus and ignores Enter, as for a + /// dialog opened when none was open. + fn set(&mut self, d: Dialog) { + self.open = Some(d); + self.shown = false; + } + /// Clip List › Auto Rename: the clips' naming scheme, filled in from the first clip's. False /// when none of the clips is in the session. pub fn open_auto_rename(&mut self, e: &Engine, clips: Vec) -> bool { let s = e.session(); let Some((_, first)) = clips.iter().find_map(|id| s.find_clip(*id)) else { return false }; let names = first.auto_name.and_then(|k| s.auto_names.get(&k)).map_or_else(StripNames::default, StripNames::from); - self.open = Some(Dialog::AutoRename { clips, names }); + self.set(Dialog::AutoRename { clips, names }); true } @@ -166,7 +173,7 @@ impl Dialogs { self.after_save = None; let home = default_dir(); let name = e.session().name.clone(); - self.open = Some(match id { + let d = match id { "clip.clear" => { let ed = &e.session().edit; return self.open_clear(e, ed.selected_clips.clone(), ed.selected_sources.clone()); @@ -253,7 +260,8 @@ impl Dialogs { Some(d) => d, None => return false, }, - }); + }; + self.set(d); true } } @@ -341,8 +349,10 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } .to_string(); // 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 = !first_frame && ctx.input(|i| i.key_pressed(egui::Key::Enter)) && (ctx.memory(|m| m.focused().is_none()) || ctx.text_edit_focused()); + // button only when no widget has the keyboard focus or a text field has it. A held Enter's repeats do not press it. + let fresh_enter = + |i: &egui::InputState| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::Enter, pressed: true, repeat: false, .. })); + let enter = !first_frame && ctx.input(fresh_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) diff --git a/crates/ui-egui/src/dialogs/files.rs b/crates/ui-egui/src/dialogs/files.rs index a2d8d0a0e..5fba30059 100644 --- a/crates/ui-egui/src/dialogs/files.rs +++ b/crates/ui-egui/src/dialogs/files.rs @@ -14,7 +14,7 @@ pub fn ask_to_save(app: &mut SoundApp, id: &str, params: &Value) -> bool { if !app.engine.is_dirty() { return false; } - app.dialogs.open = Some(Dialog::SaveChanges { then: (id.into(), params.clone()) }); + app.dialogs.set(Dialog::SaveChanges { then: (id.into(), params.clone()) }); true } diff --git a/crates/ui-egui/tests/it/focused_dialog_buttons.rs b/crates/ui-egui/tests/it/focused_dialog_buttons.rs index c7f7a1c18..091c40c23 100644 --- a/crates/ui-egui/tests/it/focused_dialog_buttons.rs +++ b/crates/ui-egui/tests/it/focused_dialog_buttons.rs @@ -136,24 +136,63 @@ fn a_dialog_opened_while_a_window_button_has_the_focus_takes_enter() { fn the_enter_that_opens_a_dialog_does_not_press_its_default_button() { let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); let ctx = egui::Context::default(); - let frame = |app: &mut SoundApp, events: Vec| { - let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; - ctx.run_ui(raw, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }) - .textures_delta - .clear(); - }; - let enter = || Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; - frame(&mut app, vec![]); + run_frame(&ctx, &mut app, vec![]); menus::invoke_menu(&mut app, "edit.fades_create", "Edit > Fades > Create..."); - frame(&mut app, vec![enter()]); + run_frame(&ctx, &mut app, vec![enter_key(false), enter_up()]); assert_eq!(app.dialogs.open_name(), Some("fades"), "the opening Enter leaves the dialog open"); - frame(&mut app, vec![enter()]); + run_frame(&ctx, &mut app, vec![enter_key(false)]); assert_eq!(app.dialogs.open_name(), None, "the next Enter presses Create"); } +/// Runs one frame of `app` with `events`. +fn run_frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec) { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); +} + +fn enter_key(repeat: bool) -> Event { + Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat, modifiers: Modifiers::NONE } +} + +fn enter_up() -> Event { + Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE } +} + +#[test] +fn the_enter_that_replaces_an_open_dialog_does_not_press_the_new_default_button() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let ctx = egui::Context::default(); + menus::invoke_menu(&mut app, "setup.session", "Setup > Session"); + run_frame(&ctx, &mut app, vec![]); + run_frame(&ctx, &mut app, vec![]); + assert_eq!(app.dialogs.open_name(), Some("session")); + menus::invoke_menu(&mut app, "edit.fades_create", "Edit > Fades > Create..."); + run_frame(&ctx, &mut app, vec![enter_key(false), enter_up()]); + assert_eq!(app.dialogs.open_name(), Some("fades"), "the opening Enter leaves the new dialog open"); + run_frame(&ctx, &mut app, vec![enter_key(false)]); + assert_eq!(app.dialogs.open_name(), None, "the next Enter presses Create"); +} + +#[test] +fn a_held_enter_does_not_press_the_default_button_until_it_is_pressed_again() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let ctx = egui::Context::default(); + run_frame(&ctx, &mut app, vec![]); + menus::invoke_menu(&mut app, "edit.fades_create", "Edit > Fades > Create..."); + run_frame(&ctx, &mut app, vec![enter_key(false)]); + run_frame(&ctx, &mut app, vec![enter_key(true)]); + run_frame(&ctx, &mut app, vec![enter_key(true)]); + assert_eq!(app.dialogs.open_name(), Some("fades"), "the held Enter's repeats leave the dialog open"); + run_frame(&ctx, &mut app, vec![enter_up()]); + run_frame(&ctx, &mut app, vec![enter_key(false)]); + assert_eq!(app.dialogs.open_name(), None, "a new press of Enter presses Create"); +} + #[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()); From 1ed2c67050fe648799f37ebf7ae0da1db1346a51 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 15:47:59 -0700 Subject: [PATCH 19/19] Meters: import MeterHold in app/audio.rs after the explicit imports on main --- crates/ui-egui/src/app/audio.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/app/audio.rs b/crates/ui-egui/src/app/audio.rs index 2cafabde7..98995c266 100644 --- a/crates/ui-egui/src/app/audio.rs +++ b/crates/ui-egui/src/app/audio.rs @@ -1,4 +1,5 @@ use super::{MeterDisplay, OutputTrouble, Player, SoundApp, Trouble}; +use crate::{METER_HOLD_SECS, MeterHold}; impl SoundApp { /// Whether the meters are live: while playing, and while armed or input-monitored tracks are heard.