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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user