From 5100cac2080bd881fd1a0eee99d237e7e33a2a54 Mon Sep 17 00:00:00 2001 From: MoosaTae Date: Wed, 1 Jul 2026 10:51:25 +0700 Subject: [PATCH] fix: expose thumbwheel tap separately --- CHANGELOG.md | 4 ++ README.md | 2 +- crates/openlogi-agent-core/src/bindings.rs | 18 +++++ .../src/watchers/gesture.rs | 19 +++++ crates/openlogi-core/src/binding.rs | 25 ++++--- crates/openlogi-gui/locales/da.yml | 1 + crates/openlogi-gui/locales/de.yml | 1 + crates/openlogi-gui/locales/el.yml | 1 + crates/openlogi-gui/locales/en.yml | 1 + crates/openlogi-gui/locales/es.yml | 1 + crates/openlogi-gui/locales/fi.yml | 1 + crates/openlogi-gui/locales/fr.yml | 1 + crates/openlogi-gui/locales/it.yml | 1 + crates/openlogi-gui/locales/ja.yml | 1 + crates/openlogi-gui/locales/ko.yml | 1 + crates/openlogi-gui/locales/nb.yml | 1 + crates/openlogi-gui/locales/nl.yml | 1 + crates/openlogi-gui/locales/pl.yml | 1 + crates/openlogi-gui/locales/pt-BR.yml | 1 + crates/openlogi-gui/locales/pt-PT.yml | 1 + crates/openlogi-gui/locales/ru.yml | 1 + crates/openlogi-gui/locales/sv.yml | 1 + crates/openlogi-gui/locales/zh-CN.yml | 1 + crates/openlogi-gui/locales/zh-HK.yml | 1 + crates/openlogi-gui/locales/zh-TW.yml | 1 + .../openlogi-gui/src/mouse_model/geometry.rs | 69 ++++++++++--------- crates/openlogi-hid/src/gesture.rs | 33 +++++---- crates/openlogi-hid/src/thumbwheel.rs | 27 ++++++++ 28 files changed, 159 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e696e11..d03dfb52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- *(thumbwheel)* expose capacitive tap separately, default it to Do Nothing, and keep rotation capture available on devices without tap support + ## [0.6.18](https://github.com/AprilNEA/OpenLogi/compare/openlogi-core-v0.6.17...openlogi-core-v0.6.18) - 2026-06-29 ### Added diff --git a/README.md b/README.md index 7655668d..16dbf1e8 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/crates/openlogi-agent-core/src/bindings.rs b/crates/openlogi-agent-core/src/bindings.rs index a416709c..371a699d 100644 --- a/crates/openlogi-agent-core/src/bindings.rs +++ b/crates/openlogi-agent-core/src/bindings.rs @@ -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(); diff --git a/crates/openlogi-agent-core/src/watchers/gesture.rs b/crates/openlogi-agent-core/src/watchers/gesture.rs index 80659682..a2ade391 100644 --- a/crates/openlogi-agent-core/src/watchers/gesture.rs +++ b/crates/openlogi-agent-core/src/watchers/gesture.rs @@ -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() + .expect("hook maps") + .bindings + .insert(ButtonId::Thumbwheel, Action::AppExpose); + assert!(thumbwheel_armed(&maps, DEFAULT_THUMBWHEEL_SENSITIVITY)); + + 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. diff --git a/crates/openlogi-core/src/binding.rs b/crates/openlogi-core/src/binding.rs index 1463492a..ff629db6 100644 --- a/crates/openlogi-core/src/binding.rs +++ b/crates/openlogi-core/src/binding.rs @@ -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. @@ -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", @@ -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 @@ -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- @@ -1407,4 +1405,9 @@ mod tests { Action::CycleDpiPresets ); } + + #[test] + fn thumbwheel_tap_defaults_to_do_nothing() { + assert_eq!(default_binding(ButtonId::Thumbwheel), Action::None); + } } diff --git a/crates/openlogi-gui/locales/da.yml b/crates/openlogi-gui/locales/da.yml index 2e2f041f..654c20f9 100644 --- a/crates/openlogi-gui/locales/da.yml +++ b/crates/openlogi-gui/locales/da.yml @@ -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" diff --git a/crates/openlogi-gui/locales/de.yml b/crates/openlogi-gui/locales/de.yml index 99480ebe..4b8ec939 100644 --- a/crates/openlogi-gui/locales/de.yml +++ b/crates/openlogi-gui/locales/de.yml @@ -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" diff --git a/crates/openlogi-gui/locales/el.yml b/crates/openlogi-gui/locales/el.yml index 3b96ae1c..a73fae77 100644 --- a/crates/openlogi-gui/locales/el.yml +++ b/crates/openlogi-gui/locales/el.yml @@ -149,6 +149,7 @@ _version: 1 "Forward": "Εμπρός" "DPI Toggle": "Εναλλαγή DPI" "Thumb Wheel": "Πλαϊνός τροχός" +"Thumb Wheel Tap": "Άγγιγμα πλαϊνού τροχού" "Gesture Button": "Κουμπί χειρονομιών" "Up": "Πάνω" "Down": "Κάτω" diff --git a/crates/openlogi-gui/locales/en.yml b/crates/openlogi-gui/locales/en.yml index 7b567aa7..e2354913 100644 --- a/crates/openlogi-gui/locales/en.yml +++ b/crates/openlogi-gui/locales/en.yml @@ -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" diff --git a/crates/openlogi-gui/locales/es.yml b/crates/openlogi-gui/locales/es.yml index 9374362f..75b75dfa 100644 --- a/crates/openlogi-gui/locales/es.yml +++ b/crates/openlogi-gui/locales/es.yml @@ -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" diff --git a/crates/openlogi-gui/locales/fi.yml b/crates/openlogi-gui/locales/fi.yml index 44fe3b8c..5f840fcc 100644 --- a/crates/openlogi-gui/locales/fi.yml +++ b/crates/openlogi-gui/locales/fi.yml @@ -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" diff --git a/crates/openlogi-gui/locales/fr.yml b/crates/openlogi-gui/locales/fr.yml index 1317cd65..b2d89f41 100644 --- a/crates/openlogi-gui/locales/fr.yml +++ b/crates/openlogi-gui/locales/fr.yml @@ -149,6 +149,7 @@ _version: 1 "Forward": "Suivant" "DPI Toggle": "Bascule DPI" "Thumb Wheel": "Molette latérale" +"Thumb Wheel Tap": "Toucher la molette latérale" "Gesture Button": "Bouton de gestes" "Up": "Haut" "Down": "Bas" diff --git a/crates/openlogi-gui/locales/it.yml b/crates/openlogi-gui/locales/it.yml index 08b290b7..d82ed873 100644 --- a/crates/openlogi-gui/locales/it.yml +++ b/crates/openlogi-gui/locales/it.yml @@ -149,6 +149,7 @@ _version: 1 "Forward": "Avanti" "DPI Toggle": "Cambia DPI" "Thumb Wheel": "Volante da pollice" +"Thumb Wheel Tap": "Tocco del volante da pollice" "Gesture Button": "Pulsante gesture" "Up": "Su" "Down": "Giù" diff --git a/crates/openlogi-gui/locales/ja.yml b/crates/openlogi-gui/locales/ja.yml index 61c9d9d0..6e40416a 100644 --- a/crates/openlogi-gui/locales/ja.yml +++ b/crates/openlogi-gui/locales/ja.yml @@ -149,6 +149,7 @@ _version: 1 "Forward": "進む" "DPI Toggle": "DPI 切り替え" "Thumb Wheel": "サムホイール" +"Thumb Wheel Tap": "サムホイールタップ" "Gesture Button": "ジェスチャーボタン" "Up": "上" "Down": "下" diff --git a/crates/openlogi-gui/locales/ko.yml b/crates/openlogi-gui/locales/ko.yml index c4504c0e..c0e72bb2 100644 --- a/crates/openlogi-gui/locales/ko.yml +++ b/crates/openlogi-gui/locales/ko.yml @@ -149,6 +149,7 @@ _version: 1 "Forward": "앞으로" "DPI Toggle": "DPI 전환" "Thumb Wheel": "썸휠" +"Thumb Wheel Tap": "썸휠 탭" "Gesture Button": "제스처 버튼" "Up": "위" "Down": "아래" diff --git a/crates/openlogi-gui/locales/nb.yml b/crates/openlogi-gui/locales/nb.yml index 2f0e55c4..7476894d 100644 --- a/crates/openlogi-gui/locales/nb.yml +++ b/crates/openlogi-gui/locales/nb.yml @@ -149,6 +149,7 @@ _version: 1 "Forward": "Frem" "DPI Toggle": "DPI-veksling" "Thumb Wheel": "Tommelhjul" +"Thumb Wheel Tap": "Trykk på tommelhjul" "Gesture Button": "Bevegelsesknapp" "Up": "Opp" "Down": "Ned" diff --git a/crates/openlogi-gui/locales/nl.yml b/crates/openlogi-gui/locales/nl.yml index 51f32483..56b09da4 100644 --- a/crates/openlogi-gui/locales/nl.yml +++ b/crates/openlogi-gui/locales/nl.yml @@ -149,6 +149,7 @@ _version: 1 "Forward": "Volgende" "DPI Toggle": "DPI wisselen" "Thumb Wheel": "Duimwiel" +"Thumb Wheel Tap": "Tik op duimwiel" "Gesture Button": "Gebarenknop" "Up": "Omhoog" "Down": "Omlaag" diff --git a/crates/openlogi-gui/locales/pl.yml b/crates/openlogi-gui/locales/pl.yml index fbec6b45..4242e762 100644 --- a/crates/openlogi-gui/locales/pl.yml +++ b/crates/openlogi-gui/locales/pl.yml @@ -149,6 +149,7 @@ _version: 1 "Forward": "Do przodu" "DPI Toggle": "Przełącznik DPI" "Thumb Wheel": "Rolka kciukowa" +"Thumb Wheel Tap": "Dotknięcie rolki kciukowej" "Gesture Button": "Przycisk gestów" "Up": "W górę" "Down": "W dół" diff --git a/crates/openlogi-gui/locales/pt-BR.yml b/crates/openlogi-gui/locales/pt-BR.yml index 4508e5ae..ecea1049 100644 --- a/crates/openlogi-gui/locales/pt-BR.yml +++ b/crates/openlogi-gui/locales/pt-BR.yml @@ -149,6 +149,7 @@ _version: 1 "Forward": "Avançar" "DPI Toggle": "Alternar DPI" "Thumb Wheel": "Roda do Polegar" +"Thumb Wheel Tap": "Toque na roda do polegar" "Gesture Button": "Botão de Gesto" "Up": "Cima" "Down": "Baixo" diff --git a/crates/openlogi-gui/locales/pt-PT.yml b/crates/openlogi-gui/locales/pt-PT.yml index 0a13f08c..fdfd15e3 100644 --- a/crates/openlogi-gui/locales/pt-PT.yml +++ b/crates/openlogi-gui/locales/pt-PT.yml @@ -149,6 +149,7 @@ _version: 1 "Forward": "Avançar" "DPI Toggle": "Alternar DPI" "Thumb Wheel": "Roda de polegar" +"Thumb Wheel Tap": "Toque na roda de polegar" "Gesture Button": "Botão de gesto" "Up": "Cima" "Down": "Baixo" diff --git a/crates/openlogi-gui/locales/ru.yml b/crates/openlogi-gui/locales/ru.yml index 32e1be75..2b0b7054 100644 --- a/crates/openlogi-gui/locales/ru.yml +++ b/crates/openlogi-gui/locales/ru.yml @@ -149,6 +149,7 @@ _version: 1 "Forward": "Вперёд" "DPI Toggle": "Переключение DPI" "Thumb Wheel": "Колесо под большой палец" +"Thumb Wheel Tap": "Касание колеса под большой палец" "Gesture Button": "Кнопка жестов" "Up": "Вверх" "Down": "Вниз" diff --git a/crates/openlogi-gui/locales/sv.yml b/crates/openlogi-gui/locales/sv.yml index cc6cf27f..9f356029 100644 --- a/crates/openlogi-gui/locales/sv.yml +++ b/crates/openlogi-gui/locales/sv.yml @@ -149,6 +149,7 @@ _version: 1 "Forward": "Framåt" "DPI Toggle": "DPI-växling" "Thumb Wheel": "Tumhjul" +"Thumb Wheel Tap": "Tryck på tumhjulet" "Gesture Button": "Gestknapp" "Up": "Upp" "Down": "Ned" diff --git a/crates/openlogi-gui/locales/zh-CN.yml b/crates/openlogi-gui/locales/zh-CN.yml index aa217b46..8342ab65 100644 --- a/crates/openlogi-gui/locales/zh-CN.yml +++ b/crates/openlogi-gui/locales/zh-CN.yml @@ -149,6 +149,7 @@ _version: 1 "Forward": "前进" "DPI Toggle": "灵敏度切换" "Thumb Wheel": "拇指滚轮" +"Thumb Wheel Tap": "拇指滚轮轻触" "Gesture Button": "手势按钮" "Up": "上" "Down": "下" diff --git a/crates/openlogi-gui/locales/zh-HK.yml b/crates/openlogi-gui/locales/zh-HK.yml index e11ad21a..c9aeac76 100644 --- a/crates/openlogi-gui/locales/zh-HK.yml +++ b/crates/openlogi-gui/locales/zh-HK.yml @@ -149,6 +149,7 @@ _version: 1 "Forward": "前進" "DPI Toggle": "靈敏度切換" "Thumb Wheel": "拇指滾輪" +"Thumb Wheel Tap": "拇指滾輪輕觸" "Gesture Button": "手勢按鈕" "Up": "上" "Down": "下" diff --git a/crates/openlogi-gui/locales/zh-TW.yml b/crates/openlogi-gui/locales/zh-TW.yml index 5d51239b..7f2bfcde 100644 --- a/crates/openlogi-gui/locales/zh-TW.yml +++ b/crates/openlogi-gui/locales/zh-TW.yml @@ -149,6 +149,7 @@ _version: 1 "Forward": "前進" "DPI Toggle": "靈敏度切換" "Thumb Wheel": "拇指滾輪" +"Thumb Wheel Tap": "拇指滾輪輕觸" "Gesture Button": "手勢按鈕" "Up": "上" "Down": "下" diff --git a/crates/openlogi-gui/src/mouse_model/geometry.rs b/crates/openlogi-gui/src/mouse_model/geometry.rs index ec709edc..7878210a 100644 --- a/crates/openlogi-gui/src/mouse_model/geometry.rs +++ b/crates/openlogi-gui/src/mouse_model/geometry.rs @@ -11,10 +11,10 @@ use crate::mouse_model::leader_lines::{Label, Side}; /// marker point per button, not a rectangle, so we size by hand. const ASSET_HOTSPOT: f32 = 56.; -/// Vertical offset of each synthetic thumb-wheel rotation hotspot from the -/// wheel's click marker, so "up" and "down" sit above and below it as two -/// separately-clickable dots. -const THUMBWHEEL_ROTATION_OFFSET: f32 = 18.; +/// Centre-to-centre spacing for the synthetic thumb-wheel Up / Tap / Down +/// controls. Their hit targets are shorter than this, so they cannot overlap. +const THUMBWHEEL_CONTROL_SPACING: f32 = 20.; +const THUMBWHEEL_CONTROL_HEIGHT: f32 = 18.; /// Scale the device image to *fit inside* a `max_w` × `target_h` box while /// preserving the **actual PNG's** aspect ratio. A tall device (a mouse) is @@ -110,33 +110,40 @@ pub fn asset_hotspots_for_png(asset: &ResolvedAsset, mouse_w: f32, mouse_h: f32) }) .collect(); - with_thumbwheel_rotation(hotspots) + with_thumbwheel_controls(hotspots) } -/// Replace the thumb-wheel *click* hotspot with two rotation hotspots -/// (`ThumbwheelScrollUp` / `ThumbwheelScrollDown`) stacked above and below the -/// wheel's marker — the click stays bound to its default and still dispatches -/// when the wheel is diverted, it just isn't surfaced in the model. +/// Replace the metadata's single thumb-wheel marker with three independent, +/// non-overlapping controls for rotation up, capacitive tap, and rotation down. /// /// No-op when the device has no thumb wheel. -fn with_thumbwheel_rotation(mut hotspots: Vec) -> Vec { +fn with_thumbwheel_controls(mut hotspots: Vec) -> Vec { let Some(wheel) = hotspots.iter().find(|h| h.id == ButtonId::Thumbwheel) else { return hotspots; }; - let rotation = [ + let center_y = wheel.y + wheel.h / 2.; + let controls = [ Hotspot { id: ButtonId::ThumbwheelScrollUp, - y: wheel.y - THUMBWHEEL_ROTATION_OFFSET, + y: center_y - THUMBWHEEL_CONTROL_SPACING - THUMBWHEEL_CONTROL_HEIGHT / 2., + h: THUMBWHEEL_CONTROL_HEIGHT, + ..*wheel + }, + Hotspot { + id: ButtonId::Thumbwheel, + y: center_y - THUMBWHEEL_CONTROL_HEIGHT / 2., + h: THUMBWHEEL_CONTROL_HEIGHT, ..*wheel }, Hotspot { id: ButtonId::ThumbwheelScrollDown, - y: wheel.y + THUMBWHEEL_ROTATION_OFFSET, + y: center_y + THUMBWHEEL_CONTROL_SPACING - THUMBWHEEL_CONTROL_HEIGHT / 2., + h: THUMBWHEEL_CONTROL_HEIGHT, ..*wheel }, ]; hotspots.retain(|h| h.id != ButtonId::Thumbwheel); - hotspots.extend(rotation); + hotspots.extend(controls); hotspots } @@ -244,7 +251,7 @@ mod tests { } #[test] - fn thumbwheel_click_becomes_two_rotation_hotspots() { + fn thumbwheel_marker_becomes_three_non_overlapping_controls() { let wheel = Hotspot { id: ButtonId::Thumbwheel, x: 100., @@ -252,23 +259,23 @@ mod tests { w: ASSET_HOTSPOT, h: ASSET_HOTSPOT, }; - let out = with_thumbwheel_rotation(vec![wheel]); - assert!( - !out.iter().any(|h| h.id == ButtonId::Thumbwheel), - "the click hotspot is not surfaced in the model" + let out = with_thumbwheel_controls(vec![wheel]); + assert_eq!(out.len(), 3); + let mut controls: Vec<&Hotspot> = out.iter().collect(); + controls.sort_by(|a, b| a.y.partial_cmp(&b.y).unwrap_or(std::cmp::Ordering::Equal)); + assert_eq!( + controls.iter().map(|h| h.id).collect::>(), + vec![ + ButtonId::ThumbwheelScrollUp, + ButtonId::Thumbwheel, + ButtonId::ThumbwheelScrollDown, + ] ); - assert_eq!(out.len(), 2, "click is replaced by the two rotations"); - let up_y = out - .iter() - .find(|h| h.id == ButtonId::ThumbwheelScrollUp) - .map(|h| h.y); - let down_y = out - .iter() - .find(|h| h.id == ButtonId::ThumbwheelScrollDown) - .map(|h| h.y); assert!( - matches!((up_y, down_y), (Some(up), Some(down)) if up < down), - "up sits above down" + controls + .windows(2) + .all(|pair| pair[0].y + pair[0].h <= pair[1].y), + "thumb-wheel control hit targets must not overlap" ); } @@ -281,7 +288,7 @@ mod tests { w: ASSET_HOTSPOT, h: ASSET_HOTSPOT, }; - let out = with_thumbwheel_rotation(vec![middle]); + let out = with_thumbwheel_controls(vec![middle]); assert_eq!(out.len(), 1); assert_eq!(out[0].id, ButtonId::MiddleClick); } diff --git a/crates/openlogi-hid/src/gesture.rs b/crates/openlogi-hid/src/gesture.rs index 1783494b..073962bf 100644 --- a/crates/openlogi-hid/src/gesture.rs +++ b/crates/openlogi-hid/src/gesture.rs @@ -120,7 +120,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 thumb_index = armed.thumb.as_ref().map(|(_, idx)| *idx); + let thumb = armed + .thumb + .as_ref() + .map(|(_, idx, supports_single_tap)| (*idx, *supports_single_tap)); let dpi_set = armed.dpi_cids.clone(); let listener = chan.add_msg_listener_guarded({ let accum = Arc::clone(&accum); @@ -139,10 +142,10 @@ pub async fn run_capture_session( handle_reprog(&mut acc, event, &dpi_set, &sink); return; } - if let Some(idx) = thumb_index + if let Some((idx, supports_single_tap)) = thumb && let Some(event) = thumbwheel::decode_event(&msg, device_index, idx) { - if event.single_tap { + if supports_single_tap && event.single_tap { let _ = sink.send(CapturedInput::ButtonPressed(ButtonId::Thumbwheel)); } if event.rotation != 0 { @@ -181,7 +184,7 @@ struct ArmedControls { dpi_cids: Vec, /// `0x2150` accessor + feature index, present when the thumb wheel is /// diverted. - thumb: Option<(Thumbwheel, u8)>, + thumb: Option<(Thumbwheel, u8, bool)>, } impl ArmedControls { @@ -198,7 +201,7 @@ impl ArmedControls { restore(rc.set_cid_reporting(cid, false, false).await, "DPI button"); } } - if let Some((tw, _)) = self.thumb.as_ref() { + if let Some((tw, _, _)) = self.thumb.as_ref() { restore(tw.set_reporting(false, false).await, "thumb wheel"); } } @@ -206,8 +209,9 @@ impl ArmedControls { /// Resolve features off the device's root and divert the controls we capture: /// the gesture button (raw-XY) and DPI/ModeShift buttons over `0x1b04`, and — -/// when `capture_thumbwheel` and the wheel reports a single tap — the thumb -/// wheel over `0x2150`. The root-feature lookup mirrors `write::open_feature`, +/// when `capture_thumbwheel` — the thumb wheel over `0x2150`. Single-tap +/// capability affects tap dispatch only; rotation capture works without it. +/// 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, @@ -254,7 +258,7 @@ async fn arm_controls( reprog = Some((rc, info.index)); } - let mut thumb: Option<(Thumbwheel, u8)> = None; + let mut thumb: Option<(Thumbwheel, u8, bool)> = None; if capture_thumbwheel && let Some(info) = device .root() @@ -273,14 +277,13 @@ 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 { - debug!("thumb wheel reports no single tap — click not capturable"); + tw.set_reporting(true, false) + .await + .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?; + if !supports_single_tap { + debug!("thumb wheel reports no single tap — rotation capture remains active"); } + thumb = Some((tw, info.index, supports_single_tap)); } if !gesture_diverted && dpi_cids.is_empty() && thumb.is_none() { diff --git a/crates/openlogi-hid/src/thumbwheel.rs b/crates/openlogi-hid/src/thumbwheel.rs index e0be78f3..9dbae116 100644 --- a/crates/openlogi-hid/src/thumbwheel.rs +++ b/crates/openlogi-hid/src/thumbwheel.rs @@ -202,6 +202,33 @@ mod tests { ); } + #[test] + fn decodes_rotation_and_tap_independently() { + let mut rotation = [0u8; 16]; + rotation[0..2].copy_from_slice(&4i16.to_be_bytes()); + assert_eq!( + decode_event(&event(0, 0, rotation), 2, 6), + Some(ThumbwheelEvent { + rotation: 4, + single_tap: false, + touch: false, + proxy: false, + }) + ); + + let mut tap = [0u8; 16]; + tap[5] = EV_SINGLE_TAP; + assert_eq!( + decode_event(&event(0, 0, tap), 2, 6), + Some(ThumbwheelEvent { + rotation: 0, + single_tap: true, + touch: false, + proxy: false, + }) + ); + } + #[test] fn ignores_responses_and_foreign_messages() { let p = [0u8; 16];