cursor: measure the idle-hide deadline from the last pointer event

refresh_idle_timer armed for the full timeout measured from now, which is
right only for notify_cursor_activity — it stamps activity immediately
before arming. The other callers deliberately do not stamp, so with
idle_timeout: Some(10) a user typing continuously never saw the cursor
hide: every key press and release pushed the deadline out by a fresh ten
seconds. That inverts the feature and regresses the shipped
cursor_hide_timeout behaviour.

resolve's RearmAfter cannot recover it — it only corrects a timer that
fires too early. So the delay is now shortest_timeout minus the time
already elapsed, extracted as CursorHideConfig::arm_delay and table
tested; the arithmetic handed to Timer::from_duration was the one part of
this path with no coverage. A zero delay is fine: the timer fires on the
next loop iteration and resolve decides.

Also rename fullscreen_focused to fullscreen_on_active_output, which is
what seat.active_output() actually reports, and log an arming failure
rather than silently disabling cursor hiding.

Drafted with Claude (Anthropic); reviewed and tested by the committer.
This commit is contained in:
Niklas Herder 2026-09-12 09:48:09 +02:00 • committed by Victoria Brekenfeld
parent 7596cf7f2f
commit 115cdac039
2 changed files with 48 additions and 20 deletions

View file

@ -251,11 +251,15 @@ impl CursorHideConfig {
}
}
/// The delay to arm the timer with, before context is known. Callers on the
/// input path cannot read fullscreen state without deadlocking, so they arm
/// pessimistically and `resolve` corrects it on fire.
pub fn shortest_timeout(&self) -> Option<Duration> {
self.effective_timeout(true)
/// How long to wait before the idle timer should next fire, given how long
/// it has been since the last pointer event. Pessimistic: fullscreen only
/// ever shortens a timeout, so arming as if fullscreen cannot fire too
/// late, and `resolve` corrects an early fire once the context is known.
pub fn arm_delay(&self, since_pointer_activity: Duration) -> Option<Duration> {
Some(
self.effective_timeout(true)?
.saturating_sub(since_pointer_activity),
)
}
}
@ -402,12 +406,6 @@ mod test {
..OFF
};
assert_eq!(long_fs.resolve(secs(3), true), HideDecision::Hide);
// The arming delay is pessimistic: the shortest timeout that could
// apply in any context, because arm-time code cannot read the shell.
assert_eq!(short_fs.shortest_timeout(), Some(secs(3)));
assert_eq!(long_fs.shortest_timeout(), Some(secs(3)));
assert_eq!(OFF.shortest_timeout(), None);
}
#[test]
@ -423,4 +421,27 @@ mod test {
assert_eq!(cfg.resolve(secs(5), false), HideDecision::Hide);
assert_eq!(cfg.resolve(secs(6), false), HideDecision::Hide);
}
#[test]
fn arm_delay_measures_from_the_last_pointer_event() {
let cfg = CursorHideConfig {
idle_timeout: Some(10),
..OFF
};
assert_eq!(cfg.arm_delay(Duration::ZERO), Some(secs(10)));
assert_eq!(cfg.arm_delay(secs(4)), Some(secs(6)));
// Exactly due, and past due: fire immediately rather than deferring.
assert_eq!(cfg.arm_delay(secs(10)), Some(Duration::ZERO));
assert_eq!(cfg.arm_delay(secs(600)), Some(Duration::ZERO));
// Armed against the shortest timeout that any context could want.
let fs = CursorHideConfig {
idle_timeout: Some(10),
fullscreen_idle_timeout: Some(3),
..OFF
};
assert_eq!(fs.arm_delay(secs(1)), Some(secs(2)));
assert_eq!(OFF.arm_delay(Duration::ZERO), None);
}
}

View file

@ -892,6 +892,10 @@ pub fn notify_cursor_activity(state: &State, seat: &Seat<State>, kind: PointerEv
/// (Re)arm the idle-hide timer without counting as pointer activity.
///
/// The deadline is measured from the last *pointer* event, not from now, so a
/// caller that does not stamp activity — a keystroke, a config reload — cannot
/// postpone the hide by re-arming.
///
/// Armed with the shortest timeout any context could want: the input that arms
/// the timer is usually what goes on to make a window fullscreen, so the
/// context is not yet knowable here. `on_idle_timer` evaluates it on fire and
@ -902,7 +906,7 @@ pub fn refresh_idle_timer(state: &State, seat: &Seat<State>) {
let cursor_state = seat.user_data().get::<CursorState>().unwrap();
let now = Instant::now();
let old_token = {
let (old_token, since_activity) = {
let mut inner = cursor_state.lock().unwrap();
if inner.hidden.is_some() {
return;
@ -915,26 +919,29 @@ pub fn refresh_idle_timer(state: &State, seat: &Seat<State>) {
if throttled {
return;
}
let since_activity = now.duration_since(inner.last_pointer_activity);
inner.last_armed = None;
inner.idle_timer.take()
(inner.idle_timer.take(), since_activity)
};
if let Some(token) = old_token {
loop_handle.remove(token);
}
let Some(delay) = config.shortest_timeout() else {
let Some(delay) = config.arm_delay(since_activity) else {
return;
};
let timer = Timer::from_duration(delay);
let timer_seat = seat.clone();
if let Ok(token) =
loop_handle.insert_source(timer, move |_, _, state| on_idle_timer(state, &timer_seat))
{
let mut inner = cursor_state.lock().unwrap();
inner.idle_timer = Some(token);
inner.last_armed = Some(now);
match loop_handle.insert_source(timer, move |_, _, state| on_idle_timer(state, &timer_seat)) {
Ok(token) => {
let mut inner = cursor_state.lock().unwrap();
inner.idle_timer = Some(token);
inner.last_armed = Some(now);
}
// `idle_timer` stays `None`, so the next activity is unthrottled and retries.
Err(err) => warn!(?err, "Failed to arm the cursor idle-hide timer"),
}
}