Skip to content

Commit 51e2ff4

Browse files
committed
fix: keep tray controls open
1 parent f1ae955 commit 51e2ff4

2 files changed

Lines changed: 186 additions & 27 deletions

File tree

src/main.py

Lines changed: 54 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,15 @@
1313

1414
from PySide6.QtCore import QObject, Signal, QThread
1515
from PySide6.QtGui import QIcon
16-
from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon
16+
from PySide6.QtWidgets import (
17+
QApplication,
18+
QButtonGroup,
19+
QCheckBox,
20+
QMenu,
21+
QRadioButton,
22+
QSystemTrayIcon,
23+
QWidgetAction,
24+
)
1725

1826
from src.audio import AudioRecorder, default_input_device_id, list_devices, resolve_device
1927
from src.config import (
@@ -170,29 +178,49 @@ def _add_choice_submenu(
170178
current: str,
171179
on_select: Any,
172180
) -> None:
173-
"""Add a submenu of checkable actions, one per (key, label).
181+
submenu = QMenu(title, self._menu)
182+
self._menu.addMenu(submenu)
183+
group = QButtonGroup(submenu)
184+
group.setExclusive(True)
174185

175-
The action matching *current* starts checked. Selecting one calls
176-
*on_select(key)*, whose handler rebuilds the menu — that rebuild is what
177-
keeps a single item checked, not a QActionGroup.
178-
"""
179-
submenu = self._menu.addMenu(title)
180186
for key, label in options:
181-
act = submenu.addAction(label)
182-
act.setCheckable(True)
183-
act.setChecked(key == current)
184-
act.triggered.connect(lambda checked, k=key: on_select(k))
187+
radio = QRadioButton(label)
188+
radio.setChecked(key == current)
189+
190+
action = QWidgetAction(submenu)
191+
action.setDefaultWidget(radio)
192+
submenu.addAction(action)
193+
194+
group.addButton(radio)
195+
196+
radio.toggled.connect(
197+
lambda checked, k=key: checked and on_select(k, rebuild_menu=False)
198+
)
199+
200+
def _add_persistent_checkbox(
201+
self,
202+
label: str,
203+
checked: bool,
204+
on_changed: Any,
205+
) -> QCheckBox:
206+
# Use QWidgetAction instead of checkable QAction so clicking the control
207+
# does not trigger QMenu's default close-on-action behavior.
208+
checkbox = QCheckBox(label)
209+
checkbox.setChecked(checked)
210+
211+
action = QWidgetAction(self._menu)
212+
action.setDefaultWidget(checkbox)
213+
self._menu.addAction(action)
214+
215+
checkbox.toggled.connect(on_changed)
216+
return checkbox
185217

186218
def _rebuild_menu(self) -> None:
187219
"""Rebuild the context menu from current config."""
188220
self._menu.clear()
189221
c = self._config
190222

191-
# Enabled toggle.
192-
act_enabled = self._menu.addAction("Enabled")
193-
act_enabled.setCheckable(True)
194-
act_enabled.setChecked(self._enabled)
195-
act_enabled.triggered.connect(self._toggle_enabled)
223+
self._add_persistent_checkbox("Enabled", self._enabled, self._toggle_enabled)
196224

197225
self._menu.addSeparator()
198226

@@ -205,12 +233,8 @@ def _rebuild_menu(self) -> None:
205233
self._add_choice_submenu("Hotkey", HOTKEY_OPTIONS, c.hotkey, self._set_hotkey)
206234
self._add_choice_submenu("Post-type Key", POST_KEY_OPTIONS, c.post_type_key, self._set_post_key)
207235

208-
# AI Rewrite toggle.
209236
self._menu.addSeparator()
210-
act_rewrite = self._menu.addAction("AI Rewrite")
211-
act_rewrite.setCheckable(True)
212-
act_rewrite.setChecked(c.llm_enabled)
213-
act_rewrite.triggered.connect(self._toggle_rewrite)
237+
self._add_persistent_checkbox("AI Rewrite", c.llm_enabled, self._toggle_rewrite)
214238

215239
# Settings / Exit.
216240
self._menu.addSeparator()
@@ -355,24 +379,27 @@ def _toggle_enabled(self, checked: bool) -> None:
355379
self._finalize_recording()
356380
log.info("Screamer %s", "enabled" if checked else "disabled")
357381

358-
def _set_recording_mode(self, mode: str) -> None:
382+
def _set_recording_mode(self, mode: str, rebuild_menu: bool = True) -> None:
359383
self._config.recording_mode = mode
360384
save_config(self._config)
361385
self._restart_hotkey()
362-
self._rebuild_menu()
386+
if rebuild_menu:
387+
self._rebuild_menu()
363388
log.info("Recording mode set to %s", mode)
364389

365-
def _set_hotkey(self, key: str) -> None:
390+
def _set_hotkey(self, key: str, rebuild_menu: bool = True) -> None:
366391
self._config.hotkey = key
367392
save_config(self._config)
368393
self._restart_hotkey()
369-
self._rebuild_menu()
394+
if rebuild_menu:
395+
self._rebuild_menu()
370396
log.info("Hotkey set to %s", key)
371397

372-
def _set_post_key(self, key: str) -> None:
398+
def _set_post_key(self, key: str, rebuild_menu: bool = True) -> None:
373399
self._config.post_type_key = key
374400
save_config(self._config)
375-
self._rebuild_menu()
401+
if rebuild_menu:
402+
self._rebuild_menu()
376403
log.info("Post-type key set to %s", key)
377404

378405
def _toggle_rewrite(self, checked: bool) -> None:

tests/test_tray_menu.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import os
2+
import unittest
3+
from unittest.mock import patch
4+
5+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
6+
7+
from PySide6.QtCore import QObject
8+
from PySide6.QtWidgets import QApplication, QMenu
9+
10+
from src.main import _TrayApp
11+
12+
13+
def make_tray_app():
14+
app = QApplication.instance() or QApplication([])
15+
tray_app = _TrayApp.__new__(_TrayApp)
16+
QObject.__init__(tray_app)
17+
tray_app._menu = QMenu()
18+
return tray_app
19+
20+
21+
class TrayMenuTests(unittest.TestCase):
22+
def test_choice_submenu_uses_widget_actions(self):
23+
from PySide6.QtWidgets import QWidgetAction
24+
25+
tray_app = make_tray_app()
26+
tray_app._add_choice_submenu(
27+
"Record Mode",
28+
[("hold", "Hold to talk"), ("toggle", "Toggle")],
29+
"hold",
30+
lambda key, rebuild_menu=True: None,
31+
)
32+
33+
submenu = tray_app._menu.actions()[0].menu()
34+
self.assertTrue(all(isinstance(a, QWidgetAction) for a in submenu.actions()))
35+
36+
def test_radio_selection_skips_menu_rebuild(self):
37+
tray_app = make_tray_app()
38+
called = []
39+
40+
tray_app._add_choice_submenu(
41+
"Record Mode",
42+
[("hold", "Hold to talk"), ("toggle", "Toggle")],
43+
"hold",
44+
lambda key, rebuild_menu=True: called.append((key, rebuild_menu)),
45+
)
46+
47+
submenu = tray_app._menu.actions()[0].menu()
48+
second_action = submenu.actions()[1]
49+
radio = second_action.defaultWidget()
50+
51+
radio.setChecked(True)
52+
53+
self.assertEqual(called, [("toggle", False)])
54+
55+
def test_persistent_checkbox_emits_toggled(self):
56+
tray_app = make_tray_app()
57+
called = []
58+
59+
checkbox = tray_app._add_persistent_checkbox(
60+
"AI Rewrite",
61+
False,
62+
lambda checked: called.append(checked),
63+
)
64+
65+
checkbox.setChecked(True)
66+
67+
self.assertEqual(called, [True])
68+
69+
def test_set_post_key_rebuilds_by_default_but_can_skip(self):
70+
from src.config import AppConfig
71+
72+
tray_app = make_tray_app()
73+
tray_app._config = AppConfig()
74+
75+
rebuilds = []
76+
tray_app._rebuild_menu = lambda: rebuilds.append("rebuilt")
77+
78+
with patch("src.main.save_config"):
79+
tray_app._set_post_key("enter")
80+
self.assertEqual(rebuilds, ["rebuilt"])
81+
82+
rebuilds.clear()
83+
tray_app._set_post_key("tab", rebuild_menu=False)
84+
self.assertEqual(rebuilds, [])
85+
86+
def test_set_hotkey_rebuilds_by_default_but_can_skip(self):
87+
from src.config import AppConfig
88+
89+
tray_app = make_tray_app()
90+
tray_app._config = AppConfig()
91+
92+
rebuilds = []
93+
restarts = []
94+
tray_app._rebuild_menu = lambda: rebuilds.append("rebuilt")
95+
tray_app._restart_hotkey = lambda: restarts.append("restarted")
96+
97+
with patch("src.main.save_config"):
98+
tray_app._set_hotkey("ctrl_alt_space")
99+
self.assertEqual(rebuilds, ["rebuilt"])
100+
self.assertEqual(restarts, ["restarted"])
101+
102+
rebuilds.clear()
103+
restarts.clear()
104+
tray_app._set_hotkey("ctrl_shift_space", rebuild_menu=False)
105+
self.assertEqual(rebuilds, [])
106+
self.assertEqual(restarts, ["restarted"])
107+
108+
def test_set_recording_mode_rebuilds_by_default_but_can_skip(self):
109+
from src.config import AppConfig
110+
111+
tray_app = make_tray_app()
112+
tray_app._config = AppConfig()
113+
114+
rebuilds = []
115+
restarts = []
116+
tray_app._rebuild_menu = lambda: rebuilds.append("rebuilt")
117+
tray_app._restart_hotkey = lambda: restarts.append("restarted")
118+
119+
with patch("src.main.save_config"):
120+
tray_app._set_recording_mode("hold")
121+
self.assertEqual(rebuilds, ["rebuilt"])
122+
self.assertEqual(restarts, ["restarted"])
123+
124+
rebuilds.clear()
125+
restarts.clear()
126+
tray_app._set_recording_mode("toggle", rebuild_menu=False)
127+
self.assertEqual(rebuilds, [])
128+
self.assertEqual(restarts, ["restarted"])
129+
130+
131+
if __name__ == "__main__":
132+
unittest.main()

0 commit comments

Comments
 (0)