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