From 9e0a98c0a37ca840413da140e38ce74c971036b3 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Sat, 21 Mar 2026 12:18:21 -0700 Subject: [PATCH] fix(tray): use icon names for AppIndicator updates (#330) * fix(tray): use icon names for AppIndicator updates AppIndicator expects icon names resolved via theme path; passing absolute paths can result in blank or stuck status icons on some desktops. Use icon names for runtime updates and tighten tests to lock behavior. * fix(resources): prefer populated asset directories Selecting the first existing resources path could choose an empty install location and break tray icons and sounds. Score candidates by expected assets so runtime picks a usable resources directory. --- src/vocalinux/ui/tray_indicator.py | 14 ++++-- src/vocalinux/utils/resource_manager.py | 63 ++++++++++++++++++++----- tests/test_resource_manager_ext.py | 44 +++++++++++++++++ tests/test_tray_indicator.py | 12 +++-- 4 files changed, 115 insertions(+), 18 deletions(-) diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 964d614..4a24444 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -95,6 +95,11 @@ class TrayIndicator: "active": _resource_manager.get_icon_path(ACTIVE_ICON), "processing": _resource_manager.get_icon_path(PROCESSING_ICON), } + self.icon_names = { + "default": DEFAULT_ICON, + "active": ACTIVE_ICON, + "processing": PROCESSING_ICON, + } # Register for speech recognition state changes self.speech_engine.register_state_callback(self._on_recognition_state_changed) @@ -186,6 +191,7 @@ class TrayIndicator: AppIndicator3.IndicatorCategory.APPLICATION_STATUS, ICON_DIR, ) + self.indicator.set_icon_theme_path(ICON_DIR) # Set the indicator status self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE) @@ -324,19 +330,19 @@ class TrayIndicator: state: The current recognition state """ if state == RecognitionState.IDLE: - self.indicator.set_icon_full(self.icon_paths["default"], "Microphone off") + self.indicator.set_icon_full(self.icon_names["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_paths["active"], "Microphone on") + self.indicator.set_icon_full(self.icon_names["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_paths["processing"], "Processing speech") + self.indicator.set_icon_full(self.icon_names["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_paths["default"], "Error") + self.indicator.set_icon_full(self.icon_names["default"], "Error") self._set_menu_item_enabled("Start Voice Typing", True) self._set_menu_item_enabled("Stop Voice Typing", False) diff --git a/src/vocalinux/utils/resource_manager.py b/src/vocalinux/utils/resource_manager.py index 36e3472..cc0c3cc 100644 --- a/src/vocalinux/utils/resource_manager.py +++ b/src/vocalinux/utils/resource_manager.py @@ -9,7 +9,7 @@ import logging import os import sys from pathlib import Path -from typing import Optional # noqa: F401 +from typing import Optional, cast logger = logging.getLogger(__name__) @@ -24,7 +24,7 @@ class ResourceManager: """ _instance = None - _resources_dir = None + _resources_dir: Optional[str] = None def __new__(cls): """Singleton pattern to ensure only one instance exists.""" @@ -86,31 +86,71 @@ class ResourceManager: f"Checking resources candidate: {candidate} (exists: {candidate.exists()})" ) - # Return the first candidate that exists - for candidate in candidates: - if candidate.exists(): - logger.info(f"Found resources directory: {candidate}") - return str(candidate) + best_candidate = self._choose_best_candidate(candidates) + if best_candidate is not None: + logger.info(f"Found resources directory: {best_candidate}") + return str(best_candidate) # If no candidate exists, default to the first one (with warning) default_path = str(candidates[0]) logger.warning(f"Could not find resources directory, defaulting to: {default_path}") return default_path + def _candidate_score(self, candidate: Path) -> int: + if not candidate.exists(): + return -1 + + expected_icons = [ + "vocalinux", + "vocalinux-microphone", + "vocalinux-microphone-off", + "vocalinux-microphone-process", + ] + expected_sounds = ["start_recording", "stop_recording", "error"] + + icon_score = sum( + (candidate / "icons" / "scalable" / f"{icon}.svg").exists() for icon in expected_icons + ) + sound_score = sum( + (candidate / "sounds" / f"{sound}.wav").exists() for sound in expected_sounds + ) + + total_score = icon_score + sound_score + logger.debug( + "Resource candidate score: %s (icons=%s sounds=%s total=%s)", + candidate, + icon_score, + sound_score, + total_score, + ) + return total_score + + def _choose_best_candidate(self, candidates: list[Path]) -> Optional[Path]: + best_candidate: Optional[Path] = None + best_score = -1 + + for candidate in candidates: + score = self._candidate_score(candidate) + if score > best_score: + best_score = score + best_candidate = candidate + + return best_candidate + @property def resources_dir(self) -> str: """Get the resources directory path.""" - return self._resources_dir + return cast(str, self._resources_dir) @property def icons_dir(self) -> str: """Get the icons directory path.""" - return os.path.join(self._resources_dir, "icons", "scalable") + return os.path.join(self.resources_dir, "icons", "scalable") @property def sounds_dir(self) -> str: """Get the sounds directory path.""" - return os.path.join(self._resources_dir, "sounds") + return os.path.join(self.resources_dir, "sounds") def get_icon_path(self, icon_name: str) -> str: """ @@ -148,8 +188,9 @@ class ResourceManager: Returns: Dictionary with validation results """ + resources_dir = self.resources_dir results = { - "resources_dir_exists": os.path.exists(self._resources_dir), + "resources_dir_exists": os.path.exists(resources_dir), "icons_dir_exists": os.path.exists(self.icons_dir), "sounds_dir_exists": os.path.exists(self.sounds_dir), "missing_icons": [], diff --git a/tests/test_resource_manager_ext.py b/tests/test_resource_manager_ext.py index 96afa14..78b47df 100644 --- a/tests/test_resource_manager_ext.py +++ b/tests/test_resource_manager_ext.py @@ -143,3 +143,47 @@ class TestResourceManagerLogging: result = manager._find_resources_dir() assert isinstance(result, str) assert len(result) > 0 + + +class TestResourceManagerCandidateSelection: + def test_choose_best_candidate_prefers_assets_over_empty_existing_dir(self): + from vocalinux.utils.resource_manager import ResourceManager + + ResourceManager._instance = None + ResourceManager._resources_dir = None + manager = ResourceManager() + + with tempfile.TemporaryDirectory() as tmp_dir: + empty_candidate = Path(tmp_dir) / "empty_resources" + full_candidate = Path(tmp_dir) / "full_resources" + + empty_candidate.mkdir(parents=True, exist_ok=True) + (full_candidate / "icons" / "scalable").mkdir(parents=True, exist_ok=True) + (full_candidate / "sounds").mkdir(parents=True, exist_ok=True) + + for icon in [ + "vocalinux", + "vocalinux-microphone", + "vocalinux-microphone-off", + "vocalinux-microphone-process", + ]: + (full_candidate / "icons" / "scalable" / f"{icon}.svg").write_text("x") + + for sound in ["start_recording", "stop_recording", "error"]: + (full_candidate / "sounds" / f"{sound}.wav").write_text("x") + + choose_best_candidate = getattr(manager, "_choose_best_candidate") + chosen = choose_best_candidate([empty_candidate, full_candidate]) + assert chosen == full_candidate + + def test_candidate_score_returns_negative_for_missing_path(self): + from vocalinux.utils.resource_manager import ResourceManager + + ResourceManager._instance = None + ResourceManager._resources_dir = None + manager = ResourceManager() + + missing = Path("/tmp/this-path-should-not-exist-vocalinux-resource-manager") + candidate_score = getattr(manager, "_candidate_score") + score = candidate_score(missing) + assert score == -1 diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py index d7bc713..36e557c 100644 --- a/tests/test_tray_indicator.py +++ b/tests/test_tray_indicator.py @@ -322,7 +322,9 @@ class TestTrayIndicator(unittest.TestCase): patched_gtk.MenuItem = type(mock_menu_item) result = self.tray_indicator._update_ui(self.RecognitionState.LISTENING) - self.tray_indicator.indicator.set_icon_full.assert_called_once() + self.tray_indicator.indicator.set_icon_full.assert_called_once_with( + "vocalinux-microphone", "Microphone on" + ) self.assertEqual(result, False) def test_update_ui_processing_state(self): @@ -337,7 +339,9 @@ class TestTrayIndicator(unittest.TestCase): patched_gtk.MenuItem = type(mock_menu_item) result = self.tray_indicator._update_ui(self.RecognitionState.PROCESSING) - self.tray_indicator.indicator.set_icon_full.assert_called_once() + self.tray_indicator.indicator.set_icon_full.assert_called_once_with( + "vocalinux-microphone-process", "Processing speech" + ) self.assertEqual(result, False) def test_update_ui_error_state(self): @@ -352,7 +356,9 @@ class TestTrayIndicator(unittest.TestCase): patched_gtk.MenuItem = type(mock_menu_item) result = self.tray_indicator._update_ui(self.RecognitionState.ERROR) - self.tray_indicator.indicator.set_icon_full.assert_called_once() + self.tray_indicator.indicator.set_icon_full.assert_called_once_with( + "vocalinux-microphone-off", "Error" + ) self.assertEqual(result, False) def test_set_menu_item_enabled(self):