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 # Put widgets in place 186 self.autocompletewindow = acw = Toplevel(self.widget) 187 # Put it in a position so that it is not seen. 188 acw.wm_geometry("+10000+10000") 189 # Make it float 190 acw.wm_overrideredirect(1) 191 try: 192 # This command is only needed and available on Tk >= 8.4.0 for OSX 193 # Without it, call tips intrude on the typing process by grabbing 194 # the focus. 195 acw.tk.call("::tk::unsupported::MacWindowStyle", "style", acw._w, 196 "help", "noActivates") 197 except TclError: 198 pass 199 self.scrollbar = scrollbar = Scrollbar(acw, orient=VERTICAL) 200 self.listbox = listbox = Listbox(acw, yscrollcommand=scrollbar.set, 201 exportselection=False) 202 for item in self.completions: 203 listbox.insert(END, item) 204 self.origselforeground = listbox.cget("selectforeground") 205 self.origselbackground = listbox.cget("selectbackground") 206 scrollbar.config(command=listbox.yview) 207 scrollbar.pack(side=RIGHT, fill=Y) 208 listbox.pack(side=LEFT, fill=BOTH, expand=True) 209 #acw.update_idletasks() # Need for tk8.6.8 on macOS: #40128. 210 acw.lift() # work around bug in Tk 8.5.18+ (issue #24570) 211 212 # Initialize the listbox selection 213 self.listbox.select_set(self._binary_search(self.start)) 214 self._selection_changed() 215 216 # bind events 217 self.hideaid = acw.bind(HIDE_VIRTUAL_EVENT_NAME, self.hide_event) 218 self.hidewid = self.widget.bind(HIDE_VIRTUAL_EVENT_NAME, self.hide_event) 219 acw.event_add(HIDE_VIRTUAL_EVENT_NAME, HIDE_FOCUS_OUT_SEQUENCE) 220 for seq in HIDE_SEQUENCES: 221 self.widget.event_add(HIDE_VIRTUAL_EVENT_NAME, seq) 222 223 self.keypressid = self.widget.bind(KEYPRESS_VIRTUAL_EVENT_NAME, 224 self.keypress_event) 225 for seq in KEYPRESS_SEQUENCES: 226 self.widget.event_add(KEYPRESS_VIRTUAL_EVENT_NAME, seq) 227 self.keyreleaseid = self.widget.bind(KEYRELEASE_VIRTUAL_EVENT_NAME, 228 self.keyrelease_event) 229 self.widget.event_add(KEYRELEASE_VIRTUAL_EVENT_NAME,KEYRELEASE_SEQUENCE) 230 self.listupdateid = listbox.bind(LISTUPDATE_SEQUENCE, 231 self.listselect_event) 232 self.is_configuring = False 233 self.winconfigid = acw.bind(WINCONFIG_SEQUENCE, self.winconfig_event) 234 self.doubleclickid = listbox.bind(DOUBLECLICK_SEQUENCE, 235 self.doubleclick_event) 236 return None 237 238 def winconfig_event(self, event): 239 if self.is_configuring: 240 # Avoid running on recursive <Configure> callback invocations. 241 return 242 243 self.is_configuring = True 244 if not self.is_active(): 245 return 246 247 # Since the <Configure> event may occur after the completion window is gone, 248 # catch potential TclError exceptions when accessing acw. See: bpo-41611. 249 try: 250 # Position the completion list window 251 text = self.widget 252 text.see(self.startindex) 253 x, y, cx, cy = text.bbox(self.startindex) 254 acw = self.autocompletewindow 255 if platform.system().startswith('Windows'): 256 # On Windows an update() call is needed for the completion 257 # list window to be created, so that we can fetch its width 258 # and height. However, this is not needed on other platforms 259 # (tested on Ubuntu and macOS) but at one point began 260 # causing freezes on macOS. See issues 37849 and 41611. 261 acw.update() 262 acw_width, acw_height = acw.winfo_width(), acw.winfo_height() 263 text_width, text_height = text.winfo_width(), text.winfo_height() 264 new_x = text.winfo_rootx() + min(x, max(0, text_width - acw_width)) 265 new_y = text.winfo_rooty() + y 266 if (text_height - (y + cy) >= acw_height # enough height below 267 or y < acw_height): # not enough height above 268 # place acw below current line 269 new_y += cy 270 else: 271 # place acw above current line 272 new_y -= acw_height 273 acw.wm_geometry("+%d+%d" % (new_x, new_y)) 274 acw.update_idletasks() 275 except TclError: 276 pass 277 278 if platform.system().startswith('Windows'): 279 # See issue 15786. When on Windows platform, Tk will misbehave 280 # to call winconfig_event multiple times, we need to prevent this, 281 # otherwise mouse button double click will not be able to used. 282 try: 283 acw.unbind(WINCONFIG_SEQUENCE, self.winconfigid) 284 except TclError: 285 pass 286 self.winconfigid = None 287 288 self.is_configuring = False 289 290 def _hide_event_check(self): 291 if not self.autocompletewindow: 292 return 293 294 try: 295 if not self.autocompletewindow.focus_get(): 296 self.hide_window() 297 except KeyError: 298 # See issue 734176, when user click on menu, acw.focus_get() 299 # will get KeyError. 300 self.hide_window() 301 302 def hide_event(self, event): 303 # Hide autocomplete list if it exists and does not have focus or 304 # mouse click on widget / text area. 305 if self.is_active(): 306 if event.type == EventType.FocusOut: 307 # On Windows platform, it will need to delay the check for 308 # acw.focus_get() when click on acw, otherwise it will return 309 # None and close the window 310 self.widget.after(1, self._hide_event_check) 311 elif event.type == EventType.ButtonPress: 312 # ButtonPress event only bind to self.widget 313 self.hide_window() 314 315 def listselect_event(self, event): 316 if self.is_active(): 317 self.userwantswindow = True 318 cursel = int(self.listbox.curselection()[0]) 319 self._change_start(self.completions[cursel]) 320 321 def doubleclick_event(self, event): 322 # Put the selected completion in the text, and close the list 323 cursel = int(self.listbox.curselection()[0]) 324 self._change_start(self.completions[cursel]) 325 self.hide_window() 326 327 def keypress_event(self, event): 328 if not self.is_active(): 329 return None 330 keysym = event.keysym 331 if hasattr(event, "mc_state"): 332 state = event.mc_state 333 else: 334 state = 0 335 if keysym != "Tab": 336 self.lastkey_was_tab = False 337 if (len(keysym) == 1 or keysym in ("underscore", "BackSpace") 338 or (self.mode == FILES and keysym in 339 ("period", "minus"))) \ 340 and not (state & ~MC_SHIFT): 341 # Normal editing of text 342 if len(keysym) == 1: 343 self._change_start(self.start + keysym) 344 elif keysym == "underscore": 345 self._change_start(self.start + '_') 346 elif keysym == "period": 347 self._change_start(self.start + '.') 348 elif keysym == "minus": 349 self._change_start(self.start + '-') 350 else: 351 # keysym == "BackSpace" 352 if len(self.start) == 0: 353 self.hide_window() 354 return None 355 self._change_start(self.start[:-1]) 356 self.lasttypedstart = self.start 357 self.listbox.select_clear(0, int(self.listbox.curselection()[0])) 358 self.listbox.select_set(self._binary_search(self.start)) 359 self._selection_changed() 360 return "break" 361 362 elif keysym == "Return": 363 self.complete() 364 self.hide_window() 365 return 'break' 366 367 elif (self.mode == ATTRS and keysym in 368 ("period", "space", "parenleft", "parenright", "bracketleft", 369 "bracketright")) or \ 370 (self.mode == FILES and keysym in 371 ("slash", "backslash", "quotedbl", "apostrophe")) \ 372 and not (state & ~MC_SHIFT): 373 # If start is a prefix of the selection, but is not '' when 374 # completing file names, put the whole 375 # selected completion. Anyway, close the list. 376 cursel = int(self.listbox.curselection()[0]) 377 if self.completions[cursel][:len(self.start)] == self.start \ 378 and (self.mode == ATTRS or self.start): 379 self._change_start(self.completions[cursel]) 380 self.hide_window() 381 return None 382 383 elif keysym in ("Home", "End", "Prior", "Next", "Up", "Down") and \ 384 not state: 385 # Move the selection in the listbox 386 self.userwantswindow = True 387 cursel = int(self.listbox.curselection()[0]) 388 if keysym == "Home": 389 newsel = 0 390 elif keysym == "End": 391 newsel = len(self.completions)-1 392 elif keysym in ("Prior", "Next"): 393 jump = self.listbox.nearest(self.listbox.winfo_height()) - \ 394 self.listbox.nearest(0) 395 if keysym == "Prior": 396 newsel = max(0, cursel-jump) 397 else: 398 assert keysym == "Next" 399 newsel = min(len(self.completions)-1, cursel+jump) 400 elif keysym == "Up": 401 newsel = max(0, cursel-1) 402 else: 403 assert keysym == "Down" 404 newsel = min(len(self.completions)-1, cursel+1) 405 self.listbox.select_clear(cursel) 406 self.listbox.select_set(newsel) 407 self._selection_changed() 408 self._change_start(self.completions[newsel]) 409 return "break" 410 411 elif (keysym == "Tab" and not state): 412 if self.lastkey_was_tab: 413 # two tabs in a row; insert current selection and close acw 414 cursel = int(self.listbox.curselection()[0]) 415 self._change_start(self.completions[cursel]) 416 self.hide_window() 417 return "break" 418 else: 419 # first tab; let AutoComplete handle the completion 420 self.userwantswindow = True 421 self.lastkey_was_tab = True 422 return None 423 424 elif any(s in keysym for s in ("Shift", "Control", "Alt", 425 "Meta", "Command", "Option")): 426 # A modifier key, so ignore 427 return None 428 429 elif event.char and event.char >= ' ': 430 # Regular character with a non-length-1 keycode 431 self._change_start(self.start + event.char) 432 self.lasttypedstart = self.start 433 self.listbox.select_clear(0, int(self.listbox.curselection()[0])) 434 self.listbox.select_set(self._binary_search(self.start)) 435 self._selection_changed() 436 return "break" 437 438 else: 439 # Unknown event, close the window and let it through. 440 self.hide_window() 441 return None 442 443 def keyrelease_event(self, event): 444 if not self.is_active(): 445 return 446 if self.widget.index("insert") != \ 447 self.widget.index("%s+%dc" % (self.startindex, len(self.start))): 448 # If we didn't catch an event which moved the insert, close window 449 self.hide_window() 450 451 def is_active(self): 452 return self.autocompletewindow is not None 453 454 def complete(self): 455 self._change_start(self._complete_string(self.start)) 456 # The selection doesn't change. 457 458 def hide_window(self): 459 if not self.is_active(): 460 return 461 462 # unbind events 463 self.autocompletewindow.event_delete(HIDE_VIRTUAL_EVENT_NAME, 464 HIDE_FOCUS_OUT_SEQUENCE) 465 for seq in HIDE_SEQUENCES: 466 self.widget.event_delete(HIDE_VIRTUAL_EVENT_NAME, seq) 467 468 self.autocompletewindow.unbind(HIDE_VIRTUAL_EVENT_NAME, self.hideaid) 469 self.widget.unbind(HIDE_VIRTUAL_EVENT_NAME, self.hidewid) 470 self.hideaid = None 471 self.hidewid = None 472 for seq in KEYPRESS_SEQUENCES: 473 self.widget.event_delete(KEYPRESS_VIRTUAL_EVENT_NAME, seq) 474 self.widget.unbind(KEYPRESS_VIRTUAL_EVENT_NAME, self.keypressid) 475 self.keypressid = None 476 self.widget.event_delete(KEYRELEASE_VIRTUAL_EVENT_NAME, 477 KEYRELEASE_SEQUENCE) 478 self.widget.unbind(KEYRELEASE_VIRTUAL_EVENT_NAME, self.keyreleaseid) 479 self.keyreleaseid = None 480 self.listbox.unbind(LISTUPDATE_SEQUENCE, self.listupdateid) 481 self.listupdateid = None 482 if self.winconfigid: 483 self.autocompletewindow.unbind(WINCONFIG_SEQUENCE, self.winconfigid) 484 self.winconfigid = None 485 486 # Re-focusOn frame.text (See issue #15786) 487 self.widget.focus_set() 488 489 # destroy widgets 490 self.scrollbar.destroy() 491 self.scrollbar = None 492 self.listbox.destroy() 493 self.listbox = None 494 self.autocompletewindow.destroy() 495 self.autocompletewindow = None 496 497 498if __name__ == '__main__': 499 from unittest import main 500 main('idlelib.idle_test.test_autocomplete_w', verbosity=2, exit=False) 501 502# TODO: autocomplete/w htest here 503