Files
Blomios 9ef2c1439c 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>
2026-06-19 23:52:34 +02:00

540 lines
20 KiB
Python

# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""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