From 17bfdf09b538ef5e39d6861b8304d4e3e57d5e6b Mon Sep 17 00:00:00 2001 From: MoosaTae Date: Wed, 1 Jul 2026 10:51:25 +0700 Subject: [PATCH 1/3] 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 546d52cc..f322a37a 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.19](https://github.com/AprilNEA/OpenLogi/compare/openlogi-core-v0.6.18...openlogi-core-v0.6.19) - 2026-07-04 ### Added diff --git a/README.md b/README.md index 8d293dbf..23a48a5c 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]; From 5f18315f763a7ede3a1cef1db2f586b54d9793f1 Mon Sep 17 00:00:00 2001 From: MoosaTae Date: Sat, 11 Jul 2026 21:14:55 +0700 Subject: [PATCH 2/3] fix: migrate legacy thumbwheel tap default --- crates/openlogi-core/src/config.rs | 81 ++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs index f5da39ba..0899b323 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -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. @@ -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)] @@ -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) } @@ -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] @@ -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. @@ -1389,12 +1412,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] From c6beeeb7b7172ae1519d7e8f4639eb7efa45d00d Mon Sep 17 00:00:00 2001 From: MoosaTae Date: Sat, 11 Jul 2026 21:45:28 +0700 Subject: [PATCH 3/3] fix: gate thumbwheel tap on device capability --- CHANGELOG.md | 2 +- crates/openlogi-agent-core/src/ipc.rs | 3 +- .../openlogi-agent-core/tests/wire_format.rs | 5 +- crates/openlogi-core/src/config.rs | 1 + crates/openlogi-core/src/device.rs | 8 +++ crates/openlogi-core/src/diagnostics.rs | 1 + crates/openlogi-gui/src/app.rs | 2 + .../openlogi-gui/src/mouse_model/geometry.rs | 58 ++++++++++++++----- crates/openlogi-gui/src/mouse_model/view.rs | 17 ++++-- crates/openlogi-gui/src/state/devices.rs | 1 + crates/openlogi-hid/src/inventory/features.rs | 19 +++++- 11 files changed, 93 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f322a37a..474452be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- *(thumbwheel)* expose capacitive tap separately, default it to Do Nothing, and keep rotation capture available on devices without tap support +- *(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 diff --git a/crates/openlogi-agent-core/src/ipc.rs b/crates/openlogi-agent-core/src/ipc.rs index 37e79e7c..d56d9f7b 100644 --- a/crates/openlogi-agent-core/src/ipc.rs +++ b/crates/openlogi-agent-core/src/ipc.rs @@ -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 diff --git a/crates/openlogi-agent-core/tests/wire_format.rs b/crates/openlogi-agent-core/tests/wire_format.rs index e0c159b7..f4b86b78 100644 --- a/crates/openlogi-agent-core/tests/wire_format.rs +++ b/crates/openlogi-agent-core/tests/wire_format.rs @@ -61,7 +61,7 @@ fn assert_wire(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 @@ -157,12 +157,13 @@ fn device_inventory() { pointer: true, lighting: false, scroll_inversion: false, + thumbwheel_tap: true, }), }], }]; assert_wire( &inventory, - "010d426f6c74205265636569766572fb6d04fb48c501084630304443414645010101094d58204d535452335301fb34b000010150020001030106323134304c5a0102030400010100fb34b0fb8240000b0101010000", + "010d426f6c74205265636569766572fb6d04fb48c501084630304443414645010101094d58204d535452335301fb34b000010150020001030106323134304c5a0102030400010100fb34b0fb8240000b010101000001", ); } diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs index 0899b323..9b5baba1 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -1230,6 +1230,7 @@ mod tests { pointer: true, lighting: false, scroll_inversion: false, + thumbwheel_tap: false, }, }; cfg.set_device_identity("2b034", mouse.clone()); diff --git a/crates/openlogi-core/src/device.rs b/crates/openlogi-core/src/device.rs index 583257d1..9a694f6a 100644 --- a/crates/openlogi-core/src/device.rs +++ b/crates/openlogi-core/src/device.rs @@ -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 { @@ -105,6 +109,7 @@ impl Capabilities { pointer: has(&POINTER), lighting: has(&LIGHTING), scroll_inversion: false, + thumbwheel_tap: false, } } @@ -121,6 +126,7 @@ impl Capabilities { pointer: true, lighting: false, scroll_inversion: false, + thumbwheel_tap: false, }, DeviceKind::Keyboard => Self { lighting: true, @@ -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. @@ -304,6 +311,7 @@ mod tests { pointer: false, lighting: true, scroll_inversion: false, + thumbwheel_tap: false, } ); // No driving features → nothing offered. diff --git a/crates/openlogi-core/src/diagnostics.rs b/crates/openlogi-core/src/diagnostics.rs index fd54a09b..af702263 100644 --- a/crates/openlogi-core/src/diagnostics.rs +++ b/crates/openlogi-core/src/diagnostics.rs @@ -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(), diff --git a/crates/openlogi-gui/src/app.rs b/crates/openlogi-gui/src/app.rs index 9b455f7c..01b3dd8f 100644 --- a/crates/openlogi-gui/src/app.rs +++ b/crates/openlogi-gui/src/app.rs @@ -1858,6 +1858,7 @@ mod tests { pointer: true, lighting: false, scroll_inversion: false, + thumbwheel_tap: false, }); // After 0x0005 kind-correction the record has kind=Mouse, not Keyboard. let tabs = DetailTab::tabs_for(&record(DeviceKind::Mouse, caps)); @@ -1876,6 +1877,7 @@ mod tests { pointer: false, lighting: true, scroll_inversion: false, + thumbwheel_tap: false, }); let tabs = DetailTab::tabs_for(&record(DeviceKind::Keyboard, caps)); assert!( diff --git a/crates/openlogi-gui/src/mouse_model/geometry.rs b/crates/openlogi-gui/src/mouse_model/geometry.rs index 7878210a..a6a9947e 100644 --- a/crates/openlogi-gui/src/mouse_model/geometry.rs +++ b/crates/openlogi-gui/src/mouse_model/geometry.rs @@ -75,7 +75,12 @@ pub fn asset_has_button_labels(asset: &ResolvedAsset) -> bool { clippy::cast_precision_loss, reason = "device images are < 4096 px on either axis — well within f32 mantissa" )] -pub fn asset_hotspots_for_png(asset: &ResolvedAsset, mouse_w: f32, mouse_h: f32) -> Vec { +pub fn asset_hotspots_for_png( + asset: &ResolvedAsset, + mouse_w: f32, + mouse_h: f32, + supports_thumbwheel_tap: bool, +) -> Vec { let png_w = asset.png_width as f32; let origin_w = asset .metadata @@ -110,31 +115,29 @@ pub fn asset_hotspots_for_png(asset: &ResolvedAsset, mouse_w: f32, mouse_h: f32) }) .collect(); - with_thumbwheel_controls(hotspots) + with_thumbwheel_controls(hotspots, supports_thumbwheel_tap) } -/// Replace the metadata's single thumb-wheel marker with three independent, -/// non-overlapping controls for rotation up, capacitive tap, and rotation down. +/// Replace the metadata's single thumb-wheel marker with independent, +/// non-overlapping rotation controls and, when the device reports it, a +/// capacitive tap control. /// /// No-op when the device has no thumb wheel. -fn with_thumbwheel_controls(mut hotspots: Vec) -> Vec { +fn with_thumbwheel_controls( + mut hotspots: Vec, + supports_thumbwheel_tap: bool, +) -> Vec { let Some(wheel) = hotspots.iter().find(|h| h.id == ButtonId::Thumbwheel) else { return hotspots; }; let center_y = wheel.y + wheel.h / 2.; - let controls = [ + let mut controls = vec![ Hotspot { id: ButtonId::ThumbwheelScrollUp, 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: center_y + THUMBWHEEL_CONTROL_SPACING - THUMBWHEEL_CONTROL_HEIGHT / 2., @@ -142,6 +145,17 @@ fn with_thumbwheel_controls(mut hotspots: Vec) -> Vec { ..*wheel }, ]; + if supports_thumbwheel_tap { + controls.insert( + 1, + Hotspot { + id: ButtonId::Thumbwheel, + y: center_y - THUMBWHEEL_CONTROL_HEIGHT / 2., + h: THUMBWHEEL_CONTROL_HEIGHT, + ..*wheel + }, + ); + } hotspots.retain(|h| h.id != ButtonId::Thumbwheel); hotspots.extend(controls); hotspots @@ -259,7 +273,7 @@ mod tests { w: ASSET_HOTSPOT, h: ASSET_HOTSPOT, }; - let out = with_thumbwheel_controls(vec![wheel]); + let out = with_thumbwheel_controls(vec![wheel], true); 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)); @@ -279,6 +293,22 @@ mod tests { ); } + #[test] + fn thumbwheel_without_single_tap_exposes_rotation_only() { + let wheel = Hotspot { + id: ButtonId::Thumbwheel, + x: 100., + y: 200., + w: ASSET_HOTSPOT, + h: ASSET_HOTSPOT, + }; + let out = with_thumbwheel_controls(vec![wheel], false); + assert_eq!( + out.iter().map(|h| h.id).collect::>(), + vec![ButtonId::ThumbwheelScrollUp, ButtonId::ThumbwheelScrollDown,] + ); + } + #[test] fn no_thumbwheel_leaves_hotspots_untouched() { let middle = Hotspot { @@ -288,7 +318,7 @@ mod tests { w: ASSET_HOTSPOT, h: ASSET_HOTSPOT, }; - let out = with_thumbwheel_controls(vec![middle]); + let out = with_thumbwheel_controls(vec![middle], true); assert_eq!(out.len(), 1); assert_eq!(out[0].id, ButtonId::MiddleClick); } diff --git a/crates/openlogi-gui/src/mouse_model/view.rs b/crates/openlogi-gui/src/mouse_model/view.rs index c25801d2..94c3d1ae 100644 --- a/crates/openlogi-gui/src/mouse_model/view.rs +++ b/crates/openlogi-gui/src/mouse_model/view.rs @@ -90,7 +90,7 @@ impl MouseModelView { impl Render for MouseModelView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let (asset, active, bindings, gesture_owner, glow) = cx + let (asset, active, bindings, gesture_owner, glow, supports_thumbwheel_tap) = cx .try_global::() .map(|s| { ( @@ -99,6 +99,9 @@ impl Render for MouseModelView { s.button_bindings.clone(), s.current_gesture_owner(), s.current_record().and_then(|r| keyboard_glow(s, r)), + s.current_record() + .and_then(|r| r.capabilities) + .is_some_and(|caps| caps.thumbwheel_tap), ) }) .unwrap_or_default(); @@ -117,8 +120,12 @@ impl Render for MouseModelView { let content_w = (viewport_w - MODEL_HORIZONTAL_RESERVE).clamp(MODEL_MIN_CONTENT_W, MODEL_CONTENT_MAX_W); let max_image_w = (content_w - gutter).max(MODEL_MIN_CONTENT_W / 2.); - let (mouse_w, mouse_h, hotspots, labels) = - scaled_model(asset.as_ref(), target_h, max_image_w); + let (mouse_w, mouse_h, hotspots, labels) = scaled_model( + asset.as_ref(), + target_h, + max_image_w, + supports_thumbwheel_tap, + ); let canvas_w = gutter + mouse_w; let canvas_h = mouse_h; @@ -215,16 +222,18 @@ fn scaled_model( asset: Option<&ResolvedAsset>, target_h: f32, max_w: f32, + supports_thumbwheel_tap: bool, ) -> (f32, f32, Vec, Vec