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:
2026-06-18 00:47:12 +02:00
parent 1d790a78fb
commit 0ab77d2a64
257 changed files with 51859 additions and 0 deletions

View 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")

View 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,
[],
)

View File

View 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))

View 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)

File diff suppressed because it is too large Load Diff

View 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)

View File

@ -0,0 +1,3 @@
class OutputTypeNames:
analog_axis = "Analog Axis"
key_or_macro = "Key or Macro"

View 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)

View 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

View 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)

View 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

View 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

View File

View 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

View 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

View 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"

View 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()

View 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

View 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
View 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,
)