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:
100
inputremapper/bin/input_remapper_focus_service.py
Normal file
100
inputremapper/bin/input_remapper_focus_service.py
Normal file
@ -0,0 +1,100 @@
|
||||
# -*- 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/>.
|
||||
|
||||
"""Starts the user-level focus-service for focus-driven preset binding."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from argparse import ArgumentParser
|
||||
from typing import Optional
|
||||
|
||||
from inputremapper.configs.global_config import GlobalConfig
|
||||
from inputremapper.daemon import Daemon, DaemonProxy
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
|
||||
class InputRemapperFocusServiceBin:
|
||||
@staticmethod
|
||||
def _connect_daemon(retries: int = 10, delay: float = 1.0) -> Optional[DaemonProxy]:
|
||||
"""Connect to the root daemon over the system bus, without pkexec.
|
||||
|
||||
The focus-service runs as a background user service and must never
|
||||
trigger a polkit prompt, so it connects with fallback=False and simply
|
||||
retries until the daemon (started separately, e.g. via its systemd
|
||||
unit) becomes available.
|
||||
"""
|
||||
for attempt in range(retries):
|
||||
daemon = Daemon.connect(fallback=False)
|
||||
if daemon is not None:
|
||||
return daemon
|
||||
logger.info(
|
||||
"Daemon not available yet (attempt %d/%d), retrying in %.1fs",
|
||||
attempt + 1,
|
||||
retries,
|
||||
delay,
|
||||
)
|
||||
time.sleep(delay)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def main() -> None:
|
||||
parser = ArgumentParser()
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--debug",
|
||||
action="store_true",
|
||||
dest="debug",
|
||||
help="Displays additional debug information",
|
||||
default=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--hide-info",
|
||||
action="store_true",
|
||||
dest="hide_info",
|
||||
help="Don't display version information",
|
||||
default=False,
|
||||
)
|
||||
|
||||
options = parser.parse_args(sys.argv[1:])
|
||||
|
||||
logger.update_verbosity(options.debug)
|
||||
|
||||
if not options.hide_info:
|
||||
logger.log_info("input-remapper-focus-service")
|
||||
|
||||
if os.getuid() == 0:
|
||||
logger.warning(
|
||||
"The focus-service is meant to run as the logged-in user, not root"
|
||||
)
|
||||
|
||||
# Imported here so log verbosity is configured first.
|
||||
from inputremapper.focus.focus_service import FocusService
|
||||
from inputremapper.focus.focus_watcher import select_backend
|
||||
|
||||
global_config = GlobalConfig()
|
||||
backend = select_backend()
|
||||
|
||||
daemon = InputRemapperFocusServiceBin._connect_daemon()
|
||||
if daemon is None:
|
||||
logger.error("Could not connect to the input-remapper daemon, exiting")
|
||||
sys.exit(1)
|
||||
|
||||
focus_service = FocusService(global_config, backend, daemon)
|
||||
focus_service.run()
|
||||
@ -87,6 +87,12 @@ class InputRemapperGtkBin:
|
||||
message_broker = MessageBroker()
|
||||
|
||||
global_config = GlobalConfig()
|
||||
global_config.load_config()
|
||||
|
||||
# Start the user-level focus-service if focus-driven application binding is
|
||||
# enabled, mirroring how the reader-service is launched below.
|
||||
if global_config.get_app_binding_enabled():
|
||||
InputRemapperGtkBin.start_focus_service()
|
||||
|
||||
# Create the ReaderClient before we start the reader-service, otherwise the
|
||||
# privileged service creates and owns those pipes, and then they cannot be accessed
|
||||
@ -143,6 +149,14 @@ class InputRemapperGtkBin:
|
||||
logger.error(e)
|
||||
sys.exit(11)
|
||||
|
||||
@staticmethod
|
||||
def start_focus_service():
|
||||
from inputremapper.gui.focus_service_client import (
|
||||
ensure_focus_service_running,
|
||||
)
|
||||
|
||||
ensure_focus_service_running()
|
||||
|
||||
@staticmethod
|
||||
def stop(daemon, controller):
|
||||
if isinstance(daemon, Daemon):
|
||||
|
||||
108
inputremapper/configs/app_binding.py
Normal file
108
inputremapper/configs/app_binding.py
Normal file
@ -0,0 +1,108 @@
|
||||
# -*- 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/>.
|
||||
|
||||
"""Models describing which presets should be applied for which application.
|
||||
|
||||
These models are persisted as part of the global config (config.json) and are
|
||||
consumed by the input-remapper-focus-service to decide which presets to inject
|
||||
depending on the currently focused window.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import re
|
||||
from typing import List, TYPE_CHECKING
|
||||
|
||||
try:
|
||||
from pydantic.v1 import BaseModel, Field, root_validator, validator
|
||||
except ImportError:
|
||||
from pydantic import ( # type: ignore[assignment, no-redef]
|
||||
BaseModel,
|
||||
Field,
|
||||
root_validator,
|
||||
validator,
|
||||
)
|
||||
|
||||
from inputremapper.configs.validation_errors import (
|
||||
EmptyAppIdError,
|
||||
InvalidTitleRegexError,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from inputremapper.focus.focus_watcher import FocusEvent
|
||||
|
||||
|
||||
class MatchType(str, enum.Enum):
|
||||
"""How an AppBinding decides whether it applies to a focused window."""
|
||||
|
||||
wm_class = "wm_class"
|
||||
title_regex = "title_regex"
|
||||
|
||||
|
||||
def normalize_app_id(app_id: str) -> str:
|
||||
"""Normalize a window identifier for case-insensitive comparison."""
|
||||
return app_id.strip().lower()
|
||||
|
||||
|
||||
class BoundPreset(BaseModel):
|
||||
"""A (device, preset) pair that should be injected for an application."""
|
||||
|
||||
group_key: str
|
||||
preset: str
|
||||
|
||||
@validator("group_key", "preset")
|
||||
def _not_empty(cls, value: str) -> str: # noqa: N805
|
||||
if not value or not value.strip():
|
||||
raise ValueError("group_key and preset must not be empty")
|
||||
return value
|
||||
|
||||
|
||||
class AppBinding(BaseModel):
|
||||
"""Binds a focused application to a list of presets to inject."""
|
||||
|
||||
app_id: str
|
||||
match: MatchType = MatchType.wm_class
|
||||
presets: List[BoundPreset] = Field(default_factory=list)
|
||||
|
||||
@validator("app_id")
|
||||
def _app_id_not_empty(cls, value: str) -> str: # noqa: N805
|
||||
if not value or not value.strip():
|
||||
raise EmptyAppIdError()
|
||||
return value
|
||||
|
||||
@root_validator(skip_on_failure=True)
|
||||
def _valid_regex(cls, values: dict) -> dict: # noqa: N805
|
||||
if values.get("match") == MatchType.title_regex:
|
||||
app_id = values.get("app_id", "")
|
||||
try:
|
||||
re.compile(app_id)
|
||||
except re.error as error:
|
||||
raise InvalidTitleRegexError(app_id, str(error))
|
||||
return values
|
||||
|
||||
def matches(self, event: "FocusEvent") -> bool:
|
||||
"""Whether this binding applies to the given focus event."""
|
||||
if self.match == MatchType.title_regex:
|
||||
try:
|
||||
return re.search(self.app_id, event.title) is not None
|
||||
except re.error:
|
||||
return False
|
||||
|
||||
return normalize_app_id(self.app_id) == normalize_app_id(event.app_id)
|
||||
@ -23,8 +23,9 @@ from __future__ import annotations
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from inputremapper.configs.app_binding import AppBinding
|
||||
from inputremapper.configs.paths import PathUtils
|
||||
from inputremapper.logging.logger import logger, VERSION
|
||||
from inputremapper.user import UserUtils
|
||||
@ -37,6 +38,8 @@ NONE = "none"
|
||||
INITIAL_CONFIG = {
|
||||
"version": VERSION,
|
||||
"autoload": {},
|
||||
"app_binding_enabled": False,
|
||||
"app_bindings": [],
|
||||
}
|
||||
|
||||
|
||||
@ -87,6 +90,32 @@ class GlobalConfig:
|
||||
|
||||
return self._config.get("autoload", {}).get(group_key) == preset
|
||||
|
||||
def get_app_binding_enabled(self) -> bool:
|
||||
"""Whether focus-driven preset binding is enabled."""
|
||||
return bool(self._config.get("app_binding_enabled", False))
|
||||
|
||||
def set_app_binding_enabled(self, enabled: bool) -> None:
|
||||
"""Enable or disable focus-driven preset binding."""
|
||||
self._config["app_binding_enabled"] = bool(enabled)
|
||||
self._save_config()
|
||||
|
||||
def get_app_bindings(self) -> List[AppBinding]:
|
||||
"""Get the configured application bindings as model objects."""
|
||||
bindings = []
|
||||
for raw in self._config.get("app_bindings", []):
|
||||
try:
|
||||
bindings.append(AppBinding(**raw))
|
||||
except (TypeError, ValueError) as error:
|
||||
logger.error("Ignoring invalid app_binding %s: %s", raw, str(error))
|
||||
return bindings
|
||||
|
||||
def set_app_bindings(self, bindings: List[AppBinding]) -> None:
|
||||
"""Persist the given application bindings."""
|
||||
self._config["app_bindings"] = [
|
||||
json.loads(binding.json()) for binding in bindings
|
||||
]
|
||||
self._save_config()
|
||||
|
||||
def load_config(self, path: Optional[str] = None):
|
||||
"""Load the config from the file system.
|
||||
Parameters
|
||||
|
||||
@ -98,6 +98,9 @@ class Migrations:
|
||||
if v < version.parse("1.6.0-beta"):
|
||||
self._convert_to_individual_mappings()
|
||||
|
||||
if v < version.parse("2.3.0"):
|
||||
self._add_app_binding_config()
|
||||
|
||||
# add new migrations here
|
||||
|
||||
if v < version.parse(VERSION):
|
||||
@ -212,6 +215,35 @@ class Migrations:
|
||||
logger.info('Updating version in config to "%s"', VERSION)
|
||||
json.dump(config, file, indent=4)
|
||||
|
||||
def _add_app_binding_config(self):
|
||||
"""Add the focus-driven app-binding keys to config.json if missing.
|
||||
|
||||
This is an additive, backwards-compatible change: presets and autoload
|
||||
configuration are left untouched.
|
||||
"""
|
||||
config_file = os.path.join(PathUtils.config_path(), "config.json")
|
||||
if not os.path.exists(config_file):
|
||||
return
|
||||
|
||||
with open(config_file, "r") as file:
|
||||
config = json.load(file)
|
||||
|
||||
changed = False
|
||||
if "app_binding_enabled" not in config:
|
||||
config["app_binding_enabled"] = False
|
||||
changed = True
|
||||
if "app_bindings" not in config:
|
||||
config["app_bindings"] = []
|
||||
changed = True
|
||||
|
||||
if not changed:
|
||||
return
|
||||
|
||||
with open(config_file, "w") as file:
|
||||
logger.info("Adding app-binding defaults to config")
|
||||
json.dump(config, file, indent=4)
|
||||
file.write("\n")
|
||||
|
||||
def _rename_to_input_remapper(self):
|
||||
"""Rename .config/key-mapper to .config/input-remapper."""
|
||||
old_config_path = os.path.join(UserUtils.home, ".config/key-mapper")
|
||||
|
||||
@ -116,6 +116,16 @@ class MissingOutputAxisError(ValueError):
|
||||
)
|
||||
|
||||
|
||||
class EmptyAppIdError(ValueError):
|
||||
def __init__(self):
|
||||
super().__init__("app_id must not be empty")
|
||||
|
||||
|
||||
class InvalidTitleRegexError(ValueError):
|
||||
def __init__(self, pattern: str, reason: str):
|
||||
super().__init__(f'"{pattern}" is not a valid title regex: {reason}')
|
||||
|
||||
|
||||
class MacroError(ValueError):
|
||||
"""Macro syntax errors."""
|
||||
|
||||
|
||||
20
inputremapper/focus/__init__.py
Normal file
20
inputremapper/focus/__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 and focus-driven preset binding (user-level)."""
|
||||
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
|
||||
88
inputremapper/focus/focus_backend.py
Normal file
88
inputremapper/focus/focus_backend.py
Normal 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
|
||||
284
inputremapper/focus/focus_service.py
Normal file
284
inputremapper/focus/focus_service.py
Normal 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]
|
||||
83
inputremapper/focus/focus_watcher.py
Normal file
83
inputremapper/focus/focus_watcher.py
Normal 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()
|
||||
539
inputremapper/gui/components/app_bindings.py
Normal file
539
inputremapper/gui/components/app_bindings.py
Normal file
@ -0,0 +1,539 @@
|
||||
# -*- 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/>.
|
||||
|
||||
|
||||
"""Components for the focus-driven "Application Bindings" page.
|
||||
|
||||
This page lets the user bind presets to applications. When the feature is
|
||||
enabled, the user-level focus-service watches the focused window and applies the
|
||||
bound presets automatically. The whole page is built dynamically because the
|
||||
list of bindings (and the presets within each binding) is fully variable.
|
||||
|
||||
Everything is driven through the MessageBroker: the editor rebuilds itself from
|
||||
``AppBindingsData`` and persists changes through the Controller, which republishes
|
||||
the new state. Self-originated saves are guarded so the editor does not rebuild
|
||||
(and lose focus) while the user is typing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from typing import Callable, List, Optional, Tuple
|
||||
|
||||
from gi.repository import Gtk
|
||||
|
||||
from inputremapper.configs.app_binding import AppBinding, BoundPreset, MatchType
|
||||
from inputremapper.gui.controller import Controller
|
||||
from inputremapper.gui.gettext import _
|
||||
from inputremapper.gui.messages.message_broker import (
|
||||
MessageBroker,
|
||||
MessageType,
|
||||
)
|
||||
from inputremapper.gui.messages.message_data import (
|
||||
AppBindingsData,
|
||||
FocusAppData,
|
||||
GroupsData,
|
||||
)
|
||||
from inputremapper.gui.utils import CTX_MAPPING, HandlerDisabled, debounce
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
# human readable labels for the match types
|
||||
MATCH_TYPE_LABELS = {
|
||||
MatchType.wm_class: _("Window class"),
|
||||
MatchType.title_regex: _("Title (regex)"),
|
||||
}
|
||||
|
||||
|
||||
def _maybe_blocked(widget: Gtk.Widget, handler: Callable, block: bool):
|
||||
"""Block ``handler`` while modifying ``widget`` only when ``block`` is True.
|
||||
|
||||
Used so the initial population of a freshly built combo (before its handlers
|
||||
are connected) does not log spurious HandlerDisabled warnings.
|
||||
"""
|
||||
if block:
|
||||
return HandlerDisabled(widget, handler)
|
||||
return contextlib.nullcontext()
|
||||
|
||||
|
||||
class AppBindingsEditor:
|
||||
"""The whole "Application Bindings" page.
|
||||
|
||||
Owns a global enable switch, a status label (e.g. for unsupported
|
||||
environments) and a dynamic list of binding rows.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_broker: MessageBroker,
|
||||
controller: Controller,
|
||||
enabled_switch: Gtk.Switch,
|
||||
status_label: Gtk.Label,
|
||||
listbox: Gtk.ListBox,
|
||||
add_button: Gtk.Button,
|
||||
):
|
||||
self._message_broker = message_broker
|
||||
self._controller = controller
|
||||
self._enabled_switch = enabled_switch
|
||||
self._status_label = status_label
|
||||
self._listbox = listbox
|
||||
self._add_button = add_button
|
||||
|
||||
# available device groups (group keys), updated from the "groups" message
|
||||
self._groups: Tuple[str, ...] = ()
|
||||
# whether focus detection is usable (service reachable + backend present)
|
||||
self._detection_available = False
|
||||
# the binding row currently waiting for a detected app, if any
|
||||
self._detecting_row: Optional[_BindingRow] = None
|
||||
# guards rebuilds caused by our own saves
|
||||
self._editing = False
|
||||
|
||||
self._rows: List[_BindingRow] = []
|
||||
|
||||
self._enabled_switch.connect("state-set", self._on_enabled_toggled)
|
||||
self._add_button.connect("clicked", self._on_add_binding_clicked)
|
||||
|
||||
self._message_broker.subscribe(MessageType.app_bindings, self._on_app_bindings)
|
||||
self._message_broker.subscribe(MessageType.groups, self._on_groups)
|
||||
self._message_broker.subscribe(MessageType.focused_app, self._on_focused_app)
|
||||
|
||||
# -- message listeners -------------------------------------------------
|
||||
|
||||
def _on_groups(self, data: GroupsData):
|
||||
self._groups = tuple(data.groups.keys())
|
||||
# refresh the device selectors of every existing row
|
||||
for row in self._rows:
|
||||
row.refresh_groups(self._groups)
|
||||
|
||||
def _on_app_bindings(self, data: AppBindingsData):
|
||||
with HandlerDisabled(self._enabled_switch, self._on_enabled_toggled):
|
||||
self._enabled_switch.set_active(data.enabled)
|
||||
|
||||
self._detection_available = data.supported
|
||||
self._update_status_label(data)
|
||||
self._update_detect_sensitivity()
|
||||
|
||||
if self._editing:
|
||||
# this is the echo of our own save; do not rebuild and steal focus
|
||||
return
|
||||
|
||||
self._rebuild(data.bindings)
|
||||
|
||||
def _on_focused_app(self, data: FocusAppData):
|
||||
if self._detecting_row is None:
|
||||
return
|
||||
|
||||
if not data.app_id:
|
||||
# transient focus, keep waiting
|
||||
return
|
||||
|
||||
row = self._detecting_row
|
||||
self._stop_detection()
|
||||
row.set_app_id(data.app_id)
|
||||
title = data.title or data.app_id
|
||||
self._controller.show_status(
|
||||
CTX_MAPPING, _('Detected application "%s"') % title
|
||||
)
|
||||
self.save()
|
||||
|
||||
# -- gtk handlers ------------------------------------------------------
|
||||
|
||||
def _on_enabled_toggled(self, _switch, state: bool):
|
||||
self._controller.set_app_binding_enabled(state)
|
||||
return False # let GTK update the switch visual state
|
||||
|
||||
def _on_add_binding_clicked(self, *_args):
|
||||
row = self._build_row(AppBinding(app_id=_("new application")))
|
||||
self._rows.append(row)
|
||||
self._listbox.insert(row.widget, -1)
|
||||
self._listbox.show_all()
|
||||
self.save()
|
||||
|
||||
# -- detection ---------------------------------------------------------
|
||||
|
||||
def request_detection(self, row: "_BindingRow"):
|
||||
"""Begin (or restart) focus detection for the given binding row."""
|
||||
if self._detecting_row is row:
|
||||
# toggling off
|
||||
self._stop_detection()
|
||||
return
|
||||
|
||||
# only one row can detect at a time
|
||||
if self._detecting_row is not None:
|
||||
self._detecting_row.set_detecting(False)
|
||||
|
||||
self._detecting_row = row
|
||||
row.set_detecting(True)
|
||||
self._controller.start_app_detection()
|
||||
self._controller.show_status(
|
||||
CTX_MAPPING, _("Switch to the application you want to bind…")
|
||||
)
|
||||
|
||||
def _stop_detection(self):
|
||||
if self._detecting_row is not None:
|
||||
self._detecting_row.set_detecting(False)
|
||||
self._detecting_row = None
|
||||
self._controller.stop_app_detection()
|
||||
|
||||
# -- persistence -------------------------------------------------------
|
||||
|
||||
def save(self):
|
||||
"""Collect every binding from the UI and persist it if all are valid."""
|
||||
try:
|
||||
bindings = self.to_model()
|
||||
except ValueError as error:
|
||||
self._controller.show_status(CTX_MAPPING, str(error))
|
||||
return
|
||||
|
||||
self._editing = True
|
||||
try:
|
||||
self._controller.update_app_bindings(bindings)
|
||||
finally:
|
||||
self._editing = False
|
||||
|
||||
def to_model(self) -> List[AppBinding]:
|
||||
"""Return all bindings, or raise if any visible row is invalid."""
|
||||
bindings: List[AppBinding] = []
|
||||
first_error: Optional[ValueError] = None
|
||||
for row in self._rows:
|
||||
try:
|
||||
model = row.to_model()
|
||||
bindings.append(model)
|
||||
row.set_error("")
|
||||
except ValueError as error:
|
||||
row.set_error(str(error))
|
||||
if first_error is None:
|
||||
first_error = error
|
||||
|
||||
if first_error is not None:
|
||||
raise first_error
|
||||
|
||||
return bindings
|
||||
|
||||
# -- helpers -----------------------------------------------------------
|
||||
|
||||
def get_presets_for_group(self, group_key: str) -> Tuple[str, ...]:
|
||||
"""Proxy used by binding rows to populate their preset selectors."""
|
||||
return self._controller.get_presets_for_group(group_key)
|
||||
|
||||
@property
|
||||
def groups(self) -> Tuple[str, ...]:
|
||||
return self._groups
|
||||
|
||||
@property
|
||||
def detection_available(self) -> bool:
|
||||
return self._detection_available
|
||||
|
||||
def _rebuild(self, bindings: Tuple[AppBinding, ...]):
|
||||
self._stop_detection()
|
||||
for child in self._listbox.get_children():
|
||||
self._listbox.remove(child)
|
||||
self._rows = []
|
||||
|
||||
for binding in bindings:
|
||||
row = self._build_row(binding)
|
||||
self._rows.append(row)
|
||||
self._listbox.insert(row.widget, -1)
|
||||
|
||||
self._listbox.show_all()
|
||||
|
||||
def _build_row(self, binding: AppBinding) -> "_BindingRow":
|
||||
return _BindingRow(self, binding)
|
||||
|
||||
def remove_row(self, row: "_BindingRow"):
|
||||
"""Remove a binding row from the list and persist."""
|
||||
if row is self._detecting_row:
|
||||
self._stop_detection()
|
||||
if row in self._rows:
|
||||
self._rows.remove(row)
|
||||
self._listbox.remove(row.widget)
|
||||
self.save()
|
||||
|
||||
def _update_detect_sensitivity(self):
|
||||
for row in self._rows:
|
||||
row.set_detect_sensitive(self._detection_available)
|
||||
|
||||
def _update_status_label(self, data: AppBindingsData):
|
||||
if not data.enabled:
|
||||
self._status_label.set_text(
|
||||
_("Enable application bindings to apply presets based on focus.")
|
||||
)
|
||||
return
|
||||
|
||||
if not data.reachable:
|
||||
self._status_label.set_text(_("Starting the focus-detection service…"))
|
||||
return
|
||||
|
||||
if not data.supported:
|
||||
self._status_label.set_text(
|
||||
_(
|
||||
"Focus detection is not supported on your environment "
|
||||
"(e.g. GNOME on Wayland)."
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
self._status_label.set_text(
|
||||
_("Focus detection is active (backend: %s).") % data.backend
|
||||
)
|
||||
|
||||
|
||||
class _PresetRow:
|
||||
"""A single (device, preset) selector inside a binding."""
|
||||
|
||||
def __init__(self, binding_row: "_BindingRow", bound: Optional[BoundPreset]):
|
||||
self._binding_row = binding_row
|
||||
self._editor = binding_row.editor
|
||||
|
||||
self.widget = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
self.widget.set_margin_start(12)
|
||||
|
||||
self._device_combo = Gtk.ComboBoxText()
|
||||
self._device_combo.set_hexpand(True)
|
||||
self._preset_combo = Gtk.ComboBoxText()
|
||||
self._preset_combo.set_hexpand(True)
|
||||
remove_button = Gtk.Button.new_from_icon_name(
|
||||
"edit-delete", Gtk.IconSize.BUTTON
|
||||
)
|
||||
remove_button.set_tooltip_text(_("Remove this preset"))
|
||||
|
||||
self.widget.pack_start(self._device_combo, True, True, 0)
|
||||
self.widget.pack_start(self._preset_combo, True, True, 0)
|
||||
self.widget.pack_start(remove_button, False, False, 0)
|
||||
|
||||
self._selected_group = bound.group_key if bound else ""
|
||||
self._selected_preset = bound.preset if bound else ""
|
||||
|
||||
# populate before connecting, so the initial selection does not trigger a save
|
||||
self._populate_devices(block=False)
|
||||
self._populate_presets(block=False)
|
||||
|
||||
self._device_combo.connect("changed", self._on_device_changed)
|
||||
self._preset_combo.connect("changed", self._on_preset_changed)
|
||||
remove_button.connect("clicked", self._on_remove_clicked)
|
||||
|
||||
def _populate_devices(self, block: bool = True):
|
||||
with _maybe_blocked(self._device_combo, self._on_device_changed, block):
|
||||
self._device_combo.remove_all()
|
||||
group_keys = list(self._editor.groups)
|
||||
# keep a stored group even if the device is not currently plugged in
|
||||
if self._selected_group and self._selected_group not in group_keys:
|
||||
group_keys.append(self._selected_group)
|
||||
for group_key in group_keys:
|
||||
self._device_combo.append(group_key, group_key)
|
||||
if self._selected_group:
|
||||
self._device_combo.set_active_id(self._selected_group)
|
||||
|
||||
def _populate_presets(self, block: bool = True):
|
||||
with _maybe_blocked(self._preset_combo, self._on_preset_changed, block):
|
||||
self._preset_combo.remove_all()
|
||||
presets: Tuple[str, ...] = ()
|
||||
if self._selected_group:
|
||||
presets = self._editor.get_presets_for_group(self._selected_group)
|
||||
preset_names = list(presets)
|
||||
if self._selected_preset and self._selected_preset not in preset_names:
|
||||
preset_names.append(self._selected_preset)
|
||||
for preset_name in preset_names:
|
||||
self._preset_combo.append(preset_name, preset_name)
|
||||
if self._selected_preset:
|
||||
self._preset_combo.set_active_id(self._selected_preset)
|
||||
|
||||
def refresh_groups(self):
|
||||
self._populate_devices()
|
||||
|
||||
def _on_device_changed(self, *_):
|
||||
self._selected_group = self._device_combo.get_active_id() or ""
|
||||
# changing the device invalidates the chosen preset
|
||||
self._selected_preset = ""
|
||||
self._populate_presets()
|
||||
self._binding_row.editor.save()
|
||||
|
||||
def _on_preset_changed(self, *_):
|
||||
self._selected_preset = self._preset_combo.get_active_id() or ""
|
||||
self._binding_row.editor.save()
|
||||
|
||||
def _on_remove_clicked(self, *_):
|
||||
self._binding_row.remove_preset(self)
|
||||
|
||||
def to_model(self) -> BoundPreset:
|
||||
if not self._selected_group or not self._selected_preset:
|
||||
raise ValueError(_("Select both a device and a preset."))
|
||||
|
||||
return BoundPreset(group_key=self._selected_group, preset=self._selected_preset)
|
||||
|
||||
|
||||
class _BindingRow:
|
||||
"""A single application binding (app_id + match type + bound presets)."""
|
||||
|
||||
def __init__(self, editor: AppBindingsEditor, binding: AppBinding):
|
||||
self.editor = editor
|
||||
self._preset_rows: List[_PresetRow] = []
|
||||
|
||||
self.widget = Gtk.ListBoxRow()
|
||||
self.widget.set_selectable(False)
|
||||
self.widget.set_activatable(False)
|
||||
|
||||
frame = Gtk.Frame()
|
||||
frame.set_margin_start(12)
|
||||
frame.set_margin_end(12)
|
||||
frame.set_margin_top(6)
|
||||
frame.set_margin_bottom(6)
|
||||
self.widget.add(frame)
|
||||
|
||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||
outer.set_margin_start(12)
|
||||
outer.set_margin_end(12)
|
||||
outer.set_margin_top(12)
|
||||
outer.set_margin_bottom(12)
|
||||
frame.add(outer)
|
||||
|
||||
# header row: app_id entry + match selector + detect + remove
|
||||
header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
outer.pack_start(header, False, False, 0)
|
||||
|
||||
self._app_id_entry = Gtk.Entry()
|
||||
self._app_id_entry.set_hexpand(True)
|
||||
self._app_id_entry.set_placeholder_text(
|
||||
_("application identifier or title pattern")
|
||||
)
|
||||
self._app_id_entry.set_text(binding.app_id)
|
||||
header.pack_start(self._app_id_entry, True, True, 0)
|
||||
|
||||
self._match_combo = Gtk.ComboBoxText()
|
||||
for match_type in MatchType:
|
||||
self._match_combo.append(match_type.value, MATCH_TYPE_LABELS[match_type])
|
||||
self._match_combo.set_active_id(binding.match.value)
|
||||
header.pack_start(self._match_combo, False, False, 0)
|
||||
|
||||
self._detect_button = Gtk.Button.new_with_label(_("Detect"))
|
||||
self._detect_button.set_tooltip_text(
|
||||
_("Detect the focused application automatically")
|
||||
)
|
||||
header.pack_start(self._detect_button, False, False, 0)
|
||||
|
||||
remove_button = Gtk.Button.new_from_icon_name(
|
||||
"edit-delete", Gtk.IconSize.BUTTON
|
||||
)
|
||||
remove_button.set_tooltip_text(_("Remove this binding"))
|
||||
header.pack_start(remove_button, False, False, 0)
|
||||
|
||||
# presets container
|
||||
self._presets_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||
outer.pack_start(self._presets_box, False, False, 0)
|
||||
|
||||
add_preset_button = Gtk.Button.new_with_label(_("Add preset"))
|
||||
add_preset_button.set_halign(Gtk.Align.START)
|
||||
outer.pack_start(add_preset_button, False, False, 0)
|
||||
|
||||
self._error_label = Gtk.Label()
|
||||
self._error_label.set_halign(Gtk.Align.START)
|
||||
self._error_label.set_line_wrap(True)
|
||||
self._error_label.set_no_show_all(True)
|
||||
outer.pack_start(self._error_label, False, False, 0)
|
||||
|
||||
for bound in binding.presets:
|
||||
self._add_preset_row(bound)
|
||||
|
||||
# signals
|
||||
self._app_id_entry.connect("changed", self._on_app_id_changed)
|
||||
self._match_combo.connect("changed", self._on_match_changed)
|
||||
self._detect_button.connect("clicked", self._on_detect_clicked)
|
||||
remove_button.connect("clicked", self._on_remove_clicked)
|
||||
add_preset_button.connect("clicked", self._on_add_preset_clicked)
|
||||
|
||||
self.set_detect_sensitive(editor.detection_available)
|
||||
|
||||
# -- preset rows -------------------------------------------------------
|
||||
|
||||
def _add_preset_row(self, bound: Optional[BoundPreset]):
|
||||
preset_row = _PresetRow(self, bound)
|
||||
self._preset_rows.append(preset_row)
|
||||
self._presets_box.pack_start(preset_row.widget, False, False, 0)
|
||||
return preset_row
|
||||
|
||||
def remove_preset(self, preset_row: _PresetRow):
|
||||
if preset_row in self._preset_rows:
|
||||
self._preset_rows.remove(preset_row)
|
||||
self._presets_box.remove(preset_row.widget)
|
||||
self.editor.save()
|
||||
|
||||
def refresh_groups(self, _groups: Tuple[str, ...]):
|
||||
for preset_row in self._preset_rows:
|
||||
preset_row.refresh_groups()
|
||||
|
||||
# -- detection ---------------------------------------------------------
|
||||
|
||||
def set_detecting(self, detecting: bool):
|
||||
self._detect_button.set_label(_("Cancel") if detecting else _("Detect"))
|
||||
|
||||
def set_detect_sensitive(self, sensitive: bool):
|
||||
self._detect_button.set_sensitive(sensitive)
|
||||
|
||||
def set_app_id(self, app_id: str):
|
||||
with HandlerDisabled(self._app_id_entry, self._on_app_id_changed):
|
||||
self._app_id_entry.set_text(app_id)
|
||||
|
||||
def set_error(self, error: str):
|
||||
self._error_label.set_text(error)
|
||||
if error:
|
||||
self._error_label.show()
|
||||
else:
|
||||
self._error_label.hide()
|
||||
|
||||
# -- gtk handlers ------------------------------------------------------
|
||||
|
||||
@debounce(500)
|
||||
def _on_app_id_changed(self, *_):
|
||||
self.editor.save()
|
||||
|
||||
def _on_match_changed(self, *_):
|
||||
self.editor.save()
|
||||
|
||||
def _on_detect_clicked(self, *_):
|
||||
self.editor.request_detection(self)
|
||||
|
||||
def _on_remove_clicked(self, *_):
|
||||
self.editor.remove_row(self)
|
||||
|
||||
def _on_add_preset_clicked(self, *_):
|
||||
self._add_preset_row(None)
|
||||
self._presets_box.show_all()
|
||||
self.editor.save()
|
||||
|
||||
# -- model -------------------------------------------------------------
|
||||
|
||||
def to_model(self) -> AppBinding:
|
||||
app_id = self._app_id_entry.get_text().strip()
|
||||
if not app_id:
|
||||
raise ValueError(_("Application identifier must not be empty."))
|
||||
|
||||
match_value = self._match_combo.get_active_id() or MatchType.wm_class.value
|
||||
presets = []
|
||||
for preset_row in self._preset_rows:
|
||||
presets.append(preset_row.to_model())
|
||||
|
||||
try:
|
||||
return AppBinding(
|
||||
app_id=app_id,
|
||||
match=MatchType(match_value),
|
||||
presets=presets,
|
||||
)
|
||||
except ValueError as error:
|
||||
# e.g. an invalid title regex; keep the row so the user can fix it
|
||||
logger.debug("Invalid app binding: %s", error)
|
||||
raise ValueError(str(error)) from error
|
||||
@ -35,7 +35,7 @@ from typing import (
|
||||
)
|
||||
|
||||
from evdev.ecodes import EV_KEY, EV_REL, EV_ABS
|
||||
from gi.repository import Gtk
|
||||
from gi.repository import Gtk, GLib
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.mapping import (
|
||||
@ -53,9 +53,11 @@ from inputremapper.configs.validation_errors import (
|
||||
WrongMappingTypeForKeyError,
|
||||
OutputSymbolVariantError,
|
||||
)
|
||||
from inputremapper.configs.app_binding import AppBinding
|
||||
from inputremapper.exceptions import DataManagementError
|
||||
from inputremapper.gui.components.output_type_names import OutputTypeNames
|
||||
from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME
|
||||
from inputremapper.gui.focus_service_client import ensure_focus_service_running
|
||||
from inputremapper.gui.gettext import _
|
||||
from inputremapper.gui.messages.message_broker import (
|
||||
MessageBroker,
|
||||
@ -120,6 +122,7 @@ class Controller:
|
||||
# initial groups
|
||||
self.data_manager.publish_groups()
|
||||
self.data_manager.publish_uinputs()
|
||||
self.data_manager.publish_app_bindings()
|
||||
|
||||
def _on_groups_changed(self, _):
|
||||
"""Load the newest group as soon as everyone got notified
|
||||
@ -603,6 +606,47 @@ class Controller:
|
||||
self.data_manager.set_autoload(autoload)
|
||||
self.data_manager.refresh_service_config_path()
|
||||
|
||||
def load_app_bindings(self):
|
||||
"""(Re)publish the current application bindings and service state."""
|
||||
self.data_manager.publish_app_bindings()
|
||||
|
||||
def set_app_binding_enabled(self, enabled: bool):
|
||||
"""Enable or disable focus-driven preset binding."""
|
||||
if enabled:
|
||||
# make sure the user-level focus-service is running, mirroring how
|
||||
# the GUI launches the reader-service.
|
||||
ensure_focus_service_running()
|
||||
|
||||
self.data_manager.set_app_binding_enabled(enabled)
|
||||
|
||||
if enabled:
|
||||
# the service might need a moment to come up and detect its backend;
|
||||
# refresh the status once it had time to settle.
|
||||
GLib.timeout_add(1500, self._refresh_app_bindings_once)
|
||||
|
||||
def _refresh_app_bindings_once(self) -> bool:
|
||||
self.data_manager.publish_app_bindings()
|
||||
return False # GLib: do not repeat
|
||||
|
||||
def update_app_bindings(self, bindings: List[AppBinding]):
|
||||
"""Persist the given application bindings."""
|
||||
try:
|
||||
self.data_manager.set_app_bindings(bindings)
|
||||
except PermissionError as e:
|
||||
self.show_status(CTX_ERROR, _("Permission denied!"), str(e))
|
||||
|
||||
def get_presets_for_group(self, group_key: str) -> Tuple[str, ...]:
|
||||
"""List the presets available for an arbitrary device group."""
|
||||
return self.data_manager.get_presets_for_group(group_key)
|
||||
|
||||
def start_app_detection(self):
|
||||
"""Start listening for focus changes to auto-fill an app_id."""
|
||||
self.data_manager.start_app_detection()
|
||||
|
||||
def stop_app_detection(self):
|
||||
"""Stop listening for focus changes."""
|
||||
self.data_manager.stop_app_detection()
|
||||
|
||||
def save(self):
|
||||
"""Save all data to the disc."""
|
||||
try:
|
||||
|
||||
@ -21,10 +21,11 @@ import glob
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Optional, List, Tuple, Set
|
||||
from typing import Optional, List, Tuple, Set, Dict
|
||||
|
||||
from gi.repository import GLib
|
||||
|
||||
from inputremapper.configs.app_binding import AppBinding
|
||||
from inputremapper.configs.global_config import GlobalConfig
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.mapping import UIMapping, MappingData
|
||||
@ -43,7 +44,10 @@ from inputremapper.gui.messages.message_data import (
|
||||
GroupData,
|
||||
PresetData,
|
||||
CombinationUpdate,
|
||||
AppBindingsData,
|
||||
FocusAppData,
|
||||
)
|
||||
from inputremapper.gui.focus_service_client import FocusServiceClient
|
||||
from inputremapper.gui.reader_client import ReaderClient
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.injector import (
|
||||
@ -74,12 +78,14 @@ class DataManager:
|
||||
daemon: DaemonProxy,
|
||||
uinputs: GlobalUInputs,
|
||||
keyboard_layout: KeyboardLayout,
|
||||
focus_service: Optional[FocusServiceClient] = None,
|
||||
):
|
||||
self.message_broker = message_broker
|
||||
self._reader_client = reader_client
|
||||
self._daemon = daemon
|
||||
self._uinputs = uinputs
|
||||
self._keyboard_layout = keyboard_layout
|
||||
self._focus_service = focus_service or FocusServiceClient()
|
||||
uinputs.prepare_all()
|
||||
|
||||
self._config = config
|
||||
@ -156,6 +162,88 @@ class DataManager:
|
||||
|
||||
self.message_broker.publish(InjectorStateMessage(self.get_state()))
|
||||
|
||||
def publish_app_bindings(self):
|
||||
"""Publish the "app_bindings" message with the current service state."""
|
||||
status = self._focus_service.get_status()
|
||||
reachable = status is not None
|
||||
backend = status.get("backend") if status else None
|
||||
self.message_broker.publish(
|
||||
AppBindingsData(
|
||||
enabled=self._config.get_app_binding_enabled(),
|
||||
bindings=tuple(self._config.get_app_bindings()),
|
||||
backend=backend,
|
||||
supported=reachable and backend is not None,
|
||||
reachable=reachable,
|
||||
)
|
||||
)
|
||||
|
||||
def get_app_bindings(self) -> List[AppBinding]:
|
||||
"""Get the configured application bindings."""
|
||||
return self._config.get_app_bindings()
|
||||
|
||||
def set_app_bindings(self, bindings: List[AppBinding]):
|
||||
"""Persist the application bindings and reconcile the focus-service.
|
||||
|
||||
Will send the "app_bindings" message on the MessageBroker.
|
||||
"""
|
||||
self._config.set_app_bindings(bindings)
|
||||
self._focus_service.reload()
|
||||
self.publish_app_bindings()
|
||||
|
||||
def get_app_binding_enabled(self) -> bool:
|
||||
"""Whether focus-driven preset binding is enabled."""
|
||||
return self._config.get_app_binding_enabled()
|
||||
|
||||
def set_app_binding_enabled(self, enabled: bool):
|
||||
"""Enable or disable focus-driven preset binding.
|
||||
|
||||
Writes the config and toggles the running service. Will send the
|
||||
"app_bindings" message on the MessageBroker.
|
||||
"""
|
||||
self._config.set_app_binding_enabled(enabled)
|
||||
self._focus_service.set_enabled(enabled)
|
||||
self.publish_app_bindings()
|
||||
|
||||
def start_app_detection(self):
|
||||
"""Listen for focus changes to auto-fill an app_id.
|
||||
|
||||
Will send "focused_app" messages as the focus changes.
|
||||
"""
|
||||
self._focus_service.connect_focus_changed(self._on_focus_changed)
|
||||
|
||||
def stop_app_detection(self):
|
||||
"""Stop listening for focus changes."""
|
||||
self._focus_service.disconnect_focus_changed()
|
||||
|
||||
def _on_focus_changed(self, app: Dict[str, str]):
|
||||
self.message_broker.publish(
|
||||
FocusAppData(
|
||||
app_id=app.get("app_id", ""),
|
||||
title=app.get("title", ""),
|
||||
)
|
||||
)
|
||||
|
||||
def get_presets_for_group(self, group_key: str) -> Tuple[Name, ...]:
|
||||
"""Get all preset names for an arbitrary group, sorted by age.
|
||||
|
||||
Unlike get_preset_names this does not depend on the active group, so it
|
||||
can be used to populate the application-binding preset selectors.
|
||||
"""
|
||||
group = self._reader_client.groups.find(key=group_key)
|
||||
if not group:
|
||||
return tuple()
|
||||
|
||||
device_folder = PathUtils.get_preset_path(group.name)
|
||||
PathUtils.mkdir(device_folder)
|
||||
|
||||
paths = glob.glob(os.path.join(glob.escape(device_folder), "*.json"))
|
||||
presets = [
|
||||
os.path.splitext(os.path.basename(path))[0]
|
||||
for path in sorted(paths, key=os.path.getmtime)
|
||||
]
|
||||
presets.reverse()
|
||||
return tuple(presets)
|
||||
|
||||
@property
|
||||
def active_group(self) -> Optional[_Group]:
|
||||
"""The currently loaded group."""
|
||||
|
||||
145
inputremapper/gui/focus_service_client.py
Normal file
145
inputremapper/gui/focus_service_client.py
Normal file
@ -0,0 +1,145 @@
|
||||
# -*- 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/>.
|
||||
|
||||
"""GUI-side client for the user-level focus-service (``inputremapper.Focus``).
|
||||
|
||||
The focus-service exposes a small interface on the session bus. This client
|
||||
wraps the dasbus proxy and stays defensive: when the service is not running (the
|
||||
feature might be disabled, or the environment might not support focus detection)
|
||||
every call degrades gracefully instead of raising, so the GUI keeps working.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from dasbus.connection import SessionMessageBus
|
||||
|
||||
from inputremapper.bin.process_utils import ProcessUtils
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
FOCUS_SERVICE_NAME = "inputremapper.Focus"
|
||||
FOCUS_OBJECT_PATH = "/inputremapper/Focus"
|
||||
FOCUS_SERVICE_EXECUTABLE = "input-remapper-focus-service"
|
||||
|
||||
|
||||
def ensure_focus_service_running() -> None:
|
||||
"""Launch the user-level focus-service if it is not already running.
|
||||
|
||||
Mirrors how the GUI starts the reader-service, but the focus-service runs as
|
||||
the logged-in user (no pkexec / no root).
|
||||
"""
|
||||
if ProcessUtils.count_python_processes(FOCUS_SERVICE_EXECUTABLE) >= 1:
|
||||
logger.info("Found an input-remapper-focus-service to already be running")
|
||||
return
|
||||
|
||||
try:
|
||||
# start_new_session detaches the service from the GUI process group so it
|
||||
# keeps running and is not torn down together with the GUI.
|
||||
subprocess.Popen( # pylint: disable=consider-using-with
|
||||
[FOCUS_SERVICE_EXECUTABLE],
|
||||
start_new_session=True,
|
||||
)
|
||||
logger.info("Started the input-remapper-focus-service")
|
||||
except Exception as error: # pylint: disable=broad-except
|
||||
logger.error("Failed to start the focus-service: %s", error)
|
||||
|
||||
|
||||
class FocusServiceClient:
|
||||
"""Thin, defensive wrapper around the focus-service session-bus interface."""
|
||||
|
||||
def __init__(self, bus: Optional[SessionMessageBus] = None) -> None:
|
||||
self._bus = bus or SessionMessageBus()
|
||||
self._proxy: Any = None
|
||||
self._focus_changed_handler: Optional[Callable[[str], None]] = None
|
||||
|
||||
def _get_proxy(self) -> Any:
|
||||
if self._proxy is None:
|
||||
self._proxy = self._bus.get_proxy(FOCUS_SERVICE_NAME, FOCUS_OBJECT_PATH)
|
||||
return self._proxy
|
||||
|
||||
def _reset(self) -> None:
|
||||
"""Drop the cached proxy so the next call reconnects."""
|
||||
self._proxy = None
|
||||
self._focus_changed_handler = None
|
||||
|
||||
def get_status(self) -> Optional[Dict[str, Any]]:
|
||||
"""Return the service status as a dict, or None if it is unreachable."""
|
||||
try:
|
||||
return json.loads(self._get_proxy().get_status())
|
||||
except Exception as error: # pylint: disable=broad-except
|
||||
logger.debug("focus-service get_status failed: %s", error)
|
||||
self._reset()
|
||||
return None
|
||||
|
||||
def get_focused_app(self) -> Dict[str, str]:
|
||||
"""Return the currently focused window as {"app_id", "title"}."""
|
||||
try:
|
||||
return json.loads(self._get_proxy().get_focused_app())
|
||||
except Exception as error: # pylint: disable=broad-except
|
||||
logger.debug("focus-service get_focused_app failed: %s", error)
|
||||
self._reset()
|
||||
return {"app_id": "", "title": ""}
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Ask the service to re-read the config from disk and reconcile."""
|
||||
try:
|
||||
self._get_proxy().reload()
|
||||
except Exception as error: # pylint: disable=broad-except
|
||||
logger.debug("focus-service reload failed: %s", error)
|
||||
self._reset()
|
||||
|
||||
def set_enabled(self, enabled: bool) -> None:
|
||||
"""Toggle focus-driven binding at runtime (does not write the config)."""
|
||||
try:
|
||||
self._get_proxy().set_enabled(enabled)
|
||||
except Exception as error: # pylint: disable=broad-except
|
||||
logger.debug("focus-service set_enabled failed: %s", error)
|
||||
self._reset()
|
||||
|
||||
def connect_focus_changed(self, callback: Callable[[Dict[str, str]], None]) -> bool:
|
||||
"""Subscribe to the focus_changed signal. Returns whether it succeeded."""
|
||||
|
||||
def handler(app_json: str) -> None:
|
||||
try:
|
||||
callback(json.loads(app_json))
|
||||
except (ValueError, TypeError) as error:
|
||||
logger.error("Invalid focus_changed payload %r: %s", app_json, error)
|
||||
|
||||
try:
|
||||
self._get_proxy().focus_changed.connect(handler)
|
||||
self._focus_changed_handler = handler
|
||||
return True
|
||||
except Exception as error: # pylint: disable=broad-except
|
||||
logger.debug("focus-service focus_changed.connect failed: %s", error)
|
||||
self._reset()
|
||||
return False
|
||||
|
||||
def disconnect_focus_changed(self) -> None:
|
||||
"""Unsubscribe from the focus_changed signal."""
|
||||
if self._focus_changed_handler is None:
|
||||
return
|
||||
try:
|
||||
self._get_proxy().focus_changed.disconnect(self._focus_changed_handler)
|
||||
except Exception as error: # pylint: disable=broad-except
|
||||
logger.debug("focus-service focus_changed.disconnect failed: %s", error)
|
||||
finally:
|
||||
self._focus_changed_handler = None
|
||||
@ -21,6 +21,7 @@ import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Tuple, Optional, Callable
|
||||
|
||||
from inputremapper.configs.app_binding import AppBinding
|
||||
from inputremapper.configs.input_config import InputCombination
|
||||
from inputremapper.configs.mapping import MappingData
|
||||
from inputremapper.gui.messages.message_types import (
|
||||
@ -124,3 +125,31 @@ class DoStackSwitch:
|
||||
|
||||
message_type = MessageType.do_stack_switch
|
||||
page_index: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AppBindingsData:
|
||||
"""Message with the current focus-driven application bindings and service state.
|
||||
|
||||
``backend`` is the name of the focus backend reported by the focus-service
|
||||
(e.g. "xorg", "sway", "hyprland", "wlr-foreign-toplevel"). ``supported`` is
|
||||
False when the service is reachable but reports no usable backend (e.g. on
|
||||
GNOME-Wayland). ``reachable`` is False when the focus-service is not running
|
||||
yet, in which case the backend state is simply unknown.
|
||||
"""
|
||||
|
||||
message_type = MessageType.app_bindings
|
||||
enabled: bool
|
||||
bindings: Tuple[AppBinding, ...]
|
||||
backend: Optional[str] = None
|
||||
supported: bool = False
|
||||
reachable: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FocusAppData:
|
||||
"""Message with the latest focused application, emitted during detection."""
|
||||
|
||||
message_type = MessageType.focused_app
|
||||
app_id: str
|
||||
title: str = ""
|
||||
|
||||
@ -55,6 +55,10 @@ class MessageType(Enum):
|
||||
|
||||
do_stack_switch = "do_stack_switch"
|
||||
|
||||
# focus-driven preset binding (application bindings)
|
||||
app_bindings = "app_bindings"
|
||||
focused_app = "focused_app"
|
||||
|
||||
# for unit tests:
|
||||
test1 = "test1"
|
||||
test2 = "test2"
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
|
||||
|
||||
"""User Interface."""
|
||||
|
||||
from typing import Dict, Callable
|
||||
|
||||
from gi.repository import Gtk, GtkSource, Gdk, GObject
|
||||
@ -49,6 +50,7 @@ from inputremapper.gui.components.editor import (
|
||||
RequireActiveMapping,
|
||||
GdkEventRecorder,
|
||||
)
|
||||
from inputremapper.gui.components.app_bindings import AppBindingsEditor
|
||||
from inputremapper.gui.components.main import Stack, StatusBar
|
||||
from inputremapper.gui.components.presets import PresetSelection
|
||||
from inputremapper.gui.controller import Controller
|
||||
@ -203,6 +205,15 @@ class UserInterface:
|
||||
self.get("expo-scale"),
|
||||
)
|
||||
|
||||
AppBindingsEditor(
|
||||
message_broker,
|
||||
controller,
|
||||
self.get("app_binding_enabled_switch"),
|
||||
self.get("app_binding_status_label"),
|
||||
self.get("app_bindings_listbox"),
|
||||
self.get("app_binding_add_button"),
|
||||
)
|
||||
|
||||
GdkEventRecorder(self.window, self.get("gdk-event-recorder-label"))
|
||||
|
||||
RequireActiveMapping(
|
||||
|
||||
@ -24,7 +24,7 @@ These defaults might be overwritten by package maintainers.
|
||||
"""
|
||||
|
||||
COMMIT_HASH = "unknown"
|
||||
VERSION = "2.2.1"
|
||||
VERSION = "2.3.0"
|
||||
# depending on where this file is installed to, make sure to use the proper
|
||||
# prefix path for data.
|
||||
DATA_DIR = "/usr/share/input-remapper"
|
||||
|
||||
Reference in New Issue
Block a user