feat(app-binding): lier les presets aux applications

Ajoute la configuration des associations application/preset, le service de suivi du focus, l'intégration GUI et les fichiers d'installation nécessaires.

Ajoute les tests unitaires ciblés des bindings, du service de focus, des migrations et des composants GUI associés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 23:52:34 +02:00
parent 0ab77d2a64
commit 9ef2c1439c
42 changed files with 4353 additions and 12 deletions

View File

@ -0,0 +1,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

View File

@ -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:

View File

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

View 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

View File

@ -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 = ""

View File

@ -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"

View File

@ -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(