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
33 changes: 28 additions & 5 deletions crates/openlogi-agent-core/src/hook_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
//! and gesture events.

use std::cell::RefCell;
use std::collections::{BTreeMap, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::sync::mpsc;
use std::sync::{Arc, Mutex, PoisonError, RwLock};
use std::thread;
Expand Down Expand Up @@ -89,10 +89,16 @@ pub struct HookMaps {
/// Per-button single action — the single-action dispatch path.
pub bindings: BTreeMap<ButtonId, Action>,
/// Per-direction maps for the OS-hook gesture buttons (Middle/Back/Forward in
/// gesture mode), so a hold+swipe resolves to a bound action. The dedicated
/// HID++ gesture button (0x00c3) uses the gesture watcher's separate map
/// instead — it never reaches the OS hook.
/// gesture mode), so a hold+swipe resolves to a bound action. The HID++
/// gesture sources use the gesture watcher's separate map instead — a
/// diverted control never reaches the OS hook.
pub gestures: BTreeMap<ButtonId, BTreeMap<GestureDirection, Action>>,
/// `(vendor_id, product_id)` of each device whose wheel is inverted in
/// software, for firmware reporting no native HID++ inversion (`0x2121`).
/// Keyed by OS-level identity because that is all the hook sees — a scroll
/// event carries an [`openlogi_hook::EventDevice`], not a config key — and a
/// set rather than one bool is what keeps the inversion per-device.
pub invert_scroll: BTreeSet<(u32, u32)>,
}

/// Shared, atomically-published [`HookMaps`], threaded between the config owner
Expand Down Expand Up @@ -406,8 +412,23 @@ pub fn start(
EventDisposition::PassThrough
}
MouseEvent::Scroll {
delta_x, delta_y, ..
delta_x,
delta_y,
from_trackpad,
device,
} => {
// Trackpad scroll is never rewritten: macOS already applies
// its own natural-scrolling preference there, so inverting
// on top would fight the OS.
if !from_trackpad
&& let Some(ids) =
device.as_ref().and_then(|d| d.vendor_id.zip(d.product_id))
&& hooks
.try_read()
.is_ok_and(|maps| maps.invert_scroll.contains(&ids))
{
return EventDisposition::InvertScroll;
}
#[cfg(not(target_os = "windows"))]
let _ = (delta_x, delta_y);
#[cfg(target_os = "windows")]
Expand Down Expand Up @@ -796,6 +817,7 @@ mod tests {
(ButtonId::ThumbwheelScrollDown, Action::PrevTab),
]),
gestures: BTreeMap::new(),
..Default::default()
};
assert_eq!(
rebound_thumbwheel_action(&maps, 1.0),
Expand All @@ -822,6 +844,7 @@ mod tests {
),
]),
gestures: BTreeMap::new(),
..Default::default()
};
assert_eq!(rebound_thumbwheel_action(&maps, 1.0), None);
assert_eq!(rebound_thumbwheel_action(&maps, -1.0), None);
Expand Down
76 changes: 71 additions & 5 deletions crates/openlogi-agent-core/src/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
//! [`DpiCycleState::capabilities`] stays `None` and presets cycle at their raw
//! (still valid) values — exactly the GUI's "window never opened" behaviour.

use std::collections::{BTreeMap, HashMap, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, RwLock};

Expand Down Expand Up @@ -241,18 +241,84 @@ impl Orchestrator {
/// so they're built together here and published under one lock — keeping
/// `rebuild` and `set_current_app` from drifting into a half-populated write.
fn hook_maps_for(&self, key: Option<&str>, app: Option<&str>) -> HookMaps {
// A disabled selected device gets empty maps: the OS hook then passes
// its events through untouched instead of applying remaps to a device
// the user asked OpenLogi to leave alone.
// A disabled selected device gets empty button maps so the hook stops
// remapping it. Inversion is exempt: it is keyed per device, so muting
// this one must not stop inverting another's wheel (and the builder
// already drops a device that is itself disabled).
if key.is_some_and(|k| !self.config.device_enabled(k)) {
return HookMaps::default();
return HookMaps {
invert_scroll: self.software_scroll_inversion(),
..Default::default()
};
}
HookMaps {
bindings: bindings_for(&self.config, key, app),
gestures: oshook_gestures_for(&self.config, key, app),
invert_scroll: self.software_scroll_inversion(),
}
}

/// `(vendor_id, product_id)` of every device with scroll inversion on but no
/// native HID++ inversion to carry it — the set the OS hook rewrites deltas
/// for. Not app-scoped, unlike the button maps: inversion belongs to the
/// device, not the foreground app.
///
/// Only [`DeviceRoute::Direct`] devices qualify — a receiver-paired device
/// reports the receiver's ids, which would match every device on that
/// dongle.
///
/// The hook rewrites *by identity* and two identical directly-attached mice
/// share one, so every device behind an identity has to agree. Anything
/// short of "enabled, inversion on, no native support" counts as a refusal
/// and drops the identity: a disabled device must stay untouched, and a
/// natively-capable one already has the setting in firmware
/// (`scroll_settings_for`), where rewriting on top would invert twice.
/// Dropping is the safer failure — inverting a wheel nobody asked for is
/// untraceable from the GUI.
fn software_scroll_inversion(&self) -> BTreeSet<(u32, u32)> {
// Per identity: whether a device sharing it wants the rewrite, and
// whether another refuses.
let mut by_identity: BTreeMap<(u32, u32), (bool, bool)> = BTreeMap::new();
for dev in &self.devices {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Offline twin blocks inversion

When two identical directly attached mice remain in inventory while one is offline, this loop still records the offline device as a refusal for their shared VID/PID. The identity is consequently removed from invert_scroll, leaving the online mouse's wheel uninverted despite its enabled software-inversion setting.

Knowledge Base Used: Background agent service

Fix in Codex Fix in Claude Code

let Some(DeviceRoute::Direct {
vendor_id,
product_id,
}) = dev.route
else {
continue;
};
// Every reason to skip a device is a refusal *on its identity*, not
// an omission: skipping it silently would let a twin's setting drag
// it along.
let wants = self.config.device_enabled(&dev.config_key)
&& self.config.invert_scroll(&dev.config_key)
&& !dev.capabilities.is_some_and(|caps| caps.scroll_inversion);
let entry = by_identity
.entry((u32::from(vendor_id), u32::from(product_id)))
.or_default();
if wants {
entry.0 = true;
} else {
entry.1 = true;
}
}
by_identity
.into_iter()
.filter_map(|(identity, (wanted, refused))| {
if wanted && refused {
warn!(
vendor_id = identity.0,
product_id = identity.1,
"devices sharing this identity disagree on scroll inversion; \
the hook cannot tell them apart, so neither wheel is inverted"
);
return None;
}
wanted.then_some(identity)
})
.collect()
}

/// The keyboard key-capture spec for the first known keyboard, or `None`
/// when no keyboard is paired or none of its capturable keys carries a
/// real binding (an unbound key must never be diverted).
Expand Down
136 changes: 136 additions & 0 deletions crates/openlogi-agent-core/src/orchestrator/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -775,3 +775,139 @@ fn app_switch_republishes_capture_plans() {
orch.set_current_app(Some("com.example.editor".into()));
assert_eq!(published_back_binding(&orch), Some(Action::Undo));
}

/// A directly-attached mouse with inversion configured, either reporting native
/// HID++ inversion or not.
fn direct_dev(key: &str, product_id: u16, native_inversion: bool) -> AgentDevice {
AgentDevice {
route: Some(DeviceRoute::Direct {
vendor_id: 0x046d,
product_id,
}),
capabilities: Some(Capabilities {
scroll_inversion: native_inversion,
..Capabilities::default()
}),
..dev(key, DIRECT_DEVICE_INDEX, true)
}
}

#[test]
fn software_scroll_inversion_skips_native_and_receiver_paired_devices() {
use std::collections::BTreeSet;

let mut config = Config::default();
for key in ["native", "software", "receiver"] {
config.set_invert_scroll(key, true);
}
let mut orch = orchestrator(config);
orch.devices = vec![
// Native inversion goes to the firmware; rewriting on top would cancel.
direct_dev("native", 0xb034, true),
direct_dev("software", 0xb020, false),
// A receiver route reports ids shared by every device on that dongle.
dev("receiver", 1, true),
];

assert_eq!(
orch.software_scroll_inversion(),
BTreeSet::from([(0x046d_u32, 0xb020_u32)])
);
}

#[test]
fn identical_devices_disagreeing_on_inversion_invert_neither() {
use std::collections::BTreeSet;

// The hook sees only vendor/product, so it cannot honour one setting without
// also applying it to the twin. Inverting a wheel nobody asked for is worse.
let mut config = Config::default();
config.set_invert_scroll("left-hand", true);
let mut orch = orchestrator(config);
orch.devices = vec![
direct_dev("left-hand", 0xb020, false),
direct_dev("right-hand", 0xb020, false),
];

assert_eq!(orch.software_scroll_inversion(), BTreeSet::new());
}

#[test]
fn a_disabled_twin_blocks_its_siblings_software_inversion() {
use std::collections::BTreeSet;

// The hook cannot rewrite one twin without the other, so a device the user
// switched off must not be dragged along by its sibling's setting.
let mut config = Config::default();
config.set_invert_scroll("enabled", true);
config.set_device_enabled("switched-off", false);
let mut orch = orchestrator(config);
orch.devices = vec![
direct_dev("enabled", 0xb020, false),
direct_dev("switched-off", 0xb020, false),
];

assert_eq!(orch.software_scroll_inversion(), BTreeSet::new());
}

#[test]
fn a_natively_capable_twin_blocks_software_inversion() {
use std::collections::BTreeSet;

// Its setting is already in firmware, and the hook would rewrite it too —
// inverting that wheel twice.
let mut config = Config::default();
config.set_invert_scroll("software", true);
config.set_invert_scroll("native", true);
let mut orch = orchestrator(config);
orch.devices = vec![
direct_dev("software", 0xb020, false),
direct_dev("native", 0xb020, true),
];

assert_eq!(orch.software_scroll_inversion(), BTreeSet::new());
}

#[test]
fn identical_devices_agreeing_on_inversion_yield_one_entry() {
use std::collections::BTreeSet;

let mut config = Config::default();
config.set_invert_scroll("left-hand", true);
config.set_invert_scroll("right-hand", true);
let mut orch = orchestrator(config);
orch.devices = vec![
direct_dev("left-hand", 0xb020, false),
direct_dev("right-hand", 0xb020, false),
];

assert_eq!(
orch.software_scroll_inversion(),
BTreeSet::from([(0x046d_u32, 0xb020_u32)])
);
}

#[test]
fn disabling_the_selected_device_keeps_another_devices_software_inversion() {
use std::collections::BTreeSet;

// Inversion is keyed per device, so muting one cannot stop inverting another.
let mut config = Config::default();
config.set_invert_scroll("other", true);
config.set_device_enabled("selected", false);
let mut orch = orchestrator(config);
orch.devices = vec![
direct_dev("selected", 0xb034, false),
direct_dev("other", 0xb020, false),
];

let maps = orch.hook_maps_for(Some("selected"), None);
assert!(
maps.bindings.is_empty(),
"a disabled selection remaps nothing"
);
assert_eq!(
maps.invert_scroll,
BTreeSet::from([(0x046d_u32, 0xb020_u32)])
);
}
20 changes: 14 additions & 6 deletions crates/openlogi-core/src/binding/button.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,22 @@ impl ButtonId {
)
}

/// Whether this button is a HID++ gesture source — a control that is
/// captured over HID++ raw-XY diversion (never the OS hook) and can
/// therefore own the gesture role with swipe directions: the dedicated
/// gesture button, or the MX Master 4 haptic panel. The capture layer maps
/// each to its control ID.
/// Whether this button is a HID++ gesture source — a control captured over
/// raw-XY diversion (never the OS hook) and so able to own the gesture role
/// with swipe directions: the dedicated gesture button, the MX Master 4
/// haptic panel, or the DPI/ModeShift button on a device that ships neither.
/// The capture layer maps each to its control ID.
///
/// [`ButtonId::DpiToggle`] qualifies because on such a device it is the only
/// raw-XY-capable control (MX Vertical's `0x00fd`). It only *acts* as one
/// once a gesture map is bound to it: its canonical default is a single
/// DPI-cycle action, which drops out of the gesture-map lookup.
#[must_use]
pub fn is_hidpp_gesture_source(self) -> bool {
matches!(self, ButtonId::GestureButton | ButtonId::HapticPanel)
matches!(
self,
ButtonId::GestureButton | ButtonId::HapticPanel | ButtonId::DpiToggle
)
}

/// Human-readable label for popovers and tooltips.
Expand Down
11 changes: 11 additions & 0 deletions crates/openlogi-core/src/binding/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -544,3 +544,14 @@ fn scroll_actions_lower_to_unit_direction() {
Effect::Scroll { dx: 1, dy: 0 }
);
}

#[test]
fn dpi_toggle_is_a_gesture_source_yet_defaults_to_a_single_action() {
// The `Single` default is what keeps DPI cycling the behavior every existing
// device still gets: it drops out of the gesture-map lookup.
assert!(ButtonId::DpiToggle.is_hidpp_gesture_source());
assert!(matches!(
default_binding_for(ButtonId::DpiToggle),
Binding::Single(_)
));
}
Loading