Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f1f20da
fix: divert thumbwheel for rotation rebinds even without single-tap c…
tagawa0525 Jul 18, 2026
e0141f9
docs: update remaining doc comments to the broadened divert trigger
tagawa0525 Jul 18, 2026
b93fd2c
fix: only grab Logitech mice in the Linux evdev hook
tagawa0525 Jul 18, 2026
09d9a6a
feat: per-device HID++ capture with plan-driven sessions
tagawa0525 Jul 18, 2026
be6f72d
feat: per-device thumbwheel sensitivity and DPI cycle state
tagawa0525 Jul 18, 2026
e8d6323
feat: per-device manage toggle, shown as the gallery ring
tagawa0525 Jul 18, 2026
06560db
fix: publish DPI cycles on the inventory fast path, read-lock SmartSh…
tagawa0525 Jul 18, 2026
afee9c9
fix: never arm a replacement session in the tick that stopped its pre…
tagawa0525 Jul 18, 2026
87a0ec5
test: cover plain divert of a single-bound gesture button (RED)
tagawa0525 Jul 18, 2026
7baf12c
fix(devenv): make create-dmg darwin-only
tagawa0525 Jul 18, 2026
dfb38e9
fix(devenv): provide the Linux GUI/hook system libraries
tagawa0525 Jul 18, 2026
4e42ae6
fix: divert the gesture button as a plain button when it has a single…
tagawa0525 Jul 18, 2026
fc8837d
refactor: extract the capture-session completion decision into on_done
tagawa0525 Jul 19, 2026
852e60e
test: cover quiet settlement of a draining capture session (RED)
tagawa0525 Jul 19, 2026
bdc7ddb
fix: track a stopped capture session until its teardown drains
tagawa0525 Jul 19, 2026
fd8ee1d
test: cover capture-plan republish on a foreground-app switch (RED)
tagawa0525 Jul 19, 2026
5bd25e2
fix: republish the capture plans on a foreground-app switch
tagawa0525 Jul 19, 2026
efcf0a3
fix(gui): clear the wheel-sensitivity override when set to the app-wi…
tagawa0525 Jul 19, 2026
9cbf4a5
fix(hid): only clear the capture-channel slot while it is still ours
tagawa0525 Jul 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions crates/openlogi-agent-core/src/capture_plan.rs
Original file line number Diff line number Diff line change
@@ -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<ButtonId, Action>,
/// Per-direction map when the dedicated HID++ gesture button owns the
/// gesture role on this device; empty otherwise.
pub gesture_bindings: BTreeMap<GestureDirection, Action>,
/// 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<RwLock<Vec<DeviceCapturePlan>>>;

/// 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"
);
}
}
71 changes: 71 additions & 0 deletions crates/openlogi-agent-core/src/dpi.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
/// One cycle state per online device, keyed by config key.
pub by_key: HashMap<String, DpiCycleState>,
}

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<DeviceRoute> {
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.
///
Expand Down Expand Up @@ -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())
);
}
}
21 changes: 12 additions & 9 deletions crates/openlogi-agent-core/src/hook_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<RwLock<DpiCycleState>>,
dpi_cycle: Arc<RwLock<DpiCycles>>,
capture: CaptureChannel,
monitor: SharedEventMonitor,
) -> Option<Hook> {
Expand Down Expand Up @@ -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;
Expand All @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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<RwLock<DpiCycleState>>,
dpi_cycle: &Arc<RwLock<DpiCycles>>,
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;
Expand Down
3 changes: 2 additions & 1 deletion crates/openlogi-agent-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//! without linking gpui.

pub mod bindings;
pub mod capture_plan;
pub mod device_order;
mod dpi;
pub mod event_monitor;
Expand All @@ -18,4 +19,4 @@ pub mod receiver_access;
pub mod transport;
pub mod watchers;

pub use dpi::DpiCycleState;
pub use dpi::{DpiCycleState, DpiCycles};
Loading