Skip to content

Commit d304bca

Browse files
committed
fix(ui): pace desktop repaint work by visible activity
The desktop UI scheduled a repaint every 500 ms and sent stats-poll commands on the same update cadence regardless of whether the proxy was running, a transient message was visible, or any background operation was in flight. That kept the immediate-mode interface waking regularly even when the app was stopped and the window contained only static state. Add a small activity predicate over UI state and use it to select the repaint cadence. Running proxy sessions, proxy startup/shutdown windows, certificate operations, downloads, update checks, SNI probes, and fresh transient status lines keep the existing 500 ms cadence. Fully idle UI state falls back to a slower two-second repaint request while still allowing egui to repaint immediately for user input. Gate stats polling on the same active-state predicate so stopped or fully idle windows do not enqueue redundant PollStats commands into the background thread. The visible text, controls, command handlers, proxy lifecycle, and persisted configuration remain unchanged. Add focused unit tests for the activity predicate covering idle state, running proxy state, in-flight SNI probes, and expired transient status timestamps.
1 parent 40b5386 commit d304bca

1 file changed

Lines changed: 98 additions & 9 deletions

File tree

src/bin/ui.rs

Lines changed: 98 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ const VERSION: &str = env!("CARGO_PKG_VERSION");
2222
const WIN_WIDTH: f32 = 520.0;
2323
const WIN_HEIGHT: f32 = 680.0;
2424
const LOG_MAX: usize = 200;
25+
const UI_STATS_POLL_EVERY: Duration = Duration::from_millis(700);
26+
const UI_ACTIVE_REPAINT_AFTER: Duration = Duration::from_millis(500);
27+
const UI_IDLE_REPAINT_AFTER: Duration = Duration::from_secs(2);
28+
const UI_TRANSIENT_TTL: Duration = Duration::from_secs(10);
29+
const UI_TOAST_TTL: Duration = Duration::from_secs(5);
30+
const UI_LOAD_ERROR_TOAST_TTL: Duration = Duration::from_secs(30);
2531

2632
fn main() -> eframe::Result<()> {
2733
let _ = rustls::crypto::ring::default_provider().install_default();
@@ -215,6 +221,50 @@ struct App {
215221
toast: Option<(String, Instant)>,
216222
}
217223

224+
impl App {
225+
fn ui_needs_active_repaint(&self) -> bool {
226+
let state = self.shared.state.lock().unwrap();
227+
state.needs_active_repaint() || self.toast_is_visible()
228+
}
229+
230+
fn toast_is_visible(&self) -> bool {
231+
self.toast.as_ref().map_or(false, |(msg, created_at)| {
232+
let ttl = if msg.contains("failed to load") {
233+
UI_LOAD_ERROR_TOAST_TTL
234+
} else {
235+
UI_TOAST_TTL
236+
};
237+
created_at.elapsed() < ttl
238+
})
239+
}
240+
}
241+
242+
impl UiState {
243+
fn needs_active_repaint(&self) -> bool {
244+
self.running
245+
|| self.proxy_active
246+
|| self.cert_op_in_progress
247+
|| self.download_in_progress
248+
|| self
249+
.last_test_msg_at
250+
.map_or(false, |t| t.elapsed() < UI_TRANSIENT_TTL)
251+
|| self
252+
.ca_trusted_at
253+
.map_or(false, |t| t.elapsed() < UI_TRANSIENT_TTL)
254+
|| self
255+
.last_update_check_at
256+
.map_or(false, |t| t.elapsed() < UI_TRANSIENT_TTL)
257+
|| self
258+
.last_download_at
259+
.map_or(false, |t| t.elapsed() < UI_TRANSIENT_TTL)
260+
|| matches!(self.last_update_check, Some(UpdateProbeState::InFlight))
261+
|| self
262+
.sni_probe
263+
.values()
264+
.any(|probe| matches!(probe, SniProbeState::InFlight))
265+
}
266+
}
267+
218268
#[derive(Clone)]
219269
struct FormState {
220270
/// `"apps_script"` (default), `"direct"`, or `"full"`. Controls
@@ -698,11 +748,16 @@ fn form_row(
698748

699749
impl eframe::App for App {
700750
fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
701-
if self.last_poll.elapsed() > Duration::from_millis(700) {
751+
let needs_active_repaint = self.ui_needs_active_repaint();
752+
if needs_active_repaint && self.last_poll.elapsed() > UI_STATS_POLL_EVERY {
702753
let _ = self.cmd_tx.send(Cmd::PollStats);
703754
self.last_poll = Instant::now();
704755
}
705-
ctx.request_repaint_after(Duration::from_millis(500));
756+
ctx.request_repaint_after(if needs_active_repaint {
757+
UI_ACTIVE_REPAINT_AFTER
758+
} else {
759+
UI_IDLE_REPAINT_AFTER
760+
});
706761

707762
egui::CentralPanel::default().show(ctx, |ui| {
708763
ui.style_mut().spacing.item_spacing = egui::vec2(8.0, 6.0);
@@ -1477,18 +1532,17 @@ impl eframe::App for App {
14771532
// stale messages don't keep pushing the log panel off-screen.
14781533
// Priority: update-check in flight > fresh test msg > fresh CA
14791534
// result > update-check result. Old/expired entries are dropped.
1480-
const TRANSIENT_TTL: Duration = Duration::from_secs(10);
14811535
let (test_msg_fresh, ca_trusted_fresh, update_check_fresh, download_fresh) = {
14821536
let s = self.shared.state.lock().unwrap();
14831537
(
14841538
s.last_test_msg_at
1485-
.map_or(false, |t| t.elapsed() < TRANSIENT_TTL),
1539+
.map_or(false, |t| t.elapsed() < UI_TRANSIENT_TTL),
14861540
s.ca_trusted_at
1487-
.map_or(false, |t| t.elapsed() < TRANSIENT_TTL),
1541+
.map_or(false, |t| t.elapsed() < UI_TRANSIENT_TTL),
14881542
s.last_update_check_at
1489-
.map_or(false, |t| t.elapsed() < TRANSIENT_TTL),
1543+
.map_or(false, |t| t.elapsed() < UI_TRANSIENT_TTL),
14901544
s.last_download_at
1491-
.map_or(false, |t| t.elapsed() < TRANSIENT_TTL),
1545+
.map_or(false, |t| t.elapsed() < UI_TRANSIENT_TTL),
14921546
)
14931547
};
14941548

@@ -1693,9 +1747,9 @@ impl eframe::App for App {
16931747
// 30s instead of 5 because they explain why the form looks empty.
16941748
if let Some((msg, t)) = &self.toast {
16951749
let ttl = if msg.contains("failed to load") {
1696-
Duration::from_secs(30)
1750+
UI_LOAD_ERROR_TOAST_TTL
16971751
} else {
1698-
Duration::from_secs(5)
1752+
UI_TOAST_TTL
16991753
};
17001754
if t.elapsed() < ttl {
17011755
ui.add_space(4.0);
@@ -1964,6 +2018,41 @@ fn fmt_bytes(b: u64) -> String {
19642018
}
19652019
}
19662020

2021+
#[cfg(test)]
2022+
mod tests {
2023+
use super::*;
2024+
2025+
#[test]
2026+
fn idle_ui_does_not_request_active_repaint() {
2027+
let state = UiState::default();
2028+
assert!(!state.needs_active_repaint());
2029+
}
2030+
2031+
#[test]
2032+
fn running_proxy_requests_active_repaint() {
2033+
let mut state = UiState::default();
2034+
state.running = true;
2035+
assert!(state.needs_active_repaint());
2036+
}
2037+
2038+
#[test]
2039+
fn inflight_probe_requests_active_repaint() {
2040+
let mut state = UiState::default();
2041+
state
2042+
.sni_probe
2043+
.insert("docs.google.com".into(), SniProbeState::InFlight);
2044+
assert!(state.needs_active_repaint());
2045+
}
2046+
2047+
#[test]
2048+
fn expired_transient_state_does_not_keep_ui_active() {
2049+
let mut state = UiState::default();
2050+
state.last_test_msg_at =
2051+
Some(Instant::now() - UI_TRANSIENT_TTL - Duration::from_millis(1));
2052+
assert!(!state.needs_active_repaint());
2053+
}
2054+
}
2055+
19672056
// ---------- Background thread: owns the tokio runtime + proxy lifecycle ----------
19682057

19692058
fn background_thread(shared: Arc<Shared>, rx: Receiver<Cmd>) {

0 commit comments

Comments
 (0)