Ajoute la configuration des associations application/preset, le service de suivi du focus, l'intégration GUI et les fichiers d'installation nécessaires. Ajoute les tests unitaires ciblés des bindings, du service de focus, des migrations et des composants GUI associés. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
211 lines
7.4 KiB
Python
211 lines
7.4 KiB
Python
# -*- coding: utf-8 -*-
|
|
# input-remapper - GUI for device specific keyboard mappings
|
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
|
#
|
|
# 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 <https://www.gnu.org/licenses/>.
|
|
|
|
"""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
|