From f1f20da8fd0cf5d0775121792e2115f1a284467e Mon Sep 17 00:00:00 2001 From: Hiroaki Tagawa Date: Sat, 18 Jul 2026 19:46:44 +0900 Subject: [PATCH 01/19] fix: divert thumbwheel for rotation rebinds even without single-tap capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit arm_controls refused to divert the thumb wheel unless getThumbwheelInfo reported the single-tap capability. Rotation rebinds and the sensitivity multiplier need the diverted event stream too, and devices like the MX Master 4 report no single-tap bit — leaving ThumbwheelScrollUp/Down bindings silently inert. Divert whenever capture is requested; a missing single tap now only means a bound click can never fire. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01N76prZoUHMrHZajXq4jET1 --- crates/openlogi-hid/src/gesture.rs | 19 +++++++++++-------- crates/openlogi-hid/src/thumbwheel.rs | 3 ++- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/crates/openlogi-hid/src/gesture.rs b/crates/openlogi-hid/src/gesture.rs index 1783494b..26b84c21 100644 --- a/crates/openlogi-hid/src/gesture.rs +++ b/crates/openlogi-hid/src/gesture.rs @@ -206,8 +206,8 @@ impl ArmedControls { /// Resolve features off the device's root and divert the controls we capture: /// the gesture button (raw-XY) and DPI/ModeShift buttons over `0x1b04`, and — -/// when `capture_thumbwheel` and the wheel reports a single tap — the thumb -/// wheel over `0x2150`. The root-feature lookup mirrors `write::open_feature`, +/// when `capture_thumbwheel` — the thumb wheel over `0x2150`. The +/// root-feature lookup mirrors `write::open_feature`, /// since hidpp 0.2's registry doesn't carry the features OpenLogi reimplements. async fn arm_controls( chan: &Arc, @@ -273,14 +273,17 @@ async fn arm_controls( false } }; - if supports_single_tap { - tw.set_reporting(true, false) - .await - .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?; - thumb = Some((tw, info.index)); - } else { + // Divert whenever capture was requested: rotation rebinds and the + // sensitivity multiplier need the diverted event stream even on wheels + // that report no single-tap capability (e.g. MX Master 4) — lacking the + // tap only means a bound click can never fire. + if !supports_single_tap { debug!("thumb wheel reports no single tap — click not capturable"); } + tw.set_reporting(true, false) + .await + .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?; + thumb = Some((tw, info.index)); } if !gesture_diverted && dpi_cids.is_empty() && thumb.is_none() { diff --git a/crates/openlogi-hid/src/thumbwheel.rs b/crates/openlogi-hid/src/thumbwheel.rs index e0be78f3..d26485ea 100644 --- a/crates/openlogi-hid/src/thumbwheel.rs +++ b/crates/openlogi-hid/src/thumbwheel.rs @@ -4,7 +4,8 @@ //! //! The wheel only has two reporting modes — Native (HID scroll) or Diverted //! (HID++ events) — there is no "report taps but keep scrolling" mode. So the -//! capture session diverts the wheel only when the user has bound its click, +//! capture session diverts the wheel whenever the user's thumbwheel config +//! leaves its defaults (click bound, rotation rebound, or sensitivity changed), //! and re-synthesises horizontal scroll from the rotation deltas to keep //! scrolling working. //! From e0141f98d95ec1a011b0562fe18609d08f039699 Mon Sep 17 00:00:00 2001 From: Hiroaki Tagawa Date: Sat, 18 Jul 2026 20:29:32 +0900 Subject: [PATCH 02/19] docs: update remaining doc comments to the broadened divert trigger Review follow-up: the module-level doc and the CapturedInput::Scroll doc still described diversion as click-bound-only. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01N76prZoUHMrHZajXq4jET1 --- crates/openlogi-hid/src/gesture.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/openlogi-hid/src/gesture.rs b/crates/openlogi-hid/src/gesture.rs index 26b84c21..1f0da4e1 100644 --- a/crates/openlogi-hid/src/gesture.rs +++ b/crates/openlogi-hid/src/gesture.rs @@ -13,7 +13,8 @@ //! it, mirroring how the CGEventTap hook handles the side buttons. The thumb //! wheel is special: diverting it stops native horizontal scroll, so the GUI //! re-synthesises scroll from the [`CapturedInput::Scroll`] deltas — the wheel -//! is therefore only diverted when its click is actually bound. +//! is therefore only diverted when the user's thumbwheel config leaves its +//! defaults (click bound, rotation rebound, or sensitivity changed). use std::sync::{Arc, Mutex, PoisonError, RwLock}; @@ -44,8 +45,8 @@ pub enum CapturedInput { /// ([`ButtonId::Thumbwheel`]). ButtonPressed(ButtonId), /// Thumb-wheel rotation to re-synthesise as horizontal scroll, in the - /// wheel's `diverted_res` increments. Emitted only while the wheel is - /// diverted to capture its click. + /// wheel's `diverted_res` increments. Emitted while the wheel is diverted + /// (click bound, rotation rebound, or sensitivity changed). Scroll(i16), } From b93fd2c6edaf6a07d7dbe0c08324dc1952efa993 Mon Sep 17 00:00:00 2001 From: Hiroaki Tagawa Date: Sat, 18 Jul 2026 21:50:30 +0900 Subject: [PATCH 03/19] fix: only grab Logitech mice in the Linux evdev hook find_mouse_devices grabbed every BTN_LEFT evdev device exclusively and routed it through the virtual mouse, applying the managed device's remaps to unrelated vendors' mice (and virtual pointers like keyd's). Filter by Logitech's vendor id, mirroring the macOS hook's vendor awareness. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01N76prZoUHMrHZajXq4jET1 --- crates/openlogi-hook/src/linux.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/openlogi-hook/src/linux.rs b/crates/openlogi-hook/src/linux.rs index 3e42e79a..e876ddf5 100644 --- a/crates/openlogi-hook/src/linux.rs +++ b/crates/openlogi-hook/src/linux.rs @@ -133,9 +133,16 @@ fn create_pipe() -> io::Result<(OwnedFd, OwnedFd)> { Ok(unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) }) } +/// USB/Bluetooth vendor id of Logitech devices. +const LOGITECH_VENDOR_ID: u16 = 0x046d; + fn find_mouse_devices() -> Vec<(std::path::PathBuf, Device)> { evdev::enumerate() .filter(|(_, d)| d.name().unwrap_or("") != VIRTUAL_DEVICE_NAME) + // Only Logitech mice: the hook exists to remap devices OpenLogi + // manages, and grabbing an unrelated vendor's mouse would route its + // events through the virtual device and apply foreign remaps to it. + .filter(|(_, d)| d.input_id().vendor() == LOGITECH_VENDOR_ID) .filter(|(_, d)| { d.supported_keys() .is_some_and(|keys| keys.contains(KeyCode::BTN_LEFT)) From 09d9a6aa0a88c5dada44ec48f0a40f8b058c197b Mon Sep 17 00:00:00 2001 From: Hiroaki Tagawa Date: Sat, 18 Jul 2026 21:50:45 +0900 Subject: [PATCH 04/19] feat: per-device HID++ capture with plan-driven sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single selected-device capture session with one session per online device, dispatching each input against the binding maps of the device it arrived on: - openlogi-hid: run_capture_session takes a CaptureSpec; rebindable standard buttons (middle/back/forward, 0x0052/0x0053/0x0056) divert over 0x1b04 when the spec asks, emit ButtonPressed on rising edges, and restore on teardown. A button at its default binding is never diverted, so native behavior needs no re-synthesis. - agent-core: new capture_plan module — the orchestrator rebuilds one DeviceCapturePlan (route, per-device bindings, gesture map, divert set) for every online device on config/app/inventory changes, including pure online-flag flips. The OS-hook gesture owner's button is excluded from diversion so hold+swipe detection keeps its events. - watcher: manages sessions in a HashMap keyed by config_key, tags inputs with their device, keeps per-device thumbwheel accumulators, and shares the capture-vs-pairing lease across sessions via an Arc (freed when the last session exits, as before). Verified on hardware: MX Master 4 and MX Master 3S (two Bolt receivers) hold concurrent sessions and their bindings apply independently of the GUI carousel selection. Known limitation: CycleDpiPresets still targets the selected device's DPI cycle state. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01N76prZoUHMrHZajXq4jET1 --- .../openlogi-agent-core/src/capture_plan.rs | 88 ++++ crates/openlogi-agent-core/src/lib.rs | 1 + .../openlogi-agent-core/src/orchestrator.rs | 34 ++ .../src/watchers/gesture.rs | 403 ++++++++++-------- crates/openlogi-agent/src/main.rs | 3 +- crates/openlogi-agent/src/pairing.rs | 1 + crates/openlogi-hid/src/gesture.rs | 104 +++-- crates/openlogi-hid/src/gesture/tests.rs | 20 +- 8 files changed, 438 insertions(+), 216 deletions(-) create mode 100644 crates/openlogi-agent-core/src/capture_plan.rs diff --git a/crates/openlogi-agent-core/src/capture_plan.rs b/crates/openlogi-agent-core/src/capture_plan.rs new file mode 100644 index 00000000..5852322a --- /dev/null +++ b/crates/openlogi-agent-core/src/capture_plan.rs @@ -0,0 +1,88 @@ +//! Per-device capture plans: what each online device's HID++ capture session +//! should divert, plus the device's own binding maps for dispatch. +//! +//! The orchestrator rebuilds the shared plan list from config + inventory for +//! *every* online device (not just the GUI's selection), and the capture +//! watcher diffs it into running sessions. Keeping the binding maps inside the +//! plan is what makes dispatch per-device: an input is resolved against the +//! plan of the session it arrived on, never against a global selected-device +//! map. + +use std::collections::BTreeMap; +use std::sync::{Arc, RwLock}; + +use openlogi_core::binding::{Action, ButtonId, GestureDirection, default_binding}; +use openlogi_core::config::Config; +use openlogi_hid::DeviceRoute; +use openlogi_hid::gesture::DIVERTABLE_STANDARD_BUTTONS; + +use crate::bindings::{bindings_for, gesture_bindings_for, oshook_gestures_for}; + +/// Everything the capture watcher needs to run one device's session and +/// dispatch its events. +#[derive(Debug, Clone, PartialEq)] +pub struct DeviceCapturePlan { + /// Stable per-device config key (binding / preset lookup). + pub config_key: String, + /// HID++ route the session opens. + pub route: DeviceRoute, + /// Per-button single actions for this device (per-app effective). + pub bindings: BTreeMap, + /// Per-direction map when the dedicated HID++ gesture button owns the + /// gesture role on this device; empty otherwise. + pub gesture_bindings: BTreeMap, + /// Standard buttons whose binding leaves the default — divert over + /// `0x1b04`. A button at its default keeps its native HID behavior, so no + /// re-synthesis is ever needed. + pub divert_buttons: Vec<(u16, ButtonId)>, + /// Whether any thumbwheel binding leaves its default. The watcher combines + /// this with the live sensitivity to decide thumb-wheel diversion. + pub thumbwheel_bindings_nondefault: bool, +} + +/// Shared plan list, rewritten by the orchestrator and read by the watcher. +pub type SharedCapturePlans = Arc>>; + +/// Build one device's plan from the config (per-app effective for `app`). +#[must_use] +pub fn plan_for_device( + config: &Config, + config_key: &str, + route: DeviceRoute, + app: Option<&str>, +) -> DeviceCapturePlan { + let bindings = bindings_for(config, Some(config_key), app); + let gesture_bindings = gesture_bindings_for(config, Some(config_key)); + // A button acting as the OS-hook gesture owner must stay native: the hook + // needs to see its press to run hold+swipe detection, and diverting it + // would starve the hook of events. + let oshook = oshook_gestures_for(config, Some(config_key), app); + let divert_buttons: Vec<(u16, ButtonId)> = DIVERTABLE_STANDARD_BUTTONS + .into_iter() + .filter(|(_, button)| !oshook.contains_key(button)) + .filter(|(_, button)| { + bindings + .get(button) + .is_some_and(|action| *action != default_binding(*button)) + }) + .collect(); + let thumbwheel_bindings_nondefault = [ + ButtonId::Thumbwheel, + ButtonId::ThumbwheelScrollUp, + ButtonId::ThumbwheelScrollDown, + ] + .iter() + .any(|button| { + bindings + .get(button) + .is_some_and(|action| *action != default_binding(*button)) + }); + DeviceCapturePlan { + config_key: config_key.to_owned(), + route, + bindings, + gesture_bindings, + divert_buttons, + thumbwheel_bindings_nondefault, + } +} diff --git a/crates/openlogi-agent-core/src/lib.rs b/crates/openlogi-agent-core/src/lib.rs index 435d848c..9d9a2245 100644 --- a/crates/openlogi-agent-core/src/lib.rs +++ b/crates/openlogi-agent-core/src/lib.rs @@ -7,6 +7,7 @@ //! without linking gpui. pub mod bindings; +pub mod capture_plan; pub mod device_order; mod dpi; pub mod event_monitor; diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index a3c26fdd..9ef4ac82 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -21,6 +21,7 @@ use tracing::warn; use crate::DpiCycleState; use crate::bindings::{bindings_for, gesture_bindings_for, oshook_gestures_for}; +use crate::capture_plan::{DeviceCapturePlan, SharedCapturePlans, plan_for_device}; use crate::device_order::DeviceStableId; use crate::hook_runtime::{HookMaps, SharedHookMaps}; use crate::ipc::InventoryHealth; @@ -56,6 +57,9 @@ pub struct SharedRuntime { pub hook_maps: SharedHookMaps, pub gesture_bindings: GestureBindings, pub dpi_cycle: Arc>, + /// One capture plan per online device — what to divert and how to + /// dispatch, keyed by the device the events arrive on. + pub capture_plans: SharedCapturePlans, pub thumbwheel_sensitivity: Arc, pub capture_channel: CaptureChannel, /// Exclusive receiver access shared by HID++ capture and pairing. Capture @@ -107,6 +111,7 @@ impl Orchestrator { hook_maps: Arc::new(RwLock::new(HookMaps::default())), gesture_bindings: Arc::new(RwLock::new(BTreeMap::new())), dpi_cycle: Arc::new(RwLock::new(DpiCycleState::default())), + capture_plans: Arc::new(RwLock::new(Vec::new())), thumbwheel_sensitivity: Arc::new(AtomicI32::new( config.app_settings.thumbwheel_sensitivity, )), @@ -183,6 +188,28 @@ impl Orchestrator { self.config.app_settings.thumbwheel_sensitivity, Ordering::Relaxed, ); + write_value( + &self.shared.capture_plans, + self.capture_plans_for(), + "capture_plans", + ); + } + + /// One capture plan per online device, from the current config + app. + fn capture_plans_for(&self) -> Vec { + self.devices + .iter() + .filter(|dev| dev.online) + .filter_map(|dev| { + let route = dev.route.clone()?; + Some(plan_for_device( + &self.config, + &dev.config_key, + route, + self.current_app.as_deref(), + )) + }) + .collect() } /// Apply a fresh inventory snapshot. Always refreshes the snapshot the IPC @@ -221,7 +248,14 @@ impl Orchestrator { if !changed { // Same set and routes — but keep the fresh `online` flags, or a // device that woke this tick would read as a transition forever. + // Capture plans key on `online`, so republish them even here or a + // woken device would never get its session armed. self.devices = devices; + write_value( + &self.shared.capture_plans, + self.capture_plans_for(), + "capture_plans", + ); return; } self.devices = devices; diff --git a/crates/openlogi-agent-core/src/watchers/gesture.rs b/crates/openlogi-agent-core/src/watchers/gesture.rs index 80659682..0268fe3b 100644 --- a/crates/openlogi-agent-core/src/watchers/gesture.rs +++ b/crates/openlogi-agent-core/src/watchers/gesture.rs @@ -1,9 +1,10 @@ -//! Background HID++ control-capture watcher for the active device. +//! Background HID++ control-capture watcher, one session per online device. //! -//! Runs [`openlogi_hid::run_capture_session`] on a dedicated thread for whichever -//! device the DPI / SmartShift path currently targets -//! ([`DpiCycleState::target`]), restarts it when the carousel selection — or the -//! thumb-wheel arming — changes, and dispatches each captured input: +//! Runs [`openlogi_hid::run_capture_session`] concurrently for every device in +//! the shared capture-plan list (not just the GUI's selection), restarts a +//! session when its device's plan — route, diverted controls, thumb-wheel +//! arming — changes, and dispatches each captured input against the binding +//! maps of the device it arrived on: //! //! - a gesture swipe through the gesture binding map, //! - a DPI/ModeShift or thumb-wheel-tap press through the button binding map, @@ -18,7 +19,7 @@ //! the events arrive over HID++, and the bound action is synthesised the same //! way regardless. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::sync::atomic::{AtomicI32, Ordering}; use std::sync::{Arc, RwLock}; use std::thread; @@ -26,13 +27,15 @@ use std::time::{Duration, Instant}; use openlogi_core::binding::{Action, ButtonId, GestureDirection, default_binding}; use openlogi_core::config::DEFAULT_THUMBWHEEL_SENSITIVITY; +use openlogi_hid::gesture::CaptureSpec; use openlogi_hid::{CaptureChannel, CapturedInput, DeviceRoute, run_capture_session}; use tokio::sync::{mpsc, oneshot}; use tracing::{debug, warn}; use crate::DpiCycleState; -use crate::hook_runtime::{self, SharedHookMaps}; -use crate::receiver_access::ReceiverAccess; +use crate::capture_plan::{DeviceCapturePlan, SharedCapturePlans}; +use crate::hook_runtime; +use crate::receiver_access::{CaptureReceiverLease, ReceiverAccess}; /// Shared gesture-direction binding map, mirrored from `AppState` (keyed by /// direction). The watcher reads it to map a captured swipe to a bound action. @@ -75,8 +78,7 @@ fn action_threshold(sensitivity: i32) -> i32 { /// keeps one capture session pointed at the active device and dispatches each /// captured input. pub fn spawn( - hook_maps: SharedHookMaps, - gesture_bindings: GestureBindings, + capture_plans: SharedCapturePlans, dpi_cycle: Arc>, capture_channel: CaptureChannel, thumbwheel_sensitivity: ThumbwheelSensitivity, @@ -94,8 +96,7 @@ pub fn spawn( } }; runtime.block_on(manage( - hook_maps, - gesture_bindings, + capture_plans, dpi_cycle, capture_channel, thumbwheel_sensitivity, @@ -104,176 +105,213 @@ pub fn spawn( }); } -/// Whether the thumb wheel must be diverted over HID++ (which suppresses native -/// scroll) so we can re-synthesise its scroll or capture its tap. -/// -/// We divert when the sensitivity leaves its default (so we can scale scroll -/// ourselves) or when the click or either rotation direction is rebound away -/// from its default; otherwise the OS scrolls the wheel natively. -fn thumbwheel_armed(hook_maps: &SharedHookMaps, sensitivity: i32) -> bool { - if sensitivity != DEFAULT_THUMBWHEEL_SENSITIVITY { - return true; +/// Whether one device's thumb wheel must be diverted over HID++ (which +/// suppresses native scroll) so we can re-synthesise its scroll or capture its +/// tap: the sensitivity leaves its default (so we scale scroll ourselves) or +/// the plan says a thumbwheel binding does. +fn thumbwheel_armed(plan: &DeviceCapturePlan, sensitivity: i32) -> bool { + sensitivity != DEFAULT_THUMBWHEEL_SENSITIVITY || plan.thumbwheel_bindings_nondefault +} + +/// The [`CaptureSpec`] one device's session should run with right now. +fn spec_for(plan: &DeviceCapturePlan, sensitivity: i32) -> CaptureSpec { + CaptureSpec { + capture_thumbwheel: thumbwheel_armed(plan, sensitivity), + divert_gesture_button: !plan.gesture_bindings.is_empty(), + divert_buttons: plan.divert_buttons.clone(), } - hook_maps.read().ok().is_some_and(|maps| { - [ - ButtonId::Thumbwheel, - ButtonId::ThumbwheelScrollUp, - ButtonId::ThumbwheelScrollDown, - ] - .iter() - .any(|&button| { - maps.bindings - .get(&button) - .is_some_and(|action| *action != default_binding(button)) - }) - }) } -/// Whether a finished capture session should make the manager re-arm. +/// One live capture session tracked by the manager. +struct RunningSession { + route: DeviceRoute, + spec: CaptureSpec, + stop: Option>, + epoch: u64, +} + +/// Whether a finished capture session should make the manager re-arm its +/// device. /// -/// `done_epoch` identifies the session that signalled completion; `live_epoch` -/// is the session the manager currently believes is running; `has_target` is -/// whether a device is currently targeted. A session ending only warrants a -/// respawn when it is the *current* one (not a stale session already superseded -/// by a deliberate restart, whose epoch no longer matches) and a target is still -/// set (not a deliberate stop-to-idle, e.g. while pairing owns the receiver). -fn should_rearm(done_epoch: u64, live_epoch: u64, has_target: bool) -> bool { - done_epoch == live_epoch && has_target +/// `done_epoch` identifies the session that signalled completion; `live` is the +/// session the manager currently tracks for that device. A completion only +/// warrants a respawn when it is the *current* session (not a stale one already +/// superseded by a deliberate restart, whose epoch no longer matches). +fn should_rearm(done_epoch: u64, live: Option<&RunningSession>) -> bool { + live.is_some_and(|session| session.epoch == done_epoch) } -/// Keep one capture session alive for the active device, restarting it when the -/// device or the thumb-wheel arming changes, and dispatch incoming inputs. Runs -/// for the lifetime of the process. +/// Keep one capture session alive per online device, restarting a session when +/// its device's plan changes, and dispatch incoming inputs against the plan of +/// the device they arrived on. Runs for the lifetime of the process. async fn manage( - hook_maps: SharedHookMaps, - gesture_bindings: GestureBindings, + capture_plans: SharedCapturePlans, dpi_cycle: Arc>, capture_channel: CaptureChannel, thumbwheel_sensitivity: ThumbwheelSensitivity, receiver_access: ReceiverAccess, ) { - let (tx, mut rx) = mpsc::unbounded_channel::(); - // (route, capture_thumbwheel, divert_gesture_button) - let mut current: Option<(DeviceRoute, bool, bool)> = None; - let mut stop: Option> = None; + let (tx, mut rx) = mpsc::unbounded_channel::<(String, CapturedInput)>(); + let mut sessions: HashMap = HashMap::new(); let mut ticker = tokio::time::interval(TARGET_POLL); - let mut accumulators = WheelAccumulators::default(); + let mut accumulators: HashMap = HashMap::new(); // Capture sessions run as detached tasks, so an unexpected exit (a transient // HID++ read error, a sleep-wake glitch, brief radio loss) would otherwise go - // unnoticed: the tick below only restarts on a *changed* target, so a session - // that dies with the target unchanged leaves the gesture button and thumb - // wheel dead until the next carousel switch, config reload, or agent restart. - // Each session reports its completion here, tagged with the epoch it started - // under, so a dead *current* session can be re-armed while stale completions - // (from an already-superseded session) are ignored. - let (done_tx, mut done_rx) = mpsc::unbounded_channel::(); + // unnoticed. Each session reports its completion here, tagged with its device + // key and the epoch it started under, so a dead *current* session re-arms on + // the next tick while stale completions are ignored. + let (done_tx, mut done_rx) = mpsc::unbounded_channel::<(String, u64)>(); let mut epoch: u64 = 0; + // The capture-vs-pairing arbiter hands out one exclusive lease. All session + // tasks share it through an `Arc`; the manager keeps only a `Weak` so the + // lease frees itself when the last session exits (letting pairing proceed). + let mut lease: std::sync::Weak = std::sync::Weak::new(); loop { tokio::select! { - Some(input) = rx.recv() => { + Some((key, input)) = rx.recv() => { dispatch( + &key, input, &mut accumulators, - &hook_maps, - &gesture_bindings, + &capture_plans, &dpi_cycle, &capture_channel, &thumbwheel_sensitivity, ); } _ = ticker.tick() => { - // While pairing is waiting or active, release the capture + // While pairing is waiting or active, release every capture // session so run_pairing can own the receiver's HID node (one // process can't read it through two channels). - let want = if receiver_access.pairing_requested() { - None - } else { - let target = dpi_cycle.read().ok().and_then(|guard| guard.target.clone()); - let sensitivity = thumbwheel_sensitivity.load(Ordering::Relaxed); - // Divert the dedicated HID++ gesture button only while it owns the gesture role. The - // shared gesture map is non-empty exactly then (gesture_bindings_for - // gates on the owner), so it doubles as that signal — no need to - // thread the full config in. Re-evaluated each tick, so a - // ReloadConfig owner change restarts the session accordingly. - let divert_gesture = gesture_bindings.read().is_ok_and(|g| !g.is_empty()); - target.map(|t| { - ( - t, - thumbwheel_armed(&hook_maps, sensitivity), - divert_gesture, - ) - }) - }; - if want == current { - continue; - } - // Target or thumb-wheel arming changed (or first tick): stop the - // old session and start one for the new state. Sending on the - // oneshot lets the old session restore the diverted controls. - if let Some(stop) = stop.take() { - let _ = stop.send(()); - } - if current.is_some() { - current = None; - continue; - } - if let Some((route, capture_thumbwheel, divert_gesture_button)) = want { - let Some(receiver_lease) = receiver_access.try_acquire_for_capture() else { - current = None; + let want: HashMap = + if receiver_access.pairing_requested() { + HashMap::new() + } else { + let sensitivity = thumbwheel_sensitivity.load(Ordering::Relaxed); + capture_plans + .read() + .map(|plans| { + plans + .iter() + .map(|plan| { + ( + plan.config_key.clone(), + (plan.route.clone(), spec_for(plan, sensitivity)), + ) + }) + .collect() + }) + .unwrap_or_default() + }; + // Stop sessions whose device disappeared or whose plan changed. + // Sending on the oneshot lets the session restore its controls. + sessions.retain(|key, session| { + let keep = want + .get(key) + .is_some_and(|(route, spec)| *route == session.route && *spec == session.spec); + if !keep && let Some(stop) = session.stop.take() { + let _ = stop.send(()); + } + keep + }); + accumulators.retain(|key, _| want.contains_key(key)); + for (key, (route, spec)) in want { + if sessions.contains_key(&key) { continue; + } + // All sessions share one exclusive lease; acquire it with the + // first session and ride the existing one afterwards. + let session_lease = if let Some(existing) = lease.upgrade() { + existing + } else { + let Some(fresh) = receiver_access.try_acquire_for_capture() else { + continue; + }; + let fresh = Arc::new(fresh); + lease = Arc::downgrade(&fresh); + fresh }; - current = Some((route.clone(), capture_thumbwheel, divert_gesture_button)); - let (stop_tx, stop_rx) = oneshot::channel(); - let sink = tx.clone(); - let slot = Arc::clone(&capture_channel); epoch = epoch.wrapping_add(1); - let session_epoch = epoch; - let done = done_tx.clone(); - tokio::spawn(async move { - let _receiver_lease = receiver_lease; - if let Err(e) = run_capture_session( - route, - capture_thumbwheel, - divert_gesture_button, - sink, - stop_rx, - slot, - ) - .await - { - debug!(error = %e, "capture session ended"); - } - // Report completion so the manager can re-arm if this exit - // was unexpected rather than a deliberate stop. - let _ = done.send(session_epoch); - }); - stop = Some(stop_tx); - } else { - current = None; + let session = spawn_session( + key.clone(), + route, + spec, + epoch, + session_lease, + &tx, + &done_tx, + &capture_channel, + ); + sessions.insert(key, session); } } - Some(done_epoch) = done_rx.recv() => { - // A capture session ended on its own. Re-arm only when it is the - // session we currently believe is live for an active target; - // clearing `current` lets the next tick start a fresh session. - // The tick fires at most once per `TARGET_POLL`, which paces the - // respawn so a permanently failing device can't hot-loop. A stale - // epoch or a deliberate stop-to-idle is a no-op (see `should_rearm`). - if should_rearm(done_epoch, epoch, current.is_some()) { - warn!("capture session for the active device ended unexpectedly, re-arming"); - current = None; - // Keep the `stop`/`current` invariant: the session already - // exited, so its stop receiver is gone and dropping the sender - // here is a no-op, but it stops the next tick from signalling a - // session that no longer exists. - stop = None; + Some((key, done_epoch)) = done_rx.recv() => { + // A capture session ended on its own. Dropping its entry lets the + // next tick start a fresh session for that device; the tick fires + // at most once per `TARGET_POLL`, which paces the respawn so a + // permanently failing device can't hot-loop. A stale epoch (an + // already-superseded session) is a no-op. + if should_rearm(done_epoch, sessions.get(&key)) { + warn!(key, "capture session ended unexpectedly, re-arming"); + sessions.remove(&key); } } } } } +/// Start one device's capture session plus its input-forwarding task, and +/// return the manager's tracking entry for it. +#[allow( + clippy::too_many_arguments, + reason = "plumbing between the manager loop's channels; grouping them into \ + a struct would only relabel the same eight values" +)] +fn spawn_session( + key: String, + route: DeviceRoute, + spec: CaptureSpec, + epoch: u64, + lease: Arc, + inputs: &mpsc::UnboundedSender<(String, CapturedInput)>, + done: &mpsc::UnboundedSender<(String, u64)>, + capture_channel: &CaptureChannel, +) -> RunningSession { + let (stop_tx, stop_rx) = oneshot::channel(); + // Tag this session's inputs with its device key so dispatch resolves them + // against the right plan. + let (session_tx, mut session_rx) = mpsc::unbounded_channel::(); + let forward = inputs.clone(); + let forward_key = key.clone(); + tokio::spawn(async move { + while let Some(input) = session_rx.recv().await { + let _ = forward.send((forward_key.clone(), input)); + } + }); + let done = done.clone(); + let session_route = route.clone(); + let session_spec = spec.clone(); + let slot = Arc::clone(capture_channel); + tokio::spawn(async move { + let _lease = lease; + if let Err(e) = + run_capture_session(session_route, session_spec, session_tx, stop_rx, slot).await + { + debug!(error = %e, "capture session ended"); + } + // Report completion so the manager can re-arm if this exit was + // unexpected rather than a deliberate stop. + let _ = done.send((key, epoch)); + }); + RunningSession { + route, + spec, + stop: Some(stop_tx), + epoch, + } +} + /// Per-direction wheel accumulators. The thumb wheel's two rotation directions /// bind to independent actions, so each keeps its own running total — sharing /// one would let a reversal cancel the other direction's progress. @@ -307,39 +345,39 @@ enum WheelOutput { FireAction, } -/// Route one captured input to its bound action (or re-synthesised scroll). +/// Route one captured input from device `key` to its bound action (or +/// re-synthesised scroll), using that device's own plan maps. fn dispatch( + key: &str, input: CapturedInput, - accumulators: &mut WheelAccumulators, - hook_maps: &SharedHookMaps, - gesture_bindings: &GestureBindings, + accumulators: &mut HashMap, + capture_plans: &SharedCapturePlans, dpi_cycle: &Arc>, capture: &CaptureChannel, thumbwheel_sensitivity: &ThumbwheelSensitivity, ) { + let Ok(plans) = capture_plans.read() else { + return; + }; + let Some(plan) = plans.iter().find(|plan| plan.config_key == key) else { + debug!(key, "input from a device with no capture plan — ignored"); + return; + }; match input { CapturedInput::Gesture(direction) => { - let action = gesture_bindings - .read() - .ok() - .and_then(|guard| guard.get(&direction).cloned()); - if let Some(action) = action { - debug!(?direction, action = %action.label(), "gesture → action"); - hook_runtime::dispatch_action(&action, dpi_cycle, capture); + if let Some(action) = plan.gesture_bindings.get(&direction) { + debug!(key, ?direction, action = %action.label(), "gesture → action"); + hook_runtime::dispatch_action(action, dpi_cycle, capture); } else { - debug!(?direction, "gesture with no binding — ignored"); + debug!(key, ?direction, "gesture with no binding — ignored"); } } CapturedInput::ButtonPressed(button) => { - let action = hook_maps - .read() - .ok() - .and_then(|maps| maps.bindings.get(&button).cloned()); - if let Some(action) = action { - debug!(?button, action = %action.label(), "HID++ button → action"); - hook_runtime::dispatch_action(&action, dpi_cycle, capture); + if let Some(action) = plan.bindings.get(&button) { + debug!(key, ?button, action = %action.label(), "HID++ button → action"); + hook_runtime::dispatch_action(action, dpi_cycle, capture); } else { - debug!(?button, "HID++ button with no binding — ignored"); + debug!(key, ?button, "HID++ button with no binding — ignored"); } } CapturedInput::Scroll(rotation) => { @@ -350,17 +388,14 @@ fn dispatch( } else { ButtonId::ThumbwheelScrollDown }; - let action = hook_maps - .read() - .ok() - .and_then(|maps| maps.bindings.get(&button).cloned()) + let action = plan + .bindings + .get(&button) + .cloned() .unwrap_or_else(|| default_binding(button)); let sensitivity = thumbwheel_sensitivity.load(Ordering::Relaxed); - let dir = if up { - &mut accumulators.up - } else { - &mut accumulators.down - }; + let wheels = accumulators.entry(key.to_owned()).or_default(); + let dir = if up { &mut wheels.up } else { &mut wheels.down }; let magnitude = i32::from(rotation).abs(); match advance(dir, &action, magnitude, sensitivity, Instant::now()) { WheelOutput::Idle => {} @@ -368,7 +403,7 @@ fn dispatch( openlogi_inject::post_horizontal_scroll(lines); } WheelOutput::FireAction => { - debug!(?button, action = %action.label(), "thumb wheel → action"); + debug!(key, ?button, action = %action.label(), "thumb wheel → action"); hook_runtime::dispatch_action(&action, dpi_cycle, capture); } } @@ -579,23 +614,35 @@ mod tests { ); } + fn session_with_epoch(epoch: u64) -> RunningSession { + RunningSession { + route: DeviceRoute::Direct { + vendor_id: 0x046d, + product_id: 0xc548, + }, + spec: CaptureSpec::default(), + stop: None, + epoch, + } + } + #[test] - fn rearms_when_the_current_session_dies_with_a_target() { - // The live session ended on its own while a device is still targeted. - assert!(should_rearm(7, 7, true)); + fn rearms_when_the_current_session_dies() { + // The live session for this device ended on its own. + assert!(should_rearm(7, Some(&session_with_epoch(7)))); } #[test] fn ignores_a_stale_session_superseded_by_a_restart() { // An older session reports completion after a deliberate restart already // bumped the epoch; re-arming would needlessly cycle the live session. - assert!(!should_rearm(6, 7, true)); + assert!(!should_rearm(6, Some(&session_with_epoch(7)))); } #[test] fn ignores_a_deliberate_stop_to_idle() { - // The session was stopped on purpose (pairing took the receiver, or no - // device is targeted): no target means there is nothing to re-arm. - assert!(!should_rearm(7, 7, false)); + // The session was stopped on purpose (pairing took the receiver, or the + // device went away): its entry is gone, so there is nothing to re-arm. + assert!(!should_rearm(7, None)); } } diff --git a/crates/openlogi-agent/src/main.rs b/crates/openlogi-agent/src/main.rs index b5e98729..93b43510 100644 --- a/crates/openlogi-agent/src/main.rs +++ b/crates/openlogi-agent/src/main.rs @@ -156,8 +156,7 @@ async fn run(config: Config) { // shared maps and dispatches bound actions itself; the two pairing flags let // it release its capture session while a pairing session owns the receiver. watchers::gesture::spawn( - shared.hook_maps.clone(), - shared.gesture_bindings.clone(), + shared.capture_plans.clone(), shared.dpi_cycle.clone(), shared.capture_channel.clone(), shared.thumbwheel_sensitivity.clone(), diff --git a/crates/openlogi-agent/src/pairing.rs b/crates/openlogi-agent/src/pairing.rs index ef365590..7c39e6c3 100644 --- a/crates/openlogi-agent/src/pairing.rs +++ b/crates/openlogi-agent/src/pairing.rs @@ -268,6 +268,7 @@ mod tests { hook_maps: Arc::new(RwLock::new(HookMaps::default())), gesture_bindings: Arc::new(RwLock::new(BTreeMap::new())), dpi_cycle: Arc::new(RwLock::new(DpiCycleState::default())), + capture_plans: Arc::new(RwLock::new(Vec::new())), thumbwheel_sensitivity: Arc::new(0.into()), capture_channel: Arc::new(RwLock::new(None)), receiver_access: ReceiverAccess::default(), diff --git a/crates/openlogi-hid/src/gesture.rs b/crates/openlogi-hid/src/gesture.rs index 1f0da4e1..2efd350d 100644 --- a/crates/openlogi-hid/src/gesture.rs +++ b/crates/openlogi-hid/src/gesture.rs @@ -76,18 +76,43 @@ struct CaptureAccum { /// Whether any DPI/ModeShift control was held in the last event — for /// rising-edge press detection. dpi_down: bool, + /// Diverted standard-button CIDs held in the last event. + buttons_down: Vec, } -/// Capture the gesture button, DPI/ModeShift button, and (when -/// `capture_thumbwheel`) the thumb wheel on `route` until `shutdown` resolves, -/// forwarding each event to `sink`. +/// HID++-divertable standard buttons: the `0x1b04` control ID and the +/// [`ButtonId`] its press dispatches as. A button is diverted per device only +/// when its binding leaves the default, so an unbound button keeps its native +/// HID behavior (no re-synthesis needed). +pub const DIVERTABLE_STANDARD_BUTTONS: [(u16, ButtonId); 3] = [ + (0x0052, ButtonId::MiddleClick), + (0x0053, ButtonId::Back), + (0x0056, ButtonId::Forward), +]; + +/// Which of one device's controls a capture session should divert. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CaptureSpec { + /// Divert the thumb wheel over `0x2150` (rotation rebind / sensitivity / + /// click bound). + pub capture_thumbwheel: bool, + /// Divert the dedicated gesture button with raw-XY (it owns the gesture + /// role). + pub divert_gesture_button: bool, + /// Standard buttons to divert, from [`DIVERTABLE_STANDARD_BUTTONS`], + /// filtered to those whose binding leaves the default. + pub divert_buttons: Vec<(u16, ButtonId)>, +} + +/// Capture the controls selected by `spec` on `route` until `shutdown` +/// resolves, forwarding each event to `sink`. /// -/// The dedicated gesture button (raw-XY) is diverted only when `divert_gesture_button` — -/// i.e. it is the device's gesture owner. When the user moves the gesture role -/// to an OS-hook button or turns gestures off, the HID++ gesture control is -/// left undiverted so it keeps its native behavior instead of being -/// captured-and-swallowed. The DPI/ModeShift capture and the channel-reuse slot -/// are independent of this. +/// The dedicated gesture button (raw-XY) is diverted only when +/// `spec.divert_gesture_button` — i.e. it is the device's gesture owner. When +/// the user moves the gesture role to an OS-hook button or turns gestures off, +/// the HID++ gesture control is left undiverted so it keeps its native behavior +/// instead of being captured-and-swallowed. The DPI/ModeShift capture and the +/// channel-reuse slot are independent of this. /// /// Opens and holds one HID++ channel, diverts whichever of those controls the /// device exposes, and listens. Returns once `shutdown` fires (or its sender is @@ -95,8 +120,7 @@ struct CaptureAccum { /// failures to restore on the way out are logged, not propagated. pub async fn run_capture_session( route: DeviceRoute, - capture_thumbwheel: bool, - divert_gesture_button: bool, + spec: CaptureSpec, sink: mpsc::UnboundedSender, shutdown: oneshot::Receiver<()>, channel_slot: CaptureChannel, @@ -105,13 +129,7 @@ pub async fn run_capture_session( .await? .ok_or(GestureError::DeviceNotFound)?; let device_index = route.device_index(); - let armed = arm_controls( - &chan, - device_index, - capture_thumbwheel, - divert_gesture_button, - ) - .await?; + let armed = arm_controls(&chan, device_index, &spec).await?; // Publish this device's open channel so DPI/SmartShift writes reuse it // instead of opening their own. Cleared on the way out. @@ -123,6 +141,7 @@ pub async fn run_capture_session( let reprog_index = armed.reprog.as_ref().map(|(_, idx)| *idx); let thumb_index = armed.thumb.as_ref().map(|(_, idx)| *idx); let dpi_set = armed.dpi_cids.clone(); + let button_set = armed.button_cids.clone(); let listener = chan.add_msg_listener_guarded({ let accum = Arc::clone(&accum); let sink = sink.clone(); @@ -137,7 +156,7 @@ pub async fn run_capture_session( // Recover the guard even if a prior holder panicked — the // critical section is panic-free, so the data is consistent. let mut acc = accum.lock().unwrap_or_else(PoisonError::into_inner); - handle_reprog(&mut acc, event, &dpi_set, &sink); + handle_reprog(&mut acc, event, &dpi_set, &button_set, &sink); return; } if let Some(idx) = thumb_index @@ -157,6 +176,7 @@ pub async fn run_capture_session( index = device_index, gesture = armed.gesture_diverted, dpi_buttons = armed.dpi_cids.len(), + buttons = armed.button_cids.len(), thumbwheel = armed.thumb.is_some(), "control capture active" ); @@ -180,6 +200,9 @@ struct ArmedControls { gesture_diverted: bool, /// DPI/ModeShift CIDs diverted as plain buttons. dpi_cids: Vec, + /// Standard-button CIDs diverted per the session's [`CaptureSpec`], with + /// the [`ButtonId`] each dispatches as. + button_cids: Vec<(u16, ButtonId)>, /// `0x2150` accessor + feature index, present when the thumb wheel is /// diverted. thumb: Option<(Thumbwheel, u8)>, @@ -198,6 +221,12 @@ impl ArmedControls { for &cid in &self.dpi_cids { restore(rc.set_cid_reporting(cid, false, false).await, "DPI button"); } + for &(cid, _) in &self.button_cids { + restore( + rc.set_cid_reporting(cid, false, false).await, + "standard button", + ); + } } if let Some((tw, _)) = self.thumb.as_ref() { restore(tw.set_reporting(false, false).await, "thumb wheel"); @@ -205,16 +234,15 @@ impl ArmedControls { } } -/// Resolve features off the device's root and divert the controls we capture: -/// the gesture button (raw-XY) and DPI/ModeShift buttons over `0x1b04`, and — -/// when `capture_thumbwheel` — the thumb wheel over `0x2150`. The +/// Resolve features off the device's root and divert the controls `spec` +/// selects: the gesture button (raw-XY), DPI/ModeShift buttons and rebindable +/// standard buttons over `0x1b04`, and the thumb wheel over `0x2150`. The /// root-feature lookup mirrors `write::open_feature`, /// since hidpp 0.2's registry doesn't carry the features OpenLogi reimplements. async fn arm_controls( chan: &Arc, slot: u8, - capture_thumbwheel: bool, - divert_gesture_button: bool, + spec: &CaptureSpec, ) -> Result { let device = Device::new(Arc::clone(chan), slot) .await @@ -223,6 +251,7 @@ async fn arm_controls( let mut reprog: Option<(ReprogControlsV4, u8)> = None; let mut gesture_diverted = false; let mut dpi_cids: Vec = Vec::new(); + let mut button_cids: Vec<(u16, ButtonId)> = Vec::new(); if let Some(info) = device .root() .get_feature(reprog_controls::FEATURE_ID) @@ -234,7 +263,7 @@ async fn arm_controls( // Only divert the gesture button when it owns the gesture role; otherwise // leave it native (a non-owner HID++ control must not be captured-and-dropped). - if divert_gesture_button + if spec.divert_gesture_button && controls .iter() .any(|c| c.cid == reprog_controls::GESTURE_BUTTON_CID && c.supports_raw_xy()) @@ -252,11 +281,19 @@ async fn arm_controls( dpi_cids.push(cid); } } + for &(cid, button) in &spec.divert_buttons { + if controls.iter().any(|c| c.cid == cid && c.is_divertable()) { + rc.set_cid_reporting(cid, true, false) + .await + .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?; + button_cids.push((cid, button)); + } + } reprog = Some((rc, info.index)); } let mut thumb: Option<(Thumbwheel, u8)> = None; - if capture_thumbwheel + if spec.capture_thumbwheel && let Some(info) = device .root() .get_feature(thumbwheel::FEATURE_ID) @@ -287,13 +324,14 @@ async fn arm_controls( thumb = Some((tw, info.index)); } - if !gesture_diverted && dpi_cids.is_empty() && thumb.is_none() { + if !gesture_diverted && dpi_cids.is_empty() && button_cids.is_empty() && thumb.is_none() { debug!(slot, "no capturable controls — idle session"); } Ok(ArmedControls { reprog, gesture_diverted, dpi_cids, + button_cids, thumb, }) } @@ -333,6 +371,7 @@ fn handle_reprog( acc: &mut CaptureAccum, event: RawControlEvent, dpi_cids: &[u16], + button_cids: &[(u16, ButtonId)], sink: &mpsc::UnboundedSender, ) { match event { @@ -353,6 +392,17 @@ fn handle_reprog( let _ = sink.send(CapturedInput::ButtonPressed(ButtonId::DpiToggle)); } acc.dpi_down = dpi_down; + + for &(cid, button) in button_cids { + let down = cids.contains(&cid); + let was_down = acc.buttons_down.contains(&cid); + if down && !was_down { + let _ = sink.send(CapturedInput::ButtonPressed(button)); + acc.buttons_down.push(cid); + } else if !down && was_down { + acc.buttons_down.retain(|&c| c != cid); + } + } } RawControlEvent::RawXy { dx, dy } => { // Commit the instant a clean direction emerges (mid-swipe, once per diff --git a/crates/openlogi-hid/src/gesture/tests.rs b/crates/openlogi-hid/src/gesture/tests.rs index 035f2cda..65cdaed5 100644 --- a/crates/openlogi-hid/src/gesture/tests.rs +++ b/crates/openlogi-hid/src/gesture/tests.rs @@ -13,14 +13,15 @@ fn quick_tap_is_a_click_even_while_the_cursor_moves() { let (tx, mut rx) = mpsc::unbounded_channel(); let mut acc = CaptureAccum::default(); - handle_reprog(&mut acc, press(), &[], &tx); + handle_reprog(&mut acc, press(), &[], &[], &tx); handle_reprog( &mut acc, RawControlEvent::RawXy { dx: 120, dy: 5 }, &[], + &[], &tx, ); - handle_reprog(&mut acc, release(), &[], &tx); + handle_reprog(&mut acc, release(), &[], &[], &tx); assert_eq!( rx.try_recv(), @@ -37,13 +38,14 @@ fn a_held_gesture_commits_a_swipe_and_does_not_also_click() { let (tx, mut rx) = mpsc::unbounded_channel(); let mut acc = CaptureAccum::default(); - handle_reprog(&mut acc, press(), &[], &tx); + handle_reprog(&mut acc, press(), &[], &[], &tx); // Pretend the button has been held well past the swipe gate. acc.swipe.backdate_hold_for_test(); handle_reprog( &mut acc, RawControlEvent::RawXy { dx: 120, dy: 5 }, &[], + &[], &tx, ); @@ -52,7 +54,7 @@ fn a_held_gesture_commits_a_swipe_and_does_not_also_click() { Ok(CapturedInput::Gesture(GestureDirection::Right)) ); - handle_reprog(&mut acc, release(), &[], &tx); + handle_reprog(&mut acc, release(), &[], &[], &tx); assert!( rx.try_recv().is_err(), "a committed swipe must not also click on release" @@ -66,8 +68,8 @@ fn a_held_dpi_button_presses_once_on_the_rising_edge() { let dpi = reprog_controls::DPI_MODE_SHIFT_CIDS[0]; let down = RawControlEvent::DivertedButtons([dpi, 0, 0, 0]); - handle_reprog(&mut acc, down, &[dpi], &tx); - handle_reprog(&mut acc, down, &[dpi], &tx); + handle_reprog(&mut acc, down, &[dpi], &[], &tx); + handle_reprog(&mut acc, down, &[dpi], &[], &tx); assert_eq!( rx.try_recv(), @@ -87,9 +89,9 @@ fn a_dpi_button_re_presses_after_a_release() { let down = RawControlEvent::DivertedButtons([dpi, 0, 0, 0]); let up = RawControlEvent::DivertedButtons([0, 0, 0, 0]); - handle_reprog(&mut acc, down, &[dpi], &tx); - handle_reprog(&mut acc, up, &[dpi], &tx); - handle_reprog(&mut acc, down, &[dpi], &tx); + handle_reprog(&mut acc, down, &[dpi], &[], &tx); + handle_reprog(&mut acc, up, &[dpi], &[], &tx); + handle_reprog(&mut acc, down, &[dpi], &[], &tx); assert_eq!( rx.try_recv(), From be6f72d657f18cd66a054dd93dc2f677885b1e79 Mon Sep 17 00:00:00 2001 From: Hiroaki Tagawa Date: Sat, 18 Jul 2026 22:18:44 +0900 Subject: [PATCH 05/19] feat: per-device thumbwheel sensitivity and DPI cycle state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finish moving per-device runtime state out of the app-wide globals now that capture runs one session per device: - config: DeviceConfig.thumbwheel_sensitivity overrides the app-wide default (which remains the fallback and the Settings → General slider's meaning); Config::thumbwheel_sensitivity(key) resolves it. - agent: each DeviceCapturePlan carries its device's resolved sensitivity for arming and scroll scaling; the SharedRuntime AtomicI32 mirror is gone. DpiCycles replaces the single DpiCycleState — one cycle state per online device plus the GUI selection; HID++ capture dispatch cycles the device an event arrived on, while the OS hook (which cannot attribute events) keeps targeting the selection. A config reload no longer snaps an unchanged device's cycle index back to preset[0]. - GUI: the SmartShift panel gains a per-device Thumb Wheel Sensitivity slider (reusing the existing localized label), editing the selected device's override. Verified on hardware: MX Master 4 at the app default and MX Master 3S with an override scroll at visibly different speeds simultaneously. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01N76prZoUHMrHZajXq4jET1 --- .../openlogi-agent-core/src/capture_plan.rs | 8 +- crates/openlogi-agent-core/src/dpi.rs | 24 +++ .../openlogi-agent-core/src/hook_runtime.rs | 24 ++- crates/openlogi-agent-core/src/lib.rs | 2 +- .../openlogi-agent-core/src/orchestrator.rs | 66 +++++--- .../src/watchers/gesture.rs | 42 ++--- crates/openlogi-agent/src/main.rs | 1 - crates/openlogi-agent/src/pairing.rs | 5 +- crates/openlogi-core/src/config.rs | 23 +++ crates/openlogi-core/src/config/device.rs | 8 + .../src/components/smartshift_panel.rs | 158 +++++++++++++++--- crates/openlogi-gui/src/state.rs | 30 +++- 12 files changed, 298 insertions(+), 93 deletions(-) diff --git a/crates/openlogi-agent-core/src/capture_plan.rs b/crates/openlogi-agent-core/src/capture_plan.rs index 5852322a..415717d9 100644 --- a/crates/openlogi-agent-core/src/capture_plan.rs +++ b/crates/openlogi-agent-core/src/capture_plan.rs @@ -35,9 +35,12 @@ pub struct DeviceCapturePlan { /// `0x1b04`. A button at its default keeps its native HID behavior, so no /// re-synthesis is ever needed. pub divert_buttons: Vec<(u16, ButtonId)>, - /// Whether any thumbwheel binding leaves its default. The watcher combines - /// this with the live sensitivity to decide thumb-wheel diversion. + /// Whether any thumbwheel binding leaves its default. Combined with the + /// sensitivity to decide thumb-wheel diversion. pub thumbwheel_bindings_nondefault: bool, + /// This device's effective thumb-wheel sensitivity (device override or the + /// app-wide default). + pub thumbwheel_sensitivity: i32, } /// Shared plan list, rewritten by the orchestrator and read by the watcher. @@ -84,5 +87,6 @@ pub fn plan_for_device( gesture_bindings, divert_buttons, thumbwheel_bindings_nondefault, + thumbwheel_sensitivity: config.thumbwheel_sensitivity(config_key), } } diff --git a/crates/openlogi-agent-core/src/dpi.rs b/crates/openlogi-agent-core/src/dpi.rs index 0c266643..17da008c 100644 --- a/crates/openlogi-agent-core/src/dpi.rs +++ b/crates/openlogi-agent-core/src/dpi.rs @@ -1,7 +1,31 @@ //! DPI-cycle state shared with background action dispatch. +use std::collections::HashMap; + use openlogi_hid::{DeviceRoute, DpiCapabilities}; +/// Per-device DPI-cycle states plus the GUI's current selection. +/// +/// HID++ capture dispatch resolves against the device an event arrived on; the +/// OS hook cannot attribute an event to a device, so it dispatches against the +/// selection — the same behavior the runtime had when this was a single state. +#[derive(Debug, Clone, Default)] +pub struct DpiCycles { + /// Config key of the GUI-selected device (the OS hook's dispatch target). + pub selected: Option, + /// One cycle state per online device, keyed by config key. + pub by_key: HashMap, +} + +impl DpiCycles { + /// The state for `key`, falling back to the selected device when `key` is + /// `None` (the OS hook path). + pub fn state_for(&mut self, key: Option<&str>) -> Option<&mut DpiCycleState> { + let key = key.or(self.selected.as_deref())?; + self.by_key.get_mut(key) + } +} + /// Shared state consumed by the OS hook thread and the DPI panel UI to /// implement DPI preset cycling and direct preset selection actions. /// diff --git a/crates/openlogi-agent-core/src/hook_runtime.rs b/crates/openlogi-agent-core/src/hook_runtime.rs index 27234f1d..31623671 100644 --- a/crates/openlogi-agent-core/src/hook_runtime.rs +++ b/crates/openlogi-agent-core/src/hook_runtime.rs @@ -16,9 +16,9 @@ use openlogi_hid::CaptureChannel; use openlogi_hook::{EventDisposition, Hook, MouseEvent}; use tracing::{info, warn}; -use crate::DpiCycleState; use crate::event_monitor::SharedEventMonitor; use crate::hardware::{toggle_smartshift_in_background, write_dpi_in_background}; +use crate::{DpiCycleState, DpiCycles}; /// 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 @@ -100,7 +100,7 @@ thread_local! { /// granted or on an unsupported platform — the app continues without crashing. pub fn start( hooks: SharedHookMaps, - dpi_cycle: Arc>, + dpi_cycle: Arc>, capture: CaptureChannel, monitor: SharedEventMonitor, ) -> Option { @@ -157,7 +157,7 @@ pub fn start( .map(|m| resolve_gesture_click(&m.gestures, id)); if let Some(action) = action { info!(button = %id, action = %action.label(), "gesture click → executing bound action"); - dispatch_action(&action, &dpi_cycle, &capture); + dispatch_action(&action, &dpi_cycle, None, &capture); } } return EventDisposition::Suppress; @@ -180,7 +180,7 @@ pub fn start( if pressed { info!(button = %id, action = %action.label(), "button → executing bound action"); - dispatch_action(&action, &dpi_cycle, &capture); + dispatch_action(&action, &dpi_cycle, None, &capture); } EventDisposition::Suppress } @@ -206,7 +206,7 @@ pub fn start( }); if let Some(action) = action { info!(button = %button, ?dir, action = %action.label(), "gesture swipe → executing bound action"); - dispatch_action(&action, &dpi_cycle, &capture); + dispatch_action(&action, &dpi_cycle, None, &capture); } } EventDisposition::PassThrough @@ -274,26 +274,32 @@ fn is_native_click(id: ButtonId, action: &Action) -> bool { /// `capture` lets those writes reuse the capture session's open channel. pub fn dispatch_action( action: &Action, - dpi_cycle: &Arc>, + dpi_cycle: &Arc>, + device_key: Option<&str>, capture: &CaptureChannel, ) { let next = match action { Action::CycleDpiPresets => match dpi_cycle.write() { - Ok(mut guard) => guard.cycle(), + Ok(mut guard) => guard.state_for(device_key).and_then(DpiCycleState::cycle), Err(e) => { warn!(error = %e, "dpi_cycle lock poisoned — cycle skipped"); None } }, Action::SetDpiPreset(i) => match dpi_cycle.write() { - Ok(mut guard) => guard.set(usize::from(*i)), + Ok(mut guard) => guard + .state_for(device_key) + .and_then(|state| state.set(usize::from(*i))), Err(e) => { warn!(error = %e, "dpi_cycle lock poisoned — set skipped"); None } }, Action::ToggleSmartShift => { - let target = dpi_cycle.read().ok().and_then(|g| g.target.clone()); + let target = dpi_cycle + .write() + .ok() + .and_then(|mut g| g.state_for(device_key).and_then(|s| s.target.clone())); info!("SmartShift toggle → flipping wheel mode"); toggle_smartshift_in_background(Some(capture), target); return; diff --git a/crates/openlogi-agent-core/src/lib.rs b/crates/openlogi-agent-core/src/lib.rs index 9d9a2245..743c1d25 100644 --- a/crates/openlogi-agent-core/src/lib.rs +++ b/crates/openlogi-agent-core/src/lib.rs @@ -19,4 +19,4 @@ pub mod receiver_access; pub mod transport; pub mod watchers; -pub use dpi::DpiCycleState; +pub use dpi::{DpiCycleState, DpiCycles}; diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index 9ef4ac82..4e825992 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -11,7 +11,6 @@ //! (still valid) values — exactly the GUI's "window never opened" behaviour. use std::collections::{BTreeMap, HashSet}; -use std::sync::atomic::{AtomicI32, Ordering}; use std::sync::{Arc, RwLock}; use openlogi_core::config::{Config, ScrollResolution}; @@ -19,7 +18,6 @@ use openlogi_core::device::{Capabilities, DeviceInventory}; use openlogi_hid::{CaptureChannel, DeviceRoute}; use tracing::warn; -use crate::DpiCycleState; use crate::bindings::{bindings_for, gesture_bindings_for, oshook_gestures_for}; use crate::capture_plan::{DeviceCapturePlan, SharedCapturePlans, plan_for_device}; use crate::device_order::DeviceStableId; @@ -27,6 +25,7 @@ use crate::hook_runtime::{HookMaps, SharedHookMaps}; use crate::ipc::InventoryHealth; use crate::receiver_access::ReceiverAccess; use crate::watchers::gesture::GestureBindings; +use crate::{DpiCycleState, DpiCycles}; /// The minimal per-device facts the agent needs: the config key (binding / /// preset lookup), the HID++ route (DPI/SmartShift writes + capture target), and @@ -56,11 +55,11 @@ pub struct SharedRuntime { /// gesture watcher for the thumb-wheel/DPI-button single actions. pub hook_maps: SharedHookMaps, pub gesture_bindings: GestureBindings, - pub dpi_cycle: Arc>, + pub dpi_cycle: Arc>, /// One capture plan per online device — what to divert and how to - /// dispatch, keyed by the device the events arrive on. + /// dispatch, keyed by the device the events arrive on. Carries each + /// device's effective thumb-wheel sensitivity. pub capture_plans: SharedCapturePlans, - pub thumbwheel_sensitivity: Arc, 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. @@ -110,11 +109,8 @@ impl Orchestrator { let shared = SharedRuntime { hook_maps: Arc::new(RwLock::new(HookMaps::default())), gesture_bindings: Arc::new(RwLock::new(BTreeMap::new())), - dpi_cycle: Arc::new(RwLock::new(DpiCycleState::default())), + dpi_cycle: Arc::new(RwLock::new(DpiCycles::default())), capture_plans: Arc::new(RwLock::new(Vec::new())), - thumbwheel_sensitivity: Arc::new(AtomicI32::new( - config.app_settings.thumbwheel_sensitivity, - )), capture_channel: Arc::new(RwLock::new(None)), receiver_access: ReceiverAccess::default(), }; @@ -144,10 +140,6 @@ impl Orchestrator { .map(|d| d.config_key.as_str()) } - fn current_route(&self) -> Option { - self.devices.get(self.current).and_then(|d| d.route.clone()) - } - /// Build the OS-hook callback's maps for `key` + foreground `app`. Both hook /// sub-maps are app-scoped (a per-app override can demote the gesture owner), /// so they're built together here and published under one lock — keeping @@ -174,20 +166,7 @@ impl Orchestrator { gesture_bindings_for(&self.config, key), "gesture_bindings", ); - write_value( - &self.shared.dpi_cycle, - DpiCycleState { - presets: key.map(|k| self.config.dpi_presets(k)).unwrap_or_default(), - index: 0, - target: self.current_route(), - capabilities: None, - }, - "dpi_cycle", - ); - self.shared.thumbwheel_sensitivity.store( - self.config.app_settings.thumbwheel_sensitivity, - Ordering::Relaxed, - ); + self.rebuild_dpi_cycles(key); write_value( &self.shared.capture_plans, self.capture_plans_for(), @@ -195,6 +174,39 @@ impl Orchestrator { ); } + /// Rewrite the per-device DPI-cycle map for every online device, + /// preserving a device's live cycle index (and lazily discovered + /// capabilities) across rebuilds whose presets did not change — a config + /// reload must not snap DPI back to `preset[0]`. + fn rebuild_dpi_cycles(&self, selected: Option<&str>) { + let Ok(mut guard) = self.shared.dpi_cycle.write() else { + warn!("dpi_cycle lock poisoned — rebuild skipped"); + return; + }; + let mut by_key = std::collections::HashMap::new(); + for dev in self.devices.iter().filter(|dev| dev.online) { + let Some(route) = dev.route.clone() else { + continue; + }; + let presets = self.config.dpi_presets(&dev.config_key); + let previous = guard + .by_key + .get(&dev.config_key) + .filter(|state| state.presets == presets); + by_key.insert( + dev.config_key.clone(), + DpiCycleState { + index: previous.map_or(0, |state| state.index), + capabilities: previous.and_then(|state| state.capabilities.clone()), + presets, + target: Some(route), + }, + ); + } + guard.selected = selected.map(str::to_owned); + guard.by_key = by_key; + } + /// One capture plan per online device, from the current config + app. fn capture_plans_for(&self) -> Vec { self.devices diff --git a/crates/openlogi-agent-core/src/watchers/gesture.rs b/crates/openlogi-agent-core/src/watchers/gesture.rs index 0268fe3b..019b3e3d 100644 --- a/crates/openlogi-agent-core/src/watchers/gesture.rs +++ b/crates/openlogi-agent-core/src/watchers/gesture.rs @@ -20,7 +20,6 @@ //! way regardless. use std::collections::{BTreeMap, HashMap}; -use std::sync::atomic::{AtomicI32, Ordering}; use std::sync::{Arc, RwLock}; use std::thread; use std::time::{Duration, Instant}; @@ -32,7 +31,7 @@ use openlogi_hid::{CaptureChannel, CapturedInput, DeviceRoute, run_capture_sessi use tokio::sync::{mpsc, oneshot}; use tracing::{debug, warn}; -use crate::DpiCycleState; +use crate::DpiCycles; use crate::capture_plan::{DeviceCapturePlan, SharedCapturePlans}; use crate::hook_runtime; use crate::receiver_access::{CaptureReceiverLease, ReceiverAccess}; @@ -41,10 +40,6 @@ use crate::receiver_access::{CaptureReceiverLease, ReceiverAccess}; /// direction). The watcher reads it to map a captured swipe to a bound action. pub type GestureBindings = Arc>>; -/// Shared thumb-wheel sensitivity, mirrored from `AppState`. Read on every wheel -/// event; written only by `AppState::set_thumbwheel_sensitivity`. -pub type ThumbwheelSensitivity = Arc; - /// How often to re-read the active device target + thumb-wheel arming so a /// carousel switch or a binding/sensitivity edit re-points / re-arms capture. /// It also paces the respawn of a session that ended on its own (see `manage`). @@ -79,9 +74,8 @@ fn action_threshold(sensitivity: i32) -> i32 { /// captured input. pub fn spawn( capture_plans: SharedCapturePlans, - dpi_cycle: Arc>, + dpi_cycle: Arc>, capture_channel: CaptureChannel, - thumbwheel_sensitivity: ThumbwheelSensitivity, receiver_access: ReceiverAccess, ) { thread::spawn(move || { @@ -99,7 +93,6 @@ pub fn spawn( capture_plans, dpi_cycle, capture_channel, - thumbwheel_sensitivity, receiver_access, )); }); @@ -107,16 +100,17 @@ pub fn spawn( /// Whether one device's thumb wheel must be diverted over HID++ (which /// suppresses native scroll) so we can re-synthesise its scroll or capture its -/// tap: the sensitivity leaves its default (so we scale scroll ourselves) or -/// the plan says a thumbwheel binding does. -fn thumbwheel_armed(plan: &DeviceCapturePlan, sensitivity: i32) -> bool { - sensitivity != DEFAULT_THUMBWHEEL_SENSITIVITY || plan.thumbwheel_bindings_nondefault +/// tap: its sensitivity leaves the default (so we scale scroll ourselves) or a +/// thumbwheel binding does. +fn thumbwheel_armed(plan: &DeviceCapturePlan) -> bool { + plan.thumbwheel_sensitivity != DEFAULT_THUMBWHEEL_SENSITIVITY + || plan.thumbwheel_bindings_nondefault } /// The [`CaptureSpec`] one device's session should run with right now. -fn spec_for(plan: &DeviceCapturePlan, sensitivity: i32) -> CaptureSpec { +fn spec_for(plan: &DeviceCapturePlan) -> CaptureSpec { CaptureSpec { - capture_thumbwheel: thumbwheel_armed(plan, sensitivity), + capture_thumbwheel: thumbwheel_armed(plan), divert_gesture_button: !plan.gesture_bindings.is_empty(), divert_buttons: plan.divert_buttons.clone(), } @@ -146,9 +140,8 @@ fn should_rearm(done_epoch: u64, live: Option<&RunningSession>) -> bool { /// the device they arrived on. Runs for the lifetime of the process. async fn manage( capture_plans: SharedCapturePlans, - dpi_cycle: Arc>, + dpi_cycle: Arc>, capture_channel: CaptureChannel, - thumbwheel_sensitivity: ThumbwheelSensitivity, receiver_access: ReceiverAccess, ) { let (tx, mut rx) = mpsc::unbounded_channel::<(String, CapturedInput)>(); @@ -177,7 +170,6 @@ async fn manage( &capture_plans, &dpi_cycle, &capture_channel, - &thumbwheel_sensitivity, ); } _ = ticker.tick() => { @@ -188,7 +180,6 @@ async fn manage( if receiver_access.pairing_requested() { HashMap::new() } else { - let sensitivity = thumbwheel_sensitivity.load(Ordering::Relaxed); capture_plans .read() .map(|plans| { @@ -197,7 +188,7 @@ async fn manage( .map(|plan| { ( plan.config_key.clone(), - (plan.route.clone(), spec_for(plan, sensitivity)), + (plan.route.clone(), spec_for(plan)), ) }) .collect() @@ -352,9 +343,8 @@ fn dispatch( input: CapturedInput, accumulators: &mut HashMap, capture_plans: &SharedCapturePlans, - dpi_cycle: &Arc>, + dpi_cycle: &Arc>, capture: &CaptureChannel, - thumbwheel_sensitivity: &ThumbwheelSensitivity, ) { let Ok(plans) = capture_plans.read() else { return; @@ -367,7 +357,7 @@ fn dispatch( CapturedInput::Gesture(direction) => { if let Some(action) = plan.gesture_bindings.get(&direction) { debug!(key, ?direction, action = %action.label(), "gesture → action"); - hook_runtime::dispatch_action(action, dpi_cycle, capture); + hook_runtime::dispatch_action(action, dpi_cycle, Some(key), capture); } else { debug!(key, ?direction, "gesture with no binding — ignored"); } @@ -375,7 +365,7 @@ fn dispatch( CapturedInput::ButtonPressed(button) => { if let Some(action) = plan.bindings.get(&button) { debug!(key, ?button, action = %action.label(), "HID++ button → action"); - hook_runtime::dispatch_action(action, dpi_cycle, capture); + hook_runtime::dispatch_action(action, dpi_cycle, Some(key), capture); } else { debug!(key, ?button, "HID++ button with no binding — ignored"); } @@ -393,7 +383,7 @@ fn dispatch( .get(&button) .cloned() .unwrap_or_else(|| default_binding(button)); - let sensitivity = thumbwheel_sensitivity.load(Ordering::Relaxed); + let sensitivity = plan.thumbwheel_sensitivity; let wheels = accumulators.entry(key.to_owned()).or_default(); let dir = if up { &mut wheels.up } else { &mut wheels.down }; let magnitude = i32::from(rotation).abs(); @@ -404,7 +394,7 @@ fn dispatch( } WheelOutput::FireAction => { debug!(key, ?button, action = %action.label(), "thumb wheel → action"); - hook_runtime::dispatch_action(&action, dpi_cycle, capture); + hook_runtime::dispatch_action(&action, dpi_cycle, Some(key), capture); } } } diff --git a/crates/openlogi-agent/src/main.rs b/crates/openlogi-agent/src/main.rs index 93b43510..45a0626b 100644 --- a/crates/openlogi-agent/src/main.rs +++ b/crates/openlogi-agent/src/main.rs @@ -159,7 +159,6 @@ async fn run(config: Config) { shared.capture_plans.clone(), shared.dpi_cycle.clone(), shared.capture_channel.clone(), - shared.thumbwheel_sensitivity.clone(), shared.receiver_access.clone(), ); diff --git a/crates/openlogi-agent/src/pairing.rs b/crates/openlogi-agent/src/pairing.rs index 7c39e6c3..b628695a 100644 --- a/crates/openlogi-agent/src/pairing.rs +++ b/crates/openlogi-agent/src/pairing.rs @@ -259,7 +259,7 @@ mod tests { use std::collections::BTreeMap; use std::sync::RwLock; - use openlogi_agent_core::DpiCycleState; + use openlogi_agent_core::DpiCycles; use openlogi_agent_core::hook_runtime::HookMaps; use openlogi_agent_core::receiver_access::ReceiverAccess; @@ -267,9 +267,8 @@ mod tests { SharedRuntime { hook_maps: Arc::new(RwLock::new(HookMaps::default())), gesture_bindings: Arc::new(RwLock::new(BTreeMap::new())), - dpi_cycle: Arc::new(RwLock::new(DpiCycleState::default())), + dpi_cycle: Arc::new(RwLock::new(DpiCycles::default())), capture_plans: Arc::new(RwLock::new(Vec::new())), - thumbwheel_sensitivity: Arc::new(0.into()), capture_channel: Arc::new(RwLock::new(None)), receiver_access: ReceiverAccess::default(), } diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs index dead22c3..ba17cedc 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -563,6 +563,29 @@ impl Config { .or_default() .scroll_resolution = resolution; } + + /// The effective thumb-wheel sensitivity for `device_key`: the device's + /// override when set, else the app-wide default. + #[must_use] + pub fn thumbwheel_sensitivity(&self, device_key: &str) -> i32 { + self.devices + .get(device_key) + .and_then(|d| d.thumbwheel_sensitivity) + .unwrap_or(self.app_settings.thumbwheel_sensitivity) + } + + /// Set (or clear, with `None`) `device_key`'s thumb-wheel sensitivity + /// override. + pub fn set_device_thumbwheel_sensitivity( + &mut self, + device_key: &str, + sensitivity: Option, + ) { + self.devices + .entry(device_key.to_string()) + .or_default() + .thumbwheel_sensitivity = sensitivity; + } } /// Write `bytes` to `path` atomically via a randomized temp file + rename, diff --git a/crates/openlogi-core/src/config/device.rs b/crates/openlogi-core/src/config/device.rs index 9d73ae37..02909e1f 100644 --- a/crates/openlogi-core/src/config/device.rs +++ b/crates/openlogi-core/src/config/device.rs @@ -101,6 +101,11 @@ pub struct DeviceConfig { /// the same reason as [`Self::dpi`]. `None` until the user changes it. #[serde(default, skip_serializing_if = "Option::is_none")] pub smartshift: Option, + /// Per-device thumb-wheel sensitivity override. `None` falls back to the + /// app-wide + /// [`AppSettings::thumbwheel_sensitivity`](crate::config::AppSettings::thumbwheel_sensitivity). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thumbwheel_sensitivity: Option, /// Invert this device's scroll-wheel direction relative to the OS setting /// (issue #126): on, a wheel tick scrolls the opposite way, so a user who /// keeps macOS "natural scrolling" for the trackpad can have a traditional @@ -160,6 +165,8 @@ struct RawDeviceConfig { #[serde(default)] smartshift: Option, #[serde(default)] + thumbwheel_sensitivity: Option, + #[serde(default)] invert_scroll: bool, #[serde(default)] scroll_resolution: Option, @@ -205,6 +212,7 @@ impl From for DeviceConfig { dpi: raw.dpi, lighting: raw.lighting, smartshift: raw.smartshift, + thumbwheel_sensitivity: raw.thumbwheel_sensitivity, invert_scroll: raw.invert_scroll, scroll_resolution: raw.scroll_resolution, } diff --git a/crates/openlogi-gui/src/components/smartshift_panel.rs b/crates/openlogi-gui/src/components/smartshift_panel.rs index 07750f1b..a1d2b00c 100644 --- a/crates/openlogi-gui/src/components/smartshift_panel.rs +++ b/crates/openlogi-gui/src/components/smartshift_panel.rs @@ -22,7 +22,10 @@ use gpui_component::{ slider::{Slider, SliderEvent, SliderState}, v_flex, }; -use openlogi_core::config::{SMARTSHIFT_AUTO_DISENGAGE_DEFAULT, SMARTSHIFT_MIN_AUTO_DISENGAGE}; +use openlogi_core::config::{ + DEFAULT_THUMBWHEEL_SENSITIVITY, MAX_THUMBWHEEL_SENSITIVITY, MIN_THUMBWHEEL_SENSITIVITY, + SMARTSHIFT_AUTO_DISENGAGE_DEFAULT, SMARTSHIFT_MIN_AUTO_DISENGAGE, +}; use openlogi_hid::{AUTO_DISENGAGE_PERMANENT, DeviceRoute, SmartShiftMode, SmartShiftStatus}; use crate::components::device_read::issue_device_read; @@ -52,6 +55,14 @@ pub struct SmartShiftPanel { /// The live drag value, shown in the numeric label until release commits. pending_threshold: Option, _threshold_sub: Subscription, + /// The per-device thumb-wheel sensitivity slider (device override; devices + /// without one follow the app-wide default from Settings → General). + wheel_sensitivity: Entity, + /// Last committed sensitivity, to re-seat the thumb on a device switch. + last_wheel_sensitivity: i32, + /// Live drag value shown in the numeric label until release commits. + pending_wheel_sensitivity: Option, + _wheel_sensitivity_sub: Subscription, _state_obs: Subscription, } @@ -88,12 +99,52 @@ impl SmartShiftPanel { } }, ); + #[allow( + clippy::cast_precision_loss, + reason = "sensitivity bounds are small 1..=100 integers — exact in f32" + )] + let wheel_sensitivity = cx.new(|_| { + SliderState::new() + .min(MIN_THUMBWHEEL_SENSITIVITY as f32) + .max(MAX_THUMBWHEEL_SENSITIVITY as f32) + .step(1.) + .default_value(DEFAULT_THUMBWHEEL_SENSITIVITY as f32) + }); + #[allow( + clippy::cast_possible_truncation, + reason = "slider values are small integers well inside i32" + )] + let wheel_sensitivity_sub = cx.subscribe( + &wheel_sensitivity, + |panel, _slider, event: &SliderEvent, cx| match event { + SliderEvent::Change(value) => { + panel.pending_wheel_sensitivity = Some(value.start().round() as i32); + cx.notify(); + } + SliderEvent::Release(value) => { + let sensitivity = value.start().round() as i32; + panel.pending_wheel_sensitivity = None; + panel.last_wheel_sensitivity = sensitivity; + cx.update_global::(|state, _| { + let key = state.current_record().map(|r| r.config_key.clone()); + if let Some(key) = key { + state.set_device_thumbwheel_sensitivity(&key, sensitivity); + } + }); + cx.notify(); + } + }, + ); let state_obs = cx.observe_global::(|_, cx| cx.notify()); Self { threshold, last_threshold: DEFAULT_THRESHOLD, pending_threshold: None, _threshold_sub: threshold_sub, + wheel_sensitivity, + last_wheel_sensitivity: DEFAULT_THUMBWHEEL_SENSITIVITY, + pending_wheel_sensitivity: None, + _wheel_sensitivity_sub: wheel_sensitivity_sub, _state_obs: state_obs, } } @@ -230,26 +281,9 @@ impl SmartShiftPanel { "Higher keeps the ratchet engaged longer before free-spin." ))); - let permanent_row = h_flex() - .justify_between() - .items_center() - .child( - v_flex() - .child(section_label(tr!("Permanent ratchet"), pal)) - .child( - div() - .text_caption() - .text_color(pal.text_muted) - .child(tr!("Never auto-switch to free-spin.")), - ), - ) - .child(permanent_toggle( - permanent, - ratchet, - restore_threshold, - torque, - pal, - )); + let wheel_row = self.wheel_sensitivity_row(window, pal, cx); + + let permanent_row = permanent_row(permanent, ratchet, restore_threshold, torque, pal); v_flex() .gap_4() @@ -257,6 +291,58 @@ impl SmartShiftPanel { .child(mode_row) .child(sensitivity_row) .child(permanent_row) + .child(wheel_row) + .into_any_element() + } +} + +impl SmartShiftPanel { + /// The per-device thumb-wheel sensitivity row: label, live value, slider. + /// Reads the selected device's effective value and re-seats the thumb on a + /// device switch / external config change, never mid-drag. + fn wheel_sensitivity_row( + &mut self, + window: &mut Window, + pal: Palette, + cx: &mut Context, + ) -> AnyElement { + let committed = cx + .try_global::() + .and_then(|state| { + state + .current_record() + .map(|r| state.device_thumbwheel_sensitivity(&r.config_key)) + }) + .unwrap_or(DEFAULT_THUMBWHEEL_SENSITIVITY); + if self.pending_wheel_sensitivity.is_none() && committed != self.last_wheel_sensitivity { + self.last_wheel_sensitivity = committed; + #[allow( + clippy::cast_precision_loss, + reason = "sensitivity is a small 1..=100 integer — exact in f32" + )] + self.wheel_sensitivity + .update(cx, |s, cx| s.set_value(committed as f32, window, cx)); + } + let display = self.pending_wheel_sensitivity.unwrap_or(committed); + v_flex() + .gap_2() + .child( + h_flex() + .justify_between() + .items_baseline() + .child(section_label(tr!("Thumb Wheel Sensitivity"), pal)) + .child( + div() + .text_body() + .text_color(rgb(ACCENT_BLUE)) + .child(format!("{display}")), + ), + ) + .child( + Slider::new(&self.wheel_sensitivity) + .horizontal() + .into_any_element(), + ) .into_any_element() } } @@ -311,6 +397,36 @@ fn smartshift_load_target(cx: &mut Context) -> Option<(String, }) } +/// The "Permanent ratchet" label + toggle row. +fn permanent_row( + permanent: bool, + ratchet: bool, + restore_threshold: u8, + torque: u8, + pal: Palette, +) -> gpui::Div { + h_flex() + .justify_between() + .items_center() + .child( + v_flex() + .child(section_label(tr!("Permanent ratchet"), pal)) + .child( + div() + .text_caption() + .text_color(pal.text_muted) + .child(tr!("Never auto-switch to free-spin.")), + ), + ) + .child(permanent_toggle( + permanent, + ratchet, + restore_threshold, + torque, + pal, + )) +} + /// A small muted section heading. fn section_label(text: SharedString, pal: Palette) -> AnyElement { div() diff --git a/crates/openlogi-gui/src/state.rs b/crates/openlogi-gui/src/state.rs index c6a74711..4b9c0df7 100644 --- a/crates/openlogi-gui/src/state.rs +++ b/crates/openlogi-gui/src/state.rs @@ -1026,9 +1026,33 @@ 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. + /// The effective thumb-wheel sensitivity for `key` (its per-device + /// override, else the app-wide default). + #[must_use] + pub fn device_thumbwheel_sensitivity(&self, key: &str) -> i32 { + self.config.thumbwheel_sensitivity(key) + } + + /// Set `key`'s per-device thumb-wheel sensitivity override (clamped to the + /// valid range) and persist it. The agent picks it up through the reloaded + /// capture plans. No-op when unchanged. + pub fn set_device_thumbwheel_sensitivity(&mut self, key: &str, sensitivity: i32) { + let sensitivity = sensitivity.clamp( + openlogi_core::config::MIN_THUMBWHEEL_SENSITIVITY, + openlogi_core::config::MAX_THUMBWHEEL_SENSITIVITY, + ); + if self.config.thumbwheel_sensitivity(key) == sensitivity { + return; + } + self.config + .set_device_thumbwheel_sensitivity(key, Some(sensitivity)); + self.persist_and_reload("device thumbwheel sensitivity"); + } + + /// Set the app-wide default thumb-wheel sensitivity (clamped to the valid + /// range) and persist it — devices without a per-device override follow it + /// through the reloaded capture plans. No-op when unchanged. Disk failures + /// are logged, not propagated. pub fn set_thumbwheel_sensitivity(&mut self, sensitivity: i32) { let sensitivity = sensitivity.clamp( openlogi_core::config::MIN_THUMBWHEEL_SENSITIVITY, From e8d63234f0b1e0706e2729610c5e74bfbc244af9 Mon Sep 17 00:00:00 2001 From: Hiroaki Tagawa Date: Sat, 18 Jul 2026 22:35:54 +0900 Subject: [PATCH 06/19] feat: per-device manage toggle, shown as the gallery ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add DeviceConfig.enabled (default true, serialized only when off). A disabled device is left fully native: no capture session (so no HID++ diversion at all, including the previously unconditional DPI/ModeShift divert), no volatile-settings re-apply on reconnect, no DPI-cycle entry, and empty OS-hook maps when it is the selection. DeviceConfig's derived Default would have created disabled entries from `.or_default()` callers, so it gets a manual impl with enabled: true. The GUI exposes the toggle on the Device tab's configuration card, and the Home gallery ring now shows managed state — accent blue when managed, red when disabled — instead of marking the selection: capture runs per device, so selection only decides what the detail screen shows. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01N76prZoUHMrHZajXq4jET1 --- .../openlogi-agent-core/src/orchestrator.rs | 18 +++++++- crates/openlogi-core/src/config.rs | 15 ++++++ crates/openlogi-core/src/config/device.rs | 46 ++++++++++++++++++- crates/openlogi-gui/src/app/detail.rs | 35 ++++++++++++++ crates/openlogi-gui/src/app/home.rs | 36 ++++++++++----- crates/openlogi-gui/src/state.rs | 16 +++++++ crates/openlogi-gui/src/theme.rs | 2 + 7 files changed, 154 insertions(+), 14 deletions(-) diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index 4e825992..5802d107 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -145,6 +145,12 @@ 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. + if key.is_some_and(|k| !self.config.device_enabled(k)) { + return HookMaps::default(); + } HookMaps { bindings: bindings_for(&self.config, key, app), gestures: oshook_gestures_for(&self.config, key, app), @@ -184,7 +190,11 @@ impl Orchestrator { return; }; let mut by_key = std::collections::HashMap::new(); - for dev in self.devices.iter().filter(|dev| dev.online) { + for dev in self + .devices + .iter() + .filter(|dev| dev.online && self.config.device_enabled(&dev.config_key)) + { let Some(route) = dev.route.clone() else { continue; }; @@ -211,7 +221,7 @@ impl Orchestrator { fn capture_plans_for(&self) -> Vec { self.devices .iter() - .filter(|dev| dev.online) + .filter(|dev| dev.online && self.config.device_enabled(&dev.config_key)) .filter_map(|dev| { let route = dev.route.clone()?; Some(plan_for_device( @@ -289,6 +299,10 @@ impl Orchestrator { /// Reuses the capture session's channel when it already points at the /// device, like every other hardware write. fn reapply_volatile_settings(&self, dev: &AgentDevice) { + // A disabled device is left fully native — no writes of any kind. + if !self.config.device_enabled(&dev.config_key) { + return; + } let Some(route) = dev.route.clone() else { return; }; diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs index ba17cedc..07323430 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -564,6 +564,21 @@ impl Config { .scroll_resolution = resolution; } + /// Whether OpenLogi manages `device_key` at all (capture + volatile + /// re-apply). Unconfigured devices are managed. + #[must_use] + pub fn device_enabled(&self, device_key: &str) -> bool { + self.devices.get(device_key).is_none_or(|d| d.enabled) + } + + /// Enable or disable OpenLogi's management of `device_key`. + pub fn set_device_enabled(&mut self, device_key: &str, enabled: bool) { + self.devices + .entry(device_key.to_string()) + .or_default() + .enabled = enabled; + } + /// The effective thumb-wheel sensitivity for `device_key`: the device's /// override when set, else the app-wide default. #[must_use] diff --git a/crates/openlogi-core/src/config/device.rs b/crates/openlogi-core/src/config/device.rs index 02909e1f..2b694806 100644 --- a/crates/openlogi-core/src/config/device.rs +++ b/crates/openlogi-core/src/config/device.rs @@ -52,9 +52,15 @@ pub struct DeviceIdentity { /// `gesture_bindings` — fold into the unified [`Self::bindings`] map. Only /// `bindings` is ever serialized, so a migrated file self-heals to the v2 shape /// on its next save. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(from = "RawDeviceConfig")] pub struct DeviceConfig { + /// Whether OpenLogi manages this device at all. `false` leaves the device + /// fully native: no capture session (no HID++ diversion of any control) + /// and no volatile-settings re-apply on reconnect. Defaults to `true` and + /// is only serialized when disabled. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub enabled: bool, /// Which button owns the device's single gesture role, once the user has /// chosen explicitly. Absent means "infer" (the dedicated HID++ gesture /// button owns gestures if present) — see @@ -120,6 +126,41 @@ pub struct DeviceConfig { pub scroll_resolution: Option, } +impl Default for DeviceConfig { + fn default() -> Self { + Self { + // A fresh entry (e.g. created by a first DPI write) must stay + // managed — `enabled: false` is an explicit user choice only. + enabled: true, + gesture_owner: None, + identity: None, + bindings: BTreeMap::new(), + per_app_bindings: BTreeMap::new(), + dpi_presets: Vec::new(), + dpi: None, + lighting: None, + smartshift: None, + thumbwheel_sensitivity: None, + invert_scroll: false, + scroll_resolution: None, + } + } +} + +/// `serde(default)` helper for `bool` fields that default to `true`. +fn default_true() -> bool { + true +} + +/// `skip_serializing_if` helper for `bool` fields whose default is `true`. +#[allow( + clippy::trivially_copy_pass_by_ref, + reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature" +)] +fn is_true(b: &bool) -> bool { + *b +} + /// `skip_serializing_if` helper for plain `bool` fields whose default is /// `false`: keeps an unset toggle out of `config.toml` entirely. #[allow( @@ -170,6 +211,8 @@ struct RawDeviceConfig { invert_scroll: bool, #[serde(default)] scroll_resolution: Option, + #[serde(default = "default_true")] + enabled: bool, } impl From for DeviceConfig { @@ -204,6 +247,7 @@ impl From for DeviceConfig { } DeviceConfig { + enabled: raw.enabled, gesture_owner: raw.gesture_owner, identity: raw.identity, bindings, diff --git a/crates/openlogi-gui/src/app/detail.rs b/crates/openlogi-gui/src/app/detail.rs index 0d9280e1..cb71eaec 100644 --- a/crates/openlogi-gui/src/app/detail.rs +++ b/crates/openlogi-gui/src/app/detail.rs @@ -10,6 +10,7 @@ use gpui_component::{ description_list::{DescriptionItem, DescriptionList}, h_flex, scroll::ScrollableElement as _, + switch::Switch, tab::TabBar, v_flex, }; @@ -462,6 +463,14 @@ fn device_details_card(pal: Palette, cx: &mut Context) -> impl IntoElem } fn configuration_card(pal: Palette, cx: &mut Context) -> impl IntoElement { + let device_enabled = cx + .try_global::() + .and_then(|state| { + state + .current_record() + .map(|r| state.device_enabled(&r.config_key)) + }) + .unwrap_or(true); let (binding_count, gesture_count, preset_count, app_profile) = cx .try_global::() .map_or((0, 0, 0, tr!("Default profile").to_string()), |state| { @@ -478,6 +487,32 @@ fn configuration_card(pal: Palette, cx: &mut Context) -> impl IntoEleme let content = v_flex() .gap_3() + .child( + h_flex() + .justify_between() + .items_center() + .child( + v_flex() + .child(div().text_sm().child(tr!("Manage this device"))) + .child(div().text_xs().text_color(pal.text_muted).child(tr!( + "Off leaves every control native and stops re-applying settings." + ))), + ) + .child( + Switch::new("device-enabled") + .checked(device_enabled) + .on_click(|checked, _window, cx| { + let enabled = *checked; + cx.update_global::(|state, _| { + let key = state.current_record().map(|r| r.config_key.clone()); + if let Some(key) = key { + state.set_device_enabled(&key, enabled); + } + }); + cx.refresh_windows(); + }), + ), + ) .child( DescriptionList::new() .columns(1) diff --git a/crates/openlogi-gui/src/app/home.rs b/crates/openlogi-gui/src/app/home.rs index 850fcc0c..156e601f 100644 --- a/crates/openlogi-gui/src/app/home.rs +++ b/crates/openlogi-gui/src/app/home.rs @@ -56,8 +56,12 @@ const GALLERY_GAP: f32 = 24.; /// cards (Logi Options+ style), via [`Carousel`]'s `uniform` mode. Each card /// floats the device photo on the window background above its name and battery; /// the row centres while the cards fit the viewport and scrolls once they don't. -/// Clicking a card opens its detail screen and makes it the active device (whose -/// bindings the hook uses); the active card wears a faint accent ring. +/// Clicking a card opens its detail screen and makes it the active device. +/// A card is borderless at rest (the accent ring appears on hover) with one +/// exception: a device the user disabled wears a persistent red ring so the +/// unmanaged state stays visible. Capture runs per device, so which card is +/// "current" only decides what the detail screen shows; the active card keeps +/// upstream's faint accent fill as its marker. pub(super) fn device_gallery(cx: &mut Context) -> impl IntoElement { let (len, active_idx) = cx.try_global::().map_or((0, 0), |s| { let len = s.device_list.len(); @@ -81,11 +85,14 @@ pub(super) fn device_gallery(cx: &mut Context) -> impl IntoElement { return div().into_any_element(); }; let key = record.config_key.clone(); + let enabled = cx + .try_global::() + .is_some_and(|s| s.device_enabled(&record.config_key)); let glow = cx .try_global::() .and_then(|s| keyboard_glow(s, &record)); let view = view.clone(); - device_card(&record, focused, glow, pal) + device_card(&record, enabled, focused, glow, pal) .id(("device-card", idx)) .cursor_pointer() .hover(move |s| s.border_color(rgb(theme::ACCENT_BLUE))) @@ -169,19 +176,26 @@ pub(crate) fn glow_canvas(geom: Arc, color: Hsla) -> impl IntoElem /// A device card in the Home gallery: the device photo floating on the window /// background above the name, connectivity dot, kind/slot, and battery. Fixed -/// width so cards stay equal in the scrollable row. No card shows a border at -/// rest — the accent ring appears only on hover (wired by the gallery). The -/// `active` device (whose bindings and DPI are live) keeps a persistent but -/// borderless marker: a faint accent fill, which reads whether or not the -/// carousel centres it. The 1px border is always reserved in a transparent -/// colour so the hover ring never nudges the layout. Returns a bare [`Div`] so -/// the gallery can wire the hover and click handlers. +/// width so cards stay equal in the scrollable row. A managed card shows no +/// border at rest — the accent ring appears only on hover (wired by the +/// gallery) — while a disabled card wears a persistent red ring so the +/// unmanaged state stays visible. The `active` device (whose bindings and DPI +/// are live) keeps a persistent but borderless marker: a faint accent fill, +/// which reads whether or not the carousel centres it. The 1px border is +/// always reserved so ring changes never nudge the layout. Returns a bare +/// [`Div`] so the gallery can wire the hover and click handlers. fn device_card( record: &DeviceRecord, + enabled: bool, active: bool, glow: Option<(Arc, Hsla)>, pal: Palette, ) -> Div { + let ring: Hsla = if enabled { + gpui::transparent_black() + } else { + rgb(theme::STATUS_DISABLED).into() + }; v_flex() .w(px(theme::GALLERY_CARD_W)) .flex_shrink_0() @@ -190,7 +204,7 @@ fn device_card( .p_3() .rounded(pal.card_radius) .border_1() - .border_color(gpui::transparent_black()) + .border_color(ring) .selected_fill(active) .child( div() diff --git a/crates/openlogi-gui/src/state.rs b/crates/openlogi-gui/src/state.rs index 4b9c0df7..85c5eda5 100644 --- a/crates/openlogi-gui/src/state.rs +++ b/crates/openlogi-gui/src/state.rs @@ -1026,6 +1026,22 @@ impl AppState { } } + /// Whether OpenLogi manages `key` (capture + volatile re-apply). + #[must_use] + pub fn device_enabled(&self, key: &str) -> bool { + self.config.device_enabled(key) + } + + /// Enable or disable OpenLogi's management of `key` and persist it. The + /// agent tears down or re-arms the device's capture session on reload. + pub fn set_device_enabled(&mut self, key: &str, enabled: bool) { + if self.config.device_enabled(key) == enabled { + return; + } + self.config.set_device_enabled(key, enabled); + self.persist_and_reload("device enabled"); + } + /// The effective thumb-wheel sensitivity for `key` (its per-device /// override, else the app-wide default). #[must_use] diff --git a/crates/openlogi-gui/src/theme.rs b/crates/openlogi-gui/src/theme.rs index 1650a095..e20ccada 100644 --- a/crates/openlogi-gui/src/theme.rs +++ b/crates/openlogi-gui/src/theme.rs @@ -26,6 +26,8 @@ pub const ACCENT_BLUE: u32 = 0x003b_82f6; pub const STATUS_CONNECTED: u32 = 0x0022_c55e; pub const STATUS_CONNECTING: u32 = 0x00ea_b308; pub const STATUS_OFFLINE: u32 = 0x006b_7280; +/// Ring color for a device the user disabled ("Manage this device" off). +pub const STATUS_DISABLED: u32 = 0x00ef_4444; /// Sizes that several components need to agree on. pub const HEADER_H: f32 = 80.; From 06560db65a4b464d524ce9ea80aefad6123efbcf Mon Sep 17 00:00:00 2001 From: Hiroaki Tagawa Date: Sun, 19 Jul 2026 00:38:54 +0900 Subject: [PATCH 07/19] fix: publish DPI cycles on the inventory fast path, read-lock SmartShift toggle Review follow-up (#419): - refresh_inventory's same-set fast path republished capture plans but not the DPI-cycle map, so a device waking from sleep got its capture session yet silently dropped DPI-cycle / SmartShift-toggle actions until the next full rebuild. Fold both runtime views into one publish_device_runtime() so the pair can't diverge again. - ToggleSmartShift only needs the target route; give DpiCycles a read-only target_for() and drop the unnecessary write lock. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01N76prZoUHMrHZajXq4jET1 --- crates/openlogi-agent-core/src/dpi.rs | 47 +++++++++++++++++++ .../openlogi-agent-core/src/hook_runtime.rs | 5 +- .../openlogi-agent-core/src/orchestrator.rs | 22 +++++---- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/crates/openlogi-agent-core/src/dpi.rs b/crates/openlogi-agent-core/src/dpi.rs index 17da008c..2ace9a62 100644 --- a/crates/openlogi-agent-core/src/dpi.rs +++ b/crates/openlogi-agent-core/src/dpi.rs @@ -24,6 +24,15 @@ impl DpiCycles { let key = key.or(self.selected.as_deref())?; self.by_key.get_mut(key) } + + /// The write target for `key` (same fallback as [`Self::state_for`]) + /// without a mutable borrow — for dispatch that only needs the route, like + /// the SmartShift toggle. + #[must_use] + pub fn target_for(&self, key: Option<&str>) -> Option { + let key = key.or(self.selected.as_deref())?; + self.by_key.get(key).and_then(|state| state.target.clone()) + } } /// Shared state consumed by the OS hook thread and the DPI panel UI to @@ -71,3 +80,41 @@ impl DpiCycleState { .map_or(dpi, |caps| caps.snap(dpi)) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn cycles_with(key: &str, slot: u8) -> DpiCycles { + let mut cycles = DpiCycles::default(); + cycles.by_key.insert( + key.to_string(), + DpiCycleState { + presets: vec![800, 1600], + index: 0, + target: Some(DeviceRoute::Bolt { + receiver_uid: "AA00".to_string(), + slot, + }), + capabilities: None, + }, + ); + cycles + } + + #[test] + fn target_for_resolves_explicit_key_and_selection_fallback() { + let mut cycles = cycles_with("a", 1); + assert!(cycles.target_for(Some("a")).is_some()); + assert!(cycles.target_for(Some("missing")).is_none()); + // No key and no selection → nothing to target. + assert!(cycles.target_for(None).is_none()); + // The OS-hook path (no key) follows the selection. + cycles.selected = Some("a".to_string()); + assert!(cycles.target_for(None).is_some()); + assert_eq!( + cycles.target_for(None), + cycles.state_for(None).and_then(|s| s.target.clone()) + ); + } +} diff --git a/crates/openlogi-agent-core/src/hook_runtime.rs b/crates/openlogi-agent-core/src/hook_runtime.rs index 31623671..f3854731 100644 --- a/crates/openlogi-agent-core/src/hook_runtime.rs +++ b/crates/openlogi-agent-core/src/hook_runtime.rs @@ -296,10 +296,7 @@ pub fn dispatch_action( } }, Action::ToggleSmartShift => { - let target = dpi_cycle - .write() - .ok() - .and_then(|mut g| g.state_for(device_key).and_then(|s| s.target.clone())); + let target = dpi_cycle.read().ok().and_then(|g| g.target_for(device_key)); info!("SmartShift toggle → flipping wheel mode"); toggle_smartshift_in_background(Some(capture), target); return; diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index 5802d107..428444c0 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -172,12 +172,21 @@ impl Orchestrator { gesture_bindings_for(&self.config, key), "gesture_bindings", ); - self.rebuild_dpi_cycles(key); + self.publish_device_runtime(); + } + + /// Republish the runtime views derived from the device set + config: the + /// capture plans and the per-device DPI-cycle map. One method so the + /// inventory fast path (same set, fresh online flags) can't update one and + /// forget the other — a waking device needs both its capture session and + /// its DPI-cycle slot. + fn publish_device_runtime(&self) { write_value( &self.shared.capture_plans, self.capture_plans_for(), "capture_plans", ); + self.rebuild_dpi_cycles(self.current_key()); } /// Rewrite the per-device DPI-cycle map for every online device, @@ -270,14 +279,11 @@ impl Orchestrator { if !changed { // Same set and routes — but keep the fresh `online` flags, or a // device that woke this tick would read as a transition forever. - // Capture plans key on `online`, so republish them even here or a - // woken device would never get its session armed. + // The runtime views key on `online`, so republish them even here or + // a woken device would get neither its capture session nor its + // DPI-cycle slot. self.devices = devices; - write_value( - &self.shared.capture_plans, - self.capture_plans_for(), - "capture_plans", - ); + self.publish_device_runtime(); return; } self.devices = devices; From afee9c9967d64564920a22fd9d3f16bdde5f228f Mon Sep 17 00:00:00 2001 From: Hiroaki Tagawa Date: Sun, 19 Jul 2026 01:09:44 +0900 Subject: [PATCH 08/19] fix: never arm a replacement session in the tick that stopped its predecessor Review follow-up (#419): the single-session manager deliberately left a one-tick gap between stopping a session and starting its successor, so the teardown's divert-restore writes could not interleave with the new session's arm writes on the same device (a restore landing after the arm leaves the control un-diverted while the session believes it owns it). The multi-session rewrite lost that gap. Track the keys stopped in the current tick and defer their restart to the next one, per device. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01N76prZoUHMrHZajXq4jET1 --- .../openlogi-agent-core/src/watchers/gesture.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/openlogi-agent-core/src/watchers/gesture.rs b/crates/openlogi-agent-core/src/watchers/gesture.rs index 019b3e3d..582e4a03 100644 --- a/crates/openlogi-agent-core/src/watchers/gesture.rs +++ b/crates/openlogi-agent-core/src/watchers/gesture.rs @@ -19,7 +19,7 @@ //! the events arrive over HID++, and the bound action is synthesised the same //! way regardless. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, RwLock}; use std::thread; use std::time::{Duration, Instant}; @@ -197,18 +197,27 @@ async fn manage( }; // Stop sessions whose device disappeared or whose plan changed. // Sending on the oneshot lets the session restore its controls. + // A key stopped this tick restarts on the *next* tick, never + // this one: arming the replacement immediately could interleave + // its divert writes with the old session's restore writes on + // the same device, leaving a control un-diverted while the new + // session believes it owns it. + let mut stopping: HashSet = HashSet::new(); sessions.retain(|key, session| { let keep = want .get(key) .is_some_and(|(route, spec)| *route == session.route && *spec == session.spec); - if !keep && let Some(stop) = session.stop.take() { - let _ = stop.send(()); + if !keep { + if let Some(stop) = session.stop.take() { + let _ = stop.send(()); + } + stopping.insert(key.clone()); } keep }); accumulators.retain(|key, _| want.contains_key(key)); for (key, (route, spec)) in want { - if sessions.contains_key(&key) { + if sessions.contains_key(&key) || stopping.contains(&key) { continue; } // All sessions share one exclusive lease; acquire it with the From 87a0ec51dc9eb4aad36d92f97b8d457ffb60d65e Mon Sep 17 00:00:00 2001 From: Hiroaki Tagawa Date: Sun, 19 Jul 2026 02:27:15 +0900 Subject: [PATCH 09/19] test: cover plain divert of a single-bound gesture button (RED) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With gestures off, a single action bound to the dedicated gesture button (CID 0x00c3) has no delivery path: the button never reaches the OS hook, and the HID++ capture session only diverts it with raw-XY while it owns the gesture role. The new tests pin the intended contract — plain-divert the button when its binding leaves the default and it is not the gesture owner — and are ignore-marked until the implementation lands. --- .../openlogi-agent-core/src/capture_plan.rs | 81 +++++++++++++++++++ crates/openlogi-hid/src/gesture/tests.rs | 24 ++++++ 2 files changed, 105 insertions(+) diff --git a/crates/openlogi-agent-core/src/capture_plan.rs b/crates/openlogi-agent-core/src/capture_plan.rs index 415717d9..4c25ed73 100644 --- a/crates/openlogi-agent-core/src/capture_plan.rs +++ b/crates/openlogi-agent-core/src/capture_plan.rs @@ -90,3 +90,84 @@ pub fn plan_for_device( thumbwheel_sensitivity: config.thumbwheel_sensitivity(config_key), } } + +#[cfg(test)] +mod tests { + use openlogi_core::binding::Binding; + use openlogi_hid::reprog_controls::GESTURE_BUTTON_CID; + + use super::*; + + fn route() -> DeviceRoute { + DeviceRoute::Bolt { + receiver_uid: "cafe".into(), + slot: 2, + } + } + + #[test] + #[ignore = "RED: plain gesture-button divert not implemented yet"] + fn gestures_off_single_bound_gesture_button_is_plain_diverted() { + // The dedicated gesture button (CID 0x00c3) never reaches the OS hook, + // so with gestures off a non-default single binding on it is only + // deliverable via a plain HID++ divert. + let mut cfg = Config::default(); + cfg.disable_gestures("2b042"); + cfg.set_binding( + "2b042", + ButtonId::GestureButton, + Binding::Single(Action::CycleDpiPresets), + ); + + let plan = plan_for_device(&cfg, "2b042", route(), None); + assert!( + plan.gesture_bindings.is_empty(), + "gestures are off — no raw-XY gesture divert" + ); + assert!( + plan.divert_buttons + .contains(&(GESTURE_BUTTON_CID, ButtonId::GestureButton)), + "a single-bound gesture button must be plain-diverted, or the binding can never fire" + ); + } + + #[test] + fn gesture_owner_button_is_never_plain_diverted() { + // When the gesture button owns the gesture role, the raw-XY gesture + // divert owns CID 0x00c3 — a plain divert on top would strip raw-XY. + // (Its default Click projects to a non-default single action, so only + // the owner rule keeps it out of the plain list.) + let mut cfg = Config::default(); + cfg.set_gesture_owner("2b042", ButtonId::GestureButton); + + let plan = plan_for_device(&cfg, "2b042", route(), None); + assert!( + !plan.gesture_bindings.is_empty(), + "the gesture button owns the gesture role" + ); + assert!( + !plan + .divert_buttons + .iter() + .any(|&(cid, _)| cid == GESTURE_BUTTON_CID), + "the gesture owner is delivered via raw-XY divert, never a plain one" + ); + } + + #[test] + fn gestures_off_default_gesture_button_stays_native() { + // With gestures off and no explicit binding, the gesture button keeps + // its native HID behavior — same contract as the standard buttons. + let mut cfg = Config::default(); + cfg.disable_gestures("2b042"); + + let plan = plan_for_device(&cfg, "2b042", route(), None); + assert!( + !plan + .divert_buttons + .iter() + .any(|&(cid, _)| cid == GESTURE_BUTTON_CID), + "an unbound gesture button must not be captured" + ); + } +} diff --git a/crates/openlogi-hid/src/gesture/tests.rs b/crates/openlogi-hid/src/gesture/tests.rs index 65cdaed5..f9a0b3b2 100644 --- a/crates/openlogi-hid/src/gesture/tests.rs +++ b/crates/openlogi-hid/src/gesture/tests.rs @@ -61,6 +61,30 @@ fn a_held_gesture_commits_a_swipe_and_does_not_also_click() { ); } +#[test] +#[ignore = "RED: plain gesture-button divert not implemented yet"] +fn a_plain_diverted_gesture_button_presses_without_gesturing() { + // A gesture button diverted as a plain button (it does NOT own the gesture + // role; its single binding needs delivery) must dispatch as a button press + // only — the swipe accumulator belongs to the raw-XY gesture divert and + // must not also emit a gesture click on release. + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut acc = CaptureAccum::default(); + let buttons = [(reprog_controls::GESTURE_BUTTON_CID, ButtonId::GestureButton)]; + + handle_reprog(&mut acc, press(), &[], &buttons, &tx); + handle_reprog(&mut acc, release(), &[], &buttons, &tx); + + assert_eq!( + rx.try_recv(), + Ok(CapturedInput::ButtonPressed(ButtonId::GestureButton)) + ); + assert!( + rx.try_recv().is_err(), + "a plain-diverted gesture button must not also emit a gesture click" + ); +} + #[test] fn a_held_dpi_button_presses_once_on_the_rising_edge() { let (tx, mut rx) = mpsc::unbounded_channel(); From 7baf12cc1311f56aa9f8927c8a767e8991765efe Mon Sep 17 00:00:00 2001 From: Hiroaki Tagawa Date: Sun, 19 Jul 2026 01:35:50 +0900 Subject: [PATCH 10/19] fix(devenv): make create-dmg darwin-only create-dmg is a macOS-only package (meta.platforms = darwin), so listing it unconditionally made nixpkgs refuse to evaluate the devenv shell on Linux, breaking direnv entirely. It is only used by xtask's macOS DMG packaging, which always runs on macOS. --- devenv.nix | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/devenv.nix b/devenv.nix index 2dcfccba..5f7aeb23 100644 --- a/devenv.nix +++ b/devenv.nix @@ -25,19 +25,24 @@ in env = { GREET = "devenv"; RUSTC_WRAPPER = "sccache"; - } // pkgs.lib.optionalAttrs pkgs.stdenv.isDarwin { + } + // pkgs.lib.optionalAttrs pkgs.stdenv.isDarwin { DEVELOPER_DIR = xcodeDeveloperDir; SDKROOT = xcodeSdkRoot; }; - packages = with pkgs; [ - git - cmake - sccache - prek - create-dmg - crowdin-cli - ]; + packages = + with pkgs; + [ + git + cmake + sccache + prek + crowdin-cli + ] + # create-dmg is macOS-only (meta.platforms = darwin); an unconditional entry + # breaks evaluation of the shell on Linux. + ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ create-dmg ]; languages.rust = { enable = true; From dfb38e94decf3b403528c8bd0c1ed44c632de9c0 Mon Sep 17 00:00:00 2001 From: Hiroaki Tagawa Date: Sun, 19 Jul 2026 02:32:13 +0900 Subject: [PATCH 11/19] fix(devenv): provide the Linux GUI/hook system libraries The Linux build links a handful of system libraries the dev shell did not carry, so workspace-wide clippy/test only succeeded when the ambient user environment happened to expose them: fontconfig via pkg-config (yeslogic-fontconfig-sys, pulled in by GPUI's text system), libxcb (x11rb in openlogi-hook and GPUI's X11 backend), and libxkbcommon (GPUI keyboard handling). Add them to the Linux package set so the shell is self-contained. --- devenv.nix | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/devenv.nix b/devenv.nix index 5f7aeb23..6f186c76 100644 --- a/devenv.nix +++ b/devenv.nix @@ -42,7 +42,18 @@ in ] # create-dmg is macOS-only (meta.platforms = darwin); an unconditional entry # breaks evaluation of the shell on Linux. - ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ create-dmg ]; + ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ create-dmg ] + # The Linux build links a handful of system libraries the shell must + # provide rather than rely on whatever the ambient user environment + # happens to expose: fontconfig via pkg-config (GPUI text rendering via + # yeslogic-fontconfig-sys), libxcb (x11rb in the hook and GPUI's X11 + # backend), and libxkbcommon (GPUI keyboard handling). + ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ + pkg-config + fontconfig + xorg.libxcb + libxkbcommon + ]; languages.rust = { enable = true; From 4e42ae6213bf24bb83e09bc12cfbf62adbbc65ac Mon Sep 17 00:00:00 2001 From: Hiroaki Tagawa Date: Sun, 19 Jul 2026 02:34:48 +0900 Subject: [PATCH 12/19] fix: divert the gesture button as a plain button when it has a single binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With gestures off (or owned by an OS-hook button), a single action bound to the dedicated gesture button never fired: CID 0x00c3 is invisible to the OS hook, and the capture session only diverted it (with raw-XY) while it owned the gesture role — so the binding had no delivery path at all. This is how an MX Master 4 with gesture_owner = "Off" and GestureButton = "CycleDpiPresets" silently ignored every press. Divert the gesture button as a plain 0x1b04 button (no raw-XY) whenever its binding leaves the default and it does not own the gesture role; its press then dispatches through the existing per-device ButtonPressed path. The swipe accumulator is now gated on the raw-XY divert being armed, so a plain-diverted press cannot also emit a spurious gesture click, and arm_controls guards against a plain write ever stripping an armed raw-XY divert. The RED tests from the previous commit are un-ignored. --- .../openlogi-agent-core/src/capture_plan.rs | 11 +++- crates/openlogi-hid/src/gesture.rs | 51 ++++++++++++++----- crates/openlogi-hid/src/gesture/tests.rs | 25 ++++----- 3 files changed, 61 insertions(+), 26 deletions(-) diff --git a/crates/openlogi-agent-core/src/capture_plan.rs b/crates/openlogi-agent-core/src/capture_plan.rs index 4c25ed73..11784bfd 100644 --- a/crates/openlogi-agent-core/src/capture_plan.rs +++ b/crates/openlogi-agent-core/src/capture_plan.rs @@ -15,6 +15,7 @@ use openlogi_core::binding::{Action, ButtonId, GestureDirection, default_binding use openlogi_core::config::Config; use openlogi_hid::DeviceRoute; use openlogi_hid::gesture::DIVERTABLE_STANDARD_BUTTONS; +use openlogi_hid::reprog_controls::GESTURE_BUTTON_CID; use crate::bindings::{bindings_for, gesture_bindings_for, oshook_gestures_for}; @@ -60,8 +61,17 @@ pub fn plan_for_device( // needs to see its press to run hold+swipe detection, and diverting it // would starve the hook of events. let oshook = oshook_gestures_for(config, Some(config_key), app); + // The dedicated gesture button never reaches the OS hook, so a non-default + // single binding on it is deliverable only via a plain HID++ divert — but + // only while it does NOT own the gesture role (the raw-XY gesture divert + // owns CID 0x00c3 in that case, and `gesture_bindings` is how the watcher + // arms that divert). + let plain_gesture_button = gesture_bindings + .is_empty() + .then_some((GESTURE_BUTTON_CID, ButtonId::GestureButton)); let divert_buttons: Vec<(u16, ButtonId)> = DIVERTABLE_STANDARD_BUTTONS .into_iter() + .chain(plain_gesture_button) .filter(|(_, button)| !oshook.contains_key(button)) .filter(|(_, button)| { bindings @@ -106,7 +116,6 @@ mod tests { } #[test] - #[ignore = "RED: plain gesture-button divert not implemented yet"] fn gestures_off_single_bound_gesture_button_is_plain_diverted() { // The dedicated gesture button (CID 0x00c3) never reaches the OS hook, // so with gestures off a non-default single binding on it is only diff --git a/crates/openlogi-hid/src/gesture.rs b/crates/openlogi-hid/src/gesture.rs index 2efd350d..9cff92c5 100644 --- a/crates/openlogi-hid/src/gesture.rs +++ b/crates/openlogi-hid/src/gesture.rs @@ -99,8 +99,10 @@ pub struct CaptureSpec { /// Divert the dedicated gesture button with raw-XY (it owns the gesture /// role). pub divert_gesture_button: bool, - /// Standard buttons to divert, from [`DIVERTABLE_STANDARD_BUTTONS`], - /// filtered to those whose binding leaves the default. + /// Buttons to divert as plain presses (no raw-XY): the + /// [`DIVERTABLE_STANDARD_BUTTONS`] whose binding leaves the default, plus + /// the dedicated gesture button when it carries a non-default single + /// binding without owning the gesture role. pub divert_buttons: Vec<(u16, ButtonId)>, } @@ -110,8 +112,10 @@ pub struct CaptureSpec { /// The dedicated gesture button (raw-XY) is diverted only when /// `spec.divert_gesture_button` — i.e. it is the device's gesture owner. When /// the user moves the gesture role to an OS-hook button or turns gestures off, -/// the HID++ gesture control is left undiverted so it keeps its native behavior -/// instead of being captured-and-swallowed. The DPI/ModeShift capture and the +/// the HID++ gesture control keeps its native behavior — unless a non-default +/// single binding puts it in `spec.divert_buttons`, in which case it is +/// diverted as a plain button (the OS hook never sees CID `0x00c3`, so this is +/// the binding's only delivery path). The DPI/ModeShift capture and the /// channel-reuse slot are independent of this. /// /// Opens and holds one HID++ channel, diverts whichever of those controls the @@ -139,6 +143,7 @@ pub async fn run_capture_session( let accum = Arc::new(Mutex::new(CaptureAccum::default())); let reprog_index = armed.reprog.as_ref().map(|(_, idx)| *idx); + let gesture_diverted = armed.gesture_diverted; let thumb_index = armed.thumb.as_ref().map(|(_, idx)| *idx); let dpi_set = armed.dpi_cids.clone(); let button_set = armed.button_cids.clone(); @@ -156,7 +161,14 @@ pub async fn run_capture_session( // Recover the guard even if a prior holder panicked — the // critical section is panic-free, so the data is consistent. let mut acc = accum.lock().unwrap_or_else(PoisonError::into_inner); - handle_reprog(&mut acc, event, &dpi_set, &button_set, &sink); + handle_reprog( + &mut acc, + event, + gesture_diverted, + &dpi_set, + &button_set, + &sink, + ); return; } if let Some(idx) = thumb_index @@ -282,6 +294,12 @@ async fn arm_controls( } } for &(cid, button) in &spec.divert_buttons { + // The plan never lists CID 0x00c3 while the raw-XY gesture divert + // owns it, but guard anyway: a plain (divert, no raw-XY) write here + // would strip the raw-XY reporting armed above. + if gesture_diverted && cid == reprog_controls::GESTURE_BUTTON_CID { + continue; + } if controls.iter().any(|c| c.cid == cid && c.is_divertable()) { rc.set_cid_reporting(cid, true, false) .await @@ -370,20 +388,27 @@ async fn enumerate_controls( fn handle_reprog( acc: &mut CaptureAccum, event: RawControlEvent, + gesture_diverted: bool, dpi_cids: &[u16], button_cids: &[(u16, ButtonId)], sink: &mpsc::UnboundedSender, ) { match event { RawControlEvent::DivertedButtons(cids) => { - let gesture_held = cids.contains(&reprog_controls::GESTURE_BUTTON_CID); - if gesture_held && !acc.swipe.is_holding() { - acc.swipe.begin(); - } else if !gesture_held && acc.swipe.is_holding() { - // A press that never committed a direction is a plain click. - if acc.swipe.end() { - debug!("gesture click"); - let _ = sink.send(CapturedInput::Gesture(GestureDirection::Click)); + // The swipe accumulator belongs to the raw-XY gesture divert. When + // the gesture button is instead diverted as a plain button (it has + // a single binding but not the gesture role), its press must flow + // through the `button_cids` loop only — not also emit a click. + if gesture_diverted { + let gesture_held = cids.contains(&reprog_controls::GESTURE_BUTTON_CID); + if gesture_held && !acc.swipe.is_holding() { + acc.swipe.begin(); + } else if !gesture_held && acc.swipe.is_holding() { + // A press that never committed a direction is a plain click. + if acc.swipe.end() { + debug!("gesture click"); + let _ = sink.send(CapturedInput::Gesture(GestureDirection::Click)); + } } } diff --git a/crates/openlogi-hid/src/gesture/tests.rs b/crates/openlogi-hid/src/gesture/tests.rs index f9a0b3b2..9ea70e43 100644 --- a/crates/openlogi-hid/src/gesture/tests.rs +++ b/crates/openlogi-hid/src/gesture/tests.rs @@ -13,15 +13,16 @@ fn quick_tap_is_a_click_even_while_the_cursor_moves() { let (tx, mut rx) = mpsc::unbounded_channel(); let mut acc = CaptureAccum::default(); - handle_reprog(&mut acc, press(), &[], &[], &tx); + handle_reprog(&mut acc, press(), true, &[], &[], &tx); handle_reprog( &mut acc, RawControlEvent::RawXy { dx: 120, dy: 5 }, + true, &[], &[], &tx, ); - handle_reprog(&mut acc, release(), &[], &[], &tx); + handle_reprog(&mut acc, release(), true, &[], &[], &tx); assert_eq!( rx.try_recv(), @@ -38,12 +39,13 @@ fn a_held_gesture_commits_a_swipe_and_does_not_also_click() { let (tx, mut rx) = mpsc::unbounded_channel(); let mut acc = CaptureAccum::default(); - handle_reprog(&mut acc, press(), &[], &[], &tx); + handle_reprog(&mut acc, press(), true, &[], &[], &tx); // Pretend the button has been held well past the swipe gate. acc.swipe.backdate_hold_for_test(); handle_reprog( &mut acc, RawControlEvent::RawXy { dx: 120, dy: 5 }, + true, &[], &[], &tx, @@ -54,7 +56,7 @@ fn a_held_gesture_commits_a_swipe_and_does_not_also_click() { Ok(CapturedInput::Gesture(GestureDirection::Right)) ); - handle_reprog(&mut acc, release(), &[], &[], &tx); + handle_reprog(&mut acc, release(), true, &[], &[], &tx); assert!( rx.try_recv().is_err(), "a committed swipe must not also click on release" @@ -62,7 +64,6 @@ fn a_held_gesture_commits_a_swipe_and_does_not_also_click() { } #[test] -#[ignore = "RED: plain gesture-button divert not implemented yet"] fn a_plain_diverted_gesture_button_presses_without_gesturing() { // A gesture button diverted as a plain button (it does NOT own the gesture // role; its single binding needs delivery) must dispatch as a button press @@ -72,8 +73,8 @@ fn a_plain_diverted_gesture_button_presses_without_gesturing() { let mut acc = CaptureAccum::default(); let buttons = [(reprog_controls::GESTURE_BUTTON_CID, ButtonId::GestureButton)]; - handle_reprog(&mut acc, press(), &[], &buttons, &tx); - handle_reprog(&mut acc, release(), &[], &buttons, &tx); + handle_reprog(&mut acc, press(), false, &[], &buttons, &tx); + handle_reprog(&mut acc, release(), false, &[], &buttons, &tx); assert_eq!( rx.try_recv(), @@ -92,8 +93,8 @@ fn a_held_dpi_button_presses_once_on_the_rising_edge() { let dpi = reprog_controls::DPI_MODE_SHIFT_CIDS[0]; let down = RawControlEvent::DivertedButtons([dpi, 0, 0, 0]); - handle_reprog(&mut acc, down, &[dpi], &[], &tx); - handle_reprog(&mut acc, down, &[dpi], &[], &tx); + handle_reprog(&mut acc, down, true, &[dpi], &[], &tx); + handle_reprog(&mut acc, down, true, &[dpi], &[], &tx); assert_eq!( rx.try_recv(), @@ -113,9 +114,9 @@ fn a_dpi_button_re_presses_after_a_release() { let down = RawControlEvent::DivertedButtons([dpi, 0, 0, 0]); let up = RawControlEvent::DivertedButtons([0, 0, 0, 0]); - handle_reprog(&mut acc, down, &[dpi], &[], &tx); - handle_reprog(&mut acc, up, &[dpi], &[], &tx); - handle_reprog(&mut acc, down, &[dpi], &[], &tx); + handle_reprog(&mut acc, down, true, &[dpi], &[], &tx); + handle_reprog(&mut acc, up, true, &[dpi], &[], &tx); + handle_reprog(&mut acc, down, true, &[dpi], &[], &tx); assert_eq!( rx.try_recv(), From fc8837d9a685072dfe775f80a07c734d3d07eece Mon Sep 17 00:00:00 2001 From: Hiroaki Tagawa Date: Sun, 19 Jul 2026 11:14:52 +0900 Subject: [PATCH 13/19] refactor: extract the capture-session completion decision into on_done Behavior-preserving: replace the boolean should_rearm with a DoneAction enum so the settle-vs-rearm-vs-ignore decision for a session-completion report is one testable function. Groundwork for tracking deliberately stopped sessions until their teardown drains. --- .../src/watchers/gesture.rs | 66 ++++++++++++++----- 1 file changed, 49 insertions(+), 17 deletions(-) diff --git a/crates/openlogi-agent-core/src/watchers/gesture.rs b/crates/openlogi-agent-core/src/watchers/gesture.rs index 582e4a03..d3f0bf8e 100644 --- a/crates/openlogi-agent-core/src/watchers/gesture.rs +++ b/crates/openlogi-agent-core/src/watchers/gesture.rs @@ -124,15 +124,29 @@ struct RunningSession { epoch: u64, } -/// Whether a finished capture session should make the manager re-arm its -/// device. +/// What the manager should do with one session-completion report. +#[derive(Debug, PartialEq)] +enum DoneAction { + /// A stale report from a session the manager no longer tracks — ignore it. + Ignore, + /// The tracked session's task has fully exited: drop its entry so the next + /// tick may arm a successor. `unexpected` is true when the exit wasn't a + /// deliberate stop and the drop deserves a warning. + Remove { unexpected: bool }, +} + +/// Decide the [`DoneAction`] for a completion report carrying `done_epoch`, +/// given the session the manager currently tracks for that device (if any). /// -/// `done_epoch` identifies the session that signalled completion; `live` is the -/// session the manager currently tracks for that device. A completion only -/// warrants a respawn when it is the *current* session (not a stale one already -/// superseded by a deliberate restart, whose epoch no longer matches). -fn should_rearm(done_epoch: u64, live: Option<&RunningSession>) -> bool { - live.is_some_and(|session| session.epoch == done_epoch) +/// Only the *current* session's report settles anything; a stale epoch belongs +/// to a session already superseded by a deliberate restart. Deliberately +/// stopped sessions are dropped from the map at stop time, so any tracked +/// session's completion is an unexpected exit. +fn on_done(done_epoch: u64, live: Option<&RunningSession>) -> DoneAction { + match live { + Some(session) if session.epoch == done_epoch => DoneAction::Remove { unexpected: true }, + _ => DoneAction::Ignore, + } } /// Keep one capture session alive per online device, restarting a session when @@ -252,8 +266,10 @@ async fn manage( // at most once per `TARGET_POLL`, which paces the respawn so a // permanently failing device can't hot-loop. A stale epoch (an // already-superseded session) is a no-op. - if should_rearm(done_epoch, sessions.get(&key)) { - warn!(key, "capture session ended unexpectedly, re-arming"); + if let DoneAction::Remove { unexpected } = on_done(done_epoch, sessions.get(&key)) { + if unexpected { + warn!(key, "capture session ended unexpectedly, re-arming"); + } sessions.remove(&key); } } @@ -613,7 +629,8 @@ mod tests { ); } - fn session_with_epoch(epoch: u64) -> RunningSession { + /// A session whose stop sender is already gone (taken by a deliberate stop). + fn stopped_session_with_epoch(epoch: u64) -> RunningSession { RunningSession { route: DeviceRoute::Direct { vendor_id: 0x046d, @@ -625,23 +642,38 @@ mod tests { } } + /// A session still holding its stop sender (never asked to stop). + fn live_session_with_epoch(epoch: u64) -> RunningSession { + let (stop, _rx) = oneshot::channel(); + RunningSession { + stop: Some(stop), + ..stopped_session_with_epoch(epoch) + } + } + #[test] fn rearms_when_the_current_session_dies() { // The live session for this device ended on its own. - assert!(should_rearm(7, Some(&session_with_epoch(7)))); + assert_eq!( + on_done(7, Some(&live_session_with_epoch(7))), + DoneAction::Remove { unexpected: true } + ); } #[test] fn ignores_a_stale_session_superseded_by_a_restart() { // An older session reports completion after a deliberate restart already // bumped the epoch; re-arming would needlessly cycle the live session. - assert!(!should_rearm(6, Some(&session_with_epoch(7)))); + assert_eq!( + on_done(6, Some(&live_session_with_epoch(7))), + DoneAction::Ignore + ); } #[test] - fn ignores_a_deliberate_stop_to_idle() { - // The session was stopped on purpose (pairing took the receiver, or the - // device went away): its entry is gone, so there is nothing to re-arm. - assert!(!should_rearm(7, None)); + fn ignores_a_completion_for_an_untracked_device() { + // The session's entry is already gone (a deliberate stop to idle, or a + // device that went away): there is nothing to settle or re-arm. + assert_eq!(on_done(7, None), DoneAction::Ignore); } } From 852e60e4c0e25f8dafaf4c1ba0fb1d86c78f7a07 Mon Sep 17 00:00:00 2001 From: Hiroaki Tagawa Date: Sun, 19 Jul 2026 11:15:12 +0900 Subject: [PATCH 14/19] test: cover quiet settlement of a draining capture session (RED) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tick-local stopping guard (2cf292c) only defers a replacement by one tick: a session stopped in tick T can still be executing its divert restore when tick T+1 arms the successor, interleaving restore and arm writes on the same device. The window exists for plan changes and for post-pairing recovery alike. Pin the intended contract — a deliberately stopped session stays tracked until its task reports completion, and that report settles the key without an unexpected-exit warning — ignore- marked until the implementation lands. --- crates/openlogi-agent-core/src/watchers/gesture.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/openlogi-agent-core/src/watchers/gesture.rs b/crates/openlogi-agent-core/src/watchers/gesture.rs index d3f0bf8e..338ac69b 100644 --- a/crates/openlogi-agent-core/src/watchers/gesture.rs +++ b/crates/openlogi-agent-core/src/watchers/gesture.rs @@ -676,4 +676,17 @@ mod tests { // device that went away): there is nothing to settle or re-arm. assert_eq!(on_done(7, None), DoneAction::Ignore); } + + #[test] + #[ignore = "drain-until-done lands with the fix commit"] + fn settles_a_draining_session_quietly() { + // A deliberately stopped session stays tracked until its task — the + // control-restore writes included — actually exits, so its key cannot + // re-arm mid-restore. Its completion report frees the key without the + // unexpected-exit warning. + assert_eq!( + on_done(7, Some(&stopped_session_with_epoch(7))), + DoneAction::Remove { unexpected: false } + ); + } } From bdc7ddb2927e4da482b7b01fe01b0423c7792d7b Mon Sep 17 00:00:00 2001 From: Hiroaki Tagawa Date: Sun, 19 Jul 2026 11:18:15 +0900 Subject: [PATCH 15/19] fix: track a stopped capture session until its teardown drains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up (#419): the tick-local stopping guard deferred the replacement session by exactly one tick, so a stop whose divert-restore writes outlast TARGET_POLL could still interleave with the successor's arm writes on the same device. Keep a deliberately stopped session in the map — stop sender taken — until its task's completion report arrives, and never arm a tracked key. The report is sent after run_capture_session returns, restore included, so the successor can no longer overlap the teardown regardless of how long it takes; the same holds for the post-pairing recovery path, which stops every session the same way. --- .../src/watchers/gesture.rs | 63 ++++++++++--------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/crates/openlogi-agent-core/src/watchers/gesture.rs b/crates/openlogi-agent-core/src/watchers/gesture.rs index 338ac69b..1b41f49f 100644 --- a/crates/openlogi-agent-core/src/watchers/gesture.rs +++ b/crates/openlogi-agent-core/src/watchers/gesture.rs @@ -19,7 +19,7 @@ //! the events arrive over HID++, and the bound action is synthesised the same //! way regardless. -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap}; use std::sync::{Arc, RwLock}; use std::thread; use std::time::{Duration, Instant}; @@ -116,10 +116,13 @@ fn spec_for(plan: &DeviceCapturePlan) -> CaptureSpec { } } -/// One live capture session tracked by the manager. +/// One capture session tracked by the manager. struct RunningSession { route: DeviceRoute, spec: CaptureSpec, + /// Present while the session runs; taken to request a stop. `None` means + /// the session is draining — deliberately stopped, but its task (and the + /// control-restore writes in its teardown) may still be in flight. stop: Option>, epoch: u64, } @@ -139,12 +142,15 @@ enum DoneAction { /// given the session the manager currently tracks for that device (if any). /// /// Only the *current* session's report settles anything; a stale epoch belongs -/// to a session already superseded by a deliberate restart. Deliberately -/// stopped sessions are dropped from the map at stop time, so any tracked -/// session's completion is an unexpected exit. +/// to a session already superseded. A tracked session whose stop sender is +/// gone was stopped deliberately and is merely draining — its report frees the +/// key quietly. One still holding its stop sender exited on its own and +/// warrants a warning alongside the re-arm. fn on_done(done_epoch: u64, live: Option<&RunningSession>) -> DoneAction { match live { - Some(session) if session.epoch == done_epoch => DoneAction::Remove { unexpected: true }, + Some(session) if session.epoch == done_epoch => DoneAction::Remove { + unexpected: session.stop.is_some(), + }, _ => DoneAction::Ignore, } } @@ -165,8 +171,10 @@ async fn manage( // Capture sessions run as detached tasks, so an unexpected exit (a transient // HID++ read error, a sleep-wake glitch, brief radio loss) would otherwise go // unnoticed. Each session reports its completion here, tagged with its device - // key and the epoch it started under, so a dead *current* session re-arms on - // the next tick while stale completions are ignored. + // key and the epoch it started under: a dead *current* session re-arms on the + // next tick, a deliberately stopped one merely frees its key for the + // replacement once its teardown has drained, and stale completions are + // ignored (see `on_done`). let (done_tx, mut done_rx) = mpsc::unbounded_channel::<(String, u64)>(); let mut epoch: u64 = 0; // The capture-vs-pairing arbiter hands out one exclusive lease. All session @@ -211,27 +219,24 @@ async fn manage( }; // Stop sessions whose device disappeared or whose plan changed. // Sending on the oneshot lets the session restore its controls. - // A key stopped this tick restarts on the *next* tick, never - // this one: arming the replacement immediately could interleave - // its divert writes with the old session's restore writes on - // the same device, leaving a control un-diverted while the new - // session believes it owns it. - let mut stopping: HashSet = HashSet::new(); - sessions.retain(|key, session| { + // A stopped session stays tracked — stop sender taken — until + // its task reports completion below, and a tracked key is never + // re-armed: arming the replacement while the old task may still + // be mid-restore could interleave its divert writes with the + // restore writes on the same device, leaving a control + // un-diverted while the new session believes it owns it, + // however many ticks the restore takes. + for (key, session) in &mut sessions { let keep = want .get(key) .is_some_and(|(route, spec)| *route == session.route && *spec == session.spec); - if !keep { - if let Some(stop) = session.stop.take() { - let _ = stop.send(()); - } - stopping.insert(key.clone()); + if !keep && let Some(stop) = session.stop.take() { + let _ = stop.send(()); } - keep - }); + } accumulators.retain(|key, _| want.contains_key(key)); for (key, (route, spec)) in want { - if sessions.contains_key(&key) || stopping.contains(&key) { + if sessions.contains_key(&key) { continue; } // All sessions share one exclusive lease; acquire it with the @@ -261,11 +266,12 @@ async fn manage( } } Some((key, done_epoch)) = done_rx.recv() => { - // A capture session ended on its own. Dropping its entry lets the - // next tick start a fresh session for that device; the tick fires - // at most once per `TARGET_POLL`, which paces the respawn so a - // permanently failing device can't hot-loop. A stale epoch (an - // already-superseded session) is a no-op. + // A capture session's task has fully exited — its restore writes + // included — so dropping its entry lets the next tick start a + // fresh session for that device; the tick fires at most once per + // `TARGET_POLL`, which paces the respawn so a permanently failing + // device can't hot-loop. A stale epoch (an already-superseded + // session) is a no-op. if let DoneAction::Remove { unexpected } = on_done(done_epoch, sessions.get(&key)) { if unexpected { warn!(key, "capture session ended unexpectedly, re-arming"); @@ -678,7 +684,6 @@ mod tests { } #[test] - #[ignore = "drain-until-done lands with the fix commit"] fn settles_a_draining_session_quietly() { // A deliberately stopped session stays tracked until its task — the // control-restore writes included — actually exits, so its key cannot From fd8ee1db2601f6ada819bf69bea3e611aeac6761 Mon Sep 17 00:00:00 2001 From: Hiroaki Tagawa Date: Sun, 19 Jul 2026 11:31:56 +0900 Subject: [PATCH 16/19] test: cover capture-plan republish on a foreground-app switch (RED) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit set_current_app refreshes the OS-hook maps but not the shared capture plans, whose binding maps and divert sets are baked per-app at build time and read by HID++ dispatch at event time. After an app switch, every diverted button therefore keeps firing the previous app's actions, and a button that should fall back to native in the new app stays diverted. Pin the intended contract — a foreground-app change republishes the plans with the new app's overlay — ignore-marked until the implementation lands. --- .../openlogi-agent-core/src/orchestrator.rs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index 428444c0..a1017c1c 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -587,6 +587,7 @@ mod tests { AgentDevice, InventoryHealth, Orchestrator, configured_wheel_mode, plan_reapply, reapply_targets, }; + use openlogi_core::binding::{Action, ButtonId}; use openlogi_core::config::{Config, ScrollResolution}; use openlogi_core::device::Capabilities; use openlogi_hid::DeviceRoute; @@ -749,6 +750,41 @@ mod tests { /// forwards completed enumerations, so "checked and found nothing" must not /// be reported as "still scanning" — that's the whole distinction the /// health exists to carry. + /// The published capture plan's Back binding for the first device, if any. + fn published_back_binding(orch: &Orchestrator) -> Option { + orch.shared.capture_plans.read().ok().and_then(|plans| { + plans + .first() + .and_then(|plan| plan.bindings.get(&ButtonId::Back).cloned()) + }) + } + + #[test] + #[ignore = "capture-plan republish on app switch lands with the fix commit"] + fn app_switch_republishes_capture_plans() { + // HID++ dispatch reads `plan.bindings` at event time, so a + // foreground-app change must republish the capture plans — their + // binding maps and divert sets are per-app effective — or every + // diverted button keeps firing the previous app's actions. + let mut config = Config::default(); + config.set_per_app_binding( + "a", + "com.example.editor", + ButtonId::Back, + Some(Action::Undo), + ); + let mut orch = Orchestrator::new(config); + orch.devices = vec![dev("a", 1, true)]; + orch.rebuild(); + assert_ne!( + published_back_binding(&orch), + Some(Action::Undo), + "no per-app overlay while no app is in front" + ); + orch.set_current_app(Some("com.example.editor".into())); + assert_eq!(published_back_binding(&orch), Some(Action::Undo)); + } + #[test] fn empty_refresh_marks_inventory_ready() { let mut orch = Orchestrator::new(Config::default()); From 5bd25e22b4d406fee31a167a1c6240183f577a0a Mon Sep 17 00:00:00 2001 From: Hiroaki Tagawa Date: Sun, 19 Jul 2026 11:32:35 +0900 Subject: [PATCH 17/19] fix: republish the capture plans on a foreground-app switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up (#419): set_current_app only rewrote the OS-hook maps, but the capture plans bake the foreground app into their binding maps and divert sets at build time, so HID++ dispatch kept serving the previous app's overlay — a diverted button fired its stale binding and a button due to fall back to native stayed diverted. Route the app switch through publish_device_runtime, the same single publish point the inventory paths use, so the plans (and the watcher sessions diffing them) always reflect the app in front. DPI cycles ride along unchanged: presets are not app-scoped, so the rebuild preserves every live index. --- crates/openlogi-agent-core/src/orchestrator.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index a1017c1c..237c9ac6 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -404,11 +404,13 @@ impl Orchestrator { self.config.app_settings.launch_at_login } - /// Foreground-app change → re-overlay per-app bindings on the hook maps (DPI - /// and the dedicated HID++ gesture map are not app-scoped, so they're untouched). - /// Both hook maps are recomputed: a per-app override of the gesture owner - /// turns it into a single action for that app, dropping it from the OS-hook - /// gesture set — so the gesture map is app-scoped too. + /// Foreground-app change → re-overlay per-app bindings on the hook maps and + /// republish the capture plans, whose binding maps and divert sets are + /// per-app effective too (HID++ dispatch reads them at event time). Both + /// hook maps are recomputed: a per-app override of the gesture owner turns + /// it into a single action for that app, dropping it from the OS-hook + /// gesture set — so the gesture map is app-scoped too. The dedicated HID++ + /// gesture map is not app-scoped and stays untouched. pub fn set_current_app(&mut self, bundle: Option) { if bundle == self.current_app { return; @@ -419,6 +421,7 @@ impl Orchestrator { self.hook_maps_for(self.current_key(), self.current_app.as_deref()), "hook_maps", ); + self.publish_device_runtime(); } /// Replace the config (after `config.toml` changed) and rebuild everything. @@ -760,7 +763,6 @@ mod tests { } #[test] - #[ignore = "capture-plan republish on app switch lands with the fix commit"] fn app_switch_republishes_capture_plans() { // HID++ dispatch reads `plan.bindings` at event time, so a // foreground-app change must republish the capture plans — their From efcf0a3ca1411271f10b71fbd4dedeca03175d99 Mon Sep 17 00:00:00 2001 From: Hiroaki Tagawa Date: Sun, 19 Jul 2026 11:46:21 +0900 Subject: [PATCH 18/19] fix(gui): clear the wheel-sensitivity override when set to the app-wide default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up (#419): the per-device slider always stored Some(value), so a device that once touched the slider was pinned forever — even at the default value — and silently stopped following later Settings → General changes. The slider is the device's only sensitivity control, so committing the app-wide default now clears the override (back to following the default) and anything else pins it. The no-op guard compares the stored override rather than the effective value, so dragging an existing override onto the default actually clears it. --- crates/openlogi-gui/src/state.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/openlogi-gui/src/state.rs b/crates/openlogi-gui/src/state.rs index 85c5eda5..2f93a130 100644 --- a/crates/openlogi-gui/src/state.rs +++ b/crates/openlogi-gui/src/state.rs @@ -1050,18 +1050,29 @@ impl AppState { } /// Set `key`'s per-device thumb-wheel sensitivity override (clamped to the - /// valid range) and persist it. The agent picks it up through the reloaded - /// capture plans. No-op when unchanged. + /// valid range) and persist it. Committing the app-wide default *clears* + /// the override — the slider is the device's only sensitivity control, so + /// landing on the default is the "no override" gesture, and the device + /// goes back to following Settings → General instead of pinning today's + /// default forever. The agent picks the change up through the reloaded + /// capture plans. No-op when the stored override would not change. pub fn set_device_thumbwheel_sensitivity(&mut self, key: &str, sensitivity: i32) { let sensitivity = sensitivity.clamp( openlogi_core::config::MIN_THUMBWHEEL_SENSITIVITY, openlogi_core::config::MAX_THUMBWHEEL_SENSITIVITY, ); - if self.config.thumbwheel_sensitivity(key) == sensitivity { + let override_value = + (sensitivity != self.config.app_settings.thumbwheel_sensitivity).then_some(sensitivity); + let stored = self + .config + .devices + .get(key) + .and_then(|d| d.thumbwheel_sensitivity); + if stored == override_value { return; } self.config - .set_device_thumbwheel_sensitivity(key, Some(sensitivity)); + .set_device_thumbwheel_sensitivity(key, override_value); self.persist_and_reload("device thumbwheel sensitivity"); } From 9cbf4a5177fe4bc16fc34b26d7232d394a76c795 Mon Sep 17 00:00:00 2001 From: Hiroaki Tagawa Date: Sun, 19 Jul 2026 11:58:32 +0900 Subject: [PATCH 19/19] fix(hid): only clear the capture-channel slot while it is still ours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up (#419): the shared write-reuse slot is a single last-writer-wins cell, so with one session per device a stopping session's unconditional clear could evict the channel a sibling session published after it — silently demoting that device's DPI/SmartShift writes to the fresh-open slow path. Guard the teardown clear with an Arc identity check so a session only removes its own channel. --- crates/openlogi-hid/src/gesture.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/openlogi-hid/src/gesture.rs b/crates/openlogi-hid/src/gesture.rs index 9cff92c5..c207d12d 100644 --- a/crates/openlogi-hid/src/gesture.rs +++ b/crates/openlogi-hid/src/gesture.rs @@ -195,7 +195,16 @@ pub async fn run_capture_session( let _ = shutdown.await; drop(listener); - if let Ok(mut slot) = channel_slot.write() { + // The slot is one last-writer-wins cell shared by every session, so a + // sibling may have published its own channel after ours. Clear it only + // while it still holds *this* session's channel — evicting the sibling's + // would silently demote its DPI/SmartShift writes to the fresh-open slow + // path. + if let Ok(mut slot) = channel_slot.write() + && slot + .as_ref() + .is_some_and(|shared| Arc::ptr_eq(shared.channel(), &chan)) + { *slot = None; } armed.disarm().await;