Skip to content

Commit 05ee3d0

Browse files
committed
feat: per-action haptic picker on HapticPage
Replaces the removed per-button haptic gate with a per-action allowlist. HapticPage now shows a dual-column picker — Enabled (left) / Available (right) — where clicking an action chip moves it between lists. Only the 11 curated actions (cycle_dpi, switch_scroll_mode, volume_mute, alt_tab, etc.) can ever fire haptic on button press. Gestures and Actions Ring detents keep firing unconditionally when global haptic is on. Config migrated to v12 with action_haptic: [] (empty = opt-in).
1 parent 46bc421 commit 05ee3d0

7 files changed

Lines changed: 301 additions & 51 deletions

File tree

core/config.py

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@
112112
"language": "en",
113113
"haptic_level": 2, # 0=subtle, 1=low, 2=medium, 3=high
114114
"haptic_enabled": True, # global haptic on/off
115+
"action_haptic": [], # action IDs that fire haptic on press; empty = opt-in
115116
},
116117
}
117118

@@ -232,21 +233,38 @@ def create_profile(cfg, name, label=None, copy_from="default", apps=None):
232233
return cfg
233234

234235

235-
def get_button_haptic(cfg, button, profile=None):
236-
"""Return True if haptic is enabled for this button in the given profile (default: True)."""
237-
if profile is None:
238-
profile = cfg.get("active_profile", "default")
239-
profiles = cfg.get("profiles", {})
240-
pdata = profiles.get(profile, profiles.get("default", {}))
241-
return bool(pdata.get("button_haptic", {}).get(button, True))
242-
243-
244-
def set_button_haptic(cfg, button, enabled, profile=None):
245-
"""Set per-button haptic enabled flag in the given profile and save."""
246-
if profile is None:
247-
profile = cfg.get("active_profile", "default")
248-
pdata = cfg.setdefault("profiles", {}).setdefault(profile, {})
249-
pdata.setdefault("button_haptic", {})[button] = bool(enabled)
236+
# Curated list of actions that may fire haptic feedback on button press.
237+
# Other actions (text editing, raw scroll, mouse buttons, etc.) never trigger
238+
# haptic regardless of mapping. Order here is the order shown in the picker UI.
239+
HAPTIC_ELIGIBLE_ACTIONS = [
240+
"switch_scroll_mode",
241+
"toggle_smart_shift",
242+
"cycle_dpi",
243+
"volume_mute",
244+
"play_pause",
245+
"next_track",
246+
"prev_track",
247+
"task_view",
248+
"alt_tab",
249+
"alt_shift_tab",
250+
"win_d",
251+
]
252+
253+
254+
def action_haptic_enabled(cfg, action_id):
255+
"""True if haptic should fire when action_id executes via a button press."""
256+
return action_id in cfg.get("settings", {}).get("action_haptic", [])
257+
258+
259+
def set_action_haptic(cfg, action_id, enabled):
260+
"""Add or remove action_id from the global per-action haptic allowlist."""
261+
lst = cfg.setdefault("settings", {}).setdefault("action_haptic", [])
262+
if enabled:
263+
if action_id not in lst:
264+
lst.append(action_id)
265+
else:
266+
if action_id in lst:
267+
lst.remove(action_id)
250268
save_config(cfg)
251269
return cfg
252270

@@ -368,6 +386,11 @@ def _migrate(cfg):
368386
cfg.setdefault("settings", {}).setdefault("haptic_enabled", True)
369387
cfg["version"] = 11
370388

389+
if version < 12:
390+
# v11 -> v12: add per-action haptic allowlist (empty = opt-in).
391+
cfg.setdefault("settings", {}).setdefault("action_haptic", [])
392+
cfg["version"] = 12
393+
371394
cfg.setdefault("settings", {})
372395
cfg["settings"].setdefault("appearance_mode", "system")
373396
cfg["settings"].setdefault("debug_mode", False)

core/engine.py

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from core.config import (
1515
load_config, get_active_mappings, get_profile_for_app,
1616
BUTTON_TO_EVENTS, GESTURE_DIRECTION_BUTTONS, save_config,
17-
get_button_haptic,
17+
action_haptic_enabled,
1818
)
1919
from core.app_detector import AppDetector
2020
from core.logi_devices import clamp_dpi
@@ -159,12 +159,6 @@ def _setup_hooks(self):
159159
else:
160160
self.hook.register(evt_type, self._make_handler(action_id, btn_key))
161161

162-
def _button_haptic_enabled(self, btn_key):
163-
"""Return True if haptic feedback is enabled for this button in the active profile."""
164-
if not btn_key:
165-
return True
166-
return get_button_haptic(self.cfg, btn_key, self._current_profile)
167-
168162
def _make_handler(self, action_id, btn_key=""):
169163
def handler(event):
170164
try:
@@ -180,16 +174,14 @@ def handler(event):
180174
"action_id": action_id,
181175
"action_label": self._action_label(action_id),
182176
})
183-
# Haptic confirmation when a gesture resolves to an action.
184-
if self._button_haptic_enabled(btn_key):
185-
self._play_haptic_async(7) # COMPLETED
177+
# Gesture-resolved actions always confirm with a pulse.
178+
self._play_haptic_async(7) # COMPLETED
186179
elif event.event_type == "actions_ring_down":
187-
# Haptic detent feedback for each ring step.
188-
if self._button_haptic_enabled(btn_key):
189-
self._play_haptic_async(0) # SHARP_STATE_CHANGE
180+
# Ring detents always click — discoverability feedback.
181+
self._play_haptic_async(0) # SHARP_STATE_CHANGE
190182
elif not event.event_type.endswith("_up"):
191-
# Regular button press — fire haptic immediately on down.
192-
if self._button_haptic_enabled(btn_key):
183+
# Regular button press — gated by per-action allowlist.
184+
if action_haptic_enabled(self.cfg, action_id):
193185
wf = 3 if action_id == "cycle_dpi" else 1
194186
self._play_haptic_async(wf)
195187
if action_id == "toggle_smart_shift":

tests/test_config.py

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def test_migrate_v1_config_adds_profile_apps_and_gesture_defaults(self):
3131

3232
migrated = config._migrate(legacy)
3333

34-
self.assertEqual(migrated["version"], 10)
34+
self.assertEqual(migrated["version"], 12)
3535
self.assertEqual(migrated["profiles"]["default"]["apps"], [])
3636
self.assertFalse(migrated["settings"]["invert_hscroll"])
3737
self.assertFalse(migrated["settings"]["invert_vscroll"])
@@ -73,7 +73,7 @@ def test_migrate_updates_media_player_profile_apps(self):
7373

7474
migrated = config._migrate(cfg)
7575

76-
self.assertEqual(migrated["version"], 10)
76+
self.assertEqual(migrated["version"], 12)
7777
self.assertEqual(
7878
migrated["profiles"]["media"]["apps"],
7979
["Microsoft.Media.Player.exe", "VLC.exe"],
@@ -112,8 +112,10 @@ def test_load_config_merges_missing_defaults_from_disk(self):
112112
):
113113
loaded = config.load_config()
114114

115-
self.assertEqual(loaded["version"], 10)
115+
self.assertEqual(loaded["version"], 12)
116116
self.assertEqual(loaded["settings"]["dpi"], 800)
117+
self.assertEqual(loaded["settings"]["action_haptic"], [])
118+
self.assertTrue(loaded["settings"]["haptic_enabled"])
117119
self.assertFalse(loaded["settings"]["start_at_login"])
118120
self.assertEqual(loaded["settings"]["gesture_threshold"], 50)
119121
self.assertEqual(loaded["settings"]["appearance_mode"], "system")
@@ -136,7 +138,7 @@ def test_migrate_renames_start_with_windows_to_start_at_login(self):
136138

137139
migrated = config._migrate(legacy)
138140

139-
self.assertEqual(migrated["version"], 10)
141+
self.assertEqual(migrated["version"], 12)
140142
self.assertTrue(migrated["settings"]["start_at_login"])
141143
self.assertEqual(
142144
migrated["profiles"]["default"]["mappings"]["mode_shift"],
@@ -162,7 +164,7 @@ def test_migrate_v8_to_v9_adds_actions_ring_and_haptic(self):
162164

163165
migrated = config._migrate(v8_cfg)
164166

165-
self.assertEqual(migrated["version"], 10)
167+
self.assertEqual(migrated["version"], 12)
166168
self.assertEqual(
167169
migrated["profiles"]["default"]["mappings"]["actions_ring"], "none"
168170
)
@@ -190,10 +192,60 @@ def test_migrate_v9_to_v10_adds_button_haptic(self):
190192

191193
migrated = config._migrate(v9_cfg)
192194

193-
self.assertEqual(migrated["version"], 10)
195+
self.assertEqual(migrated["version"], 12)
194196
self.assertEqual(migrated["profiles"]["default"]["button_haptic"], {})
195197
self.assertEqual(migrated["profiles"]["work"]["button_haptic"], {})
196198

199+
def test_migrate_v11_to_v12_adds_action_haptic_list(self):
200+
v11_cfg = {
201+
"version": 11,
202+
"active_profile": "default",
203+
"profiles": {
204+
"default": {
205+
"label": "Default",
206+
"apps": [],
207+
"mappings": {"middle": "none", "actions_ring": "none"},
208+
}
209+
},
210+
"settings": {"dpi": 1000, "haptic_level": 2, "haptic_enabled": True},
211+
}
212+
213+
migrated = config._migrate(v11_cfg)
214+
215+
self.assertEqual(migrated["version"], 12)
216+
self.assertEqual(migrated["settings"]["action_haptic"], [])
217+
# existing haptic settings preserved
218+
self.assertTrue(migrated["settings"]["haptic_enabled"])
219+
self.assertEqual(migrated["settings"]["haptic_level"], 2)
220+
221+
def test_action_haptic_enabled_returns_false_when_missing(self):
222+
cfg = {"settings": {"action_haptic": ["cycle_dpi"]}}
223+
self.assertTrue(config.action_haptic_enabled(cfg, "cycle_dpi"))
224+
self.assertFalse(config.action_haptic_enabled(cfg, "alt_tab"))
225+
226+
def test_set_action_haptic_adds_and_removes(self):
227+
cfg = {"settings": {"action_haptic": []}}
228+
229+
with patch.object(config, "save_config", lambda c: c):
230+
cfg = config.set_action_haptic(cfg, "cycle_dpi", True)
231+
self.assertEqual(cfg["settings"]["action_haptic"], ["cycle_dpi"])
232+
233+
# Adding the same action twice is a no-op
234+
cfg = config.set_action_haptic(cfg, "cycle_dpi", True)
235+
self.assertEqual(cfg["settings"]["action_haptic"], ["cycle_dpi"])
236+
237+
cfg = config.set_action_haptic(cfg, "volume_mute", True)
238+
self.assertEqual(
239+
cfg["settings"]["action_haptic"], ["cycle_dpi", "volume_mute"]
240+
)
241+
242+
cfg = config.set_action_haptic(cfg, "cycle_dpi", False)
243+
self.assertEqual(cfg["settings"]["action_haptic"], ["volume_mute"])
244+
245+
# Removing a non-present action is a no-op
246+
cfg = config.set_action_haptic(cfg, "cycle_dpi", False)
247+
self.assertEqual(cfg["settings"]["action_haptic"], ["volume_mute"])
248+
197249
def test_get_profile_for_app_matches_aliases(self):
198250
cfg = {
199251
"app_overrides": {},

tests/test_smart_shift.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -677,7 +677,7 @@ def test_multiple_profiles_all_migrated(self):
677677
def test_version_bumped_to_8(self):
678678
from core.config import _migrate
679679
migrated = _migrate(self._v6_config())
680-
self.assertEqual(migrated["version"], 10)
680+
self.assertEqual(migrated["version"], 12)
681681

682682

683683
# ──────────────────────────────────────────────────────────────────────────────
@@ -739,7 +739,7 @@ def test_multiple_profiles_all_migrated(self):
739739
def test_version_bumped_to_8(self):
740740
from core.config import _migrate
741741
migrated = _migrate(self._v7_config())
742-
self.assertEqual(migrated["version"], 10)
742+
self.assertEqual(migrated["version"], 12)
743743

744744

745745
class HidForceReconnectTests(unittest.TestCase):

ui/backend.py

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from core.config import (
1515
BUTTON_NAMES, load_config, save_config, get_active_mappings,
1616
PROFILE_BUTTON_NAMES, set_mapping, create_profile, delete_profile,
17-
get_icon_for_exe, get_button_haptic, set_button_haptic,
17+
get_icon_for_exe, HAPTIC_ELIGIBLE_ACTIONS, set_action_haptic,
1818
)
1919
from core import app_catalog
2020
from core.device_layouts import get_device_layout, get_manual_layout_choices
@@ -304,6 +304,30 @@ def hapticLevel(self):
304304
def hapticEnabled(self):
305305
return bool(self._cfg.get("settings", {}).get("haptic_enabled", True))
306306

307+
@Property(list, notify=hapticChanged)
308+
def hapticEnabledActions(self):
309+
"""Actions in the user's haptic allowlist, in stored order."""
310+
enabled = self._cfg.get("settings", {}).get("action_haptic", [])
311+
result = []
312+
for aid in enabled:
313+
data = ACTIONS.get(aid)
314+
if data:
315+
result.append({"id": aid, "label": data["label"]})
316+
return result
317+
318+
@Property(list, notify=hapticChanged)
319+
def hapticAvailableActions(self):
320+
"""Eligible haptic actions the user has NOT yet enabled."""
321+
enabled = set(self._cfg.get("settings", {}).get("action_haptic", []))
322+
result = []
323+
for aid in HAPTIC_ELIGIBLE_ACTIONS:
324+
if aid in enabled:
325+
continue
326+
data = ACTIONS.get(aid)
327+
if data:
328+
result.append({"id": aid, "label": data["label"]})
329+
return result
330+
307331
@Property(bool, notify=settingsChanged)
308332
def startMinimized(self):
309333
return bool(self._cfg.get("settings", {}).get("start_minimized", True))
@@ -659,20 +683,13 @@ def playHapticTest(self):
659683
daemon=True, name="HapticTest"
660684
).start()
661685

662-
@Slot(str, str, result=bool)
663-
def buttonHapticEnabled(self, profileName, button):
664-
"""Return whether haptic is enabled for the given button in the given profile."""
665-
profile = profileName or self._cfg.get("active_profile", "default")
666-
return get_button_haptic(self._cfg, button, profile)
667-
668-
@Slot(str, str, bool)
669-
def setButtonHaptic(self, profileName, button, enabled):
670-
"""Set per-button haptic enabled flag in the given profile."""
671-
profile = profileName or self._cfg.get("active_profile", "default")
672-
self._cfg = set_button_haptic(self._cfg, button, enabled, profile)
686+
@Slot(str, bool)
687+
def setActionHaptic(self, action_id, enabled):
688+
"""Toggle whether an action fires haptic feedback on button press."""
689+
self._cfg = set_action_haptic(self._cfg, action_id, bool(enabled))
673690
if self._engine:
674691
self._engine.cfg = self._cfg
675-
self.mappingsChanged.emit()
692+
self.hapticChanged.emit()
676693

677694
@Slot(bool)
678695
def setInvertVScroll(self, value):

ui/locale_manager.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@
2525
"haptic.test_title": "Test Haptic",
2626
"haptic.test_desc": "Play a brief haptic pulse to preview the current intensity.",
2727
"haptic.test": "Test",
28+
"haptic.actions_title": "Haptic for Actions",
29+
"haptic.actions_desc": "Pick which actions fire haptic feedback. Click an action to move it between Enabled and Available.",
30+
"haptic.actions_enabled": "Enabled",
31+
"haptic.actions_available": "Available",
32+
"haptic.actions_empty": "No actions selected. Pick from Available.",
2833
"haptic.experimental_note": "Haptic feedback support is experimental. Some settings may not take effect until the protocol is fully documented.",
2934

3035
# Mouse page — profile list
@@ -206,6 +211,11 @@
206211
"haptic.test_title": "\u6d4b\u8bd5\u89e6\u89c9",
207212
"haptic.test_desc": "\u64ad\u653e\u4e00\u6b21\u77ed\u6682\u7684\u89e6\u89c9\u8109\u51b2\u4ee5\u9884\u89c8\u5f53\u524d\u5f3a\u5ea6\u3002",
208213
"haptic.test": "\u6d4b\u8bd5",
214+
"haptic.actions_title": "\u52a8\u4f5c\u89e6\u89c9\u53cd\u9988",
215+
"haptic.actions_desc": "\u9009\u62e9\u54ea\u4e9b\u52a8\u4f5c\u89e6\u53d1\u89e6\u89c9\u53cd\u9988\u3002\u70b9\u51fb\u52a8\u4f5c\u53ef\u5728\u201c\u5df2\u542f\u7528\u201d\u548c\u201c\u53ef\u9009\u201d\u4e4b\u95f4\u79fb\u52a8\u3002",
216+
"haptic.actions_enabled": "\u5df2\u542f\u7528",
217+
"haptic.actions_available": "\u53ef\u9009",
218+
"haptic.actions_empty": "\u672a\u9009\u62e9\u4efb\u4f55\u52a8\u4f5c\u3002\u8bf7\u4ece\u201c\u53ef\u9009\u201d\u4e2d\u6dfb\u52a0\u3002",
209219
"haptic.experimental_note": "\u89e6\u89c9\u53cd\u9988\u652f\u6301\u4e3a\u5b9e\u9a8c\u6027\u529f\u80fd\u3002\u90e8\u5206\u8bbe\u7f6e\u53ef\u80fd\u5728\u534f\u8bae\u5b8c\u5168\u6587\u6863\u5316\u540e\u624d\u751f\u6548\u3002",
210220

211221
"mouse.profiles": "\u914d\u7f6e\u6587\u4ef6",
@@ -372,6 +382,11 @@
372382
"haptic.test_title": "\u6e2c\u8a66\u89f8\u89ba",
373383
"haptic.test_desc": "\u64ad\u653e\u4e00\u6b21\u77ed\u66ab\u7684\u89f8\u89ba\u8108\u885d\u4ee5\u9810\u89bd\u76ee\u524d\u5f37\u5ea6\u3002",
374384
"haptic.test": "\u6e2c\u8a66",
385+
"haptic.actions_title": "\u52d5\u4f5c\u89f8\u89ba\u56de\u994b",
386+
"haptic.actions_desc": "\u9078\u64c7\u54ea\u4e9b\u52d5\u4f5c\u89f8\u767c\u89f8\u89ba\u56de\u994b\u3002\u9ede\u64ca\u52d5\u4f5c\u53ef\u5728\u300c\u5df2\u555f\u7528\u300d\u548c\u300c\u53ef\u9078\u300d\u4e4b\u9593\u79fb\u52d5\u3002",
387+
"haptic.actions_enabled": "\u5df2\u555f\u7528",
388+
"haptic.actions_available": "\u53ef\u9078",
389+
"haptic.actions_empty": "\u672a\u9078\u64c7\u4efb\u4f55\u52d5\u4f5c\u3002\u8acb\u5f9e\u300c\u53ef\u9078\u300d\u4e2d\u52a0\u5165\u3002",
375390
"haptic.experimental_note": "\u89f8\u89ba\u56de\u994b\u652f\u63f4\u70ba\u5be6\u9a57\u6027\u529f\u80fd\u3002\u90e8\u5206\u8a2d\u5b9a\u53ef\u80fd\u5728\u5354\u5b9a\u5b8c\u5168\u6587\u4ef6\u5316\u5f8c\u624d\u751f\u6548\u3002",
376391

377392
"mouse.profiles": "\u8a2d\u5b9a\u6a94",

0 commit comments

Comments
 (0)