Merge pull request #192 from grovebit/idle-repaint
Let the desktop app sleep when idle or covered
This commit is contained in:
commit
6754f04a05
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue