fix(tray): prefer libayatana-appindicator-glib when available (#1019)
Use the maintained AyatanaAppIndicatorGlib 2.0 indicator when it is installed, mirroring the existing Gtk.Menu into a Gio.Menu whose actions keep stable, never-reused names and fire the original items. Fall back to the GTK 3 indicators otherwise; the AppImage stays on its bundled GTK 3 indicator, and the .deb recommends the glib typelib. Fixes #1013
This commit is contained in:
@@ -82,6 +82,10 @@ overrides:
|
||||
- ibus
|
||||
# Desktop notifications.
|
||||
- gir1.2-notify-0.7
|
||||
# Tray backend the app prefers when present: libayatana-appindicator
|
||||
# (GTK 3) logs a deprecation warning at startup. Debian 13+ ships it;
|
||||
# apt skips the recommendation where it does not exist (Debian 12).
|
||||
- gir1.2-ayatanaappindicatorglib-2.0
|
||||
rpm:
|
||||
depends:
|
||||
- python3 >= 3.11
|
||||
|
||||
+37
-27
@@ -13,6 +13,7 @@ import time
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .utils.host_process import running_from_appimage
|
||||
from .utils.vosk_model_info import SUPPORTED_LANGUAGES
|
||||
from .version import __version__
|
||||
|
||||
@@ -200,33 +201,17 @@ def check_dependencies():
|
||||
"GTK3 (install with: sudo apt install python3-gi gir1.2-gtk-3.0)"
|
||||
)
|
||||
|
||||
# Prefer Ayatana AppIndicator (maintained; registers on KDE Plasma).
|
||||
# Match tray_indicator.py: canonical Ayatana, rare lowercase typelib, then
|
||||
# legacy Canonical AppIndicator3 as last resort.
|
||||
try:
|
||||
import gi
|
||||
|
||||
gi.require_version("AyatanaAppIndicator3", "0.1")
|
||||
from gi.repository import AyatanaAppIndicator3 # noqa: F401
|
||||
except (ImportError, ValueError) as e:
|
||||
logger.debug("AyatanaAppIndicator3 import failed: %s", e)
|
||||
try:
|
||||
import gi
|
||||
|
||||
gi.require_version("AyatanaAppindicator3", "0.1")
|
||||
from gi.repository import AyatanaAppindicator3 # noqa: F401
|
||||
except (ImportError, ValueError) as e2:
|
||||
logger.debug("AyatanaAppindicator3 import failed: %s", e2)
|
||||
try:
|
||||
import gi
|
||||
|
||||
gi.require_version("AppIndicator3", "0.1")
|
||||
from gi.repository import AppIndicator3 # noqa: F401
|
||||
except (ImportError, ValueError) as e3:
|
||||
logger.debug("AppIndicator3 import failed: %s", e3)
|
||||
missing_system_deps.append(
|
||||
"AppIndicator3/AyatanaAppIndicator3 - Required for system tray icon"
|
||||
)
|
||||
# Match tray_indicator.py, which never loads the glib indicator from the
|
||||
# AppImage: the bundle's GLib is older than that library needs.
|
||||
indicator_typelibs = [
|
||||
(namespace, version)
|
||||
for namespace, version in _APPINDICATOR_TYPELIBS
|
||||
if namespace != "AyatanaAppIndicatorGlib" or not running_from_appimage()
|
||||
]
|
||||
if not any(_typelib_available(namespace, version) for namespace, version in indicator_typelibs):
|
||||
missing_system_deps.append(
|
||||
"AppIndicator3/AyatanaAppIndicator3 - Required for system tray icon"
|
||||
)
|
||||
|
||||
# Keyboard backends are optional and checked lazily by the shortcut manager.
|
||||
# Importing pynput can fail on Wayland/X-less sessions even when installed.
|
||||
@@ -283,6 +268,31 @@ def check_dependencies():
|
||||
return True
|
||||
|
||||
|
||||
# Tray indicator typelibs, most preferred first (same order as
|
||||
# tray_indicator.py): libayatana-appindicator-glib, Ayatana AppIndicator
|
||||
# (maintained; registers on KDE Plasma), its rare lowercase typelib, then
|
||||
# the legacy Canonical AppIndicator3 as last resort.
|
||||
_APPINDICATOR_TYPELIBS = (
|
||||
("AyatanaAppIndicatorGlib", "2.0"),
|
||||
("AyatanaAppIndicator3", "0.1"),
|
||||
("AyatanaAppindicator3", "0.1"),
|
||||
("AppIndicator3", "0.1"),
|
||||
)
|
||||
|
||||
|
||||
def _typelib_available(namespace: str, version: str) -> bool:
|
||||
"""Return True when the GObject Introspection namespace can be imported."""
|
||||
try:
|
||||
import gi
|
||||
|
||||
gi.require_version(namespace, version)
|
||||
getattr(__import__("gi.repository", fromlist=[namespace]), namespace)
|
||||
except (ImportError, ValueError, AttributeError) as e:
|
||||
logger.debug("%s import failed: %s", namespace, e)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def check_display_available():
|
||||
"""Check if a display is available for GTK."""
|
||||
try:
|
||||
|
||||
@@ -15,23 +15,42 @@ from typing import Any, Callable, Optional, Sequence, cast
|
||||
|
||||
import gi
|
||||
|
||||
from ..utils.host_process import host_env, running_from_appimage
|
||||
|
||||
# Import GTK
|
||||
gi.require_version("Gtk", "3.0")
|
||||
# Prefer Ayatana AppIndicator: it is the actively maintained fork and the one
|
||||
# most distros ship for KDE/SNI compatibility. The original Canonical
|
||||
# AppIndicator3 (unmaintained since ~2013) can be present alongside it (e.g.
|
||||
# Fedora's libappindicator-gtk3) but fails to register a StatusNotifierItem
|
||||
# on modern KDE Plasma, leaving the tray icon invisible with no error logged.
|
||||
try:
|
||||
gi.require_version("AyatanaAppIndicator3", "0.1")
|
||||
from gi.repository import AyatanaAppIndicator3 as AppIndicator3
|
||||
except (ImportError, ValueError):
|
||||
# Prefer libayatana-appindicator-glib: libayatana-appindicator (GTK 3) now
|
||||
# logs "libayatana-appindicator is deprecated" at startup and points here.
|
||||
# The glib library takes a GMenuModel instead of a Gtk.Menu, which
|
||||
# tray_menu_model bridges. The AppImage skips it: its bundled GLib is older
|
||||
# than the library needs and it does not ship the library, so a host copy on
|
||||
# GI's default search path would load against the bundle's GLib.
|
||||
# Otherwise prefer Ayatana AppIndicator: it is the actively maintained fork
|
||||
# and the one most distros ship for KDE/SNI compatibility. The original
|
||||
# Canonical AppIndicator3 (unmaintained since ~2013) can be present alongside
|
||||
# it (e.g. Fedora's libappindicator-gtk3) but fails to register a
|
||||
# StatusNotifierItem on modern KDE Plasma, leaving the tray icon invisible
|
||||
# with no error logged.
|
||||
INDICATOR_USES_GMENU = False
|
||||
if not running_from_appimage():
|
||||
try:
|
||||
gi.require_version("AyatanaAppindicator3", "0.1")
|
||||
from gi.repository import AyatanaAppindicator3 as AppIndicator3
|
||||
gi.require_version("AyatanaAppIndicatorGlib", "2.0")
|
||||
from gi.repository import AyatanaAppIndicatorGlib as AppIndicator
|
||||
|
||||
INDICATOR_USES_GMENU = True
|
||||
except (ImportError, ValueError):
|
||||
gi.require_version("AppIndicator3", "0.1")
|
||||
from gi.repository import AppIndicator3
|
||||
pass
|
||||
if not INDICATOR_USES_GMENU:
|
||||
try:
|
||||
gi.require_version("AyatanaAppIndicator3", "0.1")
|
||||
from gi.repository import AyatanaAppIndicator3 as AppIndicator
|
||||
except (ImportError, ValueError):
|
||||
try:
|
||||
gi.require_version("AyatanaAppindicator3", "0.1")
|
||||
from gi.repository import AyatanaAppindicator3 as AppIndicator
|
||||
except (ImportError, ValueError):
|
||||
gi.require_version("AppIndicator3", "0.1")
|
||||
from gi.repository import AppIndicator3 as AppIndicator
|
||||
|
||||
from gi.repository import GdkPixbuf, Gio, GLib, GObject, Gtk
|
||||
|
||||
@@ -47,7 +66,6 @@ from ..speech_recognition.diarization import (
|
||||
transcribe_audio_file,
|
||||
)
|
||||
from ..suspend_handler import SuspendHandler
|
||||
from ..utils.host_process import host_env
|
||||
from ..utils.resource_manager import ResourceManager
|
||||
from ..utils.update_checker import DEFAULT_CHECK_AUTOMATICALLY, ReleaseInfo
|
||||
from ..utils.update_monitor import UpdateMonitor
|
||||
@@ -63,6 +81,7 @@ from .keyboard_backends import (
|
||||
from .keyboard_shortcuts import KeyboardShortcutManager
|
||||
from .settings_dialog import ModelDownloadDialog, SettingsDialog, recommended_model_for_engine
|
||||
from .transcription_history import DEFAULT_MAX_ITEMS, TranscriptionHistory
|
||||
from .tray_menu_model import GMenuIndicator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -544,20 +563,21 @@ class TrayIndicator:
|
||||
initial_icon = self._icon_keys["default"]
|
||||
try:
|
||||
if FLATPAK_ID:
|
||||
self.indicator = AppIndicator3.Indicator.new(
|
||||
indicator = AppIndicator.Indicator.new(
|
||||
APP_ID,
|
||||
initial_icon,
|
||||
AppIndicator3.IndicatorCategory.APPLICATION_STATUS,
|
||||
AppIndicator.IndicatorCategory.APPLICATION_STATUS,
|
||||
)
|
||||
else:
|
||||
self.indicator = AppIndicator3.Indicator.new_with_path(
|
||||
indicator = AppIndicator.Indicator.new_with_path(
|
||||
APP_ID,
|
||||
initial_icon,
|
||||
AppIndicator3.IndicatorCategory.APPLICATION_STATUS,
|
||||
AppIndicator.IndicatorCategory.APPLICATION_STATUS,
|
||||
ICON_DIR,
|
||||
)
|
||||
self.indicator.set_icon_theme_path(ICON_DIR)
|
||||
self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
|
||||
indicator.set_icon_theme_path(ICON_DIR)
|
||||
self.indicator = GMenuIndicator(indicator, Gio) if INDICATOR_USES_GMENU else indicator
|
||||
self.indicator.set_status(AppIndicator.IndicatorStatus.ACTIVE)
|
||||
self.indicator.set_title("Vocalinux")
|
||||
self._indicator_title = "Vocalinux"
|
||||
except Exception as e:
|
||||
@@ -623,6 +643,7 @@ class TrayIndicator:
|
||||
else:
|
||||
self._show_update_menu_item(self._pending_update)
|
||||
self._gateway_stop_menu_item.hide()
|
||||
self._sync_indicator_menu()
|
||||
self._gateway_manager = get_gateway_embed_manager()
|
||||
self._gateway_manager.add_listener(self._on_gateway_status_for_tray)
|
||||
# Runtime detect + leftover compose probe (Quit does not stop compose).
|
||||
@@ -848,6 +869,12 @@ class TrayIndicator:
|
||||
separator = Gtk.SeparatorMenuItem()
|
||||
self.menu.append(separator)
|
||||
|
||||
def _sync_indicator_menu(self) -> None:
|
||||
"""Re-export the Gtk menu after a change when the indicator mirrors it."""
|
||||
indicator = getattr(self, "indicator", None)
|
||||
if isinstance(indicator, GMenuIndicator):
|
||||
indicator.refresh_menu()
|
||||
|
||||
def _on_recognition_state_changed(self, state: RecognitionState):
|
||||
"""
|
||||
Handle changes in the speech recognition state.
|
||||
@@ -1284,6 +1311,7 @@ class TrayIndicator:
|
||||
if isinstance(item, Gtk.MenuItem) and item.get_label() == label:
|
||||
item.set_sensitive(enabled)
|
||||
break
|
||||
self._sync_indicator_menu()
|
||||
|
||||
def _set_cancel_menu_item_visible(self, visible: bool) -> None:
|
||||
item = getattr(self, "_cancel_menu_item", None)
|
||||
@@ -1291,6 +1319,7 @@ class TrayIndicator:
|
||||
return
|
||||
item.set_sensitive(visible)
|
||||
item.set_visible(visible)
|
||||
self._sync_indicator_menu()
|
||||
|
||||
def _on_start_clicked(self, widget):
|
||||
"""Handle click on the Start Voice Typing menu item."""
|
||||
@@ -1335,6 +1364,7 @@ class TrayIndicator:
|
||||
|
||||
submenu.show_all()
|
||||
self._history_menu_item.set_submenu(submenu)
|
||||
self._sync_indicator_menu()
|
||||
return False # Remove idle callback
|
||||
|
||||
@staticmethod
|
||||
@@ -1463,6 +1493,7 @@ class TrayIndicator:
|
||||
self._maybe_notify_update(release)
|
||||
elif self._update_menu_item is not None:
|
||||
self._update_menu_item.hide()
|
||||
self._sync_indicator_menu()
|
||||
|
||||
def _show_update_menu_item(self, release: ReleaseInfo) -> None:
|
||||
"""Reveal the tray menu entry that opens About / release notes."""
|
||||
@@ -1474,6 +1505,7 @@ class TrayIndicator:
|
||||
"Open Settings → About for release notes and download links"
|
||||
)
|
||||
self._update_menu_item.show()
|
||||
self._sync_indicator_menu()
|
||||
|
||||
def _maybe_notify_update(self, release: ReleaseInfo) -> None:
|
||||
"""Send one desktop notification per new version when notifications are on."""
|
||||
@@ -1769,6 +1801,7 @@ class TrayIndicator:
|
||||
item.show()
|
||||
else:
|
||||
item.hide()
|
||||
self._sync_indicator_menu()
|
||||
return False
|
||||
|
||||
def _on_stop_local_gateway_clicked(self, widget: Any) -> None:
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
GMenuModel bridge for the libayatana-appindicator-glib tray backend.
|
||||
|
||||
libayatana-appindicator (GTK 3) takes a Gtk.Menu and now warns at startup
|
||||
that it is deprecated. Its successor, libayatana-appindicator-glib
|
||||
(``AyatanaAppIndicatorGlib`` 2.0), takes a GMenuModel plus a GActionGroup
|
||||
instead. The tray keeps building and mutating its Gtk.Menu exactly as it
|
||||
does for the GTK 3 backend; this module mirrors that menu into a Gio.Menu
|
||||
whose actions activate the original Gtk items, so every existing callback
|
||||
keeps working unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Action namespace the glib indicator exports its action group under.
|
||||
ACTION_PREFIX = "indicator"
|
||||
|
||||
# A menu layout is a tuple of sections (Gtk separators split them). Each
|
||||
# entry is ("item", label, action_name) or ("submenu", label, sub_layout).
|
||||
_Layout = tuple[tuple[tuple[str, Any, Any], ...], ...]
|
||||
|
||||
|
||||
def _is_separator(item: Any) -> bool:
|
||||
"""True for a Gtk.SeparatorMenuItem: it has no label and no submenu."""
|
||||
return not item.get_label() and item.get_submenu() is None
|
||||
|
||||
|
||||
class MenuModelBridge:
|
||||
"""Mirror a Gtk.Menu tree into a Gio.Menu and Gio.SimpleActionGroup.
|
||||
|
||||
Hidden items are left out, separators start a new section, submenus are
|
||||
mirrored recursively, and item sensitivity maps to action enabled state.
|
||||
Each Gtk item keeps one action name for as long as it stays in the menu,
|
||||
and a name is never handed to another item: a tray host can deliver a
|
||||
click from the menu it rendered before the last :meth:`sync`, and that
|
||||
click must reach the item it showed or nothing. Call :meth:`sync` after
|
||||
changing the Gtk menu.
|
||||
"""
|
||||
|
||||
def __init__(self, gtk_menu: Any, gio: Any) -> None:
|
||||
self._gtk_menu = gtk_menu
|
||||
self._gio = gio
|
||||
self.menu_model = gio.Menu()
|
||||
self.action_group = gio.SimpleActionGroup()
|
||||
self._items: dict[str, Any] = {}
|
||||
self._names: dict[Any, str] = {}
|
||||
self._next_name = 0
|
||||
self._layout: Optional[_Layout] = None
|
||||
self.sync()
|
||||
|
||||
def sync(self) -> None:
|
||||
"""Bring the menu model and actions in line with the Gtk menu."""
|
||||
items: dict[str, Any] = {}
|
||||
names: dict[Any, str] = {}
|
||||
layout = self._describe(self._gtk_menu, items, names)
|
||||
# Items that left the menu lose their name for good.
|
||||
self._names = names
|
||||
|
||||
for name, item in items.items():
|
||||
action = self.action_group.lookup_action(name)
|
||||
if action is None:
|
||||
action = self._gio.SimpleAction.new(name, None)
|
||||
action.connect("activate", self._on_action_activate, name)
|
||||
self.action_group.add_action(action)
|
||||
action.set_enabled(bool(item.get_sensitive()))
|
||||
for name in self._items.keys() - items.keys():
|
||||
self.action_group.remove_action(name)
|
||||
self._items = items
|
||||
|
||||
# Only rebuild the model when what the user sees changed: a rebuild
|
||||
# makes the tray host re-read the whole menu.
|
||||
if layout != self._layout:
|
||||
self.menu_model.remove_all()
|
||||
self._fill(self.menu_model, layout)
|
||||
self._layout = layout
|
||||
|
||||
def _describe(self, gtk_menu: Any, items: dict[str, Any], names: dict[Any, str]) -> _Layout:
|
||||
"""Snapshot the visible structure of ``gtk_menu``, naming each leaf action.
|
||||
|
||||
``items`` collects the visible leaves by action name; ``names`` collects
|
||||
every leaf still in the menu, hidden ones included, so an item that is
|
||||
hidden and shown again keeps its name.
|
||||
"""
|
||||
sections: list[tuple[tuple[str, Any, Any], ...]] = []
|
||||
current: list[tuple[str, Any, Any]] = []
|
||||
for item in gtk_menu.get_children():
|
||||
submenu = item.get_submenu()
|
||||
leaf = submenu is None and not _is_separator(item)
|
||||
if leaf:
|
||||
names[item] = self._action_name(item)
|
||||
if not item.get_visible():
|
||||
continue
|
||||
if submenu is not None:
|
||||
current.append(("submenu", item.get_label(), self._describe(submenu, items, names)))
|
||||
elif leaf:
|
||||
items[names[item]] = item
|
||||
current.append(("item", item.get_label(), names[item]))
|
||||
else:
|
||||
if current:
|
||||
sections.append(tuple(current))
|
||||
current = []
|
||||
if current:
|
||||
sections.append(tuple(current))
|
||||
return tuple(sections)
|
||||
|
||||
def _action_name(self, item: Any) -> str:
|
||||
"""Return ``item``'s action name, assigning a never-used one on first sight."""
|
||||
name = self._names.get(item)
|
||||
if name is None:
|
||||
name = f"item-{self._next_name}"
|
||||
self._next_name += 1
|
||||
return name
|
||||
|
||||
def _fill(self, menu: Any, layout: _Layout) -> None:
|
||||
"""Append ``layout`` to ``menu`` as one section per Gtk separator group."""
|
||||
for section in layout:
|
||||
section_menu = self._gio.Menu()
|
||||
for kind, label, payload in section:
|
||||
if kind == "submenu":
|
||||
submenu = self._gio.Menu()
|
||||
self._fill(submenu, payload)
|
||||
section_menu.append_submenu(label, submenu)
|
||||
else:
|
||||
section_menu.append(label, f"{ACTION_PREFIX}.{payload}")
|
||||
menu.append_section(None, section_menu)
|
||||
|
||||
def _on_action_activate(self, _action: Any, _parameter: Any, name: str) -> None:
|
||||
"""Forward an activated action to its Gtk item's ``activate`` handlers."""
|
||||
item = self._items.get(name)
|
||||
if item is None:
|
||||
logger.debug("Tray action %s no longer maps to a menu item", name)
|
||||
return
|
||||
if not item.get_visible() or not item.get_sensitive():
|
||||
logger.debug("Tray action %s targets a hidden or disabled item", name)
|
||||
return
|
||||
item.activate()
|
||||
|
||||
|
||||
class GMenuIndicator:
|
||||
"""Give an ``AyatanaAppIndicatorGlib.Indicator`` the GTK 3 indicator's API.
|
||||
|
||||
The tray calls ``set_menu`` with a Gtk.Menu and ``set_icon_full`` as it
|
||||
does on the GTK 3 backend; everything else passes through unchanged.
|
||||
"""
|
||||
|
||||
def __init__(self, indicator: Any, gio: Any) -> None:
|
||||
self._indicator = indicator
|
||||
self._gio = gio
|
||||
self._bridge: Optional[MenuModelBridge] = None
|
||||
|
||||
def set_menu(self, gtk_menu: Any) -> None:
|
||||
"""Export ``gtk_menu`` as the indicator's menu model and actions."""
|
||||
self._bridge = MenuModelBridge(gtk_menu, self._gio)
|
||||
self._indicator.set_menu(self._bridge.menu_model)
|
||||
self._indicator.set_actions(self._bridge.action_group)
|
||||
|
||||
def refresh_menu(self) -> None:
|
||||
"""Re-mirror the Gtk menu after its items changed."""
|
||||
if self._bridge is not None:
|
||||
self._bridge.sync()
|
||||
|
||||
def set_icon_full(self, icon_name: str, description: str) -> None:
|
||||
"""Set the icon; the glib library names this ``set_icon``."""
|
||||
self._indicator.set_icon(icon_name, description)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._indicator, name)
|
||||
@@ -20,6 +20,11 @@ from typing import Dict, Mapping, Optional
|
||||
_KEEP = ("APPDIR", "APPIMAGE")
|
||||
|
||||
|
||||
def running_from_appimage() -> bool:
|
||||
"""True when this process runs from the AppImage bundle (``APPDIR`` set)."""
|
||||
return bool(os.environ.get("APPDIR"))
|
||||
|
||||
|
||||
def _points_into(value: str, appdir: str) -> bool:
|
||||
if not value:
|
||||
return False
|
||||
|
||||
@@ -95,6 +95,28 @@ def test_appimage_build_ships_transitive_typelibs():
|
||||
assert "Need at least one of:" in text
|
||||
|
||||
|
||||
# Typelibs the app never requests inside the AppImage, so the bundle need not
|
||||
# seed them. libayatana-appindicator-glib needs a newer GLib than the bundle
|
||||
# carries; tray_indicator.py and main.py skip it when running_from_appimage().
|
||||
SKIPPED_IN_APPIMAGE = frozenset({"AyatanaAppIndicatorGlib-2.0"})
|
||||
|
||||
|
||||
def test_typelibs_skipped_in_appimage_are_guarded_by_running_from_appimage():
|
||||
for module in ("ui/tray_indicator.py", "main.py"):
|
||||
text = (REPO_ROOT / "src" / "vocalinux" / module).read_text(encoding="utf-8")
|
||||
assert "AyatanaAppIndicatorGlib" in text
|
||||
assert "running_from_appimage()" in text, f"{module} must skip the glib indicator"
|
||||
|
||||
|
||||
def test_running_from_appimage_follows_appdir(monkeypatch):
|
||||
from vocalinux.utils.host_process import running_from_appimage
|
||||
|
||||
monkeypatch.delenv("APPDIR", raising=False)
|
||||
assert not running_from_appimage()
|
||||
monkeypatch.setenv("APPDIR", "/tmp/.mount_vocalinux")
|
||||
assert running_from_appimage()
|
||||
|
||||
|
||||
def test_every_typelib_the_app_requires_is_seeded():
|
||||
"""A required typelib the bundle does not seed sends GI to the host's search
|
||||
path — the openSUSE/Fedora breakage from #585."""
|
||||
@@ -106,7 +128,9 @@ def test_every_typelib_the_app_requires_is_seeded():
|
||||
):
|
||||
required.add(f"{namespace}-{version}")
|
||||
assert required, "no gi.require_version calls found; has the app stopped using GI?"
|
||||
missing = sorted(name for name in required if name not in seeded)
|
||||
missing = sorted(
|
||||
name for name in required if name not in seeded and name not in SKIPPED_IN_APPIMAGE
|
||||
)
|
||||
assert not missing, f"the app requires these typelibs; TYPELIBS does not seed them: {missing}"
|
||||
|
||||
|
||||
|
||||
+27
-5
@@ -735,13 +735,34 @@ class TestCheckDependencies(unittest.TestCase):
|
||||
result = check_dependencies()
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_check_dependencies_accepts_appindicator_glib_alone(self):
|
||||
"""libayatana-appindicator-glib satisfies the tray dependency on its own."""
|
||||
|
||||
def require_version_side_effect(name, version):
|
||||
if name in ("AyatanaAppIndicator3", "AyatanaAppindicator3", "AppIndicator3"):
|
||||
raise ValueError(f"{name} not found")
|
||||
|
||||
mock_gi = MagicMock()
|
||||
mock_gi.require_version = MagicMock(side_effect=require_version_side_effect)
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"gi": mock_gi,
|
||||
"gi.repository": MagicMock(Gtk=MagicMock(), AyatanaAppIndicatorGlib=MagicMock()),
|
||||
"requests": MagicMock(),
|
||||
},
|
||||
):
|
||||
with patch("vocalinux.main.logger"):
|
||||
self.assertTrue(check_dependencies())
|
||||
|
||||
def test_check_dependencies_missing_appindicator_with_ayatana_fallback(self):
|
||||
"""Test when legacy AppIndicator3 is missing but Ayatana is available."""
|
||||
|
||||
# Prefer Ayatana; legacy AppIndicator3 is only a fallback.
|
||||
def require_version_side_effect(name, version):
|
||||
if name == "AppIndicator3":
|
||||
raise ValueError("AppIndicator3 not found")
|
||||
if name in ("AyatanaAppIndicatorGlib", "AppIndicator3"):
|
||||
raise ValueError(f"{name} not found")
|
||||
# AyatanaAppIndicator3 works fine
|
||||
|
||||
mock_gi = MagicMock()
|
||||
@@ -769,7 +790,7 @@ class TestCheckDependencies(unittest.TestCase):
|
||||
"""Test when Ayatana is missing but legacy AppIndicator3 is available."""
|
||||
|
||||
def require_version_side_effect(name, version):
|
||||
if name in ("AyatanaAppIndicator3", "AyatanaAppindicator3"):
|
||||
if name in ("AyatanaAppIndicatorGlib", "AyatanaAppIndicator3", "AyatanaAppindicator3"):
|
||||
raise ValueError(f"{name} not found")
|
||||
|
||||
mock_gi = MagicMock()
|
||||
@@ -796,8 +817,8 @@ class TestCheckDependencies(unittest.TestCase):
|
||||
"""Test rare lowercase AyatanaAppindicator3 typelib is accepted."""
|
||||
|
||||
def require_version_side_effect(name, version):
|
||||
if name == "AyatanaAppIndicator3":
|
||||
raise ValueError("AyatanaAppIndicator3 not found")
|
||||
if name in ("AyatanaAppIndicatorGlib", "AyatanaAppIndicator3"):
|
||||
raise ValueError(f"{name} not found")
|
||||
|
||||
mock_gi = MagicMock()
|
||||
mock_gi.require_version = MagicMock(side_effect=require_version_side_effect)
|
||||
@@ -825,6 +846,7 @@ class TestCheckDependencies(unittest.TestCase):
|
||||
# Make gi.require_version raise ValueError for all AppIndicator variants
|
||||
def require_version_side_effect(name, version):
|
||||
if name in (
|
||||
"AyatanaAppIndicatorGlib",
|
||||
"AppIndicator3",
|
||||
"AyatanaAppIndicator3",
|
||||
"AyatanaAppindicator3",
|
||||
|
||||
@@ -214,6 +214,14 @@ def test_host_tools_are_recommended_in_both_formats() -> None:
|
||||
assert tool in deps["rpm"]["recommends"], f"{tool} missing from rpm recommends"
|
||||
|
||||
|
||||
def test_deb_recommends_the_appindicator_glib_tray_backend() -> None:
|
||||
"""The tray prefers libayatana-appindicator-glib; the GTK 3 typelib stays a
|
||||
hard dependency so Debian 12, which lacks the glib one, still installs."""
|
||||
deps = _nfpm()["overrides"]["deb"]
|
||||
assert "gir1.2-ayatanaappindicatorglib-2.0" in deps["recommends"]
|
||||
assert "gir1.2-ayatanaappindicator3-0.1" in deps["depends"]
|
||||
|
||||
|
||||
def test_the_pins_are_digests_rather_than_names() -> None:
|
||||
for name, (kind, value, source) in _pins().items():
|
||||
if kind == "sha256":
|
||||
|
||||
@@ -943,7 +943,7 @@ class TestTrayIndicator(unittest.TestCase):
|
||||
|
||||
def test_init_indicator_creation_failure_shows_error_dialog(self):
|
||||
with patch(
|
||||
"vocalinux.ui.tray_indicator.AppIndicator3.Indicator.new_with_path",
|
||||
"vocalinux.ui.tray_indicator.AppIndicator.Indicator.new_with_path",
|
||||
side_effect=Exception("boom"),
|
||||
):
|
||||
with patch.object(
|
||||
@@ -968,7 +968,7 @@ class TestTrayIndicator(unittest.TestCase):
|
||||
|
||||
with patch("vocalinux.ui.tray_indicator.os.path.exists", return_value=False):
|
||||
with patch(
|
||||
"vocalinux.ui.tray_indicator.AppIndicator3.Indicator.new_with_path",
|
||||
"vocalinux.ui.tray_indicator.AppIndicator.Indicator.new_with_path",
|
||||
side_effect=Exception("fresh-boom"),
|
||||
):
|
||||
with patch(
|
||||
@@ -1333,14 +1333,42 @@ class TestAppIndicatorImportFallback(unittest.TestCase):
|
||||
)
|
||||
return repo
|
||||
|
||||
def test_prefers_ayatana_appindicator3_when_available(self):
|
||||
def test_prefers_appindicator_glib_over_gtk3_ayatana(self):
|
||||
sentinel = MagicMock(name="AyatanaAppIndicatorGlib")
|
||||
sys.modules["gi"] = mock_gi
|
||||
sys.modules["gi.repository"] = self._fake_repository(
|
||||
AyatanaAppIndicatorGlib=sentinel,
|
||||
AyatanaAppIndicator3=MagicMock(name="AyatanaAppIndicator3"),
|
||||
)
|
||||
|
||||
import vocalinux.ui.tray_indicator as tray
|
||||
|
||||
self.assertIs(tray.AppIndicator, sentinel)
|
||||
self.assertTrue(tray.INDICATOR_USES_GMENU)
|
||||
|
||||
def test_appimage_skips_appindicator_glib(self):
|
||||
sentinel = MagicMock(name="AyatanaAppIndicator3")
|
||||
sys.modules["gi"] = mock_gi
|
||||
sys.modules["gi.repository"] = self._fake_repository(
|
||||
AyatanaAppIndicatorGlib=MagicMock(name="AyatanaAppIndicatorGlib"),
|
||||
AyatanaAppIndicator3=sentinel,
|
||||
)
|
||||
|
||||
with patch.dict(os.environ, {"APPDIR": "/tmp/.mount_vocalinux"}):
|
||||
import vocalinux.ui.tray_indicator as tray
|
||||
|
||||
self.assertIs(tray.AppIndicator, sentinel)
|
||||
self.assertFalse(tray.INDICATOR_USES_GMENU)
|
||||
|
||||
def test_prefers_ayatana_appindicator3_when_glib_missing(self):
|
||||
sentinel = MagicMock(name="AyatanaAppIndicator3")
|
||||
sys.modules["gi"] = mock_gi
|
||||
sys.modules["gi.repository"] = self._fake_repository(AyatanaAppIndicator3=sentinel)
|
||||
|
||||
import vocalinux.ui.tray_indicator as tray
|
||||
|
||||
self.assertIs(tray.AppIndicator3, sentinel)
|
||||
self.assertIs(tray.AppIndicator, sentinel)
|
||||
self.assertFalse(tray.INDICATOR_USES_GMENU)
|
||||
|
||||
def test_falls_back_to_lowercase_ayatana_variant(self):
|
||||
sentinel = MagicMock(name="AyatanaAppindicator3")
|
||||
@@ -1349,7 +1377,8 @@ class TestAppIndicatorImportFallback(unittest.TestCase):
|
||||
|
||||
import vocalinux.ui.tray_indicator as tray
|
||||
|
||||
self.assertIs(tray.AppIndicator3, sentinel)
|
||||
self.assertIs(tray.AppIndicator, sentinel)
|
||||
self.assertFalse(tray.INDICATOR_USES_GMENU)
|
||||
|
||||
def test_falls_back_to_legacy_appindicator3_when_no_ayatana_available(self):
|
||||
sentinel = MagicMock(name="AppIndicator3")
|
||||
@@ -1358,7 +1387,69 @@ class TestAppIndicatorImportFallback(unittest.TestCase):
|
||||
|
||||
import vocalinux.ui.tray_indicator as tray
|
||||
|
||||
self.assertIs(tray.AppIndicator3, sentinel)
|
||||
self.assertIs(tray.AppIndicator, sentinel)
|
||||
self.assertFalse(tray.INDICATOR_USES_GMENU)
|
||||
|
||||
|
||||
class TestIndicatorBackendWiring(TestTrayIndicator):
|
||||
"""_init_indicator wraps the glib indicator and keeps its menu in sync."""
|
||||
|
||||
def _init_with_backend(self, uses_gmenu: bool) -> MagicMock:
|
||||
raw = MagicMock(name="raw-indicator")
|
||||
with (
|
||||
patch("vocalinux.ui.tray_indicator.INDICATOR_USES_GMENU", uses_gmenu),
|
||||
patch(
|
||||
"vocalinux.ui.tray_indicator.AppIndicator.Indicator.new_with_path",
|
||||
return_value=raw,
|
||||
),
|
||||
patch("vocalinux.ui.tray_indicator.FLATPAK_ID", None),
|
||||
):
|
||||
self.tray_indicator._init_indicator()
|
||||
return raw
|
||||
|
||||
def test_glib_backend_wraps_indicator_in_gmenu_adapter(self):
|
||||
from vocalinux.ui.tray_menu_model import GMenuIndicator
|
||||
|
||||
raw = self._init_with_backend(uses_gmenu=True)
|
||||
|
||||
self.assertIsInstance(self.tray_indicator.indicator, GMenuIndicator)
|
||||
raw.set_menu.assert_called_once()
|
||||
raw.set_actions.assert_called_once()
|
||||
raw.set_icon_theme_path.assert_called()
|
||||
|
||||
def test_flatpak_glib_backend_creates_indicator_without_icon_path(self):
|
||||
from vocalinux.ui.tray_menu_model import GMenuIndicator
|
||||
|
||||
raw = MagicMock(name="raw-indicator")
|
||||
with (
|
||||
patch("vocalinux.ui.tray_indicator.INDICATOR_USES_GMENU", True),
|
||||
patch(
|
||||
"vocalinux.ui.tray_indicator.AppIndicator.Indicator.new", return_value=raw
|
||||
) as new,
|
||||
patch("vocalinux.ui.tray_indicator.FLATPAK_ID", "com.vocalinux.Vocalinux"),
|
||||
):
|
||||
self.tray_indicator._init_indicator()
|
||||
|
||||
new.assert_called_once()
|
||||
self.assertIsInstance(self.tray_indicator.indicator, GMenuIndicator)
|
||||
raw.set_icon_theme_path.assert_not_called()
|
||||
raw.set_menu.assert_called_once()
|
||||
|
||||
def test_gtk3_backend_uses_indicator_directly(self):
|
||||
raw = self._init_with_backend(uses_gmenu=False)
|
||||
|
||||
self.assertIs(self.tray_indicator.indicator, raw)
|
||||
raw.set_menu.assert_called_once_with(self.tray_indicator.menu)
|
||||
|
||||
def test_menu_mutations_refresh_the_glib_menu(self):
|
||||
self._init_with_backend(uses_gmenu=True)
|
||||
with patch.object(self.tray_indicator.indicator, "refresh_menu") as refresh:
|
||||
self.tray_indicator._set_menu_item_enabled("Start Voice Typing", False)
|
||||
self.tray_indicator._set_cancel_menu_item_visible(True)
|
||||
self.tray_indicator._sync_gateway_stop_menu(None)
|
||||
self.tray_indicator._apply_update_status(False, None, notify=False)
|
||||
|
||||
self.assertEqual(refresh.call_count, 4)
|
||||
|
||||
|
||||
class TestTdrzDownloadDialog(TestTrayIndicator):
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Tests for the Gtk.Menu → GMenuModel bridge used with libayatana-appindicator-glib.
|
||||
|
||||
Gio and the Gtk menu are small recording fakes, so the tests check what the
|
||||
bridge exports without needing GObject Introspection or a display.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from vocalinux.ui.tray_menu_model import ACTION_PREFIX, GMenuIndicator, MenuModelBridge
|
||||
|
||||
|
||||
class FakeGioMenu:
|
||||
"""Records appended entries the way Gio.Menu exposes them."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.entries: list[tuple[str, Any, Any]] = []
|
||||
self.rebuilds = 0
|
||||
|
||||
def append(self, label: str, detailed_action: str) -> None:
|
||||
self.entries.append(("item", label, detailed_action))
|
||||
|
||||
def append_submenu(self, label: str, submenu: "FakeGioMenu") -> None:
|
||||
self.entries.append(("submenu", label, submenu))
|
||||
|
||||
def append_section(self, label: Optional[str], section: "FakeGioMenu") -> None:
|
||||
self.entries.append(("section", label, section))
|
||||
|
||||
def remove_all(self) -> None:
|
||||
self.entries = []
|
||||
self.rebuilds += 1
|
||||
|
||||
|
||||
class FakeAction:
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
self.enabled = True
|
||||
self._handlers: list[tuple[Callable[..., None], tuple[Any, ...]]] = []
|
||||
|
||||
def connect(self, _signal: str, handler: Callable[..., None], *user_data: Any) -> None:
|
||||
self._handlers.append((handler, user_data))
|
||||
|
||||
def set_enabled(self, enabled: bool) -> None:
|
||||
self.enabled = enabled
|
||||
|
||||
def activate(self) -> None:
|
||||
for handler, user_data in self._handlers:
|
||||
handler(self, None, *user_data)
|
||||
|
||||
|
||||
class FakeActionGroup:
|
||||
def __init__(self) -> None:
|
||||
self.actions: dict[str, FakeAction] = {}
|
||||
|
||||
def lookup_action(self, name: str) -> Optional[FakeAction]:
|
||||
return self.actions.get(name)
|
||||
|
||||
def add_action(self, action: FakeAction) -> None:
|
||||
self.actions[action.name] = action
|
||||
|
||||
def remove_action(self, name: str) -> None:
|
||||
del self.actions[name]
|
||||
|
||||
|
||||
class FakeSimpleAction:
|
||||
@staticmethod
|
||||
def new(name: str, _parameter_type: Any) -> FakeAction:
|
||||
return FakeAction(name)
|
||||
|
||||
|
||||
class FakeGio:
|
||||
Menu = FakeGioMenu
|
||||
SimpleActionGroup = FakeActionGroup
|
||||
SimpleAction = FakeSimpleAction
|
||||
|
||||
|
||||
class FakeMenuItem:
|
||||
"""Stand-in for Gtk.MenuItem / Gtk.SeparatorMenuItem (label "" = separator)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
label: str = "",
|
||||
*,
|
||||
visible: bool = True,
|
||||
sensitive: bool = True,
|
||||
submenu: Optional["FakeMenu"] = None,
|
||||
) -> None:
|
||||
self.label = label
|
||||
self.visible = visible
|
||||
self.sensitive = sensitive
|
||||
self.submenu = submenu
|
||||
self.activations = 0
|
||||
|
||||
def get_label(self) -> str:
|
||||
return self.label
|
||||
|
||||
def get_visible(self) -> bool:
|
||||
return self.visible
|
||||
|
||||
def get_sensitive(self) -> bool:
|
||||
return self.sensitive
|
||||
|
||||
def get_submenu(self) -> Optional["FakeMenu"]:
|
||||
return self.submenu
|
||||
|
||||
def activate(self) -> None:
|
||||
self.activations += 1
|
||||
|
||||
|
||||
class FakeMenu:
|
||||
def __init__(self, *items: FakeMenuItem) -> None:
|
||||
self.items = list(items)
|
||||
|
||||
def get_children(self) -> list[FakeMenuItem]:
|
||||
return list(self.items)
|
||||
|
||||
|
||||
def _sections(menu: FakeGioMenu) -> list[list[tuple[str, Any, Any]]]:
|
||||
return [section.entries for kind, _label, section in menu.entries if kind == "section"]
|
||||
|
||||
|
||||
def _labels(menu: FakeGioMenu) -> list[list[str]]:
|
||||
return [[label for _kind, label, _payload in section] for section in _sections(menu)]
|
||||
|
||||
|
||||
def test_separators_split_sections_and_hidden_items_are_skipped():
|
||||
start = FakeMenuItem("Start")
|
||||
menu = FakeMenu(
|
||||
start,
|
||||
FakeMenuItem("Cancel", visible=False),
|
||||
FakeMenuItem(),
|
||||
FakeMenuItem(), # back-to-back separators must not leave an empty section
|
||||
FakeMenuItem("Quit"),
|
||||
)
|
||||
|
||||
bridge = MenuModelBridge(menu, FakeGio)
|
||||
|
||||
assert _labels(bridge.menu_model) == [["Start"], ["Quit"]]
|
||||
first = _sections(bridge.menu_model)[0][0]
|
||||
assert first[2].startswith(f"{ACTION_PREFIX}.")
|
||||
|
||||
|
||||
def test_actions_follow_sensitivity_and_activate_the_gtk_item():
|
||||
start = FakeMenuItem("Start")
|
||||
stop = FakeMenuItem("Stop", sensitive=False)
|
||||
bridge = MenuModelBridge(FakeMenu(start, stop), FakeGio)
|
||||
|
||||
names = [payload.split(".", 1)[1] for _k, _l, payload in _sections(bridge.menu_model)[0]]
|
||||
start_action, stop_action = (bridge.action_group.actions[name] for name in names)
|
||||
assert start_action.enabled is True
|
||||
assert stop_action.enabled is False
|
||||
|
||||
start_action.activate()
|
||||
assert start.activations == 1
|
||||
|
||||
stop.sensitive = True
|
||||
bridge.sync()
|
||||
assert stop_action.enabled is True
|
||||
|
||||
|
||||
def test_submenus_are_mirrored_with_their_own_actions():
|
||||
snippet = FakeMenuItem("hello world")
|
||||
history = FakeMenuItem(
|
||||
"Recent Snippets",
|
||||
submenu=FakeMenu(snippet, FakeMenuItem(), FakeMenuItem("Clear History")),
|
||||
)
|
||||
bridge = MenuModelBridge(FakeMenu(history), FakeGio)
|
||||
|
||||
kind, label, submenu = _sections(bridge.menu_model)[0][0]
|
||||
assert (kind, label) == ("submenu", "Recent Snippets")
|
||||
assert _labels(submenu) == [["hello world"], ["Clear History"]]
|
||||
|
||||
action_name = _sections(submenu)[0][0][2].split(".", 1)[1]
|
||||
bridge.action_group.actions[action_name].activate()
|
||||
assert snippet.activations == 1
|
||||
|
||||
|
||||
def test_sync_rebuilds_only_when_the_visible_layout_changes():
|
||||
update = FakeMenuItem("Update Available…", visible=False)
|
||||
bridge = MenuModelBridge(FakeMenu(FakeMenuItem("Start"), update), FakeGio)
|
||||
rebuilds = bridge.menu_model.rebuilds
|
||||
|
||||
bridge.sync()
|
||||
assert bridge.menu_model.rebuilds == rebuilds
|
||||
|
||||
update.visible = True
|
||||
update.label = "Update Available (v1.0.0)…"
|
||||
bridge.sync()
|
||||
assert bridge.menu_model.rebuilds == rebuilds + 1
|
||||
assert _labels(bridge.menu_model) == [["Start", "Update Available (v1.0.0)…"]]
|
||||
|
||||
|
||||
def test_replaced_submenu_items_drop_stale_actions():
|
||||
old = FakeMenuItem("old snippet")
|
||||
history = FakeMenuItem("Recent Snippets", submenu=FakeMenu(old))
|
||||
bridge = MenuModelBridge(FakeMenu(FakeMenuItem("Start"), history), FakeGio)
|
||||
assert len(bridge.action_group.actions) == 2
|
||||
|
||||
history.submenu = FakeMenu(FakeMenuItem("(no snippets yet)", sensitive=False))
|
||||
bridge.sync()
|
||||
assert len(bridge.action_group.actions) == 2
|
||||
|
||||
history.submenu = FakeMenu()
|
||||
bridge.sync()
|
||||
assert len(bridge.action_group.actions) == 1
|
||||
|
||||
|
||||
def test_gmenu_indicator_adapts_gtk3_calls():
|
||||
raw = MagicMock()
|
||||
indicator = GMenuIndicator(raw, FakeGio)
|
||||
|
||||
indicator.set_menu(FakeMenu(FakeMenuItem("Start")))
|
||||
raw.set_menu.assert_called_once()
|
||||
raw.set_actions.assert_called_once()
|
||||
exported = raw.set_menu.call_args[0][0]
|
||||
assert _labels(exported) == [["Start"]]
|
||||
|
||||
indicator.set_icon_full("vocalinux-microphone", "Microphone on")
|
||||
raw.set_icon.assert_called_once_with("vocalinux-microphone", "Microphone on")
|
||||
|
||||
indicator.set_title("Vocalinux")
|
||||
raw.set_title.assert_called_once_with("Vocalinux")
|
||||
|
||||
|
||||
def test_refresh_before_set_menu_is_a_no_op():
|
||||
GMenuIndicator(MagicMock(), FakeGio).refresh_menu()
|
||||
|
||||
|
||||
def _action_names(menu: FakeGioMenu) -> dict[str, str]:
|
||||
"""Map each top-level item label to its action name."""
|
||||
return {
|
||||
label: payload.split(".", 1)[1]
|
||||
for section in _sections(menu)
|
||||
for kind, label, payload in section
|
||||
if kind == "item"
|
||||
}
|
||||
|
||||
|
||||
def _first_submenu_action(menu: FakeGioMenu) -> str:
|
||||
submenu = _sections(menu)[0][0][2]
|
||||
return str(_sections(submenu)[0][0][2]).split(".", 1)[1]
|
||||
|
||||
|
||||
def test_hiding_an_item_never_hands_its_action_to_a_neighbour():
|
||||
"""A click from the previous menu reaches its own item or nothing."""
|
||||
cancel = FakeMenuItem("Cancel")
|
||||
transcribe = FakeMenuItem("Transcribe Audio File…")
|
||||
bridge = MenuModelBridge(FakeMenu(FakeMenuItem("Start"), cancel, transcribe), FakeGio)
|
||||
before = _action_names(bridge.menu_model)
|
||||
stale_cancel = bridge.action_group.actions[before["Cancel"]]
|
||||
|
||||
cancel.visible = False
|
||||
bridge.sync()
|
||||
|
||||
assert _action_names(bridge.menu_model) == {
|
||||
"Start": before["Start"],
|
||||
"Transcribe Audio File…": before["Transcribe Audio File…"],
|
||||
}
|
||||
assert before["Cancel"] not in bridge.action_group.actions
|
||||
stale_cancel.activate()
|
||||
assert (cancel.activations, transcribe.activations) == (0, 0)
|
||||
|
||||
cancel.visible = True
|
||||
bridge.sync()
|
||||
assert _action_names(bridge.menu_model)["Cancel"] == before["Cancel"]
|
||||
|
||||
|
||||
def test_rebuilt_submenu_items_get_fresh_names():
|
||||
old = FakeMenuItem("old snippet")
|
||||
history = FakeMenuItem("Recent Snippets", submenu=FakeMenu(old))
|
||||
bridge = MenuModelBridge(FakeMenu(history), FakeGio)
|
||||
old_name = _first_submenu_action(bridge.menu_model)
|
||||
stale = bridge.action_group.actions[old_name]
|
||||
|
||||
new = FakeMenuItem("new snippet")
|
||||
history.submenu = FakeMenu(new)
|
||||
bridge.sync()
|
||||
new_name = _first_submenu_action(bridge.menu_model)
|
||||
|
||||
assert new_name != old_name
|
||||
assert old_name not in bridge.action_group.actions
|
||||
stale.activate()
|
||||
assert (old.activations, new.activations) == (0, 0)
|
||||
bridge.action_group.actions[new_name].activate()
|
||||
assert new.activations == 1
|
||||
|
||||
|
||||
def test_activation_ignores_items_changed_since_the_last_sync():
|
||||
cancel = FakeMenuItem("Cancel")
|
||||
stop = FakeMenuItem("Stop")
|
||||
bridge = MenuModelBridge(FakeMenu(cancel, stop), FakeGio)
|
||||
names = _action_names(bridge.menu_model)
|
||||
|
||||
cancel.visible = False
|
||||
stop.sensitive = False
|
||||
bridge.action_group.actions[names["Cancel"]].activate()
|
||||
bridge.action_group.actions[names["Stop"]].activate()
|
||||
|
||||
assert (cancel.activations, stop.activations) == (0, 0)
|
||||
Reference in New Issue
Block a user