# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # 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 . """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