From 183eb5957d992de3debf2e9df5c0319d041417b2 Mon Sep 17 00:00:00 2001 From: KDTHEGREATEST Date: Sun, 14 Jun 2026 12:06:08 +0300 Subject: [PATCH 1/4] Add wheel scroll settings --- .../openlogi-agent-core/src/hook_runtime.rs | 121 +++++++++++++- .../openlogi-agent-core/src/orchestrator.rs | 11 +- crates/openlogi-agent/src/main.rs | 1 + crates/openlogi-core/src/binding.rs | 77 ++++++++- crates/openlogi-core/src/config.rs | 19 +++ crates/openlogi-gui/src/state.rs | 26 +++ crates/openlogi-gui/src/windows/settings.rs | 158 +++++++++++++++++- 7 files changed, 405 insertions(+), 8 deletions(-) diff --git a/crates/openlogi-agent-core/src/hook_runtime.rs b/crates/openlogi-agent-core/src/hook_runtime.rs index a967b87a..a2eeb08d 100644 --- a/crates/openlogi-agent-core/src/hook_runtime.rs +++ b/crates/openlogi-agent-core/src/hook_runtime.rs @@ -10,8 +10,9 @@ use std::collections::BTreeMap; use std::sync::{Arc, RwLock}; use openlogi_core::binding::{ - Action, ButtonId, GestureDirection, SwipeAccumulator, default_binding, + Action, ButtonId, GestureDirection, SwipeAccumulator, default_binding, post_scroll_delta, }; +use openlogi_core::config::AppSettings; use openlogi_hid::CaptureChannel; use openlogi_hook::{EventDisposition, Hook, MouseEvent}; use tracing::{info, warn}; @@ -38,6 +39,38 @@ pub struct HookMaps { /// (orchestrator), the OS-hook callback, and the gesture watcher. pub type SharedHookMaps = Arc>; +/// App-wide scroll-wheel preferences mirrored from config into the hook callback. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ScrollSettings { + pub inverted: bool, + pub strength: u8, + pub tactility: u8, +} + +impl Default for ScrollSettings { + fn default() -> Self { + Self { + inverted: false, + strength: 1, + tactility: 0, + } + } +} + +impl ScrollSettings { + #[must_use] + pub fn from_app_settings(settings: &AppSettings) -> Self { + Self { + inverted: settings.wheel_inverted, + strength: settings.wheel_strength.max(1), + tactility: settings.wheel_tactility, + } + } +} + +/// Shared scroll-wheel preferences read by the OS-hook callback. +pub type SharedScrollSettings = Arc>; + /// Tracks which OS-hook button (Middle/Back/Forward) is mid-hold and defers the /// swipe detection itself to a shared [`SwipeAccumulator`], which commits a swipe /// *mid-motion* like the HID++ thumb-pad path in `openlogi-hid`. This wrapper @@ -101,6 +134,7 @@ pub fn start( hooks: SharedHookMaps, dpi_cycle: Arc>, capture: CaptureChannel, + scroll_settings: SharedScrollSettings, ) -> Option { if !Hook::has_accessibility() { warn!( @@ -210,7 +244,22 @@ pub fn start( HOLD.with_borrow_mut(HoldState::cancel); EventDisposition::PassThrough } - MouseEvent::Scroll { .. } => EventDisposition::PassThrough, + MouseEvent::Scroll { delta_x, delta_y } => { + let settings = scroll_settings + .read() + .map(|guard| *guard) + .unwrap_or_default(); + if settings == ScrollSettings::default() { + return EventDisposition::PassThrough; + } + + let (v, h) = transform_scroll(delta_x, delta_y, settings); + if v == 0 && h == 0 { + return EventDisposition::PassThrough; + } + post_scroll_delta(v, h); + EventDisposition::Suppress + } }); match result { @@ -246,6 +295,35 @@ fn resolve_gesture_click( /// the hook should pass the event through to the OS rather than suppress and /// re-synthesise it. For Back/Forward this keeps the genuine hardware button /// 4/5 intact instead of round-tripping it through synthesis. +fn transform_scroll(delta_x: f32, delta_y: f32, settings: ScrollSettings) -> (i32, i32) { + let strength = f32::from(settings.strength.max(1)); + let tactility = i32::from(settings.tactility.min(10)); + + let mut h = delta_x * strength; + let mut v = delta_y * strength; + if settings.inverted { + h = -h; + v = -v; + } + + (quantize_scroll(v, tactility), quantize_scroll(h, tactility)) +} + +fn quantize_scroll(value: f32, tactility: i32) -> i32 { + let v = value.round() as i32; + if tactility <= 1 { + return v; + } + + let step = tactility; + let abs = v.abs(); + let snapped = ((abs + step / 2) / step) * step; + if snapped == 0 && v != 0 { + return v.signum() * step; + } + if v < 0 { -snapped } else { snapped } +} + fn is_native_click(id: ButtonId, action: &Action) -> bool { matches!( (id, action), @@ -311,6 +389,45 @@ mod tests { use super::*; use openlogi_core::binding::GESTURE_SWIPE_THRESHOLD; + #[test] + fn transform_scroll_preserves_axis_order() { + let settings = ScrollSettings { + inverted: false, + strength: 2, + tactility: 0, + }; + + assert_eq!(transform_scroll(3.0, -4.0, settings), (-8, 6)); + } + + #[test] + fn transform_scroll_inverts_both_axes() { + let settings = ScrollSettings { + inverted: true, + strength: 1, + tactility: 0, + }; + + assert_eq!(transform_scroll(2.0, -3.0, settings), (3, -2)); + } + + #[test] + fn quantize_scroll_keeps_small_non_zero_motion() { + assert_eq!(quantize_scroll(1.0, 4), 4); + assert_eq!(quantize_scroll(-1.0, 4), -4); + } + + #[test] + fn transform_scroll_rounds_micro_motion_to_zero_for_native_passthrough() { + let settings = ScrollSettings { + inverted: true, + strength: 1, + tactility: 0, + }; + + assert_eq!(transform_scroll(0.2, -0.3, settings), (0, 0)); + } + // The mid-swipe gate itself is unit-tested on `SwipeAccumulator` in // `openlogi-core`; these cover only what `HoldState` adds on top — tagging a // commit with the held button, and matching the button on release. diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index 4fd394af..59a83189 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -22,7 +22,7 @@ use tracing::warn; use crate::DpiCycleState; use crate::bindings::{bindings_for, gesture_bindings_for, oshook_gestures_for}; use crate::device_order::DeviceStableId; -use crate::hook_runtime::{HookMaps, SharedHookMaps}; +use crate::hook_runtime::{HookMaps, ScrollSettings, SharedHookMaps, SharedScrollSettings}; use crate::ipc::InventoryHealth; use crate::watchers::gesture::GestureBindings; @@ -54,6 +54,7 @@ pub struct SharedRuntime { pub gesture_bindings: GestureBindings, pub dpi_cycle: Arc>, pub thumbwheel_sensitivity: Arc, + pub scroll_settings: SharedScrollSettings, pub capture_channel: CaptureChannel, /// Set while a pairing session runs: the gesture watcher then releases its /// capture session so `run_pairing` can own the receiver's HID node (one @@ -109,6 +110,9 @@ impl Orchestrator { thumbwheel_sensitivity: Arc::new(AtomicI32::new( config.app_settings.thumbwheel_sensitivity, )), + scroll_settings: Arc::new(RwLock::new(ScrollSettings::from_app_settings( + &config.app_settings, + ))), capture_channel: Arc::new(RwLock::new(None)), pairing_active: Arc::new(AtomicBool::new(false)), capture_idle: Arc::new(AtomicBool::new(true)), @@ -182,6 +186,11 @@ impl Orchestrator { self.config.app_settings.thumbwheel_sensitivity, Ordering::Relaxed, ); + write_value( + &self.shared.scroll_settings, + ScrollSettings::from_app_settings(&self.config.app_settings), + "scroll_settings", + ); } /// Apply a fresh inventory snapshot. Always refreshes the snapshot the IPC diff --git a/crates/openlogi-agent/src/main.rs b/crates/openlogi-agent/src/main.rs index 2dbd7ec4..165ddfef 100644 --- a/crates/openlogi-agent/src/main.rs +++ b/crates/openlogi-agent/src/main.rs @@ -201,6 +201,7 @@ async fn run(config: Config) { shared.hook_maps.clone(), shared.dpi_cycle.clone(), shared.capture_channel.clone(), + shared.scroll_settings.clone(), ); hook_installed.store(hook.is_some(), Ordering::Relaxed); } diff --git a/crates/openlogi-core/src/binding.rs b/crates/openlogi-core/src/binding.rs index ed160165..3a50f7a1 100644 --- a/crates/openlogi-core/src/binding.rs +++ b/crates/openlogi-core/src/binding.rs @@ -1174,6 +1174,37 @@ impl Action { /// need tuning per device, since the diverted resolution differs from native. /// /// No-op (logs nothing) on platforms without a supported injection mechanism. + +/// Re-inject a captured scroll-wheel event after applying user preferences. +/// +/// `v` is vertical axis 1, `h` is horizontal axis 2. On macOS this posts at the +/// session tap rather than the HID tap so OpenLogi's own HID event tap does not +/// capture and transform the synthetic event again. +pub fn post_scroll_delta(v: i32, h: i32) { + if v == 0 && h == 0 { + return; + } + + #[cfg(target_os = "macos")] + macos::post_scroll_delta(v, h); + + #[cfg(target_os = "linux")] + { + if v != 0 { + linux::scroll(evdev::RelativeAxisCode::REL_WHEEL, v); + } + if h != 0 { + linux::scroll(evdev::RelativeAxisCode::REL_HWHEEL, h); + } + } + + #[cfg(target_os = "windows")] + windows::post_scroll_delta(v, h); + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + let _ = (v, h); +} + pub fn post_horizontal_scroll(delta: i32) { #[cfg(target_os = "macos")] macos::post_horizontal_scroll(delta); @@ -1414,6 +1445,19 @@ mod macos { ev.post(CGEventTapLocation::HID); } + /// Post a scroll event with explicit vertical / horizontal deltas. + pub(super) fn post_scroll_delta(v: i32, h: i32) { + let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { + tracing::warn!("CGEventSource::new failed for scroll"); + return; + }; + let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::PIXEL, 2, v, h, 0) else { + tracing::warn!("CGEvent::new_scroll_event failed"); + return; + }; + ev.post(CGEventTapLocation::Session); + } + /// Post a horizontal scroll of `delta` lines (wheel2 axis). Line units suit /// the thumb wheel's ratchet-like increments better than pixels. pub(super) fn post_horizontal_scroll(delta: i32) { @@ -1721,8 +1765,8 @@ pub fn default_gesture_binding(direction: GestureDirection) -> Action { match direction { GestureDirection::Up => Action::MissionControl, GestureDirection::Down => Action::ShowDesktop, - GestureDirection::Left => Action::PrevTab, - GestureDirection::Right => Action::NextTab, + GestureDirection::Left => Action::PreviousDesktop, + GestureDirection::Right => Action::NextDesktop, GestureDirection::Click => Action::AppExpose, } } @@ -2338,6 +2382,23 @@ mod windows { send_inputs(&[mouse_input(flags, data)]); } + pub(super) fn post_scroll_delta(v: i32, h: i32) { + let mut inputs = Vec::new(); + if v != 0 { + inputs.push(mouse_input( + MOUSEEVENTF_WHEEL, + v.saturating_mul(WHEEL_DELTA), + )); + } + if h != 0 { + inputs.push(mouse_input( + MOUSEEVENTF_HWHEEL, + h.saturating_mul(WHEEL_DELTA), + )); + } + send_inputs(&inputs); + } + pub(super) fn post_horizontal_scroll(delta: i32) { if delta == 0 { return; @@ -2883,6 +2944,18 @@ mod tests { assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll); } + #[test] + fn default_gesture_horizontal_swipes_switch_desktops() { + assert_eq!( + default_gesture_binding(GestureDirection::Left), + Action::PreviousDesktop + ); + assert_eq!( + default_gesture_binding(GestureDirection::Right), + Action::NextDesktop + ); + } + #[test] fn category_navigation_variants() { assert_eq!(Action::MissionControl.category(), Category::Navigation); diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs index 12e5f28d..a5eefb5a 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -88,6 +88,17 @@ pub struct AppSettings { /// user opt in on first launch. #[serde(default)] pub update_prompt_seen: bool, + /// Reverse the direction of captured scroll-wheel gestures. + #[serde(default)] + pub wheel_inverted: bool, + /// Scroll strength multiplier. `1` keeps the physical wheel's native feel; + /// larger values make each wheel tick travel farther. + #[serde(default = "default_scroll_strength")] + pub wheel_strength: u8, + /// Scroll tactility / chunk size. `0` or `1` keeps motion smooth; larger + /// values quantize emitted scroll into chunkier, more tactile steps. + #[serde(default)] + pub wheel_tactility: u8, /// Whether OpenLogi shows a macOS menu-bar (status item) icon. `true` /// (default) → it lives in the menu bar, dropping the Dock icon while no /// window is open; `false` → it stays an ordinary Dock app with no status @@ -142,6 +153,9 @@ impl Default for AppSettings { launch_at_login: false, check_for_updates: false, update_prompt_seen: false, + wheel_inverted: false, + wheel_strength: default_scroll_strength(), + wheel_tactility: 0, show_in_menu_bar: true, auto_download_assets: true, language: None, @@ -156,6 +170,11 @@ fn default_true() -> bool { true } +/// serde default for [`AppSettings::wheel_strength`]: keep native feel. +const fn default_scroll_strength() -> u8 { + 1 +} + /// serde default for [`AppSettings::thumbwheel_sensitivity`]: keeps configs /// predating the field at the 1× default. const fn default_thumbwheel_sensitivity() -> i32 { diff --git a/crates/openlogi-gui/src/state.rs b/crates/openlogi-gui/src/state.rs index 1018d0db..61b3bbe0 100644 --- a/crates/openlogi-gui/src/state.rs +++ b/crates/openlogi-gui/src/state.rs @@ -1050,6 +1050,32 @@ impl AppState { /// Set the thumb-wheel sensitivity (clamped to the valid range), publish it /// to the gesture watcher via the shared atomic, and persist it. No-op when /// unchanged. Disk failures are logged, not propagated. + pub fn set_wheel_inverted(&mut self, inverted: bool) { + if self.config.app_settings.wheel_inverted == inverted { + return; + } + self.config.app_settings.wheel_inverted = inverted; + self.persist_and_reload("wheel inversion"); + } + + pub fn set_wheel_strength(&mut self, strength: u8) { + let strength = strength.clamp(1, 10); + if self.config.app_settings.wheel_strength == strength { + return; + } + self.config.app_settings.wheel_strength = strength; + self.persist_and_reload("wheel strength"); + } + + pub fn set_wheel_tactility(&mut self, tactility: u8) { + let tactility = tactility.min(10); + if self.config.app_settings.wheel_tactility == tactility { + return; + } + self.config.app_settings.wheel_tactility = tactility; + self.persist_and_reload("wheel tactility"); + } + pub fn set_thumbwheel_sensitivity(&mut self, sensitivity: i32) { let sensitivity = sensitivity.clamp( openlogi_core::config::MIN_THUMBWHEEL_SENSITIVITY, diff --git a/crates/openlogi-gui/src/windows/settings.rs b/crates/openlogi-gui/src/windows/settings.rs index 08222016..f9a0a572 100644 --- a/crates/openlogi-gui/src/windows/settings.rs +++ b/crates/openlogi-gui/src/windows/settings.rs @@ -26,6 +26,12 @@ use openlogi_core::config::{ DEFAULT_THUMBWHEEL_SENSITIVITY, MAX_THUMBWHEEL_SENSITIVITY, MIN_THUMBWHEEL_SENSITIVITY, }; +const DEFAULT_WHEEL_STRENGTH: u8 = 1; +const MIN_WHEEL_STRENGTH: u8 = 1; +const MAX_WHEEL_STRENGTH: u8 = 10; +const DEFAULT_WHEEL_TACTILITY: u8 = 0; +const MAX_WHEEL_TACTILITY: u8 = 10; + use crate::app_menu::{CloseWindow, Minimize, Zoom}; #[cfg(target_os = "macos")] use crate::platform::permissions::Permission; @@ -42,6 +48,8 @@ pub struct SettingsView { appearance_obs: Option, language_select: Entity>>, sensitivity_slider: Entity, + wheel_strength_slider: Entity, + wheel_tactility_slider: Entity, /// Asset-cache size blurb, computed once when the window opens rather than /// re-walking the cache on every render. A snapshot — reopen to refresh /// after a Clear. @@ -77,10 +85,48 @@ impl SettingsView { cx.subscribe_in(&sensitivity_slider, window, Self::on_sensitivity_slider) .detach(); + let wheel_strength = cx + .try_global::() + .map_or(DEFAULT_WHEEL_STRENGTH, |s| { + s.app_settings().wheel_strength.max(1) + }); + let wheel_strength_slider = cx.new(|_| { + SliderState::new() + .min(f32::from(MIN_WHEEL_STRENGTH)) + .max(f32::from(MAX_WHEEL_STRENGTH)) + .default_value(f32::from(wheel_strength)) + }); + cx.subscribe_in( + &wheel_strength_slider, + window, + Self::on_wheel_strength_slider, + ) + .detach(); + + let wheel_tactility = cx + .try_global::() + .map_or(DEFAULT_WHEEL_TACTILITY, |s| { + s.app_settings().wheel_tactility + }); + let wheel_tactility_slider = cx.new(|_| { + SliderState::new() + .min(0.0) + .max(f32::from(MAX_WHEEL_TACTILITY)) + .default_value(f32::from(wheel_tactility)) + }); + cx.subscribe_in( + &wheel_tactility_slider, + window, + Self::on_wheel_tactility_slider, + ) + .detach(); + Self { appearance_obs: None, language_select, sensitivity_slider, + wheel_strength_slider, + wheel_tactility_slider, asset_cache_desc: cache_size_description(), } } @@ -111,6 +157,52 @@ impl SettingsView { cx.notify(); } + #[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "slider values are small stepped integer ranges" + )] + #[allow( + clippy::unused_self, + reason = "gpui subscription handlers must take &mut self" + )] + fn on_wheel_strength_slider( + &mut self, + _: &Entity, + event: &SliderEvent, + _: &mut Window, + cx: &mut Context, + ) { + if let SliderEvent::Release(value) = event { + let strength = value.start().round() as u8; + cx.update_global::(|s, _| s.set_wheel_strength(strength)); + } + cx.notify(); + } + + #[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "slider values are small stepped integer ranges" + )] + #[allow( + clippy::unused_self, + reason = "gpui subscription handlers must take &mut self" + )] + fn on_wheel_tactility_slider( + &mut self, + _: &Entity, + event: &SliderEvent, + _: &mut Window, + cx: &mut Context, + ) { + if let SliderEvent::Release(value) = event { + let tactility = value.start().round() as u8; + cx.update_global::(|s, _| s.set_wheel_tactility(tactility)); + } + cx.notify(); + } + fn on_language_select( &mut self, _: &Entity>>, @@ -162,7 +254,11 @@ impl Render for SettingsView { .child( Settings::new("settings") .sidebar_width(px(210.)) - .page(general_page(self.sensitivity_slider.clone())) + .page(general_page( + self.sensitivity_slider.clone(), + self.wheel_strength_slider.clone(), + self.wheel_tactility_slider.clone(), + )) .page(permissions_page(pal)) .page(assets_page(pal, self.asset_cache_desc.clone())) .page(language_page(self.language_select.clone())), @@ -170,7 +266,11 @@ impl Render for SettingsView { } } -fn general_page(sensitivity_slider: Entity) -> SettingPage { +fn general_page( + sensitivity_slider: Entity, + wheel_strength_slider: Entity, + wheel_tactility_slider: Entity, +) -> SettingPage { let group = SettingGroup::new() .item( SettingItem::new( @@ -183,6 +283,44 @@ fn general_page(sensitivity_slider: Entity) -> SettingPage { "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger." )), ) + .item( + SettingItem::new( + tr!("Invert wheel direction"), + SettingField::switch( + |cx| { + cx.try_global::() + .is_some_and(|s| s.app_settings().wheel_inverted) + }, + |enabled, cx| { + cx.update_global::(move |s, _| { + s.set_wheel_inverted(enabled); + }); + cx.refresh_windows(); + }, + ), + ) + .description(tr!( + "Reverse captured vertical and horizontal wheel scrolling." + )), + ) + .item( + SettingItem::new( + tr!("Wheel strength"), + SettingField::render(move |_, _, cx| { + wheel_slider_field(&wheel_strength_slider, DEFAULT_WHEEL_STRENGTH, cx) + }), + ) + .description(tr!("Multiplies captured wheel deltas before re-emitting them.")), + ) + .item( + SettingItem::new( + tr!("Wheel tactility"), + SettingField::render(move |_, _, cx| { + wheel_slider_field(&wheel_tactility_slider, DEFAULT_WHEEL_TACTILITY, cx) + }), + ) + .description(tr!("Quantizes captured wheel deltas into chunkier steps. 0 keeps smooth scrolling.")), + ) .item( SettingItem::new( tr!("Launch at login"), @@ -612,9 +750,23 @@ fn language_select_field( clippy::cast_sign_loss, reason = "slider value is a stepped 1..=100 figure" )] +fn wheel_slider_field(slider: &Entity, default_value: u8, cx: &mut App) -> AnyElement { + let value = slider.read(cx).value().start().round() as u8; + slider_value_field(slider, value.to_string(), value == default_value, cx) +} + fn sensitivity_field(slider: &Entity, cx: &mut App) -> AnyElement { let value = slider.read(cx).value().start().round() as i32; let is_default = value == DEFAULT_THUMBWHEEL_SENSITIVITY; + slider_value_field(slider, value.to_string(), is_default, cx) +} + +fn slider_value_field( + slider: &Entity, + label: String, + is_default: bool, + cx: &mut App, +) -> AnyElement { let pal = theme::palette(cx); v_flex() .flex_shrink_0() @@ -629,7 +781,7 @@ fn sensitivity_field(slider: &Entity, cx: &mut App) -> AnyElement { .w(px(72.)) .text_sm() .text_color(pal.text_muted) - .child(value.to_string()), + .child(label), ), ) .when(is_default, |this| { From 853e71d9a34d6ed7093fac9124966071683cf76c Mon Sep 17 00:00:00 2001 From: KDTHEGREATEST Date: Sun, 14 Jun 2026 16:31:51 +0300 Subject: [PATCH 2/4] Fix macOS transformed scroll units --- crates/openlogi-core/src/binding.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/openlogi-core/src/binding.rs b/crates/openlogi-core/src/binding.rs index 3a50f7a1..4baae265 100644 --- a/crates/openlogi-core/src/binding.rs +++ b/crates/openlogi-core/src/binding.rs @@ -1445,13 +1445,13 @@ mod macos { ev.post(CGEventTapLocation::HID); } - /// Post a scroll event with explicit vertical / horizontal deltas. + /// Post a scroll event with explicit vertical / horizontal line deltas. pub(super) fn post_scroll_delta(v: i32, h: i32) { let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { tracing::warn!("CGEventSource::new failed for scroll"); return; }; - let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::PIXEL, 2, v, h, 0) else { + let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::LINE, 2, v, h, 0) else { tracing::warn!("CGEvent::new_scroll_event failed"); return; }; From e8b7819d0d32f251a61dc7a0b571af0e918e4f9d Mon Sep 17 00:00:00 2001 From: KDTHEGREATEST Date: Sun, 14 Jun 2026 17:25:17 +0300 Subject: [PATCH 3/4] Fix scroll settings doc comments --- .../openlogi-agent-core/src/hook_runtime.rs | 12 ++++++----- crates/openlogi-core/src/binding.rs | 20 +++++++++---------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/crates/openlogi-agent-core/src/hook_runtime.rs b/crates/openlogi-agent-core/src/hook_runtime.rs index a2eeb08d..5e0a820a 100644 --- a/crates/openlogi-agent-core/src/hook_runtime.rs +++ b/crates/openlogi-agent-core/src/hook_runtime.rs @@ -290,11 +290,8 @@ fn resolve_gesture_click( .unwrap_or_else(|| default_binding(id)) } -/// Whether `action` is just `id`'s own native event — i.e. the button is mapped -/// to the very click (or extra-button press) it already produces. In that case -/// the hook should pass the event through to the OS rather than suppress and -/// re-synthesise it. For Back/Forward this keeps the genuine hardware button -/// 4/5 intact instead of round-tripping it through synthesis. +/// Apply the app-wide scroll preferences to a captured wheel event, returning +/// vertical (axis 1) and horizontal (axis 2) line deltas to re-inject. fn transform_scroll(delta_x: f32, delta_y: f32, settings: ScrollSettings) -> (i32, i32) { let strength = f32::from(settings.strength.max(1)); let tactility = i32::from(settings.tactility.min(10)); @@ -324,6 +321,11 @@ fn quantize_scroll(value: f32, tactility: i32) -> i32 { if v < 0 { -snapped } else { snapped } } +/// Whether `action` is just `id`'s own native event — i.e. the button is mapped +/// to the very click (or extra-button press) it already produces. In that case +/// the hook should pass the event through to the OS rather than suppress and +/// re-synthesise it. For Back/Forward this keeps the genuine hardware button +/// 4/5 intact instead of round-tripping it through synthesis. fn is_native_click(id: ButtonId, action: &Action) -> bool { matches!( (id, action), diff --git a/crates/openlogi-core/src/binding.rs b/crates/openlogi-core/src/binding.rs index 4baae265..8218e41f 100644 --- a/crates/openlogi-core/src/binding.rs +++ b/crates/openlogi-core/src/binding.rs @@ -1165,16 +1165,6 @@ impl Action { } } -/// Synthesise a horizontal scroll of `delta` wheel lines at the current focus. -/// -/// Used by the gesture/thumbwheel capture watcher to re-inject the MX thumb -/// wheel's scrolling after the wheel has been diverted over HID++ to capture its -/// click. `delta` is the device's raw rotation; its sign follows the wheel's -/// rotation convention and its magnitude (one line per rotation increment) may -/// need tuning per device, since the diverted resolution differs from native. -/// -/// No-op (logs nothing) on platforms without a supported injection mechanism. - /// Re-inject a captured scroll-wheel event after applying user preferences. /// /// `v` is vertical axis 1, `h` is horizontal axis 2. On macOS this posts at the @@ -1205,6 +1195,16 @@ pub fn post_scroll_delta(v: i32, h: i32) { let _ = (v, h); } +/// Synthesise a horizontal scroll of `delta` wheel lines at the current focus. +/// +/// Used by the gesture/thumbwheel capture watcher to re-inject the MX thumb +/// wheel's scrolling after the wheel has been diverted over HID++ to capture its +/// click. `delta` is the device's raw rotation; its sign follows the wheel's +/// rotation convention and its magnitude (one line per rotation increment) may +/// need tuning per device, since the diverted resolution differs from native. +/// +/// No-op (logs nothing) on platforms without a supported injection mechanism. + pub fn post_horizontal_scroll(delta: i32) { #[cfg(target_os = "macos")] macos::post_horizontal_scroll(delta); From 28e63fe3c1a8872146b398b1b199393854296231 Mon Sep 17 00:00:00 2001 From: KDTHEGREATEST Date: Mon, 15 Jun 2026 23:14:26 +0300 Subject: [PATCH 4/4] Address PR 94 scroll transform review --- .../openlogi-agent-core/src/hook_runtime.rs | 35 +++++-- crates/openlogi-core/src/binding.rs | 4 +- crates/openlogi-hook/src/lib.rs | 23 +++++ crates/openlogi-hook/src/linux.rs | 16 ++-- crates/openlogi-hook/src/macos.rs | 95 ++++++++++++++++++- crates/openlogi-hook/src/tests.rs | 1 + crates/openlogi-hook/src/windows.rs | 7 +- 7 files changed, 162 insertions(+), 19 deletions(-) diff --git a/crates/openlogi-agent-core/src/hook_runtime.rs b/crates/openlogi-agent-core/src/hook_runtime.rs index 5e0a820a..9d3d3661 100644 --- a/crates/openlogi-agent-core/src/hook_runtime.rs +++ b/crates/openlogi-agent-core/src/hook_runtime.rs @@ -10,10 +10,12 @@ use std::collections::BTreeMap; use std::sync::{Arc, RwLock}; use openlogi_core::binding::{ - Action, ButtonId, GestureDirection, SwipeAccumulator, default_binding, post_scroll_delta, + Action, ButtonId, GestureDirection, SwipeAccumulator, default_binding, }; use openlogi_core::config::AppSettings; use openlogi_hid::CaptureChannel; +#[cfg(target_os = "macos")] +use openlogi_hook::ScrollTransform; use openlogi_hook::{EventDisposition, Hook, MouseEvent}; use tracing::{info, warn}; @@ -244,21 +246,38 @@ pub fn start( HOLD.with_borrow_mut(HoldState::cancel); EventDisposition::PassThrough } - MouseEvent::Scroll { delta_x, delta_y } => { + MouseEvent::Scroll { + delta_x, + delta_y, + is_continuous, + } => { let settings = scroll_settings .read() .map(|guard| *guard) .unwrap_or_default(); - if settings == ScrollSettings::default() { + if settings == ScrollSettings::default() || is_continuous { return EventDisposition::PassThrough; } - let (v, h) = transform_scroll(delta_x, delta_y, settings); - if v == 0 && h == 0 { - return EventDisposition::PassThrough; + #[cfg(target_os = "macos")] + { + let _ = (delta_x, delta_y); + EventDisposition::TransformScroll(ScrollTransform { + inverted: settings.inverted, + strength: settings.strength, + tactility: settings.tactility, + }) + } + + #[cfg(not(target_os = "macos"))] + { + let (v, h) = transform_scroll(delta_x, delta_y, settings); + if v == 0 && h == 0 { + return EventDisposition::PassThrough; + } + openlogi_core::binding::post_scroll_delta(v, h); + EventDisposition::Suppress } - post_scroll_delta(v, h); - EventDisposition::Suppress } }); diff --git a/crates/openlogi-core/src/binding.rs b/crates/openlogi-core/src/binding.rs index 8218e41f..c9485d79 100644 --- a/crates/openlogi-core/src/binding.rs +++ b/crates/openlogi-core/src/binding.rs @@ -1765,8 +1765,8 @@ pub fn default_gesture_binding(direction: GestureDirection) -> Action { match direction { GestureDirection::Up => Action::MissionControl, GestureDirection::Down => Action::ShowDesktop, - GestureDirection::Left => Action::PreviousDesktop, - GestureDirection::Right => Action::NextDesktop, + GestureDirection::Left => Action::PrevTab, + GestureDirection::Right => Action::NextTab, GestureDirection::Click => Action::AppExpose, } } diff --git a/crates/openlogi-hook/src/lib.rs b/crates/openlogi-hook/src/lib.rs index 75d31740..3e8e8393 100644 --- a/crates/openlogi-hook/src/lib.rs +++ b/crates/openlogi-hook/src/lib.rs @@ -43,6 +43,11 @@ pub enum MouseEvent { delta_x: f32, /// Positive = down, negative = up. delta_y: f32, + /// `true` for continuous/pixel scroll devices such as trackpads. + /// + /// macOS uses this to keep mouse-wheel transforms from touching the + /// built-in trackpad's natural scrolling stream. + is_continuous: bool, }, /// Pointer movement, in device units. Emitted so a held gesture button can /// accumulate a swipe; the callback passes these through (the cursor keeps @@ -68,6 +73,24 @@ pub enum EventDisposition { PassThrough, /// Drop the event; the target application never sees it. Suppress, + /// Mutate a captured scroll event in place and let it continue. + /// + /// Only macOS can rewrite the original `CGEvent` in the tap callback. Other + /// platform hooks treat this as pass-through, and callers should only return + /// it when handling a `MouseEvent::Scroll` they know can be transformed. + TransformScroll(ScrollTransform), +} + +/// In-place scroll-wheel transform requested by the hook runtime. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ScrollTransform { + /// Negate scroll axes. + pub inverted: bool, + /// Multiplier applied to line, pixel, and fixed-point scroll fields. + pub strength: u8, + /// Optional line-delta chunking strength. Pixel/fixed fields keep their + /// native precision and are only scaled/inverted. + pub tactility: u8, } /// Errors that [`Hook::start`] and related functions can produce. diff --git a/crates/openlogi-hook/src/linux.rs b/crates/openlogi-hook/src/linux.rs index a36913bc..dc7ec6cf 100644 --- a/crates/openlogi-hook/src/linux.rs +++ b/crates/openlogi-hook/src/linux.rs @@ -211,7 +211,11 @@ fn wait_readable(device_fd: i32, stop_fd: i32) -> bool { } fn scroll(delta_x: f32, delta_y: f32) -> MouseEvent { - MouseEvent::Scroll { delta_x, delta_y } + MouseEvent::Scroll { + delta_x, + delta_y, + is_continuous: false, + } } fn translate(event: &evdev::InputEvent, hires_scroll: bool) -> Option { @@ -366,7 +370,7 @@ fn device_thread( } None => EventDisposition::PassThrough, }; - if matches!(disposition, EventDisposition::PassThrough) { + if !matches!(disposition, EventDisposition::Suppress) { pending.push(event); } } @@ -575,7 +579,7 @@ mod tests { let event = InputEvent::new(EventType::RELATIVE.0, RelativeAxisCode::REL_WHEEL.0, 3); let result = translate(&event, false); assert!( - matches!(result, Some(MouseEvent::Scroll { delta_x, delta_y }) + matches!(result, Some(MouseEvent::Scroll { delta_x, delta_y, .. }) if delta_x.abs() < f32::EPSILON && (delta_y - 3.0).abs() < f32::EPSILON), "expected Scroll {{ delta_x: 0.0, delta_y: 3.0 }}, got {result:?}" ); @@ -586,7 +590,7 @@ mod tests { let event = InputEvent::new(EventType::RELATIVE.0, RelativeAxisCode::REL_HWHEEL.0, -2); let result = translate(&event, false); assert!( - matches!(result, Some(MouseEvent::Scroll { delta_x, delta_y }) + matches!(result, Some(MouseEvent::Scroll { delta_x, delta_y, .. }) if (delta_x - -2.0).abs() < f32::EPSILON && delta_y.abs() < f32::EPSILON), "expected Scroll {{ delta_x: -2.0, delta_y: 0.0 }}, got {result:?}" ); @@ -604,7 +608,7 @@ mod tests { ); let result = translate(&event, true); assert!( - matches!(result, Some(MouseEvent::Scroll { delta_x, delta_y }) + matches!(result, Some(MouseEvent::Scroll { delta_x, delta_y, .. }) if delta_x.abs() < f32::EPSILON && (delta_y - 0.5).abs() < f32::EPSILON), "expected Scroll {{ delta_x: 0.0, delta_y: 0.5 }}, got {result:?}" ); @@ -619,7 +623,7 @@ mod tests { ); let result = translate(&event, true); assert!( - matches!(result, Some(MouseEvent::Scroll { delta_x, delta_y }) + matches!(result, Some(MouseEvent::Scroll { delta_x, delta_y, .. }) if (delta_x - -1.0).abs() < f32::EPSILON && delta_y.abs() < f32::EPSILON), "expected Scroll {{ delta_x: -1.0, delta_y: 0.0 }}, got {result:?}" ); diff --git a/crates/openlogi-hook/src/macos.rs b/crates/openlogi-hook/src/macos.rs index 59385452..908dca97 100644 --- a/crates/openlogi-hook/src/macos.rs +++ b/crates/openlogi-hook/src/macos.rs @@ -12,7 +12,7 @@ use core_graphics::event::{ }; use tracing::{debug, error, warn}; -use crate::{ButtonId, EventDisposition, HookError, MouseEvent}; +use crate::{ButtonId, EventDisposition, HookError, MouseEvent, ScrollTransform}; /// Everything `Hook` needs to control the background thread. pub(crate) struct HookInner { @@ -100,6 +100,92 @@ fn button_number_to_id(n: i64) -> Option { } } +fn transform_scroll_event(event: &CGEvent, transform: ScrollTransform) { + let factor = if transform.inverted { -1.0 } else { 1.0 } * f64::from(transform.strength.max(1)); + let tactility = i64::from(transform.tactility.min(10)); + + transform_double_scroll_field( + event, + EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_1, + factor, + tactility, + ); + transform_double_scroll_field( + event, + EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_2, + factor, + tactility, + ); + transform_integer_scroll_field( + event, + EventField::SCROLL_WHEEL_EVENT_POINT_DELTA_AXIS_1, + factor, + 0, + ); + transform_integer_scroll_field( + event, + EventField::SCROLL_WHEEL_EVENT_POINT_DELTA_AXIS_2, + factor, + 0, + ); + transform_integer_scroll_field( + event, + EventField::SCROLL_WHEEL_EVENT_FIXED_POINT_DELTA_AXIS_1, + factor, + 0, + ); + transform_integer_scroll_field( + event, + EventField::SCROLL_WHEEL_EVENT_FIXED_POINT_DELTA_AXIS_2, + factor, + 0, + ); +} + +fn transform_double_scroll_field( + event: &CGEvent, + field: core_graphics::event::CGEventField, + factor: f64, + tactility: i64, +) { + let value = event.get_double_value_field(field) * factor; + let value = if tactility <= 1 { + value + } else { + quantize_scroll(value, tactility) as f64 + }; + event.set_double_value_field(field, value); +} + +fn transform_integer_scroll_field( + event: &CGEvent, + field: core_graphics::event::CGEventField, + factor: f64, + tactility: i64, +) { + let value = (event.get_integer_value_field(field) as f64 * factor).round() as i64; + let value = if tactility <= 1 { + value + } else { + quantize_scroll(value as f64, tactility) + }; + event.set_integer_value_field(field, value); +} + +fn quantize_scroll(value: f64, tactility: i64) -> i64 { + let v = value.round() as i64; + if tactility <= 1 { + return v; + } + + let abs = v.abs(); + let snapped = ((abs + tactility / 2) / tactility) * tactility; + if snapped == 0 && v != 0 { + return v.signum() * tactility; + } + if v < 0 { -snapped } else { snapped } +} + /// Convert a `CGEvent` to our [`MouseEvent`] vocabulary. Returns `None` /// for event types we don't translate (e.g. move events, unknown buttons). fn translate(etype: CGEventType, event: &CGEvent) -> Option { @@ -153,6 +239,8 @@ fn translate(etype: CGEventType, event: &CGEvent) -> Option { // axis 1 = vertical scroll; axis 2 = horizontal scroll. let dy = event.get_double_value_field(EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_1); let dx = event.get_double_value_field(EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_2); + let is_continuous = + event.get_integer_value_field(EventField::SCROLL_WHEEL_EVENT_IS_CONTINUOUS) != 0; #[allow( clippy::cast_possible_truncation, reason = "scroll deltas are small fractional values that fit comfortably in f32" @@ -160,6 +248,7 @@ fn translate(etype: CGEventType, event: &CGEvent) -> Option { Some(MouseEvent::Scroll { delta_x: dx as f32, delta_y: dy as f32, + is_continuous, }) } // Pointer movement feeds gesture-button swipe detection. While a button @@ -265,6 +354,10 @@ fn thread_main( match cb(mouse_event) { EventDisposition::PassThrough => CallbackResult::Keep, EventDisposition::Suppress => CallbackResult::Drop, + EventDisposition::TransformScroll(transform) => { + transform_scroll_event(event, transform); + CallbackResult::Keep + } } }, ); diff --git a/crates/openlogi-hook/src/tests.rs b/crates/openlogi-hook/src/tests.rs index f21947a0..d980348d 100644 --- a/crates/openlogi-hook/src/tests.rs +++ b/crates/openlogi-hook/src/tests.rs @@ -30,6 +30,7 @@ fn mouse_event_clone_and_debug() { MouseEvent::Scroll { delta_x: 1.0, delta_y: -1.5, + is_continuous: false, }, MouseEvent::Moved { delta_x: 3, diff --git a/crates/openlogi-hook/src/windows.rs b/crates/openlogi-hook/src/windows.rs index 1ff71868..86225ecf 100644 --- a/crates/openlogi-hook/src/windows.rs +++ b/crates/openlogi-hook/src/windows.rs @@ -213,10 +213,12 @@ fn translate_event(wparam: WPARAM, data: MSLLHOOKSTRUCT) -> Option { WM_MOUSEWHEEL => Some(MouseEvent::Scroll { delta_x: 0.0, delta_y: f32::from(signed_high_word(data.mouseData)) / WHEEL_DELTA, + is_continuous: false, }), WM_MOUSEHWHEEL => Some(MouseEvent::Scroll { delta_x: f32::from(signed_high_word(data.mouseData)) / WHEEL_DELTA, delta_y: 0.0, + is_continuous: false, }), _ => None, } @@ -294,8 +296,9 @@ mod tests { mouseData: 120u32 << 16, ..MSLLHOOKSTRUCT::default() }; - let Some(MouseEvent::Scroll { delta_x, delta_y }) = - translate_event(WM_MOUSEWHEEL as WPARAM, forward) + let Some(MouseEvent::Scroll { + delta_x, delta_y, .. + }) = translate_event(WM_MOUSEWHEEL as WPARAM, forward) else { panic!("expected a scroll event"); };