Skip to content
Closed
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
6 changes: 6 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

151 changes: 150 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 @@ -97,10 +132,15 @@ thread_local! {

/// Attempt to start the OS hook. Returns `None` if Accessibility is not
/// granted or on an unsupported platform — the app continues without crashing.
#[allow(
clippy::too_many_lines,
reason = "the hook callback keeps the cross-platform event policy in one place so the shared-state locking stays coherent"
)]
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 +250,40 @@ pub fn start(
HOLD.with_borrow_mut(HoldState::cancel);
EventDisposition::PassThrough
}
MouseEvent::Scroll { .. } => EventDisposition::PassThrough,
MouseEvent::Scroll {
delta_x,
delta_y,
from_trackpad,
device: _,
} => {
let settings = scroll_settings
.read()
.map(|guard| *guard)
.unwrap_or_default();
if settings == ScrollSettings::default() || from_trackpad {
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 +314,43 @@ 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.
#[cfg(any(test, not(target_os = "macos")))]
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))
}

#[cfg(any(test, not(target_os = "macos")))]
fn quantize_scroll(value: f32, tactility: i32) -> i32 {
#[allow(
clippy::cast_possible_truncation,
reason = "quantization intentionally rounds the event-local floating delta to an integer wheel step"
)]
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 +421,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::receiver_access::ReceiverAccess;
use crate::watchers::gesture::GestureBindings;
Expand Down Expand Up @@ -57,6 +57,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,
/// Exclusive receiver access shared by HID++ capture and pairing. Capture
/// and pairing must never open the same receiver HID node concurrently.
Expand Down Expand Up @@ -107,6 +108,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)),
receiver_access: ReceiverAccess::default(),
};
Expand Down Expand Up @@ -179,6 +183,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 @@ -200,6 +200,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
3 changes: 2 additions & 1 deletion crates/openlogi-agent/src/pairing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ mod tests {
use std::sync::RwLock;

use openlogi_agent_core::DpiCycleState;
use openlogi_agent_core::hook_runtime::HookMaps;
use openlogi_agent_core::hook_runtime::{HookMaps, ScrollSettings};
use openlogi_agent_core::receiver_access::ReceiverAccess;

fn shared_runtime() -> SharedRuntime {
Expand All @@ -268,6 +268,7 @@ mod tests {
gesture_bindings: Arc::new(RwLock::new(BTreeMap::new())),
dpi_cycle: Arc::new(RwLock::new(DpiCycleState::default())),
thumbwheel_sensitivity: Arc::new(0.into()),
scroll_settings: Arc::new(RwLock::new(ScrollSettings::default())),
capture_channel: Arc::new(RwLock::new(None)),
receiver_access: ReceiverAccess::default(),
}
Expand Down
8 changes: 8 additions & 0 deletions crates/openlogi-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ tracing = { workspace = true }
fs4 = { version = "1.1.0", features = ["sync"] }
etcetera = "0.11.0"

[target.'cfg(target_os = "macos")'.dependencies]
core-graphics = { version = "0.25.0", default-features = false, features = ["link", "highsierra"] }
core-foundation = { workspace = true }
objc2 = "0.6.4"
objc2-app-kit = { version = "0.3.2", features = ["NSEvent"] }
objc2-core-graphics = { version = "0.3.2", features = ["CGEvent"] }
objc2-foundation = "0.3.2"

[dev-dependencies]
tempfile = "3"
tracing-subscriber = { workspace = true }
Expand Down
Loading