From 1f5bc965fe192be47ef30a384115b446a4b85073 Mon Sep 17 00:00:00 2001 From: coygeek <65363919+coygeek@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:28:07 -0700 Subject: [PATCH 1/4] fix(ui): follow live system appearance Resolve System appearance from egui on every frame and fall back to Dark when the integration cannot report a theme. Keep receiving OS changes during manual modes and apply the selected palette to both widget style branches and the current root UI. Remove the cached detector and its unused dependency. Cover startup, live transitions, manual overrides, saved System preferences, and user colors with an application-frame regression. Add reproducible offscreen system-theme inputs and document the fallback. --- Cargo.lock | 37 -------- Cargo.toml | 1 - crates/ui-egui/Cargo.toml | 1 - crates/ui-egui/examples/ui_shot.rs | 33 +++++++- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 3 - crates/ui-egui/src/theme.rs | 130 +++++++++++++++++++---------- docs/control-protocol.md | 9 +- 8 files changed, 125 insertions(+), 91 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 34de49a..8b26758 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -837,23 +837,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" -[[package]] -name = "dark-light" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f66b71ac6b73812b0bf51cda8dd646a268fbb38623f45b2b7907593c323f745" -dependencies = [ - "futures-channel", - "futures-core", - "objc2 0.6.5", - "objc2-foundation 0.3.2", - "wasm-bindgen", - "web-sys", - "windows-sys 0.61.2", - "winreg", - "zbus", -] - [[package]] name = "dasp_sample" version = "0.11.0" @@ -1319,15 +1302,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", -] - [[package]] name = "futures-core" version = "0.3.34" @@ -3596,7 +3570,6 @@ dependencies = [ name = "soundcraft-ui-egui" version = "0.4.0" dependencies = [ - "dark-light", "egui", "egui_kittest", "image", @@ -5106,16 +5079,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" -dependencies = [ - "cfg-if", - "windows-sys 0.61.2", -] - [[package]] name = "winresource" version = "0.1.31" diff --git a/Cargo.toml b/Cargo.toml index fe55a4f..95b9a8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,6 @@ cpal = "0.16" egui = "0.36" eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" -dark-light = "3" rfd = "0.17" image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 0439b8b..c91b324 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -21,7 +21,6 @@ soundcraft-engine.workspace = true soundcraft-playback.workspace = true soundcraft-video.workspace = true egui.workspace = true -dark-light.workspace = true serde.workspace = true serde_json.workspace = true log.workspace = true diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index acb1d5a..073b823 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -1,9 +1,10 @@ //! Render the real UI offscreen (no window, no focus stealing) and save PNGs. //! -//! `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png [script.jsonl] [--blank] [--size WxH]` +//! `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png [script.jsonl] [--blank] [--size WxH] [--system-theme light|dark|none]` //! //! Script lines are control requests (`{"method": "...", "params": {...}}`), `{"shot": "path.png"}` -//! or `{"steps": n}`. The demo session is loaded unless `--blank`. +//! or `{"steps": n}`. `{"system_theme": "light"}` (also `"dark"` or `"none"`) changes the +//! simulated OS appearance for subsequent frames. The demo session is loaded unless `--blank`. use soundcraft_ui_egui::{ControlRequest, Services, SoundApp}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -11,11 +12,29 @@ use std::sync::mpsc; static READY: AtomicBool = AtomicBool::new(false); +fn system_theme(value: &str) -> Option> { + match value { + "light" => Some(Some(egui::Theme::Light)), + "dark" => Some(Some(egui::Theme::Dark)), + "none" => Some(None), + _ => None, + } +} + fn main() { let args: Vec = std::env::args().skip(1).collect(); let out = args.first().cloned().unwrap_or_else(|| "soundcraft.png".into()); let script = args.get(1).filter(|a| !a.starts_with("--")).cloned(); let blank = args.iter().any(|a| a == "--blank"); + let appearance = if let Some(i) = args.iter().position(|a| a == "--system-theme") { + let Some(appearance) = args.get(i + 1).and_then(|value| system_theme(value)) else { + eprintln!("--system-theme requires light, dark or none"); + return; + }; + appearance + } else { + None + }; let (w, h) = args .iter() .position(|a| a == "--size") @@ -39,6 +58,7 @@ fn main() { app, ); harness.input_mut().max_texture_side = Some(8192); + harness.input_mut().system_theme = appearance; READY.store(true, Ordering::Relaxed); let step = |h: &mut egui_kittest::Harness| { let mut raw = std::mem::take(h.input_mut()); @@ -63,6 +83,15 @@ fn main() { eprintln!("bad line: {line}"); continue; }; + if let Some(value) = v.get("system_theme") { + let Some(appearance) = value.as_str().and_then(system_theme) else { + eprintln!("system_theme requires light, dark or none: {line}"); + continue; + }; + harness.input_mut().system_theme = appearance; + step(&mut harness); + continue; + } if let Some(p) = v.get("shot").and_then(|x| x.as_str()) { for _ in 0..3 { step(&mut harness); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc0685d..1a28d99 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -818,6 +818,8 @@ impl SoundApp { /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); + // The integration creates this Ui before logic resolves the current frame's palette. + ui.set_style(ctx.global_style()); if !self.fonts_ready { ctx.request_repaint(); return; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d8c40e7..3fb49ce 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -538,9 +538,6 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option bool { LIGHT_THEME.load(Ordering::Relaxed) } -/// Whether `mode` resolves to the Light palette right now. Never blocks. +/// Resolve each frame from the integration's current OS appearance. Unknown uses Dark. pub fn wants_light(ctx: &egui::Context, mode: ThemeMode) -> bool { match mode { ThemeMode::Light => true, ThemeMode::Dark => false, - ThemeMode::System => system_is_light(ctx), + ThemeMode::System => ctx.system_theme() == Some(egui::Theme::Light), } } -/// Forget the cached system appearance so the next System lookup asks again. -pub fn refresh_system() { - let _ = SYSTEM.compare_exchange(SYSTEM_DARK, SYSTEM_UNKNOWN, Ordering::Relaxed, Ordering::Relaxed); - let _ = SYSTEM.compare_exchange(SYSTEM_LIGHT, SYSTEM_UNKNOWN, Ordering::Relaxed, Ordering::Relaxed); -} - -/// The OS appearance. The query (a D-Bus call on Linux) runs once on a background thread; until it -/// answers this reports dark, and `ctx` is repainted when the answer arrives. -#[cfg(not(target_arch = "wasm32"))] -fn system_is_light(ctx: &egui::Context) -> bool { - if SYSTEM.compare_exchange(SYSTEM_UNKNOWN, SYSTEM_ASKING, Ordering::Relaxed, Ordering::Relaxed).is_ok() { - let ctx = ctx.clone(); - let spawned = std::thread::Builder::new().name("soundcraft-theme".into()).spawn(move || { - let light = matches!(dark_light::detect(), Ok(dark_light::Mode::Light)); - SYSTEM.store(if light { SYSTEM_LIGHT } else { SYSTEM_DARK }, Ordering::Relaxed); - ctx.request_repaint(); - }); - if spawned.is_err() { - SYSTEM.store(SYSTEM_DARK, Ordering::Relaxed); - } - } - SYSTEM.load(Ordering::Relaxed) == SYSTEM_LIGHT -} - -/// On the web the query is a synchronous `matchMedia` lookup (no threads there). -#[cfg(target_arch = "wasm32")] -fn system_is_light(_ctx: &egui::Context) -> bool { - if SYSTEM.compare_exchange(SYSTEM_UNKNOWN, SYSTEM_ASKING, Ordering::Relaxed, Ordering::Relaxed).is_ok() { - let light = matches!(dark_light::detect(), Ok(dark_light::Mode::Light)); - SYSTEM.store(if light { SYSTEM_LIGHT } else { SYSTEM_DARK }, Ordering::Relaxed); - } - SYSTEM.load(Ordering::Relaxed) == SYSTEM_LIGHT -} - /// Track colours tinted for clip bodies. pub fn clip_colors(rgb: [u8; 3], selected: bool) -> (Color32, Color32, Color32) { let [r, g, b] = rgb; @@ -430,8 +389,13 @@ pub fn apply(ctx: &egui::Context, light: bool) { v.widgets.inactive.fg_stroke.color = t.text; v.window_corner_radius = egui::CornerRadius::same(6); v.menu_corner_radius = egui::CornerRadius::same(4); - ctx.set_visuals(v); - ctx.global_style_mut(|s| { + // Keep receiving native OS appearance changes even when our palette is manual. On macOS, + // pinning egui's theme also pins the window appearance and hides later OS changes. + ctx.set_theme(egui::ThemePreference::System); + // Both egui style branches must use our resolved palette: egui can select either branch + // as the OS changes, including while SoundCraft's manual Light/Dark choice stays fixed. + ctx.all_styles_mut(|s| { + s.visuals = v.clone(); s.spacing.item_spacing = egui::vec2(6.0, 4.0); s.spacing.button_padding = egui::vec2(6.0, 2.0); s.interaction.tooltip_delay = 0.4; @@ -476,4 +440,78 @@ mod tests { assert_eq!(app.run("ui.theme", json!({"mode": "system"})), Ok(json!({"mode": "system"}))); assert!(crate::menus::UI_COMMANDS.iter().any(|c| c.0 == "ui.theme")); } + + #[test] + fn application_frames_follow_system_appearance_and_preserve_manual_palettes() { + // Keep all frame assertions in one test: custom-painted tokens share LIGHT_THEME. + fn frame(app: &mut SoundApp, ctx: &egui::Context, system_theme: Option, light: bool) { + let mode = app.ui.theme; + let colors = |app: &SoundApp| { + app.engine + .session() + .tracks + .iter() + .map(|track| { + (track.color, track.playlists.iter().flat_map(|playlist| &playlist.clips).map(|clip| clip.color).collect::>()) + }) + .collect::>() + }; + let before = colors(app); + let mut output = ctx.run_ui(egui::RawInput { system_theme, ..Default::default() }, |ui| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + let tokens = if light { Tokens::LIGHT } else { Tokens::DARK }; + assert_eq!(is_light(), light, "custom palette for {mode:?} with {system_theme:?}"); + assert_eq!(Tokens::current().panel_bg, tokens.panel_bg); + assert_eq!(ctx.options(|options| options.theme_preference), egui::ThemePreference::System); + for theme in [egui::Theme::Light, egui::Theme::Dark] { + let style = ctx.style_of(theme); + assert_eq!(style.visuals.dark_mode, !light); + assert_eq!(style.visuals.panel_fill, tokens.panel_bg); + assert_eq!(style.visuals.widgets.inactive.bg_fill, tokens.button); + assert_eq!(style.visuals.widgets.inactive.fg_stroke.color, tokens.text); + assert_eq!(style.spacing.button_padding, egui::vec2(6.0, 2.0)); + } + app.ui(ui); + assert_eq!(ui.visuals().panel_fill, tokens.panel_bg); + assert_eq!(ui.visuals().widgets.inactive.bg_fill, tokens.button); + }); + output.textures_delta.clear(); + assert_eq!(app.ui.theme, mode); + assert_eq!(serde_json::to_value(&app.ui).unwrap()["theme"], mode.id()); + assert_eq!(colors(app), before, "appearance must preserve track and clip colors"); + } + + let appearances = [Some(egui::Theme::Light), Some(egui::Theme::Dark), Some(egui::Theme::Light), None]; + for initial in [Some(egui::Theme::Light), Some(egui::Theme::Dark), None] { + let ctx = egui::Context::default(); + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.ui = serde_json::from_value(json!({"theme": "system"})).unwrap(); + frame(&mut app, &ctx, initial, initial == Some(egui::Theme::Light)); + } + + let ctx = egui::Context::default(); + let mut engine = soundcraft_engine::demo::demo_engine(); + let track = engine.session_mut().tracks.first_mut().unwrap(); + track.color = [19, 83, 147]; + track.playlists.iter_mut().flat_map(|playlist| &mut playlist.clips).next().unwrap().color = Some([211, 57, 99]); + let mut app = SoundApp::new(engine, None, Services::default()); + for mode in [ThemeMode::System, ThemeMode::Light, ThemeMode::Dark] { + app.run("ui.theme", json!({"mode": mode.id()})).unwrap(); + for appearance in appearances { + let light = mode == ThemeMode::Light || (mode == ThemeMode::System && appearance == Some(egui::Theme::Light)); + frame(&mut app, &ctx, appearance, light); + } + if mode != ThemeMode::System { + let latest = if mode == ThemeMode::Light { egui::Theme::Dark } else { egui::Theme::Light }; + frame(&mut app, &ctx, Some(latest), mode == ThemeMode::Light); + app.run("ui.theme", json!({"mode": "system"})).unwrap(); + // Resolve the current context immediately, without another OS appearance event. + app.logic(&ctx); + assert_eq!(is_light(), latest == egui::Theme::Light); + assert_eq!(ctx.global_style().visuals.dark_mode, latest != egui::Theme::Light); + frame(&mut app, &ctx, Some(latest), latest == egui::Theme::Light); + } + } + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ba7b079..a64c4c8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -64,4 +64,11 @@ soundcraft-cli app --port $P ui.menu.invoke '{"path":"Track > New..."}' Offscreen (no window): `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png script.jsonl`, where each script line is a request (`{"method": …, "params": …}`), `{"shot": "path.png"}` or -`{"steps": n}`. +`{"steps": n}`. Use `--system-theme light|dark|none` to supply the initial OS appearance, and +script events such as `{"system_theme": "dark"}` to change it on subsequent frames. + +Appearance defaults to Dark. `ui.theme {"mode":"system"}` follows the appearance supplied by +the native or web integration at launch and on later frames, while the saved choice stays System. +When the integration cannot report an appearance, System uses Dark. Manual Light and Dark stay +fixed when the OS appearance changes. An offscreen render without `--system-theme` supplies no +OS appearance and therefore resolves System to Dark. From d3348bcbbcf90b78500523ea42625e60bd0c02aa Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:34:26 +0100 Subject: [PATCH 2/4] Nudge clip gain with Ctrl+Shift+Up/Down on Windows and Linux too Ctrl+Shift+Up/Down (#76) only worked on macOS. The registry's Ctrl there is the Mac Control key, which has no key of its own on Windows and Linux (#20), so the keys did nothing there, while the Shortcuts window and the command palette still showed Ctrl+Shift+Up. Pro Tools for Windows puts these on the Start key, but egui does not report that key off macOS, and Windows and GNOME use Win/Super+Shift+ Up/Down to resize and move windows. So off macOS the nudge is Ctrl+Shift+Up/Down: the same keys as on the Mac and what the labels already say. No SoundCraft shortcut uses them there; Ctrl+Up/Down without Shift still extends the selection. --- crates/ui-egui/src/shortcuts.rs | 6 ++++++ crates/ui-egui/tests/shortcuts.rs | 24 ++++++++++++++---------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index bffa713..90a4e08 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -170,6 +170,12 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { let _ = app.run(id, json!({})); true } + // Ctrl+Shift+Up/Down nudge clip gain off macOS too (the registry's Ctrl is the Mac Control key). + Key::ArrowUp | Key::ArrowDown if !mac && m.ctrl && m.shift && !m.alt => { + let id = if key == Key::ArrowUp { "clip.gain_nudge_up" } else { "clip.gain_nudge_down" }; + let _ = app.run(id, json!({})); + true + } // Commands Keyboard Focus: single-key editing (Edit window only). k if plain && app.ui.window == MainWindow::Edit && app.engine.session().edit.keyboard_focus == "commands" && commands_focus(app, k) => true, Key::R | Key::T if plain => { diff --git a/crates/ui-egui/tests/shortcuts.rs b/crates/ui-egui/tests/shortcuts.rs index 0844e66..fc7fdc4 100644 --- a/crates/ui-egui/tests/shortcuts.rs +++ b/crates/ui-egui/tests/shortcuts.rs @@ -1,3 +1,4 @@ +use egui::os::OperatingSystem; use egui::{Key, Modifiers}; use serde_json::json; use soundcraft_ui_egui::{Services, SoundApp, shortcuts}; @@ -9,14 +10,17 @@ fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers #[test] fn ctrl_shift_arrows_nudge_the_selected_clip_gain() { - let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); - let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id; - app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap(); - let ctx = egui::Context::default(); - ctx.set_os(egui::os::OperatingSystem::Mac); - let ctrl_shift = Modifiers { ctrl: true, shift: true, ..Modifiers::NONE }; - press(&ctx, &mut app, Key::ArrowUp, ctrl_shift); - press(&ctx, &mut app, Key::ArrowUp, ctrl_shift); - press(&ctx, &mut app, Key::ArrowDown, ctrl_shift); - assert_eq!(app.engine.session().find_clip(kick).unwrap().1.gain_db, 1.0); + let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + for (os, ctrl) in [(OperatingSystem::Mac, Modifiers::CTRL), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id; + app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap(); + let ctx = egui::Context::default(); + ctx.set_os(os); + let ctrl_shift = Modifiers { shift: true, ..ctrl }; + press(&ctx, &mut app, Key::ArrowUp, ctrl_shift); + press(&ctx, &mut app, Key::ArrowUp, ctrl_shift); + press(&ctx, &mut app, Key::ArrowDown, ctrl_shift); + assert_eq!(app.engine.session().find_clip(kick).unwrap().1.gain_db, 1.0, "{os:?}"); + } } From 0734281104f53df2d4cd5f2c094674cd01b27739 Mon Sep 17 00:00:00 2001 From: reoniazx <76652649+reoniazx@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:15:28 +0800 Subject: [PATCH 3/4] Stop playback when replacing the session --- crates/engine/src/lib.rs | 5 ++ crates/engine/tests/session_transport.rs | 30 +++++++++ crates/ui-egui/src/lib.rs | 81 +++++++++++++++++++++++- 3 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/session_transport.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 88584e0..f12901d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -53,6 +53,8 @@ pub type Result = std::result::Result; /// Requests for the transport/audio engine, drained by the host app each frame. #[derive(Debug, Clone, PartialEq, serde::Serialize)] pub enum TransportRequest { + /// Stop the old session's transport without applying stop-time edits to the new document. + ResetSession, Play, Stop, TogglePlay, @@ -158,6 +160,9 @@ impl Engine { /// Replace the whole document (open / new); clears history. pub fn replace_session(&mut self, s: Session) { + self.transport = TransportStatus { position: s.edit.selection.start.max(0), ..TransportStatus::default() }; + self.transport_requests.clear(); + self.transport_requests.push(TransportRequest::ResetSession); self.doc = Arc::new(s); self.undo.clear(); self.redo.clear(); diff --git a/crates/engine/tests/session_transport.rs b/crates/engine/tests/session_transport.rs new file mode 100644 index 0000000..73048e1 --- /dev/null +++ b/crates/engine/tests/session_transport.rs @@ -0,0 +1,30 @@ +use serde_json::json; +use soundcraft_engine::{Engine, TransportRequest, TransportStatus}; +use soundcraft_model::Session; +use soundcraft_time::Range; + +#[test] +fn replacing_session_resets_status_and_discards_old_transport_requests() { + let mut engine = Engine::default(); + engine.transport = TransportStatus { playing: true, recording: true, position: 48_000 }; + engine.execute("transport.play", &json!({"from": 96_000})).unwrap(); + engine.execute("transport.record", &json!({})).unwrap(); + let mut session = Session::default(); + session.edit.selection = Range::point(12_000); + engine.replace_session(session); + assert_eq!(engine.transport, TransportStatus { position: 12_000, ..TransportStatus::default() }); + assert_eq!(engine.transport_requests, vec![TransportRequest::ResetSession]); + assert!(!engine.is_dirty()); +} + +#[test] +fn failed_new_session_keeps_the_current_transport() { + let mut engine = Engine::default(); + engine.transport = TransportStatus { playing: true, position: 48_000, ..TransportStatus::default() }; + engine.execute("transport.play", &json!({})).unwrap(); + let status = engine.transport; + let requests = engine.transport_requests.clone(); + assert!(engine.execute("session.new", &json!({"sample_rate": 0})).is_err()); + assert_eq!(engine.transport, status); + assert_eq!(engine.transport_requests, requests); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc0685d..c4345d3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -642,6 +642,25 @@ impl SoundApp { let reqs: Vec = std::mem::take(&mut self.engine.transport_requests); for r in reqs { match r { + TransportRequest::ResetSession => { + if let Some(p) = &self.player { + p.stop(); + p.set_recording(false); + p.set_speed(1.0); + p.locate(self.engine.transport.position); + } + self.sim = None; + // The old capture belongs to the replaced document, never to the new one. + self.recorder = None; + self.recorder_failed = false; + self.punch = None; + self.gesture = None; + self.touched.clear(); + self.last_write_at = i64::MIN; + self.edit_layout.follow_hold = false; + self.meters.clear(); + self.main_meter = MeterDisplay::default(); + } TransportRequest::Play => { if !self.is_playing() { self.play_from_selection(); @@ -761,13 +780,14 @@ impl SoundApp { self.engine.end_merge(); } } + // Stop the old playback before sending a replacement session to the audio thread. + self.handle_transport(dt); if self.engine.revision != self.last_rev { self.last_rev = self.engine.revision; if let Some(p) = &self.player { p.update_session(self.engine.session_arc()); } } - self.handle_transport(dt); self.write_automation(ctx); self.autosave(now); if self.recorder.is_none() @@ -889,7 +909,64 @@ fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) { #[cfg(test)] mod tests { - use super::automation_step_due; + use super::{Services, SoundApp, automation_step_due}; + use serde_json::json; + + #[test] + fn new_session_stops_playback_and_the_old_clock() { + for (command, params) in [ + ("session.new", json!({"name": "New project", "sample_rate": 96_000})), + ("session.new", json!({"template": "demo"})), + ("session.close", json!({})), + ] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("transport.play", json!({"from": 48_000})).unwrap(); + app.handle_transport(0.25); + assert!(app.is_playing()); + assert!(app.position() > 48_000); + app.run(command, params).unwrap(); + let insertion = app.engine.session().edit.selection.start; + app.handle_transport(0.25); + assert!(!app.is_playing(), "{command}"); + assert_eq!(app.position(), insertion, "{command}"); + assert_eq!(app.engine.transport.position, insertion, "{command}"); + app.handle_transport(0.25); + assert_eq!(app.engine.transport.position, insertion, "the old clock must stay stopped"); + app.run("transport.play", json!({})).unwrap(); + app.handle_transport(0.25); + assert!(app.is_playing()); + assert_eq!(app.position(), insertion + i64::from(app.engine.session().sample_rate.hz()) / 4); + } + } + + #[test] + fn new_session_cancels_play_queued_before_the_next_frame() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("transport.play", json!({})).unwrap(); + app.run("session.new", json!({})).unwrap(); + app.handle_transport(0.25); + assert!(!app.is_playing()); + assert_eq!(app.engine.transport.position, 0); + } + + #[test] + fn session_reset_does_not_apply_old_stop_edits_to_the_new_session() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("transport.play", json!({"from": 48_000})).unwrap(); + app.handle_transport(0.25); + let mut session = soundcraft_model::Session::default(); + session.edit.selection = soundcraft_time::Range::new(12_000, 24_000); + session.edit.insertion_follows_playback = true; + session.edit.scrolling = "after_playback".into(); + let selection = session.edit.selection; + app.engine.replace_session(session); + app.handle_transport(0.25); + assert!(!app.is_playing()); + assert_eq!(app.engine.session().edit.selection, selection); + assert_eq!(app.position(), selection.start); + assert_eq!(app.engine.session().edit.zoom.scroll, 0); + assert!(!app.engine.is_dirty()); + } #[test] fn automation_first_pass_after_start_is_due_without_overflow() { From 94e13543ec6421c6015ca772e390ab4be9ce38a1 Mon Sep 17 00:00:00 2001 From: reoniazx <76652649+reoniazx@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:15:18 +0800 Subject: [PATCH 4/4] Keep the MIDI editor dock at its resized height --- crates/ui-egui/src/midi_editor.rs | 3 ++ crates/ui-egui/tests/midi_panel.rs | 79 ++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 crates/ui-egui/tests/midi_panel.rs diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index 3716c98..a4882d4 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -56,6 +56,9 @@ fn is_black(p: i32) -> bool { } pub fn show(app: &mut SoundApp, ui: &mut Ui) { + // Painter-only contents do not advance the layout. Claim the resized + // space, including the empty state, so the dock keeps its chosen height. + ui.take_available_space(); let t = Tokens::current(); let full = ui.max_rect(); ui.painter().rect_filled(full, 0.0, t.panel_bg); diff --git a/crates/ui-egui/tests/midi_panel.rs b/crates/ui-egui/tests/midi_panel.rs new file mode 100644 index 0000000..45ec0ec --- /dev/null +++ b/crates/ui-egui/tests/midi_panel.rs @@ -0,0 +1,79 @@ +use egui::{Event, Id, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, midi_editor}; + +fn 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 panel(ctx: &egui::Context) -> Rect { + egui::containers::panel::PanelState::load(ctx, Id::new("midi_editor")).unwrap().outer_rect +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() } +} + +fn check_resize(with_clip: bool) { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.ui.show_midi_editor = true; + if with_clip { + app.run("edit.select", json!({"tracks": ["Keys"]})).unwrap(); + } + assert_eq!(midi_editor::target_clip(&app).is_some(), with_clip); + let ctx = egui::Context::default(); + for _ in 0..6 { + frame(&ctx, &mut app, vec![]); + } + let initial = panel(&ctx); + assert!((initial.height() - 320.0).abs() < 1.0, "initial panel: {initial:?}"); + let notes = app.engine.session().clone(); + + // Drag the shared boundary up and down, then allow several idle frames + // to catch a panel that looks resized but snaps back after release. + for height in [440.0, 280.0, 200.0] { + let before = panel(&ctx); + let start = pos2(before.center().x, before.top() + 1.0); + let end = pos2(start.x, before.bottom() - height); + frame(&ctx, &mut app, vec![Event::PointerMoved(start)]); + frame(&ctx, &mut app, vec![button(start, true)]); + for step in 1..=6 { + frame(&ctx, &mut app, vec![Event::PointerMoved(start.lerp(end, step as f32 / 6.0))]); + } + frame(&ctx, &mut app, vec![button(end, false)]); + for _ in 0..4 { + frame(&ctx, &mut app, vec![]); + } + let after = panel(&ctx); + assert!((after.height() - height.max(260.0)).abs() < 1.0, "requested {height}, panel: {after:?}"); + assert!((after.bottom() - initial.bottom()).abs() < 1.0); + assert!(app.edit_layout.timeline[3] <= after.top()); + } + + // Closing and reopening the dock keeps the chosen height. + let resized = panel(&ctx); + app.run("window.midi_editor", json!({})).unwrap(); + frame(&ctx, &mut app, vec![]); + app.run("window.midi_editor", json!({})).unwrap(); + for _ in 0..4 { + frame(&ctx, &mut app, vec![]); + } + assert_eq!(panel(&ctx), resized); + assert_eq!(*app.engine.session(), notes); +} + +#[test] +fn midi_panel_resizes_with_a_selected_clip() { + check_resize(true); +} + +#[test] +fn midi_panel_resizes_without_a_selected_clip() { + check_resize(false); +}