From 8a7fa4f120c545cd6faf18daf6180d8ba701a3cd Mon Sep 17 00:00:00 2001 From: weter11 Date: Sun, 16 Aug 2026 07:28:07 -0200 Subject: [PATCH] =?UTF-8?q?perf(ui):=20drop=20unconditional=20per-frame=20?= =?UTF-8?q?repaint=20=E2=80=94=20idle=20window=20goes=20to=20~0%=20CPU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update() ended with an unconditional ctx.request_repaint(), forcing egui to render a full frame every vsync. On a 165 Hz display that pegs the UI thread (~65% of one core idle; 353% under unthrottled Xvfb) even when the window sits untouched. Replace it with a scoped repaint policy. All four channel drains in update() now report whether they drained anything, and repaints are only requested when the UI actually has new state to show: - any drained message (cover art, download progress, play result, async op) -> render immediately - active downloads -> request_repaint_after(250 ms) so the passive ProgressBar and the Instant-based ETA countdown keep ticking between channel messages (egui::Spinner self-repaints while visible, so cover loading / account / proton / steamguard spinners cover themselves) - pending play result -> request_repaint_after(1 s) so a game exit or login-required event is picked up while the window is idle With nothing pending the UI schedules no repaint and the main thread blocks in the event loop until the next input event (which egui repaints on itself). Measured idle (10 s samples, Xvfb :99, fresh config, no Steam session): before: 353% of one core (UI thread), 331% whole-process (top-style) after: 0% of one core (UI thread), 1.6% whole-process Input wake: 40 XTEST key events -> 288% burst, then settles back to 0%. Same binary pair, same display, only the repaint policy differs. --- src/ui.rs | 59 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 0bc69aa..398cf7b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -476,8 +476,10 @@ impl SteamLauncher { self.library.iter().find(|g| g.app_id == appid) } - fn poll_image_results(&mut self, ctx: &egui::Context) { + fn poll_image_results(&mut self, ctx: &egui::Context) -> bool { + let mut drained = false; while let Ok((appid, variant, result)) = self.image_rx.try_recv() { + drained = true; match result { Some(path) => { self.cover_fetch_failures.remove(&(appid, variant)); @@ -512,6 +514,7 @@ impl SteamLauncher { } self.pending_images.remove(&appid); } + drained } fn ensure_metadata_requested(&mut self, appid: AppId) { @@ -731,13 +734,15 @@ impl SteamLauncher { self.status = "Logged out".to_string(); } - fn poll_download_progress(&mut self) { + fn poll_download_progress(&mut self) -> bool { let mut finished: Vec = Vec::new(); + let mut drained = false; // Drain every active task's channel independently — each task is // strictly bound to its own AppID + game title. for (appid, task) in self.download_tasks.iter_mut() { while let Ok(progress) = task.rx.try_recv() { + drained = true; task.progress = Some(progress.clone()); let game_name = task.game_name.clone(); match progress.state { @@ -829,13 +834,16 @@ impl SteamLauncher { for appid in finished { self.download_tasks.remove(&appid); } + drained } - fn poll_play_result(&mut self) { + fn poll_play_result(&mut self) -> bool { + let mut drained = false; if let Some(rx) = &self.play_result_rx { match rx.try_recv() { Ok(message) => { + drained = true; let mut finished = true; if let Some(value) = message.strip_prefix("__RUNNING__") { finished = false; @@ -874,6 +882,7 @@ impl SteamLauncher { Err(std::sync::mpsc::TryRecvError::Empty) => {} } } + drained } fn start_install(&mut self, app_id: u32, platform: DepotPlatform, cached_vdf: Option>, filter_depots: Option>) { @@ -921,8 +930,10 @@ impl SteamLauncher { }); } - fn poll_async_ops(&mut self) { + fn poll_async_ops(&mut self) -> bool { + let mut drained = false; while let Ok(op) = self.operation_rx.try_recv() { + drained = true; match op { AsyncOp::DownloadStarted(appid, rx, state) => { // Register a new per-AppID task. If one already exists for @@ -1228,7 +1239,7 @@ impl SteamLauncher { if let Some(preferred_id) = self.launcher_config.preferred_launch_options.get(&appid) { if let Some(option) = options.iter().find(|o| &o.id == preferred_id) { self.start_launch_task(&game, option.clone(), proton_path); - return; + return drained; } } @@ -1260,6 +1271,7 @@ impl SteamLauncher { } } } + drained } fn confirmation_validation_message(&self) -> Option { @@ -3616,10 +3628,10 @@ fn scan_proton_runtimes(config: &LauncherConfig) -> (Vec, Vec) { impl eframe::App for SteamLauncher { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.poll_image_results(ctx); - self.poll_download_progress(); - self.poll_play_result(); - self.poll_async_ops(); + let drained_images = self.poll_image_results(ctx); + let drained_progress = self.poll_download_progress(); + let drained_play = self.poll_play_result(); + let drained_ops = self.poll_async_ops(); egui::TopBottomPanel::top("status").show(ctx, |ui| { ui.horizontal(|ui| { @@ -4703,6 +4715,33 @@ impl eframe::App for SteamLauncher { self.draw_depot_install_selection_modal(ctx); self.draw_launch_selector_modal(ctx); self.draw_proton_remove_confirm_modal(ctx); - ctx.request_repaint(); + + // Scoped repaint policy — replaces the old unconditional + // `ctx.request_repaint()` that forced a full frame every vsync + // (~165 fps on a high-refresh display ≈ one pegged core even when + // the window sat idle). egui already repaints on input events, so + // idle frames are only needed when something async changed: + // + // 1. A channel was drained this frame (cover art landed, progress + // message, play result, async op) → render the new state now. + // 2. Active downloads → tick progress bars + the Instant-based ETA + // countdown (which changes by wall-clock even between messages). + // egui::Spinner (cover loading, account/proton/steamguard) is + // animated and self-requests repaint while visible, so those + // cover themselves and stop when they disappear. + // 3. A launch/play result is pending → poll its channel at 1 Hz so + // a game exit or login-required event is picked up promptly. + // + // With nothing pending the UI schedules no repaint and the main + // thread goes fully idle (~0% CPU) until the next input event. + if drained_images || drained_progress || drained_play || drained_ops { + ctx.request_repaint(); + } + if !self.download_tasks.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + } + if self.play_result_rx.is_some() { + ctx.request_repaint_after(std::time::Duration::from_secs(1)); + } } }