input-remapper/inputremapper/gui/autocompletion.py
jonasBoss 3637204bff
Frontend Refactor (#375)
* Tests for the GuiEventHandler

* Implement GuiEventHandler

* tests for data manager

* Implemented data_manager

* Remove Ellipsis from type hint

* workaround for old pydantic version

* workaround for old pydantic version

* some more tests for data_manager

* Updated Data Manager

* move DeviceSelection to its own class

* Data Manager no longer listens for events

* Moved PresetSelection to its own class

* MappingListBox and SelectionLable Listen to the EventHandler

* DataManager no longer creates its own data objects in the init

* removed global reader object

* Changed UI startup

* created backend Interface

* event_handler debug logs show function which emit a event

* some cleanup

* added target selector to components

* created code editor component

* adapted autocompletion & some cleanup

* black

* connected some buttons to the event_handler

* tests for data_manager newest_preset and group

* cleanup presets and test_presets

* migrated confirm delete dialog

* backend tests

* controller tests

* add python3-gi to ci

* more dependencies

* and more ...

* Github-Actions workaround

remove this commit

* not so many permission denyed errors in test.yml

* Fix #404 (hopefully)

* revert Github-Actions workaround

* More tests

* event_handler allows for event supression

* more tests

* WIP Implement Key recording

* Start and Stop Injection

* context no longer stores preset

* restructured the RelToBtnHandler

* Simplified read_loop

* Implement async iterator for ipc.pipe

* multiple event actions

* helper now implements mapping handlers to read inputs all with async

* updated and simplified reader 

the helper uses the mapping handlers, so the reader now can be much simpler

* Fixed race condition in tests

* implemented DataBus

* Fixed a UIMapping bug where the last_error would not be deleted

* added a immutable variant of the UIMapping

* updated data_manager to use data_bus

* Uptdated tests to use the DataBus

* Gui uses DataBus

* removed EventHandler

* Renamed controller methods

* Implemented recording toggle

* implemented StatusBar

* Sending validation errors to status bar

* sending injection status to status bar

* proper preset renaming

* implemented copy preset in the data manager

* implemented copy_preset in controller

* fixed a bug where a wron selection lable would update

* no longer send invalid data over the bus, if the preset or group changes

* Implement create and delete mapping

* Allow for frontend specific mapping defaults

* implemented autoload toggle

* cleanup user_interface

* removed editor

* Docstings renaming and ordering of methods

* more simplifications to user_interface

* integrated backend into data_manager

* removed active preset

* transformation tests

* controller tests

* fix missing uinputs in gui

* moved some tests and implemented basic tests for mapping handlers

* docstring reformatting

Co-authored-by: Tobi <proxima@sezanzeb.de>

* allow for empty groups

* docstring

* fixed TestGroupFromHelper

* some work on integration tests

* test for annoying import error in tests

* testing if test_user_interface works

* I feel lucky

* not so lucky

* some more tests

* fixed but where the group_key was used as folder name

* Fixed a bug where state=NO_GRAB would never be read from the injector

* allow to stop the recorder

* working on integration tests

* integration tests

* fixed more integration tests

* updated coveragerc

* no longer attempt to record keys when injecting

* event_reader cleans up not finished tasks

* More integration tests

* All tests pass

* renamed data_bus

* WIP fixing typing issues

* more typing fixes

* added keyboard+mouse device to tests

* cleanup imports

* new read loop because the evdev async read loop can not be cancelled

* Added field to modify mapping name

* created tests for components

* even more component tests

* do component tests need a screen?

* apparently they do :_(

* created release_input switch

* Don't record relative axis when movement is slow

* show delete dialog above main window

* wip basic dialog to edit combination

* some gui changes to the combination-editor

* Simple implementation of CombinationListbox

* renamed attach_to_events method and mark as private

* shorter str() for UInputsData

* moved logic to generate readable event string from combination to event

* new mapping parameter force release timeout

this helps with the helper when recording multiple relative axis at once

* make it possible to rearange the event_combination

* more work on the combination editor

* tests for DataManager.load_event

* simplyfied test_controller

* more controller tests

* Implement input threshold in gui

* greater range for time dependent unit test

* implemented a output-axis selector

* data_manager now provides injector state

* black

* mypy

* Updated confirm cancel dialog

* created release timeout input

* implemented transformation graph

* Added sliders for gain, expo and deadzone

* fix bug where the system_mapping was overridden in each injector thread

* updated slider settings

* removed debug statement

* explicitly checking output code against None (0 is a valid code)

* usage

* Allow for multiple axis to be activated by same button

* readme

* only warn about not implemented mapping-handler

don't fail to create event-pipelines

* More accurate event names

* Allow removal of single events from the input-combination

* rename callback to notify_callback

* rename event message to selected_event

* made read_continuisly private

* typing for autocompletion

* docstrings for message_broker messages

* make components methods and propreties private

* gui spacings

* removed eval

* make some controller functions private

* move status message generation from data_manager to controller

* parse mapping errors in controller for more helpful messages

* remove system_mapping from code editor

* More component tests

* more tests

* mypy

* make grab_devices less greedy (partial mitigation for #435)

only grab one device if there are multiple which can satisfy the same mapping

* accumulate more values in test

* docstrings

* Updated status messages

* comments, docstrings, imports

Co-authored-by: Tobi <proxima@sezanzeb.de>
2022-07-23 10:53:41 +02:00

406 lines
14 KiB
Python

#!/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 typing import Dict, Optional, List, Tuple
from evdev.ecodes import EV_KEY
from gi.repository import Gdk, Gtk, GLib, GObject
from inputremapper.configs.mapping import MappingData
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.gui.components import CodeEditor
from inputremapper.gui.message_broker import MessageBroker, MessageType, UInputsData
from inputremapper.gui.utils import debounce
from inputremapper.injection.macros.parse import (
FUNCTIONS,
get_macro_argument_names,
remove_comments,
)
from inputremapper.logger import logger
# no deprecated shorthand function-names
FUNCTION_NAMES = [name for name in FUNCTIONS.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 = 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)
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(f"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()
return [
(name, name)
for name in list(system_mapping.list_names(codes=codes))
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()
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()
]
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, message_broker: MessageBroker, code_editor: CodeEditor):
"""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.code_editor = code_editor
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_loaded)
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):
"""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.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 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 += 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, 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 _on_mapping_loaded(self, mapping: MappingData):
if mapping and self._uinputs:
target = mapping.target_uinput or "keyboard"
self._target_key_capabilities = self._uinputs[target][EV_KEY]
def _on_uinputs_changed(self, data: UInputsData):
self._uinputs = data.uinputs
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, []
)