From 94c2eaac089edea41996289393ac874d2d2a3d28 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Tue, 8 Sep 2026 20:56:18 -0700 Subject: [PATCH] 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. --- src/vocalinux/ui/tray_indicator.py | 31 +++++++++++++++++--- tests/test_tray_indicator.py | 47 ++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index d506172..e5483b6 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -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) diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py index 470533e..d56fd0a 100644 --- a/tests/test_tray_indicator.py +++ b/tests/test_tray_indicator.py @@ -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")