Merge pull request #192 from grovebit/idle-repaint

Let the desktop app sleep when idle or covered
This commit is contained in:
Brandon Thomas 2026-10-11 20:59:48 -04:00 committed by GitHub
commit 6754f04a05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 95 additions and 18 deletions

View File

@ -25,6 +25,15 @@ use std::sync::Arc;
struct App(SoundApp, Option<&'static logging::AppLogger>, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
/// 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::<String>(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.
@ -68,7 +77,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);
@ -283,3 +292,21 @@ fn main() -> eframe::Result {
fn sane_window_size([w, h]: [f32; 2]) -> [f32; 2] {
if w.is_finite() && h.is_finite() && w >= 960.0 && h >= 600.0 { [w.min(16_384.0), h.min(16_384.0)] } else { [1600.0, 1000.0] }
}
#[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);
}
}

View File

@ -580,6 +580,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)) {

View File

@ -234,7 +234,7 @@ pub struct SoundApp {
pub autosave_dir: Option<std::path::PathBuf>,
/// Folder for user plugin presets (native apps set it).
pub preset_dir: Option<std::path::PathBuf>,
last_autosave: f64,
last_autosave: Option<std::time::Instant>,
pub dialogs: dialogs::Dialogs,
pub meters: HashMap<TrackId, MeterDisplay>,
pub main_meter: MeterDisplay,
@ -297,7 +297,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(),
@ -511,15 +511,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"));
@ -827,21 +826,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.meters_live() {
let hz = if self.ui.window == MainWindow::Mix { 60 } else { 30 };
ctx.request_repaint_after(std::time::Duration::from_millis(1000 / hz));
} else if self.meters.values().any(|m| m.level[0] > 0.0001) {
ctx.request_repaint_after(std::time::Duration::from_millis(80));
} else if !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);
}
@ -907,6 +902,15 @@ 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.meters_live() {
let hz = if self.ui.window == MainWindow::Mix { 60 } else { 30 };
ctx.request_repaint_after(std::time::Duration::from_millis(1000 / hz));
} else if self.meters.values().any(|m| m.level[0] > 0.0001) {
ctx.request_repaint_after(std::time::Duration::from_millis(80));
} else if !self.pending_shots.is_empty() || !self.synthetic.is_empty() {
ctx.request_repaint();
}
}
/// UI state as JSON (control channel `ui.inspect`).
@ -994,6 +998,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());

View File

@ -0,0 +1,28 @@
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();
// Playing repaints at the meter rate (30 Hz in the Edit window, #207).
assert!(pass(&ctx, &mut app, true) <= Duration::from_millis(34));
pass(&ctx, &mut app, false);
assert!((pass(&ctx, &mut app, false).as_secs_f64() - 1.0).abs() < 1e-3);
}