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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"hscroll_right": "Horizontal scroll right",
"mode_shift": "Mode shift button",
"dpi_switch": "DPI switch button",
"haptic": "Haptic button",
}

GESTURE_DIRECTION_BUTTONS = (
Expand Down Expand Up @@ -64,10 +65,11 @@
"hscroll_right": ("hscroll_right",),
"mode_shift": ("mode_shift_down", "mode_shift_up"),
"dpi_switch": ("dpi_switch_down", "dpi_switch_up"),
"haptic": ("haptic_down", "haptic_up"),
}

DEFAULT_CONFIG = {
"version": 9,
"version": 10,
"active_profile": "default",
"profiles": {
"default": {
Expand All @@ -85,6 +87,7 @@
"hscroll_left": "browser_back",
"hscroll_right": "browser_forward",
"mode_shift": "switch_scroll_mode",
"haptic": "none",
},
}
},
Expand Down Expand Up @@ -329,6 +332,14 @@ def _migrate(cfg):
settings.setdefault("ignore_trackpad", True)
cfg["version"] = 9

if version < 10:
# MX Master 4 haptic thumb-rest button (CID 0x01A0). Default "none" so
# existing users see no behaviour change until they explicitly remap it.
for pdata in cfg.get("profiles", {}).values():
mappings = pdata.setdefault("mappings", {})
mappings.setdefault("haptic", "none")
cfg["version"] = 10

cfg.setdefault("settings", {})
cfg["settings"].setdefault("appearance_mode", "system")
cfg["settings"].setdefault("debug_mode", False)
Expand Down
3 changes: 2 additions & 1 deletion core/device_layouts.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,9 @@
# Maps a device-specific key like "mx_master_3s" to its family layout key.
# Entries here let per-device keys fall back to the family layout until a
# dedicated layout is added. Extend this dict as new devices are cataloged.
# Note: mx_master_4 has its own dedicated layout (with the haptic thumb-rest
# button) and is intentionally NOT listed here.
_FAMILY_FALLBACKS = {
"mx_master_4": "mx_master",
"mx_master_3s": "mx_master",
"mx_master_3": "mx_master",
"mx_master_2s": "mx_master",
Expand Down
63 changes: 39 additions & 24 deletions core/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,30 +114,7 @@ def _setup_hooks(self):
timeout_ms=settings.get("gesture_timeout_ms", 3000),
cooldown_ms=settings.get("gesture_cooldown_ms", 500),
)
# Divert mode shift CID only when the device has the button and
# at least one profile maps it to an action. When no device is
# connected yet, assume the button exists (safe: if the device
# turns out not to have it, the divert simply has no effect).
device = getattr(self, "connected_device", None)
device_buttons = getattr(device, "supported_buttons", None)
has_mode_shift = device_buttons is None or "mode_shift" in device_buttons
self.hook.divert_mode_shift = (
has_mode_shift
and any(
pdata.get("mappings", {}).get("mode_shift", "none") != "none"
for pdata in self.cfg.get("profiles", {}).values()
)
)

# Divert DPI switch CID (0x00FD) on MX Vertical when mapped.
has_dpi_switch = device_buttons is None or "dpi_switch" in device_buttons
self.hook.divert_dpi_switch = (
has_dpi_switch
and any(
pdata.get("mappings", {}).get("dpi_switch", "none") != "none"
for pdata in self.cfg.get("profiles", {}).values()
)
)
self._apply_divert_flags()

self._emit_mapping_snapshot("Hook mappings refreshed", mappings)

Expand Down Expand Up @@ -169,6 +146,43 @@ def _setup_hooks(self):
else:
self.hook.register(evt_type, self._make_handler(action_id))

def _apply_divert_flags(self):

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Pulled out because reload_mappings never pushes the mappings to the Listener while running, this should fix that issue.

"""Compute HID++ divert flags and push to the live listener if changed."""
device = self.connected_device
device_buttons = getattr(device, "supported_buttons", None)

# When no device is connected yet, assume the family supports the
# button (safe: divert call is a no-op on devices without the CID).
has_mode_shift = device_buttons is None or "mode_shift" in device_buttons
has_dpi_switch = device_buttons is None or "dpi_switch" in device_buttons
has_haptic = device_buttons is None or "haptic" in device_buttons

profiles = self.cfg.get("profiles", {})

def any_mapped(button):
return any(
pdata.get("mappings", {}).get(button, "none") != "none"
for pdata in profiles.values()
)

new_mode_shift = has_mode_shift and any_mapped("mode_shift")
new_dpi_switch = has_dpi_switch and any_mapped("dpi_switch")
new_haptic = has_haptic and any_mapped("haptic")

changed = (
self.hook.divert_mode_shift != new_mode_shift
or self.hook.divert_dpi_switch != new_dpi_switch
or self.hook.divert_haptic != new_haptic
)
self.hook.divert_mode_shift = new_mode_shift
self.hook.divert_dpi_switch = new_dpi_switch
self.hook.divert_haptic = new_haptic

if changed:
hg = self.hook._hid_gesture
if hg is not None and hasattr(hg, "update_extra_diverts"):
hg.update_extra_diverts(self.hook._build_extra_diverts())

def _make_handler(self, action_id):
def handler(event):
try:
Expand Down Expand Up @@ -617,6 +631,7 @@ def _on_connection_change(self, connected):
if self._battery_poll_thread is not None:
self._battery_poll_thread.join(timeout=5)
self._battery_poll_thread = None
self._apply_divert_flags()
self._last_hid_features_ready = hid_features_ready
if self._connection_change_cb:
try:
Expand Down
9 changes: 9 additions & 0 deletions core/hid_gesture.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,7 @@ def _linux_logitech_hidraw_nodes(base="/sys/class/hidraw"):
0x00C4: "Smart Shift",
0x00D7: "Virtual Gesture Button",
0x00FD: "DPI Switch",
0x01A0: "Haptic Thumb Rest", # MX Master 4 (Action Ring trigger)
}

KEY_FLAG_BITS = (
Expand Down Expand Up @@ -1439,6 +1440,14 @@ def force_reconnect(self):
"""
self._reconnect_requested = True

def update_extra_diverts(self, extra_diverts):
"""Replace the extra-divert table; reconnect re-runs ``_divert_extras`` on the device."""
self._extra_diverts = {
cid: {**info, "held": False}
for cid, info in (extra_diverts or {}).items()
}
self._reconnect_requested = True

def read_smart_shift(self):
"""Queue a Smart Shift read.
Returns dict {'mode': str, 'enabled': bool, 'threshold': int} or None."""
Expand Down
10 changes: 10 additions & 0 deletions core/logi_device_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,16 @@ def _layout(
label_off_x=160,
label_off_y=60,
),
_hotspot(
"haptic",
"Haptic button",
"mapping",
0.30,
0.68,
label_side="left",
label_off_x=-200,
label_off_y=80,
),
],
),
"mx_master_3s": _layout(
Expand Down
21 changes: 20 additions & 1 deletion core/logi_devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from __future__ import annotations

import dataclasses
from dataclasses import dataclass
import re
from typing import Iterable
Expand Down Expand Up @@ -36,6 +37,11 @@
"mode_shift",
)

# MX Master 4 adds the haptic thumb-rest button (CID 0x01A0) that triggers
# Logitech's Action Ring overlay when not diverted. Listed only here so other
# MX Master family members never expose it in the UI or HID++ divert set.
MX_MASTER_4_BUTTONS = MX_MASTER_BUTTONS + ("haptic",)

# Conservative fallback for generic MX Anywhere-family overrides. Exact
# cataloged MX Anywhere devices provide their own button sets.
MX_ANYWHERE_BUTTONS = (
Expand Down Expand Up @@ -82,7 +88,6 @@
_HSCROLL_CIDS = (0x005B, 0x005D)
_KNOWN_UNSUPPORTED_CONTROLS = {
0x00ED: "precision_mode",
0x01A0: "haptic",
}
_KEY_FLAG_DIVERTABLE = 0x0020
_KEY_FLAG_RAW_XY = 0x0100
Expand Down Expand Up @@ -386,6 +391,19 @@ class ConnectedDeviceInfo:
),
)

# Per-device supported_buttons overrides for cataloged devices. Kept here
# (not in the catalog dict) so the family-button constants stay centralized
# in this module and we avoid a circular import into logi_device_catalog.
_PER_DEVICE_BUTTON_OVERRIDES = {
"mx_master_4": MX_MASTER_4_BUTTONS,
}
KNOWN_LOGI_DEVICES = tuple(
dataclasses.replace(d, supported_buttons=_PER_DEVICE_BUTTON_OVERRIDES[d.key])
if d.key in _PER_DEVICE_BUTTON_OVERRIDES
else d
for d in KNOWN_LOGI_DEVICES
)


def _normalize_name(value) -> str:
if not value:
Expand Down Expand Up @@ -763,6 +781,7 @@ def derive_supported_buttons_from_reprog_controls(
# resolve buttons even when individual devices use per-device ui_layout keys.
_LAYOUT_BUTTONS = {
"mx_master": MX_MASTER_BUTTONS,
"mx_master_4": MX_MASTER_4_BUTTONS,
"mx_anywhere": MX_ANYWHERE_BUTTONS,
"mx_vertical": MX_VERTICAL_BUTTONS,
"generic_mouse": GENERIC_BUTTONS,
Expand Down
12 changes: 12 additions & 0 deletions core/mouse_hook_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def __init__(self):
self._connection_change_cb = None
self.divert_mode_shift = False
self.divert_dpi_switch = False
self.divert_haptic = False
self._gesture_direction_enabled = False
self._gesture_threshold = 50.0
self._gesture_deadzone = 40.0
Expand Down Expand Up @@ -259,6 +260,11 @@ def _build_extra_diverts(self):
"on_down": self._on_hid_dpi_switch_down,
"on_up": self._on_hid_dpi_switch_up,
}
if self.divert_haptic:
extra[0x01A0] = {
"on_down": self._on_hid_haptic_down,
"on_up": self._on_hid_haptic_up,
}
return extra

def _start_hid_listener(self):
Expand Down Expand Up @@ -314,3 +320,9 @@ def _on_hid_dpi_switch_down(self):

def _on_hid_dpi_switch_up(self):
self._dispatch(MouseEvent(MouseEvent.DPI_SWITCH_UP))

def _on_hid_haptic_down(self):
self._dispatch(MouseEvent(MouseEvent.HAPTIC_DOWN))

def _on_hid_haptic_up(self):
self._dispatch(MouseEvent(MouseEvent.HAPTIC_UP))
1 change: 1 addition & 0 deletions core/mouse_hook_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class MouseHookLike(Protocol):
invert_hscroll: bool
divert_mode_shift: bool
divert_dpi_switch: bool
divert_haptic: bool
_hid_gesture: Any

def register(self, event_type: str, callback: Callable[[Any], None]) -> None: ...
Expand Down
12 changes: 12 additions & 0 deletions core/mouse_hook_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,18 @@ def _on_hid_dpi_switch_up(self):
self._emit_debug("HID DPI switch button up")
self._dispatch(MouseEvent(MouseEvent.DPI_SWITCH_UP))

def _on_hid_haptic_down(self):
if self._ui_passthrough:
return
self._emit_debug("HID haptic button down")
self._dispatch(MouseEvent(MouseEvent.HAPTIC_DOWN))

def _on_hid_haptic_up(self):
if self._ui_passthrough:
return
self._emit_debug("HID haptic button up")
self._dispatch(MouseEvent(MouseEvent.HAPTIC_UP))

def _on_hid_gesture_move(self, delta_x, delta_y):
if self._ui_passthrough:
return
Expand Down
8 changes: 8 additions & 0 deletions core/mouse_hook_macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,14 @@ def _on_hid_dpi_switch_up(self):
self._emit_debug("HID DPI switch button up")
self._dispatch(MouseEvent(MouseEvent.DPI_SWITCH_UP))

def _on_hid_haptic_down(self):
self._emit_debug("HID haptic button down")
self._dispatch(MouseEvent(MouseEvent.HAPTIC_DOWN))

def _on_hid_haptic_up(self):
self._emit_debug("HID haptic button up")
self._dispatch(MouseEvent(MouseEvent.HAPTIC_UP))

def _on_hid_gesture_move(self, delta_x, delta_y):
self._emit_debug(f"HID rawxy move dx={delta_x} dy={delta_y}")
self._emit_gesture_event(
Expand Down
2 changes: 2 additions & 0 deletions core/mouse_hook_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ class MouseEvent:
MODE_SHIFT_UP = "mode_shift_up"
DPI_SWITCH_DOWN = "dpi_switch_down"
DPI_SWITCH_UP = "dpi_switch_up"
HAPTIC_DOWN = "haptic_down"
HAPTIC_UP = "haptic_up"

def __init__(self, event_type, raw_data=None):
self.event_type = event_type
Expand Down
8 changes: 8 additions & 0 deletions core/mouse_hook_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,14 @@ def _on_hid_dpi_switch_up(self):
self._emit_debug("HID DPI switch button up")
self._dispatch(MouseEvent(MouseEvent.DPI_SWITCH_UP))

def _on_hid_haptic_down(self):
self._emit_debug("HID haptic button down")
self._dispatch(MouseEvent(MouseEvent.HAPTIC_DOWN))

def _on_hid_haptic_up(self):
self._emit_debug("HID haptic button up")
self._dispatch(MouseEvent(MouseEvent.HAPTIC_UP))

def _on_hid_gesture_move(self, delta_x, delta_y):
self._emit_debug(f"HID rawxy move dx={delta_x} dy={delta_y}")
self._emit_gesture_event(
Expand Down
Loading