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..11784bfd --- /dev/null +++ b/crates/openlogi-agent-core/src/capture_plan.rs @@ -0,0 +1,182 @@ +//! 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 openlogi_hid::reprog_controls::GESTURE_BUTTON_CID; + +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. 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. +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); + // 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 + .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, + 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] + 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-agent-core/src/dpi.rs b/crates/openlogi-agent-core/src/dpi.rs index 0c266643..2ace9a62 100644 --- a/crates/openlogi-agent-core/src/dpi.rs +++ b/crates/openlogi-agent-core/src/dpi.rs @@ -1,7 +1,40 @@ //! 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) + } + + /// 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 /// implement DPI preset cycling and direct preset selection actions. /// @@ -47,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 27234f1d..f3854731 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,29 @@ 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.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/lib.rs b/crates/openlogi-agent-core/src/lib.rs index 435d848c..743c1d25 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; @@ -18,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 a3c26fdd..237c9ac6 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,13 +18,14 @@ 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; 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 @@ -55,8 +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 thumbwheel_sensitivity: 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. Carries each + /// device's effective thumb-wheel sensitivity. + pub capture_plans: SharedCapturePlans, 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. @@ -106,10 +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())), - thumbwheel_sensitivity: Arc::new(AtomicI32::new( - config.app_settings.thumbwheel_sensitivity, - )), + dpi_cycle: Arc::new(RwLock::new(DpiCycles::default())), + capture_plans: Arc::new(RwLock::new(Vec::new())), capture_channel: Arc::new(RwLock::new(None)), receiver_access: ReceiverAccess::default(), }; @@ -139,15 +140,17 @@ 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 /// `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), @@ -169,20 +172,75 @@ impl Orchestrator { gesture_bindings_for(&self.config, key), "gesture_bindings", ); + 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.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.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, + /// 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 && self.config.device_enabled(&dev.config_key)) + { + 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 + .iter() + .filter(|dev| dev.online && self.config.device_enabled(&dev.config_key)) + .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 +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. + // 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; + self.publish_device_runtime(); return; } self.devices = devices; @@ -243,6 +305,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; }; @@ -338,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; @@ -353,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. @@ -521,6 +590,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; @@ -683,6 +753,40 @@ 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] + 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()); diff --git a/crates/openlogi-agent-core/src/watchers/gesture.rs b/crates/openlogi-agent-core/src/watchers/gesture.rs index 80659682..1b41f49f 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,30 +19,27 @@ //! the events arrive over HID++, and the bound action is synthesised the same //! way regardless. -use std::collections::BTreeMap; -use std::sync::atomic::{AtomicI32, Ordering}; +use std::collections::{BTreeMap, HashMap}; use std::sync::{Arc, RwLock}; use std::thread; 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::DpiCycles; +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. 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`). @@ -75,11 +73,9 @@ 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, - dpi_cycle: Arc>, + capture_plans: SharedCapturePlans, + dpi_cycle: Arc>, capture_channel: CaptureChannel, - thumbwheel_sensitivity: ThumbwheelSensitivity, receiver_access: ReceiverAccess, ) { thread::spawn(move || { @@ -94,186 +90,250 @@ pub fn spawn( } }; runtime.block_on(manage( - hook_maps, - gesture_bindings, + capture_plans, dpi_cycle, capture_channel, - thumbwheel_sensitivity, receiver_access, )); }); } -/// 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: 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) -> CaptureSpec { + CaptureSpec { + capture_thumbwheel: thumbwheel_armed(plan), + 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 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, +} + +/// 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_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 +/// Only the *current* session's report settles anything; a stale epoch belongs +/// 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: session.stop.is_some(), + }, + _ => DoneAction::Ignore, + } } -/// 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, - dpi_cycle: Arc>, + 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: 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 + // 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; + let want: HashMap = + if receiver_access.pairing_requested() { + HashMap::new() + } else { + capture_plans + .read() + .map(|plans| { + plans + .iter() + .map(|plan| { + ( + plan.config_key.clone(), + (plan.route.clone(), spec_for(plan)), + ) + }) + .collect() + }) + .unwrap_or_default() + }; + // Stop sessions whose device disappeared or whose plan changed. + // Sending on the oneshot lets the session restore its controls. + // 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 && let Some(stop) = session.stop.take() { + let _ = stop.send(()); + } } - if let Some((route, capture_thumbwheel, divert_gesture_button)) = want { - let Some(receiver_lease) = receiver_access.try_acquire_for_capture() else { - current = None; + 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'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"); + } + 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 +367,38 @@ 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, - dpi_cycle: &Arc>, + 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, Some(key), 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, Some(key), capture); } else { - debug!(?button, "HID++ button with no binding — ignored"); + debug!(key, ?button, "HID++ button with no binding — ignored"); } } CapturedInput::Scroll(rotation) => { @@ -350,17 +409,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 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(); match advance(dir, &action, magnitude, sensitivity, Instant::now()) { WheelOutput::Idle => {} @@ -368,8 +424,8 @@ fn dispatch( openlogi_inject::post_horizontal_scroll(lines); } WheelOutput::FireAction => { - debug!(?button, action = %action.label(), "thumb wheel → action"); - hook_runtime::dispatch_action(&action, dpi_cycle, capture); + debug!(key, ?button, action = %action.label(), "thumb wheel → action"); + hook_runtime::dispatch_action(&action, dpi_cycle, Some(key), capture); } } } @@ -579,23 +635,63 @@ mod tests { ); } + /// 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, + product_id: 0xc548, + }, + spec: CaptureSpec::default(), + stop: None, + epoch, + } + } + + /// 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_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_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, 7, true)); + 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 no - // device is targeted): no target means there is nothing to re-arm. - assert!(!should_rearm(7, 7, false)); + 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); + } + + #[test] + 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 } + ); } } diff --git a/crates/openlogi-agent/src/main.rs b/crates/openlogi-agent/src/main.rs index b5e98729..45a0626b 100644 --- a/crates/openlogi-agent/src/main.rs +++ b/crates/openlogi-agent/src/main.rs @@ -156,11 +156,9 @@ 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(), shared.receiver_access.clone(), ); diff --git a/crates/openlogi-agent/src/pairing.rs b/crates/openlogi-agent/src/pairing.rs index ef365590..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,8 +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())), - thumbwheel_sensitivity: Arc::new(0.into()), + dpi_cycle: Arc::new(RwLock::new(DpiCycles::default())), + capture_plans: Arc::new(RwLock::new(Vec::new())), 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..07323430 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -563,6 +563,44 @@ impl Config { .or_default() .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] + 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..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 @@ -101,6 +107,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 @@ -115,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( @@ -160,9 +206,13 @@ struct RawDeviceConfig { #[serde(default)] smartshift: Option, #[serde(default)] + thumbwheel_sensitivity: Option, + #[serde(default)] invert_scroll: bool, #[serde(default)] scroll_resolution: Option, + #[serde(default = "default_true")] + enabled: bool, } impl From for DeviceConfig { @@ -197,6 +247,7 @@ impl From for DeviceConfig { } DeviceConfig { + enabled: raw.enabled, gesture_owner: raw.gesture_owner, identity: raw.identity, bindings, @@ -205,6 +256,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/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/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..2f93a130 100644 --- a/crates/openlogi-gui/src/state.rs +++ b/crates/openlogi-gui/src/state.rs @@ -1026,9 +1026,60 @@ 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. + /// 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] + 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. 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, + ); + 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, override_value); + 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, 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.; diff --git a/crates/openlogi-hid/src/gesture.rs b/crates/openlogi-hid/src/gesture.rs index 1783494b..c207d12d 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), } @@ -75,18 +76,47 @@ 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, + /// 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)>, +} + +/// 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 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 /// device exposes, and listens. Returns once `shutdown` fires (or its sender is @@ -94,8 +124,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, @@ -104,13 +133,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. @@ -120,8 +143,10 @@ 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(); let listener = chan.add_msg_listener_guarded({ let accum = Arc::clone(&accum); let sink = sink.clone(); @@ -136,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, &sink); + handle_reprog( + &mut acc, + event, + gesture_diverted, + &dpi_set, + &button_set, + &sink, + ); return; } if let Some(idx) = thumb_index @@ -156,13 +188,23 @@ 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" ); 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; @@ -179,6 +221,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)>, @@ -197,6 +242,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"); @@ -204,16 +255,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` and the wheel reports a single tap — the thumb -/// wheel over `0x2150`. The root-feature lookup mirrors `write::open_feature`, +/// 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 @@ -222,6 +272,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) @@ -233,7 +284,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()) @@ -251,11 +302,25 @@ async fn arm_controls( dpi_cids.push(cid); } } + 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 + .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) @@ -273,23 +338,27 @@ 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() { + 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, }) } @@ -328,19 +397,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)); + } } } @@ -349,6 +426,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..9ea70e43 100644 --- a/crates/openlogi-hid/src/gesture/tests.rs +++ b/crates/openlogi-hid/src/gesture/tests.rs @@ -13,14 +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(), @@ -37,12 +39,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(), 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, ); @@ -52,13 +56,36 @@ 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" ); } +#[test] +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(), false, &[], &buttons, &tx); + handle_reprog(&mut acc, release(), false, &[], &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(); @@ -66,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(), @@ -87,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(), 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. //! 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)) diff --git a/devenv.nix b/devenv.nix index 2dcfccba..6f186c76 100644 --- a/devenv.nix +++ b/devenv.nix @@ -25,19 +25,35 @@ 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 ] + # 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;