Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ Things OpenLogi does that Options+ won't:
user unit, and `.deb` / `.rpm` / `.pkg.tar.zst` packages.
- **Move the Gesture Button.** Pick which physical button owns the gesture
role — the dedicated Gesture Button, middle, back, or forward — with per-direction swipe
bindings, or turn gestures off entirely. Options+ pins the gesture role to
the dedicated Gesture Button.
bindings. Turn OpenLogi gestures off while preserving the control's native
action, or disable the dedicated control entirely. Options+ pins the gesture
role to the dedicated Gesture Button.
- **Keep config in plain text.** Everything is one TOML file you can read,
diff, version-control, and copy between machines.
- **Script it.** A real CLI: device inventory, asset prefetch, and on-device
Expand Down
10 changes: 9 additions & 1 deletion crates/openlogi-agent-core/src/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
//! (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::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::{Arc, RwLock};

use openlogi_core::config::{Config, ScrollResolution};
Expand Down Expand Up @@ -55,6 +55,9 @@ pub struct SharedRuntime {
/// gesture watcher for the thumb-wheel/DPI-button single actions.
pub hook_maps: SharedHookMaps,
pub gesture_bindings: GestureBindings,
/// Whether the dedicated gesture control should be diverted and discarded
/// without enabling raw-XY motion.
pub gesture_button_disabled: Arc<AtomicBool>,
pub dpi_cycle: Arc<RwLock<DpiCycleState>>,
pub thumbwheel_sensitivity: Arc<AtomicI32>,
pub capture_channel: CaptureChannel,
Expand Down Expand Up @@ -106,6 +109,7 @@ impl Orchestrator {
let shared = SharedRuntime {
hook_maps: Arc::new(RwLock::new(HookMaps::default())),
gesture_bindings: Arc::new(RwLock::new(BTreeMap::new())),
gesture_button_disabled: Arc::new(AtomicBool::new(false)),
dpi_cycle: Arc::new(RwLock::new(DpiCycleState::default())),
thumbwheel_sensitivity: Arc::new(AtomicI32::new(
config.app_settings.thumbwheel_sensitivity,
Expand Down Expand Up @@ -164,6 +168,10 @@ impl Orchestrator {
self.hook_maps_for(key, self.current_app.as_deref()),
"hook_maps",
);
self.shared.gesture_button_disabled.store(
key.is_some_and(|key| self.config.gesture_button_disabled(key)),
Ordering::Relaxed,
);
write_value(
&self.shared.gesture_bindings,
gesture_bindings_for(&self.config, key),
Expand Down
38 changes: 24 additions & 14 deletions crates/openlogi-agent-core/src/watchers/gesture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@
//! way regardless.

use std::collections::BTreeMap;
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
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::{CaptureChannel, CapturedInput, DeviceRoute, run_capture_session};
use openlogi_hid::{
CaptureChannel, CapturedInput, DeviceRoute, GestureButtonMode, run_capture_session,
};
use tokio::sync::{mpsc, oneshot};
use tracing::{debug, warn};

Expand Down Expand Up @@ -77,6 +79,7 @@ fn action_threshold(sensitivity: i32) -> i32 {
pub fn spawn(
hook_maps: SharedHookMaps,
gesture_bindings: GestureBindings,
gesture_button_disabled: Arc<AtomicBool>,
dpi_cycle: Arc<RwLock<DpiCycleState>>,
capture_channel: CaptureChannel,
thumbwheel_sensitivity: ThumbwheelSensitivity,
Expand All @@ -96,6 +99,7 @@ pub fn spawn(
runtime.block_on(manage(
hook_maps,
gesture_bindings,
gesture_button_disabled,
dpi_cycle,
capture_channel,
thumbwheel_sensitivity,
Expand Down Expand Up @@ -147,14 +151,15 @@ fn should_rearm(done_epoch: u64, live_epoch: u64, has_target: bool) -> bool {
async fn manage(
hook_maps: SharedHookMaps,
gesture_bindings: GestureBindings,
gesture_button_disabled: Arc<AtomicBool>,
dpi_cycle: Arc<RwLock<DpiCycleState>>,
capture_channel: CaptureChannel,
thumbwheel_sensitivity: ThumbwheelSensitivity,
receiver_access: ReceiverAccess,
) {
let (tx, mut rx) = mpsc::unbounded_channel::<CapturedInput>();
// (route, capture_thumbwheel, divert_gesture_button)
let mut current: Option<(DeviceRoute, bool, bool)> = None;
// (route, capture_thumbwheel, gesture-button handling)
let mut current: Option<(DeviceRoute, bool, GestureButtonMode)> = None;
let mut stop: Option<oneshot::Sender<()>> = None;
let mut ticker = tokio::time::interval(TARGET_POLL);
let mut accumulators = WheelAccumulators::default();
Expand Down Expand Up @@ -191,17 +196,22 @@ async fn manage(
} 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());
// A non-empty gesture map requests raw-XY gesture capture.
// The explicit Disabled flag instead requests plain diversion
// so firmware cannot fire its native action. Re-evaluated each
// tick, so ReloadConfig restarts the session accordingly.
let gesture_mode = if gesture_bindings.read().is_ok_and(|g| !g.is_empty()) {
GestureButtonMode::Gestures
} else if gesture_button_disabled.load(Ordering::Relaxed) {
GestureButtonMode::Disabled
} else {
GestureButtonMode::Native
};
target.map(|t| {
(
t,
thumbwheel_armed(&hook_maps, sensitivity),
divert_gesture,
gesture_mode,
)
})
};
Expand All @@ -218,12 +228,12 @@ async fn manage(
current = None;
continue;
}
if let Some((route, capture_thumbwheel, divert_gesture_button)) = want {
if let Some((route, capture_thumbwheel, gesture_button_mode)) = want {
let Some(receiver_lease) = receiver_access.try_acquire_for_capture() else {
current = None;
continue;
};
current = Some((route.clone(), capture_thumbwheel, divert_gesture_button));
current = Some((route.clone(), capture_thumbwheel, gesture_button_mode));
let (stop_tx, stop_rx) = oneshot::channel();
let sink = tx.clone();
let slot = Arc::clone(&capture_channel);
Expand All @@ -235,7 +245,7 @@ async fn manage(
if let Err(e) = run_capture_session(
route,
capture_thumbwheel,
divert_gesture_button,
gesture_button_mode,
sink,
stop_rx,
slot,
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-agent/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ async fn run(config: Config) {
watchers::gesture::spawn(
shared.hook_maps.clone(),
shared.gesture_bindings.clone(),
shared.gesture_button_disabled.clone(),
shared.dpi_cycle.clone(),
shared.capture_channel.clone(),
shared.thumbwheel_sensitivity.clone(),
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-agent/src/pairing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ mod tests {
SharedRuntime {
hook_maps: Arc::new(RwLock::new(HookMaps::default())),
gesture_bindings: Arc::new(RwLock::new(BTreeMap::new())),
gesture_button_disabled: Arc::new(false.into()),
dpi_cycle: Arc::new(RwLock::new(DpiCycleState::default())),
thumbwheel_sensitivity: Arc::new(0.into()),
capture_channel: Arc::new(RwLock::new(None)),
Expand Down
45 changes: 44 additions & 1 deletion crates/openlogi-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ impl Config {
return Some(ButtonId::GestureButton);
};
match device.gesture_owner {
Some(GestureOwner::Off) => None,
Some(GestureOwner::Off | GestureOwner::Disabled) => None,
Some(GestureOwner::Button(id)) => Some(id),
None => Self::infer_gesture_owner(&device.bindings),
}
Expand Down Expand Up @@ -351,6 +351,25 @@ impl Config {
.gesture_owner = Some(GestureOwner::Off);
}

/// Disable the dedicated gesture control completely. Unlike
/// [`Self::disable_gestures`], this asks the runtime to divert and discard
/// the control's button reports so its native firmware action cannot fire.
pub fn disable_gesture_button(&mut self, device_key: &str) {
self.devices
.entry(device_key.to_string())
.or_default()
.gesture_owner = Some(GestureOwner::Disabled);
}

/// Whether the dedicated gesture control is explicitly disabled and should
/// be diverted without raw-XY reporting.
#[must_use]
pub fn gesture_button_disabled(&self, device_key: &str) -> bool {
self.devices
.get(device_key)
.is_some_and(|device| device.gesture_owner == Some(GestureOwner::Disabled))
}

/// Resolve the effective binding map for `device_key`, overlaying the
/// per-app entry for `bundle_id` (if any) on top of the global per-device
/// `bindings`. A per-app override replaces the whole button with a
Expand Down Expand Up @@ -1391,6 +1410,30 @@ Back = \"BrowserBack\"
}
}

#[test]
fn disabled_gesture_button_is_distinct_from_native_off_and_preserves_maps() {
let mut cfg = Config::default();
cfg.set_gesture_direction(
"2b042",
ButtonId::GestureButton,
GestureDirection::Up,
Action::Copy,
);
cfg.disable_gesture_button("2b042");

assert_eq!(cfg.gesture_owner("2b042"), None);
assert!(cfg.gesture_button_disabled("2b042"));
assert!(matches!(
cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
Some(Binding::Gesture(map)) if map.get(&GestureDirection::Up) == Some(&Action::Copy)
));

let parsed = write_and_read(&cfg);
assert!(parsed.gesture_button_disabled("2b042"));
let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(body.contains("gesture_owner = \"Disabled\""), "got: {body}");
}

#[test]
fn gesture_owner_field_roundtrips_as_a_scalar() {
let mut cfg = Config::default();
Expand Down
9 changes: 8 additions & 1 deletion crates/openlogi-core/src/config/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -363,12 +363,15 @@ pub struct SmartShift {
/// [`Binding::Gesture`](crate::binding::Binding::Gesture) — so switching the
/// gesture button never has to collapse a button's gesture map to encode the
/// choice: every gesture-capable button keeps its full direction map, and only
/// the owner is dispatched. Serialized as a bare string (`"Off"` or a
/// the owner is dispatched. Serialized as a bare string (`"Off"`, `"Disabled"`, or a
/// [`ButtonId`] name) so it stays a TOML scalar.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GestureOwner {
/// Gestures are explicitly turned off for this device.
Off,
/// The dedicated gesture control is diverted without raw motion and its
/// button reports are discarded.
Disabled,
/// The named button owns the gesture role.
Button(ButtonId),
}
Expand All @@ -379,6 +382,7 @@ impl Serialize for GestureOwner {
// "Off" can't collide with a ButtonId variant name (all CamelCase
// control names), so the string space is unambiguous.
GestureOwner::Off => serializer.serialize_str("Off"),
GestureOwner::Disabled => serializer.serialize_str("Disabled"),
GestureOwner::Button(id) => id.serialize(serializer),
}
}
Expand All @@ -401,6 +405,9 @@ where
if s == "Off" {
return Ok(Some(GestureOwner::Off));
}
if s == "Disabled" {
return Ok(Some(GestureOwner::Disabled));
}
// Parse the button name with a throwaway error type so an unknown token maps
// to `None` (infer) rather than propagating an error.
let button = ButtonId::deserialize(
Expand Down
70 changes: 57 additions & 13 deletions crates/openlogi-gui/src/mouse_model/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ enum BindingPopover {

impl Render for MouseModelView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (device_key, asset, active, bindings, gesture_owner, glow) = cx
let (device_key, asset, active, bindings, gesture_owner, gesture_disabled, glow) = cx
.try_global::<AppState>()
.map(|s| {
(
Expand All @@ -117,6 +117,7 @@ impl Render for MouseModelView {
s.active_button,
s.button_bindings.clone(),
s.current_gesture_owner(),
s.current_gesture_button_disabled(),
s.current_record().and_then(|r| keyboard_glow(s, r)),
)
})
Expand Down Expand Up @@ -229,7 +230,13 @@ impl Render for MouseModelView {
.w(px(canvas_w))
.gap_4()
.when(!capable.is_empty(), |col| {
col.child(gesture_owner_selector(&capable, gesture_owner, &view, pal))
col.child(gesture_owner_selector(
&capable,
gesture_owner,
gesture_disabled,
&view,
pal,
))
})
.child(canvas)
}
Expand Down Expand Up @@ -309,6 +316,7 @@ fn gesture_owner_label(btn: ButtonId) -> &'static str {
fn gesture_owner_selector(
capable: &[ButtonId],
owner: Option<ButtonId>,
disabled: bool,
view: &Entity<MouseModelView>,
pal: Palette,
) -> impl IntoElement {
Expand All @@ -323,27 +331,57 @@ fn gesture_owner_selector(
.child(tr!("Gesture Button")),
)
.children(
capable
.iter()
.map(|&btn| owner_chip(Some(btn), owner, view, pal)),
capable.iter().map(|&btn| {
owner_chip(GestureOwnerChoice::Button(btn), owner, disabled, view, pal)
}),
)
.child(owner_chip(None, owner, view, pal))
.child(owner_chip(
GestureOwnerChoice::Off,
owner,
disabled,
view,
pal,
))
.child(owner_chip(
GestureOwnerChoice::Disabled,
owner,
disabled,
view,
pal,
))
}

#[derive(Clone, Copy)]
enum GestureOwnerChoice {
Button(ButtonId),
Off,
Disabled,
}

/// One selectable chip in [`gesture_owner_selector`]. Clicking commits the new
/// gesture owner via [`AppState::commit_gesture_owner`].
fn owner_chip(
btn: Option<ButtonId>,
choice: GestureOwnerChoice,
owner: Option<ButtonId>,
disabled: bool,
view: &Entity<MouseModelView>,
pal: Palette,
) -> AnyElement {
let selected = btn == owner;
let text = match btn {
Some(b) => tr!(gesture_owner_label(b)),
None => tr!("Off"),
let selected = match choice {
GestureOwnerChoice::Button(button) => !disabled && owner == Some(button),
GestureOwnerChoice::Off => !disabled && owner.is_none(),
GestureOwnerChoice::Disabled => disabled,
};
let text = match choice {
GestureOwnerChoice::Button(button) => tr!(gesture_owner_label(button)),
GestureOwnerChoice::Off => tr!("Off"),
GestureOwnerChoice::Disabled => tr!("Disabled"),
};
let id_part = match choice {
GestureOwnerChoice::Off => 0,
GestureOwnerChoice::Disabled => 1,
GestureOwnerChoice::Button(button) => button as usize + 2,
};
let id_part = btn.map_or(0usize, |b| b as usize + 1);
let view = view.clone();
div()
.id(("gesture-owner", id_part))
Expand All @@ -369,7 +407,13 @@ fn owner_chip(
.cursor_pointer()
.child(text)
.on_click(move |_event, _window, cx| {
cx.update_global::<AppState, _>(|state, _| state.commit_gesture_owner(btn));
cx.update_global::<AppState, _>(|state, _| match choice {
GestureOwnerChoice::Button(button) => {
state.commit_gesture_owner(Some(button));
}
GestureOwnerChoice::Off => state.commit_gesture_owner(None),
GestureOwnerChoice::Disabled => state.commit_gesture_button_disabled(),
});
view.update(cx, |_, vcx| vcx.notify());
})
.into_any_element()
Expand Down
Loading
Loading