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