chore(repo): baseline du fork input-remapper (upstream v2.2.1)
Le dépôt ne contenait que README.md ; ajout de l'intégralité du code fonctionnel du fork comme socle stable de la branche de release. L'état runtime d'orchestration (.ideai/) est exclu du suivi. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
6
inputremapper/gui/__init__.py
Normal file
6
inputremapper/gui/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
import gi
|
||||
|
||||
gi.require_version("Gdk", "3.0")
|
||||
gi.require_version("Gtk", "3.0")
|
||||
gi.require_version("GLib", "2.0")
|
||||
gi.require_version("GtkSource", "4")
|
||||
457
inputremapper/gui/autocompletion.py
Normal file
457
inputremapper/gui/autocompletion.py
Normal file
@ -0,0 +1,457 @@
|
||||
# -*- 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/>.
|
||||
|
||||
|
||||
"""Autocompletion for the editor."""
|
||||
|
||||
|
||||
import re
|
||||
from typing import Dict, Optional, List, Tuple
|
||||
|
||||
from evdev.ecodes import EV_KEY
|
||||
from gi.repository import Gdk, Gtk, GLib, GObject
|
||||
|
||||
from inputremapper.configs.keyboard_layout import keyboard_layout, DISABLE_NAME
|
||||
from inputremapper.configs.mapping import MappingData
|
||||
from inputremapper.gui.components.editor import CodeEditor
|
||||
from inputremapper.gui.controller import Controller
|
||||
from inputremapper.gui.messages.message_broker import MessageBroker, MessageType
|
||||
from inputremapper.gui.messages.message_data import UInputsData
|
||||
from inputremapper.gui.utils import debounce
|
||||
from inputremapper.injection.macros.parse import Parser
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
# no deprecated shorthand function-names
|
||||
FUNCTION_NAMES = [name for name in Parser.TASK_CLASSES.keys() if len(name) > 1]
|
||||
# no deprecated functions
|
||||
FUNCTION_NAMES.remove("ifeq")
|
||||
|
||||
Capabilities = Dict[int, List]
|
||||
|
||||
|
||||
def _get_left_text(iter_: Gtk.TextIter) -> str:
|
||||
buffer = iter_.get_buffer()
|
||||
result = buffer.get_text(buffer.get_start_iter(), iter_, True)
|
||||
result = Parser.remove_comments(result)
|
||||
result = result.replace("\n", " ")
|
||||
return result.lower()
|
||||
|
||||
|
||||
# regex to search for the beginning of a...
|
||||
PARAMETER = r".*?[(,=+]\s*"
|
||||
FUNCTION_CHAIN = r".*?\)\s*\.\s*"
|
||||
|
||||
|
||||
def get_incomplete_function_name(iter_: Gtk.TextIter) -> str:
|
||||
"""Get the word that is written left to the TextIter."""
|
||||
left_text = _get_left_text(iter_)
|
||||
|
||||
# match foo in:
|
||||
# bar().foo
|
||||
# bar()\n.foo
|
||||
# bar().\nfoo
|
||||
# bar(\nfoo
|
||||
# bar(\nqux=foo
|
||||
# bar(KEY_A,\nfoo
|
||||
# foo
|
||||
match = re.match(rf"(?:{FUNCTION_CHAIN}|{PARAMETER}|^)(\w+)$", left_text)
|
||||
logger.debug('get_incomplete_function_name text: "%s" match: %s', left_text, match)
|
||||
|
||||
if match is None:
|
||||
return ""
|
||||
|
||||
return match[1]
|
||||
|
||||
|
||||
def get_incomplete_parameter(iter_: Gtk.TextIter) -> Optional[str]:
|
||||
"""Get the parameter that is written left to the TextIter."""
|
||||
left_text = _get_left_text(iter_)
|
||||
|
||||
# match foo in:
|
||||
# bar(foo
|
||||
# bar(a=foo
|
||||
# bar(qux, foo
|
||||
# foo
|
||||
# bar + foo
|
||||
match = re.match(rf"(?:{PARAMETER}|^)(\w+)$", left_text)
|
||||
logger.debug('get_incomplete_parameter text: "%s" match: %s', left_text, match)
|
||||
|
||||
if match is None:
|
||||
return None
|
||||
|
||||
return match[1]
|
||||
|
||||
|
||||
def propose_symbols(text_iter: Gtk.TextIter, codes: List[int]) -> List[Tuple[str, str]]:
|
||||
"""Find key names that match the input at the cursor and are mapped to the codes."""
|
||||
incomplete_name = get_incomplete_parameter(text_iter)
|
||||
|
||||
if incomplete_name is None or len(incomplete_name) <= 1:
|
||||
return []
|
||||
|
||||
incomplete_name = incomplete_name.lower()
|
||||
|
||||
names = list(keyboard_layout.list_names(codes=codes)) + [DISABLE_NAME]
|
||||
|
||||
return [
|
||||
(name, name)
|
||||
for name in names
|
||||
if incomplete_name in name.lower() and incomplete_name != name.lower()
|
||||
]
|
||||
|
||||
|
||||
def propose_function_names(text_iter: Gtk.TextIter) -> List[Tuple[str, str]]:
|
||||
"""Find function names that match the input at the cursor."""
|
||||
incomplete_name = get_incomplete_function_name(text_iter)
|
||||
|
||||
if incomplete_name is None or len(incomplete_name) <= 1:
|
||||
return []
|
||||
|
||||
incomplete_name = incomplete_name.lower()
|
||||
|
||||
# A list of
|
||||
# - ("key", "key(symbol)")
|
||||
# - ("repeat", "repeat(repeats, macro)")
|
||||
# etc.
|
||||
function_names: List[Tuple[str, str]] = []
|
||||
|
||||
for name in FUNCTION_NAMES:
|
||||
if incomplete_name in name.lower() and incomplete_name != name.lower():
|
||||
task_class = Parser.TASK_CLASSES[name]
|
||||
argument_names = task_class.get_macro_argument_names()
|
||||
function_names.append((name, f"{name}({', '.join(argument_names)})"))
|
||||
|
||||
return function_names
|
||||
|
||||
|
||||
class SuggestionLabel(Gtk.Label):
|
||||
"""A label with some extra internal information."""
|
||||
|
||||
__gtype_name__ = "SuggestionLabel"
|
||||
|
||||
def __init__(self, display_name: str, suggestion: str):
|
||||
super().__init__(label=display_name)
|
||||
self.suggestion = suggestion
|
||||
|
||||
|
||||
class Autocompletion(Gtk.Popover):
|
||||
"""Provide keyboard-controllable beautiful autocompletions.
|
||||
|
||||
The one provided via source_view.get_completion() is not very appealing
|
||||
"""
|
||||
|
||||
__gtype_name__ = "Autocompletion"
|
||||
_target_uinput: Optional[str] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_broker: MessageBroker,
|
||||
controller: Controller,
|
||||
code_editor: CodeEditor,
|
||||
):
|
||||
"""Create an autocompletion popover.
|
||||
|
||||
It will remain hidden until there is something to autocomplete.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
code_editor
|
||||
The widget that contains the text that should be autocompleted
|
||||
"""
|
||||
super().__init__(
|
||||
# Don't switch the focus to the popover when it shows
|
||||
modal=False,
|
||||
# Always show the popover below the cursor, don't move it to a different
|
||||
# position based on the location within the window
|
||||
constrain_to=Gtk.PopoverConstraint.NONE,
|
||||
)
|
||||
|
||||
self.code_editor = code_editor
|
||||
self.controller = controller
|
||||
self.message_broker = message_broker
|
||||
self._uinputs: Optional[Dict[str, Capabilities]] = None
|
||||
self._target_key_capabilities: List[int] = []
|
||||
|
||||
self.scrolled_window = Gtk.ScrolledWindow(
|
||||
min_content_width=200,
|
||||
max_content_height=200,
|
||||
propagate_natural_width=True,
|
||||
propagate_natural_height=True,
|
||||
)
|
||||
self.list_box = Gtk.ListBox()
|
||||
self.list_box.get_style_context().add_class("transparent")
|
||||
self.scrolled_window.add(self.list_box)
|
||||
|
||||
# row-activated is on-click,
|
||||
# row-selected is when scrolling through it
|
||||
self.list_box.connect(
|
||||
"row-activated",
|
||||
self._on_suggestion_clicked,
|
||||
)
|
||||
|
||||
self.add(self.scrolled_window)
|
||||
|
||||
self.get_style_context().add_class("autocompletion")
|
||||
|
||||
self.set_position(Gtk.PositionType.BOTTOM)
|
||||
|
||||
self.code_editor.gui.connect("key-press-event", self.navigate)
|
||||
|
||||
# add some delay, so that pressing the button in the completion works before
|
||||
# the popover is hidden due to focus-out-event
|
||||
self.code_editor.gui.connect("focus-out-event", self.on_gtk_text_input_unfocus)
|
||||
|
||||
self.code_editor.gui.get_buffer().connect("changed", self.update)
|
||||
|
||||
self.set_position(Gtk.PositionType.BOTTOM)
|
||||
|
||||
self.visible = False
|
||||
|
||||
self.attach_to_events()
|
||||
self.show_all()
|
||||
self.popdown() # hidden by default. this needs to happen after show_all!
|
||||
|
||||
def attach_to_events(self):
|
||||
self.message_broker.subscribe(MessageType.mapping, self._on_mapping_changed)
|
||||
self.message_broker.subscribe(MessageType.uinputs, self._on_uinputs_changed)
|
||||
|
||||
def on_gtk_text_input_unfocus(self, *_):
|
||||
"""The code editor was unfocused."""
|
||||
GLib.timeout_add(100, self.popdown)
|
||||
# "(input-remapper-gtk:97611): Gtk-WARNING **: 16:33:56.464: GtkTextView -
|
||||
# did not receive focus-out-event. If you connect a handler to this signal,
|
||||
# it must return FALSE so the text view gets the event as well"
|
||||
return False
|
||||
|
||||
def navigate(self, _, event: Gdk.EventKey):
|
||||
"""Using the keyboard to select an autocompletion suggestion."""
|
||||
if not self.visible:
|
||||
return
|
||||
|
||||
if event.keyval == Gdk.KEY_Escape:
|
||||
self.popdown()
|
||||
return
|
||||
|
||||
selected_row = self.list_box.get_selected_row()
|
||||
|
||||
if event.keyval not in [Gdk.KEY_Down, Gdk.KEY_Up, Gdk.KEY_Return]:
|
||||
# not one of the keys that controls the autocompletion. Deselect
|
||||
# the row but keep it open
|
||||
self.list_box.select_row(None)
|
||||
return
|
||||
|
||||
if event.keyval == Gdk.KEY_Return:
|
||||
if selected_row is None:
|
||||
# nothing selected, forward the event to the text editor
|
||||
return
|
||||
|
||||
# a row is selected and should be used for autocompletion
|
||||
self.list_box.emit("row-activated", selected_row)
|
||||
return Gdk.EVENT_STOP
|
||||
|
||||
num_rows = len(self.list_box.get_children())
|
||||
|
||||
if selected_row is None:
|
||||
# select the first row
|
||||
if event.keyval == Gdk.KEY_Down:
|
||||
new_selected_row = self.list_box.get_row_at_index(0)
|
||||
|
||||
if event.keyval == Gdk.KEY_Up:
|
||||
new_selected_row = self.list_box.get_row_at_index(num_rows - 1)
|
||||
else:
|
||||
# select the next row
|
||||
selected_index = selected_row.get_index()
|
||||
new_index = selected_index
|
||||
|
||||
if event.keyval == Gdk.KEY_Down:
|
||||
new_index += 1
|
||||
|
||||
if event.keyval == Gdk.KEY_Up:
|
||||
new_index -= 1
|
||||
|
||||
if new_index < 0:
|
||||
new_index = num_rows - 1
|
||||
|
||||
if new_index > num_rows - 1:
|
||||
new_index = 0
|
||||
|
||||
new_selected_row = self.list_box.get_row_at_index(new_index)
|
||||
|
||||
self.list_box.select_row(new_selected_row)
|
||||
|
||||
self._scroll_to_row(new_selected_row)
|
||||
|
||||
# don't change editor contents
|
||||
return Gdk.EVENT_STOP
|
||||
|
||||
def _scroll_to_row(self, row: Gtk.ListBoxRow):
|
||||
"""Scroll up or down so that the row is visible."""
|
||||
# unfortunately, it seems that without focusing the row it won't happen
|
||||
# automatically (or whatever the reason for this is, just a wild guess)
|
||||
# (the focus should not leave the code editor, so that continuing
|
||||
# to write code is possible), so here is a custom solution.
|
||||
row_height = row.get_allocation().height
|
||||
|
||||
list_box_height = self.list_box.get_allocated_height()
|
||||
|
||||
if row:
|
||||
# get coordinate relative to the list_box,
|
||||
# measured from the top of the selected row to the top of the list_box
|
||||
row_y_position = row.translate_coordinates(self.list_box, 0, 0)[1]
|
||||
|
||||
# Depending on the theme, the y_offset will be > 0, even though it
|
||||
# is the uppermost element, due to margins/paddings.
|
||||
if row_y_position < row_height:
|
||||
row_y_position = 0
|
||||
|
||||
# if the selected row sits lower than the second to last row,
|
||||
# then scroll all the way down. otherwise it will only scroll down
|
||||
# to the bottom edge of the selected-row, which might not actually be the
|
||||
# bottom of the list-box due to paddings.
|
||||
if row_y_position > list_box_height - row_height * 1.5:
|
||||
# using a value that is too high doesn't hurt here.
|
||||
row_y_position = list_box_height
|
||||
|
||||
# the visible height of the scrolled_window. not the content.
|
||||
height = self.scrolled_window.get_max_content_height()
|
||||
|
||||
current_y_scroll = self.scrolled_window.get_vadjustment().get_value()
|
||||
|
||||
vadjustment = self.scrolled_window.get_vadjustment()
|
||||
|
||||
# for the selected row to still be visible, its y_offset has to be
|
||||
# at height - row_height. If the y_offset is higher than that, then
|
||||
# the autocompletion needs to scroll down to make it visible again.
|
||||
if row_y_position > current_y_scroll + (height - row_height):
|
||||
value = row_y_position - (height - row_height)
|
||||
vadjustment.set_value(value)
|
||||
|
||||
if row_y_position < current_y_scroll:
|
||||
# the selected element is not visiable, so we need to scroll up.
|
||||
vadjustment.set_value(row_y_position)
|
||||
|
||||
def _get_text_iter_at_cursor(self):
|
||||
"""Get Gtk.TextIter at the current text cursor location."""
|
||||
cursor = self.code_editor.gui.get_cursor_locations()[0]
|
||||
return self.code_editor.gui.get_iter_at_location(cursor.x, cursor.y)[1]
|
||||
|
||||
def popup(self):
|
||||
self.visible = True
|
||||
super().popup()
|
||||
|
||||
def popdown(self):
|
||||
self.visible = False
|
||||
super().popdown()
|
||||
|
||||
@debounce(100)
|
||||
def update(self, *_):
|
||||
"""Find new autocompletion suggestions and display them. Hide if none."""
|
||||
if len(self._target_key_capabilities) == 0:
|
||||
logger.error("No target capabilities available")
|
||||
return
|
||||
|
||||
if not self.code_editor.gui.is_focus():
|
||||
self.popdown()
|
||||
return
|
||||
|
||||
self.list_box.forall(self.list_box.remove)
|
||||
|
||||
# move the autocompletion to the text cursor
|
||||
cursor = self.code_editor.gui.get_cursor_locations()[0]
|
||||
# convert it to window coords, because the cursor values will be very large
|
||||
# when the TextView is in a scrolled down ScrolledWindow.
|
||||
window_coords = self.code_editor.gui.buffer_to_window_coords(
|
||||
Gtk.TextWindowType.TEXT, cursor.x, cursor.y
|
||||
)
|
||||
cursor.x = window_coords.window_x
|
||||
cursor.y = window_coords.window_y
|
||||
cursor.y += 12
|
||||
|
||||
if self.code_editor.gui.get_show_line_numbers():
|
||||
cursor.x += 48
|
||||
|
||||
self.set_pointing_to(cursor)
|
||||
|
||||
text_iter = self._get_text_iter_at_cursor()
|
||||
# get a list of (evdev/xmodmap symbol-name, display-name)
|
||||
suggested_names = propose_function_names(text_iter)
|
||||
suggested_names += propose_symbols(text_iter, self._target_key_capabilities)
|
||||
|
||||
if len(suggested_names) == 0:
|
||||
self.popdown()
|
||||
return
|
||||
|
||||
self.popup() # ffs was this hard to find
|
||||
|
||||
# add visible autocompletion entries
|
||||
for suggestion, display_name in suggested_names:
|
||||
label = SuggestionLabel(display_name, suggestion)
|
||||
self.list_box.insert(label, -1)
|
||||
label.show_all()
|
||||
|
||||
def _update_capabilities(self):
|
||||
if self._target_uinput and self._uinputs:
|
||||
self._target_key_capabilities = self._uinputs[self._target_uinput][EV_KEY]
|
||||
|
||||
def _on_mapping_changed(self, mapping: MappingData):
|
||||
self._target_uinput = mapping.target_uinput
|
||||
self._update_capabilities()
|
||||
|
||||
def _on_uinputs_changed(self, data: UInputsData):
|
||||
self._uinputs = data.uinputs
|
||||
self._update_capabilities()
|
||||
|
||||
def _on_suggestion_clicked(self, _, selected_row):
|
||||
"""An autocompletion suggestion was selected and should be inserted."""
|
||||
selected_label = selected_row.get_children()[0]
|
||||
suggestion = selected_label.suggestion
|
||||
buffer = self.code_editor.gui.get_buffer()
|
||||
|
||||
# make sure to replace the complete unfinished word. Look to the right and
|
||||
# remove whatever there is
|
||||
cursor_iter = self._get_text_iter_at_cursor()
|
||||
right = buffer.get_text(cursor_iter, buffer.get_end_iter(), True)
|
||||
match = re.match(r"^(\w+)", right)
|
||||
right = match[1] if match else ""
|
||||
Gtk.TextView.do_delete_from_cursor(
|
||||
self.code_editor.gui, Gtk.DeleteType.CHARS, len(right)
|
||||
)
|
||||
|
||||
# do the same to the left
|
||||
cursor_iter = self._get_text_iter_at_cursor()
|
||||
left = buffer.get_text(buffer.get_start_iter(), cursor_iter, True)
|
||||
match = re.match(r".*?(\w+)$", re.sub("\n", " ", left))
|
||||
left = match[1] if match else ""
|
||||
Gtk.TextView.do_delete_from_cursor(
|
||||
self.code_editor.gui, Gtk.DeleteType.CHARS, -len(left)
|
||||
)
|
||||
|
||||
# insert the autocompletion
|
||||
Gtk.TextView.do_insert_at_cursor(self.code_editor.gui, suggestion)
|
||||
|
||||
self.emit("suggestion-inserted")
|
||||
|
||||
|
||||
GObject.signal_new(
|
||||
"suggestion-inserted",
|
||||
Autocompletion,
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
None,
|
||||
[],
|
||||
)
|
||||
0
inputremapper/gui/components/__init__.py
Normal file
0
inputremapper/gui/components/__init__.py
Normal file
174
inputremapper/gui/components/common.py
Normal file
174
inputremapper/gui/components/common.py
Normal file
@ -0,0 +1,174 @@
|
||||
# -*- 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 used in multiple places."""
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from gi.repository import Gtk
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from inputremapper.configs.mapping import MappingData
|
||||
|
||||
from inputremapper.gui.controller import Controller
|
||||
from inputremapper.gui.messages.message_broker import (
|
||||
MessageBroker,
|
||||
MessageType,
|
||||
)
|
||||
from inputremapper.gui.messages.message_data import GroupData, PresetData
|
||||
from inputremapper.gui.utils import HandlerDisabled
|
||||
|
||||
|
||||
class FlowBoxEntry(Gtk.ToggleButton):
|
||||
"""A device that can be selected in the GUI.
|
||||
|
||||
For example a keyboard or a mouse.
|
||||
"""
|
||||
|
||||
__gtype_name__ = "FlowBoxEntry"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_broker: MessageBroker,
|
||||
controller: Controller,
|
||||
name: str,
|
||||
icon_name: Optional[str] = None,
|
||||
):
|
||||
super().__init__()
|
||||
self.icon_name = icon_name
|
||||
self.message_broker = message_broker
|
||||
self._controller = controller
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
if icon_name:
|
||||
icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG)
|
||||
box.add(icon)
|
||||
|
||||
label = Gtk.Label()
|
||||
label.set_label(name)
|
||||
self.name = name
|
||||
|
||||
# wrap very long names properly
|
||||
label.set_line_wrap(True)
|
||||
label.set_line_wrap_mode(2)
|
||||
# this affeects how many device entries fit next to each other
|
||||
label.set_width_chars(28)
|
||||
label.set_max_width_chars(28)
|
||||
|
||||
box.add(label)
|
||||
|
||||
box.set_margin_top(18)
|
||||
box.set_margin_bottom(18)
|
||||
box.set_homogeneous(True)
|
||||
box.set_spacing(12)
|
||||
|
||||
# self.set_relief(Gtk.ReliefStyle.NONE)
|
||||
|
||||
self.add(box)
|
||||
|
||||
self.show_all()
|
||||
|
||||
self.connect("toggled", self._on_gtk_toggle)
|
||||
|
||||
def _on_gtk_toggle(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def show_active(self, active):
|
||||
"""Show the active state without triggering anything."""
|
||||
with HandlerDisabled(self, self._on_gtk_toggle):
|
||||
self.set_active(active)
|
||||
|
||||
|
||||
class FlowBoxWrapper:
|
||||
"""A wrapper for a flowbox that contains FlowBoxEntry widgets."""
|
||||
|
||||
def __init__(self, flowbox: Gtk.FlowBox):
|
||||
self._gui = flowbox
|
||||
|
||||
def show_active_entry(self, name: Optional[str]):
|
||||
"""Activate the togglebutton that matches the name."""
|
||||
for child in self._gui.get_children():
|
||||
flow_box_entry: FlowBoxEntry = child.get_children()[0]
|
||||
flow_box_entry.show_active(flow_box_entry.name == name)
|
||||
|
||||
|
||||
class Breadcrumbs:
|
||||
"""Writes a breadcrumbs string into a given label."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_broker: MessageBroker,
|
||||
label: Gtk.Label,
|
||||
show_device_group: bool = False,
|
||||
show_preset: bool = False,
|
||||
show_mapping: bool = False,
|
||||
):
|
||||
self._message_broker = message_broker
|
||||
self._gui = label
|
||||
self._connect_message_listener()
|
||||
|
||||
self.show_device_group = show_device_group
|
||||
self.show_preset = show_preset
|
||||
self.show_mapping = show_mapping
|
||||
|
||||
self._group_key: str = ""
|
||||
self._preset_name: str = ""
|
||||
self._mapping_name: str = ""
|
||||
|
||||
label.set_max_width_chars(50)
|
||||
label.set_line_wrap(True)
|
||||
label.set_line_wrap_mode(2)
|
||||
|
||||
self._render()
|
||||
|
||||
def _connect_message_listener(self):
|
||||
self._message_broker.subscribe(MessageType.group, self._on_group_changed)
|
||||
self._message_broker.subscribe(MessageType.preset, self._on_preset_changed)
|
||||
self._message_broker.subscribe(MessageType.mapping, self._on_mapping_changed)
|
||||
|
||||
def _on_preset_changed(self, data: PresetData):
|
||||
self._preset_name = data.name or ""
|
||||
self._render()
|
||||
|
||||
def _on_group_changed(self, data: GroupData):
|
||||
self._group_key = data.group_key
|
||||
self._render()
|
||||
|
||||
def _on_mapping_changed(self, mapping_data: MappingData):
|
||||
self._mapping_name = mapping_data.format_name()
|
||||
self._render()
|
||||
|
||||
def _render(self):
|
||||
label = []
|
||||
|
||||
if self.show_device_group:
|
||||
label.append(self._group_key or "?")
|
||||
|
||||
if self.show_preset:
|
||||
label.append(self._preset_name or "?")
|
||||
|
||||
if self.show_mapping:
|
||||
label.append(self._mapping_name or "?")
|
||||
|
||||
self._gui.set_label(" / ".join(label))
|
||||
115
inputremapper/gui/components/device_groups.py
Normal file
115
inputremapper/gui/components/device_groups.py
Normal file
@ -0,0 +1,115 @@
|
||||
# -*- 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/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from gi.repository import Gtk
|
||||
|
||||
from inputremapper.gui.components.common import FlowBoxEntry, FlowBoxWrapper
|
||||
from inputremapper.gui.components.editor import ICON_PRIORITIES, ICON_NAMES
|
||||
from inputremapper.gui.components.main import Stack
|
||||
from inputremapper.gui.controller import Controller
|
||||
from inputremapper.gui.messages.message_broker import (
|
||||
MessageBroker,
|
||||
MessageType,
|
||||
)
|
||||
from inputremapper.gui.messages.message_data import (
|
||||
GroupsData,
|
||||
GroupData,
|
||||
DoStackSwitch,
|
||||
)
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
|
||||
class DeviceGroupEntry(FlowBoxEntry):
|
||||
"""A device that can be selected in the GUI.
|
||||
|
||||
For example a keyboard or a mouse.
|
||||
"""
|
||||
|
||||
__gtype_name__ = "DeviceGroupEntry"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_broker: MessageBroker,
|
||||
controller: Controller,
|
||||
icon_name: Optional[str],
|
||||
group_key: str,
|
||||
):
|
||||
super().__init__(
|
||||
message_broker=message_broker,
|
||||
controller=controller,
|
||||
icon_name=icon_name,
|
||||
name=group_key,
|
||||
)
|
||||
self.group_key = group_key
|
||||
|
||||
def _on_gtk_toggle(self, *_, **__):
|
||||
logger.debug('Selecting device "%s"', self.group_key)
|
||||
self._controller.load_group(self.group_key)
|
||||
self.message_broker.publish(DoStackSwitch(Stack.presets_page))
|
||||
|
||||
|
||||
class DeviceGroupSelection(FlowBoxWrapper):
|
||||
"""A wrapper for the container with our groups.
|
||||
|
||||
A group is a collection of devices.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_broker: MessageBroker,
|
||||
controller: Controller,
|
||||
flowbox: Gtk.FlowBox,
|
||||
):
|
||||
super().__init__(flowbox)
|
||||
|
||||
self._message_broker = message_broker
|
||||
self._controller = controller
|
||||
self._gui = flowbox
|
||||
|
||||
self._message_broker.subscribe(MessageType.groups, self._on_groups_changed)
|
||||
self._message_broker.subscribe(MessageType.group, self._on_group_changed)
|
||||
|
||||
def _on_groups_changed(self, data: GroupsData):
|
||||
self._gui.foreach(self._gui.remove)
|
||||
|
||||
for group_key, types in data.groups.items():
|
||||
if len(types) > 0:
|
||||
device_type = sorted(types, key=ICON_PRIORITIES.index)[0]
|
||||
icon_name = ICON_NAMES[device_type]
|
||||
else:
|
||||
icon_name = None
|
||||
|
||||
logger.debug("adding %s to device selection", group_key)
|
||||
device_group_entry = DeviceGroupEntry(
|
||||
self._message_broker,
|
||||
self._controller,
|
||||
icon_name,
|
||||
group_key,
|
||||
)
|
||||
self._gui.insert(device_group_entry, -1)
|
||||
|
||||
if self._controller.data_manager.active_group:
|
||||
self.show_active_entry(self._controller.data_manager.active_group.key)
|
||||
|
||||
def _on_group_changed(self, data: GroupData):
|
||||
self.show_active_entry(data.group_key)
|
||||
1211
inputremapper/gui/components/editor.py
Normal file
1211
inputremapper/gui/components/editor.py
Normal file
File diff suppressed because it is too large
Load Diff
134
inputremapper/gui/components/main.py
Normal file
134
inputremapper/gui/components/main.py
Normal file
@ -0,0 +1,134 @@
|
||||
# -*- 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 that wrap everything."""
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from gi.repository import Gtk, Pango
|
||||
|
||||
from inputremapper.gui.controller import Controller
|
||||
from inputremapper.gui.messages.message_broker import (
|
||||
MessageBroker,
|
||||
MessageType,
|
||||
)
|
||||
from inputremapper.gui.messages.message_data import StatusData, DoStackSwitch
|
||||
from inputremapper.gui.utils import CTX_ERROR, CTX_MAPPING, CTX_WARNING
|
||||
|
||||
|
||||
class Stack:
|
||||
"""Wraps the Stack, which contains the main menu pages."""
|
||||
|
||||
devices_page = 0
|
||||
presets_page = 1
|
||||
editor_page = 2
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_broker: MessageBroker,
|
||||
controller: Controller,
|
||||
stack: Gtk.Stack,
|
||||
):
|
||||
self._message_broker = message_broker
|
||||
self._controller = controller
|
||||
self._gui = stack
|
||||
|
||||
self._message_broker.subscribe(
|
||||
MessageType.do_stack_switch, self._do_stack_switch
|
||||
)
|
||||
|
||||
def _do_stack_switch(self, msg: DoStackSwitch):
|
||||
self._gui.set_visible_child(self._gui.get_children()[msg.page_index])
|
||||
|
||||
|
||||
class StatusBar:
|
||||
"""The status bar on the bottom of the main window."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_broker: MessageBroker,
|
||||
controller: Controller,
|
||||
status_bar: Gtk.Statusbar,
|
||||
error_icon: Gtk.Image,
|
||||
warning_icon: Gtk.Image,
|
||||
):
|
||||
self._message_broker = message_broker
|
||||
self._controller = controller
|
||||
self._gui = status_bar
|
||||
self._error_icon = error_icon
|
||||
self._warning_icon = warning_icon
|
||||
|
||||
label = self._gui.get_message_area().get_children()[0]
|
||||
label.set_ellipsize(Pango.EllipsizeMode.END)
|
||||
label.set_selectable(True)
|
||||
|
||||
self._message_broker.subscribe(MessageType.status_msg, self._on_status_update)
|
||||
|
||||
# keep track if there is an error or warning in the stack of statusbar
|
||||
# unfortunately this is not exposed over the api
|
||||
self._error = False
|
||||
self._warning = False
|
||||
|
||||
def _on_status_update(self, data: StatusData):
|
||||
"""Show a status message and set its tooltip.
|
||||
|
||||
If message is None, it will remove the newest message of the
|
||||
given context_id.
|
||||
"""
|
||||
context_id = data.ctx_id
|
||||
message = data.msg
|
||||
tooltip = data.tooltip
|
||||
status_bar = self._gui
|
||||
|
||||
if message is None:
|
||||
status_bar.remove_all(context_id)
|
||||
|
||||
if context_id in (CTX_ERROR, CTX_MAPPING):
|
||||
self._error_icon.hide()
|
||||
self._error = False
|
||||
if self._warning:
|
||||
self._warning_icon.show()
|
||||
|
||||
if context_id == CTX_WARNING:
|
||||
self._warning_icon.hide()
|
||||
self._warning = False
|
||||
if self._error:
|
||||
self._error_icon.show()
|
||||
|
||||
status_bar.set_tooltip_text("")
|
||||
return
|
||||
|
||||
if tooltip is None:
|
||||
tooltip = message
|
||||
|
||||
self._error_icon.hide()
|
||||
self._warning_icon.hide()
|
||||
|
||||
if context_id in (CTX_ERROR, CTX_MAPPING):
|
||||
self._error_icon.show()
|
||||
self._error = True
|
||||
|
||||
if context_id == CTX_WARNING:
|
||||
self._warning_icon.show()
|
||||
self._warning = True
|
||||
|
||||
status_bar.push(context_id, message)
|
||||
status_bar.set_tooltip_text(tooltip)
|
||||
3
inputremapper/gui/components/output_type_names.py
Normal file
3
inputremapper/gui/components/output_type_names.py
Normal file
@ -0,0 +1,3 @@
|
||||
class OutputTypeNames:
|
||||
analog_axis = "Analog Axis"
|
||||
key_or_macro = "Key or Macro"
|
||||
106
inputremapper/gui/components/presets.py
Normal file
106
inputremapper/gui/components/presets.py
Normal file
@ -0,0 +1,106 @@
|
||||
# -*- 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/>.
|
||||
|
||||
|
||||
"""All components that are visible on the page that shows all the presets."""
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from gi.repository import Gtk
|
||||
|
||||
from inputremapper.gui.components.common import FlowBoxEntry, FlowBoxWrapper
|
||||
from inputremapper.gui.components.main import Stack
|
||||
from inputremapper.gui.controller import Controller
|
||||
from inputremapper.gui.messages.message_broker import (
|
||||
MessageBroker,
|
||||
MessageType,
|
||||
)
|
||||
from inputremapper.gui.messages.message_data import (
|
||||
GroupData,
|
||||
PresetData,
|
||||
DoStackSwitch,
|
||||
)
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
|
||||
class PresetEntry(FlowBoxEntry):
|
||||
"""A preset that can be selected in the GUI."""
|
||||
|
||||
__gtype_name__ = "PresetEntry"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_broker: MessageBroker,
|
||||
controller: Controller,
|
||||
preset_name: str,
|
||||
):
|
||||
super().__init__(
|
||||
message_broker=message_broker, controller=controller, name=preset_name
|
||||
)
|
||||
self.preset_name = preset_name
|
||||
|
||||
def _on_gtk_toggle(self, *_, **__):
|
||||
logger.debug('Selecting preset "%s"', self.preset_name)
|
||||
self._controller.load_preset(self.preset_name)
|
||||
self.message_broker.publish(DoStackSwitch(Stack.editor_page))
|
||||
|
||||
|
||||
class PresetSelection(FlowBoxWrapper):
|
||||
"""A wrapper for the container with our presets.
|
||||
|
||||
Selectes the active_preset.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_broker: MessageBroker,
|
||||
controller: Controller,
|
||||
flowbox: Gtk.FlowBox,
|
||||
):
|
||||
super().__init__(flowbox)
|
||||
|
||||
self._message_broker = message_broker
|
||||
self._controller = controller
|
||||
self._gui = flowbox
|
||||
self._connect_message_listener()
|
||||
|
||||
def _connect_message_listener(self):
|
||||
self._message_broker.subscribe(MessageType.group, self._on_group_changed)
|
||||
self._message_broker.subscribe(MessageType.preset, self._on_preset_changed)
|
||||
|
||||
def _on_group_changed(self, data: GroupData):
|
||||
self._gui.foreach(self._gui.remove)
|
||||
for preset_name in data.presets:
|
||||
preset_entry = PresetEntry(
|
||||
self._message_broker,
|
||||
self._controller,
|
||||
preset_name,
|
||||
)
|
||||
self._gui.insert(preset_entry, -1)
|
||||
|
||||
def _on_preset_changed(self, data: PresetData):
|
||||
self.show_active_entry(data.name)
|
||||
|
||||
def set_active_preset(self, preset_name: str):
|
||||
"""Change the currently selected preset."""
|
||||
# TODO might only be needed in tests
|
||||
for child in self._gui.get_children():
|
||||
preset_entry: PresetEntry = child.get_children()[0]
|
||||
preset_entry.set_active(preset_entry.preset_name == preset_name)
|
||||
876
inputremapper/gui/controller.py
Normal file
876
inputremapper/gui/controller.py
Normal file
@ -0,0 +1,876 @@
|
||||
# -*- 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/>.
|
||||
|
||||
from __future__ import annotations # needed for the TYPE_CHECKING import
|
||||
|
||||
import re
|
||||
from functools import partial
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Optional,
|
||||
Union,
|
||||
Literal,
|
||||
Sequence,
|
||||
Dict,
|
||||
Callable,
|
||||
List,
|
||||
Any,
|
||||
Tuple,
|
||||
)
|
||||
|
||||
from evdev.ecodes import EV_KEY, EV_REL, EV_ABS
|
||||
from gi.repository import Gtk
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.mapping import (
|
||||
MappingData,
|
||||
UIMapping,
|
||||
MappingType,
|
||||
)
|
||||
from inputremapper.configs.paths import PathUtils
|
||||
from inputremapper.configs.validation_errors import (
|
||||
pydantify,
|
||||
MissingMacroOrKeyError,
|
||||
MacroButTypeOrCodeSetError,
|
||||
SymbolAndCodeMismatchError,
|
||||
MissingOutputAxisError,
|
||||
WrongMappingTypeForKeyError,
|
||||
OutputSymbolVariantError,
|
||||
)
|
||||
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.gettext import _
|
||||
from inputremapper.gui.messages.message_broker import (
|
||||
MessageBroker,
|
||||
MessageType,
|
||||
)
|
||||
from inputremapper.gui.messages.message_data import (
|
||||
PresetData,
|
||||
StatusData,
|
||||
CombinationRecorded,
|
||||
UserConfirmRequest,
|
||||
DoStackSwitch,
|
||||
)
|
||||
from inputremapper.gui.utils import CTX_APPLY, CTX_ERROR, CTX_WARNING, CTX_MAPPING
|
||||
from inputremapper.injection.injector import (
|
||||
InjectorState,
|
||||
InjectorStateMessage,
|
||||
)
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# avoids gtk import error in tests
|
||||
from inputremapper.gui.user_interface import UserInterface
|
||||
|
||||
|
||||
MAPPING_DEFAULTS = {"target_uinput": "keyboard"}
|
||||
|
||||
|
||||
class Controller:
|
||||
"""Implements the behaviour of the gui."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_broker: MessageBroker,
|
||||
data_manager: DataManager,
|
||||
) -> None:
|
||||
self.message_broker = message_broker
|
||||
self.data_manager = data_manager
|
||||
self.gui: Optional[UserInterface] = None
|
||||
|
||||
self.button_left_warn = False
|
||||
self._attach_to_events()
|
||||
|
||||
def set_gui(self, gui: UserInterface):
|
||||
"""Let the Controller know about the user interface singleton.."""
|
||||
self.gui = gui
|
||||
|
||||
def _attach_to_events(self) -> None:
|
||||
self.message_broker.subscribe(MessageType.groups, self._on_groups_changed)
|
||||
self.message_broker.subscribe(MessageType.preset, self._on_preset_changed)
|
||||
self.message_broker.subscribe(MessageType.init, self._on_init)
|
||||
self.message_broker.subscribe(
|
||||
MessageType.preset, self._publish_mapping_errors_as_status_msg
|
||||
)
|
||||
self.message_broker.subscribe(
|
||||
MessageType.mapping, self._publish_mapping_errors_as_status_msg
|
||||
)
|
||||
|
||||
def _on_init(self, __):
|
||||
"""Initialize the gui and the data_manager."""
|
||||
# make sure we get a groups_changed event when everything is ready
|
||||
# this might not be necessary if the reader-service takes longer to provide the
|
||||
# initial groups
|
||||
self.data_manager.publish_groups()
|
||||
self.data_manager.publish_uinputs()
|
||||
|
||||
def _on_groups_changed(self, _):
|
||||
"""Load the newest group as soon as everyone got notified
|
||||
about the updated groups."""
|
||||
|
||||
if self.data_manager.active_group is not None:
|
||||
# don't jump to a different group and preset suddenly, if the user
|
||||
# is already looking at one
|
||||
logger.debug("A group is already active")
|
||||
return
|
||||
|
||||
group_key = self.get_a_group()
|
||||
if group_key is None:
|
||||
logger.debug("Could not find a group")
|
||||
return
|
||||
|
||||
self.load_group(group_key)
|
||||
|
||||
def _on_preset_changed(self, data: PresetData):
|
||||
"""Load a mapping as soon as everyone got notified about the new preset."""
|
||||
if data.mappings:
|
||||
mappings = list(data.mappings)
|
||||
mappings.sort(
|
||||
key=lambda mapping: (
|
||||
mapping.format_name() or mapping.input_combination.beautify()
|
||||
)
|
||||
)
|
||||
combination = mappings[0].input_combination
|
||||
self.load_mapping(combination)
|
||||
self.load_input_config(combination[0])
|
||||
else:
|
||||
# send an empty mapping to make sure the ui is reset to default values
|
||||
self.message_broker.publish(MappingData(**MAPPING_DEFAULTS))
|
||||
|
||||
def _on_combination_recorded(self, data: CombinationRecorded):
|
||||
combination = self._auto_use_as_analog(data.combination)
|
||||
self.update_combination(combination)
|
||||
|
||||
def _format_status_bar_validation_errors(self) -> Optional[Tuple[str, str]]:
|
||||
if not self.data_manager.active_preset:
|
||||
return None
|
||||
|
||||
if self.data_manager.active_preset.is_valid():
|
||||
self.message_broker.publish(StatusData(CTX_MAPPING))
|
||||
return None
|
||||
|
||||
mappings = list(self.data_manager.active_preset)
|
||||
|
||||
# Move the selected (active) mapping to the front, so that it is checked first.
|
||||
active_mapping = self.data_manager.active_mapping
|
||||
if active_mapping is not None:
|
||||
mappings.remove(active_mapping)
|
||||
mappings.insert(0, active_mapping)
|
||||
|
||||
for mapping in mappings:
|
||||
if not mapping.has_input_defined():
|
||||
# Empty mapping, nothing recorded yet so nothing can be configured,
|
||||
# therefore there isn't anything to validate.
|
||||
continue
|
||||
|
||||
position = mapping.format_name()
|
||||
error_strings = self._get_ui_error_strings(mapping)
|
||||
|
||||
if len(error_strings) == 0:
|
||||
continue
|
||||
|
||||
if len(error_strings) > 1:
|
||||
msg = _('%d Mapping errors at "%s", hover for info') % (
|
||||
len(error_strings),
|
||||
position,
|
||||
)
|
||||
tooltip = "– " + "\n– ".join(error_strings)
|
||||
else:
|
||||
msg = f'"{position}": {error_strings[0]}'
|
||||
tooltip = error_strings[0]
|
||||
|
||||
return msg.replace("\n", " "), tooltip
|
||||
|
||||
return None
|
||||
|
||||
def _publish_mapping_errors_as_status_msg(self, *__) -> None:
|
||||
"""Send mapping ValidationErrors to the MessageBroker."""
|
||||
validation_result = self._format_status_bar_validation_errors()
|
||||
|
||||
if validation_result is None:
|
||||
return
|
||||
|
||||
self.show_status(
|
||||
CTX_MAPPING,
|
||||
validation_result[0],
|
||||
validation_result[1],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def format_error_message(mapping, error_type, error_message: str) -> str:
|
||||
"""Check all the different error messages which are not useful for the user."""
|
||||
# There is no more elegant way of comparing error_type with the base class.
|
||||
# https://github.com/pydantic/pydantic/discussions/5112
|
||||
if (
|
||||
pydantify(MacroButTypeOrCodeSetError) in error_type
|
||||
or pydantify(SymbolAndCodeMismatchError) in error_type
|
||||
) and mapping.input_combination.defines_analog_input:
|
||||
return _(
|
||||
"Remove the macro or key from the macro input field "
|
||||
"when specifying an analog output"
|
||||
)
|
||||
|
||||
if (
|
||||
pydantify(MacroButTypeOrCodeSetError) in error_type
|
||||
or pydantify(SymbolAndCodeMismatchError) in error_type
|
||||
) and not mapping.input_combination.defines_analog_input:
|
||||
return _(
|
||||
"Remove the Analog Output Axis when specifying a macro or key output"
|
||||
)
|
||||
|
||||
if pydantify(MissingOutputAxisError) in error_type:
|
||||
error_message = _(
|
||||
"The input specifies an analog axis, but no output axis is selected."
|
||||
)
|
||||
if mapping.output_symbol is not None:
|
||||
event = [
|
||||
event
|
||||
for event in mapping.input_combination
|
||||
if event.defines_analog_input
|
||||
][0]
|
||||
error_message += _(
|
||||
"\nIf you mean to create a key or macro mapping "
|
||||
"go to the advanced input configuration"
|
||||
' and set a "Trigger Threshold" for '
|
||||
f'"{event.description()}"'
|
||||
)
|
||||
return error_message
|
||||
|
||||
if pydantify(WrongMappingTypeForKeyError) in error_type:
|
||||
error_message = _(
|
||||
"The input specifies a key, but the output type is not "
|
||||
f'"{OutputTypeNames.key_or_macro}".'
|
||||
)
|
||||
|
||||
if mapping.output_type in (EV_ABS, EV_REL):
|
||||
error_message += _(
|
||||
"\nIf you mean to create an analog axis mapping go to the "
|
||||
'advanced input configuration and set an input to "Use as Analog".'
|
||||
)
|
||||
|
||||
return error_message
|
||||
|
||||
if pydantify(MissingMacroOrKeyError) in error_type:
|
||||
return _("Missing macro or key")
|
||||
|
||||
return error_message
|
||||
|
||||
@staticmethod
|
||||
def _get_ui_error_strings(mapping: UIMapping) -> List[str]:
|
||||
"""Get a human readable error message from a mapping error."""
|
||||
validation_error = mapping.get_error()
|
||||
|
||||
if validation_error is None:
|
||||
return []
|
||||
|
||||
formatted_errors = []
|
||||
|
||||
for error in validation_error.errors():
|
||||
if pydantify(OutputSymbolVariantError) in error["type"]:
|
||||
# this is rather internal, when this error appears in the gui, there is
|
||||
# also always another more readable error at the same time that explains
|
||||
# this problem.
|
||||
continue
|
||||
|
||||
error_string = f'"{mapping.format_name()}": '
|
||||
error_message = error["msg"]
|
||||
error_location = error["loc"][0]
|
||||
if error_location != "__root__":
|
||||
error_string += f"{error_location}: "
|
||||
|
||||
# check all the different error messages which are not useful for the user
|
||||
formatted_errors.append(
|
||||
Controller.format_error_message(
|
||||
mapping,
|
||||
error["type"],
|
||||
error_message,
|
||||
)
|
||||
)
|
||||
|
||||
return formatted_errors
|
||||
|
||||
def get_a_preset(self) -> str:
|
||||
"""Attempts to get the newest preset in the current group
|
||||
creates a new preset if that fails."""
|
||||
try:
|
||||
return self.data_manager.get_newest_preset_name()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
self.data_manager.create_preset(self.data_manager.get_available_preset_name())
|
||||
return self.data_manager.get_newest_preset_name()
|
||||
|
||||
def get_a_group(self) -> Optional[str]:
|
||||
"""Attempts to get the group with the newest preset
|
||||
returns any if that fails."""
|
||||
try:
|
||||
return self.data_manager.get_newest_group_key()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
keys = self.data_manager.get_group_keys()
|
||||
return keys[0] if keys else None
|
||||
|
||||
def copy_preset(self):
|
||||
"""Create a copy of the active preset and name it `preset_name copy`."""
|
||||
name = self.data_manager.active_preset.name
|
||||
match = re.search(r" copy *\d*$", name)
|
||||
if match:
|
||||
name = name[: match.start()]
|
||||
|
||||
self.data_manager.copy_preset(
|
||||
self.data_manager.get_available_preset_name(f"{name} copy")
|
||||
)
|
||||
self.message_broker.publish(DoStackSwitch(1))
|
||||
|
||||
def _auto_use_as_analog(self, combination: InputCombination) -> InputCombination:
|
||||
"""If output is analog, set the first fitting input to analog."""
|
||||
if self.data_manager.active_mapping is None:
|
||||
return combination
|
||||
|
||||
if not self.data_manager.active_mapping.is_analog_output():
|
||||
return combination
|
||||
|
||||
if combination.find_analog_input_config():
|
||||
# something is already set to do that
|
||||
return combination
|
||||
|
||||
for i, input_config in enumerate(combination):
|
||||
# find the first analog input and set it to "use as analog"
|
||||
if input_config.type in (EV_ABS, EV_REL):
|
||||
logger.info("Using %s as analog input", input_config)
|
||||
|
||||
# combinations and input_configs are immutable, a new combination
|
||||
# is created to fit the needs instead
|
||||
combination_list = list(combination)
|
||||
combination_list[i] = input_config.modify(analog_threshold=0)
|
||||
new_combination = InputCombination(combination_list)
|
||||
return new_combination
|
||||
|
||||
return combination
|
||||
|
||||
def update_combination(self, combination: InputCombination):
|
||||
"""Update the input_combination of the active mapping."""
|
||||
combination = self._auto_use_as_analog(combination)
|
||||
|
||||
try:
|
||||
self.data_manager.update_mapping(input_combination=combination)
|
||||
self.save()
|
||||
except KeyError:
|
||||
self.show_status(
|
||||
CTX_MAPPING,
|
||||
f'"{combination.beautify()}" already mapped to something else',
|
||||
)
|
||||
return
|
||||
|
||||
if combination.is_problematic():
|
||||
self.show_status(
|
||||
CTX_WARNING,
|
||||
_("ctrl, alt and shift may not combine properly"),
|
||||
_(
|
||||
"Your system might reinterpret combinations with those after they "
|
||||
+ "are injected, and by doing so break them. Play around with the "
|
||||
+ 'advanced "Release Input" toggle.'
|
||||
),
|
||||
)
|
||||
|
||||
def move_input_config_in_combination(
|
||||
self,
|
||||
input_config: InputConfig,
|
||||
direction: Union[Literal["up"], Literal["down"]],
|
||||
):
|
||||
"""Move the active_input_config up or down in the input_combination of the
|
||||
active_mapping."""
|
||||
if (
|
||||
not self.data_manager.active_mapping
|
||||
or len(self.data_manager.active_mapping.input_combination) == 1
|
||||
):
|
||||
return
|
||||
combination: Sequence[InputConfig] = (
|
||||
self.data_manager.active_mapping.input_combination
|
||||
)
|
||||
|
||||
i = combination.index(input_config)
|
||||
if (
|
||||
i + 1 == len(combination)
|
||||
and direction == "down"
|
||||
or i == 0
|
||||
and direction == "up"
|
||||
):
|
||||
return
|
||||
|
||||
if direction == "up":
|
||||
combination = (
|
||||
list(combination[: i - 1])
|
||||
+ [input_config]
|
||||
+ [combination[i - 1]]
|
||||
+ list(combination[i + 1 :])
|
||||
)
|
||||
elif direction == "down":
|
||||
combination = (
|
||||
list(combination[:i])
|
||||
+ [combination[i + 1]]
|
||||
+ [input_config]
|
||||
+ list(combination[i + 2 :])
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"unknown direction: {direction}")
|
||||
self.update_combination(InputCombination(combination))
|
||||
self.load_input_config(input_config)
|
||||
|
||||
def load_input_config(self, input_config: InputConfig):
|
||||
"""Load an InputConfig form the active mapping input combination."""
|
||||
self.data_manager.load_input_config(input_config)
|
||||
|
||||
def update_input_config(self, new_input_config: InputConfig):
|
||||
"""Modify the active input configuration."""
|
||||
try:
|
||||
self.data_manager.update_input_config(new_input_config)
|
||||
except KeyError:
|
||||
# we need to synchronize the gui
|
||||
self.data_manager.publish_mapping()
|
||||
self.data_manager.publish_event()
|
||||
|
||||
def remove_event(self):
|
||||
"""Remove the active InputEvent from the active mapping event combination."""
|
||||
if (
|
||||
not self.data_manager.active_mapping
|
||||
or not self.data_manager.active_input_config
|
||||
):
|
||||
return
|
||||
|
||||
combination = list(self.data_manager.active_mapping.input_combination)
|
||||
combination.remove(self.data_manager.active_input_config)
|
||||
try:
|
||||
self.data_manager.update_mapping(
|
||||
input_combination=InputCombination(combination)
|
||||
)
|
||||
self.load_input_config(combination[0])
|
||||
self.save()
|
||||
except (KeyError, ValueError):
|
||||
# we need to synchronize the gui
|
||||
self.data_manager.publish_mapping()
|
||||
self.data_manager.publish_event()
|
||||
|
||||
def set_event_as_analog(self, analog: bool):
|
||||
"""Use the active event as an analog input."""
|
||||
assert self.data_manager.active_input_config is not None
|
||||
event = self.data_manager.active_input_config
|
||||
|
||||
if event.type != EV_KEY:
|
||||
if analog:
|
||||
try:
|
||||
self.data_manager.update_input_config(
|
||||
event.modify(analog_threshold=0)
|
||||
)
|
||||
self.save()
|
||||
return
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
try_values = {EV_REL: [1, -1], EV_ABS: [10, -10]}
|
||||
for value in try_values[event.type]:
|
||||
try:
|
||||
self.data_manager.update_input_config(
|
||||
event.modify(analog_threshold=value)
|
||||
)
|
||||
self.save()
|
||||
return
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# didn't update successfully
|
||||
# we need to synchronize the gui
|
||||
self.data_manager.publish_mapping()
|
||||
self.data_manager.publish_event()
|
||||
|
||||
def load_groups(self):
|
||||
"""Refresh the groups."""
|
||||
self.data_manager.refresh_groups()
|
||||
|
||||
def load_group(self, group_key: str):
|
||||
"""Load the group and then a preset of that group."""
|
||||
self.data_manager.load_group(group_key)
|
||||
self.load_preset(self.get_a_preset())
|
||||
|
||||
def load_preset(self, name: str):
|
||||
"""Load the preset."""
|
||||
self.data_manager.load_preset(name)
|
||||
# self.load_mapping(...) # not needed because we have on_preset_changed()
|
||||
|
||||
def rename_preset(self, new_name: str):
|
||||
"""Rename the active_preset."""
|
||||
if (
|
||||
not self.data_manager.active_preset
|
||||
or not new_name
|
||||
or new_name == self.data_manager.active_preset.name
|
||||
):
|
||||
return
|
||||
|
||||
new_name = PathUtils.sanitize_path_component(new_name)
|
||||
new_name = self.data_manager.get_available_preset_name(new_name)
|
||||
self.data_manager.rename_preset(new_name)
|
||||
|
||||
def add_preset(self, name: str = DEFAULT_PRESET_NAME):
|
||||
"""Create a new preset called `new preset n`, add it to the active_group."""
|
||||
name = self.data_manager.get_available_preset_name(name)
|
||||
try:
|
||||
self.data_manager.create_preset(name)
|
||||
self.data_manager.load_preset(name)
|
||||
except PermissionError as e:
|
||||
self.show_status(CTX_ERROR, _("Permission denied!"), str(e))
|
||||
|
||||
def delete_preset(self):
|
||||
"""Delete the active_preset from the disc."""
|
||||
|
||||
def f(answer: bool):
|
||||
if answer:
|
||||
self.data_manager.delete_preset()
|
||||
self.data_manager.load_preset(self.get_a_preset())
|
||||
self.message_broker.publish(DoStackSwitch(1))
|
||||
|
||||
if not self.data_manager.active_preset:
|
||||
return
|
||||
msg = (
|
||||
_('Are you sure you want to delete the preset "%s"?')
|
||||
% self.data_manager.active_preset.name
|
||||
)
|
||||
self.message_broker.publish(UserConfirmRequest(msg, f))
|
||||
|
||||
def load_mapping(self, input_combination: InputCombination):
|
||||
"""Load the mapping with the given input_combination form the active_preset."""
|
||||
self.data_manager.load_mapping(input_combination)
|
||||
self.load_input_config(input_combination[0])
|
||||
|
||||
def update_mapping(self, **changes):
|
||||
"""Update the active_mapping with the given keywords and values."""
|
||||
if "mapping_type" in changes.keys():
|
||||
if not (changes := self._change_mapping_type(changes)):
|
||||
# we need to synchronize the gui
|
||||
self.data_manager.publish_mapping()
|
||||
self.data_manager.publish_event()
|
||||
return
|
||||
|
||||
self.data_manager.update_mapping(**changes)
|
||||
self.save()
|
||||
|
||||
def create_mapping(self):
|
||||
"""Create a new empty mapping in the active_preset."""
|
||||
try:
|
||||
self.data_manager.create_mapping()
|
||||
except KeyError:
|
||||
# there is already an empty mapping
|
||||
return
|
||||
|
||||
self.data_manager.load_mapping(combination=InputCombination.empty_combination())
|
||||
self.data_manager.update_mapping(**MAPPING_DEFAULTS)
|
||||
|
||||
def delete_mapping(self):
|
||||
"""Remove the active_mapping form the active_preset."""
|
||||
|
||||
def get_answer(answer: bool):
|
||||
if answer:
|
||||
self.data_manager.delete_mapping()
|
||||
self.save()
|
||||
|
||||
if not self.data_manager.active_mapping:
|
||||
return
|
||||
self.message_broker.publish(
|
||||
UserConfirmRequest(
|
||||
_("Are you sure you want to delete this mapping?"),
|
||||
get_answer,
|
||||
)
|
||||
)
|
||||
|
||||
def set_autoload(self, autoload: bool):
|
||||
"""Set the autoload state for the active_preset and active_group."""
|
||||
self.data_manager.set_autoload(autoload)
|
||||
self.data_manager.refresh_service_config_path()
|
||||
|
||||
def save(self):
|
||||
"""Save all data to the disc."""
|
||||
try:
|
||||
self.data_manager.save()
|
||||
except PermissionError as e:
|
||||
self.show_status(CTX_ERROR, _("Permission denied!"), str(e))
|
||||
|
||||
def start_key_recording(self):
|
||||
"""Record the input of the active_group
|
||||
|
||||
Updates the active_mapping.input_combination with the recorded events.
|
||||
"""
|
||||
state = self.data_manager.get_state()
|
||||
if state == InjectorState.RUNNING or state == InjectorState.STARTING:
|
||||
self.data_manager.stop_combination_recording()
|
||||
self.message_broker.signal(MessageType.recording_finished)
|
||||
self.show_status(CTX_ERROR, _('Use "Stop" to stop before editing'))
|
||||
return
|
||||
|
||||
logger.debug("Recording Keys")
|
||||
|
||||
def on_recording_finished(_):
|
||||
self.message_broker.unsubscribe(on_recording_finished)
|
||||
self.message_broker.unsubscribe(self._on_combination_recorded)
|
||||
self.gui.connect_shortcuts()
|
||||
|
||||
self.gui.disconnect_shortcuts()
|
||||
self.message_broker.subscribe(
|
||||
MessageType.combination_recorded,
|
||||
self._on_combination_recorded,
|
||||
)
|
||||
self.message_broker.subscribe(
|
||||
MessageType.recording_finished, on_recording_finished
|
||||
)
|
||||
self.data_manager.start_combination_recording()
|
||||
|
||||
def stop_key_recording(self):
|
||||
"""Stop recording the input."""
|
||||
logger.debug("Stopping Recording Keys")
|
||||
self.data_manager.stop_combination_recording()
|
||||
|
||||
def start_injecting(self):
|
||||
"""Inject the active_preset for the active_group."""
|
||||
if len(self.data_manager.active_preset) == 0:
|
||||
logger.error(_("Cannot apply empty preset file"))
|
||||
# also helpful for first time use
|
||||
self.show_status(CTX_ERROR, _("You need to add mappings first"))
|
||||
return
|
||||
|
||||
if not self.button_left_warn:
|
||||
if self.data_manager.active_preset.dangerously_mapped_btn_left():
|
||||
self.show_status(
|
||||
CTX_ERROR,
|
||||
"This would disable your click button",
|
||||
"Map a button to BTN_LEFT to avoid this.\n"
|
||||
"To overwrite this warning, press apply again.",
|
||||
)
|
||||
self.button_left_warn = True
|
||||
return
|
||||
|
||||
# todo: warn about unreleased keys
|
||||
self.button_left_warn = False
|
||||
self.message_broker.subscribe(
|
||||
MessageType.injector_state,
|
||||
self.show_injector_result,
|
||||
)
|
||||
self.show_status(CTX_APPLY, _("Starting injection..."))
|
||||
if not self.data_manager.start_injecting():
|
||||
self.message_broker.unsubscribe(self.show_injector_result)
|
||||
self.show_status(
|
||||
CTX_APPLY,
|
||||
_('Failed to apply preset "%s"') % self.data_manager.active_preset.name,
|
||||
)
|
||||
|
||||
def show_injector_result(self, msg: InjectorStateMessage) -> None:
|
||||
"""Show if the injection was successfully started."""
|
||||
self.message_broker.unsubscribe(self.show_injector_result)
|
||||
state = msg.state
|
||||
|
||||
def running() -> None:
|
||||
assert self.data_manager.active_preset is not None
|
||||
msg = _('Applied preset "%s"') % self.data_manager.active_preset.name
|
||||
if self.data_manager.active_preset.dangerously_mapped_btn_left():
|
||||
msg += _(", CTRL + DEL to stop")
|
||||
self.show_status(CTX_APPLY, msg)
|
||||
logger.info(
|
||||
'Group "%s" is currently mapped', self.data_manager.active_group.key
|
||||
)
|
||||
|
||||
def no_grab() -> None:
|
||||
assert self.data_manager.active_preset is not None
|
||||
msg = (
|
||||
_('Failed to apply preset "%s"') % self.data_manager.active_preset.name
|
||||
)
|
||||
tooltip = (
|
||||
"Maybe your preset doesn't contain anything that is sent by the "
|
||||
"device or another device is already grabbing it"
|
||||
)
|
||||
|
||||
# InjectorState.NO_GRAB also happens when all mappings have validation
|
||||
# errors. In that case, we can show something more useful.
|
||||
validation_result = self._format_status_bar_validation_errors()
|
||||
if validation_result is not None:
|
||||
msg = f"{msg}. {validation_result[0]}"
|
||||
tooltip = validation_result[1]
|
||||
|
||||
self.show_status(CTX_ERROR, msg, tooltip)
|
||||
|
||||
assert self.data_manager.active_preset # make mypy happy
|
||||
state_calls: Dict[InjectorState, Callable] = {
|
||||
InjectorState.RUNNING: running,
|
||||
InjectorState.ERROR: partial(
|
||||
self.show_status,
|
||||
CTX_ERROR,
|
||||
_('Error applying preset "%s"') % self.data_manager.active_preset.name,
|
||||
),
|
||||
InjectorState.NO_GRAB: no_grab,
|
||||
InjectorState.UPGRADE_EVDEV: partial(
|
||||
self.show_status,
|
||||
CTX_ERROR,
|
||||
"Upgrade python-evdev",
|
||||
"Your python-evdev version is too old.",
|
||||
),
|
||||
}
|
||||
|
||||
if state in state_calls:
|
||||
state_calls[state]()
|
||||
|
||||
def stop_injecting(self):
|
||||
"""Stop injecting any preset for the active_group."""
|
||||
|
||||
def show_result(msg: InjectorStateMessage):
|
||||
self.message_broker.unsubscribe(show_result)
|
||||
|
||||
if not msg.inactive():
|
||||
# some speculation: there might be unexpected additional status messages
|
||||
# with a different state, or the status is wrong because something in
|
||||
# the long pipeline of status messages is broken.
|
||||
logger.error(
|
||||
"Expected the injection to eventually stop, but got state %s",
|
||||
msg.state,
|
||||
)
|
||||
return
|
||||
|
||||
self.show_status(CTX_APPLY, _("Stopped the injection"))
|
||||
|
||||
try:
|
||||
self.message_broker.subscribe(MessageType.injector_state, show_result)
|
||||
self.data_manager.stop_injecting()
|
||||
except DataManagementError:
|
||||
self.message_broker.unsubscribe(show_result)
|
||||
|
||||
def show_status(
|
||||
self,
|
||||
ctx_id: int,
|
||||
msg: Optional[str] = None,
|
||||
tooltip: Optional[str] = None,
|
||||
):
|
||||
"""Send a status message to the ui to show it in the status-bar."""
|
||||
self.message_broker.publish(StatusData(ctx_id, msg, tooltip))
|
||||
|
||||
def is_empty_mapping(self) -> bool:
|
||||
"""Check if the active_mapping is empty."""
|
||||
return (
|
||||
self.data_manager.active_mapping == UIMapping(**MAPPING_DEFAULTS)
|
||||
or self.data_manager.active_mapping is None
|
||||
)
|
||||
|
||||
def refresh_groups(self):
|
||||
"""Reload the connected devices and send them as a groups message.
|
||||
|
||||
Runs asynchronously.
|
||||
"""
|
||||
self.data_manager.refresh_groups()
|
||||
|
||||
def close(self):
|
||||
"""Safely close the application."""
|
||||
logger.debug("Closing Application")
|
||||
self.save()
|
||||
self.message_broker.signal(MessageType.terminate)
|
||||
logger.debug("Quitting")
|
||||
Gtk.main_quit()
|
||||
|
||||
def set_focus(self, component):
|
||||
"""Focus the given component."""
|
||||
self.gui.window.set_focus(component)
|
||||
|
||||
def _change_mapping_type(self, changes: Dict[str, Any]):
|
||||
"""Query the user to update the mapping in order to change the mapping type."""
|
||||
mapping = self.data_manager.active_mapping
|
||||
|
||||
if mapping is None:
|
||||
return changes
|
||||
|
||||
if changes["mapping_type"] == mapping.mapping_type:
|
||||
return changes
|
||||
|
||||
if changes["mapping_type"] == MappingType.ANALOG.value:
|
||||
msg = _("You are about to change the mapping to analog.")
|
||||
if mapping.output_symbol:
|
||||
msg += _('\nThis will remove "{}" ' "from the text input!").format(
|
||||
mapping.output_symbol
|
||||
)
|
||||
|
||||
if not [
|
||||
input_config
|
||||
for input_config in mapping.input_combination
|
||||
if input_config.defines_analog_input
|
||||
]:
|
||||
# there is no analog input configured, let's try to autoconfigure it
|
||||
inputs: List[InputConfig] = list(mapping.input_combination)
|
||||
for i, input_config in enumerate(inputs):
|
||||
if input_config.type in [EV_ABS, EV_REL]:
|
||||
inputs[i] = input_config.modify(analog_threshold=0)
|
||||
changes["input_combination"] = InputCombination(inputs)
|
||||
msg += _(
|
||||
'\nThe input "{}" will be used as analog input.'
|
||||
).format(input_config.description())
|
||||
break
|
||||
else:
|
||||
# not possible to autoconfigure inform the user
|
||||
msg += _("\nYou need to record an analog input.")
|
||||
|
||||
elif not mapping.output_symbol:
|
||||
return changes
|
||||
|
||||
answer = None
|
||||
|
||||
def get_answer(answer_: bool):
|
||||
nonlocal answer
|
||||
answer = answer_
|
||||
|
||||
self.message_broker.publish(UserConfirmRequest(msg, get_answer))
|
||||
if answer:
|
||||
changes["output_symbol"] = None
|
||||
return changes
|
||||
else:
|
||||
return None
|
||||
|
||||
if changes["mapping_type"] == MappingType.KEY_MACRO.value:
|
||||
try:
|
||||
analog_input = tuple(
|
||||
filter(lambda i: i.defines_analog_input, mapping.input_combination)
|
||||
)[0]
|
||||
except IndexError:
|
||||
changes["output_type"] = None
|
||||
changes["output_code"] = None
|
||||
return changes
|
||||
|
||||
answer = None
|
||||
|
||||
def get_answer(answer_: bool):
|
||||
nonlocal answer
|
||||
answer = answer_
|
||||
|
||||
self.message_broker.publish(
|
||||
UserConfirmRequest(
|
||||
f"You are about to change the mapping to a Key or Macro mapping!\n"
|
||||
f"Go to the advanced input configuration and set a "
|
||||
f'"Trigger Threshold" for "{analog_input.description()}".',
|
||||
get_answer,
|
||||
)
|
||||
)
|
||||
if answer:
|
||||
changes["output_type"] = None
|
||||
changes["output_code"] = None
|
||||
return changes
|
||||
else:
|
||||
return None
|
||||
|
||||
return changes
|
||||
609
inputremapper/gui/data_manager.py
Normal file
609
inputremapper/gui/data_manager.py
Normal file
@ -0,0 +1,609 @@
|
||||
# -*- 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/>.
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Optional, List, Tuple, Set
|
||||
|
||||
from gi.repository import GLib
|
||||
|
||||
from inputremapper.configs.global_config import GlobalConfig
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.mapping import UIMapping, MappingData
|
||||
from inputremapper.configs.paths import PathUtils
|
||||
from inputremapper.configs.preset import Preset
|
||||
from inputremapper.configs.keyboard_layout import KeyboardLayout
|
||||
from inputremapper.daemon import DaemonProxy
|
||||
from inputremapper.exceptions import DataManagementError
|
||||
from inputremapper.groups import _Group
|
||||
from inputremapper.gui.gettext import _
|
||||
from inputremapper.gui.messages.message_broker import (
|
||||
MessageBroker,
|
||||
)
|
||||
from inputremapper.gui.messages.message_data import (
|
||||
UInputsData,
|
||||
GroupData,
|
||||
PresetData,
|
||||
CombinationUpdate,
|
||||
)
|
||||
from inputremapper.gui.reader_client import ReaderClient
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.injector import (
|
||||
InjectorState,
|
||||
InjectorStateMessage,
|
||||
)
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
DEFAULT_PRESET_NAME = _("new preset")
|
||||
|
||||
# useful type aliases
|
||||
Name = str
|
||||
GroupKey = str
|
||||
|
||||
|
||||
class DataManager:
|
||||
"""DataManager provides an interface to create and modify configurations as well
|
||||
as modify the state of the Service.
|
||||
|
||||
Any state changes will be announced via the MessageBroker.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_broker: MessageBroker,
|
||||
config: GlobalConfig,
|
||||
reader_client: ReaderClient,
|
||||
daemon: DaemonProxy,
|
||||
uinputs: GlobalUInputs,
|
||||
keyboard_layout: KeyboardLayout,
|
||||
):
|
||||
self.message_broker = message_broker
|
||||
self._reader_client = reader_client
|
||||
self._daemon = daemon
|
||||
self._uinputs = uinputs
|
||||
self._keyboard_layout = keyboard_layout
|
||||
uinputs.prepare_all()
|
||||
|
||||
self._config = config
|
||||
self._config.load_config()
|
||||
|
||||
self._active_preset: Optional[Preset[UIMapping]] = None
|
||||
self._active_mapping: Optional[UIMapping] = None
|
||||
self._active_input_config: Optional[InputConfig] = None
|
||||
|
||||
def publish_group(self):
|
||||
"""Send active group to the MessageBroker.
|
||||
|
||||
This is internally called whenever the group changes.
|
||||
It is usually not necessary to call this explicitly from
|
||||
outside DataManager.
|
||||
"""
|
||||
self.message_broker.publish(
|
||||
GroupData(self.active_group.key, self.get_preset_names())
|
||||
)
|
||||
|
||||
def publish_preset(self):
|
||||
"""Send active preset to the MessageBroker.
|
||||
|
||||
This is internally called whenever the preset changes.
|
||||
It is usually not necessary to call this explicitly from
|
||||
outside DataManager.
|
||||
"""
|
||||
self.message_broker.publish(
|
||||
PresetData(
|
||||
self.active_preset.name, self.get_mappings(), self.get_autoload()
|
||||
)
|
||||
)
|
||||
|
||||
def publish_mapping(self):
|
||||
"""Send active mapping to the MessageBroker
|
||||
|
||||
This is internally called whenever the mapping changes.
|
||||
It is usually not necessary to call this explicitly from
|
||||
outside DataManager.
|
||||
"""
|
||||
if self.active_mapping:
|
||||
self.message_broker.publish(self.active_mapping.get_bus_message())
|
||||
|
||||
def publish_event(self):
|
||||
"""Send active event to the MessageBroker.
|
||||
|
||||
This is internally called whenever the event changes.
|
||||
It is usually not necessary to call this explicitly from
|
||||
outside DataManager
|
||||
"""
|
||||
if self.active_input_config:
|
||||
assert self.active_input_config in self.active_mapping.input_combination
|
||||
self.message_broker.publish(self.active_input_config)
|
||||
|
||||
def publish_uinputs(self):
|
||||
"""Send the "uinputs" message on the MessageBroker."""
|
||||
self.message_broker.publish(
|
||||
UInputsData(
|
||||
{
|
||||
name: uinput.capabilities()
|
||||
for name, uinput in self._uinputs.devices.items()
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def publish_groups(self):
|
||||
"""Publish the "groups" message on the MessageBroker."""
|
||||
self._reader_client.publish_groups()
|
||||
|
||||
def publish_injector_state(self):
|
||||
"""Publish the "injector_state" message for the active_group."""
|
||||
if not self.active_group:
|
||||
return
|
||||
|
||||
self.message_broker.publish(InjectorStateMessage(self.get_state()))
|
||||
|
||||
@property
|
||||
def active_group(self) -> Optional[_Group]:
|
||||
"""The currently loaded group."""
|
||||
return self._reader_client.group
|
||||
|
||||
@property
|
||||
def active_preset(self) -> Optional[Preset[UIMapping]]:
|
||||
"""The currently loaded preset."""
|
||||
return self._active_preset
|
||||
|
||||
@property
|
||||
def active_mapping(self) -> Optional[UIMapping]:
|
||||
"""The currently loaded mapping."""
|
||||
return self._active_mapping
|
||||
|
||||
@property
|
||||
def active_input_config(self) -> Optional[InputConfig]:
|
||||
"""The currently loaded event."""
|
||||
return self._active_input_config
|
||||
|
||||
def get_group_keys(self) -> Tuple[GroupKey, ...]:
|
||||
"""Get all group keys (plugged devices)."""
|
||||
return tuple(group.key for group in self._reader_client.groups.get_groups())
|
||||
|
||||
def get_preset_names(self) -> Tuple[Name, ...]:
|
||||
"""Get all preset names for active_group and current user sorted by age."""
|
||||
if not self.active_group:
|
||||
raise DataManagementError("Cannot find presets: Group is not set")
|
||||
device_folder = PathUtils.get_preset_path(self.active_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)
|
||||
]
|
||||
# the highest timestamp to the front
|
||||
presets.reverse()
|
||||
return tuple(presets)
|
||||
|
||||
def get_mappings(self) -> Optional[List[MappingData]]:
|
||||
"""All mappings from the active_preset."""
|
||||
if not self._active_preset:
|
||||
return None
|
||||
|
||||
return [mapping.get_bus_message() for mapping in self._active_preset]
|
||||
|
||||
def get_autoload(self) -> bool:
|
||||
"""The autoload status of the active_preset."""
|
||||
if not self.active_preset or not self.active_group:
|
||||
return False
|
||||
return self._config.is_autoloaded(
|
||||
self.active_group.key, self.active_preset.name
|
||||
)
|
||||
|
||||
def set_autoload(self, status: bool):
|
||||
"""Set the autoload status of the active_preset.
|
||||
|
||||
Will send "preset" message on the MessageBroker.
|
||||
"""
|
||||
if not self.active_preset or not self.active_group:
|
||||
raise DataManagementError("Cannot set autoload status: Preset is not set")
|
||||
|
||||
if status:
|
||||
self._config.set_autoload_preset(
|
||||
self.active_group.key, self.active_preset.name
|
||||
)
|
||||
elif self.get_autoload():
|
||||
self._config.set_autoload_preset(self.active_group.key, None)
|
||||
|
||||
self.publish_preset()
|
||||
|
||||
def get_newest_group_key(self) -> GroupKey:
|
||||
"""group_key of the group with the most recently modified preset."""
|
||||
paths = []
|
||||
pattern = os.path.join(
|
||||
glob.escape(PathUtils.get_preset_path()),
|
||||
"*/*.json",
|
||||
)
|
||||
for path in glob.glob(pattern):
|
||||
if self._reader_client.groups.find(key=PathUtils.split_all(path)[-2]):
|
||||
paths.append((path, os.path.getmtime(path)))
|
||||
|
||||
if not paths:
|
||||
raise FileNotFoundError()
|
||||
|
||||
path, _ = max(paths, key=lambda x: x[1])
|
||||
return PathUtils.split_all(path)[-2]
|
||||
|
||||
def get_newest_preset_name(self) -> Name:
|
||||
"""Preset name of the most recently modified preset in the active group."""
|
||||
if not self.active_group:
|
||||
raise DataManagementError("Cannot find newest preset: Group is not set")
|
||||
|
||||
pattern = os.path.join(
|
||||
glob.escape(PathUtils.get_preset_path(self.active_group.name)),
|
||||
"*.json",
|
||||
)
|
||||
paths = [(path, os.path.getmtime(path)) for path in glob.glob(pattern)]
|
||||
if not paths:
|
||||
raise FileNotFoundError()
|
||||
|
||||
path, _ = max(paths, key=lambda x: x[1])
|
||||
return os.path.split(path)[-1].split(".")[0]
|
||||
|
||||
def get_available_preset_name(self, name=DEFAULT_PRESET_NAME) -> Name:
|
||||
"""The first available preset in the active group."""
|
||||
if not self.active_group:
|
||||
raise DataManagementError("Unable find preset name. Group is not set")
|
||||
|
||||
name = name.strip()
|
||||
|
||||
# find a name that is not already taken
|
||||
if os.path.exists(PathUtils.get_preset_path(self.active_group.name, name)):
|
||||
# if there already is a trailing number, increment it instead of
|
||||
# adding another one
|
||||
match = re.match(r"^(.+) (\d+)$", name)
|
||||
if match:
|
||||
name = match[1]
|
||||
i = int(match[2]) + 1
|
||||
else:
|
||||
i = 2
|
||||
|
||||
while os.path.exists(
|
||||
PathUtils.get_preset_path(self.active_group.name, f"{name} {i}")
|
||||
):
|
||||
i += 1
|
||||
|
||||
return f"{name} {i}"
|
||||
|
||||
return name
|
||||
|
||||
def load_group(self, group_key: str):
|
||||
"""Load a group. will publish "groups" and "injector_state" messages.
|
||||
|
||||
This will render the active_mapping and active_preset invalid.
|
||||
"""
|
||||
if group_key not in self.get_group_keys():
|
||||
raise DataManagementError("Unable to load non existing group")
|
||||
|
||||
logger.info('Loading group "%s"', group_key)
|
||||
|
||||
self._active_input_config = None
|
||||
self._active_mapping = None
|
||||
self._active_preset = None
|
||||
group = self._reader_client.groups.find(key=group_key)
|
||||
self._reader_client.set_group(group)
|
||||
self.publish_group()
|
||||
self.publish_injector_state()
|
||||
|
||||
def load_preset(self, name: str):
|
||||
"""Load a preset. Will send "preset" message on the MessageBroker.
|
||||
|
||||
This will render the active_mapping invalid.
|
||||
"""
|
||||
if not self.active_group:
|
||||
raise DataManagementError("Unable to load preset. Group is not set")
|
||||
|
||||
logger.info('Loading preset "%s"', name)
|
||||
|
||||
preset_path = PathUtils.get_preset_path(self.active_group.name, name)
|
||||
preset = Preset(preset_path, mapping_factory=UIMapping)
|
||||
preset.load()
|
||||
self._active_input_config = None
|
||||
self._active_mapping = None
|
||||
self._active_preset = preset
|
||||
self.publish_preset()
|
||||
|
||||
def load_mapping(self, combination: InputCombination):
|
||||
"""Load a mapping. Will send "mapping" message on the MessageBroker."""
|
||||
if not self._active_preset:
|
||||
raise DataManagementError("Unable to load mapping. Preset is not set")
|
||||
|
||||
mapping = self._active_preset.get_mapping(combination)
|
||||
if not mapping:
|
||||
msg = (
|
||||
f"the mapping with {combination = } does not "
|
||||
f"exist in the {self._active_preset.path}"
|
||||
)
|
||||
logger.error(msg)
|
||||
raise KeyError(msg)
|
||||
|
||||
self._active_input_config = None
|
||||
self._active_mapping = mapping
|
||||
self.publish_mapping()
|
||||
|
||||
def load_input_config(self, input_config: InputConfig):
|
||||
"""Load a InputConfig from the combination in the active mapping.
|
||||
|
||||
Will send "event" message on the MessageBroker,
|
||||
"""
|
||||
if not self.active_mapping:
|
||||
raise DataManagementError("Unable to load event. Mapping is not set")
|
||||
if input_config not in self.active_mapping.input_combination:
|
||||
raise ValueError(
|
||||
f"{input_config} is not member of active_mapping.input_combination: "
|
||||
f"{self.active_mapping.input_combination}"
|
||||
)
|
||||
self._active_input_config = input_config
|
||||
self.publish_event()
|
||||
|
||||
def rename_preset(self, new_name: str):
|
||||
"""Rename the current preset and move the correct file.
|
||||
|
||||
Will send "group" and then "preset" message on the MessageBroker
|
||||
"""
|
||||
if not self.active_preset or not self.active_group:
|
||||
raise DataManagementError("Unable rename preset: Preset is not set")
|
||||
|
||||
if self.active_preset.path == PathUtils.get_preset_path(
|
||||
self.active_group.name, new_name
|
||||
):
|
||||
return
|
||||
|
||||
old_path = self.active_preset.path
|
||||
assert old_path is not None
|
||||
old_name = os.path.basename(old_path).split(".")[0]
|
||||
new_path = PathUtils.get_preset_path(self.active_group.name, new_name)
|
||||
if os.path.exists(new_path):
|
||||
raise ValueError(
|
||||
f"cannot rename {old_name} to " f"{new_name}, preset already exists"
|
||||
)
|
||||
|
||||
logger.info('Moving "%s" to "%s"', old_path, new_path)
|
||||
os.rename(old_path, new_path)
|
||||
now = time.time()
|
||||
os.utime(new_path, (now, now))
|
||||
|
||||
if self._config.is_autoloaded(self.active_group.key, old_name):
|
||||
self._config.set_autoload_preset(self.active_group.key, new_name)
|
||||
|
||||
self.active_preset.path = PathUtils.get_preset_path(
|
||||
self.active_group.name, new_name
|
||||
)
|
||||
self.publish_group()
|
||||
self.publish_preset()
|
||||
|
||||
def copy_preset(self, name: str):
|
||||
"""Copy the current preset to the given name.
|
||||
|
||||
Will send "group" and "preset" message to the MessageBroker and load the copy
|
||||
"""
|
||||
# todo: Do we want to load the copy here? or is this up to the controller?
|
||||
if not self.active_preset or not self.active_group:
|
||||
raise DataManagementError("Unable to copy preset: Preset is not set")
|
||||
|
||||
if self.active_preset.path == PathUtils.get_preset_path(
|
||||
self.active_group.name, name
|
||||
):
|
||||
return
|
||||
|
||||
if name in self.get_preset_names():
|
||||
raise ValueError(f"a preset with the name {name} already exits")
|
||||
|
||||
new_path = PathUtils.get_preset_path(self.active_group.name, name)
|
||||
logger.info('Copy "%s" to "%s"', self.active_preset.path, new_path)
|
||||
self.active_preset.path = new_path
|
||||
self.save()
|
||||
self.publish_group()
|
||||
self.publish_preset()
|
||||
|
||||
def create_preset(self, name: str):
|
||||
"""Create empty preset in the active_group.
|
||||
|
||||
Will send "group" message to the MessageBroker
|
||||
"""
|
||||
if not self.active_group:
|
||||
raise DataManagementError("Unable to add preset. Group is not set")
|
||||
|
||||
path = PathUtils.get_preset_path(self.active_group.name, name)
|
||||
if os.path.exists(path):
|
||||
raise DataManagementError("Unable to add preset. Preset exists")
|
||||
|
||||
Preset(path).save()
|
||||
self.publish_group()
|
||||
|
||||
def delete_preset(self):
|
||||
"""Delete the active preset.
|
||||
|
||||
Will send "group" message to the MessageBroker
|
||||
this will invalidate the active mapping,
|
||||
"""
|
||||
preset_path = self._active_preset.path
|
||||
logger.info('Removing "%s"', preset_path)
|
||||
os.remove(preset_path)
|
||||
self._active_mapping = None
|
||||
self._active_preset = None
|
||||
self.publish_group()
|
||||
|
||||
def update_mapping(self, **kwargs):
|
||||
"""Update the active mapping with the given keywords and values.
|
||||
|
||||
Will send "mapping" message to the MessageBroker. In case of a new
|
||||
input_combination. This will first send a "combination_update" message.
|
||||
"""
|
||||
if not self._active_mapping:
|
||||
raise DataManagementError("Cannot modify Mapping: Mapping is not set")
|
||||
|
||||
if symbol := kwargs.get("output_symbol"):
|
||||
kwargs["output_symbol"] = self._keyboard_layout.correct_case(symbol)
|
||||
|
||||
combination = self.active_mapping.input_combination
|
||||
for key, value in kwargs.items():
|
||||
setattr(self._active_mapping, key, value)
|
||||
|
||||
if (
|
||||
"input_combination" in kwargs
|
||||
and combination != self.active_mapping.input_combination
|
||||
):
|
||||
self._active_input_config = None
|
||||
self.message_broker.publish(
|
||||
CombinationUpdate(combination, self._active_mapping.input_combination)
|
||||
)
|
||||
|
||||
if "mapping_type" in kwargs:
|
||||
# mapping_type must be the last update because it is automatically updated
|
||||
# by a validation function
|
||||
self._active_mapping.mapping_type = kwargs["mapping_type"]
|
||||
|
||||
self.publish_mapping()
|
||||
|
||||
def update_input_config(self, new_input_config: InputConfig):
|
||||
"""Update the active input configuration.
|
||||
|
||||
Will send "combination_update", "mapping" and "event" messages to the
|
||||
MessageBroker (in that order)
|
||||
"""
|
||||
if not self.active_mapping or not self.active_input_config:
|
||||
raise DataManagementError("Cannot modify event: Event is not set")
|
||||
|
||||
combination = list(self.active_mapping.input_combination)
|
||||
combination[combination.index(self.active_input_config)] = new_input_config
|
||||
self.update_mapping(input_combination=InputCombination(combination))
|
||||
self._active_input_config = new_input_config
|
||||
self.publish_event()
|
||||
|
||||
def create_mapping(self):
|
||||
"""Create empty mapping in the active preset.
|
||||
|
||||
Will send "preset" message to the MessageBroker
|
||||
"""
|
||||
if not self._active_preset:
|
||||
raise DataManagementError("Cannot create mapping: Preset is not set")
|
||||
self._active_preset.add(UIMapping())
|
||||
self.publish_preset()
|
||||
|
||||
def delete_mapping(self):
|
||||
"""Delete the active mapping.
|
||||
|
||||
Will send "preset" message to the MessageBroker
|
||||
"""
|
||||
if not self._active_mapping:
|
||||
raise DataManagementError(
|
||||
"cannot delete active mapping: active mapping is not set"
|
||||
)
|
||||
|
||||
self._active_preset.remove(self._active_mapping.input_combination)
|
||||
self._active_mapping = None
|
||||
self.publish_preset()
|
||||
|
||||
def save(self):
|
||||
"""Save the active preset."""
|
||||
if self._active_preset:
|
||||
self._active_preset.save()
|
||||
|
||||
def refresh_groups(self):
|
||||
"""Refresh the groups (plugged devices).
|
||||
|
||||
Should send "groups" message to MessageBroker this will not happen immediately
|
||||
because the system might take a bit until the groups are available
|
||||
"""
|
||||
self._reader_client.refresh_groups()
|
||||
|
||||
def start_combination_recording(self):
|
||||
"""Record user input.
|
||||
|
||||
Will send "combination_recorded" messages as new input arrives.
|
||||
Will eventually send a "recording_finished" message.
|
||||
"""
|
||||
self._reader_client.start_recorder()
|
||||
|
||||
def stop_combination_recording(self):
|
||||
"""Stop recording user input.
|
||||
|
||||
Will send a recording_finished signal if a recording is running.
|
||||
"""
|
||||
self._reader_client.stop_recorder()
|
||||
|
||||
def stop_injecting(self) -> None:
|
||||
"""Stop injecting for the active group.
|
||||
|
||||
Will send "injector_state" message once the injector has stopped."""
|
||||
if not self.active_group:
|
||||
raise DataManagementError("Cannot stop injection: Group is not set")
|
||||
self._daemon.stop_injecting(self.active_group.key)
|
||||
self.do_when_injector_state(
|
||||
{InjectorState.STOPPED}, self.publish_injector_state
|
||||
)
|
||||
|
||||
def start_injecting(self) -> bool:
|
||||
"""Start injecting the active preset for the active group.
|
||||
|
||||
returns if the startup was successfully initialized.
|
||||
Will send "injector_state" message once the startup is complete.
|
||||
"""
|
||||
if not self.active_preset or not self.active_group:
|
||||
raise DataManagementError("Cannot start injection: Preset is not set")
|
||||
|
||||
self._daemon.set_config_dir(self._config.get_dir())
|
||||
assert self.active_preset.name is not None
|
||||
if self._daemon.start_injecting(self.active_group.key, self.active_preset.name):
|
||||
self.do_when_injector_state(
|
||||
{
|
||||
InjectorState.RUNNING,
|
||||
InjectorState.ERROR,
|
||||
InjectorState.NO_GRAB,
|
||||
InjectorState.UPGRADE_EVDEV,
|
||||
},
|
||||
self.publish_injector_state,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_state(self) -> InjectorState:
|
||||
"""The state of the injector."""
|
||||
if not self.active_group:
|
||||
raise DataManagementError("Cannot read state: Group is not set")
|
||||
return self._daemon.get_state(self.active_group.key)
|
||||
|
||||
def refresh_service_config_path(self):
|
||||
"""Tell the service to refresh its config path."""
|
||||
self._daemon.set_config_dir(self._config.get_dir())
|
||||
|
||||
def do_when_injector_state(self, states: Set[InjectorState], callback):
|
||||
"""Run callback once the injector state is one of states."""
|
||||
start = time.time()
|
||||
|
||||
def do():
|
||||
if time.time() - start > 3:
|
||||
# something went wrong, there should have been a state long ago.
|
||||
# the timeout prevents tons of GLib.timeouts to run forever, especially
|
||||
# after spamming the "Stop" button.
|
||||
logger.error("Timed out while waiting for injector state %s", states)
|
||||
return False
|
||||
|
||||
if self.get_state() in states:
|
||||
callback()
|
||||
return False
|
||||
return True
|
||||
|
||||
GLib.timeout_add(100, do)
|
||||
125
inputremapper/gui/forward_to_ui_handler.py
Normal file
125
inputremapper/gui/forward_to_ui_handler.py
Normal file
@ -0,0 +1,125 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2023 sezanzeb <proxima@hip70890b.de>
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
"""Process that sends stuff to the GUI.
|
||||
|
||||
It should be started via input-remapper-control and pkexec.
|
||||
|
||||
GUIs should not run as root
|
||||
https://wiki.archlinux.org/index.php/Running_GUI_applications_as_root
|
||||
|
||||
The service shouldn't do that even though it has root rights, because that
|
||||
would enable key-loggers to just ask input-remapper for all user-input.
|
||||
|
||||
Instead, the ReaderService is used, which will be stopped when the gui closes.
|
||||
|
||||
Whereas for the reader-service to start a password is needed and it stops whe
|
||||
the ui closes.
|
||||
|
||||
This uses the backend injection.event_reader and mapping_handlers to process all the
|
||||
different input-events into simple on/off events and sends them to the gui.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import EV_ABS
|
||||
|
||||
from inputremapper.configs.input_config import (
|
||||
DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.ipc.pipe import Pipe
|
||||
from inputremapper.logging.logger import logger
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import MappingHandler
|
||||
from inputremapper.injection.mapping_handlers.abs_util import calculate_trigger_point
|
||||
|
||||
# received by the reader-service
|
||||
CMD_TERMINATE = "terminate"
|
||||
CMD_STOP_READING = "stop-reading"
|
||||
CMD_REFRESH_GROUPS = "refresh_groups"
|
||||
|
||||
# sent by the reader-service to the reader
|
||||
MSG_GROUPS = "groups"
|
||||
MSG_EVENT = "event"
|
||||
MSG_STATUS = "status"
|
||||
|
||||
|
||||
class ForwardToUIHandler(MappingHandler):
|
||||
"""Implements the MappingHandler protocol. Sends all events into the pipe."""
|
||||
|
||||
def __init__(self, pipe: Pipe):
|
||||
self.pipe = pipe
|
||||
self._last_event = InputEvent.from_tuple((99, 99, 99))
|
||||
|
||||
def notify(
|
||||
self,
|
||||
event: InputEvent,
|
||||
source: evdev.InputDevice,
|
||||
suppress: bool = False,
|
||||
) -> bool:
|
||||
"""Filter duplicates and send into the pipe."""
|
||||
if event == self._last_event:
|
||||
return True
|
||||
|
||||
# These defaults work with EV_KEY and EV_REL
|
||||
pressed = False if event.value == 0 else True
|
||||
direction = 1 if event.value >= 0 else -1
|
||||
|
||||
# Because joysticks aren't as precise, they wiggle and their value might not be
|
||||
# centered around 0, they need special treatment
|
||||
if event.type == EV_ABS:
|
||||
threshold, mid_point = calculate_trigger_point(
|
||||
event,
|
||||
DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE,
|
||||
source,
|
||||
)
|
||||
|
||||
# If within 30% (into each direction) of the mid_point, count as released
|
||||
# A large threshold makes it significantly easier to not accidentally
|
||||
# record both ABS_X and ABS_Y.
|
||||
if abs(event.value - mid_point) < threshold:
|
||||
pressed = False
|
||||
|
||||
if event.value < mid_point:
|
||||
direction = -1
|
||||
|
||||
self._last_event = event
|
||||
|
||||
logger.debug("Sending %s to frontend", event)
|
||||
self.pipe.send(
|
||||
{
|
||||
"type": MSG_EVENT,
|
||||
"message": {
|
||||
"sec": event.sec,
|
||||
"usec": event.usec,
|
||||
"type": event.type,
|
||||
"code": event.code,
|
||||
"value": event.value,
|
||||
"pressed": pressed,
|
||||
"direction": direction,
|
||||
"origin_hash": event.origin_hash,
|
||||
},
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
def reset(self):
|
||||
pass
|
||||
33
inputremapper/gui/gettext.py
Normal file
33
inputremapper/gui/gettext.py
Normal file
@ -0,0 +1,33 @@
|
||||
# -*- 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/>.
|
||||
|
||||
import gettext
|
||||
import locale
|
||||
import os.path
|
||||
|
||||
from inputremapper.configs.data import get_data_path
|
||||
|
||||
APP_NAME = "input-remapper"
|
||||
LOCALE_DIR = os.path.join(get_data_path(), "lang")
|
||||
|
||||
locale.bindtextdomain(APP_NAME, LOCALE_DIR)
|
||||
locale.textdomain(APP_NAME)
|
||||
|
||||
translate = gettext.translation(APP_NAME, LOCALE_DIR, fallback=True)
|
||||
_ = translate.gettext
|
||||
0
inputremapper/gui/messages/__init__.py
Normal file
0
inputremapper/gui/messages/__init__.py
Normal file
120
inputremapper/gui/messages/message_broker.py
Normal file
120
inputremapper/gui/messages/message_broker.py
Normal file
@ -0,0 +1,120 @@
|
||||
# -*- 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/>.
|
||||
|
||||
import os.path
|
||||
import re
|
||||
import traceback
|
||||
from collections import defaultdict, deque
|
||||
from typing import (
|
||||
Callable,
|
||||
Dict,
|
||||
Set,
|
||||
Protocol,
|
||||
Tuple,
|
||||
Deque,
|
||||
Any,
|
||||
)
|
||||
|
||||
from inputremapper.gui.messages.message_types import MessageType
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
|
||||
class Message(Protocol):
|
||||
"""The protocol any message must follow to be sent with the MessageBroker."""
|
||||
|
||||
@property
|
||||
def message_type(self) -> MessageType: ...
|
||||
|
||||
|
||||
# useful type aliases
|
||||
MessageListener = Callable[[Any], None]
|
||||
|
||||
|
||||
class MessageBroker:
|
||||
shorten_path = re.compile(r"inputremapper/")
|
||||
|
||||
def __init__(self):
|
||||
self._listeners: Dict[MessageType, Set[MessageListener]] = defaultdict(set)
|
||||
self._messages: Deque[Tuple[Message, str, int]] = deque()
|
||||
self._publishing = False
|
||||
|
||||
def publish(self, data: Message):
|
||||
"""Schedule a massage to be sent.
|
||||
The message will be sent after all currently pending messages are sent."""
|
||||
self._messages.append((data, *self.get_caller()))
|
||||
self._publish_all()
|
||||
|
||||
def signal(self, signal: MessageType):
|
||||
"""Send a signal without any data payload."""
|
||||
# This is different from calling self.publish because self.get_caller()
|
||||
# looks back at the current stack 3 frames
|
||||
self._messages.append((Signal(signal), *self.get_caller()))
|
||||
self._publish_all()
|
||||
|
||||
def _publish(self, data: Message, file: str, line: int):
|
||||
logger.debug(
|
||||
"from %s:%d: Signal=%s: %s", file, line, data.message_type.name, data
|
||||
)
|
||||
for listener in self._listeners[data.message_type].copy():
|
||||
listener(data)
|
||||
|
||||
def _publish_all(self):
|
||||
"""Send all scheduled messages in order."""
|
||||
if self._publishing:
|
||||
# don't run this twice, so we not mess up the order
|
||||
return
|
||||
|
||||
self._publishing = True
|
||||
try:
|
||||
while self._messages:
|
||||
self._publish(*self._messages.popleft())
|
||||
finally:
|
||||
self._publishing = False
|
||||
|
||||
def subscribe(self, massage_type: MessageType, listener: MessageListener):
|
||||
"""Attach a listener to an event."""
|
||||
logger.debug("adding new Listener for %s: %s", massage_type, listener)
|
||||
self._listeners[massage_type].add(listener)
|
||||
return self
|
||||
|
||||
@staticmethod
|
||||
def get_caller(position: int = 3) -> Tuple[str, int]:
|
||||
"""Extract a file and line from current stack and format for logging."""
|
||||
tb = traceback.extract_stack(limit=position)[0]
|
||||
return os.path.basename(tb.filename), tb.lineno or 0
|
||||
|
||||
def unsubscribe(self, listener: MessageListener) -> None:
|
||||
for listeners in self._listeners.values():
|
||||
try:
|
||||
listeners.remove(listener)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
||||
class Signal:
|
||||
"""Send a Message without any associated data over the MassageBus."""
|
||||
|
||||
def __init__(self, message_type: MessageType):
|
||||
self.message_type: MessageType = message_type
|
||||
|
||||
def __str__(self):
|
||||
return f"Signal: {self.message_type}"
|
||||
|
||||
def __eq__(self, other: Any):
|
||||
return type(self) is type(other) and self.message_type == other.message_type
|
||||
126
inputremapper/gui/messages/message_data.py
Normal file
126
inputremapper/gui/messages/message_data.py
Normal file
@ -0,0 +1,126 @@
|
||||
# -*- 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/>.
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Tuple, Optional, Callable
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination
|
||||
from inputremapper.configs.mapping import MappingData
|
||||
from inputremapper.gui.messages.message_types import (
|
||||
MessageType,
|
||||
Name,
|
||||
Capabilities,
|
||||
Key,
|
||||
DeviceTypes,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UInputsData:
|
||||
message_type = MessageType.uinputs
|
||||
uinputs: Dict[Name, Capabilities]
|
||||
|
||||
def __str__(self):
|
||||
string = f"{self.__class__.__name__}(uinputs={self.uinputs})"
|
||||
|
||||
# find all sequences of comma+space separated numbers, and shorten them
|
||||
# to the first and last number
|
||||
all_matches = list(re.finditer(r"(\d+, )+", string))
|
||||
all_matches.reverse()
|
||||
for match in all_matches:
|
||||
start = match.start()
|
||||
end = match.end()
|
||||
start += string[start:].find(",") + 2
|
||||
if start == end:
|
||||
continue
|
||||
string = f"{string[:start]}... {string[end:]}"
|
||||
|
||||
return string
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GroupsData:
|
||||
"""Message containing all available groups and their device types."""
|
||||
|
||||
message_type = MessageType.groups
|
||||
groups: Dict[Key, DeviceTypes]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GroupData:
|
||||
"""Message with the active group and available presets for the group."""
|
||||
|
||||
message_type = MessageType.group
|
||||
group_key: str
|
||||
presets: Tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PresetData:
|
||||
"""Message with the active preset name and mapping names/combinations."""
|
||||
|
||||
message_type = MessageType.preset
|
||||
name: Optional[Name]
|
||||
mappings: Optional[Tuple[MappingData, ...]]
|
||||
autoload: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StatusData:
|
||||
"""Message with the strings and id for the status bar."""
|
||||
|
||||
message_type = MessageType.status_msg
|
||||
ctx_id: int
|
||||
msg: Optional[str] = None
|
||||
tooltip: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CombinationRecorded:
|
||||
"""Message with the latest recoded combination."""
|
||||
|
||||
message_type = MessageType.combination_recorded
|
||||
combination: "InputCombination"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CombinationUpdate:
|
||||
"""Message with the old and new combination (hash for a mapping) when it changed."""
|
||||
|
||||
message_type = MessageType.combination_update
|
||||
old_combination: "InputCombination"
|
||||
new_combination: "InputCombination"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserConfirmRequest:
|
||||
"""Message for requesting a user response (confirm/cancel) from the gui."""
|
||||
|
||||
message_type = MessageType.user_confirm_request
|
||||
msg: str
|
||||
respond: Callable[[bool], None] = lambda _: None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DoStackSwitch:
|
||||
"""Command the stack to switch to a different page."""
|
||||
|
||||
message_type = MessageType.do_stack_switch
|
||||
page_index: int
|
||||
60
inputremapper/gui/messages/message_types.py
Normal file
60
inputremapper/gui/messages/message_types.py
Normal file
@ -0,0 +1,60 @@
|
||||
# -*- 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/>.
|
||||
|
||||
from enum import Enum
|
||||
from typing import Dict, List
|
||||
|
||||
from inputremapper.groups import DeviceType
|
||||
|
||||
# useful type aliases
|
||||
Capabilities = Dict[int, List]
|
||||
Name = str
|
||||
Key = str
|
||||
DeviceTypes = List[DeviceType]
|
||||
|
||||
|
||||
class MessageType(Enum):
|
||||
reset_gui = "reset_gui"
|
||||
terminate = "terminate"
|
||||
init = "init"
|
||||
|
||||
uinputs = "uinputs"
|
||||
groups = "groups"
|
||||
group = "group"
|
||||
preset = "preset"
|
||||
mapping = "mapping"
|
||||
selected_event = "selected_event"
|
||||
combination_recorded = "combination_recorded"
|
||||
|
||||
# only the reader_client should send those messages:
|
||||
recording_started = "recording_started"
|
||||
recording_finished = "recording_finished"
|
||||
|
||||
combination_update = "combination_update"
|
||||
status_msg = "status_msg"
|
||||
injector_state = "injector_state"
|
||||
|
||||
gui_focus_request = "gui_focus_request"
|
||||
user_confirm_request = "user_confirm_request"
|
||||
|
||||
do_stack_switch = "do_stack_switch"
|
||||
|
||||
# for unit tests:
|
||||
test1 = "test1"
|
||||
test2 = "test2"
|
||||
306
inputremapper/gui/reader_client.py
Normal file
306
inputremapper/gui/reader_client.py
Normal file
@ -0,0 +1,306 @@
|
||||
# -*- 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/>.
|
||||
|
||||
|
||||
"""Talking to the ReaderService that has root permissions.
|
||||
|
||||
see gui.reader_service.ReaderService
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Optional, List, Generator, Dict, Set
|
||||
|
||||
import evdev
|
||||
from gi.repository import GLib
|
||||
|
||||
from inputremapper.configs.input_config import (
|
||||
InputCombination,
|
||||
DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE,
|
||||
)
|
||||
from inputremapper.groups import _Groups, _Group
|
||||
from inputremapper.gui.gettext import _
|
||||
from inputremapper.gui.messages.message_broker import MessageBroker
|
||||
from inputremapper.gui.messages.message_data import (
|
||||
GroupsData,
|
||||
CombinationRecorded,
|
||||
StatusData,
|
||||
)
|
||||
from inputremapper.gui.messages.message_types import MessageType
|
||||
from inputremapper.gui.reader_service import (
|
||||
MSG_EVENT,
|
||||
MSG_GROUPS,
|
||||
CMD_TERMINATE,
|
||||
CMD_REFRESH_GROUPS,
|
||||
CMD_STOP_READING,
|
||||
ReaderService,
|
||||
)
|
||||
from inputremapper.gui.utils import CTX_ERROR
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.ipc.pipe import Pipe
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
BLACKLISTED_EVENTS = [(1, evdev.ecodes.BTN_TOOL_DOUBLETAP)]
|
||||
RecordingGenerator = Generator[None, InputEvent, None]
|
||||
|
||||
|
||||
class ReaderClient:
|
||||
"""Processes events from the reader-service for the GUI to use.
|
||||
|
||||
Does not serve any purpose for the injection service.
|
||||
|
||||
When a button was pressed, the newest keycode can be obtained from this object.
|
||||
GTK has get_key for keyboard keys, but Reader also has knowledge of buttons like
|
||||
the middle-mouse button.
|
||||
"""
|
||||
|
||||
# how long to wait for the reader-service at most
|
||||
_timeout: int = 5
|
||||
|
||||
def __init__(self, message_broker: MessageBroker, groups: _Groups):
|
||||
self.groups = groups
|
||||
self.message_broker = message_broker
|
||||
|
||||
self.group: Optional[_Group] = None
|
||||
|
||||
self._recording_generator: Optional[RecordingGenerator] = None
|
||||
self._results_pipe, self._commands_pipe = self.connect()
|
||||
|
||||
self.attach_to_events()
|
||||
|
||||
self._read_timeout = GLib.timeout_add(30, self._read)
|
||||
|
||||
def ensure_reader_service_running(self):
|
||||
if ReaderService.is_running():
|
||||
return
|
||||
|
||||
logger.info("ReaderService not running anymore, restarting")
|
||||
ReaderService.pkexec_reader_service()
|
||||
|
||||
# wait until the ReaderService is up
|
||||
|
||||
# wait no more than:
|
||||
polling_period = 0.01
|
||||
# this will make the gui non-responsive for 0.4s or something. The pkexec
|
||||
# password prompt will appear, so the user understands that the lag has to
|
||||
# be connected to the authentication. I would actually prefer the frozen gui
|
||||
# over a reactive one here, because the short lag shows that stuff is going on
|
||||
# behind the scenes.
|
||||
for __ in range(int(self._timeout / polling_period)):
|
||||
if self._results_pipe.poll():
|
||||
logger.info("ReaderService started")
|
||||
break
|
||||
|
||||
time.sleep(polling_period)
|
||||
else:
|
||||
msg = "The reader-service did not start"
|
||||
logger.error(msg)
|
||||
self.message_broker.publish(StatusData(CTX_ERROR, _(msg)))
|
||||
|
||||
def _send_command(self, command: str):
|
||||
"""Send a command to the ReaderService."""
|
||||
if command not in [CMD_TERMINATE, CMD_STOP_READING]:
|
||||
self.ensure_reader_service_running()
|
||||
|
||||
logger.debug('Sending "%s" to ReaderService', command)
|
||||
self._commands_pipe.send(command)
|
||||
|
||||
def connect(self):
|
||||
"""Connect to the reader-service."""
|
||||
results_pipe = Pipe(ReaderService.get_pipe_paths()[0])
|
||||
commands_pipe = Pipe(ReaderService.get_pipe_paths()[1])
|
||||
return results_pipe, commands_pipe
|
||||
|
||||
def attach_to_events(self):
|
||||
"""Connect listeners to event_reader."""
|
||||
self.message_broker.subscribe(
|
||||
MessageType.terminate,
|
||||
lambda _: self.terminate(),
|
||||
)
|
||||
|
||||
def _read(self):
|
||||
"""Read the messages from the reader-service and handle them."""
|
||||
while self._results_pipe.poll():
|
||||
message = self._results_pipe.recv()
|
||||
|
||||
logger.debug("received %s", message)
|
||||
|
||||
message_type = message["type"]
|
||||
message_body = message["message"]
|
||||
|
||||
if message_type == MSG_GROUPS:
|
||||
self._update_groups(message_body)
|
||||
|
||||
if message_type == MSG_EVENT:
|
||||
# update the generator
|
||||
try:
|
||||
if self._recording_generator is not None:
|
||||
self._recording_generator.send(InputEvent(**message_body))
|
||||
else:
|
||||
# the ReaderService should only send events while the gui
|
||||
# is recording, so this is unexpected.
|
||||
logger.error("Got event, but recorder is not running.")
|
||||
except StopIteration:
|
||||
# the _recording_generator returned
|
||||
logger.debug("Recorder finished.")
|
||||
self.stop_recorder()
|
||||
break
|
||||
|
||||
return True
|
||||
|
||||
def start_recorder(self) -> None:
|
||||
"""Record user input."""
|
||||
if self.group is None:
|
||||
logger.error("No group set")
|
||||
return
|
||||
|
||||
logger.debug("Starting recorder.")
|
||||
self._send_command(self.group.key)
|
||||
|
||||
self._recording_generator = self._recorder()
|
||||
next(self._recording_generator)
|
||||
|
||||
self.message_broker.signal(MessageType.recording_started)
|
||||
|
||||
def stop_recorder(self) -> None:
|
||||
"""Stop recording the input.
|
||||
|
||||
Will send recording_finished signals.
|
||||
"""
|
||||
logger.debug("Stopping recorder.")
|
||||
self._send_command(CMD_STOP_READING)
|
||||
|
||||
if self._recording_generator:
|
||||
self._recording_generator.close()
|
||||
self._recording_generator = None
|
||||
else:
|
||||
# this would be unexpected. but this is not critical enough to
|
||||
# show to the user without debug logs
|
||||
logger.debug("No recording generator existed")
|
||||
|
||||
self.message_broker.signal(MessageType.recording_finished)
|
||||
|
||||
@staticmethod
|
||||
def _input_event_to_config(event: InputEvent):
|
||||
# This used to default to event.value, which was broken for joysticks because
|
||||
# it resulted in a very low value (I tihnk 1). Which I think was because we
|
||||
# overwrote the event.value with 1 in the handlers.
|
||||
|
||||
# For joysticks the default uses DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE
|
||||
# in percent now. This value would break moues movements, because mice don't
|
||||
# have a maximum value that we could use for percent calculations.
|
||||
|
||||
# So for EV_REL we just use 1 or -1 to keep it working the same way it used to
|
||||
# work.
|
||||
analog_threshold = event.direction
|
||||
|
||||
if event.type == evdev.ecodes.EV_ABS:
|
||||
analog_threshold = event.direction * DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE
|
||||
|
||||
return {
|
||||
"type": event.type,
|
||||
"code": event.code,
|
||||
"analog_threshold": analog_threshold,
|
||||
"origin_hash": event.origin_hash,
|
||||
}
|
||||
|
||||
def _recorder(self) -> RecordingGenerator:
|
||||
"""Generator which receives InputEvents.
|
||||
|
||||
It accumulates them into EventCombinations and sends those on the
|
||||
message_broker. It will stop once all keys or inputs are released.
|
||||
"""
|
||||
active: Set = set()
|
||||
accumulator: List[InputEvent] = []
|
||||
while True:
|
||||
event: InputEvent = yield
|
||||
if event.type_and_code in BLACKLISTED_EVENTS:
|
||||
continue
|
||||
|
||||
if not event.is_pressed():
|
||||
try:
|
||||
active.remove(event.input_match_hash)
|
||||
except KeyError:
|
||||
# we haven't seen this before probably a key got released which
|
||||
# was pressed before we started recording. ignore it.
|
||||
continue
|
||||
|
||||
if not active:
|
||||
# all previously recorded events are released
|
||||
return
|
||||
continue
|
||||
|
||||
active.add(event.input_match_hash)
|
||||
accu_input_hashes = [e.input_match_hash for e in accumulator]
|
||||
if event.input_match_hash in accu_input_hashes and event not in accumulator:
|
||||
# the value has changed but the event is already in the accumulator
|
||||
# update the event
|
||||
i = accu_input_hashes.index(event.input_match_hash)
|
||||
accumulator[i] = event
|
||||
self.message_broker.publish(
|
||||
CombinationRecorded(
|
||||
InputCombination(map(self._input_event_to_config, accumulator))
|
||||
)
|
||||
)
|
||||
|
||||
if event not in accumulator:
|
||||
accumulator.append(event)
|
||||
self.message_broker.publish(
|
||||
CombinationRecorded(
|
||||
InputCombination(map(self._input_event_to_config, accumulator))
|
||||
)
|
||||
)
|
||||
|
||||
def set_group(self, group: Optional[_Group]):
|
||||
"""Set the group for which input events should be read later."""
|
||||
# TODO load the active_group from the controller instead?
|
||||
self.group = group
|
||||
|
||||
def terminate(self):
|
||||
"""Stop reading keycodes for good."""
|
||||
self._send_command(CMD_TERMINATE)
|
||||
|
||||
self.stop_recorder()
|
||||
|
||||
if self._read_timeout is not None:
|
||||
GLib.source_remove(self._read_timeout)
|
||||
self._read_timeout = None
|
||||
|
||||
while self._results_pipe.poll():
|
||||
self._results_pipe.recv()
|
||||
|
||||
def refresh_groups(self):
|
||||
"""Ask the ReaderService for new device groups."""
|
||||
self._send_command(CMD_REFRESH_GROUPS)
|
||||
|
||||
def publish_groups(self):
|
||||
"""Announce all known groups."""
|
||||
groups: Dict[str, List[str]] = {
|
||||
group.key: group.types or [] for group in self.groups.get_groups()
|
||||
}
|
||||
self.message_broker.publish(GroupsData(groups))
|
||||
|
||||
def _update_groups(self, dump: str):
|
||||
if dump != self.groups.dumps():
|
||||
self.groups.loads(dump)
|
||||
logger.debug("Received %d devices", len(self.groups.get_groups()))
|
||||
self._groups_updated = True
|
||||
|
||||
# send this even if the groups did not change, as the user expects the ui
|
||||
# to respond in some form
|
||||
self.publish_groups()
|
||||
420
inputremapper/gui/reader_service.py
Normal file
420
inputremapper/gui/reader_service.py
Normal file
@ -0,0 +1,420 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2023 sezanzeb <proxima@hip70890b.de>
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
"""Process that sends stuff to the GUI.
|
||||
|
||||
It should be started via input-remapper-control and pkexec.
|
||||
|
||||
GUIs should not run as root
|
||||
https://wiki.archlinux.org/index.php/Running_GUI_applications_as_root
|
||||
|
||||
The service shouldn't do that even though it has root rights, because that
|
||||
would enable key-loggers to just ask input-remapper for all user-input.
|
||||
|
||||
Instead, the ReaderService is used, which will be stopped when the gui closes.
|
||||
|
||||
Whereas for the reader-service to start a password is needed and it stops whe
|
||||
the ui closes.
|
||||
|
||||
This uses the backend injection.event_reader and mapping_handlers to process all the
|
||||
different input-events into simple on/off events and sends them to the gui.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Set, List, Tuple
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import EV_KEY, EV_ABS, EV_REL, REL_HWHEEL, REL_WHEEL
|
||||
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.configs.mapping import Mapping, KnownUinput
|
||||
from inputremapper.groups import _Groups, _Group
|
||||
from inputremapper.injection.event_reader import EventReader
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||
NotifyCallback,
|
||||
MappingHandler,
|
||||
)
|
||||
from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.ipc.pipe import Pipe
|
||||
from inputremapper.logging.logger import logger
|
||||
from inputremapper.user import UserUtils
|
||||
from inputremapper.utils import get_device_hash
|
||||
from inputremapper.gui.forward_to_ui_handler import ForwardToUIHandler
|
||||
|
||||
# received by the reader-service
|
||||
CMD_TERMINATE = "terminate"
|
||||
CMD_STOP_READING = "stop-reading"
|
||||
CMD_REFRESH_GROUPS = "refresh_groups"
|
||||
|
||||
# sent by the reader-service to the reader
|
||||
MSG_GROUPS = "groups"
|
||||
MSG_EVENT = "event"
|
||||
MSG_STATUS = "status"
|
||||
|
||||
RELEASE_TIMEOUT = 0.3
|
||||
|
||||
|
||||
class ReaderService:
|
||||
"""Service that only reads events and is supposed to run as root.
|
||||
|
||||
Sends device information and keycodes to the GUI.
|
||||
|
||||
Commands are either numbers for generic commands,
|
||||
or strings to start listening on a specific device.
|
||||
"""
|
||||
|
||||
# the speed threshold at which relative axis are considered moving
|
||||
# and will be sent as "pressed" to the frontend.
|
||||
# We want to allow some mouse movement before we record it as an input
|
||||
rel_xy_speed = defaultdict(lambda: 3)
|
||||
# wheel events usually don't produce values higher than 1
|
||||
rel_xy_speed[REL_WHEEL] = 1
|
||||
rel_xy_speed[REL_HWHEEL] = 1
|
||||
|
||||
# Polkit won't ask for another password if the pid stays the same or something, and
|
||||
# if the previous request was no more than 5 minutes ago. see
|
||||
# https://unix.stackexchange.com/a/458260.
|
||||
# If the user does something after 6 minutes they will get a prompt already if the
|
||||
# reader timed out already, which sounds annoying. Instead, I'd rather have the
|
||||
# password prompt appear at most every 15 minutes.
|
||||
_maximum_lifetime: int = 60 * 15
|
||||
_timeout_tolerance: int = 60
|
||||
|
||||
def __init__(self, groups: _Groups, global_uinputs: GlobalUInputs) -> None:
|
||||
"""Construct the reader-service and initialize its communication pipes."""
|
||||
self._start_time = time.time()
|
||||
self.groups = groups
|
||||
self.global_uinputs = global_uinputs
|
||||
self._results_pipe = Pipe(self.get_pipe_paths()[0])
|
||||
self._commands_pipe = Pipe(self.get_pipe_paths()[1])
|
||||
self._pipe = multiprocessing.Pipe()
|
||||
|
||||
self._tasks: Set[asyncio.Task] = set()
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
self._results_pipe.send({"type": MSG_STATUS, "message": "ready"})
|
||||
|
||||
@staticmethod
|
||||
def get_pipe_paths() -> Tuple[str, str]:
|
||||
"""Get the path where the pipe can be found."""
|
||||
return (
|
||||
f"/tmp/input-remapper-{UserUtils.home}/reader-results",
|
||||
f"/tmp/input-remapper-{UserUtils.home}/reader-commands",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def pipes_exist() -> bool:
|
||||
# Just checking for one of the 4 files (results, commands both read and write)
|
||||
# should be enough I guess.
|
||||
path = f"{ReaderService.get_pipe_paths()[0]}r"
|
||||
# Use os.path.exists, not lexists or islink, because broken links are bad.
|
||||
# New pipes and symlinks need to be made.
|
||||
return os.path.exists(path)
|
||||
|
||||
@staticmethod
|
||||
def is_running() -> bool:
|
||||
"""Check if the reader-service is running."""
|
||||
try:
|
||||
subprocess.check_output(["pgrep", "-f", "input-remapper-reader-service"])
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def pkexec_reader_service() -> None:
|
||||
"""Start reader-service via pkexec to run in the background."""
|
||||
debug = " -d" if logger.level <= logging.DEBUG else ""
|
||||
cmd = f"pkexec input-remapper-control --command start-reader-service{debug}"
|
||||
|
||||
logger.debug("Running `%s`", cmd)
|
||||
exit_code = os.system(cmd)
|
||||
|
||||
if exit_code != 0:
|
||||
raise Exception(f"Failed to pkexec the reader-service, code {exit_code}")
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Start doing stuff."""
|
||||
# the reader will check for new commands later, once it is running
|
||||
# it keeps running for one device or another.
|
||||
logger.debug("Discovering initial groups")
|
||||
self.groups.refresh()
|
||||
self._send_groups()
|
||||
await asyncio.gather(
|
||||
self._read_commands(),
|
||||
self._timeout(),
|
||||
self._stop_if_pipes_broken(),
|
||||
)
|
||||
|
||||
def _send_groups(self) -> None:
|
||||
"""Send the groups to the gui."""
|
||||
logger.debug("Sending groups")
|
||||
self._results_pipe.send({"type": MSG_GROUPS, "message": self.groups.dumps()})
|
||||
|
||||
async def _timeout(self) -> None:
|
||||
"""Stop automatically after some time."""
|
||||
# Prevents a permanent hole for key-loggers to exist, in case the gui crashes.
|
||||
# If the ReaderService stops even though the gui needs it, it needs to restart
|
||||
# it. This makes it also more comfortable to have debug mode running during
|
||||
# development, because it won't keep writing inputs containing passwords and
|
||||
# such to the terminal forever.
|
||||
|
||||
await asyncio.sleep(self._maximum_lifetime)
|
||||
|
||||
# if it is currently reading, wait a bit longer for the gui to complete
|
||||
# what it is doing.
|
||||
if self._is_reading():
|
||||
logger.debug("Waiting a bit longer for the gui to finish reading")
|
||||
|
||||
for _ in range(self._timeout_tolerance):
|
||||
if not self._is_reading():
|
||||
# once reading completes, it should terminate right away
|
||||
break
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
logger.debug("Maximum life-span reached, terminating")
|
||||
sys.exit(1)
|
||||
|
||||
async def _read_commands(self) -> None:
|
||||
"""Handle all unread commands.
|
||||
this will run until it receives CMD_TERMINATE
|
||||
"""
|
||||
logger.debug("Waiting for commands")
|
||||
async for cmd in self._commands_pipe:
|
||||
logger.debug('Received command "%s"', cmd)
|
||||
|
||||
if cmd == CMD_TERMINATE:
|
||||
await self._stop_reading()
|
||||
logger.debug("Terminating")
|
||||
sys.exit(0)
|
||||
|
||||
if cmd == CMD_REFRESH_GROUPS:
|
||||
self.groups.refresh()
|
||||
self._send_groups()
|
||||
continue
|
||||
|
||||
if cmd == CMD_STOP_READING:
|
||||
await self._stop_reading()
|
||||
continue
|
||||
|
||||
group = self.groups.find(key=cmd)
|
||||
if group is None:
|
||||
# this will block for a bit maybe we want to do this async?
|
||||
self.groups.refresh()
|
||||
group = self.groups.find(key=cmd)
|
||||
|
||||
if group is not None:
|
||||
await self._stop_reading()
|
||||
self._start_reading(group)
|
||||
continue
|
||||
|
||||
logger.error('Received unknown command "%s"', cmd)
|
||||
|
||||
async def _stop_if_pipes_broken(self) -> None:
|
||||
# The GUI probably exited, and failed to tell the reader-service to stop.
|
||||
# Pipes are owned by the GUI process, because the non-privileged GUI process
|
||||
# needs to be able to read them. Therefore, they are gone.
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
if not self.pipes_exist():
|
||||
await self._stop_reading()
|
||||
logger.debug("Pipes broken, exiting")
|
||||
sys.exit(13)
|
||||
|
||||
def _is_reading(self) -> bool:
|
||||
"""Check if the ReaderService is currently sending events to the GUI."""
|
||||
return len(self._tasks) > 0
|
||||
|
||||
def _start_reading(self, group: _Group) -> None:
|
||||
"""Find all devices of that group, filter interesting ones and send the events
|
||||
to the gui."""
|
||||
sources = []
|
||||
for path in group.paths:
|
||||
try:
|
||||
device = evdev.InputDevice(path)
|
||||
except (FileNotFoundError, OSError):
|
||||
logger.error('Could not find "%s"', path)
|
||||
return None
|
||||
|
||||
capabilities = device.capabilities(absinfo=False)
|
||||
if (
|
||||
EV_KEY in capabilities
|
||||
or EV_ABS in capabilities
|
||||
or EV_REL in capabilities
|
||||
):
|
||||
sources.append(device)
|
||||
|
||||
context = self._create_event_pipeline(sources)
|
||||
# create the event reader and start it
|
||||
for device in sources:
|
||||
reader = EventReader(context, device, self._stop_event)
|
||||
self._tasks.add(asyncio.create_task(reader.run()))
|
||||
|
||||
async def _stop_reading(self) -> None:
|
||||
"""Stop the running event_reader."""
|
||||
self._stop_event.set()
|
||||
if self._tasks:
|
||||
await asyncio.gather(*self._tasks)
|
||||
self._tasks = set()
|
||||
self._stop_event.clear()
|
||||
|
||||
def _create_event_pipeline(self, sources: List[evdev.InputDevice]) -> ContextDummy:
|
||||
"""Create a custom event pipeline for each event code in the capabilities.
|
||||
|
||||
Instead of sending the events to an uinput they will be sent to the frontend.
|
||||
"""
|
||||
context_dummy = ContextDummy()
|
||||
# create a context for each source
|
||||
for device in sources:
|
||||
device_hash = get_device_hash(device)
|
||||
capabilities = device.capabilities(absinfo=False)
|
||||
|
||||
for ev_code in capabilities.get(EV_KEY) or ():
|
||||
input_config = InputConfig(
|
||||
type=EV_KEY,
|
||||
code=ev_code,
|
||||
origin_hash=device_hash,
|
||||
)
|
||||
context_dummy.add_handler(
|
||||
input_config,
|
||||
ForwardToUIHandler(self._results_pipe),
|
||||
)
|
||||
|
||||
for ev_code in capabilities.get(EV_ABS) or ():
|
||||
# positive direction
|
||||
input_config = InputConfig(
|
||||
type=EV_ABS,
|
||||
code=ev_code,
|
||||
analog_threshold=30,
|
||||
origin_hash=device_hash,
|
||||
)
|
||||
mapping = Mapping(
|
||||
input_combination=InputCombination([input_config]),
|
||||
target_uinput=KnownUinput.KEYBOARD,
|
||||
output_symbol="KEY_A",
|
||||
)
|
||||
handler: MappingHandler = AbsToBtnHandler(
|
||||
InputCombination([input_config]),
|
||||
mapping,
|
||||
self.global_uinputs,
|
||||
)
|
||||
handler.set_sub_handler(ForwardToUIHandler(self._results_pipe))
|
||||
context_dummy.add_handler(input_config, handler)
|
||||
|
||||
# negative direction
|
||||
input_config = input_config.modify(analog_threshold=-30)
|
||||
mapping = Mapping(
|
||||
input_combination=InputCombination([input_config]),
|
||||
target_uinput=KnownUinput.KEYBOARD,
|
||||
output_symbol="KEY_A",
|
||||
)
|
||||
handler = AbsToBtnHandler(
|
||||
InputCombination([input_config]),
|
||||
mapping,
|
||||
self.global_uinputs,
|
||||
)
|
||||
handler.set_sub_handler(ForwardToUIHandler(self._results_pipe))
|
||||
context_dummy.add_handler(input_config, handler)
|
||||
|
||||
for ev_code in capabilities.get(EV_REL) or ():
|
||||
# positive direction
|
||||
input_config = InputConfig(
|
||||
type=EV_REL,
|
||||
code=ev_code,
|
||||
analog_threshold=self.rel_xy_speed[ev_code],
|
||||
origin_hash=device_hash,
|
||||
)
|
||||
mapping = Mapping(
|
||||
input_combination=InputCombination([input_config]),
|
||||
target_uinput=KnownUinput.KEYBOARD,
|
||||
output_symbol="KEY_A",
|
||||
release_timeout=RELEASE_TIMEOUT,
|
||||
force_release_timeout=True,
|
||||
)
|
||||
handler = RelToBtnHandler(
|
||||
InputCombination([input_config]),
|
||||
mapping,
|
||||
self.global_uinputs,
|
||||
)
|
||||
handler.set_sub_handler(ForwardToUIHandler(self._results_pipe))
|
||||
context_dummy.add_handler(input_config, handler)
|
||||
|
||||
# negative direction
|
||||
input_config = input_config.modify(
|
||||
analog_threshold=-self.rel_xy_speed[ev_code]
|
||||
)
|
||||
mapping = Mapping(
|
||||
input_combination=InputCombination([input_config]),
|
||||
target_uinput=KnownUinput.KEYBOARD,
|
||||
output_symbol="KEY_A",
|
||||
release_timeout=RELEASE_TIMEOUT,
|
||||
force_release_timeout=True,
|
||||
)
|
||||
handler = RelToBtnHandler(
|
||||
InputCombination([input_config]),
|
||||
mapping,
|
||||
self.global_uinputs,
|
||||
)
|
||||
handler.set_sub_handler(ForwardToUIHandler(self._results_pipe))
|
||||
context_dummy.add_handler(input_config, handler)
|
||||
|
||||
return context_dummy
|
||||
|
||||
|
||||
class ForwardDummy(evdev.UInput):
|
||||
# You may add more attributes of evdev.UInput here for compatibility
|
||||
name: str = "forward-dummy"
|
||||
|
||||
@staticmethod
|
||||
def write(*_):
|
||||
pass
|
||||
|
||||
|
||||
class ContextDummy:
|
||||
"""Used for the reader so that no events are actually written to any uinput."""
|
||||
|
||||
def __init__(self):
|
||||
self.listeners = set()
|
||||
self._notify_callbacks = defaultdict(list)
|
||||
self.forward_dummy = ForwardDummy()
|
||||
|
||||
def add_handler(self, input_config: InputConfig, handler: MappingHandler):
|
||||
self._notify_callbacks[input_config.input_match_hash].append(handler.notify)
|
||||
|
||||
def get_notify_callbacks(self, input_event: InputEvent) -> List[NotifyCallback]:
|
||||
return self._notify_callbacks[input_event.input_match_hash]
|
||||
|
||||
def reset(self):
|
||||
pass
|
||||
|
||||
def get_forward_uinput(self, origin_hash) -> evdev.UInput:
|
||||
"""Don't actually write anything."""
|
||||
return self.forward_dummy
|
||||
417
inputremapper/gui/user_interface.py
Normal file
417
inputremapper/gui/user_interface.py
Normal file
@ -0,0 +1,417 @@
|
||||
# -*- 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.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"),
|
||||
)
|
||||
|
||||
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()
|
||||
272
inputremapper/gui/utils.py
Normal file
272
inputremapper/gui/utils.py
Normal file
@ -0,0 +1,272 @@
|
||||
# -*- 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/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Callable, Dict, Optional
|
||||
|
||||
from gi.repository import Gtk, GLib, Gdk
|
||||
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
# status ctx ids
|
||||
|
||||
CTX_SAVE = 0
|
||||
CTX_APPLY = 1
|
||||
CTX_KEYCODE = 2
|
||||
CTX_ERROR = 3
|
||||
CTX_WARNING = 4
|
||||
CTX_MAPPING = 5
|
||||
|
||||
|
||||
@dataclass()
|
||||
class DebounceInfo:
|
||||
# constant after register:
|
||||
function: Optional[Callable]
|
||||
other: object
|
||||
key: int
|
||||
|
||||
# can change when called again:
|
||||
args: list
|
||||
kwargs: dict
|
||||
glib_timeout: Optional[int]
|
||||
|
||||
|
||||
class DebounceManager:
|
||||
"""Stops all debounced functions if needed."""
|
||||
|
||||
debounce_infos: Dict[int, DebounceInfo] = {}
|
||||
|
||||
def _register(self, other, function):
|
||||
debounce_info = DebounceInfo(
|
||||
function=function,
|
||||
glib_timeout=None,
|
||||
other=other,
|
||||
args=[],
|
||||
kwargs={},
|
||||
key=self._get_key(other, function),
|
||||
)
|
||||
key = self._get_key(other, function)
|
||||
self.debounce_infos[key] = debounce_info
|
||||
return debounce_info
|
||||
|
||||
def get(self, other: object, function: Callable) -> Optional[DebounceInfo]:
|
||||
"""Find the debounce_info that matches the given callable."""
|
||||
key = self._get_key(other, function)
|
||||
return self.debounce_infos.get(key)
|
||||
|
||||
def _get_key(self, other, function):
|
||||
return f"{id(other)},{function.__name__}"
|
||||
|
||||
def debounce(self, other, function, timeout_ms, *args, **kwargs):
|
||||
"""Call this function with the given args later."""
|
||||
debounce_info = self.get(other, function)
|
||||
if debounce_info is None:
|
||||
debounce_info = self._register(other, function)
|
||||
|
||||
debounce_info.args = args
|
||||
debounce_info.kwargs = kwargs
|
||||
|
||||
glib_timeout = debounce_info.glib_timeout
|
||||
if glib_timeout is not None:
|
||||
GLib.source_remove(glib_timeout)
|
||||
|
||||
def run():
|
||||
self.stop(other, function)
|
||||
return function(other, *args, **kwargs)
|
||||
|
||||
debounce_info.glib_timeout = GLib.timeout_add(
|
||||
timeout_ms,
|
||||
lambda: run(),
|
||||
)
|
||||
|
||||
def stop(self, other: object, function: Callable):
|
||||
"""Stop the current debounce timeout of this function and don't call it.
|
||||
|
||||
New calls to that function will be debounced again.
|
||||
"""
|
||||
debounce_info = self.get(other, function)
|
||||
if debounce_info is None:
|
||||
logger.debug("Tried to stop function that is not currently scheduled")
|
||||
return
|
||||
|
||||
if debounce_info.glib_timeout is not None:
|
||||
GLib.source_remove(debounce_info.glib_timeout)
|
||||
debounce_info.glib_timeout = None
|
||||
|
||||
def stop_all(self):
|
||||
"""No debounced function should be called anymore after this.
|
||||
|
||||
New calls to that function will be debounced again.
|
||||
"""
|
||||
for debounce_info in self.debounce_infos.values():
|
||||
self.stop(debounce_info.other, debounce_info.function)
|
||||
|
||||
def run_all_now(self):
|
||||
"""Don't wait any longer."""
|
||||
for debounce_info in self.debounce_infos.values():
|
||||
if debounce_info.glib_timeout is None:
|
||||
# nothing is currently waiting for this function to be called
|
||||
continue
|
||||
|
||||
self.stop(debounce_info.other, debounce_info.function)
|
||||
try:
|
||||
logger.warning(
|
||||
'Running "%s" now without waiting',
|
||||
debounce_info.function.__name__,
|
||||
)
|
||||
debounce_info.function(
|
||||
debounce_info.other,
|
||||
*debounce_info.args,
|
||||
**debounce_info.kwargs,
|
||||
)
|
||||
except Exception as exception:
|
||||
# if individual functions fails, continue calling the others.
|
||||
# also, don't raise this because there is nowhere this exception
|
||||
# could be caught in a useful way
|
||||
logger.error(exception)
|
||||
|
||||
|
||||
def debounce(timeout):
|
||||
"""Debounce a method call to improve performance.
|
||||
|
||||
Calling this with a millisecond value creates the decorator, so use something like
|
||||
|
||||
@debounce(50)
|
||||
def function(self):
|
||||
...
|
||||
|
||||
In tests, run_all_now can be used to avoid waiting to speed them up.
|
||||
"""
|
||||
# the outside `debounce` function is needed to obtain the millisecond value
|
||||
|
||||
def decorator(function):
|
||||
# the regular decorator.
|
||||
# @decorator
|
||||
# def foo():
|
||||
# ...
|
||||
def wrapped(self, *args, **kwargs):
|
||||
# this is the function that will actually be called
|
||||
debounce_manager.debounce(self, function, timeout, *args, **kwargs)
|
||||
|
||||
wrapped.__name__ = function.__name__
|
||||
|
||||
return wrapped
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
debounce_manager = DebounceManager()
|
||||
|
||||
|
||||
class HandlerDisabled:
|
||||
"""Safely modify a widget without causing handlers to be called.
|
||||
|
||||
Use in a `with` statement.
|
||||
"""
|
||||
|
||||
def __init__(self, widget: Gtk.Widget, handler: Callable):
|
||||
self.widget = widget
|
||||
self.handler = handler
|
||||
|
||||
def __enter__(self):
|
||||
try:
|
||||
self.widget.handler_block_by_func(self.handler)
|
||||
except TypeError as error:
|
||||
# if nothing is connected to the given signal, it is not critical
|
||||
# at all
|
||||
logger.warning('HandlerDisabled entry failed: "%s"', error)
|
||||
|
||||
def __exit__(self, *_):
|
||||
try:
|
||||
self.widget.handler_unblock_by_func(self.handler)
|
||||
except TypeError as error:
|
||||
logger.warning('HandlerDisabled exit failed: "%s"', error)
|
||||
|
||||
|
||||
def gtk_iteration(iterations=0):
|
||||
"""Iterate while events are pending."""
|
||||
while Gtk.events_pending():
|
||||
Gtk.main_iteration()
|
||||
for _ in range(iterations):
|
||||
time.sleep(0.002)
|
||||
while Gtk.events_pending():
|
||||
Gtk.main_iteration()
|
||||
|
||||
|
||||
class Colors:
|
||||
"""Looks up colors from the GTK theme.
|
||||
|
||||
Defaults to libadwaita-light theme colors if the lookup fails.
|
||||
"""
|
||||
|
||||
fallback_accent = Gdk.RGBA(0.21, 0.52, 0.89, 1)
|
||||
fallback_background = Gdk.RGBA(0.98, 0.98, 0.98, 1)
|
||||
fallback_base = Gdk.RGBA(1, 1, 1, 1)
|
||||
fallback_border = Gdk.RGBA(0.87, 0.87, 0.87, 1)
|
||||
fallback_font = Gdk.RGBA(0.20, 0.20, 0.20, 1)
|
||||
|
||||
@staticmethod
|
||||
def get_color(names: List[str], fallback: Gdk.RGBA) -> Gdk.RGBA:
|
||||
"""Get theme colors. Provide multiple names for fallback purposes."""
|
||||
for name in names:
|
||||
found, color = Gtk.StyleContext().lookup_color(name)
|
||||
if found:
|
||||
return color
|
||||
|
||||
return fallback
|
||||
|
||||
@staticmethod
|
||||
def get_accent_color() -> Gdk.RGBA:
|
||||
"""Look up the accent color from the current theme."""
|
||||
return Colors.get_color(
|
||||
["accent_bg_color", "theme_selected_bg_color"],
|
||||
Colors.fallback_accent,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_background_color() -> Gdk.RGBA:
|
||||
"""Look up the background-color from the current theme."""
|
||||
return Colors.get_color(
|
||||
["theme_bg_color"],
|
||||
Colors.fallback_background,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_base_color() -> Gdk.RGBA:
|
||||
"""Look up the base-color from the current theme."""
|
||||
return Colors.get_color(
|
||||
["theme_base_color"],
|
||||
Colors.fallback_base,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_border_color() -> Gdk.RGBA:
|
||||
"""Look up the border from the current theme."""
|
||||
return Colors.get_color(["borders"], Colors.fallback_border)
|
||||
|
||||
@staticmethod
|
||||
def get_font_color() -> Gdk.RGBA:
|
||||
"""Look up the border from the current theme."""
|
||||
return Colors.get_color(
|
||||
["theme_fg_color"],
|
||||
Colors.fallback_font,
|
||||
)
|
||||
Reference in New Issue
Block a user