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,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 and focus-driven preset binding (user-level)."""

View 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."""

View 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,
)

View 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

View 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)

View 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

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

View File

@ -0,0 +1,88 @@
# -*- 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/>.
"""The base class and event for all focus backends.
This lives in its own module to avoid a circular import between the backend
registry (``focus_watcher``) and the concrete backends.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Callable, Optional
@dataclass(frozen=True)
class FocusEvent:
"""A normalized description of the currently focused window.
Attributes
----------
app_id
The canonical application identifier (WM_CLASS on X11, app_id on
Wayland). This is the value bindings are matched against. May be empty
for transient focus changes (menus, popups, no focus).
title
The human readable window title (_NET_WM_NAME / title).
backend
The name of the backend that produced this event.
"""
app_id: str
title: str
backend: str
# Type alias for the focus callback passed to FocusBackend.start.
OnFocus = Callable[[FocusEvent], None]
class FocusBackend(ABC):
"""Detects the focused window for a specific compositor / display server.
Backends integrate into the GLib main loop (e.g. via ``GLib.io_add_watch``
on a file descriptor) and call the ``on_focus`` callback whenever the
focused window changes.
"""
# A short, human readable identifier, also used as FocusEvent.backend.
name: str = "base"
@staticmethod
@abstractmethod
def is_available() -> bool:
"""Whether this backend can run in the current session."""
raise NotImplementedError
@abstractmethod
def start(self, on_focus: OnFocus) -> None:
"""Begin watching for focus changes, invoking on_focus on each change."""
raise NotImplementedError
@abstractmethod
def stop(self) -> None:
"""Stop watching and release all resources."""
raise NotImplementedError
@abstractmethod
def get_current(self) -> Optional[FocusEvent]:
"""Return the currently focused window, or None if unavailable."""
raise NotImplementedError

View File

@ -0,0 +1,284 @@
# -*- 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/>.
"""User-level service that applies presets based on the focused application.
This process runs as the logged-in user (never root, no pkexec). It reads the
user config, detects the focused window through a ``FocusBackend`` and drives
the existing root daemon over the system D-Bus. It exposes a small interface on
the session bus (``inputremapper.Focus``) for the GUI.
"""
from __future__ import annotations
import json
from typing import Dict, List, Optional
import gi
from dasbus.connection import SessionMessageBus
from dasbus.identifier import DBusServiceIdentifier
from dasbus.loop import EventLoop
from dasbus.signal import Signal
from inputremapper.configs.app_binding import AppBinding
from inputremapper.configs.global_config import GlobalConfig
from inputremapper.daemon import DaemonProxy
from inputremapper.focus.focus_backend import FocusBackend, FocusEvent
from inputremapper.logging.logger import logger
gi.require_version("GLib", "2.0")
from gi.repository import GLib # noqa: E402
SESSION_BUS = SessionMessageBus()
FOCUS = DBusServiceIdentifier(
namespace=("inputremapper", "Focus"),
message_bus=SESSION_BUS,
)
# How long to wait for the focus to settle before reconciling, in milliseconds.
# This collapses rapid alt-tabbing into a single reconciliation.
DEFAULT_DEBOUNCE_MS = 150
class FocusService:
"""Reconciles injected presets with the currently focused application.
The service only ever stops injections that it started itself (tracked in
``_app_controlled``). Presets applied manually (e.g. from the GUI) are left
untouched until another *bound* application takes focus and reclaims that
device, which implements the "manual apply is a temporary override"
behavior.
"""
__dbus_xml__ = f"""
<node>
<interface name='{FOCUS.interface_name}'>
<method name='get_focused_app'>
<arg type='s' name='response' direction='out'/>
</method>
<method name='reload'>
</method>
<method name='set_enabled'>
<arg type='b' name='enabled' direction='in'/>
</method>
<method name='get_status'>
<arg type='s' name='response' direction='out'/>
</method>
<signal name='focus_changed'>
<arg type='s' name='app'/>
</signal>
</interface>
</node>
"""
def __init__(
self,
global_config: GlobalConfig,
backend: FocusBackend,
daemon: DaemonProxy,
debounce_ms: int = DEFAULT_DEBOUNCE_MS,
) -> None:
self.global_config = global_config
self.backend = backend
self.daemon = daemon
self._debounce_ms = debounce_ms
# The signal attribute that dasbus connects to the bus.
self.focus_changed = Signal()
self._bindings: List[AppBinding] = []
self._enabled = False
# group_key -> preset currently injected because of a binding
self._app_controlled: Dict[str, str] = {}
self._current_event: Optional[FocusEvent] = None
self._pending_event: Optional[FocusEvent] = None
self._debounce_id: Optional[int] = None
self._load_config()
# -- lifecycle ---------------------------------------------------------
def _load_config(self) -> None:
self.global_config.load_config()
self._enabled = self.global_config.get_app_binding_enabled()
self._bindings = self.global_config.get_app_bindings()
logger.info(
"Loaded %d app binding(s), enabled=%s",
len(self._bindings),
self._enabled,
)
def publish(self) -> None:
"""Make the session-bus interface available to the GUI."""
try:
SESSION_BUS.publish_object(FOCUS.object_path, self)
SESSION_BUS.register_service(FOCUS.service_name)
except ConnectionError as error:
logger.error("Is the focus-service already running? (%s)", str(error))
raise RuntimeError("Failed to publish focus-service on D-Bus") from error
def start(self) -> None:
"""Publish the interface and start watching for focus changes."""
self.publish()
self.backend.start(self._on_focus)
self._current_event = self.backend.get_current()
if self._current_event is not None and self._enabled:
self._reconcile(self._current_event)
logger.info("Focus-service started with backend '%s'", self.backend.name)
def run(self) -> None:
"""Start the service and block on the GLib main loop."""
self.start()
loop = EventLoop()
loop.run()
def stop(self) -> None:
"""Stop the backend and any injections this service controls."""
if self._debounce_id is not None:
GLib.source_remove(self._debounce_id)
self._debounce_id = None
self.backend.stop()
for group_key in list(self._app_controlled):
self.daemon.stop_injecting(group_key)
self._app_controlled.clear()
# -- focus handling ----------------------------------------------------
def _on_focus(self, event: FocusEvent) -> None:
"""Called by the backend on every focus change (debounced here)."""
if not event.app_id:
# Transient focus (menus, popups, no focused window). Ignore it so
# we don't tear down injections during alt-tab.
logger.debug("Ignoring transient focus event: %s", event)
return
self._pending_event = event
if self._debounce_id is not None:
GLib.source_remove(self._debounce_id)
self._debounce_id = GLib.timeout_add(self._debounce_ms, self._flush)
def _flush(self) -> bool:
self._debounce_id = None
event = self._pending_event
if event is None:
return False
changed = event != self._current_event
self._current_event = event
if changed:
self._emit_focus_changed(event)
if self._enabled:
self._reconcile(event)
return False # GLib: do not repeat
def _compute_desired(self, event: FocusEvent) -> Dict[str, str]:
"""Map of group_key -> preset that should be injected for this event."""
desired: Dict[str, str] = {}
for binding in self._bindings:
if binding.matches(event):
for bound in binding.presets:
desired[bound.group_key] = bound.preset
return desired
def _reconcile(self, event: FocusEvent) -> None:
desired = self._compute_desired(event)
# Stop app-controlled injections that are no longer wanted. Manually
# applied presets are not in _app_controlled, so they are never stopped
# here.
for group_key in list(self._app_controlled):
if group_key not in desired:
logger.info("Focus reconcile: stopping '%s'", group_key)
self.daemon.stop_injecting(group_key)
del self._app_controlled[group_key]
# Start or replace the wanted injections.
for group_key, preset in desired.items():
if self._app_controlled.get(group_key) == preset:
continue
logger.info(
"Focus reconcile: injecting '%s' on '%s' for app '%s'",
preset,
group_key,
event.app_id,
)
if self.daemon.start_injecting(group_key, preset):
self._app_controlled[group_key] = preset
else:
logger.error(
"Failed to start injecting '%s' on '%s'", preset, group_key
)
def _emit_focus_changed(self, event: FocusEvent) -> None:
self.focus_changed(self._event_to_json(event))
@staticmethod
def _event_to_json(event: Optional[FocusEvent]) -> str:
if event is None:
return json.dumps({"app_id": "", "title": ""})
return json.dumps({"app_id": event.app_id, "title": event.title})
# -- D-Bus interface ---------------------------------------------------
def get_focused_app(self) -> str:
"""Return json {app_id, title} for the currently focused window."""
return self._event_to_json(self._current_event)
def reload(self) -> None:
"""Re-read the config from disk and reconcile the current focus."""
logger.info("Reloading focus-service config")
self._load_config()
if self._current_event is not None and self._enabled:
self._reconcile(self._current_event)
elif not self._enabled:
self._stop_app_controlled()
def set_enabled(self, enabled: bool) -> None:
"""Toggle focus-driven binding at runtime (does not write the config)."""
logger.info("Setting focus-binding enabled=%s", enabled)
self._enabled = bool(enabled)
if self._enabled:
if self._current_event is not None:
self._reconcile(self._current_event)
else:
self._stop_app_controlled()
def get_status(self) -> str:
"""Return json describing the service state for the GUI."""
return json.dumps(
{
"backend": self.backend.name,
"enabled": self._enabled,
"focused": {
"app_id": self._current_event.app_id if self._current_event else "",
"title": self._current_event.title if self._current_event else "",
},
"app_controlled": dict(self._app_controlled),
}
)
def _stop_app_controlled(self) -> None:
for group_key in list(self._app_controlled):
self.daemon.stop_injecting(group_key)
del self._app_controlled[group_key]

View File

@ -0,0 +1,83 @@
# -*- 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/>.
"""Abstractions to detect the currently focused window across compositors.
Focus detection is implemented as a registry of backends. To add support for a
new compositor, implement a new ``FocusBackend`` and append its class to
``focus_backend_classes`` (ordered by priority). ``select_backend`` returns the
first backend that reports itself as available, so the most specific backend
for the current session wins.
"""
from __future__ import annotations
from typing import List, Type
from inputremapper.focus.backends.hyprland import HyprlandFocusBackend
from inputremapper.focus.backends.null import NullBackend
from inputremapper.focus.backends.sway import SwayFocusBackend
from inputremapper.focus.backends.wlr_foreign_toplevel import (
WlrForeignToplevelBackend,
)
from inputremapper.focus.backends.xorg import XorgFocusBackend
from inputremapper.focus.focus_backend import FocusBackend, FocusEvent
from inputremapper.logging.logger import logger
# Re-exported for convenience / backwards compatible imports.
__all__ = [
"FocusEvent",
"FocusBackend",
"focus_backend_classes",
"select_backend",
]
# Ordered by priority. The first one that reports is_available() wins.
# SWAYSOCK -> HYPRLAND_INSTANCE_SIGNATURE -> wlr-foreign-toplevel -> DISPLAY -> Null
focus_backend_classes: List[Type[FocusBackend]] = [
SwayFocusBackend,
HyprlandFocusBackend,
WlrForeignToplevelBackend,
XorgFocusBackend,
NullBackend,
]
def select_backend() -> FocusBackend:
"""Instantiate the first available focus backend by priority."""
for backend_class in focus_backend_classes:
try:
available = backend_class.is_available()
except Exception as error: # pragma: no cover - defensive
logger.debug(
"Backend %s failed its availability check: %s",
backend_class.__name__,
error,
)
available = False
if available:
logger.info("Using focus backend %s", backend_class.name)
return backend_class()
# NullBackend.is_available() always returns True, so this is unreachable
# as long as it stays in the registry.
logger.warning("No focus backend available, falling back to NullBackend")
return NullBackend()