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>
429 lines
16 KiB
Python
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()
|