#109 New mapping editor with multiline input and improved autocompletion
parent
76c3cadcfa
commit
47bcefa7f3
@ -0,0 +1,407 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.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/>.
|
||||
|
||||
|
||||
"""Autocompletion for the editor."""
|
||||
|
||||
|
||||
import re
|
||||
|
||||
from gi.repository import Gdk, Gtk, GLib, GObject, GtkSource
|
||||
|
||||
from inputremapper.system_mapping import system_mapping
|
||||
from inputremapper.injection.macros.parse import (
|
||||
FUNCTIONS,
|
||||
get_macro_argument_names,
|
||||
remove_comments,
|
||||
)
|
||||
from inputremapper.logger import logger
|
||||
|
||||
|
||||
# no shorthand names
|
||||
FUNCTION_NAMES = [name for name in FUNCTIONS.keys() if len(name) > 1]
|
||||
# no deprecated functions
|
||||
FUNCTION_NAMES.remove("ifeq")
|
||||
|
||||
|
||||
def _get_left_text(iter):
|
||||
buffer = iter.get_buffer()
|
||||
result = buffer.get_text(buffer.get_start_iter(), iter, True)
|
||||
result = 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):
|
||||
"""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)
|
||||
|
||||
if match is None:
|
||||
return ""
|
||||
|
||||
return match[1]
|
||||
|
||||
|
||||
def get_incomplete_parameter(iter):
|
||||
"""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)
|
||||
print("get_incomplete_parameter", left_text, match)
|
||||
|
||||
if match is None:
|
||||
return None
|
||||
|
||||
return match[1]
|
||||
|
||||
|
||||
def propose_symbols(text_iter):
|
||||
"""Find key names that match the input at the cursor."""
|
||||
incomplete_name = get_incomplete_parameter(text_iter)
|
||||
|
||||
if incomplete_name is None or len(incomplete_name) <= 1:
|
||||
return []
|
||||
|
||||
incomplete_name = incomplete_name.lower()
|
||||
|
||||
return [
|
||||
(name, name)
|
||||
for name in list(system_mapping.list_names())
|
||||
if incomplete_name in name.lower() and incomplete_name != name.lower()
|
||||
]
|
||||
|
||||
|
||||
def propose_function_names(text_iter):
|
||||
"""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()
|
||||
|
||||
return [
|
||||
(name, f"{name}({', '.join(get_macro_argument_names(FUNCTIONS[name]))})")
|
||||
for name in FUNCTION_NAMES
|
||||
if incomplete_name in name.lower() and incomplete_name != name.lower()
|
||||
]
|
||||
|
||||
|
||||
debounces = {}
|
||||
|
||||
|
||||
def debounce(func):
|
||||
"""Debounce a function call to improve performance."""
|
||||
|
||||
def clear_debounce(self, *args):
|
||||
debounces[func.__name__] = None
|
||||
return func(self, *args)
|
||||
|
||||
def wrapped(self, *args):
|
||||
if debounces.get(func.__name__) is not None:
|
||||
GLib.source_remove(debounces[func.__name__])
|
||||
|
||||
timeout = self.debounce_timeout
|
||||
|
||||
debounces[func.__name__] = GLib.timeout_add(
|
||||
timeout, lambda: clear_debounce(self, *args)
|
||||
)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
class SuggestionLabel(Gtk.Label):
|
||||
"""A label with some extra internal information."""
|
||||
|
||||
__gtype_name__ = "SuggestionLabel"
|
||||
|
||||
def __init__(self, display_name, suggestion):
|
||||
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"
|
||||
|
||||
def __init__(self, text_input):
|
||||
"""Create an autocompletion popover.
|
||||
|
||||
It will remain hidden until there is something to autocomplete.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
text_input : Gtk.SourceView | Gtk.TextView
|
||||
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.debounce_timeout = 100
|
||||
|
||||
self.text_input = text_input
|
||||
|
||||
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)
|
||||
|
||||
text_input.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
|
||||
text_input.connect("focus-out-event", self.on_text_input_unfocus)
|
||||
|
||||
text_input.get_buffer().connect("changed", self.update)
|
||||
|
||||
self.set_position(Gtk.PositionType.BOTTOM)
|
||||
|
||||
self.visible = False
|
||||
|
||||
self.show_all()
|
||||
self.popdown() # hidden by default. this needs to happen after show_all!
|
||||
|
||||
def on_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):
|
||||
"""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):
|
||||
"""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
|
||||
|
||||
if row:
|
||||
y_offset = row.translate_coordinates(self.list_box, 0, 0)[1]
|
||||
height = self.scrolled_window.get_max_content_height()
|
||||
current_y_scroll = self.scrolled_window.get_vadjustment().get_value()
|
||||
|
||||
vadjustment = self.scrolled_window.get_vadjustment()
|
||||
|
||||
if y_offset > current_y_scroll + (height - row_height):
|
||||
vadjustment.set_value(y_offset - (height - row_height))
|
||||
|
||||
if y_offset < current_y_scroll:
|
||||
# scroll up because the element is not visible anymore
|
||||
vadjustment.set_value(y_offset)
|
||||
|
||||
def _get_text_iter_at_cursor(self):
|
||||
"""Get Gtk.TextIter at the current text cursor location."""
|
||||
cursor = self.text_input.get_cursor_locations()[0]
|
||||
return self.text_input.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
|
||||
def update(self, *_):
|
||||
"""Find new autocompletion suggestions and display them. Hide if none."""
|
||||
if not self.text_input.is_focus():
|
||||
self.popdown()
|
||||
return
|
||||
|
||||
self.list_box.forall(self.list_box.remove)
|
||||
|
||||
# move the autocompletion to the text cursor
|
||||
cursor = self.text_input.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.text_input.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.text_input.get_show_line_numbers():
|
||||
cursor.x += 25
|
||||
|
||||
self.set_pointing_to(cursor)
|
||||
|
||||
text_iter = self._get_text_iter_at_cursor()
|
||||
suggested_names = propose_function_names(text_iter)
|
||||
suggested_names += propose_symbols(text_iter)
|
||||
|
||||
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 _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.text_input.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.text_input, 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.text_input, Gtk.DeleteType.CHARS, -len(left)
|
||||
)
|
||||
|
||||
# insert the autocompletion
|
||||
Gtk.TextView.do_insert_at_cursor(self.text_input, suggestion)
|
||||
|
||||
self.emit("suggestion-inserted")
|
||||
|
||||
|
||||
GObject.signal_new(
|
||||
"suggestion-inserted", Autocompletion, GObject.SignalFlags.RUN_FIRST, None, []
|
||||
)
|
@ -0,0 +1,566 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.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/>.
|
||||
|
||||
|
||||
"""The editor with multiline code input, recording toggle and autocompletion."""
|
||||
|
||||
|
||||
import re
|
||||
|
||||
from gi.repository import Gtk, GLib, GtkSource, Gdk
|
||||
|
||||
from inputremapper.gui.editor.autocompletion import Autocompletion
|
||||
from inputremapper.system_mapping import system_mapping
|
||||
from inputremapper.gui.custom_mapping import custom_mapping
|
||||
from inputremapper.key import Key
|
||||
from inputremapper.logger import logger
|
||||
from inputremapper.gui.reader import reader
|
||||
from inputremapper.gui.utils import CTX_KEYCODE, CTX_WARNING
|
||||
|
||||
|
||||
class SelectionLabel(Gtk.ListBoxRow):
|
||||
"""One label per mapping in the preset.
|
||||
|
||||
This wrapper serves as a storage for the information the inherited label represents.
|
||||
"""
|
||||
|
||||
__gtype_name__ = "SelectionLabel"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.key = None
|
||||
self.symbol = ""
|
||||
|
||||
label = Gtk.Label()
|
||||
|
||||
# Make the child label widget break lines, important for
|
||||
# long combinations
|
||||
label.set_line_wrap(True)
|
||||
label.set_line_wrap_mode(2)
|
||||
label.set_justify(Gtk.Justification.CENTER)
|
||||
|
||||
self.label = label
|
||||
self.add(label)
|
||||
|
||||
self.show_all()
|
||||
|
||||
def set_key(self, key):
|
||||
"""Set the key this button represents
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key : Key
|
||||
"""
|
||||
self.key = key
|
||||
if key:
|
||||
self.label.set_label(key.beautify())
|
||||
else:
|
||||
self.label.set_label("new entry")
|
||||
|
||||
def get_key(self):
|
||||
return self.key
|
||||
|
||||
def set_label(self, label):
|
||||
return self.label.set_label(label)
|
||||
|
||||
def get_label(self):
|
||||
return self.label.get_label()
|
||||
|
||||
def __str__(self):
|
||||
return f"SelectionLabel({str(self.key)})"
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
|
||||
def ensure_everything_saved(func):
|
||||
"""Make sure the editor has written its changes to custom_mapping and save."""
|
||||
|
||||
def wrapped(self, *args, **kwargs):
|
||||
if self.user_interface.preset_name:
|
||||
self.gather_changes_and_save()
|
||||
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
SET_KEY_FIRST = "Set the key first"
|
||||
|
||||
|
||||
class Editor:
|
||||
"""Maintains the widgets of the editor."""
|
||||
|
||||
def __init__(self, user_interface):
|
||||
self.user_interface = user_interface
|
||||
|
||||
self.autocompletion = None
|
||||
|
||||
self._setup_source_view()
|
||||
self._setup_recording_toggle()
|
||||
|
||||
self.window = self.get("window")
|
||||
self.timeout = GLib.timeout_add(100, self.check_add_new_key)
|
||||
self.active_selection_label = None
|
||||
|
||||
selection_label_listbox = self.get("selection_label_listbox")
|
||||
selection_label_listbox.connect("row-selected", self.on_mapping_selected)
|
||||
|
||||
self.device = user_interface.group
|
||||
|
||||
# keys were not pressed yet
|
||||
self._input_has_arrived = False
|
||||
|
||||
toggle = self.get_recording_toggle()
|
||||
toggle.connect("focus-out-event", self._reset_keycode_consumption)
|
||||
toggle.connect("focus-out-event", lambda *_: toggle.set_active(False))
|
||||
toggle.connect("focus-in-event", self._on_recording_toggle_focus)
|
||||
# Don't leave the input when using arrow keys or tab. wait for the
|
||||
# window to consume the keycode from the reader. I.e. a tab input should
|
||||
# be recorded, instead of causing the recording to stop.
|
||||
toggle.connect("key-press-event", lambda *args: Gdk.EVENT_STOP)
|
||||
|
||||
text_input = self.get_text_input()
|
||||
text_input.connect("focus-out-event", self.on_text_input_unfocus)
|
||||
|
||||
delete_button = self.get_delete_button()
|
||||
delete_button.connect("clicked", self._on_delete_button_clicked)
|
||||
|
||||
@ensure_everything_saved
|
||||
def on_text_input_unfocus(self, *_):
|
||||
"""When unfocusing the text it saves.
|
||||
|
||||
Input Remapper doesn't save the editor on change, because that would cause
|
||||
an incredible amount of logs for every single input. The custom_mapping would
|
||||
need to be changed, which causes two logs, then it has to be saved
|
||||
to disk which is another two log messages. So every time a single character
|
||||
is typed it writes 4 lines.
|
||||
|
||||
Instead, it will save the preset when it is really needed, i.e. when a button
|
||||
that requires a saved preset is pressed. For this there exists the
|
||||
@ensure_everything_saved decorator.
|
||||
|
||||
To avoid maybe forgetting to add this decorator somewhere, it will also save
|
||||
when unfocusing the text input.
|
||||
|
||||
If the scroll wheel is used to interact with gtk widgets it won't unfocus,
|
||||
so this focus-out handler is not the solution to everything as well.
|
||||
|
||||
One could debounce saving on text-change to avoid those logs, but that just
|
||||
sounds like a huge source of race conditions and is also hard to test.
|
||||
"""
|
||||
pass
|
||||
|
||||
def clear(self):
|
||||
"""Clear all inputs, labels, etc. Reset the state.
|
||||
|
||||
This is really important to do before loading a different preset.
|
||||
Otherwise the inputs will be read and then saved into the next preset.
|
||||
"""
|
||||
if self.active_selection_label:
|
||||
self.set_key(None)
|
||||
|
||||
self.set_symbol_input_text("")
|
||||
self.disable_symbol_input()
|
||||
self._reset_keycode_consumption()
|
||||
|
||||
selection_label_listbox = self.get("selection_label_listbox")
|
||||
selection_label_listbox.forall(selection_label_listbox.remove)
|
||||
self.add_empty()
|
||||
|
||||
selection_label_listbox.select_row(selection_label_listbox.get_children()[0])
|
||||
|
||||
def _setup_recording_toggle(self):
|
||||
"""Prepare the toggle button for recording key inputs."""
|
||||
toggle = self.get("key_recording_toggle")
|
||||
toggle.connect(
|
||||
"focus-out-event",
|
||||
self._show_change_key,
|
||||
)
|
||||
toggle.connect(
|
||||
"focus-in-event",
|
||||
self._show_press_key,
|
||||
)
|
||||
toggle.connect(
|
||||
"clicked",
|
||||
lambda _: (
|
||||
self._show_press_key()
|
||||
if toggle.get_active()
|
||||
else self._show_change_key()
|
||||
),
|
||||
)
|
||||
|
||||
def _show_press_key(self, *_):
|
||||
"""Show user friendly instructions."""
|
||||
self.get("key_recording_toggle").set_label("Press Key")
|
||||
|
||||
def _show_change_key(self, *_):
|
||||
"""Show user friendly instructions."""
|
||||
self.get("key_recording_toggle").set_label("Change Key")
|
||||
|
||||
def _setup_source_view(self):
|
||||
"""Prepare the code editor."""
|
||||
source_view = self.get("code_editor")
|
||||
|
||||
# without this the wrapping ScrolledWindow acts weird when new lines are added,
|
||||
# not offering enough space to the text editor so the whole thing is suddenly
|
||||
# scrollable by a few pixels.
|
||||
# Found this after making blind guesses with settings in glade, and then
|
||||
# actually looking at the snaphot preview! In glades editor this didn have an
|
||||
# effect.
|
||||
source_view.set_resize_mode(Gtk.ResizeMode.IMMEDIATE)
|
||||
|
||||
source_view.get_buffer().connect("changed", self.show_line_numbers_if_multiline)
|
||||
|
||||
# Syntax Highlighting
|
||||
# Thanks to https://github.com/wolfthefallen/py-GtkSourceCompletion-example
|
||||
# language_manager = GtkSource.LanguageManager()
|
||||
# fun fact: without saving LanguageManager into its own variable it doesn't work
|
||||
# python = language_manager.get_language("python")
|
||||
# source_view.get_buffer().set_language(python)
|
||||
# TODO there are some similarities with python, but overall it's quite useless.
|
||||
# commented out until there is proper highlighting for input-remappers syntax.
|
||||
|
||||
autocompletion = Autocompletion(source_view)
|
||||
autocompletion.set_relative_to(self.get("code_editor_container"))
|
||||
autocompletion.connect("suggestion-inserted", self.gather_changes_and_save)
|
||||
self.autocompletion = autocompletion
|
||||
|
||||
def show_line_numbers_if_multiline(self, *_):
|
||||
"""Show line numbers if a macro is being edited."""
|
||||
code_editor = self.get("code_editor")
|
||||
symbol = self.get_symbol_input_text() or ""
|
||||
|
||||
if "\n" in symbol:
|
||||
code_editor.set_show_line_numbers(True)
|
||||
code_editor.set_monospace(True)
|
||||
code_editor.get_style_context().add_class("multiline")
|
||||
else:
|
||||
code_editor.set_show_line_numbers(False)
|
||||
code_editor.set_monospace(False)
|
||||
code_editor.get_style_context().remove_class("multiline")
|
||||
|
||||
def get_delete_button(self):
|
||||
return self.get("delete-mapping")
|
||||
|
||||
def check_add_new_key(self):
|
||||
"""If needed, add a new empty mapping to the list for the user to configure."""
|
||||
selection_label_listbox = self.get("selection_label_listbox")
|
||||
|
||||
selection_label_listbox = selection_label_listbox.get_children()
|
||||
|
||||
for selection_label in selection_label_listbox:
|
||||
if selection_label.get_key() is None:
|
||||
# unfinished row found
|
||||
break
|
||||
else:
|
||||
self.add_empty()
|
||||
|
||||
return True
|
||||
|
||||
def disable_symbol_input(self):
|
||||
"""Display help information and dont allow entering a symbol yet.
|
||||
|
||||
Without this, maybe a user enters a symbol or writes a macro, switches
|
||||
presets accidentally before configuring the key and then it's gone. It can
|
||||
only be saved to the preset if a key is configured. This avoids that pitfall.
|
||||
"""
|
||||
text_input = self.get_text_input()
|
||||
text_input.set_sensitive(False)
|
||||
text_input.set_opacity(0.5)
|
||||
|
||||
if self.get_symbol_input_text() == "":
|
||||
# don't overwrite user input
|
||||
self.set_symbol_input_text(SET_KEY_FIRST)
|
||||
|
||||
def enable_symbol_input(self):
|
||||
"""Don't display help information anymore and allow changing the symbol."""
|
||||
text_input = self.get_text_input()
|
||||
text_input.set_sensitive(True)
|
||||
text_input.set_opacity(1)
|
||||
|
||||
buffer = text_input.get_buffer()
|
||||
symbol = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True)
|
||||
if symbol == SET_KEY_FIRST:
|
||||
# don't overwrite user input
|
||||
self.set_symbol_input_text("")
|
||||
|
||||
@ensure_everything_saved
|
||||
def on_mapping_selected(self, _=None, selection_label=None):
|
||||
"""One of the buttons in the left "key" column was clicked.
|
||||
|
||||
Load the information from that mapping entry into the editor.
|
||||
"""
|
||||
self.active_selection_label = selection_label
|
||||
|
||||
if selection_label is None:
|
||||
return
|
||||
|
||||
key = selection_label.key
|
||||
self.set_key(key)
|
||||
|
||||
if key is None:
|
||||
self.set_symbol_input_text("")
|
||||
self.disable_symbol_input()
|
||||
# symbol input disabled until a key is configured
|
||||
else:
|
||||
self.set_symbol_input_text(custom_mapping.get_symbol(key))
|
||||
self.enable_symbol_input()
|
||||
|
||||
self.get("window").set_focus(self.get_text_input())
|
||||
|
||||
def add_empty(self):
|
||||
"""Add one empty row for a single mapped key."""
|
||||
selection_label_listbox = self.get("selection_label_listbox")
|
||||
mapping_selection = SelectionLabel()
|
||||
mapping_selection.set_label("new entry")
|
||||
mapping_selection.show_all()
|
||||
selection_label_listbox.insert(mapping_selection, -1)
|
||||
|
||||
@ensure_everything_saved
|
||||
def load_custom_mapping(self):
|
||||
"""Display the entries in custom_mapping."""
|
||||
self.set_symbol_input_text("")
|
||||
|
||||
selection_label_listbox = self.get("selection_label_listbox")
|
||||
|
||||
selection_label_listbox.forall(selection_label_listbox.remove)
|
||||
|
||||
for key, output in custom_mapping:
|
||||
selection_label = SelectionLabel()
|
||||
selection_label.set_key(key)
|
||||
selection_label_listbox.insert(selection_label, -1)
|
||||
|
||||
self.check_add_new_key()
|
||||
|
||||
# select the first entry
|
||||
selection_labels = selection_label_listbox.get_children()
|
||||
|
||||
if len(selection_labels) == 0:
|
||||
self.add_empty()
|
||||
selection_labels = selection_label_listbox.get_children()
|
||||
|
||||
selection_label_listbox.select_row(selection_labels[0])
|
||||
|
||||
def get_recording_toggle(self):
|
||||
return self.get("key_recording_toggle")
|
||||
|
||||
def get_text_input(self):
|
||||
return self.get("code_editor")
|
||||
|
||||
def get_key(self):
|
||||
"""Get the Key object from the left column.
|
||||
|
||||
Or None if no code is mapped on this row.
|
||||
"""
|
||||
if self.active_selection_label is None:
|
||||
return None
|
||||
|
||||
return self.active_selection_label.key
|
||||
|
||||
def set_symbol_input_text(self, symbol):
|
||||
self.get("code_editor").get_buffer().set_text(symbol or "")
|
||||
# move cursor location to the beginning, like any code editor does
|
||||
Gtk.TextView.do_move_cursor(
|
||||
self.get("code_editor"),
|
||||
Gtk.MovementStep.BUFFER_ENDS,
|
||||
-1,
|
||||
False,
|
||||
)
|
||||
|
||||
def get_symbol_input_text(self):
|
||||
"""Get the assigned symbol from the text input.
|
||||
|
||||
This might not be stored in custom_mapping yet, and might therefore also not
|
||||
be part of the preset json file yet.
|
||||
|
||||
If there is no symbol, this returns None. This is important for some other
|
||||
logic down the road in custom_mapping or something.
|
||||
"""
|
||||
buffer = self.get("code_editor").get_buffer()
|
||||
symbol = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True)
|
||||
|
||||
if symbol == SET_KEY_FIRST:
|
||||
# not configured yet
|
||||
return ""
|
||||
|
||||
return symbol
|
||||
|
||||
def set_key(self, key):
|
||||
"""Show what the user is currently pressing in the user interface."""
|
||||
self.active_selection_label.set_key(key)
|
||||
|
||||
def get(self, name):
|
||||
"""Get a widget from the window"""
|
||||
return self.user_interface.builder.get_object(name)
|
||||
|
||||
def _on_recording_toggle_focus(self, *_):
|
||||
"""Refresh useful usage information."""
|
||||
self._reset_keycode_consumption()
|
||||
reader.clear()
|
||||
self.user_interface.can_modify_mapping()
|
||||
|
||||
def _on_delete_button_clicked(self, *_):
|
||||
"""Destroy the row and remove it from the config."""
|
||||
accept = Gtk.ResponseType.ACCEPT
|
||||
if (
|
||||
len(self.get_symbol_input_text()) > 0
|
||||
and self._show_confirm_delete() != accept
|
||||
):
|
||||
return
|
||||
|
||||
key = self.get_key()
|
||||
if key is not None:
|
||||
custom_mapping.clear(key)
|
||||
|
||||
# make sure there is no outdated information lying around in memory
|
||||
self.set_key(None)
|
||||
|
||||
self.load_custom_mapping()
|
||||
|
||||
def _show_confirm_delete(self):
|
||||
"""Blocks until the user decided about an action."""
|
||||
confirm_delete = self.get("confirm-delete")
|
||||
|
||||
text = f"Are you sure to delete this mapping?"
|
||||
self.get("confirm-delete-label").set_text(text)
|
||||
|
||||
confirm_delete.show()
|
||||
response = confirm_delete.run()
|
||||
confirm_delete.hide()
|
||||
return response
|
||||
|
||||
def gather_changes_and_save(self, *_):
|
||||
"""Look into the ui if new changes should be written, and save the preset."""
|
||||
# correct case
|
||||
symbol = self.get_symbol_input_text()
|
||||
|
||||
if not symbol:
|
||||
return
|
||||
|
||||
correct_case = system_mapping.correct_case(symbol)
|
||||
if symbol != correct_case:
|
||||
self.get_text_input().get_buffer().set_text(correct_case)
|
||||
|
||||
# make sure the custom_mapping is up to date
|
||||
key = self.get_key()
|
||||
if correct_case is not None and key is not None:
|
||||
custom_mapping.change(key, correct_case)
|
||||
|
||||
# save to disk if required
|
||||
if custom_mapping.has_unsaved_changes():
|
||||
self.user_interface.save_preset()
|
||||
|
||||
def is_waiting_for_input(self):
|
||||
"""Check if the user is interacting with the ToggleButton for key recording."""
|
||||
return self.get_recording_toggle().get_active()
|
||||
|
||||
def consume_newest_keycode(self, key):
|
||||
"""To capture events from keyboards, mice and gamepads.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key : Key or None
|
||||
"""
|
||||
self._switch_focus_if_complete()
|
||||
|
||||
if key is None:
|
||||
return
|
||||
|
||||
if not self.is_waiting_for_input():
|
||||
return
|
||||
|
||||
if not isinstance(key, Key):
|
||||
raise TypeError("Expected new_key to be a Key object")
|
||||
|
||||
# keycode is already set by some other row
|
||||
existing = custom_mapping.get_symbol(key)
|
||||
if existing is not None:
|
||||
existing = re.sub(r"\s", "", existing)
|
||||
msg = f'"{key.beautify()}" already mapped to "{existing}"'
|
||||
logger.info("%s %s", key, msg)
|
||||
self.user_interface.show_status(CTX_KEYCODE, msg)
|
||||
return True
|
||||
|
||||
if key.is_problematic():
|
||||
self.user_interface.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.",
|
||||
)
|
||||
|
||||
# the newest_keycode is populated since the ui regularly polls it
|
||||
# in order to display it in the status bar.
|
||||
previous_key = self.get_key()
|
||||
|
||||
# it might end up being a key combination, wait for more
|
||||
self._input_has_arrived = True
|
||||
|
||||
# keycode didn't change, do nothing
|
||||
if key == previous_key:
|
||||
logger.debug("%s didn't change", previous_key)
|
||||
return
|
||||
|
||||
self.set_key(key)
|
||||
|
||||
symbol = self.get_symbol_input_text()
|
||||
|
||||
# the symbol is empty and therefore the mapping is not complete
|
||||
if not symbol:
|
||||
return
|
||||
|
||||
# else, the keycode has changed, the symbol is set, all good
|
||||
custom_mapping.change(new_key=key, symbol=symbol, previous_key=previous_key)
|
||||
|
||||
def _switch_focus_if_complete(self):
|
||||
"""If keys are released, it will switch to the text_input.
|
||||
|
||||
States:
|
||||
1. not doing anything, waiting for the user to start using it
|
||||
2. user focuses it, no keys pressed
|
||||
3. user presses keys
|
||||
4. user releases keys. no keys are pressed, just like in step 2, but this time
|
||||
the focus needs to switch.
|
||||
"""
|
||||
if not self.is_waiting_for_input():
|
||||
self._reset_keycode_consumption()
|
||||
return
|
||||
|
||||
all_keys_released = reader.get_unreleased_keys() is None
|
||||
if all_keys_released and self._input_has_arrived and self.get_key():
|
||||
# A key was pressed and then released.
|
||||
# Switch to the symbol. idle_add this so that the
|
||||
# keycode event won't write into the symbol input as well.
|
||||
window = self.user_interface.window
|
||||
self.enable_symbol_input()
|
||||
GLib.idle_add(lambda: window.set_focus(self.get_text_input()))
|
||||
|
||||
if not all_keys_released:
|
||||
# currently the user is using the widget, and certain keys have already
|
||||
# reached it.
|
||||
self._input_has_arrived = True
|
||||
return
|
||||
|
||||
self._reset_keycode_consumption()
|
||||
|
||||
def _reset_keycode_consumption(self, *_):
|
||||
self._input_has_arrived = False
|
@ -1,409 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.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/>.
|
||||
|
||||
|
||||
"""A single, configurable key mapping."""
|
||||
|
||||
|
||||
import evdev
|
||||
from gi.repository import Gtk, GLib, Gdk
|
||||
|
||||
from inputremapper.system_mapping import system_mapping
|
||||
from inputremapper.gui.custom_mapping import custom_mapping
|
||||
from inputremapper.logger import logger
|
||||
from inputremapper.key import Key
|
||||
from inputremapper.gui.reader import reader
|
||||
|
||||
|
||||
CTX_KEYCODE = 2
|
||||
|
||||
|
||||
store = Gtk.ListStore(str)
|
||||
|
||||
|
||||
def populate_store():
|
||||
"""Fill the dropdown for key suggestions with values."""
|
||||
for name in system_mapping.list_names():
|
||||
store.append([name])
|
||||
|
||||
extra = [
|
||||
"mouse(up, 1)",
|
||||
"mouse(down, 1)",
|
||||
"mouse(left, 1)",
|
||||
"mouse(right, 1)",
|
||||
"wheel(up, 1)",
|
||||
"wheel(down, 1)",
|
||||
"wheel(left, 1)",
|
||||
"wheel(right, 1)",
|
||||
]
|
||||
|
||||
for key in extra:
|
||||
# add some more keys to the dropdown list
|
||||
store.append([key])
|
||||
|
||||
|
||||
populate_store()
|
||||
|
||||
|
||||
def to_string(key):
|
||||
"""A nice to show description of the pressed key."""
|
||||
if isinstance(key, Key):
|
||||
return " + ".join([to_string(sub_key) for sub_key in key])
|
||||
|
||||
if isinstance(key[0], tuple):
|
||||
raise Exception("deprecated stuff")
|
||||
|
||||
ev_type, code, value = key
|
||||
|
||||
if ev_type not in evdev.ecodes.bytype:
|
||||
logger.error("Unknown key type for %s", key)
|
||||
return str(code)
|
||||
|
||||
if code not in evdev.ecodes.bytype[ev_type]:
|
||||
logger.error("Unknown key code for %s", key)
|
||||
return str(code)
|
||||
|
||||
key_name = None
|
||||
|
||||
# first try to find the name in xmodmap to not display wrong
|
||||
# names due to the keyboard layout
|
||||
if ev_type == evdev.ecodes.EV_KEY:
|
||||
key_name = system_mapping.get_name(code)
|
||||
|
||||
if key_name is None:
|
||||
# if no result, look in the linux key constants. On a german
|
||||
# keyboard for example z and y are switched, which will therefore
|
||||
# cause the wrong letter to be displayed.
|
||||
key_name = evdev.ecodes.bytype[ev_type][code]
|
||||
if isinstance(key_name, list):
|
||||
key_name = key_name[0]
|
||||
|
||||
if ev_type != evdev.ecodes.EV_KEY:
|
||||
direction = {
|
||||
# D-Pad
|
||||
(evdev.ecodes.ABS_HAT0X, -1): "Left",
|
||||
(evdev.ecodes.ABS_HAT0X, 1): "Right",
|
||||
(evdev.ecodes.ABS_HAT0Y, -1): "Up",
|
||||
(evdev.ecodes.ABS_HAT0Y, 1): "Down",
|
||||
(evdev.ecodes.ABS_HAT1X, -1): "Left",
|
||||
(evdev.ecodes.ABS_HAT1X, 1): "Right",
|
||||
(evdev.ecodes.ABS_HAT1Y, -1): "Up",
|
||||
(evdev.ecodes.ABS_HAT1Y, 1): "Down",
|
||||
(evdev.ecodes.ABS_HAT2X, -1): "Left",
|
||||
(evdev.ecodes.ABS_HAT2X, 1): "Right",
|
||||
(evdev.ecodes.ABS_HAT2Y, -1): "Up",
|
||||
(evdev.ecodes.ABS_HAT2Y, 1): "Down",
|
||||
# joystick
|
||||
(evdev.ecodes.ABS_X, 1): "Right",
|
||||
(evdev.ecodes.ABS_X, -1): "Left",
|
||||
(evdev.ecodes.ABS_Y, 1): "Down",
|
||||
(evdev.ecodes.ABS_Y, -1): "Up",
|
||||
(evdev.ecodes.ABS_RX, 1): "Right",
|
||||
(evdev.ecodes.ABS_RX, -1): "Left",
|
||||
(evdev.ecodes.ABS_RY, 1): "Down",
|
||||
(evdev.ecodes.ABS_RY, -1): "Up",
|
||||
# wheel
|
||||
(evdev.ecodes.REL_WHEEL, -1): "Down",
|
||||
(evdev.ecodes.REL_WHEEL, 1): "Up",
|
||||
(evdev.ecodes.REL_HWHEEL, -1): "Left",
|
||||
(evdev.ecodes.REL_HWHEEL, 1): "Right",
|
||||
}.get((code, value))
|
||||
if direction is not None:
|
||||
key_name += f" {direction}"
|
||||
|
||||
key_name = key_name.replace("ABS_Z", "Trigger Left")
|
||||
key_name = key_name.replace("ABS_RZ", "Trigger Right")
|
||||
|
||||
key_name = key_name.replace("ABS_HAT0X", "DPad")
|
||||
key_name = key_name.replace("ABS_HAT0Y", "DPad")
|
||||
key_name = key_name.replace("ABS_HAT1X", "DPad 2")
|
||||
key_name = key_name.replace("ABS_HAT1Y", "DPad 2")
|
||||
key_name = key_name.replace("ABS_HAT2X", "DPad 3")
|
||||
key_name = key_name.replace("ABS_HAT2Y", "DPad 3")
|
||||
|
||||
key_name = key_name.replace("ABS_X", "Joystick")
|
||||
key_name = key_name.replace("ABS_Y", "Joystick")
|
||||
key_name = key_name.replace("ABS_RX", "Joystick 2")
|
||||
key_name = key_name.replace("ABS_RY", "Joystick 2")
|
||||
|
||||
key_name = key_name.replace("BTN_", "Button ")
|
||||
key_name = key_name.replace("KEY_", "")
|
||||
|
||||
key_name = key_name.replace("REL_", "")
|
||||
key_name = key_name.replace("HWHEEL", "Wheel")
|
||||
key_name = key_name.replace("WHEEL", "Wheel")
|
||||
|
||||
key_name = key_name.replace("_", " ")
|
||||
key_name = key_name.replace(" ", " ")
|
||||
|
||||
return key_name
|
||||
|
||||
|
||||
IDLE = 0
|
||||
HOLDING = 1
|
||||
|
||||
|
||||
class Row(Gtk.ListBoxRow):
|
||||
"""A single, configurable key mapping."""
|
||||
|
||||
__gtype_name__ = "ListBoxRow"
|
||||
|
||||
def __init__(self, delete_callback, window, key=None, symbol=None):
|
||||
"""Construct a row widget.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key : Key
|
||||
"""
|
||||
if key is not None and not isinstance(key, Key):
|
||||
raise TypeError("Expected key to be a Key object")
|
||||
|
||||
super().__init__()
|
||||
self.device = window.group
|
||||
self.window = window
|
||||
self.delete_callback = delete_callback
|
||||
|
||||
self.symbol_input = None
|
||||
self.keycode_input = None
|
||||
|
||||
self.key = key
|
||||
|
||||
self.put_together(symbol)
|
||||
|
||||
self._state = IDLE
|
||||
|
||||
def refresh_state(self):
|
||||
"""Refresh the state.
|
||||
|
||||
The state is needed to switch focus when no keys are held anymore,
|
||||
but only if the row has been in the HOLDING state before.
|
||||
"""
|
||||
old_state = self._state
|
||||
|
||||
if not self.keycode_input.is_focus():
|
||||
self._state = IDLE
|
||||
return
|
||||
|
||||
unreleased_keys = reader.get_unreleased_keys()
|
||||
if unreleased_keys is None and old_state == HOLDING and self.key:
|
||||
# A key was pressed and then released.
|
||||
# Switch to the symbol. idle_add this so that the
|
||||
# keycode event won't write into the symbol input as well.
|
||||
window = self.window.window
|
||||
GLib.idle_add(lambda: window.set_focus(self.symbol_input))
|
||||
|
||||
if unreleased_keys is not None:
|
||||
self._state = HOLDING
|
||||
return
|
||||
|
||||
self._state = IDLE
|
||||
|
||||
def get_key(self):
|
||||
"""Get the Key object from the left column.
|
||||
|
||||
Or None if no code is mapped on this row.
|
||||
"""
|
||||
return self.key
|
||||
|
||||
def get_symbol(self):
|
||||
"""Get the assigned symbol from the middle column."""
|
||||
symbol = self.symbol_input.get_text()
|
||||
return symbol if symbol else None
|
||||
|
||||
def set_new_key(self, new_key):
|
||||
"""Check if a keycode has been pressed and if so, display it.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
new_key : Key
|
||||
"""
|
||||
if new_key is not None and not isinstance(new_key, Key):
|
||||
raise TypeError("Expected new_key to be a Key object")
|
||||
|
||||
# the newest_keycode is populated since the ui regularly polls it
|
||||
# in order to display it in the status bar.
|
||||
previous_key = self.get_key()
|
||||
|
||||
# no input
|
||||
if new_key is None:
|
||||
return
|
||||
|
||||
# it might end up being a key combination
|
||||
self._state = HOLDING
|
||||
|
||||
# keycode didn't change, do nothing
|
||||
if new_key == previous_key:
|
||||
return
|
||||
|
||||
# keycode is already set by some other row
|
||||
existing = custom_mapping.get_symbol(new_key)
|
||||
if existing is not None:
|
||||
msg = f'"{to_string(new_key)}" already mapped to "{existing}"'
|
||||
logger.info(msg)
|
||||
self.window.show_status(CTX_KEYCODE, msg)
|
||||
return
|
||||
|
||||
# it's legal to display the keycode
|
||||
|
||||
# always ask for get_child to set the label, otherwise line breaking
|
||||
# has to be configured again.
|
||||
self.set_keycode_input_label(to_string(new_key))
|
||||
|
||||
self.key = new_key
|
||||
|
||||
symbol = self.get_symbol()
|
||||
|
||||
# the symbol is empty and therefore the mapping is not complete
|
||||
if symbol is None:
|
||||
return
|
||||
|
||||
# else, the keycode has changed, the symbol is set, all good
|
||||
custom_mapping.change(new_key=new_key, symbol=symbol, previous_key=previous_key)
|
||||
|
||||
def on_symbol_input_change(self, _):
|
||||
"""When the output symbol for that keycode is typed in."""
|
||||
key = self.get_key()
|
||||
symbol = self.get_symbol()
|
||||
|
||||
if symbol is None:
|
||||
return
|
||||
|
||||
if key is not None:
|
||||
custom_mapping.change(new_key=key, symbol=symbol, previous_key=None)
|
||||
|
||||
def match(self, _, key, tree_iter):
|
||||
"""Search the avilable names."""
|
||||
value = store.get_value(tree_iter, 0)
|
||||
return key in value.lower()
|
||||
|
||||
def show_click_here(self):
|
||||
"""Show 'click here' on the keycode input button."""
|
||||
if self.get_key() is not None:
|
||||
return
|
||||
|
||||
self.set_keycode_input_label("click here")
|
||||
self.keycode_input.set_opacity(0.3)
|
||||
|
||||
def show_press_key(self):
|
||||
"""Show 'press key' on the keycode input button."""
|
||||
if self.get_key() is not None:
|
||||
return
|
||||
|
||||
self.set_keycode_input_label("press key")
|
||||
self.keycode_input.set_opacity(1)
|
||||
|
||||
def on_keycode_input_focus(self, *_):
|
||||
"""Refresh useful usage information."""
|
||||
reader.clear()
|
||||
self.show_press_key()
|
||||
self.window.can_modify_mapping()
|
||||
|
||||
def on_keycode_input_unfocus(self, *_):
|
||||
"""Refresh useful usage information and set some state stuff."""
|
||||
self.show_click_here()
|
||||
self.keycode_input.set_active(False)
|
||||
self._state = IDLE
|
||||
self.window.save_preset()
|
||||
|
||||
def set_keycode_input_label(self, label):
|
||||
"""Set the label of the keycode input."""
|
||||
self.keycode_input.set_label(label)
|
||||
# make the child label widget break lines, important for
|
||||
# long combinations
|
||||
label = self.keycode_input.get_child()
|
||||
label.set_line_wrap(True)
|
||||
label.set_line_wrap_mode(2)
|
||||
label.set_max_width_chars(13)
|
||||
label.set_justify(Gtk.Justification.CENTER)
|
||||
self.keycode_input.set_opacity(1)
|
||||
|
||||
def on_symbol_input_unfocus(self, symbol_input, _):
|
||||
"""Save the preset and correct the input casing."""
|
||||
symbol = symbol_input.get_text()
|
||||
correct_case = system_mapping.correct_case(symbol)
|
||||
if symbol != correct_case:
|
||||
symbol_input.set_text(correct_case)
|
||||
self.window.save_preset()
|
||||
|
||||
def put_together(self, symbol):
|
||||
"""Create all child GTK widgets and connect their signals."""
|
||||
delete_button = Gtk.EventBox()
|
||||
delete_button.add(
|
||||
Gtk.Image.new_from_icon_name("window-close", Gtk.IconSize.BUTTON)
|
||||
)
|
||||
delete_button.connect("button-press-event", self.on_delete_button_clicked)
|
||||
delete_button.set_size_request(50, -1)
|
||||
|
||||
keycode_input = Gtk.ToggleButton()
|
||||
self.keycode_input = keycode_input
|
||||
keycode_input.set_size_request(140, -1)
|
||||
|
||||
if self.key is not None:
|
||||
self.set_keycode_input_label(to_string(self.key))
|
||||
else:
|
||||
self.show_click_here()
|
||||
|
||||
# make the togglebutton go back to its normal state when doing
|
||||
# something else in the UI
|
||||
keycode_input.connect("focus-in-event", self.on_keycode_input_focus)
|
||||
keycode_input.connect("focus-out-event", self.on_keycode_input_unfocus)
|
||||
# don't leave the input when using arrow keys or tab. wait for the
|
||||
# window to consume the keycode from the reader
|
||||
keycode_input.connect("key-press-event", lambda *args: Gdk.EVENT_STOP)
|
||||
|
||||
symbol_input = Gtk.Entry()
|
||||
self.symbol_input = symbol_input
|
||||
symbol_input.set_alignment(0.5)
|
||||
symbol_input.set_width_chars(4)
|
||||
symbol_input.set_has_frame(False)
|
||||
completion = Gtk.EntryCompletion()
|
||||
completion.set_model(store)
|
||||
completion.set_text_column(0)
|
||||
completion.set_match_func(self.match)
|
||||
symbol_input.set_completion(completion)
|
||||
|
||||
if symbol is not None:
|
||||
symbol_input.set_text(symbol)
|
||||
|
||||
symbol_input.connect("changed", self.on_symbol_input_change)
|
||||
symbol_input.connect("focus-out-event", self.on_symbol_input_unfocus)
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
box.set_homogeneous(False)
|
||||
box.set_spacing(0)
|
||||
box.pack_start(keycode_input, expand=False, fill=True, padding=0)
|
||||
box.pack_start(symbol_input, expand=True, fill=True, padding=0)
|
||||
box.pack_start(delete_button, expand=False, fill=True, padding=0)
|
||||
box.show_all()
|
||||
box.get_style_context().add_class("row-box")
|
||||
|
||||
self.add(box)
|
||||
self.show_all()
|
||||
|
||||
def on_delete_button_clicked(self, *_):
|
||||
"""Destroy the row and remove it from the config."""
|
||||
key = self.get_key()
|
||||
if key is not None:
|
||||
custom_mapping.clear(key)
|
||||
|
||||
self.symbol_input.set_text("")
|
||||
self.set_keycode_input_label("")
|
||||
self.key = None
|
||||
self.delete_callback(self)
|
@ -0,0 +1,54 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.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/>.
|
||||
|
||||
|
||||
from gi.repository import Gtk
|
||||
|
||||
|
||||
# status ctx ids
|
||||
CTX_SAVE = 0
|
||||
CTX_APPLY = 1
|
||||
CTX_KEYCODE = 2
|
||||
CTX_ERROR = 3
|
||||
CTX_WARNING = 4
|
||||
CTX_MAPPING = 5
|
||||
|
||||
|
||||
class HandlerDisabled:
|
||||
"""Safely modify a widget without causing handlers to be called.
|
||||
|
||||
Use in a with statement.
|
||||
"""
|
||||
|
||||
def __init__(self, widget, handler):
|
||||
self.widget = widget
|
||||
self.handler = handler
|
||||
|
||||
def __enter__(self):
|
||||
self.widget.handler_block_by_func(self.handler)
|
||||
|
||||
def __exit__(self, *_):
|
||||
self.widget.handler_unblock_by_func(self.handler)
|
||||
|
||||
|
||||
def gtk_iteration():
|
||||
"""Iterate while events are pending."""
|
||||
while Gtk.events_pending():
|
||||
Gtk.main_iteration()
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 43 KiB |
Binary file not shown.
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 33 KiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue