Skip to content
Open
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
88 changes: 87 additions & 1 deletion crates/openlogi-agent-core/src/hook_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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<HoldState> = 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<WheelTiltState> = RefCell::new(WheelTiltState::default());
}

/// Cooldown tracking for the main scroll wheel's left/right tilt.
#[derive(Default)]
struct WheelTiltState {
last_fired_left: Option<Instant>,
last_fired_right: Option<Instant>,
}

/// Attempt to start the OS hook. Returns `None` if Accessibility is not
Expand Down Expand Up @@ -210,7 +228,75 @@ 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 {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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 or `None` → native horizontal scroll passes
// through. `None` means "no remapping", so the user keeps native
// scrolling rather than losing it with no replacement action.
if matches!(
action,
Action::HorizontalScrollLeft
| Action::HorizontalScrollRight
| Action::None
) {
return EventDisposition::PassThrough;
}

// 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 entire burst so no native horizontal scroll
// leaks through alongside the fired action. The cooldown prevents
// repeated dispatches without letting the captured tilt burst
// continue as native scroll.
//
// Known limitation: on devices with BOTH a tilt wheel and a
// thumbwheel (e.g. MX Master 3S), mapping WheelLeft/WheelRight to
// a custom action will also intercept the thumbwheel's native
// horizontal scroll. Users who rely on native thumbwheel scroll
// should not remap WheelLeft/WheelRight, or should divert the
// thumbwheel via HID++ instead.
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 {
Expand Down
19 changes: 18 additions & 1 deletion crates/openlogi-core/src/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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",
}
}
}
Expand Down Expand Up @@ -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,
}
}

Expand Down
33 changes: 33 additions & 0 deletions crates/openlogi-gui/src/data/mouse_buttons.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,22 @@ pub fn default_hotspots() -> Vec<Hotspot> {
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.,
},
]
}

Expand Down Expand Up @@ -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"
);
}
}
52 changes: 46 additions & 6 deletions crates/openlogi-gui/src/mouse_model/geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -140,6 +140,33 @@ fn with_thumbwheel_rotation(mut hotspots: Vec<Hotspot>) -> Vec<Hotspot> {
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<Hotspot>) -> Vec<Hotspot> {
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.
Expand Down Expand Up @@ -180,32 +207,45 @@ pub fn labels_from_hotspots(hotspots: &[Hotspot], mouse_h: f32) -> Vec<Label> {
}

/// Label positions for the synthetic fallback silhouette.
///
/// Seven labels evenly spaced down the 560 px canvas (step = 560 / 8 = 70),
/// so each has enough vertical room for the 56 px tall label card.
pub fn default_labels() -> Vec<Label> {
vec![
Label {
id: ButtonId::WheelLeft,
side: Side::Left,
y: 70.,
},
Label {
id: ButtonId::MiddleClick,
side: Side::Left,
y: 120.,
y: 140.,
},
Label {
id: ButtonId::WheelRight,
side: Side::Left,
y: 210.,
},
Label {
id: ButtonId::Back,
side: Side::Left,
y: 240.,
y: 280.,
},
Label {
id: ButtonId::Forward,
side: Side::Left,
y: 340.,
y: 350.,
},
Label {
id: ButtonId::DpiToggle,
side: Side::Left,
y: 430.,
y: 420.,
},
Label {
id: ButtonId::GestureButton,
side: Side::Left,
y: 510.,
y: 490.,
},
]
}
Expand Down