diff --git a/ROADMAP.md b/ROADMAP.md index 8ff79f111..c43ea9d9a 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 (side panels in dock columns, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-27 · **Change:** trivial (side panels in dock columns, Cancel Zoom Toggle, Auto Pan stereo balance, fine adjustment of sends, Alt-click returns a plugin parameter to its default, fine adjustment on plugin parameter sliders, Strip Silence fine adjustment, Unloop dialog, Peak Hold and Clip Indication options, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1 SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is @@ -123,7 +123,14 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log - **2026-10-25:** Tracks and Groups and the Clip List sit in dock columns that run the Edit window's full height at its left and right edges, with the MIDI Editor under the session view between them; Mix docks Tracks and Groups at its left edge. A panel dragged onto a dock group joins it as a tab, stacks above or below it, or makes a new column; one dragged to an empty window edge makes a dock there. A list with no saved place reopens in its dock, and makes the dock again when it is gone. Preferences and window configurations saved with the earlier split-frame layouts load with the new default layouts and keep their other settings. No readiness percentage change. +- **2026-10-25:** Alt+Shift+E (`view.zoom_toggle_cancel`) and an Alt-click on the Zoom Toggle button cancel Zoom Toggle and keep the current view: the zoom, the scroll and the track heights stay as they are, and the button is no longer lit. The next Zoom Toggle zooms in from that view and goes back to it. No readiness percentage change. - **2026-10-25:** Auto Pan moves the balance of a stereo track: the left channel's level falls as the position moves right and the right channel's as it moves left, at constant power, and each channel stays on its own side. At the center the track passes unchanged, so a wide or out-of-phase stereo image keeps its width, and the envelope follower follows the louder channel. No readiness percentage change. +- **2026-10-25:** A vertical drag on a send's level moves it as finely with Ctrl (Command on a Mac) held as with Shift, as a fader's Ctrl-drag does. The Level and Pan sliders of a send's options menu, the Send window's pan knob and the tracks' pan knobs move a tenth as far for the same drag while Ctrl (Command on a Mac) is held. No readiness percentage change. +- **2026-10-24:** An Alt-click (Option-click on a Mac) on a parameter in the plugin window, the Clip Effects window or an AudioSuite plugin's window returns it to its default value, on a slider, a slider's value box or a choice list, as an Alt-click returns a fader to 0 dB. In the plugin window and the Clip Effects window the reset is one undo step. While Alt is held, a drag on a slider leaves the value where it is. No readiness percentage change. +- **2026-10-24:** The parameter sliders of the plugin window, the Clip Effects window and AudioSuite plugins move a tenth as far for the same drag while Ctrl (Command on a Mac) is held, as the Strip Silence sliders do. Logarithmic sliders move a tenth as far along the slider, and whole-number sliders stay on whole numbers. No readiness percentage change. +- **2026-10-24:** The Strip Silence window's four sliders move a tenth as far for the same drag while Ctrl (Command on a Mac) is held, on the slider and on its value box. Pressing or releasing the key during a drag leaves the value where it is. The Threshold and Clip Start Pad boxes show one decimal place. No readiness percentage change. +- **2026-10-24:** Clip › Unloop… opens the Unloop dialog. Remove deletes the loop iterations and keeps the source clip, and Flatten keeps each loop iteration as its own clip. No readiness percentage change. +- **2026-10-24:** Setup › Metering… sets Peak Hold and Clip Indication to 3 Seconds, Infinite or None, as `ui.peak_hold` and `ui.clip_indication`, kept with the preferences. Meters held a peak for 2 s and kept a clip lit until cleared; the defaults are now 3 Seconds and Infinite. Track › Clear All Clip Indicators also clears held peaks. No readiness percentage change. - **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change. - **2026-10-24:** While the Strip Silence window is open, the Edit window draws a rectangle around each range that Strip would keep on the selected tracks, padded by Clip Start Pad and Clip End Pad, and the rectangles follow the sliders as they move. They are removed when the window closes. `edit.strip_silence_preview` returns the same ranges without changing the session. No readiness percentage change. - **2026-10-24:** The Strip Silence window has a Rename button. It opens Rename Selected Clips, where a name, a starting number, a count of zeros and a suffix set the names of the clips that Strip, Extract and Separate leave in the selection (for example SFX023.Reel1, SFX024.Reel1). The app keeps the scheme for later uses of Strip Silence until the app quits, or until Clear and then OK empty it. No readiness percentage change. @@ -177,7 +184,14 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| | 2026-10-25 | trivial | Progress log: side panels in dock columns; readiness estimates unchanged | +| 2026-10-25 | trivial | Progress log: Cancel Zoom Toggle; readiness estimates unchanged | | 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged | +| 2026-10-25 | trivial | Progress log: fine adjustment of sends; readiness estimates unchanged | +| 2026-10-24 | trivial | Progress log: Alt-click returns a plugin parameter to its default; readiness estimates unchanged | +| 2026-10-24 | trivial | Progress log: fine adjustment on plugin parameter sliders; readiness estimates unchanged | +| 2026-10-24 | trivial | Progress log: Strip Silence fine adjustment; readiness estimates unchanged | +| 2026-10-24 | trivial | Progress log: Unloop dialog; readiness estimates unchanged | +| 2026-10-24 | trivial | Progress log: Peak Hold and Clip Indication options; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Strip Silence rectangles; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Strip Silence Rename; readiness estimates unchanged | diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index b943c5c1f..32d3b7ff7 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -60,6 +60,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/engine/src/cmd/clip_more.rs b/crates/engine/src/cmd/clip_more.rs index a2ebd8b3e..6a73fb73a 100644 --- a/crates/engine/src/cmd/clip_more.rs +++ b/crates/engine/src/cmd/clip_more.rs @@ -150,6 +150,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/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/app.rs b/crates/ui-egui/src/app.rs index 35ca59747..e97b10837 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -14,6 +14,8 @@ mod input; mod playback; mod plugins; +#[cfg(test)] +pub(crate) use audio::feed_meter; #[cfg(test)] pub(crate) use playback::automation_step_due; #[cfg(test)] diff --git a/crates/ui-egui/src/app/audio.rs b/crates/ui-egui/src/app/audio.rs index 3ec928491..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. @@ -125,9 +126,12 @@ impl SoundApp { } } -pub(super) fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) { - // Instant attack, ~26 dB/s release, 2 s peak hold. +/// 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. +pub(crate) fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32, now: f64, holds: (MeterHold, MeterHold)) { + let (peak_hold, clip_indication) = holds; let fall = 10f32.powf(-26.0 * dt / 20.0); + let mut clipped = false; for c in 0..2 { let v = peak.get(c).copied().unwrap_or(0.0); let lvl = d.level.get(c).copied().unwrap_or(0.0); @@ -135,19 +139,35 @@ pub(super) fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) if let Some(x) = d.level.get_mut(c) { *x = if nv < 1e-5 { 0.0 } else { nv }; } - if v >= d.hold.get(c).copied().unwrap_or(0.0) { + if v > 0.0 && v >= d.hold.get(c).copied().unwrap_or(0.0) { if let Some(h) = d.hold.get_mut(c) { *h = v; } - d.hold_age = 0.0; + if let Some(t) = d.hold_at.get_mut(c) { + *t = now; + } } if v >= 1.0 { - d.clip = true; + clipped = true; + } + let hold_over = match peak_hold { + MeterHold::ThreeSeconds => now - d.hold_at.get(c).copied().unwrap_or(now) > METER_HOLD_SECS, + MeterHold::Infinite => false, + MeterHold::Off => true, + }; + if hold_over && let (Some(h), Some(l)) = (d.hold.get_mut(c), d.level.get(c)) { + *h = *l; } } - d.hold_age += dt; - if d.hold_age > 2.0 { - d.hold = d.level; + if clipped { + d.clip = true; + d.clip_at = now; + } else { + d.clip &= match clip_indication { + MeterHold::ThreeSeconds => now - d.clip_at <= METER_HOLD_SECS, + MeterHold::Infinite => true, + MeterHold::Off => false, + }; } d.gr = if gr > d.gr { gr } else { d.gr * fall }; } diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 422ff784f..596fff597 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -30,8 +30,14 @@ impl SoundApp { // Re-selecting a scrolling mode resumes the playhead follow. self.edit_layout.follow_hold = false; } + if id == "view.zoom_toggle_cancel" && v["canceled"] == true { + 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); + self.meters.values_mut().chain([&mut self.main_meter]).for_each(|m| { + m.clip = false; + m.hold = m.level; + }); } Ok(v) } diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index 65c6c1945..839d48799 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -364,9 +364,10 @@ impl SoundApp { self.last_meter_poll = now; } let snap = &self.meter_snapshot; + let holds = (self.ui.peak_hold, self.ui.clip_indication); for (id, m) in &snap.tracks { let d = self.meters.entry(*id).or_default(); - feed_meter(d, m.peak, m.gain_reduction_db, dt); + feed_meter(d, m.peak, m.gain_reduction_db, dt, now, holds); if m.plugin_failures.count != d.plugin_failures { d.plugin_failures = m.plugin_failures.count; if let Some(msg) = failed_plugin_status(self.engine.session(), *id, m.plugin_failures.last) { @@ -375,7 +376,7 @@ impl SoundApp { } } let main = snap.main.peak; - feed_meter(&mut self.main_meter, main, 0.0, dt); + feed_meter(&mut self.main_meter, main, 0.0, dt, now, holds); if ended { self.stop_play(); } @@ -394,10 +395,12 @@ impl SoundApp { } } if !self.meters_live() { + let holds = (self.ui.peak_hold, self.ui.clip_indication); + let now = self.uptime; for d in self.meters.values_mut() { - feed_meter(d, [0.0, 0.0], 0.0, dt); + feed_meter(d, [0.0, 0.0], 0.0, dt, now, holds); } - feed_meter(&mut self.main_meter, [0.0, 0.0], 0.0, dt); + feed_meter(&mut self.main_meter, [0.0, 0.0], 0.0, dt, now, holds); } } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fc18e1738..e076d4736 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -37,6 +37,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 @@ -79,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 { @@ -91,6 +95,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", @@ -142,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 } @@ -161,11 +173,18 @@ 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()); } + "clip.unloop" => { + let clips = soundcraft_engine::cmd::clip_ids_param(e, &json!({})); + if !clips.iter().any(|&c| soundcraft_engine::cmd::is_looped(e.session(), c)) { + return false; + } + Dialog::Unloop { clips } + } "track.new" => { Dialog::NewTracks { count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() } } @@ -241,7 +260,8 @@ impl Dialogs { Some(d) => d, None => return false, }, - }); + }; + self.set(d); true } } @@ -410,10 +430,10 @@ fn strip_silence_form( let mut rename_from = None; let [threshold, min_ms, pre_ms, post_ms] = values; 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 { @@ -455,10 +475,20 @@ 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; + 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; @@ -472,6 +502,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", @@ -487,7 +518,11 @@ 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. 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) @@ -546,9 +581,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; } @@ -578,6 +612,20 @@ 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)); + } + // A Flatten click in the same frame as Enter wins. + if buttons(ui, "Remove", enter && action.is_none()) { + action = Some(unloop_action(clips, true)); + } + }); + } Dialog::PathPrompt { cmd, path, key, .. } => { if let Some(c) = path_prompt_form(app, ui, cmd, path, key, enter) { action = Some(c); @@ -745,7 +793,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()); } }); @@ -753,15 +802,18 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } } }); + // 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"))); } - // 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(params) = bounce { + crate::bounce::start(app, ¶ms); } if let Some(strip) = rename_from && keep @@ -867,6 +919,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})) @@ -950,6 +1007,37 @@ mod tests { assert!(matches!(d.open, Some(Dialog::ScoreSetup { bars_per_system: 4, .. }))); } + #[test] + fn clip_unloop_opens_unloop_and_sends_the_chosen_mode() { + let mut e = soundcraft_engine::demo::demo_engine(); + let mut d = Dialogs::default(); + e.execute("edit.select_none", &json!({})).unwrap(); + assert!(!d.open_for_command(&e, "clip.unloop")); + let track = e.session().track_by_name("Snare").unwrap().id; + let clip = e.session().track(track).unwrap().clips()[0].id; + e.execute("edit.select", &json!({"clips": [clip.0]})).unwrap(); + assert!(!d.open_for_command(&e, "clip.unloop"), "a clip that is not looped opens no dialog"); + assert!(d.open.is_none()); + e.execute("clip.loop", &json!({"clip": clip.0, "count": 3})).unwrap(); + e.execute("edit.select", &json!({"clips": [clip.0]})).unwrap(); + assert!(d.open_for_command(&e, "clip.unloop")); + assert_eq!(d.open_name(), Some("unloop")); + let Some(Dialog::Unloop { clips }) = d.open.take() else { panic!("Unloop… did not open the Unloop dialog") }; + assert_eq!(clips, vec![clip]); + let count = |e: &Engine| e.session().track(track).map_or(0, |t| t.clips().len()); + let before = count(&e); + let (cmd, params) = unloop_action(&clips, false); + assert_eq!(params["mode"], "flatten"); + e.execute(&cmd, ¶ms).unwrap(); + assert_eq!(count(&e), before, "Flatten keeps every iteration"); + assert!(e.session().track(track).unwrap().clips().iter().all(|c| c.loop_id.is_none())); + e.execute("edit.undo", &json!({})).unwrap(); + let (cmd, params) = unloop_action(&clips, true); + assert_eq!(params["mode"], "remove"); + e.execute(&cmd, ¶ms).unwrap(); + assert_eq!(count(&e), before - 2, "Remove keeps only the source clip"); + } + #[test] fn clip_rename_opens_the_rename_dialog_for_the_selected_clip() { let mut e = soundcraft_engine::demo::demo_engine(); diff --git a/crates/ui-egui/src/dialogs/audiosuite.rs b/crates/ui-egui/src/dialogs/audiosuite.rs index 759565a8b..eeba0a092 100644 --- a/crates/ui-egui/src/dialogs/audiosuite.rs +++ b/crates/ui-egui/src/dialogs/audiosuite.rs @@ -18,21 +18,32 @@ pub(super) fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { if let Some(info) = soundcraft_dsp::plugin_info(&process) { for p in info.params { let v = params.entry(p.id.to_string()).or_insert(p.default); - ui.add(egui::Slider::new(v, p.min..=p.max).text(p.name)); + let mut wide = f64::from(*v); + crate::widgets::FineSlider::new(&mut wide, f64::from(p.min)..=f64::from(p.max)) + .text(p.name) + .default_value(f64::from(p.default)) + .show(ui); + *v = wide as f32; } } else { match process.as_str() { "normalize" => { let v = params.entry("target_db".into()).or_insert(-0.1); - ui.add(egui::Slider::new(v, -24.0..=0.0).text("Peak dB")); + let mut wide = f64::from(*v); + crate::widgets::FineSlider::new(&mut wide, -24.0..=0.0).text("Peak dB").default_value(-0.1).show(ui); + *v = wide as f32; } "time_stretch" => { let v = params.entry("ratio".into()).or_insert(1.0); - ui.add(egui::Slider::new(v, 0.25..=4.0).text("Length ratio")); + let mut wide = f64::from(*v); + crate::widgets::FineSlider::new(&mut wide, 0.25..=4.0).text("Length ratio").default_value(1.0).show(ui); + *v = wide as f32; } "pitch_shift" => { let v = params.entry("semitones".into()).or_insert(0.0); - ui.add(egui::Slider::new(v, -24.0..=24.0).text("Semitones")); + let mut wide = f64::from(*v); + crate::widgets::FineSlider::new(&mut wide, -24.0..=24.0).text("Semitones").default_value(0.0).show(ui); + *v = wide as f32; } "speed_ramp" => { for (id, label, default, choices) in SPEED_RAMP_CHOICES { diff --git a/crates/ui-egui/src/dialogs/files.rs b/crates/ui-egui/src/dialogs/files.rs index 4c8e42810..a6c107c45 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/src/edit_window/layout.rs b/crates/ui-egui/src/edit_window/layout.rs index 83452fcc3..4af86e354 100644 --- a/crates/ui-egui/src/edit_window/layout.rs +++ b/crates/ui-egui/src/edit_window/layout.rs @@ -70,6 +70,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 { diff --git a/crates/ui-egui/src/edit_window/scroll.rs b/crates/ui-egui/src/edit_window/scroll.rs index 0b78e2628..ba0b9b728 100644 --- a/crates/ui-egui/src/edit_window/scroll.rs +++ b/crates/ui-egui/src/edit_window/scroll.rs @@ -75,8 +75,13 @@ pub(super) 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/edit_window/tests.rs b/crates/ui-egui/src/edit_window/tests.rs index 8b6fff5f3..99d7dc452 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(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a84be7e4b..df8c27f48 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -52,7 +52,7 @@ mod services; mod state; #[cfg(test)] -use app::{automation_step_due, editor_changes, editor_states_due, failed_plugin_status}; +use app::{automation_step_due, editor_changes, editor_states_due, failed_plugin_status, feed_meter}; pub use services::Services; pub use state::{DragSource, MainWindow}; @@ -87,6 +87,12 @@ pub struct UiState { pub edit_views: Vec, /// How strongly Edit window tracks take on their colour, 0..1 (0: only the colour strip). pub track_tint: f32, + /// How long meters show their last peak (`ui.peak_hold`) and their last clip + /// (`ui.clip_indication`). + pub peak_hold: MeterHold, + pub clip_indication: MeterHold, + /// The Metering window, which sets both (`window.metering`). + pub show_metering: bool, /// Open plugin windows (track, slot). pub plugin_windows: Vec<(TrackId, usize)>, /// The send in the Send window (track, slot). @@ -158,6 +164,9 @@ impl Default for UiState { mix_views: vec!["inserts_ae".into(), "sends_ae".into(), "io".into(), "color".into()], edit_views: vec!["color".into()], track_tint: 0.5, + peak_hold: MeterHold::ThreeSeconds, + clip_indication: MeterHold::Infinite, + show_metering: false, plugin_windows: Vec::new(), send_window: None, audiosuite: None, @@ -212,13 +221,57 @@ pub fn fit_window_to_screen(ctx: &egui::Context) { } } +/// How long a meter shows its last peak or its last clip: three seconds, until it is cleared +/// (Track › Clear All Clip Indicators), or not at all. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MeterHold { + #[default] + ThreeSeconds, + Infinite, + #[serde(rename = "none")] + Off, +} + +impl MeterHold { + pub const ALL: [Self; 3] = [Self::ThreeSeconds, Self::Infinite, Self::Off]; + + pub const fn label(self) -> &'static str { + match self { + Self::ThreeSeconds => "3 Seconds", + Self::Infinite => "Infinite", + Self::Off => "None", + } + } + + /// The id used in `ui.peak_hold {"mode": …}`, `ui.clip_indication {"mode": …}` and in the + /// saved UI prefs. + pub const fn id(self) -> &'static str { + match self { + Self::ThreeSeconds => "three_seconds", + Self::Infinite => "infinite", + Self::Off => "none", + } + } + + pub fn parse(s: &str) -> Option { + Self::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, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fc821b420..33b3fb953 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -67,6 +67,9 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[ ("window.rtp", "MIDI Real-Time Properties", "", None), ("window.playback_engine", "Playback Engine", "", None), ("ui.audio_device", "Audio Device", "", None), + ("window.metering", "Metering", "", None), + ("ui.peak_hold", "Peak Hold", "", None), + ("ui.clip_indication", "Clip Indication", "", None), ("window.io_setup", "I/O Setup", "", None), ("window.shortcuts", "Keyboard Shortcuts", "", None), ("view.mix_section", "Mix Window View", "", None), @@ -150,6 +153,7 @@ pub const AUDIOSUITE: &[(&str, &str)] = &[ /// Commands that open a dialog when invoked from a menu (programmatic calls never do). const DIALOG_COMMANDS: &[&str] = &[ "clip.clear", + "clip.unloop", "track.new", "file.bounce_mix", "session.new", @@ -443,6 +447,7 @@ const MENU_WINDOWS: &[(&str, &str)] = &[ ("Setup > Hardware...", "window.playback_engine"), ("Setup > Playback Engine...", "window.playback_engine"), ("Setup > I/O...", "window.io_setup"), + ("Setup > Metering...", "window.metering"), ("Setup > Keyboard Shortcuts...", "window.shortcuts"), ("Setup > Session", "setup.session"), ("Event > Beat Detective", "window.beat_detective"), @@ -679,6 +684,18 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option { 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/mix_window/sends.rs b/crates/ui-egui/src/mix_window/sends.rs index e0c3da4ab..9d0ec9f41 100644 --- a/crates/ui-egui/src/mix_window/sends.rs +++ b/crates/ui-egui/src/mix_window/sends.rs @@ -44,13 +44,14 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us if level_resp.dragged() { // Use this frame's pointer movement. `Response::drag_delta()` is cumulative, so // applying it to the already-updated value made trackpad adjustment accelerate. - let (dy, fine) = ui.input(|i| (i.pointer.delta().y, i.modifiers.shift)); + let (dy, fine) = ui.input(|i| (i.pointer.delta().y, i.modifiers.shift || i.modifiers.command || i.modifiers.ctrl)); let db = dragged_send_db(s.level_db, dy, fine); let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0)); } + let fine_drag = if ui.ctx().os().is_mac() { "⌘-drag" } else { "Ctrl-drag" }; let _ = level_resp.clone().on_hover_text(format!( - "Send {which} to {name}: {} dB\nClick for the send fader · drag vertically to adjust · Shift-drag for fine control · Alt-click for 0 dB · right-click for options", + "Send {which} to {name}: {} dB\nClick for the send fader · drag vertically to adjust · {fine_drag} or Shift-drag for fine control · Alt-click for 0 dB · right-click for options", db_text(s.level_db) )); level_resp.context_menu(|ui| send_options_menu(app, ui, id, slot, s)); @@ -155,8 +156,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); @@ -174,8 +178,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| { @@ -333,7 +339,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| { @@ -353,6 +358,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) }; @@ -391,6 +397,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() diff --git a/crates/ui-egui/src/panels/plugins.rs b/crates/ui-egui/src/panels/plugins.rs index 291254326..2495c142e 100644 --- a/crates/ui-egui/src/panels/plugins.rs +++ b/crates/ui-egui/src/panels/plugins.rs @@ -78,16 +78,26 @@ pub(super) fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { } }); x = idx as f32; + if r.response.clicked() && ui.input(|i| i.modifiers.alt) { + x = p.default; + egui::Popup::close_all(ui.ctx()); + } r.response } else { - let mut sl = egui::Slider::new(&mut x, p.min..=p.max).show_value(false); + let mut wide = f64::from(x); + let mut sl = crate::widgets::FineSlider::new(&mut wide, f64::from(p.min)..=f64::from(p.max)) + .id_salt(p.id) + .show_value(false) + .default_value(f64::from(p.default)); if instrument && crate::sampler_window::whole_number(&ins.plugin, p.id) { sl = sl.integer(); } if p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0 { sl = sl.logarithmic(true); } - ui.add(sl) + let r = sl.show(ui); + x = wide as f32; + r }; // The grid cell's painted label is not an accesskit name; tie the // combo box or slider to it so screen readers and label queries find it. diff --git a/crates/ui-egui/src/panels/windows.rs b/crates/ui-egui/src/panels/windows.rs index a0719ea2f..123ab33d1 100644 --- a/crates/ui-egui/src/panels/windows.rs +++ b/crates/ui-egui/src/panels/windows.rs @@ -22,6 +22,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); @@ -423,6 +424,34 @@ pub fn recall_configuration(app: &mut SoundApp, saved: &Value) -> Result<(), Str Ok(()) } +/// Metering preferences: how long meters show their last peak (Peak Hold) and their last clip +/// (Clip Indication). +fn metering(app: &mut SoundApp, ctx: &egui::Context) { + let mut open = app.ui.show_metering; + let mut chosen = None; + win(ctx, &mut open, "Metering", vec2(280.0, 140.0), |ui| { + egui::Grid::new("metering").num_columns(2).spacing(vec2(12.0, 8.0)).show(ui, |ui| { + for (cmd, label, value) in + [("ui.peak_hold", "Peak Hold", app.ui.peak_hold), ("ui.clip_indication", "Clip Indication", app.ui.clip_indication)] + { + ui.label(label); + ui.horizontal(|ui| { + for mode in crate::MeterHold::ALL { + if ui.radio(value == mode, mode.label()).clicked() { + chosen = Some((cmd, mode)); + } + } + }); + ui.end_row(); + } + }); + }); + if let Some((cmd, mode)) = chosen { + let _ = app.run(cmd, json!({"mode": mode.id()})); + } + app.ui.show_metering = open; +} + /// The audio systems as last enumerated (enumerating probes devices: done when the window opens /// and on Refresh, not every frame). fn audio_systems(ctx: &egui::Context, refresh: bool) -> Arc> { @@ -735,7 +764,12 @@ pub fn clip_effects(app: &mut SoundApp, ctx: &egui::Context) { let key = format!("{prefix}{}", p.id); let mut v = get(app, &key, p.default); let before = v; - col.add(egui::Slider::new(&mut v, p.min..=p.max).text(p.name)); + let mut wide = f64::from(v); + crate::widgets::FineSlider::new(&mut wide, f64::from(p.min)..=f64::from(p.max)) + .text(p.name) + .default_value(f64::from(p.default)) + .show(col); + v = wide as f32; if (v - before).abs() > f32::EPSILON { let mut m = serde_json::Map::new(); m.insert(key.clone(), json!(v)); @@ -750,7 +784,9 @@ pub fn clip_effects(app: &mut SoundApp, ctx: &egui::Context) { }); let mut g = get(app, "gain", 0.0); let before = g; - ui.add(egui::Slider::new(&mut g, -36.0..=24.0).text("Clip effects gain (dB)")); + let mut wide = f64::from(g); + crate::widgets::FineSlider::new(&mut wide, -36.0..=24.0).text("Clip effects gain (dB)").default_value(0.0).show(ui); + g = wide as f32; if (g - before).abs() > f32::EPSILON { let _ = app.engine.execute_merged("clip.effects_set", &json!({"clips": [cid.0], "params": {"gain": g}}), &format!("cfx:{}:gain", cid.0)); diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 892b53930..017e36e2a 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -1,4 +1,4 @@ -use super::{Services, SoundApp, automation_step_due}; +use super::{MeterDisplay, MeterHold, Services, SoundApp, automation_step_due, feed_meter}; use serde_json::json; use soundcraft_model::TrackId; @@ -159,8 +159,95 @@ fn clear_all_clip_indicators_clears_every_meter() { let kick = app.engine.session().track_by_name("Kick").unwrap().id; app.meters.entry(kick).or_default().clip = true; app.main_meter.clip = true; + app.meters.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/widgets.rs b/crates/ui-egui/src/widgets.rs index e4a2acb14..51009db48 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; @@ -424,6 +426,177 @@ pub fn db_text(db: f32) -> String { if db <= -143.9 { "-inf".into() } else { format!("{db:.1}") } } +/// Where a [`fine_slider`] drag is anchored: the pointer's x and the value there, and the pass +/// that last drew the slider during the drag. +#[derive(Clone, Copy)] +struct FineDrag { + x: f32, + value: f64, + fine: bool, + relative: bool, + pass: u64, +} + +/// A horizontal slider that moves a tenth as far for the same drag while Ctrl (Command on a Mac) +/// is held, as a [`fader`] does, on the rail and on the value box. Pressing or releasing the key +/// during a drag on the rail leaves the value where it is, and the drag goes on from there. The +/// value box shows the decimal places that a tenth of a point's step needs, and a drag on the +/// rail that the key has touched rounds the value to them. +pub fn fine_slider(ui: &mut Ui, value: &mut f64, range: std::ops::RangeInclusive, text: &str) -> Response { + FineSlider::new(value, range).text(text).show(ui) +} + +/// A [`fine_slider`] with the options of `egui::Slider` that plug-in parameters use. +pub struct FineSlider<'a> { + value: &'a mut f64, + range: std::ops::RangeInclusive, + text: &'a str, + id_salt: Option<&'a str>, + show_value: bool, + 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, suffix: "" } + } + + /// Text after the number in the value box, as `egui::Slider::suffix`. + pub fn suffix(mut self, suffix: &'a str) -> Self { + self.suffix = suffix; + self + } + + /// The value that an Alt-click (Option-click on a Mac) on the slider or its value box sets, + /// as a [`fader`] returns to 0 dB. While Alt is held, a drag leaves the value where it is, and + /// an Alt-click on the value box does not open it for typing. + pub fn default_value(mut self, default: f64) -> Self { + self.default = Some(default); + self + } + + /// The label after the slider. It also tells sliders in the same `Ui` apart unless + /// [`Self::id_salt`] is set. + pub fn text(mut self, text: &'a str) -> Self { + self.text = text; + self + } + + /// Tells sliders in the same `Ui` apart when they have no label. + pub fn id_salt(mut self, salt: &'a str) -> Self { + self.id_salt = Some(salt); + self + } + + /// Whether the value box is shown. Without it, a drag that the key has touched rounds the + /// value only on a whole-number slider. + pub fn show_value(mut self, show: bool) -> Self { + self.show_value = show; + self + } + + /// A logarithmic rail, as `egui::Slider::logarithmic`, for a range above zero. A range that + /// reaches zero or below stays linear. + pub fn logarithmic(mut self, logarithmic: bool) -> Self { + self.logarithmic = logarithmic; + self + } + + /// Whole numbers only, as `egui::Slider::integer`. + pub fn integer(mut self) -> Self { + self.integer = true; + self + } + + pub fn show(self, ui: &mut Ui) -> Response { + let Self { value, range, text, id_salt, show_value, logarithmic, integer, default, suffix } = self; + let id = ui.scope_id().with(("fine_slider", id_salt.unwrap_or(text))); + let (fine, x) = ui.input(|i| (i.modifiers.command || i.modifiers.ctrl, i.pointer.interact_pos().map(|p| p.x))); + let reset = default.is_some() && ui.input(|i| i.modifiers.alt); + // A drag lasts through the pass that releases the button, where egui still sets the value + // from the pointer. An anchor that the previous pass did not store was left by an earlier + // drag, and is ignored. + let pass = ui.ctx().cumulative_pass_nr(); + let mut drag = ui.data(|d| d.get_temp::(id)).filter(|d| d.pass + 1 == pass); + let (lo, hi) = (*range.start(), *range.end()); + let log = logarithmic && lo > 0.0 && hi > lo; + // The handle's travel along the rail, as `egui::Slider` lays it out. + let thickness = ui.text_style_height(&egui::TextStyle::Body).max(ui.spacing().interact_size.y); + let travel = f64::from((ui.spacing().slider_width - 2.0 * thickness / 2.5).max(1.0)); + // Where a value sits along the rail (0 to 1), and back, as `egui::Slider` maps them. + let span = if log { (hi / lo).ln() } else { hi - lo }; + let along = |v: f64| if span > 0.0 { if log { (v / lo).ln() / span } else { (v - lo) / span } } else { 0.0 }; + let at = |n: f64| { + let n = n.clamp(0.0, 1.0); + if log { lo * (n * span).exp() } else { lo + n * span } + }; + // How far the value moves for one point of the rail at `v`. + let per_point = |v: f64| if log { v * span / travel } else { span / travel }; + let scale = if fine { 0.1 } else { 1.0 }; + let decimals = if integer { 0 } else { (-(per_point(lo) * 0.1).log10()).ceil().clamp(0.0, 3.0) as usize }; + if let (Some(d), Some(x)) = (&mut drag, x) { + if d.fine != fine || reset { + *d = FineDrag { x, value: *value, fine, relative: true, pass }; + } else if d.relative { + let moved = at(along(d.value) + f64::from(x - d.x) / travel * scale); + let moved = if show_value || integer { egui::emath::round_to_decimals(moved, decimals) } else { moved }; + *value = moved.clamp(lo, hi); + } + } + let hold = reset || drag.is_some_and(|d| d.relative); + let speed = per_point(*value) * scale; + let mut slider = egui::Slider::from_get_set(range, |v| { + if let Some(v) = v + && !hold + { + *value = v; + } + *value + }) + .show_value(show_value) + .logarithmic(log) + .min_decimals(decimals) + .suffix(suffix) + .text(text); + if integer { + slider = slider.integer(); + } + if fine { + slider = slider.drag_value_speed(speed); + } + let mut resp = ui.add(slider); + // The rail senses drags only, so a click on it ends as a drag that the pointer did not + // move far enough to count as one. + let clicked = resp.clicked() || (resp.drag_stopped() && ui.input(|i| i.pointer.could_any_button_be_click())); + if let Some(d) = default + && reset + && clicked + { + let d = if integer { d.round() } else { d }; + *value = d.clamp(lo, hi); + resp.surrender_focus(); + resp.mark_changed(); + } + let rail = Rect::from_min_size(resp.rect.min, vec2(ui.spacing().slider_width, resp.rect.height())); + let on_rail = resp.dragged() && ui.input(|i| i.pointer.press_origin()).is_some_and(|p| rail.contains(p)); + let next = match (on_rail, drag, x) { + (true, Some(d), _) => Some(FineDrag { pass, ..d }), + (true, None, Some(x)) => Some(FineDrag { x, value: *value, fine, relative: fine, pass }), + _ => None, + }; + ui.data_mut(|m| match next { + Some(d) => { + m.insert_temp(id, d); + } + None => m.remove::(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/bounce.rs b/crates/ui-egui/tests/it/bounce.rs index 77e73fff0..b06e0be82 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-enter-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/focused_dialog_buttons.rs b/crates/ui-egui/tests/it/focused_dialog_buttons.rs new file mode 100644 index 000000000..091c40c23 --- /dev/null +++ b/crates/ui-egui/tests/it/focused_dialog_buttons.rs @@ -0,0 +1,206 @@ +//! 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, Pos2, RawInput, Rect, 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}; + +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(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); + 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(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 == FileFormat::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 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(); + 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), enter_up()]); + assert_eq!(app.dialogs.open_name(), Some("fades"), "the opening Enter leaves the dialog open"); + 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()); + 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 3578eeb67..2003f55fe 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -18,6 +18,7 @@ mod docking_workflows; mod edit_menu; mod edit_modes; mod edit_window_headers; +mod focused_dialog_buttons; mod group_dialog; mod hide_floating; mod idle; @@ -34,6 +35,8 @@ mod mix_separate; mod new_midi_clip; mod new_tracks; mod open_on_insert; +mod plugin_default_reset; +mod plugin_fine_sliders; mod plugin_window; mod pro_tools_keys; mod record_hint; @@ -43,6 +46,7 @@ mod sampler_window; mod save_as_replace; mod save_changes; mod scroll_into_view; +mod send_fine_adjustment; mod send_window; mod shared_controls; mod shortcuts; @@ -51,6 +55,7 @@ mod side_lists_text; mod strip_silence_dialog; mod strip_silence_rectangles; mod strip_silence_rename; +mod strip_silence_sliders; mod toolbar_scroll; mod toolbar_tempo; mod track_height; @@ -59,6 +64,7 @@ mod track_name_menu; mod track_preset_state; mod trackpad; mod tracks_scroll_bar; +mod unloop_dialog; mod waveform; mod window_shortcuts; mod zoom_toggle; 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..0be7e128a --- /dev/null +++ b/crates/ui-egui/tests/it/plugin_default_reset.rs @@ -0,0 +1,234 @@ +use egui::accesskit::Role; +use egui::{Event, Id, Modifiers, PointerButton, Pos2, Rect, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_dsp::ParamInfo; +use soundcraft_model::TrackId; +use soundcraft_ui_egui::{Services, SoundApp}; +use std::collections::BTreeMap; + +fn harness(app: SoundApp) -> Harness<'static, SoundApp> { + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn hold(h: &mut Harness<'_, SoundApp>, modifiers: Modifiers) { + h.event(Event::ModifiersChanged(modifiers)); + h.run_steps(1); +} + +fn press(h: &mut Harness<'_, SoundApp>, pos: Pos2, pressed: bool, modifiers: Modifiers) { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }); + h.run_steps(1); +} + +fn move_to(h: &mut Harness<'_, SoundApp>, pos: Pos2) { + h.event(Event::PointerMoved(pos)); + h.run_steps(1); +} + +/// Presses at `at` with Alt held, moves `dx` points right in six steps, and releases; `dx` of 0 +/// is an Alt-click. +fn alt_press(h: &mut Harness<'_, SoundApp>, at: Pos2, dx: f32) { + hold(h, Modifiers::ALT); + move_to(h, at); + press(h, at, true, Modifiers::ALT); + for i in 1..=6 { + if dx != 0.0 { + move_to(h, at + vec2(dx * i as f32 / 6.0, 0.0)); + } + } + press(h, at + vec2(dx, 0.0), false, Modifiers::ALT); + hold(h, Modifiers::NONE); + h.run_steps(2); +} + +/// A point near the left end of `rail`, where a plain click would set a value far from the +/// parameter's default. +fn near_left(rail: Rect) -> Pos2 { + rail.left_center() + vec2(3.0, 0.0) +} + +/// The rails of the unlabeled sliders inside the window with id `window`, top to bottom. +fn window_sliders(h: &Harness<'_, SoundApp>, window: Id) -> Vec { + 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::unique(("plugin", kick.0, 0usize)))[0]; + let steps = undo_steps(&h); + alt_press(&mut h, near_left(rail), 0.0); + assert_eq!(insert_param(&h, kick, 0, input), input.default, "Alt-click did not return Input to its default"); + assert_eq!(undo_steps(&h), steps + 1, "Alt-click is not one undo step"); + h.state_mut().engine.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(insert_param(&h, kick, 0, input), 6.0, "Undo did not bring Input back"); +} + +#[test] +fn alt_drag_leaves_a_plugin_window_slider_where_it_is() { + let (mut h, kick) = kick_eq(); + let input = param("eq_7band", "input_gain"); + let rail = window_sliders(&h, Id::unique(("plugin", kick.0, 0usize)))[0]; + let steps = undo_steps(&h); + alt_press(&mut h, near_left(rail), 40.0); + assert_eq!(insert_param(&h, kick, 0, input), 6.0, "an Alt-drag moved Input"); + assert_eq!(undo_steps(&h), steps, "an Alt-drag added an undo step"); +} + +#[test] +fn alt_click_returns_a_plugin_window_choice_to_its_default_without_opening_the_list() { + let (mut h, kick) = kick_eq(); + let slope = param("eq_7band", "hpf_slope"); + assert_ne!(slope.default, slope.max); + let row = h.get_by_label("HPF Slope").rect(); + let combo = h.query_all_by_role(Role::ComboBox).map(|n| n.rect()).find(|r| (r.center().y - row.center().y).abs() < 4.0).unwrap(); + alt_press(&mut h, combo.center(), 0.0); + assert_eq!(insert_param(&h, kick, 0, slope), slope.default, "Alt-click did not return HPF Slope to its default"); + assert!(!egui::Popup::is_any_open(&h.ctx), "Alt-click opened the list"); +} + +#[test] +fn alt_click_returns_clip_effects_sliders_and_value_boxes_to_their_defaults() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let s = app.engine.session(); + let clip = s.track_by_name("Kick").unwrap().clips().iter().find(|c| c.is_audio()).unwrap().id; + app.run("edit.select", json!({"clips": [clip.0]})).unwrap(); + app.run("clip.effects_set", json!({"clips": [clip.0], "params": {"eq.input_gain": 6.0, "comp.threshold": -6.0}})).unwrap(); + app.engine.end_merge(); + app.ui.show_clip_effects = true; + let mut h = harness(app); + for (label, key, p, on_box) in + [("Input", "eq.input_gain", param("eq_7band", "input_gain"), false), ("Threshold", "comp.threshold", param("compressor", "threshold"), true)] + { + let key = format!("clip_fx.{}.{key}", clip.0); + let read = |h: &Harness<'_, SoundApp>| h.state().engine.session().edit.values.get(&key).copied(); + assert_ne!(read(&h), Some(f64::from(p.default))); + let rail = h.get_by_role_and_label(Role::Slider, label).rect(); + let at = if on_box { + h.query_all_by_role(Role::SpinButton) + .map(|n| n.rect()) + .find(|r| r.left() > rail.right() && (r.center().y - rail.center().y).abs() < 4.0) + .unwrap() + .center() + } else { + near_left(rail) + }; + // A plain click on the value box gives it the keyboard focus for typing. + let focus = h.ctx.memory(|m| m.focused()); + let steps = undo_steps(&h); + alt_press(&mut h, at, 0.0); + assert_eq!(read(&h), Some(f64::from(p.default)), "Alt-click did not return {label} to its default"); + assert_eq!(undo_steps(&h), steps + 1, "Alt-click on {label} is not one undo step"); + assert_eq!(h.ctx.memory(|m| m.focused()), focus, "Alt-click opened {label}'s value box for typing"); + } +} + +#[test] +fn fallback_audiosuite_sliders_reset_with_alt_click() { + for (process, label, id, default) in [ + ("normalize", "Peak dB", "target_db", -0.1f32), + ("time_stretch", "Length ratio", "ratio", 1.0), + ("pitch_shift", "Semitones", "semitones", 0.0), + ] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.ui.audiosuite = Some(process.into()); + let mut h = harness(app); + let read = |h: &Harness<'_, SoundApp>| -> BTreeMap { + h.ctx.data(|d| d.get_temp(Id::unique(("as_params", process.to_string())))).unwrap_or_default() + }; + let rail = h.get_by_role_and_label(Role::Slider, label).rect(); + move_to(&mut h, near_left(rail)); + press(&mut h, near_left(rail), true, Modifiers::NONE); + press(&mut h, near_left(rail), false, Modifiers::NONE); + assert_ne!(read(&h)[id], default, "{process}: plain click did not move the value"); + alt_press(&mut h, near_left(rail), 0.0); + assert_eq!(read(&h)[id], default, "{process}: Alt-click did not restore the default"); + } +} + +#[test] +fn alt_click_returns_audiosuite_parameter_sliders_to_their_defaults() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.ui.audiosuite = Some("compressor".into()); + let mut h = harness(app); + let read = |h: &Harness<'_, SoundApp>| -> BTreeMap { + h.ctx.data(|d| d.get_temp(Id::unique(("as_params", "compressor".to_string())))).unwrap_or_default() + }; + let threshold = param("compressor", "threshold"); + let rail = h.get_by_role_and_label(Role::Slider, "Threshold").rect(); + move_to(&mut h, near_left(rail)); + press(&mut h, near_left(rail), true, Modifiers::NONE); + press(&mut h, near_left(rail), false, Modifiers::NONE); + assert_ne!(read(&h)["threshold"], threshold.default, "a plain click did not move Threshold"); + alt_press(&mut h, near_left(rail), 0.0); + assert_eq!(read(&h)["threshold"], threshold.default, "Alt-click did not return Threshold to its default"); +} + +#[test] +fn clip_effects_gain_resets_with_alt_click_and_undo_restores_the_value() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let clip = app.engine.session().track_by_name("Kick").unwrap().clips().iter().find(|c| c.is_audio()).unwrap().id; + app.run("edit.select", json!({"clips": [clip.0]})).unwrap(); + app.run("clip.effects_set", json!({"clips": [clip.0], "params": {"gain": 6.0}})).unwrap(); + app.engine.end_merge(); + app.ui.show_clip_effects = true; + let mut h = harness(app); + let key = format!("clip_fx.{}.gain", clip.0); + let input = h.get_by_role_and_label(Role::Slider, "Input").rect(); + move_to(&mut h, input.center()); + h.event(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -2000.0), + modifiers: Modifiers::NONE, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(30); + let rail = h.get_by_role_and_label(Role::Slider, "Clip effects gain (dB)").rect(); + let steps = undo_steps(&h); + alt_press(&mut h, near_left(rail), 0.0); + assert_eq!(h.state().engine.session().edit.values.get(&key), Some(&0.0)); + assert_eq!(undo_steps(&h), steps + 1); + h.state_mut().engine.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(h.state().engine.session().edit.values.get(&key), Some(&6.0)); +} 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..f336b1f04 --- /dev/null +++ b/crates/ui-egui/tests/it/plugin_fine_sliders.rs @@ -0,0 +1,220 @@ +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::unique(("plugin", kick.0, 0usize))); + let params = slider_params("eq_7band"); + for i in [0, 2] { + let p = params[i]; + // Along the rail, so that a log-taper parameter compares as a linear one does. + let read = move |h: &Harness<'_, SoundApp>| f64::from(p.to_normalized(insert_param(h, kick, 0, p) as f32)); + assert_tenth(p.name, plain_and_fine(&mut h, rails[i], &read), 0.08..=0.12); + } +} + +#[test] +fn a_small_ctrl_drag_at_the_low_end_of_a_log_slider_moves_the_value() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let kick = app.engine.session().track_by_name("Kick").unwrap().id; + app.ui.plugin_windows = vec![(kick, 1)]; + let mut h = harness(app); + let rails = window_sliders(&h, Id::unique(("plugin", kick.0, 1usize))); + let attack = slider_params("compressor")[2]; + assert_eq!(attack.id, "attack"); + let from = rails[2].left_center() + vec2(1.0, 0.0); + hold(&mut h, Modifiers::COMMAND); + move_to(&mut h, from); + press(&mut h, from, true, Modifiers::COMMAND); + let start = insert_param(&h, kick, 1, attack); + assert_eq!(start, f64::from(attack.min), "the press did not put Attack at its lowest value"); + move_to(&mut h, from + vec2(1.0, 0.0)); + let moved = insert_param(&h, kick, 1, attack); + press(&mut h, from + vec2(1.0, 0.0), false, Modifiers::COMMAND); + assert!(moved > start, "a one-point Ctrl-drag left Attack at {moved}"); +} + +#[test] +fn whole_number_instrument_sliders_move_a_tenth_as_far_and_stay_whole() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let keys = app.engine.session().track_by_name("Keys").unwrap().id; + app.run("mix.instrument", json!({"track": keys.0, "plugin": "sampler"})).unwrap(); + app.ui.plugin_windows = vec![(keys, soundcraft_mix::INSTRUMENT_SLOT)]; + let mut h = harness(app); + let rails = window_sliders(&h, Id::unique(("plugin", keys.0, soundcraft_mix::INSTRUMENT_SLOT))); + let root = slider_params("sampler")[0]; + assert_eq!(root.id, "root_note"); + // Each read during the drags checks that the instrument was sent a whole number. + let read = move |h: &Harness<'_, SoundApp>| { + let v = insert_param(h, keys, soundcraft_mix::INSTRUMENT_SLOT, root); + assert_eq!(v.fract(), 0.0, "Root Note is {v} during a drag"); + v + }; + assert_tenth(root.name, plain_and_fine(&mut h, rails[0], &read), 0.06..=0.14); +} + +#[test] +fn clip_effects_sliders_move_a_tenth_as_far_with_ctrl_held() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let s = app.engine.session(); + let clip = s.track_by_name("Kick").unwrap().clips().iter().find(|c| c.is_audio()).unwrap().id; + app.run("edit.select", json!({"clips": [clip.0]})).unwrap(); + app.ui.show_clip_effects = true; + let mut h = harness(app); + for (label, key, default) in [("Input", "eq.input_gain", 0.0), ("Threshold", "comp.threshold", -20.0), ("Clip effects gain (dB)", "gain", 0.0)] { + if key == "gain" { + let input = h.get_by_role_and_label(Role::Slider, "Input").rect(); + move_to(&mut h, input.center()); + h.event(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -2000.0), + modifiers: Modifiers::NONE, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(30); + } + let key = format!("clip_fx.{}.{key}", clip.0); + let read = move |h: &Harness<'_, SoundApp>| h.state().engine.session().edit.values.get(&key).copied().unwrap_or(default); + let rail = h.get_by_role_and_label(Role::Slider, label).rect(); + assert_tenth(label, plain_and_fine(&mut h, rail, &read), 0.08..=0.12); + } +} + +#[test] +fn fallback_audiosuite_sliders_move_a_tenth_as_far_with_ctrl_held() { + for (process, label, id) in + [("normalize", "Peak dB", "target_db"), ("time_stretch", "Length ratio", "ratio"), ("pitch_shift", "Semitones", "semitones")] + { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.ui.audiosuite = Some(process.into()); + let mut h = harness(app); + let read = |h: &Harness<'_, SoundApp>| { + let params: BTreeMap = h.ctx.data(|d| d.get_temp(Id::unique(("as_params", process.to_string())))).unwrap_or_default(); + f64::from(params[id]) + }; + let rail = h.get_by_role_and_label(Role::Slider, label).rect(); + assert_tenth(label, plain_and_fine(&mut h, rail, &read), 0.08..=0.12); + } +} + +#[test] +fn audiosuite_parameter_sliders_move_a_tenth_as_far_with_ctrl_held() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.ui.audiosuite = Some("compressor".into()); + let mut h = harness(app); + for (label, id) in [("Threshold", "threshold"), ("Knee", "knee")] { + let read = move |h: &Harness<'_, SoundApp>| { + let params: BTreeMap = h.ctx.data(|d| d.get_temp(Id::unique(("as_params", "compressor".to_string())))).unwrap_or_default(); + f64::from(params[id]) + }; + let rail = h.get_by_role_and_label(Role::Slider, label).rect(); + assert_tenth(label, plain_and_fine(&mut h, rail, &read), 0.08..=0.12); + } +} + +#[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); + } +} 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/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/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(); 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..e89982691 --- /dev/null +++ b/crates/ui-egui/tests/it/strip_silence_sliders.rs @@ -0,0 +1,154 @@ +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"); +} + +#[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); +} 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..7a17bd264 --- /dev/null +++ b/crates/ui-egui/tests/it/unloop_dialog.rs @@ -0,0 +1,97 @@ +use egui::accesskit::Role; +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_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; + 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")); + (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(); + 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 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(); + 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); +} 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/control-protocol.md b/docs/control-protocol.md index 470d39127..3e5bf9255 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -91,6 +91,12 @@ a portal answer it uses Dark. Manual Light and Dark stay fixed when the OS appea An offscreen render without `--system-theme` supplies no OS appearance and therefore resolves System to Dark, or on Linux to the desktop's portal setting when one is available. +`ui.peak_hold {"mode":"three_seconds"|"infinite"|"none"}` sets how long meters show their last +peak, and `ui.clip_indication` takes the same modes for the clip lights; without `mode`, each +returns the current setting. The defaults are `three_seconds` and `infinite`. Both are saved with +the UI preferences, and Setup › Metering… (`window.metering`) shows them. +`track.clear_clip_indicators` clears every clip light and held peak. + `view.scroll_into_view {"track":"Bass"}` (a track id or name; without it, the first selected track) brings the track to the top of the Edit window and the left of the Mix window, the same as Scroll Into View in the track name menu. A window not on screen catches up when it is next shown. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 23c4b54a5..9b447ddc9 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Tracks and Groups and Clips in dock columns) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-27 · **Change:** minor (Tracks and Groups and Clips in dock columns; fine adjustment on the Strip Silence sliders and plugin parameter sliders; Alt-click returns a plugin parameter to its default; fine adjustment of sends; Peak Hold and Clip Indication options; Cancel Zoom Toggle) · **Target:** Avid Pro Tools Ultimate 2026.4.1 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command @@ -21,6 +21,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Live waveform fade preview | ✅ | Waveform amplitude updates during fade-length and midpoint-curvature drags, sharing the curve's temporary envelope. Mono/stereo, waveform styles, zoom and clip gain remain effective; release preserves the displayed waveform with one undo step and cached peaks | | Edit modes: Shuffle, Slip, Spot, Grid (absolute/relative) | ✅ | Spot dialog present | | Grid and nudge values, all timebases | ✅ | Bars/Beats, Min:Secs, Timecode, Feet+Frames, Samples | +| Zoom Toggle | 🟡 | E (Commands focus) and the button zoom to the edit selection with Fit To Window heights and back; Alt+Shift+E and Alt-click cancel it and keep the view. The Zoom Toggle preferences (Vertical Zoom, Horizontal Zoom, Track Height, Track View, Separate Grid Settings When Zoomed In, Zoom Toggle Follows Edit Selection, Remove Range Selection After Zooming In) are not present | | Keyboard focus modes (Commands, Clip List, Groups) | 🟡 | Commands focus single-key editing ✅; list focus partial | | Link timeline/edit, link track/edit, Tab to transient, insertion follows playback | ✅ | | | Mirrored MIDI, MIDI merge, layered editing | 🟡 | Stored and partly honoured | @@ -50,7 +51,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | I/O Setup | 🟡 | Busses and paths; no hardware channel matrix (G-01) | | Preferences | 🟡 | Many settings stored, fewer honoured | | Window configurations, Tile/Cascade | 🟡 | Configurations ✅; Tile/Cascade in PR #151 | -| Plugin windows | 🟡 | Generic parameter sliders; Pro Tools plugins have bespoke GUIs (#138 asks for skeuomorphic design); multi-page plugin views (#6) | +| Plugin windows | 🟡 | Generic parameter sliders; Pro Tools plugins have bespoke GUIs (#138 asks for skeuomorphic design); multi-page plugin views (#6); Alt/Option-click returns a parameter to its default | | A plugin opens when it's inserted | ✅ | #486; a hosted plugin's own editor, else its plugin window | | Command palette (Search) | ✅ | Over every command | @@ -62,14 +63,22 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Interface scaling | 🟡 | Slider in PR #147 | | Meter types and ballistics (peak, RMS, VU, K-scales, gain reduction) | 🟡 | Peak/RMS; meter type choice and GR meters partial | | Clip indicators, track colours, waveform views (peak/power/rectified/outlines) | ✅ | | -| Fader resolution, fine adjust, numeric entry | 🟡 | | +| Fader resolution, fine adjust, numeric entry | 🟡 | Ctrl/Cmd-drag moves the faders, the Strip Silence sliders, plugin parameter sliders, a send's Level and Pan sliders and the pan knobs a tenth as far, and a send's level as finely as Shift-drag | +| Peak Hold and Clip Indication: 3 Seconds, Infinite, None | 🟡 | Setup › Metering… (`ui.peak_hold`, `ui.clip_indication`), kept with the preferences; Track › Clear All Clip Indicators also clears held peaks; clicking a meter does not clear it yet, and there are no plug-in or send meters | | macOS system menu bar | ✅ | #10 | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | Clip Effects gain supports Alt/Option-click reset to 0 dB and Ctrl/Cmd fine adjustment. Readiness estimate unchanged | | 2026-10-25 | minor | Edit and Mix windows: Tracks and Groups and Clips in full-height dock columns. Readiness estimate unchanged | +| 2026-10-25 | minor | Zoom Toggle row: Alt+Shift+E and Alt-click cancel Zoom Toggle and keep the view. Readiness estimate unchanged | +| 2026-10-25 | minor | Sends and pan knobs: Ctrl/Cmd-drag on a send's level, its options menu's Level and Pan sliders and the pan knobs gives fine adjustment. Readiness estimate unchanged | +| 2026-10-24 | minor | Plugin parameters: Alt/Option-click returns one to its default. Readiness estimate unchanged | +| 2026-10-24 | minor | Plugin parameter sliders: Ctrl/Cmd-drag moves them a tenth as far. Readiness estimate unchanged | +| 2026-10-24 | minor | Strip Silence sliders: Ctrl/Cmd-drag moves them a tenth as far. Readiness estimate unchanged | +| 2026-10-24 | minor | Meters: Peak Hold and Clip Indication can be set to 3 Seconds, Infinite or None (Setup › Metering…). Readiness estimate unchanged | | 2026-10-16 | minor | Track name menu: Scroll Into View brings the track to the top of the Edit window and the left of the Mix. Readiness estimate unchanged | | 2026-10-13 | minor | A plugin opens when it's inserted (#486) | | 2026-10-13 | minor | Drag across checkboxes (#399) |