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:
2026-06-19 23:52:34 +02:00
parent 0ab77d2a64
commit 9ef2c1439c
42 changed files with 4353 additions and 12 deletions

View 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