From 6c4613c05d2fe4a60dd1db8d0da762ac455bd827 Mon Sep 17 00:00:00 2001 From: Ammar Abou Zor Date: Wed, 23 Sep 2026 20:44:22 +0200 Subject: [PATCH 1/2] Use one sys-call for current time in render-loop * Use one time instant for all UI elements in render loop instead of each one of them making a system call with Instant::now() on their own. * Main host owns this instant and will update on each App::logic() call * Changes in UI components to call the method directly from `Host` * Action throttler receive the instant in it's signature as it doesn't live in the UI render loop. * Fallback for when it's called from outside UI thread is built by doing a sys-call for the current time at this state --- crates/app/src/common/action_throttle.rs | 88 ++++++++++--------- crates/app/src/host/ui/home/file_explorer.rs | 6 +- crates/app/src/host/ui/mod.rs | 23 ++++- crates/app/src/host/ui/notification/banner.rs | 5 +- crates/app/src/host/ui/quick_open/mod.rs | 6 +- .../ui/session_setup/state/sources/serial.rs | 9 +- .../src/session/ui/bottom_panel/chart/mod.rs | 13 +-- .../ui/bottom_panel/search/nested_search.rs | 10 ++- .../src/session/ui/shared/searching/nested.rs | 4 +- 9 files changed, 99 insertions(+), 65 deletions(-) diff --git a/crates/app/src/common/action_throttle.rs b/crates/app/src/common/action_throttle.rs index ae7959271a..4414855c11 100644 --- a/crates/app/src/common/action_throttle.rs +++ b/crates/app/src/common/action_throttle.rs @@ -12,7 +12,8 @@ use std::time::{Duration, Instant}; /// final state of a drag/scroll operation is processed even if the user stops input. #[derive(Debug)] pub struct ActionThrottle { - last_action: Instant, + /// `None` until the first allowed action, making the leading edge pass immediately. + last_action: Option, interval: Duration, } @@ -20,10 +21,7 @@ impl ActionThrottle { /// Creates a new throttler with the specified cool-down interval. pub fn new(interval: Duration) -> Self { Self { - // Initialize in the past so the first action always works immediately. - last_action: Instant::now() - .checked_sub(interval) - .unwrap_or_else(Instant::now), + last_action: None, interval, } } @@ -32,6 +30,7 @@ impl ActionThrottle { /// /// # Arguments /// + /// * `now` - Current timestamp, provided by the caller to avoid a clock call per frame. /// * `ctx` - Optional `egui::Context`. If provided, and the action is throttled, /// a repaint will be requested for the remaining duration. This is needed for /// handling the "tail" of scroll/zoom events. @@ -40,17 +39,16 @@ impl ActionThrottle { /// /// * `true` - The interval has passed. The timer is reset, and the action should proceed. /// * `false` - The interval has not passed. The action should be skipped. - pub fn ready(&mut self, ctx: Option<&egui::Context>) -> bool { - let elapsed = self.last_action.elapsed(); + pub fn ready(&mut self, now: Instant, ctx: Option<&egui::Context>) -> bool { + let remaining = self.remaining(now); - if elapsed >= self.interval { - self.last_action = Instant::now(); + if remaining.is_zero() { + self.last_action = Some(now); return true; } // Ensure we wake up exactly when the cool-down finishes to process final state. if let Some(ctx) = ctx { - let remaining = self.interval - elapsed; ctx.request_repaint_after(remaining); } @@ -58,72 +56,80 @@ impl ActionThrottle { } /// Delays the next action until the full interval has elapsed. - pub fn delay_next(&mut self) { - self.last_action = Instant::now(); + pub fn delay_next(&mut self, now: Instant) { + self.last_action = Some(now); } /// Resets the throttle, allowing the very next call to `ready()` to return true. pub fn reset(&mut self) { - // Set last_action to the past to ensure immediate trigger. - self.last_action = Instant::now() - .checked_sub(self.interval) - .unwrap_or_else(Instant::now); + self.last_action = None; + } + + /// Returns the cool-down left before the next action is allowed. + fn remaining(&self, now: Instant) -> Duration { + let Some(last_action) = self.last_action else { + return Duration::ZERO; + }; + + self.interval + .saturating_sub(now.saturating_duration_since(last_action)) } } #[cfg(test)] mod tests { use super::*; - use std::thread; + + const INTERVAL: Duration = Duration::from_millis(100); #[test] fn test_throttle_leading_edge() { - let mut throttle = ActionThrottle::new(Duration::from_millis(100)); + let mut throttle = ActionThrottle::new(INTERVAL); // First call should always be ready - assert!(throttle.ready(None)); + assert!(throttle.ready(Instant::now(), None)); } #[test] fn test_throttle_blocking() { - let mut throttle = ActionThrottle::new(Duration::from_millis(100)); - assert!(throttle.ready(None)); + let mut throttle = ActionThrottle::new(INTERVAL); + let now = Instant::now(); + + assert!(throttle.ready(now, None)); // Immediate second call should be blocked - assert!(!throttle.ready(None)); + assert!(!throttle.ready(now, None)); } #[test] fn test_throttle_wait() { - let interval = Duration::from_millis(20); - let mut throttle = ActionThrottle::new(interval); - - assert!(throttle.ready(None)); - assert!(!throttle.ready(None)); + let mut throttle = ActionThrottle::new(INTERVAL); + let now = Instant::now(); - // Wait for interval to pass - thread::sleep(interval + Duration::from_millis(10)); - assert!(throttle.ready(None)); + assert!(throttle.ready(now, None)); + assert!(!throttle.ready(now, None)); + assert!(!throttle.ready(now + INTERVAL / 2, None)); + assert!(throttle.ready(now + INTERVAL, None)); } #[test] fn test_throttle_delay_next() { - let interval = Duration::from_millis(20); - let mut throttle = ActionThrottle::new(interval); + let mut throttle = ActionThrottle::new(INTERVAL); + let now = Instant::now(); - throttle.delay_next(); - assert!(!throttle.ready(None)); - - thread::sleep(interval + Duration::from_millis(10)); - assert!(throttle.ready(None)); + throttle.delay_next(now); + assert!(!throttle.ready(now, None)); + assert!(throttle.ready(now + INTERVAL, None)); } #[test] fn test_throttle_reset() { - let mut throttle = ActionThrottle::new(Duration::from_millis(100)); - assert!(throttle.ready(None)); - assert!(!throttle.ready(None)); + let mut throttle = ActionThrottle::new(INTERVAL); + let now = Instant::now(); + + assert!(throttle.ready(now, None)); + assert!(!throttle.ready(now, None)); throttle.reset(); // Should be ready immediately after reset - assert!(throttle.ready(None)); + assert!(throttle.ready(now, None)); } } diff --git a/crates/app/src/host/ui/home/file_explorer.rs b/crates/app/src/host/ui/home/file_explorer.rs index 9963905ff0..47bea0d4c2 100644 --- a/crates/app/src/host/ui/home/file_explorer.rs +++ b/crates/app/src/host/ui/home/file_explorer.rs @@ -19,7 +19,7 @@ use crate::host::common::ui_utls::{sized_singleline_text_edit, truncate_path_to_ use crate::host::{ command::{HostCommand, ScanFavoriteFoldersParam}, ui::{ - UiActions, + Host, UiActions, actions::FileDialogOptions, storage::{ file_explorer::{ @@ -179,7 +179,7 @@ impl FileExplorerUi { if self.search_query.is_empty() { self.clear_search_cache(file_explorer.revision); } else { - self.search_throttle.delay_next(); + self.search_throttle.delay_next(Host::frame_now()); } } }, @@ -447,7 +447,7 @@ impl FileExplorerUi { return; } - if query_changed && !self.search_throttle.ready(ctx) { + if query_changed && !self.search_throttle.ready(Host::frame_now(), ctx) { return; } diff --git a/crates/app/src/host/ui/mod.rs b/crates/app/src/host/ui/mod.rs index b9d9708497..3798f908c6 100644 --- a/crates/app/src/host/ui/mod.rs +++ b/crates/app/src/host/ui/mod.rs @@ -1,4 +1,7 @@ -use std::time::Duration; +use std::{ + cell::Cell, + time::{Duration, Instant}, +}; use anyhow::ensure; use eframe::NativeOptions; @@ -64,6 +67,12 @@ pub mod storage; mod tabs; pub mod update; +thread_local! { + /// Timestamp captured at the start of each UI pass in [`Host::logic`]. + /// Stays `None` on threads that never run the UI loop. + static FRAME_NOW: Cell> = const { Cell::new(None) }; +} + #[derive(Debug)] pub struct Host { receivers: UiReceivers, @@ -79,6 +88,16 @@ pub struct Host { } impl Host { + /// Returns the timestamp captured at the start of the current UI pass, avoiding a clock + /// call per call site and keeping all timing within a pass consistent. + /// + /// # Note: + /// Meant for the UI loop. Callers outside it, like tests driving widgets directly, fall back + /// to the clock and therefore get a fresh timestamp on each call instead of a shared one. + pub fn frame_now() -> Instant { + FRAME_NOW.get().unwrap_or_else(Instant::now) + } + pub fn run(cli_cmds: Vec) -> eframe::Result<()> { let native_options = NativeOptions { viewport: egui::ViewportBuilder::default() @@ -587,6 +606,8 @@ fn render_panel_toggle(ui: &mut Ui, visible: &mut bool, icon: &str, panel_name: impl eframe::App for Host { fn logic(&mut self, ctx: &Context, _frame: &mut eframe::Frame) { + FRAME_NOW.set(Some(Instant::now())); + while let Ok(msg) = self.receivers.message_rx.try_recv() { self.handle_message(msg, ctx); } diff --git a/crates/app/src/host/ui/notification/banner.rs b/crates/app/src/host/ui/notification/banner.rs index 0537dd18b9..2939aaeea1 100644 --- a/crates/app/src/host/ui/notification/banner.rs +++ b/crates/app/src/host/ui/notification/banner.rs @@ -8,6 +8,7 @@ use egui::{ }; use super::NotificationEntry; +use crate::host::ui::Host; /// State for the currently visible temporal notification banner. #[derive(Debug, Clone)] @@ -32,7 +33,7 @@ impl NotificationBanner { pub fn new(entry: NotificationEntry, history_backed: bool) -> Self { const BANNER_TTL: Duration = Duration::from_secs(4); - let now = Instant::now(); + let now = Host::frame_now(); Self { entry, history_backed, @@ -73,7 +74,7 @@ impl NotificationBanner { button_rect.bottom() + BANNER_GAP, ); - let now = Instant::now(); + let now = Host::frame_now(); const FADE_IN: Duration = Duration::from_millis(120); const FADE_OUT: Duration = Duration::from_millis(400); let age_alpha = (now.saturating_duration_since(self.created_at).as_secs_f32() diff --git a/crates/app/src/host/ui/quick_open/mod.rs b/crates/app/src/host/ui/quick_open/mod.rs index 5473775a5b..f5665da6e9 100644 --- a/crates/app/src/host/ui/quick_open/mod.rs +++ b/crates/app/src/host/ui/quick_open/mod.rs @@ -17,7 +17,7 @@ use crate::{ }, }; -use super::UiActions; +use super::{Host, UiActions}; mod row; mod search; @@ -194,7 +194,7 @@ impl QuickOpen { if self.query.is_empty() { self.throttle.reset(); } else { - self.throttle.delay_next(); + self.throttle.delay_next(Host::frame_now()); } } @@ -291,7 +291,7 @@ impl QuickOpen { } fn refresh_results(&mut self, storage: &HostStorage, ctx: &Context) { - if !self.needs_recompute || !self.throttle.ready(Some(ctx)) { + if !self.needs_recompute || !self.throttle.ready(Host::frame_now(), Some(ctx)) { return; } diff --git a/crates/app/src/host/ui/session_setup/state/sources/serial.rs b/crates/app/src/host/ui/session_setup/state/sources/serial.rs index 6ffa605a5d..50116ea919 100644 --- a/crates/app/src/host/ui/session_setup/state/sources/serial.rs +++ b/crates/app/src/host/ui/session_setup/state/sources/serial.rs @@ -2,6 +2,8 @@ use std::time::{Duration, Instant}; use itertools::Itertools; +use crate::host::ui::Host; + const REGULAR_RESCAN_PORTS_DURATION: Duration = Duration::from_secs(10); const NOPORTS_RESCAN_PORTS_DURATION: Duration = Duration::from_secs(5); @@ -106,7 +108,7 @@ impl SerialConfig { path: String::new(), path_err: None, available_ports, - last_ports_scan: Instant::now(), + last_ports_scan: Host::frame_now(), baud_rate: BaudRate::default(), available_bauds, @@ -184,9 +186,10 @@ impl SerialConfig { REGULAR_RESCAN_PORTS_DURATION }; - if self.last_ports_scan.elapsed() > check_duration { + let now = Host::frame_now(); + if now.saturating_duration_since(self.last_ports_scan) > check_duration { self.available_ports = Self::scan_ports(); - self.last_ports_scan = Instant::now(); + self.last_ports_scan = now; } } diff --git a/crates/app/src/session/ui/bottom_panel/chart/mod.rs b/crates/app/src/session/ui/bottom_panel/chart/mod.rs index 706b750076..0b2ca7798a 100644 --- a/crates/app/src/session/ui/bottom_panel/chart/mod.rs +++ b/crates/app/src/session/ui/bottom_panel/chart/mod.rs @@ -6,7 +6,7 @@ use tokio::sync::mpsc::Sender; use crate::{ common::action_throttle::ActionThrottle, - host::ui::{UiActions, registry::filters::FilterRegistry}, + host::ui::{Host, UiActions, registry::filters::FilterRegistry}, session::{ command::SessionCommand, ui::shared::{SearchTableSync, SessionShared}, @@ -353,7 +353,7 @@ impl ChartUI { self.pending_request_range = None; let retry_range = bound_x.clone(); - if !self.throttle.ready(Some(ui.ctx())) { + if !self.throttle.ready(Host::frame_now(), Some(ui.ctx())) { self.pending_request_range = Some(bound_x); return; } @@ -515,7 +515,7 @@ impl ChartUI { #[cfg(test)] mod tests { - use std::path::PathBuf; + use std::{path::PathBuf, time::Instant}; use egui_plot::PlotBounds; use tokio::sync::mpsc; @@ -693,8 +693,9 @@ mod tests { chart.data.set_histogram(vec![vec![ChartBar::new(0, 3)]]); chart.data.line_plots.insert(0, vec![point(1, 5.0)]); - assert!(chart.throttle.ready(None)); - assert!(!chart.throttle.ready(None)); + let now = Instant::now(); + assert!(chart.throttle.ready(now, None)); + assert!(!chart.throttle.ready(now, None)); chart.reset(); @@ -704,7 +705,7 @@ mod tests { assert!(!chart.reset_full_range); assert!(chart.data.bars.is_empty()); assert!(chart.data.line_plots.is_empty()); - assert!(chart.throttle.ready(None)); + assert!(chart.throttle.ready(now, None)); } #[test] diff --git a/crates/app/src/session/ui/bottom_panel/search/nested_search.rs b/crates/app/src/session/ui/bottom_panel/search/nested_search.rs index fd0d9ce475..eb9143d792 100644 --- a/crates/app/src/session/ui/bottom_panel/search/nested_search.rs +++ b/crates/app/src/session/ui/bottom_panel/search/nested_search.rs @@ -1,7 +1,5 @@ //! Floating Find in Search Results UI. -use std::time::Instant; - use egui::{ Align2, Area, Button, Context, Frame, Id, Key, Margin, Modifiers, Order, Rect, RichText, Stroke, TextEdit, Ui, Widget, pos2, vec2, @@ -14,7 +12,11 @@ use session_core::state::IndexedNavigation; use crate::{ common::{phosphor::icons, validation::ValidationEligibility}, - host::{common::colors::main_accent_stroke, notification::AppNotification, ui::UiActions}, + host::{ + common::colors::main_accent_stroke, + notification::AppNotification, + ui::{Host, UiActions}, + }, session::{command::SessionCommand, ui::shared::searching::NestedSearchState}, }; @@ -97,7 +99,7 @@ impl NestedSearch { .inner_margin(Margin::same(4)) .show(ui, |ui| self.render_row(session_id, state, actions, ui)); - if let Some(remaining) = state.progress_remaining(Instant::now()) { + if let Some(remaining) = state.progress_remaining(Host::frame_now()) { if remaining.is_zero() { paint_pending_border(ui, output.response.rect); } else { diff --git a/crates/app/src/session/ui/shared/searching/nested.rs b/crates/app/src/session/ui/shared/searching/nested.rs index e02f347ae6..911eeb111b 100644 --- a/crates/app/src/session/ui/shared/searching/nested.rs +++ b/crates/app/src/session/ui/shared/searching/nested.rs @@ -10,7 +10,7 @@ use uuid::Uuid; use crate::{ common::validation::{ValidationEligibility, validate_filter}, - host::ui::UiActions, + host::ui::{Host, UiActions}, session::command::{FindNestedMatchParams, SessionCommand}, }; @@ -143,7 +143,7 @@ impl NestedSearchState { let command = SessionCommand::FindNestedMatch(Box::new(params)); self.pending_request = Some(PendingRequest { id: request_id, - started_at: Instant::now(), + started_at: Host::frame_now(), }); if actions.try_send_command(cmd_tx, command) { From bd7317e52f40e5cffcddced879a2d67b7129ccfd Mon Sep 17 00:00:00 2001 From: Ammar Abou Zor Date: Wed, 23 Sep 2026 21:08:35 +0200 Subject: [PATCH 2/2] Chart: Fix throttle being dropped on each range request * Interval updates recreated the throttle before it was queried, so the cool-down never applied. Use ActionThrottle::set_interval instead. * Requests deferred by the cool-down now use the live plot bounds rather than replaying bounds the user already left. --- crates/app/src/common/action_throttle.rs | 19 +++++++++++++ .../src/session/ui/bottom_panel/chart/mod.rs | 27 ++++++++++++++----- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/crates/app/src/common/action_throttle.rs b/crates/app/src/common/action_throttle.rs index 4414855c11..9ffe63578e 100644 --- a/crates/app/src/common/action_throttle.rs +++ b/crates/app/src/common/action_throttle.rs @@ -65,6 +65,11 @@ impl ActionThrottle { self.last_action = None; } + /// Updates the cool-down interval, keeping the current cool-down state. + pub fn set_interval(&mut self, interval: Duration) { + self.interval = interval; + } + /// Returns the cool-down left before the next action is allowed. fn remaining(&self, now: Instant) -> Duration { let Some(last_action) = self.last_action else { @@ -120,6 +125,20 @@ mod tests { assert!(throttle.ready(now + INTERVAL, None)); } + #[test] + fn test_throttle_set_interval_keeps_cooldown() { + let mut throttle = ActionThrottle::new(INTERVAL); + let now = Instant::now(); + + assert!(throttle.ready(now, None)); + assert!(!throttle.ready(now, None)); + + throttle.set_interval(INTERVAL * 2); + // The running cool-down must survive an interval change and follow the new interval. + assert!(!throttle.ready(now + INTERVAL, None)); + assert!(throttle.ready(now + INTERVAL * 2, None)); + } + #[test] fn test_throttle_reset() { let mut throttle = ActionThrottle::new(INTERVAL); diff --git a/crates/app/src/session/ui/bottom_panel/chart/mod.rs b/crates/app/src/session/ui/bottom_panel/chart/mod.rs index 0b2ca7798a..3febdefbd2 100644 --- a/crates/app/src/session/ui/bottom_panel/chart/mod.rs +++ b/crates/app/src/session/ui/bottom_panel/chart/mod.rs @@ -221,7 +221,10 @@ impl ChartUI { }); let plot_res = plot.show(ui, |plot_ui| { - if self.reset_full_range { + // Bounds set here apply to the next pass only, so the transform read via + // `plot_ui.plot_bounds()` stays stale for the rest of this one. + let bounds_reset = self.reset_full_range; + if bounds_reset { self.reset_full_range = false; // We need to reset X axis manually to show all logs span. @@ -316,12 +319,21 @@ impl ChartUI { return PlotResponse::JumpToLog(log); } + let plot_bounds = plot_ui.plot_bounds(); + + // A queued range only marks that a request is owed. Its range is authoritative just + // while the transform is stale, otherwise the live viewport wins: a range queued + // before a throttled cool-down can be bounds the user has already left. if let Some(range) = self.pending_request_range.take() { + let range = if bounds_reset { + range + } else { + Self::bounds_request_range(&plot_bounds, shared) + }; + return PlotResponse::RequestForRange(range); } - let plot_bounds = plot_ui.plot_bounds(); - let Some(current_request_range) = self.current_request_range.clone() else { self.set_full_range(shared); return PlotResponse::RequestForRange(Self::get_full_range(shared)); @@ -349,14 +361,15 @@ impl ChartUI { shared.logs.focus_main_row(log_nr, SearchTableSync::Sync); } PlotResponse::RequestForRange(bound_x) => { - self.update_throttle_config(shared.logs.logs_count()); + self.update_throttle_interval(shared.logs.logs_count()); self.pending_request_range = None; - let retry_range = bound_x.clone(); if !self.throttle.ready(Host::frame_now(), Some(ui.ctx())) { self.pending_request_range = Some(bound_x); return; } + + let retry_range = bound_x.clone(); self.current_request_range = Some(bound_x.clone()); // Taken from current Chipmunk: Matches are divided by 2 @@ -482,7 +495,7 @@ impl ChartUI { } /// Adjusts the throttle interval based on the current dataset size. - pub fn update_throttle_config(&mut self, logs_count: u64) { + fn update_throttle_interval(&mut self, logs_count: u64) { let new_interval = match logs_count { 0..=100_000 => Duration::from_millis(50), 100_001..=500_000 => Duration::from_millis(100), @@ -491,7 +504,7 @@ impl ChartUI { _ => Duration::from_millis(225), }; - self.throttle = ActionThrottle::new(new_interval); + self.throttle.set_interval(new_interval); } fn place_holder(ui: &mut Ui) {