diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md new file mode 100644 index 00000000..287efe41 --- /dev/null +++ b/IMPROVEMENTS.md @@ -0,0 +1,6 @@ +# Improvements + +- Add a `--device` selector to `openlogi diag features`; `diag controls` already has one, but feature dumps cannot currently isolate one paired device when several are connected. +- Implement the G502 onboard/gaming-button protocol before promoting the currently read-only DPI Up/Down, DPI Shift, Smart Shift, wheel tilt, profile cycle, G-Shift, or other non-standard controls to editable runtime-dispatched buttons. +- Add a cross-platform hook-event diagnostic that prints raw mouse button numbers/codes so G502 extra buttons can be verified on macOS, Linux, and Windows without guessing. +- Extend the asset pipeline/index for G502-family renders and hotspot metadata if the current OpenLogi asset cache does not include those depots. diff --git a/crates/openlogi-agent-core/src/bindings.rs b/crates/openlogi-agent-core/src/bindings.rs index a416709c..a1b93679 100644 --- a/crates/openlogi-agent-core/src/bindings.rs +++ b/crates/openlogi-agent-core/src/bindings.rs @@ -12,7 +12,7 @@ use openlogi_core::binding::{ use openlogi_core::config::Config; /// Effective per-button single-action map for the device `config_key`, with -/// `app_bundle`'s per-app overlay applied. Unset buttons fall back to +/// `app_bundle`'s per-app overlay applied. Unset bindable buttons fall back to /// [`default_binding`]. /// /// This is the map the OS hook and the HID++ button-press path consume, so a @@ -28,12 +28,15 @@ pub fn bindings_for( let stored = config_key .map(|key| config.effective_bindings(key, app_bundle)) .unwrap_or_default(); - let mut bindings: BTreeMap = ButtonId::ALL + let mut bindings: BTreeMap = ButtonId::BINDABLE .iter() .copied() .map(|b| (b, default_binding(b))) .collect(); for (k, binding) in stored { + if !k.is_bindable() { + continue; + } // A gesture binding with no explicit `Click` has no opinion on the // plain-press action, so leave the button's default seed in place rather // than clobbering it with the `Action::None` that `click_action()` would @@ -160,6 +163,21 @@ mod tests { ); } + #[test] + fn display_only_controls_are_not_seeded_or_projected() { + let mut cfg = Config::default(); + cfg.set_binding( + "g502", + ButtonId::DpiUp, + Binding::Single(Action::SetDpiPreset(2)), + ); + + let projected = bindings_for(&cfg, Some("g502"), None); + + assert!(!projected.contains_key(&ButtonId::DpiUp)); + assert!(!projected.contains_key(&ButtonId::SmartShift)); + } + #[test] fn oshook_gestures_collects_only_os_hook_gesture_buttons() { let mut cfg = Config::default(); diff --git a/crates/openlogi-cli/src/cmd/diag/lighting.rs b/crates/openlogi-cli/src/cmd/diag/lighting.rs index 3ae708e8..7586213c 100644 --- a/crates/openlogi-cli/src/cmd/diag/lighting.rs +++ b/crates/openlogi-cli/src/cmd/diag/lighting.rs @@ -1,16 +1,17 @@ -//! `openlogi diag lighting ` — set a wired RGB keyboard to a solid -//! colour via HID++ `PerKeyLighting` (0x8080). -//! -//! Targets the first online direct-attached (USB) Logitech device — i.e. a -//! wired G-series keyboard — by VID/PID, so it isn't tied to one model. +//! `openlogi diag lighting ` — set an online RGB device to a solid +//! colour via the same HID++ lighting write path the GUI uses. use anyhow::{Result, anyhow}; use clap::{Args, ValueEnum}; -use openlogi_hid::{DeviceRoute, LightingMethod}; +use openlogi_hid::LightingMethod; + +use super::select_device; + +const LIGHTING_FEATURES: &[u16] = &[0x8070, 0x8071, 0x8080, 0x8081]; #[derive(Debug, Clone, Copy, ValueEnum)] pub enum Method { - /// Prefer 0x8070 ColorLedEffects, fall back to 0x8080 per-key (default). + /// Prefer effect engines, then fall back to 0x8080 per-key (default). Auto, /// Force 0x8070 ColorLedEffects (the fixed-effect onboard override). Effects, @@ -33,8 +34,8 @@ pub struct LightingArgs { /// Colour as `RRGGBB` hex (e.g. `ff0000` for red). pub color: String, - /// Run against the wired device whose name contains this string - /// (case-insensitive). Useful when several keyboards are connected. + /// Run against the online device whose name contains this string + /// (case-insensitive). Useful when several devices are connected. #[arg(long, value_name = "NAME")] pub device: Option, @@ -54,41 +55,12 @@ pub async fn run(args: LightingArgs) -> Result<()> { let g = ((rgb >> 8) & 0xff) as u8; let b = (rgb & 0xff) as u8; - let device_query = args.device; - let needle = device_query.as_deref().map(str::to_lowercase); - - let inventories = openlogi_hid::enumerate().await?; - let (route, name) = inventories - .iter() - .find_map(|inv| { - // Direct (USB-wired) devices carry no receiver UID — that's the - // wired keyboard. Bolt/Unifying receivers (mice) are skipped. - if inv.receiver.unique_id.is_some() { - return None; - } - let paired = inv.paired.iter().find(|p| p.online)?; - let name = paired.codename.clone().unwrap_or_else(|| { - format!( - "{:04x}:{:04x}", - inv.receiver.vendor_id, inv.receiver.product_id - ) - }); - if let Some(ref n) = needle - && !name.to_lowercase().contains(n.as_str()) - { - return None; - } - let route = DeviceRoute::Direct { - vendor_id: inv.receiver.vendor_id, - product_id: inv.receiver.product_id, - }; - Some((route, name)) - }) - .ok_or_else(|| match &device_query { - Some(q) => anyhow!("no wired device matches `--device {q}`"), - None => { - anyhow!("no wired (direct-USB) Logitech device found — is the keyboard plugged in?") - } + let device_query = args.device.as_deref(); + let (route, name) = select_device(device_query, LIGHTING_FEATURES) + .await + .map_err(|e| match device_query { + Some(q) => anyhow!("no lighting-capable online device matches `--device {q}`: {e}"), + None => anyhow!("no lighting-capable online device found: {e}"), })?; let method: LightingMethod = args.method.into(); diff --git a/crates/openlogi-cli/src/cmd/diag/mod.rs b/crates/openlogi-cli/src/cmd/diag/mod.rs index 25ac24d0..41833eff 100644 --- a/crates/openlogi-cli/src/cmd/diag/mod.rs +++ b/crates/openlogi-cli/src/cmd/diag/mod.rs @@ -26,7 +26,7 @@ pub enum DiagCmd { Dpi(dpi::DpiArgs), /// Read SmartShift mode → toggle → read back → toggle back → report. Smartshift(smartshift::SmartshiftArgs), - /// Set a wired RGB keyboard to a solid colour (e.g. `ff0000` for red). + /// Set an online RGB device to a solid colour (e.g. `ff0000` for red). Lighting(lighting::LightingArgs), } diff --git a/crates/openlogi-cli/src/cmd/list.rs b/crates/openlogi-cli/src/cmd/list.rs index 4f648052..baac0cfe 100644 --- a/crates/openlogi-cli/src/cmd/list.rs +++ b/crates/openlogi-cli/src/cmd/list.rs @@ -20,8 +20,8 @@ pub async fn run(_args: ListArgs) -> Result<()> { permission: System Settings → Privacy & Security → Input Monitoring." ); println!( - " - hidpp 0.2 only recognises Logi Bolt receivers (PID 0xC548); other \ - receivers (Unifying) aren't surfaced yet." + " - LIGHTSPEED mice are normally already paired to their USB receiver; \ + Add Device is only for supported pairing flows." ); std::process::exit(2); } diff --git a/crates/openlogi-core/src/binding.rs b/crates/openlogi-core/src/binding.rs index 1463492a..686a6bde 100644 --- a/crates/openlogi-core/src/binding.rs +++ b/crates/openlogi-core/src/binding.rs @@ -12,9 +12,12 @@ use std::time::Instant; use serde::{Deserialize, Serialize}; -/// One of the user-rebindable hotspots on a Logi mouse. The order matches the -/// physical layout from front to side; [`ButtonId::ALL`] is consumed by the -/// default-binding generator and the popover trigger list. +/// One logical control on a Logi mouse. The order matches the physical layout +/// from front to side. +/// +/// Not every ID is currently runtime-bindable: some gaming-mouse controls are +/// display-only until their HID++ gaming-button protocol is implemented. Use +/// [`ButtonId::BINDABLE`] when seeding or dispatching user bindings. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub enum ButtonId { LeftClick, @@ -25,7 +28,19 @@ 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 + /// Increase DPI on gaming mice that expose separate DPI buttons. + DpiUp, + /// Decrease DPI on gaming mice that expose separate DPI buttons. + DpiDown, + /// Temporary DPI-shift / sniper button on gaming mice. + DpiShift, + /// Scroll-wheel tilt left on mice that report wheel tilt as a button. + WheelLeft, + /// Scroll-wheel tilt right on mice that report wheel tilt as a button. + WheelRight, + /// Hardware wheel mode button on gaming mice with ratchet/free-spin wheels. + SmartShift, + /// The horizontal thumb wheel's click. Kept in [`ButtonId::BINDABLE`] 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`). @@ -41,7 +56,29 @@ pub enum ButtonId { } impl ButtonId { - pub const ALL: [ButtonId; 10] = [ + /// Every known serializable button/control ID, including display-only + /// controls surfaced by device-family-specific layouts. + pub const ALL: [ButtonId; 16] = [ + ButtonId::LeftClick, + ButtonId::RightClick, + ButtonId::MiddleClick, + ButtonId::Back, + ButtonId::Forward, + ButtonId::DpiToggle, + ButtonId::DpiUp, + ButtonId::DpiDown, + ButtonId::DpiShift, + ButtonId::WheelLeft, + ButtonId::WheelRight, + ButtonId::SmartShift, + ButtonId::Thumbwheel, + ButtonId::ThumbwheelScrollUp, + ButtonId::ThumbwheelScrollDown, + ButtonId::GestureButton, + ]; + + /// Controls whose saved bindings can currently be edited and dispatched. + pub const BINDABLE: [ButtonId; 10] = [ ButtonId::LeftClick, ButtonId::RightClick, ButtonId::MiddleClick, @@ -54,6 +91,11 @@ impl ButtonId { ButtonId::GestureButton, ]; + #[must_use] + pub fn is_bindable(self) -> bool { + Self::BINDABLE.contains(&self) + } + /// Whether this button is one the OS hook (macOS `CGEventTap` / Linux evdev) /// remaps: Middle, Back, or Forward. The primary L/R clicks always pass /// through (suppressing them would brick the mouse), and the DPI / thumb / @@ -79,6 +121,12 @@ impl ButtonId { ButtonId::Back => "Back", ButtonId::Forward => "Forward", ButtonId::DpiToggle => "DPI Toggle", + ButtonId::DpiUp => "DPI Up", + ButtonId::DpiDown => "DPI Down", + ButtonId::DpiShift => "DPI Shift", + ButtonId::WheelLeft => "Wheel Left", + ButtonId::WheelRight => "Wheel Right", + ButtonId::SmartShift => "Smart Shift", ButtonId::Thumbwheel => "Thumb Wheel", ButtonId::ThumbwheelScrollUp => "Thumb Wheel Up", ButtonId::ThumbwheelScrollDown => "Thumb Wheel Down", @@ -862,15 +910,18 @@ pub fn default_binding(button: ButtonId) -> Action { ButtonId::MiddleClick => Action::MiddleClick, ButtonId::Back => Action::BrowserBack, ButtonId::Forward => Action::BrowserForward, - ButtonId::DpiToggle => Action::CycleDpiPresets, + ButtonId::DpiToggle | ButtonId::DpiUp | ButtonId::DpiDown | ButtonId::DpiShift => { + Action::CycleDpiPresets + } + ButtonId::WheelLeft | ButtonId::ThumbwheelScrollDown => Action::HorizontalScrollLeft, + ButtonId::WheelRight | ButtonId::ThumbwheelScrollUp => Action::HorizontalScrollRight, + ButtonId::SmartShift => Action::ToggleSmartShift, ButtonId::Thumbwheel => Action::AppExpose, // 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- // scaled scrolling rather than the discrete per-press burst a button // would get (see `watchers::gesture`). - ButtonId::ThumbwheelScrollUp => Action::HorizontalScrollRight, - ButtonId::ThumbwheelScrollDown => Action::HorizontalScrollLeft, ButtonId::GestureButton => Action::MissionControl, } } diff --git a/crates/openlogi-core/src/device.rs b/crates/openlogi-core/src/device.rs index 583257d1..de0a50cc 100644 --- a/crates/openlogi-core/src/device.rs +++ b/crates/openlogi-core/src/device.rs @@ -73,14 +73,16 @@ impl DeviceKind { reason = "capabilities is a serialized feature-bit DTO; independent booleans keep the IPC/config shape explicit" )] pub struct Capabilities { - /// Reprogrammable buttons — HID++ `0x1b00`–`0x1b04` (ReprogControls). + /// Button bindings the UI/agent can offer. Most devices advertise this via + /// HID++ `0x1b00`–`0x1b04` (ReprogControls); known gaming mice can also be + /// host-hook bindable without exposing that MX-line feature. pub buttons: bool, /// Adjustable pointer resolution — HID++ `0x2201` / `0x2202` (AdjustableDpi). pub pointer: bool, - /// Solid-colour RGB the lighting panel can actually drive — HID++ - /// `ColorLedEffects` (`0x8070`) or `PerKeyLighting` (`0x8080`), the features - /// `set_keyboard_color` writes. Backlight-only families aren't driven by the - /// panel, so they don't flip this and don't earn an inert Lighting tab. + /// Solid-colour RGB the lighting panel can actually drive — HID++ RGB + /// effect engines (`0x8070` / `0x8071`) or per-zone lighting (`0x8080` / + /// `0x8081`). Backlight-only families aren't driven by the panel, so they + /// don't flip this and don't earn an inert Lighting tab. pub lighting: bool, /// Native vertical wheel inversion — HID++ `0x2121 HiResWheel` with the /// firmware-reported `has_invert` capability. @@ -94,11 +96,9 @@ impl Capabilities { pub fn from_feature_ids(ids: &[u16]) -> Self { const BUTTONS: [u16; 5] = [0x1b00, 0x1b01, 0x1b02, 0x1b03, 0x1b04]; const POINTER: [u16; 2] = [0x2201, 0x2202]; - // PerKeyLighting (0x8080) and ColorLedEffects (0x8070) — both now driven - // by `set_keyboard_color` (it prefers 0x8070's fixed effect to override a - // running onboard profile, falling back to 0x8080 per-key). Other families - // (backlight 0x198x) stay out so they don't earn a tab the panel can't drive. - const LIGHTING: [u16; 2] = [0x8080, 0x8070]; + // Include the modern G-series RGB families as well: G502 X PLUS reports + // 0x8071/0x8081 instead of the older 0x8070/0x8080 pair. + const LIGHTING: [u16; 4] = [0x8070, 0x8071, 0x8080, 0x8081]; let has = |family: &[u16]| ids.iter().any(|id| family.contains(id)); Self { buttons: has(&BUTTONS), @@ -108,6 +108,19 @@ impl Capabilities { } } + /// Add measured-by-model capabilities that do not appear in the HID++ + /// feature table. G502-family mice do not expose MX `0x1b04` controls, but + /// their standard mouse buttons are still host-hook bindable. + pub fn include_known_model_support( + &mut self, + model: Option<&DeviceModelInfo>, + name: Option<&str>, + ) { + if is_g502_family(model, name) { + self.buttons = true; + } + } + /// Best-effort capabilities for a device we could not probe (offline / /// never reached), guessed from its [`DeviceKind`]. Used only as a fallback /// when no measured [`Capabilities`] exist — a sleeping mouse should still @@ -131,6 +144,36 @@ impl Capabilities { } } +/// Product/model IDs observed for the G502 family across HID++ model-info, +/// wired USB, and LIGHTSPEED variants. The LIGHTSPEED receiver PID itself is +/// deliberately excluded: a receiver can host non-G502 devices. +const G502_FAMILY_MODEL_IDS: &[u16] = &[ + 0x407e, 0x407f, // G502 LIGHTSPEED wireless PID. + 0x4099, // G502 X PLUS wireless PID (live HID++ model id). + 0x409a, 0xc08b, // G502 HERO USB PID. + 0xc090, 0xc091, 0xc095, // G502 X PLUS secondary live HID++ model id. + 0xc098, 0xc09d, 0xc332, +]; + +/// Whether the runtime identity belongs to the Logitech G502 family. +#[must_use] +pub fn is_g502_family(model: Option<&DeviceModelInfo>, name: Option<&str>) -> bool { + let model_match = model.is_some_and(|info| { + info.model_ids + .iter() + .any(|id| *id != 0 && G502_FAMILY_MODEL_IDS.contains(id)) + }); + model_match || name.is_some_and(name_mentions_g502) +} + +fn name_mentions_g502(name: &str) -> bool { + name.to_ascii_uppercase() + .chars() + .filter(char::is_ascii_alphanumeric) + .collect::() + .contains("G502") +} + /// Coarse battery bucket reported by the device firmware. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -256,7 +299,22 @@ pub struct DeviceInventory { #[cfg(test)] mod tests { - use super::DeviceKind; + use super::{Capabilities, DeviceKind, DeviceModelInfo, DeviceTransports, is_g502_family}; + + fn g502_x_plus_model() -> DeviceModelInfo { + DeviceModelInfo { + entity_count: 1, + serial_number: None, + unit_id: [0x66, 0x2e, 0xa5, 0xf1], + transports: DeviceTransports { + usb: true, + equad: true, + ..Default::default() + }, + model_ids: [0x4099, 0xc095, 0], + extended_model_id: 0, + } + } #[test] fn registry_type_is_case_folded() { @@ -280,6 +338,18 @@ mod tests { assert_eq!(DeviceKind::from_registry_type(""), DeviceKind::Unknown); } + #[test] + fn modern_rgb_effects_enable_lighting_capability() { + assert!( + Capabilities::from_feature_ids(&[0x8071]).lighting, + "G502 X PLUS exposes RGB effects instead of ColorLedEffects" + ); + assert!( + Capabilities::from_feature_ids(&[0x8081]).lighting, + "G502 X PLUS exposes per-key lighting v2 instead of v1" + ); + } + #[test] fn capabilities_track_the_driving_feature_ids() { use super::Capabilities; @@ -313,6 +383,31 @@ mod tests { ); } + #[test] + fn g502_family_matches_live_model_ids() { + assert!(is_g502_family(Some(&g502_x_plus_model()), None)); + } + + #[test] + fn g502_family_matches_marketing_name() { + assert!(is_g502_family(None, Some("Logitech G502 X Plus"))); + assert!(!is_g502_family(None, Some("MX Master 3S"))); + } + + #[test] + fn g502_model_support_exposes_host_button_bindings() { + let mut caps = Capabilities::from_feature_ids(&[0x0001, 0x8071]); + assert!( + !caps.buttons, + "the G502 X Plus does not expose MX ReprogControls" + ); + + caps.include_known_model_support(Some(&g502_x_plus_model()), Some("G502 X PLUS")); + + assert!(caps.buttons); + assert!(caps.lighting); + } + #[test] fn presumed_capabilities_keep_an_unprobed_mouse_configurable() { use super::Capabilities; diff --git a/crates/openlogi-gui/locales/da.yml b/crates/openlogi-gui/locales/da.yml index 2e2f041f..7e5dafde 100644 --- a/crates/openlogi-gui/locales/da.yml +++ b/crates/openlogi-gui/locales/da.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Tildel %{name}" "Unbound": "Ikke tildelt" "Default": "Standard" +"Native": "Indbygget" "5 directions": "5 retninger" "Middle": "Midterknap" "DPI Preset %{index}": "DPI-forindstilling %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Tilbage" "Forward": "Frem" "DPI Toggle": "DPI-skift" +"DPI Up": "DPI op" +"DPI Down": "DPI ned" +"DPI Shift": "DPI-skift" +"Wheel Left": "Hjul venstre" +"Wheel Right": "Hjul højre" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Tommelfingerhjul" "Gesture Button": "Bevægelsesknap" +"View 1": "Visning 1" +"View 2": "Visning 2" "Up": "Op" "Down": "Ned" "Left": "Venstre" diff --git a/crates/openlogi-gui/locales/de.yml b/crates/openlogi-gui/locales/de.yml index 99480ebe..d150c85d 100644 --- a/crates/openlogi-gui/locales/de.yml +++ b/crates/openlogi-gui/locales/de.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "%{name} zuweisen" "Unbound": "Nicht zugewiesen" "Default": "Standard" +"Native": "Nativ" "5 directions": "5 Richtungen" "Middle": "Mitteltaste" "DPI Preset %{index}": "DPI-Voreinstellung %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Zurück" "Forward": "Vor" "DPI Toggle": "DPI-Umschaltung" +"DPI Up": "DPI erhöhen" +"DPI Down": "DPI verringern" +"DPI Shift": "DPI Shift" +"Wheel Left": "Rad links" +"Wheel Right": "Rad rechts" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Daumenrad" "Gesture Button": "Gestentaste" +"View 1": "Ansicht 1" +"View 2": "Ansicht 2" "Up": "Oben" "Down": "Unten" "Left": "Links" diff --git a/crates/openlogi-gui/locales/el.yml b/crates/openlogi-gui/locales/el.yml index 3b96ae1c..c4d43987 100644 --- a/crates/openlogi-gui/locales/el.yml +++ b/crates/openlogi-gui/locales/el.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Αντιστοίχιση %{name}" "Unbound": "Χωρίς αντιστοίχιση" "Default": "Προεπιλογή" +"Native": "Εγγενές" "5 directions": "5 κατευθύνσεις" "Middle": "Μεσαίο" "DPI Preset %{index}": "Προεπιλογή DPI %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Πίσω" "Forward": "Εμπρός" "DPI Toggle": "Εναλλαγή DPI" +"DPI Up": "Αύξηση DPI" +"DPI Down": "Μείωση DPI" +"DPI Shift": "Μετατόπιση DPI" +"Wheel Left": "Τροχός αριστερά" +"Wheel Right": "Τροχός δεξιά" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Πλαϊνός τροχός" "Gesture Button": "Κουμπί χειρονομιών" +"View 1": "Προβολή 1" +"View 2": "Προβολή 2" "Up": "Πάνω" "Down": "Κάτω" "Left": "Αριστερά" diff --git a/crates/openlogi-gui/locales/en.yml b/crates/openlogi-gui/locales/en.yml index 7b567aa7..a03ff933 100644 --- a/crates/openlogi-gui/locales/en.yml +++ b/crates/openlogi-gui/locales/en.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Bind %{name}" "Unbound": "Unbound" "Default": "Default" +"Native": "Native" "5 directions": "5 directions" "Middle": "Middle" "DPI Preset %{index}": "DPI Preset %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Back" "Forward": "Forward" "DPI Toggle": "DPI Toggle" +"DPI Up": "DPI Up" +"DPI Down": "DPI Down" +"DPI Shift": "DPI Shift" +"Wheel Left": "Wheel Left" +"Wheel Right": "Wheel Right" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Thumb Wheel" "Gesture Button": "Gesture Button" +"View 1": "View 1" +"View 2": "View 2" "Up": "Up" "Down": "Down" "Left": "Left" diff --git a/crates/openlogi-gui/locales/es.yml b/crates/openlogi-gui/locales/es.yml index 9374362f..4100d4b1 100644 --- a/crates/openlogi-gui/locales/es.yml +++ b/crates/openlogi-gui/locales/es.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Asignar %{name}" "Unbound": "Sin asignar" "Default": "Predeterminado" +"Native": "Nativo" "5 directions": "5 direcciones" "Middle": "Central" "DPI Preset %{index}": "Preajuste de DPI %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Atrás" "Forward": "Adelante" "DPI Toggle": "Cambiar DPI" +"DPI Up": "Subir DPI" +"DPI Down": "Bajar DPI" +"DPI Shift": "Cambio de DPI" +"Wheel Left": "Rueda izquierda" +"Wheel Right": "Rueda derecha" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Rueda lateral" "Gesture Button": "Botón de gestos" +"View 1": "Vista 1" +"View 2": "Vista 2" "Up": "Arriba" "Down": "Abajo" "Left": "Izquierda" diff --git a/crates/openlogi-gui/locales/fi.yml b/crates/openlogi-gui/locales/fi.yml index 44fe3b8c..075ceb92 100644 --- a/crates/openlogi-gui/locales/fi.yml +++ b/crates/openlogi-gui/locales/fi.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Määritä %{name}" "Unbound": "Ei määritetty" "Default": "Oletus" +"Native": "Natiivi" "5 directions": "5 suuntaa" "Middle": "Keskipainike" "DPI Preset %{index}": "DPI-esiasetus %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Takaisin" "Forward": "Eteenpäin" "DPI Toggle": "DPI-vaihto" +"DPI Up": "DPI ylös" +"DPI Down": "DPI alas" +"DPI Shift": "DPI-vaihto" +"Wheel Left": "Rulla vasemmalle" +"Wheel Right": "Rulla oikealle" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Peukalorulla" "Gesture Button": "Eletoiminto" +"View 1": "Näkymä 1" +"View 2": "Näkymä 2" "Up": "Ylös" "Down": "Alas" "Left": "Vasen" diff --git a/crates/openlogi-gui/locales/fr.yml b/crates/openlogi-gui/locales/fr.yml index 1317cd65..9b20cdc8 100644 --- a/crates/openlogi-gui/locales/fr.yml +++ b/crates/openlogi-gui/locales/fr.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Attribuer %{name}" "Unbound": "Non attribué" "Default": "Par défaut" +"Native": "Natif" "5 directions": "5 directions" "Middle": "Milieu" "DPI Preset %{index}": "Préréglage DPI %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Précédent" "Forward": "Suivant" "DPI Toggle": "Bascule DPI" +"DPI Up": "Augmenter DPI" +"DPI Down": "Réduire DPI" +"DPI Shift": "DPI Shift" +"Wheel Left": "Molette gauche" +"Wheel Right": "Molette droite" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Molette latérale" "Gesture Button": "Bouton de gestes" +"View 1": "Vue 1" +"View 2": "Vue 2" "Up": "Haut" "Down": "Bas" "Left": "Gauche" diff --git a/crates/openlogi-gui/locales/it.yml b/crates/openlogi-gui/locales/it.yml index 08b290b7..13b2af91 100644 --- a/crates/openlogi-gui/locales/it.yml +++ b/crates/openlogi-gui/locales/it.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Associa %{name}" "Unbound": "Non associato" "Default": "Predefinito" +"Native": "Nativo" "5 directions": "5 direzioni" "Middle": "Centrale" "DPI Preset %{index}": "Preset DPI %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Indietro" "Forward": "Avanti" "DPI Toggle": "Cambia DPI" +"DPI Up": "Aumenta DPI" +"DPI Down": "Riduci DPI" +"DPI Shift": "DPI Shift" +"Wheel Left": "Rotella a sinistra" +"Wheel Right": "Rotella a destra" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Volante da pollice" "Gesture Button": "Pulsante gesture" +"View 1": "Vista 1" +"View 2": "Vista 2" "Up": "Su" "Down": "Giù" "Left": "Sinistra" diff --git a/crates/openlogi-gui/locales/ja.yml b/crates/openlogi-gui/locales/ja.yml index 61c9d9d0..cc630140 100644 --- a/crates/openlogi-gui/locales/ja.yml +++ b/crates/openlogi-gui/locales/ja.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "%{name} を割り当て" "Unbound": "未割り当て" "Default": "デフォルト" +"Native": "ネイティブ" "5 directions": "5 方向" "Middle": "中ボタン" "DPI Preset %{index}": "DPI プリセット %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "戻る" "Forward": "進む" "DPI Toggle": "DPI 切り替え" +"DPI Up": "DPI 上げる" +"DPI Down": "DPI 下げる" +"DPI Shift": "DPI シフト" +"Wheel Left": "ホイール左" +"Wheel Right": "ホイール右" +"Smart Shift": "Smart Shift" "Thumb Wheel": "サムホイール" "Gesture Button": "ジェスチャーボタン" +"View 1": "ビュー 1" +"View 2": "ビュー 2" "Up": "上" "Down": "下" "Left": "左" diff --git a/crates/openlogi-gui/locales/ko.yml b/crates/openlogi-gui/locales/ko.yml index c4504c0e..f8dbd264 100644 --- a/crates/openlogi-gui/locales/ko.yml +++ b/crates/openlogi-gui/locales/ko.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "%{name} 지정" "Unbound": "할당되지 않음" "Default": "기본값" +"Native": "기본 동작" "5 directions": "5방향" "Middle": "가운데 버튼" "DPI Preset %{index}": "DPI 프리셋 %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "뒤로" "Forward": "앞으로" "DPI Toggle": "DPI 전환" +"DPI Up": "DPI 올리기" +"DPI Down": "DPI 내리기" +"DPI Shift": "DPI 시프트" +"Wheel Left": "휠 왼쪽" +"Wheel Right": "휠 오른쪽" +"Smart Shift": "Smart Shift" "Thumb Wheel": "썸휠" "Gesture Button": "제스처 버튼" +"View 1": "보기 1" +"View 2": "보기 2" "Up": "위" "Down": "아래" "Left": "왼쪽" diff --git a/crates/openlogi-gui/locales/nb.yml b/crates/openlogi-gui/locales/nb.yml index 2f0e55c4..87ab95c9 100644 --- a/crates/openlogi-gui/locales/nb.yml +++ b/crates/openlogi-gui/locales/nb.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Tilordne %{name}" "Unbound": "Ikke tildelt" "Default": "Standard" +"Native": "Innebygd" "5 directions": "5 retninger" "Middle": "Midtknapp" "DPI Preset %{index}": "DPI-forhåndsinnstilling %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Tilbake" "Forward": "Frem" "DPI Toggle": "DPI-veksling" +"DPI Up": "DPI opp" +"DPI Down": "DPI ned" +"DPI Shift": "DPI Shift" +"Wheel Left": "Hjul venstre" +"Wheel Right": "Hjul høyre" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Tommelhjul" "Gesture Button": "Bevegelsesknapp" +"View 1": "Visning 1" +"View 2": "Visning 2" "Up": "Opp" "Down": "Ned" "Left": "Venstre" diff --git a/crates/openlogi-gui/locales/nl.yml b/crates/openlogi-gui/locales/nl.yml index 51f32483..5ce23bf7 100644 --- a/crates/openlogi-gui/locales/nl.yml +++ b/crates/openlogi-gui/locales/nl.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "%{name} toewijzen" "Unbound": "Niet toegewezen" "Default": "Standaard" +"Native": "Systeemeigen" "5 directions": "5 richtingen" "Middle": "Middenknop" "DPI Preset %{index}": "DPI-voorinstelling %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Vorige" "Forward": "Volgende" "DPI Toggle": "DPI wisselen" +"DPI Up": "DPI omhoog" +"DPI Down": "DPI omlaag" +"DPI Shift": "DPI-shift" +"Wheel Left": "Wiel links" +"Wheel Right": "Wiel rechts" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Duimwiel" "Gesture Button": "Gebarenknop" +"View 1": "Weergave 1" +"View 2": "Weergave 2" "Up": "Omhoog" "Down": "Omlaag" "Left": "Links" diff --git a/crates/openlogi-gui/locales/pl.yml b/crates/openlogi-gui/locales/pl.yml index fbec6b45..99a6762d 100644 --- a/crates/openlogi-gui/locales/pl.yml +++ b/crates/openlogi-gui/locales/pl.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Przypisz %{name}" "Unbound": "Nieprzypisane" "Default": "Domyślne" +"Native": "Natywne" "5 directions": "5 kierunków" "Middle": "Środkowy" "DPI Preset %{index}": "Ustawienie wstępne DPI %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Wstecz" "Forward": "Do przodu" "DPI Toggle": "Przełącznik DPI" +"DPI Up": "Zwiększ DPI" +"DPI Down": "Zmniejsz DPI" +"DPI Shift": "DPI Shift" +"Wheel Left": "Kółko w lewo" +"Wheel Right": "Kółko w prawo" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Rolka kciukowa" "Gesture Button": "Przycisk gestów" +"View 1": "Widok 1" +"View 2": "Widok 2" "Up": "W górę" "Down": "W dół" "Left": "W lewo" diff --git a/crates/openlogi-gui/locales/pt-BR.yml b/crates/openlogi-gui/locales/pt-BR.yml index 4508e5ae..f10658fc 100644 --- a/crates/openlogi-gui/locales/pt-BR.yml +++ b/crates/openlogi-gui/locales/pt-BR.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Atribuir %{name}" "Unbound": "Não atribuído" "Default": "Padrão" +"Native": "Nativo" "5 directions": "5 direções" "Middle": "Meio" "DPI Preset %{index}": "Predefinição de DPI %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Voltar" "Forward": "Avançar" "DPI Toggle": "Alternar DPI" +"DPI Up": "Aumentar DPI" +"DPI Down": "Diminuir DPI" +"DPI Shift": "DPI Shift" +"Wheel Left": "Roda para a esquerda" +"Wheel Right": "Roda para a direita" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Roda do Polegar" "Gesture Button": "Botão de Gesto" +"View 1": "Vista 1" +"View 2": "Vista 2" "Up": "Cima" "Down": "Baixo" "Left": "Esquerda" diff --git a/crates/openlogi-gui/locales/pt-PT.yml b/crates/openlogi-gui/locales/pt-PT.yml index 0a13f08c..7770d095 100644 --- a/crates/openlogi-gui/locales/pt-PT.yml +++ b/crates/openlogi-gui/locales/pt-PT.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Atribuir %{name}" "Unbound": "Não atribuído" "Default": "Predefinição" +"Native": "Nativo" "5 directions": "5 direções" "Middle": "Central" "DPI Preset %{index}": "Predefinição de DPI %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Retroceder" "Forward": "Avançar" "DPI Toggle": "Alternar DPI" +"DPI Up": "Aumentar DPI" +"DPI Down": "Reduzir DPI" +"DPI Shift": "DPI Shift" +"Wheel Left": "Roda à esquerda" +"Wheel Right": "Roda à direita" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Roda de polegar" "Gesture Button": "Botão de gesto" +"View 1": "Vista 1" +"View 2": "Vista 2" "Up": "Cima" "Down": "Baixo" "Left": "Esquerda" diff --git a/crates/openlogi-gui/locales/ru.yml b/crates/openlogi-gui/locales/ru.yml index 32e1be75..2ef42e99 100644 --- a/crates/openlogi-gui/locales/ru.yml +++ b/crates/openlogi-gui/locales/ru.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Назначить %{name}" "Unbound": "Не назначено" "Default": "По умолчанию" +"Native": "Системное" "5 directions": "5 направлений" "Middle": "Средняя" "DPI Preset %{index}": "Пресет DPI %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Назад" "Forward": "Вперёд" "DPI Toggle": "Переключение DPI" +"DPI Up": "Повысить DPI" +"DPI Down": "Понизить DPI" +"DPI Shift": "DPI Shift" +"Wheel Left": "Колесо влево" +"Wheel Right": "Колесо вправо" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Колесо под большой палец" "Gesture Button": "Кнопка жестов" +"View 1": "Вид 1" +"View 2": "Вид 2" "Up": "Вверх" "Down": "Вниз" "Left": "Влево" diff --git a/crates/openlogi-gui/locales/sv.yml b/crates/openlogi-gui/locales/sv.yml index cc6cf27f..a35a707d 100644 --- a/crates/openlogi-gui/locales/sv.yml +++ b/crates/openlogi-gui/locales/sv.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Bind %{name}" "Unbound": "Ej tilldelad" "Default": "Standard" +"Native": "Inbyggd" "5 directions": "5 riktningar" "Middle": "Mittenknapp" "DPI Preset %{index}": "DPI-förinställning %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Bakåt" "Forward": "Framåt" "DPI Toggle": "DPI-växling" +"DPI Up": "DPI upp" +"DPI Down": "DPI ned" +"DPI Shift": "DPI-shift" +"Wheel Left": "Hjul vänster" +"Wheel Right": "Hjul höger" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Tumhjul" "Gesture Button": "Gestknapp" +"View 1": "Vy 1" +"View 2": "Vy 2" "Up": "Upp" "Down": "Ned" "Left": "Vänster" diff --git a/crates/openlogi-gui/locales/zh-CN.yml b/crates/openlogi-gui/locales/zh-CN.yml index aa217b46..cf906b91 100644 --- a/crates/openlogi-gui/locales/zh-CN.yml +++ b/crates/openlogi-gui/locales/zh-CN.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "绑定 %{name}" "Unbound": "未绑定" "Default": "默认" +"Native": "原生" "5 directions": "5 个方向" "Middle": "中键" "DPI Preset %{index}": "灵敏度预设 %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "后退" "Forward": "前进" "DPI Toggle": "灵敏度切换" +"DPI Up": "灵敏度提高" +"DPI Down": "灵敏度降低" +"DPI Shift": "灵敏度切换键" +"Wheel Left": "滚轮左倾" +"Wheel Right": "滚轮右倾" +"Smart Shift": "智能切换" "Thumb Wheel": "拇指滚轮" "Gesture Button": "手势按钮" +"View 1": "视图 1" +"View 2": "视图 2" "Up": "上" "Down": "下" "Left": "左" diff --git a/crates/openlogi-gui/locales/zh-HK.yml b/crates/openlogi-gui/locales/zh-HK.yml index e11ad21a..235eba36 100644 --- a/crates/openlogi-gui/locales/zh-HK.yml +++ b/crates/openlogi-gui/locales/zh-HK.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "綁定 %{name}" "Unbound": "未綁定" "Default": "預設" +"Native": "原生" "5 directions": "5 個方向" "Middle": "中鍵" "DPI Preset %{index}": "靈敏度預設 %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "後退" "Forward": "前進" "DPI Toggle": "靈敏度切換" +"DPI Up": "靈敏度提高" +"DPI Down": "靈敏度降低" +"DPI Shift": "靈敏度切換鍵" +"Wheel Left": "滾輪左傾" +"Wheel Right": "滾輪右傾" +"Smart Shift": "智能切換" "Thumb Wheel": "拇指滾輪" "Gesture Button": "手勢按鈕" +"View 1": "視圖 1" +"View 2": "視圖 2" "Up": "上" "Down": "下" "Left": "左" diff --git a/crates/openlogi-gui/locales/zh-TW.yml b/crates/openlogi-gui/locales/zh-TW.yml index 5d51239b..36f2267b 100644 --- a/crates/openlogi-gui/locales/zh-TW.yml +++ b/crates/openlogi-gui/locales/zh-TW.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "設定 %{name}" "Unbound": "未設定" "Default": "預設" +"Native": "原生" "5 directions": "5 個方向" "Middle": "中鍵" "DPI Preset %{index}": "靈敏度預設 %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "後退" "Forward": "前進" "DPI Toggle": "靈敏度切換" +"DPI Up": "靈敏度提高" +"DPI Down": "靈敏度降低" +"DPI Shift": "靈敏度切換鍵" +"Wheel Left": "滾輪左傾" +"Wheel Right": "滾輪右傾" +"Smart Shift": "智慧切換" "Thumb Wheel": "拇指滾輪" "Gesture Button": "手勢按鈕" +"View 1": "檢視 1" +"View 2": "檢視 2" "Up": "上" "Down": "下" "Left": "左" diff --git a/crates/openlogi-gui/src/app.rs b/crates/openlogi-gui/src/app.rs index 5a171e9d..1cab8c95 100644 --- a/crates/openlogi-gui/src/app.rs +++ b/crates/openlogi-gui/src/app.rs @@ -18,6 +18,7 @@ use gpui_component::{ }; use openlogi_core::device::{ BatteryInfo, BatteryLevel, BatteryStatus, Capabilities, DeviceInventory, DeviceKind, + is_g502_family, }; use openlogi_hid::DeviceRoute; use tracing::info; @@ -95,8 +96,7 @@ impl DetailTab { let caps = record .capabilities .unwrap_or_else(|| Capabilities::presumed_from_kind(record.kind)); - let can_show_mouse_model = record.asset.is_some() - || matches!(record.kind, DeviceKind::Mouse | DeviceKind::Trackball); + let can_show_mouse_model = has_mouse_model_surface(record); let mut tabs = Vec::new(); if caps.buttons && can_show_mouse_model { tabs.push(Self::Buttons); @@ -129,6 +129,15 @@ impl DetailTab { } } +fn has_mouse_model_surface(record: &DeviceRecord) -> bool { + let name = record.codename.as_deref().unwrap_or(&record.display_name); + record.asset.is_some() + || matches!(record.kind, DeviceKind::Mouse | DeviceKind::Trackball) + // G502 can be identified from model info even when 0x0005 kind fails; + // it has a family-authored mouse model, so don't depend on kind here. + || is_g502_family(record.model_info.as_ref(), Some(name)) +} + /// Root application view. pub struct AppView { focus_handle: FocusHandle, @@ -1728,6 +1737,7 @@ mod tests { use super::{ Capabilities, DetailTab, DeviceKind, DeviceRecord, DeviceRoute, connection_icon_path, }; + use openlogi_core::device::{DeviceModelInfo, DeviceTransports}; #[test] fn connection_icon_matches_route() { @@ -1841,4 +1851,25 @@ mod tests { let tabs = DetailTab::tabs_for(&record(DeviceKind::Unknown, None)); assert_eq!(tabs, vec![DetailTab::Device]); } + + #[test] + fn g502_model_info_shows_buttons_even_when_kind_is_unknown() { + let mut g502 = record( + DeviceKind::Unknown, + Some(Capabilities { + buttons: true, + ..Capabilities::default() + }), + ); + g502.model_info = Some(DeviceModelInfo { + entity_count: 1, + serial_number: None, + unit_id: [0; 4], + transports: DeviceTransports::default(), + model_ids: [0x4099, 0xc095, 0], + extended_model_id: 0, + }); + + assert!(DetailTab::tabs_for(&g502).contains(&DetailTab::Buttons)); + } } diff --git a/crates/openlogi-gui/src/mouse_model/button_layout.rs b/crates/openlogi-gui/src/mouse_model/button_layout.rs new file mode 100644 index 00000000..61e3b7ff --- /dev/null +++ b/crates/openlogi-gui/src/mouse_model/button_layout.rs @@ -0,0 +1,354 @@ +//! Device-family-specific button layouts for the mouse model. +//! +//! The G502 family does not expose the MX-line `0x1b04` reprogrammable-control +//! table, so this layout is model-authored rather than discovered from firmware. + +use openlogi_core::device::{DeviceModelInfo, is_g502_family}; + +use crate::data::mouse_buttons::{ButtonId, Hotspot, default_hotspots}; +use crate::mouse_model::leader_lines::{Label, Side}; + +/// G Hub splits the G502 button map into top and thumb-side perspectives. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MouseModelPerspective { + #[default] + View1, + View2, +} + +impl MouseModelPerspective { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::View1 => "View 1", + Self::View2 => "View 2", + } + } +} + +/// Which fallback/hotspot policy the mouse model should use. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MouseButtonLayout { + #[default] + Default, + G502, +} + +impl MouseButtonLayout { + #[must_use] + pub fn for_device(model: Option<&DeviceModelInfo>, name: Option<&str>) -> Self { + if is_g502_family(model, name) { + Self::G502 + } else { + Self::Default + } + } + + #[must_use] + pub fn keeps_hotspot(self, id: ButtonId) -> bool { + match self { + Self::Default => true, + Self::G502 => matches!( + id, + ButtonId::MiddleClick + | ButtonId::Back + | ButtonId::Forward + | ButtonId::DpiUp + | ButtonId::DpiDown + | ButtonId::DpiShift + | ButtonId::WheelLeft + | ButtonId::WheelRight + | ButtonId::SmartShift + ), + } + } + + #[must_use] + pub fn binds_hotspot(self, id: ButtonId) -> bool { + match self { + Self::Default => true, + // G502 extras need the gaming-button protocol before a saved binding + // can actually fire; keep them visible but not editable for now. + Self::G502 => matches!( + id, + ButtonId::MiddleClick | ButtonId::Back | ButtonId::Forward + ), + } + } + + #[must_use] + pub fn supports_perspectives(self) -> bool { + matches!(self, Self::G502) + } + + #[must_use] + pub fn fallback_hotspots(self, perspective: MouseModelPerspective) -> Vec { + match self { + Self::Default => default_hotspots(), + Self::G502 => g502_hotspots(perspective), + } + } + + #[must_use] + pub fn fallback_labels(self, perspective: MouseModelPerspective) -> Vec