From 437cce6f68428f9382179b799916293e369080cf Mon Sep 17 00:00:00 2001 From: srako Date: Mon, 6 Jul 2026 14:08:56 +0800 Subject: [PATCH 1/3] feat(wheel): support main scroll wheel left/right tilt as remappable buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add WheelLeft and WheelRight to the ButtonId enum so the main scroll wheel's horizontal tilt — which arrives as a horizontal scroll event at the OS hook, not as a button press — can be intercepted and remapped to any Action, just like a physical button. Core changes: - binding.rs: WheelLeft/WheelRight variants, ALL array, labels, and default bindings (native horizontal scroll, pass-through) - hook_runtime.rs: intercept MouseEvent::Scroll with non-zero delta_x from a non-trackpad source; look up the bound action and dispatch it with a 250ms per-direction cooldown so one deliberate tilt fires once - mouse_buttons.rs: GUI hotspots flanking the middle-click area - geometry.rs: with_wheel_tilt() auto-derives tilt hotspots from the MiddleClick position; default_labels() updated for the new buttons Default behavior is unchanged: unbound or default-bound tilt events pass through as native horizontal scroll. Only when the user maps WheelLeft/WheelRight to a non-default action does the hook suppress the scroll event and fire the bound action instead. --- .../openlogi-agent-core/src/hook_runtime.rs | 78 ++++++++++++++++++- crates/openlogi-core/src/binding.rs | 19 ++++- crates/openlogi-gui/src/data/mouse_buttons.rs | 33 ++++++++ .../openlogi-gui/src/mouse_model/geometry.rs | 52 +++++++++++-- 4 files changed, 174 insertions(+), 8 deletions(-) diff --git a/crates/openlogi-agent-core/src/hook_runtime.rs b/crates/openlogi-agent-core/src/hook_runtime.rs index 5c45584d..399b3d5b 100644 --- a/crates/openlogi-agent-core/src/hook_runtime.rs +++ b/crates/openlogi-agent-core/src/hook_runtime.rs @@ -8,6 +8,7 @@ use std::cell::RefCell; use std::collections::BTreeMap; use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; use openlogi_core::binding::{ Action, ButtonId, GestureDirection, SwipeAccumulator, default_binding, @@ -19,6 +20,11 @@ use tracing::{info, warn}; use crate::DpiCycleState; use crate::hardware::{toggle_smartshift_in_background, write_dpi_in_background}; +/// Minimum gap between two fires of the same wheel-tilt direction, so one +/// deliberate tilt triggers once instead of repeating across the burst of +/// scroll events a single physical tilt produces. +const WHEEL_TILT_COOLDOWN: Duration = Duration::from_millis(250); + /// The two button maps the OS-hook callback reads, kept behind ONE lock so a /// config rebuild publishes both atomically — a press during an owner switch can /// never see the new single-action bindings against the old gesture map (or vice @@ -93,6 +99,18 @@ thread_local! { /// Thread-local rather than a shared `Mutex` keeps the hot path lock-free and /// free of cross-thread contention on the freeze-sensitive callback. static HOLD: RefCell = RefCell::new(HoldState::default()); + + /// Wheel-tilt cooldown state, one per hook-callback thread. Tracks when each + /// direction last fired so a single physical tilt (which produces a burst of + /// scroll events) triggers its action exactly once. + static WHEEL_TILT: RefCell = RefCell::new(WheelTiltState::default()); +} + +/// Cooldown tracking for the main scroll wheel's left/right tilt. +#[derive(Default)] +struct WheelTiltState { + last_fired_left: Option, + last_fired_right: Option, } /// Attempt to start the OS hook. Returns `None` if Accessibility is not @@ -210,7 +228,65 @@ pub fn start( HOLD.with_borrow_mut(HoldState::cancel); EventDisposition::PassThrough } - MouseEvent::Scroll { .. } => EventDisposition::PassThrough, + MouseEvent::Scroll { + delta_x, + from_trackpad, + .. + } => { + // Only intercept horizontal scroll from a real mouse wheel — + // trackpad horizontal scrolling (two-finger swipe) must stay + // native. + if from_trackpad || delta_x == 0.0 { + return EventDisposition::PassThrough; + } + + let (button, is_left) = if delta_x < 0.0 { + (ButtonId::WheelLeft, true) + } else { + (ButtonId::WheelRight, false) + }; + + // Resolve the bound action (owned, so no lock is held across dispatch). + let action = hooks.read().ok().and_then(|m| m.bindings.get(&button).cloned()); + let Some(action) = action else { + return EventDisposition::PassThrough; + }; + + // Default binding → native horizontal scroll passes through. + // `None` → captured but suppressed (no scroll, no action). + if matches!( + action, + Action::HorizontalScrollLeft | Action::HorizontalScrollRight + ) { + return EventDisposition::PassThrough; + } + if matches!(action, Action::None) { + return EventDisposition::Suppress; + } + + // Custom action — fire once per tilt with a cooldown so the burst + // of scroll events from a single physical tilt triggers exactly + // once. Suppress the native horizontal scroll regardless. + let now = Instant::now(); + let fire = WHEEL_TILT.with_borrow_mut(|state| { + let last = if is_left { + &mut state.last_fired_left + } else { + &mut state.last_fired_right + }; + let ok = last + .is_none_or(|t| now.saturating_duration_since(t) >= WHEEL_TILT_COOLDOWN); + if ok { + *last = Some(now); + } + ok + }); + if fire { + info!(button = %button, action = %action.label(), "wheel tilt → executing bound action"); + dispatch_action(&action, &dpi_cycle, &capture); + } + EventDisposition::Suppress + } }); match result { diff --git a/crates/openlogi-core/src/binding.rs b/crates/openlogi-core/src/binding.rs index 1463492a..716acb41 100644 --- a/crates/openlogi-core/src/binding.rs +++ b/crates/openlogi-core/src/binding.rs @@ -38,10 +38,16 @@ pub enum ButtonId { /// The HID++ gesture button on MX-line devices. The press itself /// fires the bound action; swipe directions are P1.5 territory. GestureButton, + /// Tilting the main scroll wheel left. Arrives as a horizontal scroll + /// event (negative `delta_x`) at the OS hook, not as a button press — + /// intercepted in `hook_runtime` when bound to a non-default action. + WheelLeft, + /// Tilting the main scroll wheel right. Positive `delta_x`. + WheelRight, } impl ButtonId { - pub const ALL: [ButtonId; 10] = [ + pub const ALL: [ButtonId; 12] = [ ButtonId::LeftClick, ButtonId::RightClick, ButtonId::MiddleClick, @@ -52,6 +58,8 @@ impl ButtonId { ButtonId::ThumbwheelScrollUp, ButtonId::ThumbwheelScrollDown, ButtonId::GestureButton, + ButtonId::WheelLeft, + ButtonId::WheelRight, ]; /// Whether this button is one the OS hook (macOS `CGEventTap` / Linux evdev) @@ -83,6 +91,8 @@ impl ButtonId { ButtonId::ThumbwheelScrollUp => "Thumb Wheel Up", ButtonId::ThumbwheelScrollDown => "Thumb Wheel Down", ButtonId::GestureButton => "Gesture Button", + ButtonId::WheelLeft => "Wheel Left", + ButtonId::WheelRight => "Wheel Right", } } } @@ -872,6 +882,13 @@ pub fn default_binding(button: ButtonId) -> Action { ButtonId::ThumbwheelScrollUp => Action::HorizontalScrollRight, ButtonId::ThumbwheelScrollDown => Action::HorizontalScrollLeft, ButtonId::GestureButton => Action::MissionControl, + // The main wheel tilt scrolls horizontally by default: tilting left + // produces native horizontal scroll left, tilting right produces + // native horizontal scroll right. When the user binds a custom + // action, the OS hook intercepts the horizontal scroll and fires it + // instead (see `hook_runtime`). + ButtonId::WheelLeft => Action::HorizontalScrollLeft, + ButtonId::WheelRight => Action::HorizontalScrollRight, } } diff --git a/crates/openlogi-gui/src/data/mouse_buttons.rs b/crates/openlogi-gui/src/data/mouse_buttons.rs index 1ce70480..fb749c29 100644 --- a/crates/openlogi-gui/src/data/mouse_buttons.rs +++ b/crates/openlogi-gui/src/data/mouse_buttons.rs @@ -79,6 +79,22 @@ pub fn default_hotspots() -> Vec { w: 44., h: 80., }, + // Wheel tilt: two hotspots flanking the middle-click area, so the + // user can click "left tilt" or "right tilt" to rebind them. + Hotspot { + id: ButtonId::WheelLeft, + x: 120., + y: 120., + w: 50., + h: 70., + }, + Hotspot { + id: ButtonId::WheelRight, + x: 250., + y: 120., + w: 50., + h: 70., + }, ] } @@ -107,4 +123,21 @@ mod tests { "primary clicks are not remappable and must stay out of the model" ); } + + #[test] + fn default_hotspots_expose_wheel_tilt() { + let hotspots = default_hotspots(); + assert!( + hotspots + .iter() + .any(|h| matches!(h.id, ButtonId::WheelLeft)), + "wheel-left tilt must be a mappable hotspot" + ); + assert!( + hotspots + .iter() + .any(|h| matches!(h.id, ButtonId::WheelRight)), + "wheel-right tilt must be a mappable hotspot" + ); + } } diff --git a/crates/openlogi-gui/src/mouse_model/geometry.rs b/crates/openlogi-gui/src/mouse_model/geometry.rs index ec709edc..45428592 100644 --- a/crates/openlogi-gui/src/mouse_model/geometry.rs +++ b/crates/openlogi-gui/src/mouse_model/geometry.rs @@ -110,7 +110,7 @@ pub fn asset_hotspots_for_png(asset: &ResolvedAsset, mouse_w: f32, mouse_h: f32) }) .collect(); - with_thumbwheel_rotation(hotspots) + with_thumbwheel_rotation(with_wheel_tilt(hotspots)) } /// Replace the thumb-wheel *click* hotspot with two rotation hotspots @@ -140,6 +140,33 @@ fn with_thumbwheel_rotation(mut hotspots: Vec) -> Vec { hotspots } +/// Add `WheelLeft` / `WheelRight` hotspots flanking the middle-click area, so +/// the main scroll wheel's left/right tilt is remappable even on devices whose +/// Logitech metadata has no explicit tilt marker. No-op when the device has no +/// middle-click marker. +fn with_wheel_tilt(mut hotspots: Vec) -> Vec { + let Some(wheel) = hotspots.iter().find(|h| h.id == ButtonId::MiddleClick).copied() else { + return hotspots; + }; + let tilt_w = (wheel.w * 0.8).min(50.); + let gap = 6.; + hotspots.push(Hotspot { + id: ButtonId::WheelLeft, + x: wheel.x - tilt_w - gap, + y: wheel.y + (wheel.h - wheel.h * 0.7) / 2., + w: tilt_w, + h: wheel.h * 0.7, + }); + hotspots.push(Hotspot { + id: ButtonId::WheelRight, + x: wheel.x + wheel.w + gap, + y: wheel.y + (wheel.h - wheel.h * 0.7) / 2., + w: tilt_w, + h: wheel.h * 0.7, + }); + hotspots +} + /// Lay labels out on the left side, evenly spaced down the mouse's vertical /// extent. Slots are assigned in order of the hotspots' y position (top /// hotspot → top label) so leader lines don't cross. @@ -180,32 +207,45 @@ pub fn labels_from_hotspots(hotspots: &[Hotspot], mouse_h: f32) -> Vec