File: autocompletion.py

package info (click to toggle)
input-remapper 2.1.1-2
  • links: PTS, VCS
  • area: main
  • in suites: forky
  • size: 2,856 kB
  • sloc: python: 27,277; sh: 191; xml: 33; makefile: 3
file content (457 lines) | stat: -rw-r--r-- 16,257 bytes parent folder | download | duplicates (3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
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,
    [],
)