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 return text.count(f"{index} linestart", 29 f"{index} lineend", 30 "displaylines", return_ints=True) 31 32def get_widget_padding(widget): 33 """Get the total padding of a Tk widget, including its border.""" 34 # TODO: use also in codecontext.py 35 manager = widget.winfo_manager() 36 if manager == 'pack': 37 info = widget.pack_info() 38 elif manager == 'grid': 39 info = widget.grid_info() 40 else: 41 raise ValueError(f"Unsupported geometry manager: {manager}") 42 43 # All values are passed through getint(), since some 44 # values may be pixel objects, which can't simply be added to ints. 45 padx = sum(map(widget.tk.getint, [ 46 info['padx'], 47 widget.cget('padx'), 48 widget.cget('border'), 49 ])) 50 pady = sum(map(widget.tk.getint, [ 51 info['pady'], 52 widget.cget('pady'), 53 widget.cget('border'), 54 ])) 55 return padx, pady 56 57 58@contextlib.contextmanager 59def temp_enable_text_widget(text): 60 text.configure(state=tk.NORMAL) 61 try: 62 yield 63 finally: 64 text.configure(state=tk.DISABLED) 65 66 67class BaseSideBar: 68 """A base class for sidebars using Text.""" 69 def __init__(self, editwin): 70 self.editwin = editwin 71 self.parent = editwin.text_frame 72 self.text = editwin.text 73 74 self.is_shown = False 75 76 self.main_widget = self.init_widgets() 77 78 self.bind_events() 79 80 self.update_font() 81 self.update_colors() 82 83 def init_widgets(self): 84 """Initialize the sidebar's widgets, returning the main widget.""" 85 raise NotImplementedError 86 87 def update_font(self): 88 """Update the sidebar text font, usually after config changes.""" 89 raise NotImplementedError 90 91 def update_colors(self): 92 """Update the sidebar text colors, usually after config changes.""" 93 raise NotImplementedError 94 95 def grid(self): 96 """Layout the widget, always using grid layout.""" 97 raise NotImplementedError 98 99 def show_sidebar(self): 100 if not self.is_shown: 101 self.grid() 102 self.is_shown = True 103 104 def hide_sidebar(self): 105 if self.is_shown: 106 self.main_widget.grid_forget() 107 self.is_shown = False 108 109 def yscroll_event(self, *args, **kwargs): 110 """Hook for vertical scrolling for sub-classes to override.""" 111 raise NotImplementedError 112 113 def redirect_yscroll_event(self, *args, **kwargs): 114 """Redirect vertical scrolling to the main editor text widget. 115 116 The scroll bar is also updated. 117 """ 118 self.editwin.vbar.set(*args) 119 return self.yscroll_event(*args, **kwargs) 120 121 def redirect_focusin_event(self, event): 122 """Redirect focus-in events to the main editor text widget.""" 123 self.text.focus_set() 124 return 'break' 125 126 def redirect_mousebutton_event(self, event, event_name): 127 """Redirect mouse button events to the main editor text widget.""" 128 self.text.focus_set() 129 self.text.event_generate(event_name, x=0, y=event.y) 130 return 'break' 131 132 def redirect_mousewheel_event(self, event): 133 """Redirect mouse wheel events to the editwin text widget.""" 134 self.text.event_generate('<MouseWheel>', 135 x=0, y=event.y, delta=event.delta) 136 return 'break' 137 138 def bind_events(self): 139 self.text['yscrollcommand'] = self.redirect_yscroll_event 140 141 # Ensure focus is always redirected to the main editor text widget. 142 self.main_widget.bind('<FocusIn>', self.redirect_focusin_event) 143 144 # Redirect mouse scrolling to the main editor text widget. 145 # 146 # Note that without this, scrolling with the mouse only scrolls 147 # the line numbers. 148 self.main_widget.bind('<MouseWheel>', self.redirect_mousewheel_event) 149 150 # Redirect mouse button events to the main editor text widget, 151 # except for the left mouse button (1). 152 # 153 # Note: X-11 sends Button-4 and Button-5 events for the scroll wheel. 154 def bind_mouse_event(event_name, target_event_name): 155 handler = functools.partial(self.redirect_mousebutton_event, 156 event_name=target_event_name) 157 self.main_widget.bind(event_name, handler) 158 159 for button in [2, 3, 4, 5]: 160 for event_name in (f'<Button-{button}>', 161 f'<ButtonRelease-{button}>', 162 f'<B{button}-Motion>', 163 ): 164 bind_mouse_event(event_name, target_event_name=event_name) 165 166 # Convert double- and triple-click events to normal click events, 167 # since event_generate() doesn't allow generating such events. 168 for event_name in (f'<Double-Button-{button}>', 169 f'<Triple-Button-{button}>', 170 ): 171 bind_mouse_event(event_name, 172 target_event_name=f'<Button-{button}>') 173 174 # start_line is set upon <Button-1> to allow selecting a range of rows 175 # by dragging. It is cleared upon <ButtonRelease-1>. 176 start_line = None 177 178 # last_y is initially set upon <B1-Leave> and is continuously updated 179 # upon <B1-Motion>, until <B1-Enter> or the mouse button is released. 180 # It is used in text_auto_scroll(), which is called repeatedly and 181 # does have a mouse event available. 182 last_y = None 183 184 # auto_scrolling_after_id is set whenever text_auto_scroll is 185 # scheduled via .after(). It is used to stop the auto-scrolling 186 # upon <B1-Enter>, as well as to avoid scheduling the function several 187 # times in parallel. 188 auto_scrolling_after_id = None 189 190 def drag_update_selection_and_insert_mark(y_coord): 191 """Helper function for drag and selection event handlers.""" 192 lineno = get_lineno(self.text, f"@0,{y_coord}") 193 a, b = sorted([start_line, lineno]) 194 self.text.tag_remove("sel", "1.0", "end") 195 self.text.tag_add("sel", f"{a}.0", f"{b+1}.0") 196 self.text.mark_set("insert", 197 f"{lineno if lineno == a else lineno + 1}.0") 198 199 def b1_mousedown_handler(event): 200 nonlocal start_line 201 nonlocal last_y 202 start_line = int(float(self.text.index(f"@0,{event.y}"))) 203 last_y = event.y 204 205 drag_update_selection_and_insert_mark(event.y) 206 self.main_widget.bind('<Button-1>', b1_mousedown_handler) 207 208 def b1_mouseup_handler(event): 209 # On mouse up, we're no longer dragging. Set the shared persistent 210 # variables to None to represent this. 211 nonlocal start_line 212 nonlocal last_y 213 start_line = None 214 last_y = None 215 self.text.event_generate('<ButtonRelease-1>', x=0, y=event.y) 216 self.main_widget.bind('<ButtonRelease-1>', b1_mouseup_handler) 217 218 def b1_drag_handler(event): 219 nonlocal last_y 220 if last_y is None: # i.e. if not currently dragging 221 return 222 last_y = event.y 223 drag_update_selection_and_insert_mark(event.y) 224 self.main_widget.bind('<B1-Motion>', b1_drag_handler) 225 226 def text_auto_scroll(): 227 """Mimic Text auto-scrolling when dragging outside of it.""" 228 # See: https://github.com/tcltk/tk/blob/064ff9941b4b80b85916a8afe86a6c21fd388b54/library/text.tcl#L670 229 nonlocal auto_scrolling_after_id 230 y = last_y 231 if y is None: 232 self.main_widget.after_cancel(auto_scrolling_after_id) 233 auto_scrolling_after_id = None 234 return 235 elif y < 0: 236 self.text.yview_scroll(-1 + y, 'pixels') 237 drag_update_selection_and_insert_mark(y) 238 elif y > self.main_widget.winfo_height(): 239 self.text.yview_scroll(1 + y - self.main_widget.winfo_height(), 240 'pixels') 241 drag_update_selection_and_insert_mark(y) 242 auto_scrolling_after_id = \ 243 self.main_widget.after(50, text_auto_scroll) 244 245 def b1_leave_handler(event): 246 # Schedule the initial call to text_auto_scroll(), if not already 247 # scheduled. 248 nonlocal auto_scrolling_after_id 249 if auto_scrolling_after_id is None: 250 nonlocal last_y 251 last_y = event.y 252 auto_scrolling_after_id = \ 253 self.main_widget.after(0, text_auto_scroll) 254 self.main_widget.bind('<B1-Leave>', b1_leave_handler) 255 256 def b1_enter_handler(event): 257 # Cancel the scheduling of text_auto_scroll(), if it exists. 258 nonlocal auto_scrolling_after_id 259 if auto_scrolling_after_id is not None: 260 self.main_widget.after_cancel(auto_scrolling_after_id) 261 auto_scrolling_after_id = None 262 self.main_widget.bind('<B1-Enter>', b1_enter_handler) 263 264 265class EndLineDelegator(Delegator): 266 """Generate callbacks with the current end line number. 267 268 The provided callback is called after every insert and delete. 269 """ 270 def __init__(self, changed_callback): 271 Delegator.__init__(self) 272 self.changed_callback = changed_callback 273 274 def insert(self, index, chars, tags=None): 275 self.delegate.insert(index, chars, tags) 276 self.changed_callback(get_end_linenumber(self.delegate)) 277 278 def delete(self, index1, index2=None): 279 self.delegate.delete(index1, index2) 280 self.changed_callback(get_end_linenumber(self.delegate)) 281 282 283class LineNumbers(BaseSideBar): 284 """Line numbers support for editor windows.""" 285 def __init__(self, editwin): 286 super().__init__(editwin) 287 288 end_line_delegator = EndLineDelegator(self.update_sidebar_text) 289 # Insert the delegator after the undo delegator, so that line numbers 290 # are properly updated after undo and redo actions. 291 self.editwin.per.insertfilterafter(end_line_delegator, 292 after=self.editwin.undo) 293 294 def init_widgets(self): 295 _padx, pady = get_widget_padding(self.text) 296 self.sidebar_text = tk.Text(self.parent, width=1, wrap=tk.NONE, 297 padx=2, pady=pady, 298 borderwidth=0, highlightthickness=0) 299 self.sidebar_text.config(state=tk.DISABLED) 300 301 self.prev_end = 1 302 self._sidebar_width_type = type(self.sidebar_text['width']) 303 with temp_enable_text_widget(self.sidebar_text): 304 self.sidebar_text.insert('insert', '1', 'linenumber') 305 self.sidebar_text.config(takefocus=False, exportselection=False) 306 self.sidebar_text.tag_config('linenumber', justify=tk.RIGHT) 307 308 end = get_end_linenumber(self.text) 309 self.update_sidebar_text(end) 310 311 return self.sidebar_text 312 313 def grid(self): 314 self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW) 315 316 def update_font(self): 317 font = idleConf.GetFont(self.text, 'main', 'EditorWindow') 318 self.sidebar_text['font'] = font 319 320 def update_colors(self): 321 """Update the sidebar text colors, usually after config changes.""" 322 colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber') 323 foreground = colors['foreground'] 324 background = colors['background'] 325 self.sidebar_text.config( 326 fg=foreground, bg=background, 327 selectforeground=foreground, selectbackground=background, 328 inactiveselectbackground=background, 329 ) 330 331 def update_sidebar_text(self, end): 332 """ 333 Perform the following action: 334 Each line sidebar_text contains the linenumber for that line 335 Synchronize with editwin.text so that both sidebar_text and 336 editwin.text contain the same number of lines""" 337 if end == self.prev_end: 338 return 339 340 width_difference = len(str(end)) - len(str(self.prev_end)) 341 if width_difference: 342 cur_width = int(float(self.sidebar_text['width'])) 343 new_width = cur_width + width_difference 344 self.sidebar_text['width'] = self._sidebar_width_type(new_width) 345 346 with temp_enable_text_widget(self.sidebar_text): 347 if end > self.prev_end: 348 new_text = '\n'.join(itertools.chain( 349 [''], 350 map(str, range(self.prev_end + 1, end + 1)), 351 )) 352 self.sidebar_text.insert(f'end -1c', new_text, 'linenumber') 353 else: 354 self.sidebar_text.delete(f'{end+1}.0 -1c', 'end -1c') 355 356 self.prev_end = end 357 358 def yscroll_event(self, *args, **kwargs): 359 self.sidebar_text.yview_moveto(args[0]) 360 return 'break' 361 362 363class WrappedLineHeightChangeDelegator(Delegator): 364 def __init__(self, callback): 365 """ 366 callback - Callable, will be called when an insert, delete or replace 367 action on the text widget may require updating the shell 368 sidebar. 369 """ 370 Delegator.__init__(self) 371 self.callback = callback 372 373 def insert(self, index, chars, tags=None): 374 is_single_line = '\n' not in chars 375 if is_single_line: 376 before_displaylines = get_displaylines(self, index) 377 378 self.delegate.insert(index, chars, tags) 379 380 if is_single_line: 381 after_displaylines = get_displaylines(self, index) 382 if after_displaylines == before_displaylines: 383 return # no need to update the sidebar 384 385 self.callback() 386 387 def delete(self, index1, index2=None): 388 if index2 is None: 389 index2 = index1 + "+1c" 390 is_single_line = get_lineno(self, index1) == get_lineno(self, index2) 391 if is_single_line: 392 before_displaylines = get_displaylines(self, index1) 393 394 self.delegate.delete(index1, index2) 395 396 if is_single_line: 397 after_displaylines = get_displaylines(self, index1) 398 if after_displaylines == before_displaylines: 399 return # no need to update the sidebar 400 401 self.callback() 402 403 404class ShellSidebar(BaseSideBar): 405 """Sidebar for the PyShell window, for prompts etc.""" 406 def __init__(self, editwin): 407 self.canvas = None 408 self.line_prompts = {} 409 410 super().__init__(editwin) 411 412 change_delegator = \ 413 WrappedLineHeightChangeDelegator(self.change_callback) 414 # Insert the TextChangeDelegator after the last delegator, so that 415 # the sidebar reflects final changes to the text widget contents. 416 d = self.editwin.per.top 417 if d.delegate is not self.text: 418 while d.delegate is not self.editwin.per.bottom: 419 d = d.delegate 420 self.editwin.per.insertfilterafter(change_delegator, after=d) 421 422 self.is_shown = True 423 424 def init_widgets(self): 425 self.canvas = tk.Canvas(self.parent, width=30, 426 borderwidth=0, highlightthickness=0, 427 takefocus=False) 428 self.update_sidebar() 429 self.grid() 430 return self.canvas 431 432 def bind_events(self): 433 super().bind_events() 434 435 self.main_widget.bind( 436 # AquaTk defines <2> as the right button, not <3>. 437 "<Button-2>" if macosx.isAquaTk() else "<Button-3>", 438 self.context_menu_event, 439 ) 440 441 def context_menu_event(self, event): 442 rmenu = tk.Menu(self.main_widget, tearoff=0) 443 has_selection = bool(self.text.tag_nextrange('sel', '1.0')) 444 def mkcmd(eventname): 445 return lambda: self.text.event_generate(eventname) 446 rmenu.add_command(label='Copy', 447 command=mkcmd('<<copy>>'), 448 state='normal' if has_selection else 'disabled') 449 rmenu.add_command(label='Copy with prompts', 450 command=mkcmd('<<copy-with-prompts>>'), 451 state='normal' if has_selection else 'disabled') 452 rmenu.tk_popup(event.x_root, event.y_root) 453 return "break" 454 455 def grid(self): 456 self.canvas.grid(row=1, column=0, sticky=tk.NSEW, padx=2, pady=0) 457 458 def change_callback(self): 459 if self.is_shown: 460 self.update_sidebar() 461 462 def update_sidebar(self): 463 text = self.text 464 text_tagnames = text.tag_names 465 canvas = self.canvas 466 line_prompts = self.line_prompts = {} 467 468 canvas.delete(tk.ALL) 469 470 index = text.index("@0,0") 471 if index.split('.', 1)[1] != '0': 472 index = text.index(f'{index}+1line linestart') 473 while (lineinfo := text.dlineinfo(index)) is not None: 474 y = lineinfo[1] 475 prev_newline_tagnames = text_tagnames(f"{index} linestart -1c") 476 prompt = ( 477 '>>>' if "console" in prev_newline_tagnames else 478 '...' if "stdin" in prev_newline_tagnames else 479 None 480 ) 481 if prompt: 482 canvas.create_text(2, y, anchor=tk.NW, text=prompt, 483 font=self.font, fill=self.colors[0]) 484 lineno = get_lineno(text, index) 485 line_prompts[lineno] = prompt 486 index = text.index(f'{index}+1line') 487 488 def yscroll_event(self, *args, **kwargs): 489 """Redirect vertical scrolling to the main editor text widget. 490 491 The scroll bar is also updated. 492 """ 493 self.change_callback() 494 return 'break' 495 496 def update_font(self): 497 """Update the sidebar text font, usually after config changes.""" 498 font = idleConf.GetFont(self.text, 'main', 'EditorWindow') 499 tk_font = Font(self.text, font=font) 500 char_width = max(tk_font.measure(char) for char in ['>', '.']) 501 self.canvas.configure(width=char_width * 3 + 4) 502 self.font = font 503 self.change_callback() 504 505 def update_colors(self): 506 """Update the sidebar text colors, usually after config changes.""" 507 linenumbers_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber') 508 prompt_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'console') 509 foreground = prompt_colors['foreground'] 510 background = linenumbers_colors['background'] 511 self.colors = (foreground, background) 512 self.canvas.configure(background=background) 513 self.change_callback() 514 515 516def _sidebar_number_scrolling(parent): # htest # 517 from idlelib.idle_test.test_sidebar import Dummy_editwin 518 519 top = tk.Toplevel(parent) 520 text_frame = tk.Frame(top) 521 text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) 522 text_frame.rowconfigure(1, weight=1) 523 text_frame.columnconfigure(1, weight=1) 524 525 font = idleConf.GetFont(top, 'main', 'EditorWindow') 526 text = tk.Text(text_frame, width=80, height=24, wrap=tk.NONE, font=font) 527 text.grid(row=1, column=1, sticky=tk.NSEW) 528 529 editwin = Dummy_editwin(text) 530 editwin.vbar = tk.Scrollbar(text_frame) 531 532 linenumbers = LineNumbers(editwin) 533 linenumbers.show_sidebar() 534 535 text.insert('1.0', '\n'.join('a'*i for i in range(1, 101))) 536 537 538if __name__ == '__main__': 539 from unittest import main 540 main('idlelib.idle_test.test_sidebar', verbosity=2, exit=False) 541 542 from idlelib.idle_test.htest import run 543 run(_sidebar_number_scrolling) 544