fix(tray): make PTT tray icon turn red on every hold (#809)

* fix(tray): refresh PTT icon when AppIndicator reuses a path

Right-Alt push-to-talk turned the tray red only on the first hold.
Later holds dictated fine but left the icon gray until restart.

StatusNotifier hosts often skip a redraw when IconName returns to an
absolute path already used in the session. Nudge IconThemePath before
set_icon_full so gray-red-gray-red actually turns red again.

* test(tray): assert alternating IconThemePath nudge args

Greptile P2: the PTT redraw regression only checked set_icon_theme_path
call count, so identical paths would still pass. Assert the alternating
ICON_DIR / ICON_DIR+os.sep arguments that drive the host reload.
This commit is contained in:
Jatin K Malik
2026-09-08 20:56:18 -07:00
committed by GitHub
parent ab1400b7c1
commit 94c2eaac08
2 changed files with 74 additions and 4 deletions
+27 -4
View File
@@ -161,6 +161,11 @@ class TrayIndicator:
self.icon_names["processing"] if FLATPAK_ID else self.icon_paths["processing"]
),
}
# Last IconName applied this session. StatusNotifier hosts skip a
# redraw when the same path is reused; _set_indicator_icon nudges
# IconThemePath so gray→red→gray→red actually turns red again.
self._last_icon_key: Optional[str] = None
self._icon_theme_nudge = False
# Register for speech recognition state changes
self.speech_engine.register_state_callback(self._on_recognition_state_changed)
@@ -691,6 +696,24 @@ class TrayIndicator:
self.speech_engine.end_download()
self._model_download_active = False
def _set_indicator_icon(self, icon_key: str, description: str) -> None:
"""Apply a tray icon, forcing a host redraw when cycling reused paths.
Ayatana/GNOME StatusNotifier hosts often skip a redraw when IconName
returns to a path already used earlier in the session. Alternating
IconThemePath between ICON_DIR and ICON_DIR + os.sep triggers a reload
while keeping absolute paths (theme names would regress the stale
~/.local/share/icons hicolor placeholder). Flatpak uses themed names
and does not need the nudge.
"""
if not FLATPAK_ID and self._last_icon_key is not None:
nudge = getattr(self.indicator, "set_icon_theme_path", None)
if callable(nudge):
self._icon_theme_nudge = not self._icon_theme_nudge
nudge(ICON_DIR + os.sep if self._icon_theme_nudge else ICON_DIR)
self.indicator.set_icon_full(icon_key, description)
self._last_icon_key = icon_key
def _update_ui(self, state: RecognitionState):
"""
Update the UI based on the recognition state.
@@ -708,19 +731,19 @@ class TrayIndicator:
state = engine_state
if state == RecognitionState.IDLE:
self.indicator.set_icon_full(self._icon_keys["default"], "Microphone off")
self._set_indicator_icon(self._icon_keys["default"], "Microphone off")
self._set_menu_item_enabled("Start Voice Typing", True)
self._set_menu_item_enabled("Stop Voice Typing", False)
elif state == RecognitionState.LISTENING:
self.indicator.set_icon_full(self._icon_keys["active"], "Microphone on")
self._set_indicator_icon(self._icon_keys["active"], "Microphone on")
self._set_menu_item_enabled("Start Voice Typing", False)
self._set_menu_item_enabled("Stop Voice Typing", True)
elif state == RecognitionState.PROCESSING:
self.indicator.set_icon_full(self._icon_keys["processing"], "Processing speech")
self._set_indicator_icon(self._icon_keys["processing"], "Processing speech")
self._set_menu_item_enabled("Start Voice Typing", False)
self._set_menu_item_enabled("Stop Voice Typing", True)
elif state == RecognitionState.ERROR:
self.indicator.set_icon_full(self._icon_keys["default"], "Error")
self._set_indicator_icon(self._icon_keys["default"], "Error")
self._set_menu_item_enabled("Start Voice Typing", True)
self._set_menu_item_enabled("Stop Voice Typing", False)
+47
View File
@@ -832,6 +832,53 @@ class TestTrayIndicator(unittest.TestCase):
)
self.assertEqual(result, False)
def test_update_ui_ptt_cycle_reapplies_active_icon(self):
"""PTT IDLE→LISTENING→IDLE→LISTENING must set the active icon both times.
StatusNotifier hosts skip a redraw when IconName returns to a path
already used this session; the helper must still call set_icon_full
on the second LISTENING and nudge IconThemePath outside Flatpak.
"""
indicator = MagicMock()
self.tray_indicator.indicator = indicator
mock_menu_item = MagicMock()
mock_menu_item.get_label.return_value = "Start Voice Typing"
self.tray_indicator.menu = MagicMock()
self.tray_indicator.menu.get_children.return_value = [mock_menu_item]
cycle = (
self.RecognitionState.IDLE,
self.RecognitionState.LISTENING,
self.RecognitionState.IDLE,
self.RecognitionState.LISTENING,
)
with patch("vocalinux.ui.tray_indicator.Gtk") as patched_gtk:
patched_gtk.MenuItem = type(mock_menu_item)
with patch("vocalinux.ui.tray_indicator.FLATPAK_ID", None):
for state in cycle:
self.mock_speech_engine.state = state
self.tray_indicator._update_ui(state)
active = self.tray_indicator._icon_keys["active"]
default = self.tray_indicator._icon_keys["default"]
icon_names = [call[0][0] for call in indicator.set_icon_full.call_args_list]
self.assertEqual(icon_names, [default, active, default, active])
listening_calls = [
call for call in indicator.set_icon_full.call_args_list if call[0][0] == active
]
self.assertEqual(len(listening_calls), 2)
for call in listening_calls:
self.assertEqual(call[0][1], "Microphone on")
from vocalinux.ui.tray_indicator import ICON_DIR
# _init_indicator already applied the default icon, so each cycle step
# nudges IconThemePath. Assert the alternating args, not only count.
theme_paths = [call[0][0] for call in indicator.set_icon_theme_path.call_args_list]
self.assertEqual(
theme_paths,
[ICON_DIR + os.sep, ICON_DIR, ICON_DIR + os.sep, ICON_DIR],
)
def test_set_menu_item_enabled_noop_when_menu_missing(self):
if hasattr(self.tray_indicator, "menu"):
delattr(self.tray_indicator, "menu")