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/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/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..6da9b92 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() @@ -818,6 +838,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; @@ -889,7 +911,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() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a5a8c87..5efecd8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -550,9 +550,6 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option 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/src/ops_windows.rs b/crates/ui-egui/src/ops_windows.rs index d298ab3..87c7dde 100644 --- a/crates/ui-egui/src/ops_windows.rs +++ b/crates/ui-egui/src/ops_windows.rs @@ -123,6 +123,21 @@ fn beat_detective(app: &mut SoundApp, ctx: &egui::Context) { app.ui.show_beat_detective = open; } +/// Command the Tempo Operations Apply button sends. Stretch uses `factor = start / end` +/// so a lower end BPM lengthens the selection (`event.tempo_stretch` has no `end_bpm`). +pub(crate) fn tempo_ops_apply(curve: &str, start_bpm: f64, end_bpm: f64) -> (&'static str, Value) { + let start = if start_bpm.is_finite() { start_bpm.max(1.0) } else { 1.0 }; + let end = if end_bpm.is_finite() { end_bpm.max(1.0) } else { 1.0 }; + match curve { + "constant" => ("event.tempo_constant", json!({"bpm": start_bpm})), + "parabolic" => ("event.tempo_parabolic", json!({"start_bpm": start_bpm, "end_bpm": end_bpm})), + "s-curve" => ("event.tempo_s_curve", json!({"start_bpm": start_bpm, "end_bpm": end_bpm})), + "scale" => ("event.tempo_scale", json!({"factor": end / start})), + "stretch" => ("event.tempo_stretch", json!({"factor": start / end})), + _ => ("event.tempo_linear", json!({"start_bpm": start_bpm, "end_bpm": end_bpm})), + } +} + fn tempo_ops(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.ui.show_tempo_ops; if !open { @@ -136,16 +151,14 @@ fn tempo_ops(app: &mut SoundApp, ctx: &egui::Context) { }); ui.add(egui::DragValue::new(&mut app.ops.tempo_start).range(5.0..=999.0).prefix("start bpm ")); ui.add(egui::DragValue::new(&mut app.ops.tempo_end).range(5.0..=999.0).prefix("end bpm ")); - ui.label(egui::RichText::new("Applies across the edit selection (constant: at the insertion point; scale: factor = end / start).").small()); + ui.label( + egui::RichText::new( + "Applies across the edit selection (constant: at the insertion point; scale: factor = end / start; stretch: factor = start / end).", + ) + .small(), + ); if ui.button(egui::RichText::new("Apply").strong()).clicked() { - let (id, p) = match app.ops.tempo_curve.as_str() { - "constant" => ("event.tempo_constant", json!({"bpm": app.ops.tempo_start})), - "parabolic" => ("event.tempo_parabolic", json!({"start_bpm": app.ops.tempo_start, "end_bpm": app.ops.tempo_end})), - "s-curve" => ("event.tempo_s_curve", json!({"start_bpm": app.ops.tempo_start, "end_bpm": app.ops.tempo_end})), - "scale" => ("event.tempo_scale", json!({"factor": app.ops.tempo_end / app.ops.tempo_start.max(1.0)})), - "stretch" => ("event.tempo_stretch", json!({"end_bpm": app.ops.tempo_end})), - _ => ("event.tempo_linear", json!({"start_bpm": app.ops.tempo_start, "end_bpm": app.ops.tempo_end})), - }; + let (id, p) = tempo_ops_apply(&app.ops.tempo_curve, app.ops.tempo_start, app.ops.tempo_end); let r = app.run(id, p); result_line(ui, &r); } @@ -274,3 +287,25 @@ fn rtp(app: &mut SoundApp, ctx: &egui::Context) { }); app.ui.show_rtp = open; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stretch_uses_start_over_end_and_scale_uses_end_over_start() { + let (id, p) = tempo_ops_apply("stretch", 120.0, 60.0); + assert_eq!(id, "event.tempo_stretch"); + assert!((p["factor"].as_f64().unwrap() - 2.0).abs() < 1e-9, "{p}"); + let (_, p) = tempo_ops_apply("stretch", 120.0, 120.0); + assert!((p["factor"].as_f64().unwrap() - 1.0).abs() < 1e-9, "{p}"); + let (id, p) = tempo_ops_apply("scale", 120.0, 60.0); + assert_eq!(id, "event.tempo_scale"); + assert!((p["factor"].as_f64().unwrap() - 0.5).abs() < 1e-9, "{p}"); + let (_, p) = tempo_ops_apply("stretch", f64::NAN, f64::INFINITY); + assert!((p["factor"].as_f64().unwrap() - 1.0).abs() < 1e-9, "{p}"); + let (id, p) = tempo_ops_apply("linear", 100.0, 140.0); + assert_eq!(id, "event.tempo_linear"); + assert_eq!(p["end_bpm"], 140.0); + } +} 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/src/theme.rs b/crates/ui-egui/src/theme.rs index 113e152..66c3380 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -1,18 +1,11 @@ //! Design tokens. Colours were chosen by eye to give a dark, studio-style look; they are ours. use egui::{Color32, FontFamily, FontId}; -use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; /// Whether the Light palette is active. Dark is the default (the incumbent is dark-only). static LIGHT_THEME: AtomicBool = AtomicBool::new(false); -/// The operating system's appearance, asked once off the UI thread. -static SYSTEM: AtomicU8 = AtomicU8::new(SYSTEM_UNKNOWN); -const SYSTEM_UNKNOWN: u8 = 0; -const SYSTEM_ASKING: u8 = 1; -const SYSTEM_DARK: u8 = 2; -const SYSTEM_LIGHT: u8 = 3; - /// The Appearance setting (`ui.theme {mode}`). Dark by default; System and Light are opt-in. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] @@ -335,49 +328,15 @@ pub fn is_light() -> 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/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 96677a6..b7b34e5 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -446,11 +446,11 @@ pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) { let r = ui.text_edit_singleline(&mut edit); ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone())); if r.lost_focus() && edit != name { - let _ = app.run("mix.bus_rename", json!({"bus": name, "name": edit})); + let _ = app.run("setup.io", json!({"action": "rename_bus", "name": name, "new_name": edit})); } ui.label(fmt); if ui.small_button("Delete").clicked() { - let _ = app.run("mix.bus_delete", json!({"bus": name})); + let _ = app.run("setup.io", json!({"action": "delete_bus", "name": name})); } ui.end_row(); } 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); +} 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:?}"); + } } 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.