Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 65 additions & 40 deletions crates/app/src/common/action_throttle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,16 @@ 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<Instant>,
interval: Duration,
}

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,
}
}
Expand All @@ -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.
Expand All @@ -40,90 +39,116 @@ 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);
}

false
}

/// 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;
}

/// 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 {
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);
let mut throttle = ActionThrottle::new(INTERVAL);
let now = Instant::now();

assert!(throttle.ready(None));
assert!(!throttle.ready(None));

// 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(now);
assert!(!throttle.ready(now, None));
assert!(throttle.ready(now + INTERVAL, None));
}

throttle.delay_next();
assert!(!throttle.ready(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));

thread::sleep(interval + Duration::from_millis(10));
assert!(throttle.ready(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(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));
}
}
6 changes: 3 additions & 3 deletions crates/app/src/host/ui/home/file_explorer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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());
}
}
},
Expand Down Expand Up @@ -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;
}

Expand Down
23 changes: 22 additions & 1 deletion crates/app/src/host/ui/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use std::time::Duration;
use std::{
cell::Cell,
time::{Duration, Instant},
};

use anyhow::ensure;
use eframe::NativeOptions;
Expand Down Expand Up @@ -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<Option<Instant>> = const { Cell::new(None) };
}

#[derive(Debug)]
pub struct Host {
receivers: UiReceivers,
Expand All @@ -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<CliCommand>) -> eframe::Result<()> {
let native_options = NativeOptions {
viewport: egui::ViewportBuilder::default()
Expand Down Expand Up @@ -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);
}
Expand Down
5 changes: 3 additions & 2 deletions crates/app/src/host/ui/notification/banner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
6 changes: 3 additions & 3 deletions crates/app/src/host/ui/quick_open/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use crate::{
},
};

use super::UiActions;
use super::{Host, UiActions};

mod row;
mod search;
Expand Down Expand Up @@ -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());
}
}

Expand Down Expand Up @@ -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;
}

Expand Down
9 changes: 6 additions & 3 deletions crates/app/src/host/ui/session_setup/state/sources/serial.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
}

Expand Down
Loading
Loading