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
140 changes: 139 additions & 1 deletion crates/openlogi-agent-core/src/hook_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ use std::sync::{Arc, RwLock};
use openlogi_core::binding::{
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};

Expand All @@ -38,6 +41,38 @@ pub struct HookMaps {
/// (orchestrator), the OS-hook callback, and the gesture watcher.
pub type SharedHookMaps = Arc<RwLock<HookMaps>>;

/// 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<RwLock<ScrollSettings>>;

/// 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
Expand Down Expand Up @@ -101,6 +136,7 @@ pub fn start(
hooks: SharedHookMaps,
dpi_cycle: Arc<RwLock<DpiCycleState>>,
capture: CaptureChannel,
scroll_settings: SharedScrollSettings,
) -> Option<Hook> {
if !Hook::has_accessibility() {
warn!(
Expand Down Expand Up @@ -210,7 +246,39 @@ pub fn start(
HOLD.with_borrow_mut(HoldState::cancel);
EventDisposition::PassThrough
}
MouseEvent::Scroll { .. } => EventDisposition::PassThrough,
MouseEvent::Scroll {
delta_x,
delta_y,
is_continuous,
} => {
let settings = scroll_settings
.read()
.map(|guard| *guard)
.unwrap_or_default();
if settings == ScrollSettings::default() || is_continuous {
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
}
}
});

match result {
Expand Down Expand Up @@ -241,6 +309,37 @@ fn resolve_gesture_click(
.unwrap_or_else(|| default_binding(id))
}

/// 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));

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 }
}

/// 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
Expand Down Expand Up @@ -311,6 +410,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.
Expand Down
11 changes: 10 additions & 1 deletion crates/openlogi-agent-core/src/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -54,6 +54,7 @@ pub struct SharedRuntime {
pub gesture_bindings: GestureBindings,
pub dpi_cycle: Arc<RwLock<DpiCycleState>>,
pub thumbwheel_sensitivity: Arc<AtomicI32>,
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
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-agent/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading