Coverage for sidebar.py: 16%

304 statements  

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

1"""Line numbering implementation for IDLE as an extension. 

2Includes BaseSideBar which can be extended for other sidebar based extensions 

3""" 

4import contextlib 

5import functools 

6import itertools 

7 

8import tkinter as tk 

9from tkinter.font import Font 

10from idlelib.config import idleConf 

11from idlelib.delegator import Delegator 

12from idlelib import macosx 

13 

14 

15def get_lineno(text, index): 

16 """Return the line number of an index in a Tk text widget.""" 

17 text_index = text.index(index) 

18 return int(float(text_index)) if text_index else None 

19 

20 

21def get_end_linenumber(text): 

22 """Return the number of the last line in a Tk text widget.""" 

23 return get_lineno(text, 'end-1c') 

24 

25 

26def get_displaylines(text, index): 

27 """Display height, in lines, of a logical line in a Tk text widget.""" 

28 res = text.count(f"{index} linestart", 

29 f"{index} lineend", 

30 "displaylines") 

31 return res[0] if res else 0 

32 

33def get_widget_padding(widget): 

34 """Get the total padding of a Tk widget, including its border.""" 

35 # TODO: use also in codecontext.py 

36 manager = widget.winfo_manager() 

37 if manager == 'pack': 

38 info = widget.pack_info() 

39 elif manager == 'grid': 

40 info = widget.grid_info() 

41 else: 

42 raise ValueError(f"Unsupported geometry manager: {manager}") 

43 

44 # All values are passed through getint(), since some 

45 # values may be pixel objects, which can't simply be added to ints. 

46 padx = sum(map(widget.tk.getint, [ 

47 info['padx'], 

48 widget.cget('padx'), 

49 widget.cget('border'), 

50 ])) 

51 pady = sum(map(widget.tk.getint, [ 

52 info['pady'], 

53 widget.cget('pady'), 

54 widget.cget('border'), 

55 ])) 

56 return padx, pady 

57 

58 

59@contextlib.contextmanager 

60def temp_enable_text_widget(text): 

61 text.configure(state=tk.NORMAL) 

62 try: 

63 yield 

64 finally: 

65 text.configure(state=tk.DISABLED) 

66 

67 

68class BaseSideBar: 

69 """A base class for sidebars using Text.""" 

70 def __init__(self, editwin): 

71 self.editwin = editwin 

72 self.parent = editwin.text_frame 

73 self.text = editwin.text 

74 

75 self.is_shown = False 

76 

77 self.main_widget = self.init_widgets() 

78 

79 self.bind_events() 

80 

81 self.update_font() 

82 self.update_colors() 

83 

84 def init_widgets(self): 

85 """Initialize the sidebar's widgets, returning the main widget.""" 

86 raise NotImplementedError 

87 

88 def update_font(self): 

89 """Update the sidebar text font, usually after config changes.""" 

90 raise NotImplementedError 

91 

92 def update_colors(self): 

93 """Update the sidebar text colors, usually after config changes.""" 

94 raise NotImplementedError 

95 

96 def grid(self): 

97 """Layout the widget, always using grid layout.""" 

98 raise NotImplementedError 

99 

100 def show_sidebar(self): 

101 if not self.is_shown: 

102 self.grid() 

103 self.is_shown = True 

104 

105 def hide_sidebar(self): 

106 if self.is_shown: 

107 self.main_widget.grid_forget() 

108 self.is_shown = False 

109 

110 def yscroll_event(self, *args, **kwargs): 

111 """Hook for vertical scrolling for sub-classes to override.""" 

112 raise NotImplementedError 

113 

114 def redirect_yscroll_event(self, *args, **kwargs): 

115 """Redirect vertical scrolling to the main editor text widget. 

116 

117 The scroll bar is also updated. 

118 """ 

119 self.editwin.vbar.set(*args) 

120 return self.yscroll_event(*args, **kwargs) 

121 

122 def redirect_focusin_event(self, event): 

123 """Redirect focus-in events to the main editor text widget.""" 

124 self.text.focus_set() 

125 return 'break' 

126 

127 def redirect_mousebutton_event(self, event, event_name): 

128 """Redirect mouse button events to the main editor text widget.""" 

129 self.text.focus_set() 

130 self.text.event_generate(event_name, x=0, y=event.y) 

131 return 'break' 

132 

133 def redirect_mousewheel_event(self, event): 

134 """Redirect mouse wheel events to the editwin text widget.""" 

135 self.text.event_generate('<MouseWheel>', 

136 x=0, y=event.y, delta=event.delta) 

137 return 'break' 

138 

139 def bind_events(self): 

140 self.text['yscrollcommand'] = self.redirect_yscroll_event 

141 

142 # Ensure focus is always redirected to the main editor text widget. 

143 self.main_widget.bind('<FocusIn>', self.redirect_focusin_event) 

144 

145 # Redirect mouse scrolling to the main editor text widget. 

146 # 

147 # Note that without this, scrolling with the mouse only scrolls 

148 # the line numbers. 

149 self.main_widget.bind('<MouseWheel>', self.redirect_mousewheel_event) 

150 

151 # Redirect mouse button events to the main editor text widget, 

152 # except for the left mouse button (1). 

153 # 

154 # Note: X-11 sends Button-4 and Button-5 events for the scroll wheel. 

155 def bind_mouse_event(event_name, target_event_name): 

156 handler = functools.partial(self.redirect_mousebutton_event, 

157 event_name=target_event_name) 

158 self.main_widget.bind(event_name, handler) 

159 

160 for button in [2, 3, 4, 5]: 

161 for event_name in (f'<Button-{button}>', 

162 f'<ButtonRelease-{button}>', 

163 f'<B{button}-Motion>', 

164 ): 

165 bind_mouse_event(event_name, target_event_name=event_name) 

166 

167 # Convert double- and triple-click events to normal click events, 

168 # since event_generate() doesn't allow generating such events. 

169 for event_name in (f'<Double-Button-{button}>', 

170 f'<Triple-Button-{button}>', 

171 ): 

172 bind_mouse_event(event_name, 

173 target_event_name=f'<Button-{button}>') 

174 

175 # start_line is set upon <Button-1> to allow selecting a range of rows 

176 # by dragging. It is cleared upon <ButtonRelease-1>. 

177 start_line = None 

178 

179 # last_y is initially set upon <B1-Leave> and is continuously updated 

180 # upon <B1-Motion>, until <B1-Enter> or the mouse button is released. 

181 # It is used in text_auto_scroll(), which is called repeatedly and 

182 # does have a mouse event available. 

183 last_y = None 

184 

185 # auto_scrolling_after_id is set whenever text_auto_scroll is 

186 # scheduled via .after(). It is used to stop the auto-scrolling 

187 # upon <B1-Enter>, as well as to avoid scheduling the function several 

188 # times in parallel. 

189 auto_scrolling_after_id = None 

190 

191 def drag_update_selection_and_insert_mark(y_coord): 

192 """Helper function for drag and selection event handlers.""" 

193 lineno = get_lineno(self.text, f"@0,{y_coord}") 

194 a, b = sorted([start_line, lineno]) 

195 self.text.tag_remove("sel", "1.0", "end") 

196 self.text.tag_add("sel", f"{a}.0", f"{b+1}.0") 

197 self.text.mark_set("insert", 

198 f"{lineno if lineno == a else lineno + 1}.0") 

199 

200 def b1_mousedown_handler(event): 

201 nonlocal start_line 

202 nonlocal last_y 

203 start_line = int(float(self.text.index(f"@0,{event.y}"))) 

204 last_y = event.y 

205 

206 drag_update_selection_and_insert_mark(event.y) 

207 self.main_widget.bind('<Button-1>', b1_mousedown_handler) 

208 

209 def b1_mouseup_handler(event): 

210 # On mouse up, we're no longer dragging. Set the shared persistent 

211 # variables to None to represent this. 

212 nonlocal start_line 

213 nonlocal last_y 

214 start_line = None 

215 last_y = None 

216 self.text.event_generate('<ButtonRelease-1>', x=0, y=event.y) 

217 self.main_widget.bind('<ButtonRelease-1>', b1_mouseup_handler) 

218 

219 def b1_drag_handler(event): 

220 nonlocal last_y 

221 if last_y is None: # i.e. if not currently dragging 

222 return 

223 last_y = event.y 

224 drag_update_selection_and_insert_mark(event.y) 

225 self.main_widget.bind('<B1-Motion>', b1_drag_handler) 

226 

227 def text_auto_scroll(): 

228 """Mimic Text auto-scrolling when dragging outside of it.""" 

229 # See: https://github.com/tcltk/tk/blob/064ff9941b4b80b85916a8afe86a6c21fd388b54/library/text.tcl#L670 

230 nonlocal auto_scrolling_after_id 

231 y = last_y 

232 if y is None: 

233 self.main_widget.after_cancel(auto_scrolling_after_id) 

234 auto_scrolling_after_id = None 

235 return 

236 elif y < 0: 

237 self.text.yview_scroll(-1 + y, 'pixels') 

238 drag_update_selection_and_insert_mark(y) 

239 elif y > self.main_widget.winfo_height(): 

240 self.text.yview_scroll(1 + y - self.main_widget.winfo_height(), 

241 'pixels') 

242 drag_update_selection_and_insert_mark(y) 

243 auto_scrolling_after_id = \ 

244 self.main_widget.after(50, text_auto_scroll) 

245 

246 def b1_leave_handler(event): 

247 # Schedule the initial call to text_auto_scroll(), if not already 

248 # scheduled. 

249 nonlocal auto_scrolling_after_id 

250 if auto_scrolling_after_id is None: 

251 nonlocal last_y 

252 last_y = event.y 

253 auto_scrolling_after_id = \ 

254 self.main_widget.after(0, text_auto_scroll) 

255 self.main_widget.bind('<B1-Leave>', b1_leave_handler) 

256 

257 def b1_enter_handler(event): 

258 # Cancel the scheduling of text_auto_scroll(), if it exists. 

259 nonlocal auto_scrolling_after_id 

260 if auto_scrolling_after_id is not None: 

261 self.main_widget.after_cancel(auto_scrolling_after_id) 

262 auto_scrolling_after_id = None 

263 self.main_widget.bind('<B1-Enter>', b1_enter_handler) 

264 

265 

266class EndLineDelegator(Delegator): 

267 """Generate callbacks with the current end line number. 

268 

269 The provided callback is called after every insert and delete. 

270 """ 

271 def __init__(self, changed_callback): 

272 Delegator.__init__(self) 

273 self.changed_callback = changed_callback 

274 

275 def insert(self, index, chars, tags=None): 

276 self.delegate.insert(index, chars, tags) 

277 self.changed_callback(get_end_linenumber(self.delegate)) 

278 

279 def delete(self, index1, index2=None): 

280 self.delegate.delete(index1, index2) 

281 self.changed_callback(get_end_linenumber(self.delegate)) 

282 

283 

284class LineNumbers(BaseSideBar): 

285 """Line numbers support for editor windows.""" 

286 def __init__(self, editwin): 

287 super().__init__(editwin) 

288 

289 end_line_delegator = EndLineDelegator(self.update_sidebar_text) 

290 # Insert the delegator after the undo delegator, so that line numbers 

291 # are properly updated after undo and redo actions. 

292 self.editwin.per.insertfilterafter(end_line_delegator, 

293 after=self.editwin.undo) 

294 

295 def init_widgets(self): 

296 _padx, pady = get_widget_padding(self.text) 

297 self.sidebar_text = tk.Text(self.parent, width=1, wrap=tk.NONE, 

298 padx=2, pady=pady, 

299 borderwidth=0, highlightthickness=0) 

300 self.sidebar_text.config(state=tk.DISABLED) 

301 

302 self.prev_end = 1 

303 self._sidebar_width_type = type(self.sidebar_text['width']) 

304 with temp_enable_text_widget(self.sidebar_text): 

305 self.sidebar_text.insert('insert', '1', 'linenumber') 

306 self.sidebar_text.config(takefocus=False, exportselection=False) 

307 self.sidebar_text.tag_config('linenumber', justify=tk.RIGHT) 

308 

309 end = get_end_linenumber(self.text) 

310 self.update_sidebar_text(end) 

311 

312 return self.sidebar_text 

313 

314 def grid(self): 

315 self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW) 

316 

317 def update_font(self): 

318 font = idleConf.GetFont(self.text, 'main', 'EditorWindow') 

319 self.sidebar_text['font'] = font 

320 

321 def update_colors(self): 

322 """Update the sidebar text colors, usually after config changes.""" 

323 colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber') 

324 foreground = colors['foreground'] 

325 background = colors['background'] 

326 self.sidebar_text.config( 

327 fg=foreground, bg=background, 

328 selectforeground=foreground, selectbackground=background, 

329 inactiveselectbackground=background, 

330 ) 

331 

332 def update_sidebar_text(self, end): 

333 """ 

334 Perform the following action: 

335 Each line sidebar_text contains the linenumber for that line 

336 Synchronize with editwin.text so that both sidebar_text and 

337 editwin.text contain the same number of lines""" 

338 if end == self.prev_end: 

339 return 

340 

341 width_difference = len(str(end)) - len(str(self.prev_end)) 

342 if width_difference: 

343 cur_width = int(float(self.sidebar_text['width'])) 

344 new_width = cur_width + width_difference 

345 self.sidebar_text['width'] = self._sidebar_width_type(new_width) 

346 

347 with temp_enable_text_widget(self.sidebar_text): 

348 if end > self.prev_end: 

349 new_text = '\n'.join(itertools.chain( 

350 [''], 

351 map(str, range(self.prev_end + 1, end + 1)), 

352 )) 

353 self.sidebar_text.insert(f'end -1c', new_text, 'linenumber') 

354 else: 

355 self.sidebar_text.delete(f'{end+1}.0 -1c', 'end -1c') 

356 

357 self.prev_end = end 

358 

359 def yscroll_event(self, *args, **kwargs): 

360 self.sidebar_text.yview_moveto(args[0]) 

361 return 'break' 

362 

363 

364class WrappedLineHeightChangeDelegator(Delegator): 

365 def __init__(self, callback): 

366 """ 

367 callback - Callable, will be called when an insert, delete or replace 

368 action on the text widget may require updating the shell 

369 sidebar. 

370 """ 

371 Delegator.__init__(self) 

372 self.callback = callback 

373 

374 def insert(self, index, chars, tags=None): 

375 is_single_line = '\n' not in chars 

376 if is_single_line: 

377 before_displaylines = get_displaylines(self, index) 

378 

379 self.delegate.insert(index, chars, tags) 

380 

381 if is_single_line: 

382 after_displaylines = get_displaylines(self, index) 

383 if after_displaylines == before_displaylines: 

384 return # no need to update the sidebar 

385 

386 self.callback() 

387 

388 def delete(self, index1, index2=None): 

389 if index2 is None: 

390 index2 = index1 + "+1c" 

391 is_single_line = get_lineno(self, index1) == get_lineno(self, index2) 

392 if is_single_line: 

393 before_displaylines = get_displaylines(self, index1) 

394 

395 self.delegate.delete(index1, index2) 

396 

397 if is_single_line: 

398 after_displaylines = get_displaylines(self, index1) 

399 if after_displaylines == before_displaylines: 

400 return # no need to update the sidebar 

401 

402 self.callback() 

403 

404 

405class ShellSidebar(BaseSideBar): 

406 """Sidebar for the PyShell window, for prompts etc.""" 

407 def __init__(self, editwin): 

408 self.canvas = None 

409 self.line_prompts = {} 

410 

411 super().__init__(editwin) 

412 

413 change_delegator = \ 

414 WrappedLineHeightChangeDelegator(self.change_callback) 

415 # Insert the TextChangeDelegator after the last delegator, so that 

416 # the sidebar reflects final changes to the text widget contents. 

417 d = self.editwin.per.top 

418 if d.delegate is not self.text: 

419 while d.delegate is not self.editwin.per.bottom: 

420 d = d.delegate 

421 self.editwin.per.insertfilterafter(change_delegator, after=d) 

422 

423 self.is_shown = True 

424 

425 def init_widgets(self): 

426 self.canvas = tk.Canvas(self.parent, width=30, 

427 borderwidth=0, highlightthickness=0, 

428 takefocus=False) 

429 self.update_sidebar() 

430 self.grid() 

431 return self.canvas 

432 

433 def bind_events(self): 

434 super().bind_events() 

435 

436 self.main_widget.bind( 

437 # AquaTk defines <2> as the right button, not <3>. 

438 "<Button-2>" if macosx.isAquaTk() else "<Button-3>", 

439 self.context_menu_event, 

440 ) 

441 

442 def context_menu_event(self, event): 

443 rmenu = tk.Menu(self.main_widget, tearoff=0) 

444 has_selection = bool(self.text.tag_nextrange('sel', '1.0')) 

445 def mkcmd(eventname): 

446 return lambda: self.text.event_generate(eventname) 

447 rmenu.add_command(label='Copy', 

448 command=mkcmd('<<copy>>'), 

449 state='normal' if has_selection else 'disabled') 

450 rmenu.add_command(label='Copy with prompts', 

451 command=mkcmd('<<copy-with-prompts>>'), 

452 state='normal' if has_selection else 'disabled') 

453 rmenu.tk_popup(event.x_root, event.y_root) 

454 return "break" 

455 

456 def grid(self): 

457 self.canvas.grid(row=1, column=0, sticky=tk.NSEW, padx=2, pady=0) 

458 

459 def change_callback(self): 

460 if self.is_shown: 

461 self.update_sidebar() 

462 

463 def update_sidebar(self): 

464 text = self.text 

465 text_tagnames = text.tag_names 

466 canvas = self.canvas 

467 line_prompts = self.line_prompts = {} 

468 

469 canvas.delete(tk.ALL) 

470 

471 index = text.index("@0,0") 

472 if index.split('.', 1)[1] != '0': 

473 index = text.index(f'{index}+1line linestart') 

474 while (lineinfo := text.dlineinfo(index)) is not None: 

475 y = lineinfo[1] 

476 prev_newline_tagnames = text_tagnames(f"{index} linestart -1c") 

477 prompt = ( 

478 '>>>' if "console" in prev_newline_tagnames else 

479 '...' if "stdin" in prev_newline_tagnames else 

480 None 

481 ) 

482 if prompt: 

483 canvas.create_text(2, y, anchor=tk.NW, text=prompt, 

484 font=self.font, fill=self.colors[0]) 

485 lineno = get_lineno(text, index) 

486 line_prompts[lineno] = prompt 

487 index = text.index(f'{index}+1line') 

488 

489 def yscroll_event(self, *args, **kwargs): 

490 """Redirect vertical scrolling to the main editor text widget. 

491 

492 The scroll bar is also updated. 

493 """ 

494 self.change_callback() 

495 return 'break' 

496 

497 def update_font(self): 

498 """Update the sidebar text font, usually after config changes.""" 

499 font = idleConf.GetFont(self.text, 'main', 'EditorWindow') 

500 tk_font = Font(self.text, font=font) 

501 char_width = max(tk_font.measure(char) for char in ['>', '.']) 

502 self.canvas.configure(width=char_width * 3 + 4) 

503 self.font = font 

504 self.change_callback() 

505 

506 def update_colors(self): 

507 """Update the sidebar text colors, usually after config changes.""" 

508 linenumbers_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber') 

509 prompt_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'console') 

510 foreground = prompt_colors['foreground'] 

511 background = linenumbers_colors['background'] 

512 self.colors = (foreground, background) 

513 self.canvas.configure(background=background) 

514 self.change_callback() 

515 

516 

517def _linenumbers_drag_scrolling(parent): # htest # 

518 from idlelib.idle_test.test_sidebar import Dummy_editwin 

519 

520 toplevel = tk.Toplevel(parent) 

521 text_frame = tk.Frame(toplevel) 

522 text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) 

523 text_frame.rowconfigure(1, weight=1) 

524 text_frame.columnconfigure(1, weight=1) 

525 

526 font = idleConf.GetFont(toplevel, 'main', 'EditorWindow') 

527 text = tk.Text(text_frame, width=80, height=24, wrap=tk.NONE, font=font) 

528 text.grid(row=1, column=1, sticky=tk.NSEW) 

529 

530 editwin = Dummy_editwin(text) 

531 editwin.vbar = tk.Scrollbar(text_frame) 

532 

533 linenumbers = LineNumbers(editwin) 

534 linenumbers.show_sidebar() 

535 

536 text.insert('1.0', '\n'.join('a'*i for i in range(1, 101))) 

537 

538 

539if __name__ == '__main__': 539 ↛ 540line 539 didn't jump to line 540, because the condition on line 539 was never true

540 from unittest import main 

541 main('idlelib.idle_test.test_sidebar', verbosity=2, exit=False) 

542 

543 from idlelib.idle_test.htest import run 

544 run(_linenumbers_drag_scrolling)