T13: press-and-hold repeat for mouse broadcast / mirror clicks

Hyprland mouse binds fire once per press; for multiboxing you want a
held mouse button to repeat clicks on every captured slot at a
configurable cadence. Wire that:

- mouse-press <button> IPC verb: fires the broadcast once immediately,
  then spawns a per-button tokio task that re-fires it every
  ENBOXER_MOUSE_REPEAT_MS (default 50 ms = 20 Hz). Stored per button
  in Session.mouse_repeats.
- mouse-release <button>: cancels the matching repeat.
- mirror_repeat_loop worker: holds an Arc<AtomicBool> cancel signal so
  the release side can flip it without taking the Session mutex.
- Bind installation: each mouse button (272, 273) now installs BOTH
  a press BindSpec AND a release=true BindSpec for both mouse_broadcast
  and Mode::Mirror so the behaviour is automatic for the operator.
  Hyprland translates release=true to { release = true } bind option.

Cadence is tunable at startup via ENBOXER_MOUSE_REPEAT_MS, clamped to
1..=2000 ms. The mirror_clicks_to docstring (previously "OUT OF
SCOPE for T13 ...") now points at mirror_repeat_loop.

cargo test 98+/0; clippy clean.
This commit is contained in:
en 2026-09-16 12:43:33 +02:00
parent bb6d9e8566
commit 4ee58acf2c

View File

@ -23,6 +23,23 @@ pub struct Session {
pub vfx_source: Option<u32>,
pub mouse_broadcast: bool,
pub mouse_follow: bool,
/// Per-button press-and-hold repeat state. Keyed by Linux input
/// event code (272 = BTN_LEFT, 273 = BTN_RIGHT). Cancelled when
/// the matching `mouse-release` IPC verb arrives.
pub mouse_repeats: std::collections::HashMap<u32, RepeatState>,
/// Cadence in milliseconds between repeat clicks while a button is
/// held. Tunable via `ENBOXER_MOUSE_REPEAT_MS`; defaults to 50 ms
/// (20 Hz) which feels responsive without saturating the IPC socket.
pub mouse_repeat_ms: u64,
}
/// Per-button repeat-loop handle + cancel signal. Holding the cancel
/// `Arc<AtomicBool>` in the Session lets the release path flip it
/// without holding the Session mutex; the worker checks the bit at
/// each cadence tick and exits cleanly.
pub struct RepeatState {
pub cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
pub handle: Option<tokio::task::JoinHandle<()>>,
}
impl Session {
@ -37,6 +54,12 @@ impl Session {
vfx_source: None,
mouse_broadcast: false,
mouse_follow: false,
mouse_repeats: std::collections::HashMap::new(),
mouse_repeat_ms: std::env::var("ENBOXER_MOUSE_REPEAT_MS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.filter(|&n| (1..=2000).contains(&n))
.unwrap_or(50),
})
}
}
@ -804,10 +827,124 @@ pub fn others_clients(slots: &[(u32, Client)], leader: u32) -> Vec<Client> {
/// click are forwarded from the cursor position in
/// `broadcast_mirror_click`.
///
/// Press-and-hold guard is OUT OF SCOPE for T13 (Master has not requested
/// it yet); the caller should hold the mouse binds as a follow-up.
/// T13-todo: press-and-hold guard — repeated button-down without release
/// should re-fire on a configurable cadence.
/// Handle the IPC verb `mouse-press <button>`. Fires one click
/// immediately so the operator gets no perceptible delay, then starts
/// a per-button repeat loop. Cancels any prior repeat for the same
/// button before installing the new one.
pub async fn mouse_press(session: &Arc<Mutex<Session>>, button: u32) -> Result<()> {
// Fire the first click synchronously through whichever path the
// session is in (mouse-broadcast or mirror mode). mirror-click
// no-ops outside of Mode::Mirror so the dispatch is harmless.
{
let g = session.lock().await;
let mode = g.engine.mode;
let mouse_broadcast = g.mouse_broadcast;
drop(g);
let r = if mode == Mode::Mirror {
broadcast_mirror_click(session, button).await
} else if mouse_broadcast {
broadcast_click(session, button).await
} else {
// Neither mode nor mouse_broadcast is on; the bind was
// installed anyway. Just no-op.
Ok(())
};
if let Err(e) = r {
tracing::warn!("mouse-press initial click failed: {e}");
}
}
// Cancel any prior repeat for this button.
{
let mut g = session.lock().await;
if let Some(prev) = g.mouse_repeats.remove(&button) {
prev.cancel
.store(true, std::sync::atomic::Ordering::Relaxed);
if let Some(h) = prev.handle {
h.abort();
}
}
}
// Spawn the repeat loop.
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let cancel_clone = cancel.clone();
let cadence = {
let g = session.lock().await;
g.mouse_repeat_ms
};
let session_clone = session.clone();
let handle = tokio::spawn(async move {
mirror_repeat_loop(session_clone, button, cadence, cancel_clone).await;
});
let mut g = session.lock().await;
g.mouse_repeats.insert(
button,
RepeatState {
cancel,
handle: Some(handle),
},
);
Ok(())
}
/// Handle the IPC verb `mouse-release <button>`. Cancels the per-button
/// repeat loop; the most recent click is the last one delivered.
pub async fn mouse_release(session: &Arc<Mutex<Session>>, button: u32) -> Result<()> {
let mut g = session.lock().await;
if let Some(mut state) = g.mouse_repeats.remove(&button) {
state.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
if let Some(h) = state.handle.take() {
h.abort();
}
}
Ok(())
}
/// Per-button repeat loop. Fires `broadcast_click` (or
/// `broadcast_mirror_click` in mirror mode) every `cadence_ms` until
/// either the cancel bit is flipped by the matching release handler or the
/// session shuts down. Each click is independent: a failure is logged and
/// the loop continues. Cadence is captured at spawn time; changing
/// `mouse_repeat_ms` mid-hold does not affect the active repeat.
async fn mirror_repeat_loop(
session: Arc<Mutex<Session>>,
button: u32,
cadence_ms: u64,
cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
) {
use std::sync::atomic::Ordering;
use std::time::Duration;
loop {
tokio::time::sleep(Duration::from_millis(cadence_ms)).await;
if cancel.load(Ordering::Relaxed) {
break;
}
let (mode, mouse_broadcast) = {
let g = session.lock().await;
(g.engine.mode, g.mouse_broadcast)
};
let r = if mode == Mode::Mirror {
broadcast_mirror_click(&session, button).await
} else if mouse_broadcast {
broadcast_click(&session, button).await
} else {
// User disabled mouse_broadcast while holding the button;
// cancel the repeat so we don't fire forever.
cancel.store(true, Ordering::Relaxed);
break;
};
if let Err(e) = r {
tracing::warn!("mouse repeat click failed: {e}");
}
}
}
/// Returns one `(target_client, button)` pair per non-leader slot so
/// the daemon can broadcast a single mirror-mode click. Repeated
/// clicks on a held mouse button are handled by [`mirror_repeat_loop`]:
/// a per-button timer re-fires this same broadcast at a configurable
/// cadence until the matching release IPC verb cancels it. The
/// initial press IPC verb still calls this once on its own so the first
/// click is delivered immediately, not after the cadence delay.
pub fn mirror_clicks_to(
slots: &[(u32, Client)],
leader: u32,