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:
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
|
||||
Reference in New Issue
Block a user