diff --git a/packaging/aur/vocalinux/PKGBUILD b/packaging/aur/vocalinux/PKGBUILD index b9aeb32..b7b5bf6 100644 --- a/packaging/aur/vocalinux/PKGBUILD +++ b/packaging/aur/vocalinux/PKGBUILD @@ -56,6 +56,7 @@ optdepends=( 'xclip: X11 clipboard tools (copy/paste injection fallbacks)' 'wl-clipboard: Wayland clipboard (wl-copy/wl-paste) injection fallbacks' 'wtype: Wayland keystroke injection fallback' + 'gtk-layer-shell: dictation overlay as a layer surface on KDE and wlroots Wayland' ) conflicts=('vocalinux-git') source=("${pkgname}-${_version}.tar.gz::https://github.com/VocaHQ/vocalinux/releases/download/v${_tag}/${pkgname}-${_version}.tar.gz") diff --git a/src/vocalinux/ui/dictation_overlay.py b/src/vocalinux/ui/dictation_overlay.py index fca581e..268b621 100644 --- a/src/vocalinux/ui/dictation_overlay.py +++ b/src/vocalinux/ui/dictation_overlay.py @@ -10,6 +10,7 @@ from __future__ import annotations import logging import math +import os from types import ModuleType from typing import TYPE_CHECKING, Callable, Optional @@ -92,6 +93,16 @@ class DictationOverlayController: return MODE_PROCESSING +def _is_kde_session() -> bool: + """Whether the session is KDE Plasma (KWin), from the usual desktop variables.""" + if os.environ.get("KDE_FULL_SESSION", "").lower() == "true": + return True + desktop = " ".join( + os.environ.get(var, "") for var in ("XDG_CURRENT_DESKTOP", "DESKTOP_SESSION", "GDMSESSION") + ).lower() + return "kde" in desktop or "plasma" in desktop + + def _try_import_layer_shell() -> Optional[ModuleType]: """ Soft-import GtkLayerShell when available (Wayland, wlroots-based). @@ -115,9 +126,11 @@ class DictationOverlay: """ Floating glowing dictation indicator backed by a GTK window. - Uses a full-width bottom strip so the orb is always bottom-center of the - monitor (Wayland often ignores move() on a small centered popup). The - window is passive: no keyboard focus, click-through input region. + On X11 and with layer shell, a full-width bottom strip keeps the orb + bottom-center of the monitor. A plain Wayland toplevel cannot be + positioned: on KDE it is a compact window KWin places, elsewhere the + strip stays so the orb is at least horizontally centered. The window is + passive: no keyboard focus, click-through input region. """ def __init__( @@ -312,16 +325,6 @@ class DictationOverlay: """Re-evaluate the hide strategy when a compositor appears or leaves.""" self._sync_window() - def _is_wayland(self) -> bool: - """True when the default display is a Wayland one.""" - try: - wayland_cls = getattr(self._Gdk, "WaylandDisplay", None) - if wayland_cls is None: - return False - return isinstance(self._Gdk.Display.get_default(), wayland_cls) - except Exception: - return False - def _display_is_wayland(self) -> bool: """Check the GType name; GTK3's Gdk typelib does not expose GdkWaylandDisplay as a class.""" display = self._Gdk.Display.get_default() @@ -467,13 +470,16 @@ class DictationOverlay: def _layout_bottom_strip(self) -> None: """ - Size the window as a full-width bottom strip and place it on the primary monitor. + Size and place the fallback window (no layer shell) on the primary monitor. Drawing the orb at the strip's horizontal center always yields bottom-center of the screen, even when the compositor only roughly honors move(). On Wayland without layer shell, move() is a hint the compositor can - ignore entirely, so ask for a centered compact window instead of - promising a bottom position it cannot place. + ignore. KWin lists the full-width strip in the taskbar as a wide empty + window (#1030), so on KDE ask for a centered compact window instead. + Mutter ignores CENTER_ALWAYS and would put a compact window in the + top-left corner, so GNOME and other compositors keep the strip, which + at least keeps the orb horizontally centered. """ if self._window is None: return @@ -488,8 +494,8 @@ class DictationOverlay: return geom = monitor.get_geometry() height = _STRIP_HEIGHT - wayland = self._is_wayland() - if wayland: + compact = self._display_is_wayland() and _is_kde_session() + if compact: self._window.set_position(self._Gtk.WindowPosition.CENTER_ALWAYS) width = _ORB_SIZE else: @@ -497,7 +503,7 @@ class DictationOverlay: self._window.resize(width, height) if self._drawing_area is not None: self._drawing_area.set_size_request(width, height) - if not wayland: + if not compact: x = geom.x y = geom.y + geom.height - height - _BOTTOM_MARGIN self._window.move(x, y) diff --git a/tests/test_dictation_overlay.py b/tests/test_dictation_overlay.py index 72459a9..b16f4e1 100644 --- a/tests/test_dictation_overlay.py +++ b/tests/test_dictation_overlay.py @@ -592,6 +592,7 @@ class TestOverlayWithMockedGtkWindow(unittest.TestCase): monitor = MagicMock() monitor.get_geometry.return_value = geom display = MagicMock() + display.__gtype__ = SimpleNamespace(name="GdkX11Display") display.get_primary_monitor.return_value = monitor overlay._Gdk = MagicMock() overlay._Gdk.Display.get_default.return_value = display @@ -601,6 +602,55 @@ class TestOverlayWithMockedGtkWindow(unittest.TestCase): mock_window.resize.assert_called_once_with(1920, _STRIP_HEIGHT) mock_window.move.assert_called_once_with(100, 50 + 1080 - _STRIP_HEIGHT - _BOTTOM_MARGIN) + def _layout_on_wayland(self, desktop: str) -> DictationOverlay: + """Lay out the no-layer-shell fallback on a 2560x1600 Wayland monitor. + + GTK3's Gdk typelib has no ``WaylandDisplay`` class, so the display + must be recognized by its GType name, as ``_display_is_wayland`` does. + """ + overlay = _overlay_without_gtk(enabled=True) + overlay._window = MagicMock() + overlay._drawing_area = MagicMock() + geom = MagicMock(x=0, y=0, width=2560, height=1600) + monitor = MagicMock() + monitor.get_geometry.return_value = geom + display = MagicMock() + display.__gtype__ = SimpleNamespace(name="GdkWaylandDisplay") + display.get_primary_monitor.return_value = monitor + # Mirror the real GTK3 typelib: Gdk has Display but no WaylandDisplay. + overlay._Gdk = SimpleNamespace(Display=MagicMock()) + overlay._Gdk.Display.get_default.return_value = display + overlay._Gtk = MagicMock() + env = {"XDG_CURRENT_DESKTOP": desktop, "DESKTOP_SESSION": "", "GDMSESSION": ""} + with patch.dict(os.environ, env): + os.environ.pop("KDE_FULL_SESSION", None) + overlay._layout_bottom_strip() + return overlay + + def test_layout_wayland_fallback_on_kde_is_compact_and_centered(self): + """KWin lists the full-width strip in the taskbar; KDE gets a compact window (#1030).""" + from vocalinux.ui.dictation_overlay import _ORB_SIZE, _STRIP_HEIGHT + + overlay = self._layout_on_wayland("KDE") + + overlay._window.resize.assert_called_once_with(_ORB_SIZE, _STRIP_HEIGHT) + overlay._window.set_position.assert_called_once_with( + overlay._Gtk.WindowPosition.CENTER_ALWAYS + ) + overlay._window.move.assert_not_called() + + def test_layout_wayland_fallback_on_gnome_keeps_full_width_strip(self): + """Mutter ignores CENTER_ALWAYS, so a compact window would sit top-left. + + GNOME keeps the full-width strip, which leaves the orb horizontally centered. + """ + from vocalinux.ui.dictation_overlay import _STRIP_HEIGHT + + overlay = self._layout_on_wayland("ubuntu:GNOME") + + overlay._window.resize.assert_called_once_with(2560, _STRIP_HEIGHT) + overlay._window.set_position.assert_not_called() + def test_layout_falls_back_to_first_monitor(self): overlay = _overlay_without_gtk(enabled=True) mock_window = MagicMock()