fix(overlay): detect Wayland by GType so the fallback overlay stays compact (#1030) (#1035)

* fix(overlay): detect Wayland by GType so the fallback overlay stays compact (#1030)

_is_wayland() checked isinstance(display, Gdk.WaylandDisplay), but GTK3's
Gdk typelib has no WaylandDisplay class, so it always returned False. On
Wayland without gtk-layer-shell, _layout_bottom_strip() therefore sized the
prewarmed fallback window to the full monitor width (the X11 strip) instead
of the compact centered window meant for Wayland, which on KDE showed up as
a wide empty "Vocalinux Dictation" window listed in the taskbar.

Drop _is_wayland() and use _display_is_wayland(), which checks the GType
name and already gates prewarm, so both decisions agree.

Also list gtk-layer-shell as an optdepend of the source AUR package: with
it KWin and wlroots compositors get a layer surface that is never listed
in the taskbar and never needs the prewarmed toplevel. The AppImage (and
so vocalinux-bin) already bundles it.

* docs(overlay): describe the compact Wayland fallback in the overlay docstrings

The class docstring still said the overlay is always a full-width strip, which no longer holds on Wayland without layer shell (#1030).

* fix(overlay): keep the full-width strip on GNOME Wayland

Mutter ignores CENTER_ALWAYS for a plain Wayland toplevel, so the compact
fallback window landed in the top-left corner on GNOME (tested on GNOME
Shell 46 with the Ubuntu Dock). The full-width strip at least keeps the
orb horizontally centered there. Use the compact window only on KDE,
where KWin listed the strip in the taskbar as a wide empty window (#1030).
This commit is contained in:
Jatin K Malik
2026-11-02 19:17:27 -08:00
committed by GitHub
parent f70357f2b8
commit 0a4d637f5f
3 changed files with 76 additions and 19 deletions
+1
View File
@@ -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")
+25 -19
View File
@@ -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)
+50
View File
@@ -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()