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 True: 475 lineinfo = text.dlineinfo(index) 476 if lineinfo is None: 477 break 478 y = lineinfo[1] 479 prev_newline_tagnames = text_tagnames(f"{index} linestart -1c") 480 prompt = ( 481 '>>>' if "console" in prev_newline_tagnames else 482 '...' if "stdin" in prev_newline_tagnames else 483 None 484 ) 485 if prompt: 486 canvas.create_text(2, y, anchor=tk.NW, text=prompt, 487 font=self.font, fill=self.colors[0]) 488 lineno = get_lineno(text, index) 489 line_prompts[lineno] = prompt 490 index = text.index(f'{index}+1line') 491 492 def yscroll_event(self, *args, **kwargs): 493 """Redirect vertical scrolling to the main editor text widget. 494 495 The scroll bar is also updated. 496 """ 497 self.change_callback() 498 return 'break' 499 500 def update_font(self): 501 """Update the sidebar text font, usually after config changes.""" 502 font = idleConf.GetFont(self.text, 'main', 'EditorWindow') 503 tk_font = Font(self.text, font=font) 504 char_width = max(tk_font.measure(char) for char in ['>', '.']) 505 self.canvas.configure(width=char_width * 3 + 4) 506 self.font = font 507 self.change_callback() 508 509 def update_colors(self): 510 """Update the sidebar text colors, usually after config changes.""" 511 linenumbers_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber') 512 prompt_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'console') 513 foreground = prompt_colors['foreground'] 514 background = linenumbers_colors['background'] 515 self.colors = (foreground, background) 516 self.canvas.configure(background=background) 517 self.change_callback() 518 519 520def _linenumbers_drag_scrolling(parent): # htest # 521 from idlelib.idle_test.test_sidebar import Dummy_editwin 522 523 toplevel = tk.Toplevel(parent) 524 text_frame = tk.Frame(toplevel) 525 text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) 526 text_frame.rowconfigure(1, weight=1) 527 text_frame.columnconfigure(1, weight=1) 528 529 font = idleConf.GetFont(toplevel, 'main', 'EditorWindow') 530 text = tk.Text(text_frame, width=80, height=24, wrap=tk.NONE, font=font) 531 text.grid(row=1, column=1, sticky=tk.NSEW) 532 533 editwin = Dummy_editwin(text) 534 editwin.vbar = tk.Scrollbar(text_frame) 535 536 linenumbers = LineNumbers(editwin) 537 linenumbers.show_sidebar() 538 539 text.insert('1.0', '\n'.join('a'*i for i in range(1, 101))) 540 541 542if __name__ == '__main__': 543 from unittest import main 544 main('idlelib.idle_test.test_sidebar', verbosity=2, exit=False) 545 546 from idlelib.idle_test.htest import run 547 run(_linenumbers_drag_scrolling) 548