Files
InputRemapper/inputremapper/gui/user_interface.py
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

429 lines
16 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/>.
"""User Interface."""
from typing import Dict, Callable
from gi.repository import Gtk, GtkSource, Gdk, GObject
from inputremapper.configs.data import get_data_path
from inputremapper.configs.input_config import InputCombination
from inputremapper.configs.mapping import MappingData
from inputremapper.gui.autocompletion import Autocompletion
from inputremapper.gui.components.common import Breadcrumbs
from inputremapper.gui.components.device_groups import DeviceGroupSelection
from inputremapper.gui.components.editor import (
MappingListBox,
TargetSelection,
CodeEditor,
RecordingToggle,
RecordingStatus,
AutoloadSwitch,
ReleaseCombinationSwitch,
CombinationListbox,
AnalogInputSwitch,
TriggerThresholdInput,
OutputAxisSelector,
ReleaseTimeoutInput,
TransformationDrawArea,
Sliders,
RelativeInputCutoffInput,
KeyAxisStackSwitcher,
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
from inputremapper.gui.gettext import _
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
)
from inputremapper.gui.messages.message_data import UserConfirmRequest
from inputremapper.gui.utils import (
gtk_iteration,
)
from inputremapper.injection.injector import InjectorStateMessage
from inputremapper.logging.logger import logger, COMMIT_HASH, VERSION, EVDEV_VERSION
# https://cjenkins.wordpress.com/2012/05/08/use-gtksourceview-widget-in-glade/
GObject.type_register(GtkSource.View)
# GtkSource.View() also works:
# https://stackoverflow.com/questions/60126579/gtk-builder-error-quark-invalid-object-type-webkitwebview
def on_close_about(about, _):
"""Hide the about dialog without destroying it."""
about.hide()
return True
class UserInterface:
"""The input-remapper gtk window."""
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
):
self.message_broker = message_broker
self.controller = controller
# all shortcuts executed when ctrl+...
self.shortcuts: Dict[int, Callable] = {
Gdk.KEY_q: self.controller.close,
Gdk.KEY_r: self.controller.refresh_groups,
Gdk.KEY_Delete: self.controller.stop_injecting,
Gdk.KEY_n: self.controller.add_preset,
}
# stores the ids for all the listeners attached to the gui
self.gtk_listeners: Dict[Callable, int] = {}
self.message_broker.subscribe(MessageType.terminate, lambda _: self.close())
self.builder = Gtk.Builder()
self._build_ui()
self.window: Gtk.Window = self.get("window")
self.about: Gtk.Window = self.get("about-dialog")
self.combination_editor: Gtk.Dialog = self.get("combination-editor")
self._create_dialogs()
self._create_components()
self._connect_gtk_signals()
self._connect_message_listener()
self.window.show()
# hide everything until stuff is populated
self.get("vertical-wrapper").set_opacity(0)
# if any of the next steps take a bit to complete, have the window
# already visible (without content) to make it look more responsive.
gtk_iteration()
# now show the proper finished content of the window
self.get("vertical-wrapper").set_opacity(1)
def _build_ui(self):
"""Build the window from stylesheet and gladefile."""
css_provider = Gtk.CssProvider()
with open(get_data_path("style.css"), "r") as file:
css_provider.load_from_data(bytes(file.read(), encoding="UTF-8"))
Gtk.StyleContext.add_provider_for_screen(
Gdk.Screen.get_default(),
css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)
gladefile = get_data_path("input-remapper.glade")
self.builder.add_from_file(gladefile)
self.builder.connect_signals(self)
def _create_components(self):
"""Setup all objects which manage individual components of the ui."""
message_broker = self.message_broker
controller = self.controller
DeviceGroupSelection(message_broker, controller, self.get("device_selection"))
PresetSelection(message_broker, controller, self.get("preset_selection"))
MappingListBox(message_broker, controller, self.get("selection_label_listbox"))
TargetSelection(message_broker, controller, self.get("target-selector"))
Breadcrumbs(
message_broker,
self.get("selected_device_name"),
show_device_group=True,
)
Breadcrumbs(
message_broker,
self.get("selected_preset_name"),
show_device_group=True,
show_preset=True,
)
Stack(message_broker, controller, self.get("main_stack"))
RecordingToggle(message_broker, controller, self.get("key_recording_toggle"))
StatusBar(
message_broker,
controller,
self.get("status_bar"),
self.get("error_status_icon"),
self.get("warning_status_icon"),
)
RecordingStatus(message_broker, self.get("recording_status"))
AutoloadSwitch(message_broker, controller, self.get("preset_autoload_switch"))
ReleaseCombinationSwitch(
message_broker, controller, self.get("release-combination-switch")
)
CombinationListbox(message_broker, controller, self.get("combination-listbox"))
AnalogInputSwitch(message_broker, controller, self.get("analog-input-switch"))
TriggerThresholdInput(
message_broker, controller, self.get("trigger-threshold-spin-btn")
)
RelativeInputCutoffInput(
message_broker, controller, self.get("input-cutoff-spin-btn")
)
OutputAxisSelector(message_broker, controller, self.get("output-axis-selector"))
KeyAxisStackSwitcher(
message_broker,
controller,
self.get("editor-stack"),
self.get("key_macro_toggle_btn"),
self.get("analog_toggle_btn"),
)
ReleaseTimeoutInput(
message_broker, controller, self.get("release-timeout-spin-button")
)
TransformationDrawArea(
message_broker, controller, self.get("transformation-draw-area")
)
Sliders(
message_broker,
controller,
self.get("gain-scale"),
self.get("deadzone-scale"),
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(
message_broker,
self.get("edit-combination-btn"),
require_recorded_input=True,
)
RequireActiveMapping(
message_broker,
self.get("output"),
require_recorded_input=True,
)
RequireActiveMapping(
message_broker,
self.get("delete-mapping"),
require_recorded_input=False,
)
# code editor and autocompletion
code_editor = CodeEditor(message_broker, controller, self.get("code_editor"))
autocompletion = Autocompletion(message_broker, controller, code_editor)
autocompletion.set_relative_to(self.get("code_editor_container"))
self.autocompletion = autocompletion # only for testing
def _create_dialogs(self):
"""Setup different dialogs, such as the about page."""
self.about.connect("delete-event", on_close_about)
# set_position needs to be done once initially, otherwise the
# dialog is not centered when it is opened for the first time
self.about.set_position(Gtk.WindowPosition.CENTER_ON_PARENT)
self.get("version-label").set_text(
f"input-remapper {VERSION} {COMMIT_HASH[:7]}"
f"\npython-evdev {EVDEV_VERSION}"
if EVDEV_VERSION
else ""
)
def _connect_gtk_signals(self):
self.get("delete_preset").connect(
"clicked", lambda *_: self.controller.delete_preset()
)
self.get("copy_preset").connect(
"clicked", lambda *_: self.controller.copy_preset()
)
self.get("create_preset").connect(
"clicked", lambda *_: self.controller.add_preset()
)
self.get("apply_preset").connect(
"clicked", lambda *_: self.controller.start_injecting()
)
self.get("stop_injection_preset_page").connect(
"clicked", lambda *_: self.controller.stop_injecting()
)
self.get("stop_injection_editor_page").connect(
"clicked", lambda *_: self.controller.stop_injecting()
)
self.get("rename-button").connect("clicked", self.on_gtk_rename_clicked)
self.get("preset_name_input").connect(
"key-release-event", self.on_gtk_preset_name_input_return
)
self.get("create_mapping_button").connect(
"clicked", lambda *_: self.controller.create_mapping()
)
self.get("delete-mapping").connect(
"clicked", lambda *_: self.controller.delete_mapping()
)
self.combination_editor.connect(
# it only takes self as argument, but delete-events provides more
# probably a gtk bug
"delete-event",
lambda dialog, *_: Gtk.Widget.hide_on_delete(dialog),
)
self.get("edit-combination-btn").connect(
"clicked", lambda *_: self.combination_editor.show()
)
self.get("remove-event-btn").connect(
"clicked", lambda *_: self.controller.remove_event()
)
self.connect_shortcuts()
def _connect_message_listener(self):
self.message_broker.subscribe(
MessageType.mapping, self.update_combination_label
)
self.message_broker.subscribe(
MessageType.injector_state, self.on_injector_state_msg
)
self.message_broker.subscribe(
MessageType.user_confirm_request, self._on_user_confirm_request
)
def _create_dialog(self, primary: str, secondary: str) -> Gtk.MessageDialog:
"""Create a message dialog with cancel and confirm buttons."""
message_dialog = Gtk.MessageDialog(
self.window,
Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT,
Gtk.MessageType.QUESTION,
Gtk.ButtonsType.NONE,
primary,
)
if secondary:
message_dialog.format_secondary_text(secondary)
message_dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
confirm_button = message_dialog.add_button("Confirm", Gtk.ResponseType.ACCEPT)
confirm_button.get_style_context().add_class(Gtk.STYLE_CLASS_DESTRUCTIVE_ACTION)
return message_dialog
def _on_user_confirm_request(self, msg: UserConfirmRequest):
# if the message contains a line-break, use the first chunk for the primary
# message, and the rest for the secondary message.
chunks = msg.msg.split("\n")
primary = chunks[0]
secondary = " ".join(chunks[1:])
message_dialog = self._create_dialog(primary, secondary)
response = message_dialog.run()
msg.respond(response == Gtk.ResponseType.ACCEPT)
message_dialog.hide()
def on_injector_state_msg(self, msg: InjectorStateMessage):
"""Update the ui to reflect the status of the injector."""
stop_injection_preset_page: Gtk.Button = self.get("stop_injection_preset_page")
stop_injection_editor_page: Gtk.Button = self.get("stop_injection_editor_page")
recording_toggle: Gtk.ToggleButton = self.get("key_recording_toggle")
if msg.active():
stop_injection_preset_page.set_opacity(1)
stop_injection_editor_page.set_opacity(1)
stop_injection_preset_page.set_sensitive(True)
stop_injection_editor_page.set_sensitive(True)
recording_toggle.set_opacity(0.5)
else:
stop_injection_preset_page.set_opacity(0.5)
stop_injection_editor_page.set_opacity(0.5)
stop_injection_preset_page.set_sensitive(True)
stop_injection_editor_page.set_sensitive(True)
recording_toggle.set_opacity(1)
def disconnect_shortcuts(self):
"""Stop listening for shortcuts.
e.g. when recording key combinations
"""
try:
self.window.disconnect(self.gtk_listeners.pop(self.on_gtk_shortcut))
except KeyError:
logger.debug("key listeners seem to be not connected")
def connect_shortcuts(self):
"""Start listening for shortcuts."""
if not self.gtk_listeners.get(self.on_gtk_shortcut):
self.gtk_listeners[self.on_gtk_shortcut] = self.window.connect(
"key-press-event", self.on_gtk_shortcut
)
def get(self, name: str):
"""Get a widget from the window."""
return self.builder.get_object(name)
def close(self):
"""Close the window."""
logger.debug("Closing window")
self.window.hide()
def update_combination_label(self, mapping: MappingData):
"""Listens for mapping and updates the combination label."""
label: Gtk.Label = self.get("combination-label")
if mapping.input_combination.beautify() == label.get_label():
return
if mapping.input_combination == InputCombination.empty_combination():
label.set_opacity(0.5)
label.set_label(_("no input configured"))
return
label.set_opacity(1)
label.set_label(mapping.input_combination.beautify())
def on_gtk_shortcut(self, _, event: Gdk.EventKey):
"""Execute shortcuts."""
if event.state & Gdk.ModifierType.CONTROL_MASK:
try:
self.shortcuts[event.keyval]()
except KeyError:
pass
def on_gtk_close(self, *_):
self.controller.close()
def on_gtk_about_clicked(self, _):
"""Show the about/help dialog."""
self.about.show()
def on_gtk_about_key_press(self, _, event):
"""Hide the about/help dialog."""
gdk_keycode = event.get_keyval()[1]
if gdk_keycode == Gdk.KEY_Escape:
self.about.hide()
def on_gtk_rename_clicked(self, *_):
name = self.get("preset_name_input").get_text()
self.controller.rename_preset(name)
self.get("preset_name_input").set_text("")
def on_gtk_preset_name_input_return(self, _, event: Gdk.EventKey):
if event.keyval == Gdk.KEY_Return:
self.on_gtk_rename_clicked()