1import re
2
3import tkinter as tk
4from tkinter import messagebox
5
6from thonny import get_runner, get_workbench
7from thonny.codeview import CodeViewText
8from thonny.common import InlineCommand
9from thonny.languages import tr
10from thonny.shell import ShellText
11
12# TODO: adjust the window position in cases where it's too close to bottom or right edge - but make sure the current line is shown
13"""Completions get computed on the backend, therefore getting the completions is
14asynchronous.
15"""
16
17
18class Completer(tk.Listbox):
19    def __init__(self, text):
20        tk.Listbox.__init__(
21            self, master=text, font="SmallEditorFont", activestyle="dotbox", exportselection=False
22        )
23
24        self.text = text
25        self.completions = []
26
27        self.doc_label = tk.Label(
28            master=text, text="...", bg="#ffffe0", justify="left", anchor="nw"
29        )
30
31        # Auto indenter will eat up returns, therefore I need to raise the priority
32        # of this binding
33        self.text_priority_bindtag = "completable" + str(self.text.winfo_id())
34        self.text.bindtags((self.text_priority_bindtag,) + self.text.bindtags())
35        self.text.bind_class(self.text_priority_bindtag, "<Key>", self._on_text_keypress, True)
36
37        self.text.bind("<<TextChange>>", self._on_text_change, True)  # Assuming TweakableText
38
39        # for cases when Listbox gets focus
40        self.bind("<Escape>", self._close)
41        self.bind("<Return>", self._insert_current_selection)
42        self.bind("<Double-Button-1>", self._insert_current_selection)
43        self._bind_result_event()
44
45    def _bind_result_event(self):
46        # TODO: remove binding when editor gets closed
47        get_workbench().bind("editor_autocomplete_response", self._handle_backend_response, True)
48
49    def handle_autocomplete_request(self):
50        row, column = self._get_position()
51        source = self.text.get("1.0", "end-1c")
52        get_runner().send_command(
53            InlineCommand(
54                "editor_autocomplete",
55                source=source,
56                row=row,
57                column=column,
58                filename=self._get_filename(),
59            )
60        )
61
62    def _handle_backend_response(self, msg):
63        row, column = self._get_position()
64        source = self.text.get("1.0", "end-1c")
65
66        if msg.source != source or msg.row != row or msg.column != column:
67            # situation has changed, information is obsolete
68            self._close()
69        elif msg.get("error"):
70            self._close()
71            messagebox.showerror("Autocomplete error", msg.error, master=self)
72        else:
73            self._present_completions(msg.completions)
74
75    def _present_completions(self, completions_):
76        # Check if autocompeted name starts with an underscore,
77        # if it doesn't - don't show names starting with '_'
78        source = self.text.get("insert linestart", tk.INSERT)
79        try:
80            current_source_chunk = re.split(r"\W", source)[-1]
81        except IndexError:
82            current_source_chunk = ""
83
84        if current_source_chunk.startswith("_"):
85            completions = completions_
86        else:
87            completions = [c for c in completions_ if not c.get("name", "_").startswith("_")]
88
89        self.completions = completions
90
91        # broadcast logging info
92        row, column = self._get_position()
93        get_workbench().event_generate(
94            "AutocompleteProposal",
95            text_widget=self.text,
96            row=row,
97            column=column,
98            proposal_count=len(completions),
99        )
100
101        # present
102        if len(completions) == 0:
103            self._close()
104        elif len(completions) == 1:
105            self._insert_completion(completions[0])  # insert the only completion
106            self._close()
107        else:
108            self._show_box(completions)
109
110    def _show_box(self, completions):
111        self.delete(0, self.size())
112        self.insert(0, *[c["name"] for c in completions])
113        self.activate(0)
114        self.selection_set(0)
115
116        # place box
117        if not self._is_visible():
118
119            # _, _, _, list_box_height = self.bbox(0)
120            height = 100  # min(150, list_box_height * len(completions) * 1.15)
121            typed_name_length = len(completions[0]["name"]) - len(completions[0]["complete"])
122            text_box_x, text_box_y, _, text_box_height = self.text.bbox(
123                "insert-%dc" % typed_name_length
124            )
125
126            # should the box appear below or above cursor?
127            space_below = self.master.winfo_height() - text_box_y - text_box_height
128            space_above = text_box_y
129
130            if space_below >= height or space_below > space_above:
131                height = min(height, space_below)
132                y = text_box_y + text_box_height
133            else:
134                height = min(height, space_above)
135                y = text_box_y - height
136
137            width = 400
138            self.place(x=text_box_x, y=y, width=width, height=height)
139
140            self._update_doc()
141
142    def _update_doc(self):
143        c = self._get_selected_completion()
144
145        if c is None:
146            self.doc_label["text"] = ""
147            self.doc_label.place_forget()
148        else:
149            docstring = c.get("docstring", None)
150            if docstring:
151                self.doc_label["text"] = docstring
152                self.doc_label.place(
153                    x=self.winfo_x() + self.winfo_width(),
154                    y=self.winfo_y(),
155                    width=400,
156                    height=self.winfo_height(),
157                )
158            else:
159                self.doc_label["text"] = ""
160                self.doc_label.place_forget()
161
162    def _is_visible(self):
163        return self.winfo_ismapped()
164
165    def _insert_completion(self, completion):
166        typed_len = len(completion["name"]) - len(completion["complete"])
167        typed_prefix = self.text.get("insert-{}c".format(typed_len), "insert")
168        get_workbench().event_generate(
169            "AutocompleteInsertion",
170            text_widget=self.text,
171            typed_prefix=typed_prefix,
172            completed_name=completion["name"],
173        )
174
175        if self._is_visible():
176            self._close()
177
178        if not completion["name"].startswith(typed_prefix):
179            # eg. case of the prefix was not correct
180            self.text.delete("insert-{}c".format(typed_len), "insert")
181            self.text.insert("insert", completion["name"])
182        else:
183            self.text.insert("insert", completion["complete"])
184
185    def _get_filename(self):
186        # TODO: allow completing in shell
187        if not isinstance(self.text, CodeViewText):
188            return None
189
190        codeview = self.text.master
191
192        editor = get_workbench().get_editor_notebook().get_current_editor()
193        if editor.get_code_view() is codeview:
194            return editor.get_filename()
195        else:
196            return None
197
198    def _move_selection(self, delta):
199        selected = self.curselection()
200        if len(selected) == 0:
201            index = 0
202        else:
203            index = selected[0]
204
205        index += delta
206        index = max(0, min(self.size() - 1, index))
207
208        self.selection_clear(0, self.size() - 1)
209        self.selection_set(index)
210        self.activate(index)
211        self.see(index)
212        self._update_doc()
213
214    def _get_request_id(self):
215        return "autocomplete_" + str(self.text.winfo_id())
216
217    def _get_position(self):
218        return map(int, self.text.index("insert").split("."))
219
220    def _on_text_keypress(self, event=None):
221        if not self._is_visible():
222            return None
223
224        if event.keysym == "Escape":
225            self._close()
226            return "break"
227        elif event.keysym in ["Up", "KP_Up"]:
228            self._move_selection(-1)
229            return "break"
230        elif event.keysym in ["Down", "KP_Down"]:
231            self._move_selection(1)
232            return "break"
233        elif event.keysym in ["Return", "KP_Enter", "Tab"]:
234            assert self.size() > 0
235            self._insert_current_selection()
236            return "break"
237
238        return None
239
240    def _insert_current_selection(self, event=None):
241        self._insert_completion(self._get_selected_completion())
242
243    def _get_selected_completion(self):
244        sel = self.curselection()
245        if len(sel) != 1:
246            return None
247
248        return self.completions[sel[0]]
249
250    def _on_text_change(self, event=None):
251        if self._is_visible():
252            self.handle_autocomplete_request()
253
254    def _close(self, event=None):
255        self.place_forget()
256        self.doc_label.place_forget()
257        self.text.focus_set()
258
259    def on_text_click(self, event=None):
260        if self._is_visible():
261            self._close()
262
263
264class ShellCompleter(Completer):
265    def _bind_result_event(self):
266        # TODO: remove binding when editor gets closed
267        get_workbench().bind("shell_autocomplete_response", self._handle_backend_response, True)
268
269    def handle_autocomplete_request(self):
270        source = self._get_prefix()
271
272        get_runner().send_command(InlineCommand("shell_autocomplete", source=source))
273
274    def _handle_backend_response(self, msg):
275        if hasattr(msg, "source"):
276            # check if the response is relevant for current state
277            if msg.source != self._get_prefix():
278                self._close()
279            elif msg.get("error"):
280                self._close()
281                messagebox.showerror("Autocomplete error", msg.error, master=self)
282            else:
283                self._present_completions(msg.completions)
284
285    def _get_prefix(self):
286        return self.text.get("input_start", "insert")  # TODO: allow multiple line input
287
288
289def handle_autocomplete_request(event=None):
290    if event is None:
291        text = get_workbench().focus_get()
292    else:
293        text = event.widget
294
295    _handle_autocomplete_request_for_text(text)
296
297
298def _handle_autocomplete_request_for_text(text):
299    if not hasattr(text, "autocompleter"):
300        if isinstance(text, (CodeViewText, ShellText)) and text.is_python_text():
301            if isinstance(text, CodeViewText):
302                text.autocompleter = Completer(text)
303            elif isinstance(text, ShellText):
304                text.autocompleter = ShellCompleter(text)
305            text.bind("<1>", text.autocompleter.on_text_click)
306        else:
307            return
308
309    text.autocompleter.handle_autocomplete_request()
310
311
312def patched_perform_midline_tab(text, event):
313    if text.is_python_text():
314        if isinstance(text, ShellText):
315            option_name = "edit.tab_complete_in_shell"
316        else:
317            option_name = "edit.tab_complete_in_editor"
318
319        if get_workbench().get_option(option_name):
320            if not text.has_selection():
321                _handle_autocomplete_request_for_text(text)
322                return "break"
323            else:
324                return None
325
326    return text.perform_dumb_tab(event)
327
328
329def load_plugin() -> None:
330
331    get_workbench().add_command(
332        "autocomplete",
333        "edit",
334        tr("Auto-complete"),
335        handle_autocomplete_request,
336        default_sequence="<Control-space>"
337        # TODO: tester
338    )
339
340    get_workbench().set_default("edit.tab_complete_in_editor", True)
341    get_workbench().set_default("edit.tab_complete_in_shell", True)
342
343    CodeViewText.perform_midline_tab = patched_perform_midline_tab  # type: ignore
344    ShellText.perform_midline_tab = patched_perform_midline_tab  # type: ignore
345