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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- *(thumbwheel)* expose firmware-supported capacitive tap separately, default it to Do Nothing, hide it on unsupported devices, and keep rotation capture available without tap support

## [0.6.19](https://github.com/AprilNEA/OpenLogi/compare/openlogi-core-v0.6.18...openlogi-core-v0.6.19) - 2026-07-04

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Things OpenLogi does that Options+ won't:
| Interface localization (20 languages: da, de, el, en, es, fi, fr, it, ja, ko, nb, nl, pl, pt-BR, pt-PT, ru, sv, zh-CN, zh-HK, zh-TW) | ✅ |
| Linux packaging: udev rules, systemd unit, `.deb` / `.rpm` | ✅ Linux |
| Gesture-button per-direction bindings | 🟡 configurable; hardware capture pending |
| Middle / mode-shift / thumbwheel button capture | 🟡 configurable; hook owns side buttons only |
| Middle / mode-shift / thumbwheel tap + rotation capture | 🟡 configurable; hook owns side buttons only |
| Windows (agent, GUI, event hook) | 🟡 untested preview — signed `.exe` / `.msi` ship per release |

¹ Media key actions use D-Bus MPRIS on Linux; a handful of macOS-specific actions (e.g. Launchpad) have no Linux equivalent and are no-ops.
Expand Down
18 changes: 18 additions & 0 deletions crates/openlogi-agent-core/src/bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,24 @@ mod tests {
);
}

#[test]
fn thumbwheel_tap_is_off_by_default_but_preserves_explicit_actions() {
let mut cfg = Config::default();
let projected = bindings_for(&cfg, Some("2b042"), None);
assert_eq!(projected.get(&ButtonId::Thumbwheel), Some(&Action::None));

cfg.set_binding(
"2b042",
ButtonId::Thumbwheel,
Binding::Single(Action::AppExpose),
);
let projected = bindings_for(&cfg, Some("2b042"), None);
assert_eq!(
projected.get(&ButtonId::Thumbwheel),
Some(&Action::AppExpose)
);
}

#[test]
fn oshook_gestures_collects_only_os_hook_gesture_buttons() {
let mut cfg = Config::default();
Expand Down
3 changes: 2 additions & 1 deletion crates/openlogi-agent-core/src/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ use serde::{Deserialize, Serialize};
/// v6: `Capabilities::scroll_inversion` added.
/// v7: pairing commands return typed acceptance errors.
/// v8: [`WriteError`] carries typed HID++ operation failures.
pub const PROTOCOL_VERSION: u32 = 8;
/// v9: `Capabilities::thumbwheel_tap` added.
pub const PROTOCOL_VERSION: u32 = 9;

/// Where the agent's device enumeration stands. The distinction matters
/// because an empty inventory list is ambiguous on its own: the GUI must keep
Expand Down
19 changes: 19 additions & 0 deletions crates/openlogi-agent-core/src/watchers/gesture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,25 @@ mod tests {
);
}

#[test]
fn thumbwheel_capture_arms_only_for_non_default_behavior() {
let maps = Arc::new(RwLock::new(hook_runtime::HookMaps::default()));
assert!(!thumbwheel_armed(&maps, DEFAULT_THUMBWHEEL_SENSITIVITY));

maps.write()
Comment thread
MoosaTae marked this conversation as resolved.
.expect("hook maps")
.bindings
.insert(ButtonId::Thumbwheel, Action::AppExpose);
assert!(thumbwheel_armed(&maps, DEFAULT_THUMBWHEEL_SENSITIVITY));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Legacy Tap Still Arms Capture

This expectation keeps the upgraded-config failure reachable. Older configs can contain an explicit Thumbwheel = AppExpose tap mapping while rotation bindings and sensitivity are still default. After this PR changes the tap default to None, the arming check treats that preserved tap action as customized behavior and enables HID++ thumbwheel diversion, so native thumbwheel rotation can be captured and re-synthesized even though the user only kept the old tap mapping. The arming decision needs to avoid using the preserved tap binding alone as a reason to capture rotation.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code


maps.write()
.expect("hook maps")
.bindings
.insert(ButtonId::Thumbwheel, Action::None);
assert!(!thumbwheel_armed(&maps, DEFAULT_THUMBWHEEL_SENSITIVITY));
assert!(thumbwheel_armed(&maps, DEFAULT_THUMBWHEEL_SENSITIVITY + 1));
}

#[test]
fn rearms_when_the_current_session_dies_with_a_target() {
// The live session ended on its own while a device is still targeted.
Expand Down
5 changes: 3 additions & 2 deletions crates/openlogi-agent-core/tests/wire_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ fn assert_wire<T: serde::Serialize>(value: &T, golden: &str) {
/// that makes that visible in the same diff.
#[test]
fn protocol_version_is_pinned() {
assert_eq!(PROTOCOL_VERSION, 8);
assert_eq!(PROTOCOL_VERSION, 9);
}

/// tarpc encodes the request enum's variant index, so trait *method order* is
Expand Down Expand Up @@ -157,12 +157,13 @@ fn device_inventory() {
pointer: true,
lighting: false,
scroll_inversion: false,
thumbwheel_tap: true,
}),
}],
}];
assert_wire(
&inventory,
"010d426f6c74205265636569766572fb6d04fb48c501084630304443414645010101094d58204d535452335301fb34b000010150020001030106323134304c5a0102030400010100fb34b0fb8240000b0101010000",
"010d426f6c74205265636569766572fb6d04fb48c501084630304443414645010101094d58204d535452335301fb34b000010150020001030106323134304c5a0102030400010100fb34b0fb8240000b010101000001",
);
}

Expand Down
25 changes: 14 additions & 11 deletions crates/openlogi-core/src/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,9 @@ pub enum ButtonId {
/// The "ModeShift" button under the wheel — typically used for SmartShift /
/// DPI cycle. Named `DpiToggle` for historical reasons.
DpiToggle,
/// The horizontal thumb wheel's click. Kept in [`ButtonId::ALL`] so its
/// default still seeds and dispatches when the wheel is diverted, even
/// though the mouse model surfaces the two rotation directions instead of
/// the click (see `mouse_model::geometry`).
/// The horizontal thumb wheel's capacitive single-tap gesture. Kept
/// separate from its two rotation directions because HID++ reports all
/// three independently while the wheel is diverted.
Thumbwheel,
/// Rotating the thumb wheel "up" (positive rotation). Bound, by default, to
/// continuous horizontal scroll; see the agent-core `watchers`-side dispatch.
Expand Down Expand Up @@ -79,7 +78,7 @@ impl ButtonId {
ButtonId::Back => "Back",
ButtonId::Forward => "Forward",
ButtonId::DpiToggle => "DPI Toggle",
ButtonId::Thumbwheel => "Thumb Wheel",
ButtonId::Thumbwheel => "Thumb Wheel Tap",
ButtonId::ThumbwheelScrollUp => "Thumb Wheel Up",
ButtonId::ThumbwheelScrollDown => "Thumb Wheel Down",
ButtonId::GestureButton => "Gesture Button",
Expand Down Expand Up @@ -843,11 +842,10 @@ impl Action {

/// Sensible defaults for a fresh device so the panel isn't empty on first run.
///
/// Thumbwheel / GestureButton defaults match what Logi Options+ ships for
/// MX-line devices: thumb wheel click → App Exposé, gesture button →
/// Mission Control. The thumb wheel isn't captured yet; the dedicated gesture button is
/// (per-direction, see [`default_gesture_binding`]). The bindings persist
/// regardless so the user only configures once.
/// The capacitive thumb-wheel tap defaults to no action because ordinary
/// rotation-and-release can be reported as a single tap by some devices.
/// Rotation still defaults to horizontal scrolling, while the dedicated
/// gesture button defaults to Mission Control.
///
/// `GestureButton`'s entry here is vestigial: in the merged [`Binding`] model
/// the gesture button defaults to [`Binding::Gesture`] (see
Expand All @@ -863,7 +861,7 @@ pub fn default_binding(button: ButtonId) -> Action {
ButtonId::Back => Action::BrowserBack,
ButtonId::Forward => Action::BrowserForward,
ButtonId::DpiToggle => Action::CycleDpiPresets,
ButtonId::Thumbwheel => Action::AppExpose,
ButtonId::Thumbwheel => Action::None,
// The thumb wheel scrolls horizontally by default: rotating it produces
// continuous horizontal scroll, with "up" → right and "down" → left.
// The wheel watcher renders these two actions as smooth, sensitivity-
Expand Down Expand Up @@ -1407,4 +1405,9 @@ mod tests {
Action::CycleDpiPresets
);
}

#[test]
fn thumbwheel_tap_defaults_to_do_nothing() {
assert_eq!(default_binding(ButtonId::Thumbwheel), Action::None);
}
}
82 changes: 77 additions & 5 deletions crates/openlogi-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ use crate::paths::{self, PathsError};
/// changes; readers branch on the parsed value before consuming the rest of
/// the file.
///
/// v4 removes the historical `Thumbwheel = AppExpose` default from existing
/// device configs. The capacitive tap now defaults to `None`; retaining the old
/// implicit value would unnecessarily divert and re-synthesise wheel rotation.
///
/// v3 changes the device map from model keys to physical-device keys. No v2
/// device entries are migrated because model-scoped settings cannot be assigned
/// safely when two identical devices exist.
Expand All @@ -32,7 +36,7 @@ use crate::paths::{self, PathsError};
/// `RawDeviceConfig` shim folds the legacy fields) and self-heals to v2 on the
/// next save; [`Config::load_from_path`] rejects only versions *newer* than this
/// so a forward file fails loudly instead of silently losing bindings.
pub const SCHEMA_VERSION: u32 = 3;
pub const SCHEMA_VERSION: u32 = 4;

/// Top-level config document.
#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -590,9 +594,12 @@ impl Config {
found: config.schema_version,
});
}
if config.schema_version < 4 {
config.remove_legacy_thumbwheel_tap_defaults();
}
// Stamp the in-memory doc to the current version so a re-save
// writes the migrated v2 shape (the device shim already folded
// the legacy fields during deserialize).
// writes every migrated shape (the device shim already folded
// the legacy binding fields during deserialize).
config.schema_version = SCHEMA_VERSION;
Ok(config)
}
Expand Down Expand Up @@ -626,6 +633,22 @@ impl Config {
})
}

/// Drop the pre-v4 thumbwheel tap default. Before the tap was exposed as a
/// separate control, `AppExpose` was stored as its default; preserving that
/// implicit value would arm HID++ diversion after the default changed to
/// `None`. New v4 configs can still intentionally bind the tap to
/// `AppExpose` (or any other action).
fn remove_legacy_thumbwheel_tap_defaults(&mut self) {
for device in self.devices.values_mut() {
if matches!(
device.bindings.get(&ButtonId::Thumbwheel),
Some(Binding::Single(Action::AppExpose))
) {
device.bindings.remove(&ButtonId::Thumbwheel);
}
}
}

/// Returns the bindings stored for `device_key`, or an empty map if the
/// device has no committed bindings yet.
#[must_use]
Expand Down Expand Up @@ -1157,7 +1180,7 @@ mod tests {
// The key only contains [A-Za-z0-9_], so TOML emits it as a bare-word
// table key (no surrounding quotes). The test asserts the observable
// structure rather than locking in a specific quoting.
assert!(body.contains("schema_version = 3"), "got: {body}");
assert!(body.contains("schema_version = 4"), "got: {body}");
assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
// A `Single` binding serializes byte-identically to the pre-v2 bare
// `Action`, so the leaf line is unchanged.
Expand Down Expand Up @@ -1207,6 +1230,7 @@ mod tests {
pointer: true,
lighting: false,
scroll_inversion: false,
thumbwheel_tap: false,
},
};
cfg.set_device_identity("2b034", mouse.clone());
Expand Down Expand Up @@ -1389,12 +1413,60 @@ Click = \"Paste\"
// Saving self-heals to the current shape: stamped version + merged table,
// legacy field names gone.
let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(body.contains("schema_version = 3"), "got: {body}");
assert!(body.contains("schema_version = 4"), "got: {body}");
assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
assert!(!body.contains("button_bindings"), "got: {body}");
assert!(!body.contains("gesture_bindings"), "got: {body}");
}

#[test]
fn migration_removes_only_the_legacy_thumbwheel_tap_default() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
fs::write(
&path,
r#"schema_version = 3

[devices.mouse.bindings]
Thumbwheel = "AppExpose"
Back = "Copy"
"#,
)
.expect("write v3 config");

let cfg = Config::load_from_path(&path).expect("load v3 config");
assert_eq!(cfg.schema_version, SCHEMA_VERSION);
assert!(
!cfg.bindings_for("mouse")
.contains_key(&ButtonId::Thumbwheel)
);
assert_eq!(
cfg.bindings_for("mouse").get(&ButtonId::Back),
Some(&Binding::Single(Action::Copy))
);
}

#[test]
fn current_schema_preserves_an_intentional_thumbwheel_tap_binding() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
fs::write(
&path,
r#"schema_version = 4

[devices.mouse.bindings]
Thumbwheel = "AppExpose"
"#,
)
.expect("write v4 config");

let cfg = Config::load_from_path(&path).expect("load v4 config");
assert_eq!(
cfg.bindings_for("mouse").get(&ButtonId::Thumbwheel),
Some(&Binding::Single(Action::AppExpose))
);
}

#[test]
fn migration_gesture_map_wins_over_legacy_single_gesture_button_entry() {
// The data-loss guard: when a legacy single button_bindings[GestureButton]
Expand Down
8 changes: 8 additions & 0 deletions crates/openlogi-core/src/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ pub struct Capabilities {
/// Native vertical wheel inversion — HID++ `0x2121 HiResWheel` with the
/// firmware-reported `has_invert` capability.
pub scroll_inversion: bool,
/// Capacitive single-tap on the horizontal thumbwheel — HID++ `0x2150`
/// `getThumbwheelInfo` with the `c_single_tap` capability bit set.
#[serde(default)]
pub thumbwheel_tap: bool,
}

impl Capabilities {
Expand All @@ -105,6 +109,7 @@ impl Capabilities {
pointer: has(&POINTER),
lighting: has(&LIGHTING),
scroll_inversion: false,
thumbwheel_tap: false,
}
}

Expand All @@ -121,6 +126,7 @@ impl Capabilities {
pointer: true,
lighting: false,
scroll_inversion: false,
thumbwheel_tap: false,
},
DeviceKind::Keyboard => Self {
lighting: true,
Expand Down Expand Up @@ -293,6 +299,7 @@ mod tests {
pointer: true,
lighting: false,
scroll_inversion: false,
thumbwheel_tap: false,
}
);
// A wired G-series keyboard: PerKeyLighting (0x8080), no DPI/buttons.
Expand All @@ -304,6 +311,7 @@ mod tests {
pointer: false,
lighting: true,
scroll_inversion: false,
thumbwheel_tap: false,
}
);
// No driving features → nothing offered.
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-core/src/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,7 @@ mod tests {
pointer: true,
lighting: false,
scroll_inversion: false,
thumbwheel_tap: false,
}),
dpi: Some("1600 dpi (range 200–8000, 5 steps)".to_string()),
config_key: "4082d".to_string(),
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/da.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ _version: 1
"Forward": "Frem"
"DPI Toggle": "DPI-skift"
"Thumb Wheel": "Tommelfingerhjul"
"Thumb Wheel Tap": "Tryk på tommelfingerhjul"
"Gesture Button": "Bevægelsesknap"
"Up": "Op"
"Down": "Ned"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ _version: 1
"Forward": "Vor"
"DPI Toggle": "DPI-Umschaltung"
"Thumb Wheel": "Daumenrad"
"Thumb Wheel Tap": "Daumenrad antippen"
"Gesture Button": "Gestentaste"
"Up": "Oben"
"Down": "Unten"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/el.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ _version: 1
"Forward": "Εμπρός"
"DPI Toggle": "Εναλλαγή DPI"
"Thumb Wheel": "Πλαϊνός τροχός"
"Thumb Wheel Tap": "Άγγιγμα πλαϊνού τροχού"
"Gesture Button": "Κουμπί χειρονομιών"
"Up": "Πάνω"
"Down": "Κάτω"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ _version: 1
"Forward": "Forward"
"DPI Toggle": "DPI Toggle"
"Thumb Wheel": "Thumb Wheel"
"Thumb Wheel Tap": "Thumb Wheel Tap"
"Gesture Button": "Gesture Button"
"Up": "Up"
"Down": "Down"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ _version: 1
"Forward": "Adelante"
"DPI Toggle": "Cambiar DPI"
"Thumb Wheel": "Rueda lateral"
"Thumb Wheel Tap": "Toque de rueda lateral"
"Gesture Button": "Botón de gestos"
"Up": "Arriba"
"Down": "Abajo"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/fi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ _version: 1
"Forward": "Eteenpäin"
"DPI Toggle": "DPI-vaihto"
"Thumb Wheel": "Peukalorulla"
"Thumb Wheel Tap": "Peukalorullan napautus"
"Gesture Button": "Eletoiminto"
"Up": "Ylös"
"Down": "Alas"
Expand Down
Loading