feat(app-binding): lier les presets aux applications
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>
This commit is contained in:
20
inputremapper/focus/backends/__init__.py
Normal file
20
inputremapper/focus/backends/__init__.py
Normal file
@ -0,0 +1,20 @@
|
||||
# -*- 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 detection backends, one per compositor / display server."""
|
||||
174
inputremapper/focus/backends/hyprland.py
Normal file
174
inputremapper/focus/backends/hyprland.py
Normal file
@ -0,0 +1,174 @@
|
||||
# -*- 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 Hyprland (socket2 event stream + socket1 queries)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
from typing import 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
|
||||
|
||||
|
||||
def _runtime_dirs(signature: str) -> list[str]:
|
||||
"""Candidate hypr runtime directories, newest layout first."""
|
||||
candidates = []
|
||||
xdg_runtime = os.environ.get("XDG_RUNTIME_DIR")
|
||||
if xdg_runtime:
|
||||
candidates.append(os.path.join(xdg_runtime, "hypr", signature))
|
||||
# legacy location for older Hyprland versions
|
||||
candidates.append(os.path.join("/tmp", "hypr", signature))
|
||||
return candidates
|
||||
|
||||
|
||||
class HyprlandFocusBackend(FocusBackend):
|
||||
"""Reads ``activewindow`` events from the Hyprland socket2."""
|
||||
|
||||
name = "hyprland"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._socket: Optional[socket.socket] = None
|
||||
self._watch_id: Optional[int] = None
|
||||
self._buffer = b""
|
||||
self._on_focus: Optional[OnFocus] = None
|
||||
|
||||
@staticmethod
|
||||
def _signature() -> Optional[str]:
|
||||
return os.environ.get("HYPRLAND_INSTANCE_SIGNATURE")
|
||||
|
||||
@classmethod
|
||||
def _socket_path(cls, name: str) -> Optional[str]:
|
||||
signature = cls._signature()
|
||||
if not signature:
|
||||
return None
|
||||
for directory in _runtime_dirs(signature):
|
||||
path = os.path.join(directory, name)
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def is_available() -> bool:
|
||||
return HyprlandFocusBackend._socket_path(".socket2.sock") is not None
|
||||
|
||||
def start(self, on_focus: OnFocus) -> None:
|
||||
path = self._socket_path(".socket2.sock")
|
||||
if not path:
|
||||
raise RuntimeError("Hyprland socket2 not found")
|
||||
|
||||
self._on_focus = on_focus
|
||||
self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self._socket.connect(path)
|
||||
self._socket.setblocking(False)
|
||||
|
||||
self._watch_id = GLib.io_add_watch(
|
||||
self._socket.fileno(),
|
||||
GLib.IO_IN | GLib.IO_HUP | GLib.IO_ERR,
|
||||
self._on_readable,
|
||||
)
|
||||
logger.debug("Hyprland focus backend listening on %s", path)
|
||||
|
||||
def _on_readable(self, _fd: int, condition: int) -> bool:
|
||||
if condition & (GLib.IO_HUP | GLib.IO_ERR):
|
||||
logger.error("Hyprland socket2 closed")
|
||||
return False
|
||||
|
||||
assert self._socket is not None
|
||||
try:
|
||||
data = self._socket.recv(65536)
|
||||
except BlockingIOError:
|
||||
return True
|
||||
except OSError as error:
|
||||
logger.error("Hyprland socket2 read failed: %s", error)
|
||||
return False
|
||||
|
||||
if not data:
|
||||
return False
|
||||
|
||||
self._buffer += data
|
||||
while b"\n" in self._buffer:
|
||||
line, self._buffer = self._buffer.split(b"\n", 1)
|
||||
self._handle_line(line.decode(errors="replace"))
|
||||
return True
|
||||
|
||||
def _handle_line(self, line: str) -> None:
|
||||
# Lines look like "activewindow>>CLASS,TITLE"
|
||||
name, separator, data = line.partition(">>")
|
||||
if not separator or name != "activewindow":
|
||||
return
|
||||
|
||||
app_id, _, title = data.partition(",")
|
||||
event = FocusEvent(app_id=app_id, title=title, backend=self.name)
|
||||
if self._on_focus is not None:
|
||||
self._on_focus(event)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._watch_id is not None:
|
||||
GLib.source_remove(self._watch_id)
|
||||
self._watch_id = None
|
||||
if self._socket is not None:
|
||||
self._socket.close()
|
||||
self._socket = None
|
||||
self._buffer = b""
|
||||
|
||||
def get_current(self) -> Optional[FocusEvent]:
|
||||
path = self._socket_path(".socket.sock")
|
||||
if not path:
|
||||
return None
|
||||
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.connect(path)
|
||||
sock.sendall(b"j/activewindow")
|
||||
chunks = []
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
except OSError as error:
|
||||
logger.error("Failed to query Hyprland active window: %s", error)
|
||||
return None
|
||||
|
||||
raw = b"".join(chunks).decode(errors="replace").strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
if not data:
|
||||
return None
|
||||
|
||||
return FocusEvent(
|
||||
app_id=data.get("class", "") or "",
|
||||
title=data.get("title", "") or "",
|
||||
backend=self.name,
|
||||
)
|
||||
55
inputremapper/focus/backends/null.py
Normal file
55
inputremapper/focus/backends/null.py
Normal file
@ -0,0 +1,55 @@
|
||||
# -*- 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/>.
|
||||
|
||||
"""A fallback backend used when no focus detection is possible.
|
||||
|
||||
This is selected for example on GNOME-Wayland, where the focused window cannot
|
||||
be queried without a shell extension. The feature is effectively inactive, but
|
||||
the focus-service still runs and reports a clear status.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from inputremapper.focus.focus_backend import FocusBackend, FocusEvent, OnFocus
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
|
||||
class NullBackend(FocusBackend):
|
||||
"""Does nothing. Always available as the last-resort fallback."""
|
||||
|
||||
name = "null"
|
||||
|
||||
@staticmethod
|
||||
def is_available() -> bool:
|
||||
return True
|
||||
|
||||
def start(self, on_focus: OnFocus) -> None:
|
||||
logger.warning(
|
||||
"No supported focus detection is available for this session "
|
||||
"(GNOME-Wayland is not supported). Focus-driven preset binding is "
|
||||
"inactive."
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
pass
|
||||
|
||||
def get_current(self) -> Optional[FocusEvent]:
|
||||
return None
|
||||
215
inputremapper/focus/backends/sway.py
Normal file
215
inputremapper/focus/backends/sway.py
Normal file
@ -0,0 +1,215 @@
|
||||
# -*- 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 Sway (and other i3-IPC compatible compositors)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
from typing import 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
|
||||
|
||||
# https://i3wm.org/docs/ipc.html
|
||||
_MAGIC = b"i3-ipc"
|
||||
_HEADER = struct.Struct("=6sII") # magic, length, type (native byte order)
|
||||
|
||||
_MSG_SUBSCRIBE = 2
|
||||
_MSG_GET_TREE = 4
|
||||
|
||||
_EVENT_MASK = 0x80000000
|
||||
|
||||
|
||||
def _pack(message_type: int, payload: bytes = b"") -> bytes:
|
||||
return _HEADER.pack(_MAGIC, len(payload), message_type) + payload
|
||||
|
||||
|
||||
def _recv_exactly(sock: socket.socket, length: int) -> bytes:
|
||||
chunks = []
|
||||
received = 0
|
||||
while received < length:
|
||||
chunk = sock.recv(length - received)
|
||||
if not chunk:
|
||||
raise ConnectionError("Socket closed while reading")
|
||||
chunks.append(chunk)
|
||||
received += len(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _recv_message(sock: socket.socket) -> tuple[int, bytes]:
|
||||
header = _recv_exactly(sock, _HEADER.size)
|
||||
_, length, message_type = _HEADER.unpack(header)
|
||||
payload = _recv_exactly(sock, length) if length else b""
|
||||
return message_type, payload
|
||||
|
||||
|
||||
def _container_to_event(container: dict) -> Optional[FocusEvent]:
|
||||
if not container:
|
||||
return None
|
||||
|
||||
app_id = container.get("app_id")
|
||||
if not app_id:
|
||||
# XWayland windows expose their class via window_properties instead.
|
||||
window_properties = container.get("window_properties") or {}
|
||||
app_id = window_properties.get("class")
|
||||
|
||||
title = container.get("name") or ""
|
||||
return FocusEvent(app_id=app_id or "", title=title, backend=SwayFocusBackend.name)
|
||||
|
||||
|
||||
def _find_focused(node: dict) -> Optional[dict]:
|
||||
if node.get("focused"):
|
||||
return node
|
||||
for child in node.get("nodes", []) + node.get("floating_nodes", []):
|
||||
found = _find_focused(child)
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
class SwayFocusBackend(FocusBackend):
|
||||
"""Subscribes to window events over the i3-IPC socket ($SWAYSOCK)."""
|
||||
|
||||
name = "sway"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._socket: Optional[socket.socket] = None
|
||||
self._watch_id: Optional[int] = None
|
||||
self._buffer = b""
|
||||
self._on_focus: Optional[OnFocus] = None
|
||||
|
||||
@staticmethod
|
||||
def _socket_path() -> Optional[str]:
|
||||
return os.environ.get("SWAYSOCK") or os.environ.get("I3SOCK")
|
||||
|
||||
@staticmethod
|
||||
def is_available() -> bool:
|
||||
path = SwayFocusBackend._socket_path()
|
||||
return path is not None and os.path.exists(path)
|
||||
|
||||
def start(self, on_focus: OnFocus) -> None:
|
||||
path = self._socket_path()
|
||||
if not path:
|
||||
raise RuntimeError("SWAYSOCK is not set")
|
||||
|
||||
self._on_focus = on_focus
|
||||
self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self._socket.connect(path)
|
||||
self._socket.sendall(_pack(_MSG_SUBSCRIBE, json.dumps(["window"]).encode()))
|
||||
self._socket.setblocking(False)
|
||||
|
||||
self._watch_id = GLib.io_add_watch(
|
||||
self._socket.fileno(),
|
||||
GLib.IO_IN | GLib.IO_HUP | GLib.IO_ERR,
|
||||
self._on_readable,
|
||||
)
|
||||
logger.debug("Sway focus backend listening on %s", path)
|
||||
|
||||
def _on_readable(self, _fd: int, condition: int) -> bool:
|
||||
if condition & (GLib.IO_HUP | GLib.IO_ERR):
|
||||
logger.error("Sway IPC socket closed")
|
||||
return False
|
||||
|
||||
assert self._socket is not None
|
||||
try:
|
||||
data = self._socket.recv(65536)
|
||||
except BlockingIOError:
|
||||
return True
|
||||
except OSError as error:
|
||||
logger.error("Sway IPC read failed: %s", error)
|
||||
return False
|
||||
|
||||
if not data:
|
||||
return False
|
||||
|
||||
self._buffer += data
|
||||
self._consume_buffer()
|
||||
return True
|
||||
|
||||
def _consume_buffer(self) -> None:
|
||||
while len(self._buffer) >= _HEADER.size:
|
||||
_, length, message_type = _HEADER.unpack(self._buffer[: _HEADER.size])
|
||||
total = _HEADER.size + length
|
||||
if len(self._buffer) < total:
|
||||
break
|
||||
|
||||
payload = self._buffer[_HEADER.size : total]
|
||||
self._buffer = self._buffer[total:]
|
||||
|
||||
if message_type & _EVENT_MASK:
|
||||
self._handle_event(payload)
|
||||
|
||||
def _handle_event(self, payload: bytes) -> None:
|
||||
try:
|
||||
data = json.loads(payload.decode())
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return
|
||||
|
||||
change = data.get("change")
|
||||
if change not in ("focus", "title", "new"):
|
||||
return
|
||||
|
||||
event = _container_to_event(data.get("container") or {})
|
||||
if event is not None and self._on_focus is not None:
|
||||
self._on_focus(event)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._watch_id is not None:
|
||||
GLib.source_remove(self._watch_id)
|
||||
self._watch_id = None
|
||||
if self._socket is not None:
|
||||
self._socket.close()
|
||||
self._socket = None
|
||||
self._buffer = b""
|
||||
|
||||
def get_current(self) -> Optional[FocusEvent]:
|
||||
path = self._socket_path()
|
||||
if not path:
|
||||
return None
|
||||
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.connect(path)
|
||||
sock.sendall(_pack(_MSG_GET_TREE))
|
||||
while True:
|
||||
message_type, payload = _recv_message(sock)
|
||||
if message_type == _MSG_GET_TREE:
|
||||
break
|
||||
except OSError as error:
|
||||
logger.error("Failed to query Sway tree: %s", error)
|
||||
return None
|
||||
|
||||
try:
|
||||
tree = json.loads(payload.decode())
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
focused = _find_focused(tree)
|
||||
if focused is None:
|
||||
return None
|
||||
return _container_to_event(focused)
|
||||
210
inputremapper/focus/backends/wlr_foreign_toplevel.py
Normal file
210
inputremapper/focus/backends/wlr_foreign_toplevel.py
Normal file
@ -0,0 +1,210 @@
|
||||
# -*- 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
|
||||
181
inputremapper/focus/backends/xorg.py
Normal file
181
inputremapper/focus/backends/xorg.py
Normal file
@ -0,0 +1,181 @@
|
||||
# -*- 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 X11 / XWayland using python-xlib.
|
||||
|
||||
The root window is watched for ``_NET_ACTIVE_WINDOW`` property changes. The
|
||||
X connection's file descriptor is integrated into the GLib main loop so no
|
||||
extra thread is needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, 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
|
||||
|
||||
|
||||
class XorgFocusBackend(FocusBackend):
|
||||
"""Detects focus changes via _NET_ACTIVE_WINDOW on the X root window."""
|
||||
|
||||
name = "xorg"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._display: Any = None
|
||||
self._root: Any = None
|
||||
self._watch_id: Optional[int] = None
|
||||
self._on_focus: Optional[OnFocus] = None
|
||||
self._net_active_window: Any = None
|
||||
self._net_wm_name: Any = None
|
||||
self._utf8_string: Any = None
|
||||
|
||||
@staticmethod
|
||||
def is_available() -> bool:
|
||||
if not os.environ.get("DISPLAY"):
|
||||
return False
|
||||
try:
|
||||
from Xlib import display as xdisplay
|
||||
except ImportError:
|
||||
logger.debug("python-xlib is not installed")
|
||||
return False
|
||||
|
||||
try:
|
||||
connection = xdisplay.Display()
|
||||
connection.close()
|
||||
except Exception as error: # pragma: no cover - depends on environment
|
||||
logger.debug("Could not open X display: %s", error)
|
||||
return False
|
||||
return True
|
||||
|
||||
def start(self, on_focus: OnFocus) -> None:
|
||||
from Xlib import X, display as xdisplay
|
||||
|
||||
self._on_focus = on_focus
|
||||
self._display = xdisplay.Display()
|
||||
self._root = self._display.screen().root
|
||||
|
||||
self._net_active_window = self._display.intern_atom("_NET_ACTIVE_WINDOW")
|
||||
self._net_wm_name = self._display.intern_atom("_NET_WM_NAME")
|
||||
self._utf8_string = self._display.intern_atom("UTF8_STRING")
|
||||
|
||||
self._root.change_attributes(event_mask=X.PropertyChangeMask)
|
||||
self._display.flush()
|
||||
|
||||
self._watch_id = GLib.io_add_watch(
|
||||
self._display.fileno(),
|
||||
GLib.IO_IN | GLib.IO_HUP | GLib.IO_ERR,
|
||||
self._on_readable,
|
||||
)
|
||||
logger.debug("Xorg focus backend watching root window")
|
||||
|
||||
def _on_readable(self, _fd: int, condition: int) -> bool:
|
||||
from Xlib import X
|
||||
|
||||
if condition & (GLib.IO_HUP | GLib.IO_ERR):
|
||||
logger.error("X connection lost")
|
||||
return False
|
||||
|
||||
try:
|
||||
pending = self._display.pending_events()
|
||||
for _ in range(pending):
|
||||
event = self._display.next_event()
|
||||
if (
|
||||
event.type == X.PropertyNotify
|
||||
and event.atom == self._net_active_window
|
||||
):
|
||||
self._emit_current()
|
||||
except Exception as error: # pragma: no cover - depends on environment
|
||||
logger.error("Error while reading X events: %s", error)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _emit_current(self) -> None:
|
||||
event = self.get_current()
|
||||
if event is not None and self._on_focus is not None:
|
||||
self._on_focus(event)
|
||||
|
||||
def _active_window(self) -> Any:
|
||||
prop = self._root.get_full_property(self._net_active_window, 0)
|
||||
if prop is None or not prop.value:
|
||||
return None
|
||||
window_id = prop.value[0]
|
||||
if not window_id:
|
||||
return None
|
||||
return self._display.create_resource_object("window", window_id)
|
||||
|
||||
def _read_title(self, window: Any) -> str:
|
||||
from Xlib import X
|
||||
|
||||
try:
|
||||
prop = window.get_full_property(self._net_wm_name, self._utf8_string)
|
||||
if prop and prop.value:
|
||||
value = prop.value
|
||||
if isinstance(value, bytes):
|
||||
return value.decode(errors="replace")
|
||||
return str(value)
|
||||
prop = window.get_full_property(X.XA_WM_NAME, X.AnyPropertyType)
|
||||
if prop and prop.value:
|
||||
value = prop.value
|
||||
if isinstance(value, bytes):
|
||||
return value.decode(errors="replace")
|
||||
return str(value)
|
||||
except Exception: # pragma: no cover - depends on environment
|
||||
pass
|
||||
return ""
|
||||
|
||||
def get_current(self) -> Optional[FocusEvent]:
|
||||
if self._display is None:
|
||||
return None
|
||||
|
||||
from Xlib.error import XError
|
||||
|
||||
try:
|
||||
window = self._active_window()
|
||||
if window is None:
|
||||
return FocusEvent(app_id="", title="", backend=self.name)
|
||||
|
||||
wm_class = window.get_wm_class()
|
||||
# get_wm_class returns (instance, class); the class is the canonical id
|
||||
app_id = wm_class[1] if wm_class else ""
|
||||
title = self._read_title(window)
|
||||
except XError as error:
|
||||
logger.debug("X error while reading active window: %s", error)
|
||||
return None
|
||||
|
||||
return FocusEvent(app_id=app_id or "", title=title, backend=self.name)
|
||||
|
||||
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.close()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
pass
|
||||
self._display = None
|
||||
self._root = None
|
||||
Reference in New Issue
Block a user