Coverage for autocomplete_w.py: 8%

316 statements  

« prev     ^ index     » next       coverage.py v7.2.5, created at 2023-05-11 13:22 -0700

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__': 498 ↛ 499line 498 didn't jump to line 499, because the condition on line 498 was never true

499 from unittest import main 

500 main('idlelib.idle_test.test_autocomplete_w', verbosity=2, exit=False) 

501 

502# TODO: autocomplete/w htest here