1""" 2An auto-completion window for IDLE, used by the autocomplete extension 3""" 4import platform 5 6from tkinter import * 7from tkinter.ttk import Scrollbar 8 9from idlelib.autocomplete import FILES, ATTRS 10from idlelib.multicall import MC_SHIFT 11 12HIDE_VIRTUAL_EVENT_NAME = "<<autocompletewindow-hide>>" 13HIDE_FOCUS_OUT_SEQUENCE = "<FocusOut>" 14HIDE_SEQUENCES = (HIDE_FOCUS_OUT_SEQUENCE, "<ButtonPress>") 15KEYPRESS_VIRTUAL_EVENT_NAME = "<<autocompletewindow-keypress>>" 16# We need to bind event beyond <Key> so that the function will be called 17# before the default specific IDLE function 18KEYPRESS_SEQUENCES = ("<Key>", "<Key-BackSpace>", "<Key-Return>", "<Key-Tab>", 19 "<Key-Up>", "<Key-Down>", "<Key-Home>", "<Key-End>", 20 "<Key-Prior>", "<Key-Next>", "<Key-Escape>") 21KEYRELEASE_VIRTUAL_EVENT_NAME = "<<autocompletewindow-keyrelease>>" 22KEYRELEASE_SEQUENCE = "<KeyRelease>" 23LISTUPDATE_SEQUENCE = "<B1-ButtonRelease>" 24WINCONFIG_SEQUENCE = "<Configure>" 25DOUBLECLICK_SEQUENCE = "<B1-Double-ButtonRelease>" 26 27class AutoCompleteWindow: 28 29 def __init__(self, widget, tags): 30 # The widget (Text) on which we place the AutoCompleteWindow 31 self.widget = widget 32 # Tags to mark inserted text with 33 self.tags = tags 34 # The widgets we create 35 self.autocompletewindow = self.listbox = self.scrollbar = None 36 # The default foreground and background of a selection. Saved because 37 # they are changed to the regular colors of list items when the 38 # completion start is not a prefix of the selected completion 39 self.origselforeground = self.origselbackground = None 40 # The list of completions 41 self.completions = None 42 # A list with more completions, or None 43 self.morecompletions = None 44 # The completion mode, either autocomplete.ATTRS or .FILES. 45 self.mode = None 46 # The current completion start, on the text box (a string) 47 self.start = None 48 # The index of the start of the completion 49 self.startindex = None 50 # The last typed start, used so that when the selection changes, 51 # the new start will be as close as possible to the last typed one. 52 self.lasttypedstart = None 53 # Do we have an indication that the user wants the completion window 54 # (for example, he clicked the list) 55 self.userwantswindow = None 56 # event ids 57 self.hideid = self.keypressid = self.listupdateid = \ 58 self.winconfigid = self.keyreleaseid = self.doubleclickid = None 59 # Flag set if last keypress was a tab 60 self.lastkey_was_tab = False 61 # Flag set to avoid recursive <Configure> callback invocations. 62 self.is_configuring = False 63 64 def _change_start(self, newstart): 65 min_len = min(len(self.start), len(newstart)) 66 i = 0 67 while i < min_len and self.start[i] == newstart[i]: 68 i += 1 69 if i < len(self.start): 70 self.widget.delete("%s+%dc" % (self.startindex, i), 71 "%s+%dc" % (self.startindex, len(self.start))) 72 if i < len(newstart): 73 self.widget.insert("%s+%dc" % (self.startindex, i), 74 newstart[i:], 75 self.tags) 76 self.start = newstart 77 78 def _binary_search(self, s): 79 """Find the first index in self.completions where completions[i] is 80 greater or equal to s, or the last index if there is no such. 81 """ 82 i = 0; j = len(self.completions) 83 while j > i: 84 m = (i + j) // 2 85 if self.completions[m] >= s: 86 j = m 87 else: 88 i = m + 1 89 return min(i, len(self.completions)-1) 90 91 def _complete_string(self, s): 92 """Assuming that s is the prefix of a string in self.completions, 93 return the longest string which is a prefix of all the strings which 94 s is a prefix of them. If s is not a prefix of a string, return s. 95 """ 96 first = self._binary_search(s) 97 if self.completions[first][:len(s)] != s: 98 # There is not even one completion which s is a prefix of. 99 return s 100 # Find the end of the range of completions where s is a prefix of. 101 i = first + 1 102 j = len(self.completions) 103 while j > i: 104 m = (i + j) // 2 105 if self.completions[m][:len(s)] != s: 106 j = m 107 else: 108 i = m + 1 109 last = i-1 110 111 if first == last: # only one possible completion 112 return self.completions[first] 113 114 # We should return the maximum prefix of first and last 115 first_comp = self.completions[first] 116 last_comp = self.completions[last] 117 min_len = min(len(first_comp), len(last_comp)) 118 i = len(s) 119 while i < min_len and first_comp[i] == last_comp[i]: 120 i += 1 121 return first_comp[:i] 122 123 def _selection_changed(self): 124 """Call when the selection of the Listbox has changed. 125 126 Updates the Listbox display and calls _change_start. 127 """ 128 cursel = int(self.listbox.curselection()[0]) 129 130 self.listbox.see(cursel) 131 132 lts = self.lasttypedstart 133 selstart = self.completions[cursel] 134 if self._binary_search(lts) == cursel: 135 newstart = lts 136 else: 137 min_len = min(len(lts), len(selstart)) 138 i = 0 139 while i < min_len and lts[i] == selstart[i]: 140 i += 1 141 newstart = selstart[:i] 142 self._change_start(newstart) 143 144 if self.completions[cursel][:len(self.start)] == self.start: 145 # start is a prefix of the selected completion 146 self.listbox.configure(selectbackground=self.origselbackground, 147 selectforeground=self.origselforeground) 148 else: 149 self.listbox.configure(selectbackground=self.listbox.cget("bg"), 150 selectforeground=self.listbox.cget("fg")) 151 # If there are more completions, show them, and call me again. 152 if self.morecompletions: 153 self.completions = self.morecompletions 154 self.morecompletions = None 155 self.listbox.delete(0, END) 156 for item in self.completions: 157 self.listbox.insert(END, item) 158 self.listbox.select_set(self._binary_search(self.start)) 159 self._selection_changed() 160 161 def show_window(self, comp_lists, index, complete, mode, userWantsWin): 162 """Show the autocomplete list, bind events. 163 164 If complete is True, complete the text, and if there is exactly 165 one matching completion, don't open a list. 166 """ 167 # Handle the start we already have 168 self.completions, self.morecompletions = comp_lists 169 self.mode = mode 170 self.startindex = self.widget.index(index) 171 self.start = self.widget.get(self.startindex, "insert") 172 if complete: 173 completed = self._complete_string(self.start) 174 start = self.start 175 self._change_start(completed) 176 i = self._binary_search(completed) 177 if self.completions[i] == completed and \ 178 (i == len(self.completions)-1 or 179 self.completions[i+1][:len(completed)] != completed): 180 # There is exactly one matching completion 181 return completed == start 182 self.userwantswindow = userWantsWin 183 self.lasttypedstart = self.start 184 185 self.autocompletewindow = acw = Toplevel(self.widget) 186 acw.withdraw() 187 acw.wm_overrideredirect(1) 188 try: 189 # Prevent grabbing focus on macOS. 190 acw.tk.call("::tk::unsupported::MacWindowStyle", "style", acw._w, 191 "help", "noActivates") 192 except TclError: 193 pass 194 self.scrollbar = scrollbar = Scrollbar(acw, orient=VERTICAL) 195 self.listbox = listbox = Listbox(acw, yscrollcommand=scrollbar.set, 196 exportselection=False) 197 for item in self.completions: 198 listbox.insert(END, item) 199 self.origselforeground = listbox.cget("selectforeground") 200 self.origselbackground = listbox.cget("selectbackground") 201 scrollbar.config(command=listbox.yview) 202 scrollbar.pack(side=RIGHT, fill=Y) 203 listbox.pack(side=LEFT, fill=BOTH, expand=True) 204 #acw.update_idletasks() # Need for tk8.6.8 on macOS: #40128. 205 acw.lift() # work around bug in Tk 8.5.18+ (issue #24570) 206 207 # Initialize the listbox selection 208 self.listbox.select_set(self._binary_search(self.start)) 209 self._selection_changed() 210 211 # bind events 212 self.hideaid = acw.bind(HIDE_VIRTUAL_EVENT_NAME, self.hide_event) 213 self.hidewid = self.widget.bind(HIDE_VIRTUAL_EVENT_NAME, self.hide_event) 214 acw.event_add(HIDE_VIRTUAL_EVENT_NAME, HIDE_FOCUS_OUT_SEQUENCE) 215 for seq in HIDE_SEQUENCES: 216 self.widget.event_add(HIDE_VIRTUAL_EVENT_NAME, seq) 217 218 self.keypressid = self.widget.bind(KEYPRESS_VIRTUAL_EVENT_NAME, 219 self.keypress_event) 220 for seq in KEYPRESS_SEQUENCES: 221 self.widget.event_add(KEYPRESS_VIRTUAL_EVENT_NAME, seq) 222 self.keyreleaseid = self.widget.bind(KEYRELEASE_VIRTUAL_EVENT_NAME, 223 self.keyrelease_event) 224 self.widget.event_add(KEYRELEASE_VIRTUAL_EVENT_NAME,KEYRELEASE_SEQUENCE) 225 self.listupdateid = listbox.bind(LISTUPDATE_SEQUENCE, 226 self.listselect_event) 227 self.is_configuring = False 228 self.winconfigid = acw.bind(WINCONFIG_SEQUENCE, self.winconfig_event) 229 self.doubleclickid = listbox.bind(DOUBLECLICK_SEQUENCE, 230 self.doubleclick_event) 231 return None 232 233 def winconfig_event(self, event): 234 if self.is_configuring: 235 # Avoid running on recursive <Configure> callback invocations. 236 return 237 238 self.is_configuring = True 239 if not self.is_active(): 240 return 241 242 # Since the <Configure> event may occur after the completion window is gone, 243 # catch potential TclError exceptions when accessing acw. See: bpo-41611. 244 try: 245 # Position the completion list window 246 text = self.widget 247 text.see(self.startindex) 248 x, y, cx, cy = text.bbox(self.startindex) 249 acw = self.autocompletewindow 250 if platform.system().startswith('Windows'): 251 # On Windows an update() call is needed for the completion 252 # list window to be created, so that we can fetch its width 253 # and height. However, this is not needed on other platforms 254 # (tested on Ubuntu and macOS) but at one point began 255 # causing freezes on macOS. See issues 37849 and 41611. 256 acw.update() 257 acw_width, acw_height = acw.winfo_width(), acw.winfo_height() 258 text_width, text_height = text.winfo_width(), text.winfo_height() 259 new_x = text.winfo_rootx() + min(x, max(0, text_width - acw_width)) 260 new_y = text.winfo_rooty() + y 261 if (text_height - (y + cy) >= acw_height # enough height below 262 or y < acw_height): # not enough height above 263 # place acw below current line 264 new_y += cy 265 else: 266 # place acw above current line 267 new_y -= acw_height 268 acw.wm_geometry("+%d+%d" % (new_x, new_y)) 269 acw.deiconify() 270 acw.update_idletasks() 271 except TclError: 272 pass 273 274 if platform.system().startswith('Windows'): 275 # See issue 15786. When on Windows platform, Tk will misbehave 276 # to call winconfig_event multiple times, we need to prevent this, 277 # otherwise mouse button double click will not be able to used. 278 try: 279 acw.unbind(WINCONFIG_SEQUENCE, self.winconfigid) 280 except TclError: 281 pass 282 self.winconfigid = None 283 284 self.is_configuring = False 285 286 def _hide_event_check(self): 287 if not self.autocompletewindow: 288 return 289 290 try: 291 if not self.autocompletewindow.focus_get(): 292 self.hide_window() 293 except KeyError: 294 # See issue 734176, when user click on menu, acw.focus_get() 295 # will get KeyError. 296 self.hide_window() 297 298 def hide_event(self, event): 299 # Hide autocomplete list if it exists and does not have focus or 300 # mouse click on widget / text area. 301 if self.is_active(): 302 if event.type == EventType.FocusOut: 303 # On Windows platform, it will need to delay the check for 304 # acw.focus_get() when click on acw, otherwise it will return 305 # None and close the window 306 self.widget.after(1, self._hide_event_check) 307 elif event.type == EventType.ButtonPress: 308 # ButtonPress event only bind to self.widget 309 self.hide_window() 310 311 def listselect_event(self, event): 312 if self.is_active(): 313 self.userwantswindow = True 314 cursel = int(self.listbox.curselection()[0]) 315 self._change_start(self.completions[cursel]) 316 317 def doubleclick_event(self, event): 318 # Put the selected completion in the text, and close the list 319 cursel = int(self.listbox.curselection()[0]) 320 self._change_start(self.completions[cursel]) 321 self.hide_window() 322 323 def keypress_event(self, event): 324 if not self.is_active(): 325 return None 326 keysym = event.keysym 327 if hasattr(event, "mc_state"): 328 state = event.mc_state 329 else: 330 state = 0 331 if keysym != "Tab": 332 self.lastkey_was_tab = False 333 if (len(keysym) == 1 or keysym in ("underscore", "BackSpace") 334 or (self.mode == FILES and keysym in 335 ("period", "minus"))) \ 336 and not (state & ~MC_SHIFT): 337 # Normal editing of text 338 if len(keysym) == 1: 339 self._change_start(self.start + keysym) 340 elif keysym == "underscore": 341 self._change_start(self.start + '_') 342 elif keysym == "period": 343 self._change_start(self.start + '.') 344 elif keysym == "minus": 345 self._change_start(self.start + '-') 346 else: 347 # keysym == "BackSpace" 348 if len(self.start) == 0: 349 self.hide_window() 350 return None 351 self._change_start(self.start[:-1]) 352 self.lasttypedstart = self.start 353 self.listbox.select_clear(0, int(self.listbox.curselection()[0])) 354 self.listbox.select_set(self._binary_search(self.start)) 355 self._selection_changed() 356 return "break" 357 358 elif keysym == "Return": 359 self.complete() 360 self.hide_window() 361 return 'break' 362 363 elif (self.mode == ATTRS and keysym in 364 ("period", "space", "parenleft", "parenright", "bracketleft", 365 "bracketright")) or \ 366 (self.mode == FILES and keysym in 367 ("slash", "backslash", "quotedbl", "apostrophe")) \ 368 and not (state & ~MC_SHIFT): 369 # If start is a prefix of the selection, but is not '' when 370 # completing file names, put the whole 371 # selected completion. Anyway, close the list. 372 cursel = int(self.listbox.curselection()[0]) 373 if self.completions[cursel][:len(self.start)] == self.start \ 374 and (self.mode == ATTRS or self.start): 375 self._change_start(self.completions[cursel]) 376 self.hide_window() 377 return None 378 379 elif keysym in ("Home", "End", "Prior", "Next", "Up", "Down") and \ 380 not state: 381 # Move the selection in the listbox 382 self.userwantswindow = True 383 cursel = int(self.listbox.curselection()[0]) 384 if keysym == "Home": 385 newsel = 0 386 elif keysym == "End": 387 newsel = len(self.completions)-1 388 elif keysym in ("Prior", "Next"): 389 jump = self.listbox.nearest(self.listbox.winfo_height()) - \ 390 self.listbox.nearest(0) 391 if keysym == "Prior": 392 newsel = max(0, cursel-jump) 393 else: 394 assert keysym == "Next" 395 newsel = min(len(self.completions)-1, cursel+jump) 396 elif keysym == "Up": 397 newsel = max(0, cursel-1) 398 else: 399 assert keysym == "Down" 400 newsel = min(len(self.completions)-1, cursel+1) 401 self.listbox.select_clear(cursel) 402 self.listbox.select_set(newsel) 403 self._selection_changed() 404 self._change_start(self.completions[newsel]) 405 return "break" 406 407 elif (keysym == "Tab" and not state): 408 if self.lastkey_was_tab: 409 # two tabs in a row; insert current selection and close acw 410 cursel = int(self.listbox.curselection()[0]) 411 self._change_start(self.completions[cursel]) 412 self.hide_window() 413 return "break" 414 else: 415 # first tab; let AutoComplete handle the completion 416 self.userwantswindow = True 417 self.lastkey_was_tab = True 418 return None 419 420 elif any(s in keysym for s in ("Shift", "Control", "Alt", 421 "Meta", "Command", "Option")): 422 # A modifier key, so ignore 423 return None 424 425 elif event.char and event.char >= ' ': 426 # Regular character with a non-length-1 keycode 427 self._change_start(self.start + event.char) 428 self.lasttypedstart = self.start 429 self.listbox.select_clear(0, int(self.listbox.curselection()[0])) 430 self.listbox.select_set(self._binary_search(self.start)) 431 self._selection_changed() 432 return "break" 433 434 else: 435 # Unknown event, close the window and let it through. 436 self.hide_window() 437 return None 438 439 def keyrelease_event(self, event): 440 if not self.is_active(): 441 return 442 if self.widget.index("insert") != \ 443 self.widget.index("%s+%dc" % (self.startindex, len(self.start))): 444 # If we didn't catch an event which moved the insert, close window 445 self.hide_window() 446 447 def is_active(self): 448 return self.autocompletewindow is not None 449 450 def complete(self): 451 self._change_start(self._complete_string(self.start)) 452 # The selection doesn't change. 453 454 def hide_window(self): 455 if not self.is_active(): 456 return 457 458 # unbind events 459 self.autocompletewindow.event_delete(HIDE_VIRTUAL_EVENT_NAME, 460 HIDE_FOCUS_OUT_SEQUENCE) 461 for seq in HIDE_SEQUENCES: 462 self.widget.event_delete(HIDE_VIRTUAL_EVENT_NAME, seq) 463 464 self.autocompletewindow.unbind(HIDE_VIRTUAL_EVENT_NAME, self.hideaid) 465 self.widget.unbind(HIDE_VIRTUAL_EVENT_NAME, self.hidewid) 466 self.hideaid = None 467 self.hidewid = None 468 for seq in KEYPRESS_SEQUENCES: 469 self.widget.event_delete(KEYPRESS_VIRTUAL_EVENT_NAME, seq) 470 self.widget.unbind(KEYPRESS_VIRTUAL_EVENT_NAME, self.keypressid) 471 self.keypressid = None 472 self.widget.event_delete(KEYRELEASE_VIRTUAL_EVENT_NAME, 473 KEYRELEASE_SEQUENCE) 474 self.widget.unbind(KEYRELEASE_VIRTUAL_EVENT_NAME, self.keyreleaseid) 475 self.keyreleaseid = None 476 self.listbox.unbind(LISTUPDATE_SEQUENCE, self.listupdateid) 477 self.listupdateid = None 478 if self.winconfigid: 479 self.autocompletewindow.unbind(WINCONFIG_SEQUENCE, self.winconfigid) 480 self.winconfigid = None 481 482 # Re-focusOn frame.text (See issue #15786) 483 self.widget.focus_set() 484 485 # destroy widgets 486 self.scrollbar.destroy() 487 self.scrollbar = None 488 self.listbox.destroy() 489 self.listbox = None 490 self.autocompletewindow.destroy() 491 self.autocompletewindow = None 492 493 494if __name__ == '__main__': 495 from unittest import main 496 main('idlelib.idle_test.test_autocomplete_w', verbosity=2, exit=False) 497 498# TODO: autocomplete/w htest here 499