diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index a54e7e1..27684a1 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -25,6 +25,15 @@ use std::sync::Arc; struct App(SoundApp, Option<&'static logging::AppLogger>, #[cfg(target_os = "macos")] Option); +/// Sends the title only when it changes, remembered here as X11 and Wayland can't read it back. +fn set_title(ctx: &egui::Context, title: String) { + let id = egui::Id::new("window_title"); + if ctx.data(|d| d.get_temp::(id)).as_deref() != Some(title.as_str()) { + ctx.data_mut(|d| d.insert_temp(id, title.clone())); + ctx.send_viewport_cmd(egui::ViewportCommand::Title(title)); + } +} + impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // What the realtime audio thread logged is written here, off that thread. @@ -61,7 +70,7 @@ impl eframe::App for App { } else { format!("{} — SoundCraft", self.0.engine.session().name) }; - ctx.send_viewport_cmd(egui::ViewportCommand::Title(title)); + set_title(ctx, title); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { self.0.raw_input_hook(raw); @@ -267,3 +276,21 @@ fn main() -> eframe::Result { }), ) } + +#[cfg(test)] +mod tests { + use super::set_title; + + #[test] + fn the_title_is_sent_only_when_it_changes() { + let ctx = egui::Context::default(); + let titles = |title: &str| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| set_title(ui.ctx(), title.to_string())); + out.textures_delta.clear(); + out.viewport_output.values().flat_map(|v| &v.commands).filter(|c| matches!(c, egui::ViewportCommand::Title(_))).count() + }; + assert_eq!(titles("Song — SoundCraft"), 1); + assert_eq!(titles("Song — SoundCraft"), 0); + assert_eq!(titles("Song * — SoundCraft"), 1); + } +} diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index eb1db9b..0e0a380 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -584,6 +584,10 @@ impl Player { self.side().editors.get(&(track, slot)).is_some_and(|e| e.is_open()) } + pub fn any_editor_open(&self) -> bool { + self.side().editors.values().any(|e| e.is_open()) + } + /// Shows a parameter change made elsewhere in the plugin's open editor. pub fn editor_set_param(&self, track: TrackId, slot: usize, id: &str, value: f32) { if let Some(e) = self.side().editors.get_mut(&(track, slot)) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 955bd5a..1e28b04 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -216,7 +216,7 @@ pub struct SoundApp { pub autosave_dir: Option, /// Folder for user plugin presets (native apps set it). pub preset_dir: Option, - last_autosave: f64, + last_autosave: Option, pub dialogs: dialogs::Dialogs, pub meters: HashMap, pub main_meter: MeterDisplay, @@ -276,7 +276,7 @@ impl SoundApp { native_menu_bar: false, autosave_dir: None, preset_dir: None, - last_autosave: 0.0, + last_autosave: None, dialogs: dialogs::Dialogs::default(), meters: HashMap::new(), main_meter: MeterDisplay::default(), @@ -473,15 +473,14 @@ impl SoundApp { } /// Every two minutes, copy a modified session to the autosave folder (not while recording). - fn autosave(&mut self, now: f64) { + /// Timed by the wall clock: egui's stands still while the window is hidden. + fn autosave(&mut self) { let Some(dir) = self.autosave_dir.clone() else { return }; - if self.last_autosave == 0.0 { - self.last_autosave = now; - } - if now - self.last_autosave < 120.0 || !self.engine.is_dirty() || self.engine.transport.recording { + let last = *self.last_autosave.get_or_insert_with(std::time::Instant::now); + if last.elapsed().as_secs() < 120 || !self.engine.is_dirty() || self.engine.transport.recording { return; } - self.last_autosave = now; + self.last_autosave = Some(std::time::Instant::now()); self.capture_plugin_states(); let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name); let path = dir.join(&name).join(format!("{name}.scraft")); @@ -798,16 +797,17 @@ impl SoundApp { } } self.write_automation(ctx); - self.autosave(now); + self.autosave(); if self.recorder.is_none() && !self.recorder_failed && self.engine.session().tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm) { self.ensure_input(); } - if self.is_playing() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() || !self.synthetic.is_empty() { - ctx.request_repaint(); - } + // Autosave and the macOS menu bar only run on frames, and plugin editors are native windows egui never hears from. + // egui takes its predicted frame time off every delay, so add it back. + let editors = self.player.as_ref().is_some_and(Player::any_editor_open); + ctx.request_repaint_after_secs(if editors { 0.03 } else { 1.0 } + ctx.input(|i| i.predicted_dt)); control::collect_screenshots(self, ctx); } @@ -873,6 +873,10 @@ impl SoundApp { score_editor::show(self, &ctx); palette::show(self, &ctx); dialogs::show(self, &ctx); + // Here, not in logic(): a covered or minimized window runs only logic(), with no vsync to pace it. + if self.is_playing() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() || !self.synthetic.is_empty() { + ctx.request_repaint(); + } } /// UI state as JSON (control channel `ui.inspect`). @@ -960,6 +964,20 @@ mod tests { assert_eq!(app.engine.transport.position, 0); } + #[test] + fn a_hidden_window_still_autosaves() { + let dir = std::env::temp_dir().join(format!("soundcraft-hidden-autosave-{}", std::process::id())); + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.autosave_dir = Some(dir.clone()); + app.run("track.new", json!({"count": 1, "format": "mono"})).unwrap(); + app.last_autosave = std::time::Instant::now().checked_sub(std::time::Duration::from_secs(121)); + let hidden = egui::ViewportInfo { minimized: Some(true), ..Default::default() }; + let raw = egui::RawInput { viewports: [(egui::ViewportId::ROOT, hidden)].into_iter().collect(), ..Default::default() }; + let _ = egui::Context::default().run_logic(&raw, |ctx| app.logic(ctx)); + assert!(app.latest_autosave().is_some()); + let _ = std::fs::remove_dir_all(dir); + } + #[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()); diff --git a/crates/ui-egui/tests/idle.rs b/crates/ui-egui/tests/idle.rs new file mode 100644 index 0000000..be7cfa7 --- /dev/null +++ b/crates/ui-egui/tests/idle.rs @@ -0,0 +1,27 @@ +use soundcraft_ui_egui::{Services, SoundApp}; +use std::time::Duration; + +fn pass(ctx: &egui::Context, app: &mut SoundApp, shown: bool) -> Duration { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + app.logic(ui.ctx()); + if shown { + app.ui(ui); + } + }); + out.textures_delta.clear(); + out.viewport_output[&egui::ViewportId::ROOT].repaint_delay +} + +#[test] +fn only_a_shown_window_animates_and_a_hidden_one_wakes_once_a_second() { + let ctx = egui::Context::default(); + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + for _ in 0..3 { + pass(&ctx, &mut app, true); + } + assert_ne!(pass(&ctx, &mut app, true), Duration::ZERO); + app.run("transport.play", serde_json::json!({})).unwrap(); + assert_eq!(pass(&ctx, &mut app, true), Duration::ZERO); + pass(&ctx, &mut app, false); + assert!((pass(&ctx, &mut app, false).as_secs_f64() - 1.0).abs() < 1e-3); +}