# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Focus backend for wlroots based compositors and KDE/KWin (Wayland). Uses the ``zwlr_foreign_toplevel_manager_v1`` protocol to learn which toplevel window is currently "activated" (focused). pywayland is an optional dependency: if it (or the protocol bindings) are missing, the backend reports itself as unavailable and the registry falls back to the next backend. """ from __future__ import annotations import os from typing import Any, Dict, Optional import gi from inputremapper.focus.focus_backend import FocusBackend, FocusEvent, OnFocus from inputremapper.logging.logger import logger gi.require_version("GLib", "2.0") from gi.repository import GLib # noqa: E402 # zwlr_foreign_toplevel_handle_v1 state value for "activated". _STATE_ACTIVATED = 2 _MANAGER_INTERFACE = "zwlr_foreign_toplevel_manager_v1" def _import_protocol() -> Optional[Any]: """Import the zwlr_foreign_toplevel manager class, or None if unavailable.""" try: from pywayland.protocol.wlr_foreign_toplevel_management_unstable_v1 import ( # noqa: E501 ZwlrForeignToplevelManagerV1, ) except (ImportError, ModuleNotFoundError): return None return ZwlrForeignToplevelManagerV1 class WlrForeignToplevelBackend(FocusBackend): """Tracks the activated toplevel via zwlr_foreign_toplevel_manager_v1.""" name = "wlr-foreign-toplevel" def __init__(self) -> None: self._display: Any = None self._registry: Any = None self._manager: Any = None self._watch_id: Optional[int] = None self._on_focus: Optional[OnFocus] = None # handle -> {"app_id": str, "title": str, "activated": bool} self._toplevels: Dict[Any, Dict[str, Any]] = {} self._current: Optional[FocusEvent] = None @staticmethod def is_available() -> bool: if not os.environ.get("WAYLAND_DISPLAY"): return False if _import_protocol() is None: logger.debug("pywayland or wlr-foreign-toplevel bindings not available") return False # Verify the compositor actually exports the manager global. try: from pywayland.client import Display except (ImportError, ModuleNotFoundError): return False display = None try: display = Display() display.connect() found = {"value": False} registry = display.get_registry() def _on_global(_registry, _name, interface, _version): if interface == _MANAGER_INTERFACE: found["value"] = True registry.dispatcher["global"] = _on_global display.roundtrip() return found["value"] except Exception as error: # pragma: no cover - depends on environment logger.debug("wlr-foreign-toplevel probe failed: %s", error) return False finally: if display is not None: try: display.disconnect() except Exception: # pragma: no cover - defensive pass def start(self, on_focus: OnFocus) -> None: from pywayland.client import Display manager_class = _import_protocol() if manager_class is None: raise RuntimeError("wlr-foreign-toplevel bindings are not available") self._on_focus = on_focus self._display = Display() self._display.connect() self._registry = self._display.get_registry() self._registry.dispatcher["global"] = self._make_global_handler(manager_class) self._display.roundtrip() self._display.flush() self._watch_id = GLib.io_add_watch( self._display.get_fd(), GLib.IO_IN | GLib.IO_HUP | GLib.IO_ERR, self._on_readable, ) logger.debug("wlr-foreign-toplevel focus backend started") def _make_global_handler(self, manager_class: Any): def _on_global(registry, name, interface, version): if interface != _MANAGER_INTERFACE: return self._manager = registry.bind(name, manager_class, version) self._manager.dispatcher["toplevel"] = self._on_toplevel return _on_global def _on_toplevel(self, _manager, handle) -> None: self._toplevels[handle] = {"app_id": "", "title": "", "activated": False} handle.dispatcher["app_id"] = self._on_app_id handle.dispatcher["title"] = self._on_title handle.dispatcher["state"] = self._on_state handle.dispatcher["done"] = self._on_done handle.dispatcher["closed"] = self._on_closed def _on_app_id(self, handle, app_id) -> None: if handle in self._toplevels: self._toplevels[handle]["app_id"] = app_id or "" def _on_title(self, handle, title) -> None: if handle in self._toplevels: self._toplevels[handle]["title"] = title or "" def _on_state(self, handle, states) -> None: if handle not in self._toplevels: return # `states` is a wl_array of 32-bit ints. values = list(states) if states is not None else [] self._toplevels[handle]["activated"] = _STATE_ACTIVATED in values def _on_done(self, handle) -> None: info = self._toplevels.get(handle) if info is None or not info["activated"]: return event = FocusEvent( app_id=info["app_id"], title=info["title"], backend=self.name ) self._current = event if self._on_focus is not None: self._on_focus(event) def _on_closed(self, handle) -> None: self._toplevels.pop(handle, None) def _on_readable(self, _fd: int, condition: int) -> bool: if condition & (GLib.IO_HUP | GLib.IO_ERR): logger.error("Wayland connection lost") return False try: self._display.dispatch(block=False) self._display.flush() except Exception as error: # pragma: no cover - depends on environment logger.error("Error dispatching Wayland events: %s", error) return False return True def get_current(self) -> Optional[FocusEvent]: return self._current def stop(self) -> None: if self._watch_id is not None: GLib.source_remove(self._watch_id) self._watch_id = None if self._display is not None: try: self._display.disconnect() except Exception: # pragma: no cover - defensive pass self._display = None self._manager = None self._registry = None self._toplevels.clear() self._current = None