Le suivi du focus laissait les injections app-controlled actives lors d'une perte de focus réelle (fermeture de la fenêtre courante, retour à un bureau vide). On corrige la détection sur toute la chaîne : - FocusService: un app_id vide est désormais traité comme un unfocus réel (et non un focus transitoire ignoré), ce qui stoppe les injections liées à l'application. - Sway: on ignore les events 'new' (pas un changement de focus) et on relit le focus courant sur un event 'title' non focused. - WlrForeignToplevelBackend: suit _current_handle, le clear sur stop() et émet un FocusEvent vide quand le toplevel actuellement focus se ferme. Tests: test_focus_service + test_focus_watcher (67) OK; lot feature historique (295) OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
221 lines
7.8 KiB
Python
221 lines
7.8 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
|
|
self._current_handle: Optional[Any] = 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_handle = handle
|
|
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)
|
|
if handle != self._current_handle:
|
|
return
|
|
|
|
self._current_handle = None
|
|
self._current = FocusEvent(app_id="", title="", backend=self.name)
|
|
if self._on_focus is not None:
|
|
self._on_focus(self._current)
|
|
|
|
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_handle = None
|
|
self._current = None
|