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>
182 lines
6.2 KiB
Python
182 lines
6.2 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 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
|