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.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user