1"""Line numbering implementation for IDLE as an extension. 2Includes BaseSideBar which can be extended for other sidebar based extensions 3""" 4import functools 5import itertools 6 7import tkinter as tk 8from idlelib.config import idleConf 9from idlelib.delegator import Delegator 10 11 12def get_end_linenumber(text): 13 """Utility to get the last line's number in a Tk text widget.""" 14 return int(float(text.index('end-1c'))) 15 16 17def get_widget_padding(widget): 18 """Get the total padding of a Tk widget, including its border.""" 19 # TODO: use also in codecontext.py 20 manager = widget.winfo_manager() 21 if manager == 'pack': 22 info = widget.pack_info() 23 elif manager == 'grid': 24 info = widget.grid_info() 25 else: 26 raise ValueError(f"Unsupported geometry manager: {manager}") 27 28 # All values are passed through getint(), since some 29 # values may be pixel objects, which can't simply be added to ints. 30 padx = sum(map(widget.tk.getint, [ 31 info['padx'], 32 widget.cget('padx'), 33 widget.cget('border'), 34 ])) 35 pady = sum(map(widget.tk.getint, [ 36 info['pady'], 37 widget.cget('pady'), 38 widget.cget('border'), 39 ])) 40 return padx, pady 41 42 43class BaseSideBar: 44 """ 45 The base class for extensions which require a sidebar. 46 """ 47 def __init__(self, editwin): 48 self.editwin = editwin 49 self.parent = editwin.text_frame 50 self.text = editwin.text 51 52 _padx, pady = get_widget_padding(self.text) 53 self.sidebar_text = tk.Text(self.parent, width=1, wrap=tk.NONE, 54 padx=2, pady=pady, 55 borderwidth=0, highlightthickness=0) 56 self.sidebar_text.config(state=tk.DISABLED) 57 self.text['yscrollcommand'] = self.redirect_yscroll_event 58 self.update_font() 59 self.update_colors() 60 61 self.is_shown = False 62 63 def update_font(self): 64 """Update the sidebar text font, usually after config changes.""" 65 font = idleConf.GetFont(self.text, 'main', 'EditorWindow') 66 self._update_font(font) 67 68 def _update_font(self, font): 69 self.sidebar_text['font'] = font 70 71 def update_colors(self): 72 """Update the sidebar text colors, usually after config changes.""" 73 colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'normal') 74 self._update_colors(foreground=colors['foreground'], 75 background=colors['background']) 76 77 def _update_colors(self, foreground, background): 78 self.sidebar_text.config( 79 fg=foreground, bg=background, 80 selectforeground=foreground, selectbackground=background, 81 inactiveselectbackground=background, 82 ) 83 84 def show_sidebar(self): 85 if not self.is_shown: 86 self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW) 87 self.is_shown = True 88 89 def hide_sidebar(self): 90 if self.is_shown: 91 self.sidebar_text.grid_forget() 92 self.is_shown = False 93 94 def redirect_yscroll_event(self, *args, **kwargs): 95 """Redirect vertical scrolling to the main editor text widget. 96 97 The scroll bar is also updated. 98 """ 99 self.editwin.vbar.set(*args) 100 self.sidebar_text.yview_moveto(args[0]) 101 return 'break' 102 103 def redirect_focusin_event(self, event): 104 """Redirect focus-in events to the main editor text widget.""" 105 self.text.focus_set() 106 return 'break' 107 108 def redirect_mousebutton_event(self, event, event_name): 109 """Redirect mouse button events to the main editor text widget.""" 110 self.text.focus_set() 111 self.text.event_generate(event_name, x=0, y=event.y) 112 return 'break' 113 114 def redirect_mousewheel_event(self, event): 115 """Redirect mouse wheel events to the editwin text widget.""" 116 self.text.event_generate('<MouseWheel>', 117 x=0, y=event.y, delta=event.delta) 118 return 'break' 119 120 121class EndLineDelegator(Delegator): 122 """Generate callbacks with the current end line number after 123 insert or delete operations""" 124 def __init__(self, changed_callback): 125 """ 126 changed_callback - Callable, will be called after insert 127 or delete operations with the current 128 end line number. 129 """ 130 Delegator.__init__(self) 131 self.changed_callback = changed_callback 132 133 def insert(self, index, chars, tags=None): 134 self.delegate.insert(index, chars, tags) 135 self.changed_callback(get_end_linenumber(self.delegate)) 136 137 def delete(self, index1, index2=None): 138 self.delegate.delete(index1, index2) 139 self.changed_callback(get_end_linenumber(self.delegate)) 140 141 142class LineNumbers(BaseSideBar): 143 """Line numbers support for editor windows.""" 144 def __init__(self, editwin): 145 BaseSideBar.__init__(self, editwin) 146 self.prev_end = 1 147 self._sidebar_width_type = type(self.sidebar_text['width']) 148 self.sidebar_text.config(state=tk.NORMAL) 149 self.sidebar_text.insert('insert', '1', 'linenumber') 150 self.sidebar_text.config(state=tk.DISABLED) 151 self.sidebar_text.config(takefocus=False, exportselection=False) 152 self.sidebar_text.tag_config('linenumber', justify=tk.RIGHT) 153 154 self.bind_events() 155 156 end = get_end_linenumber(self.text) 157 self.update_sidebar_text(end) 158 159 end_line_delegator = EndLineDelegator(self.update_sidebar_text) 160 # Insert the delegator after the undo delegator, so that line numbers 161 # are properly updated after undo and redo actions. 162 end_line_delegator.setdelegate(self.editwin.undo.delegate) 163 self.editwin.undo.setdelegate(end_line_delegator) 164 # Reset the delegator caches of the delegators "above" the 165 # end line delegator we just inserted. 166 delegator = self.editwin.per.top 167 while delegator is not end_line_delegator: 168 delegator.resetcache() 169 delegator = delegator.delegate 170 171 self.is_shown = False 172 173 def bind_events(self): 174 # Ensure focus is always redirected to the main editor text widget. 175 self.sidebar_text.bind('<FocusIn>', self.redirect_focusin_event) 176 177 # Redirect mouse scrolling to the main editor text widget. 178 # 179 # Note that without this, scrolling with the mouse only scrolls 180 # the line numbers. 181 self.sidebar_text.bind('<MouseWheel>', self.redirect_mousewheel_event) 182 183 # Redirect mouse button events to the main editor text widget, 184 # except for the left mouse button (1). 185 # 186 # Note: X-11 sends Button-4 and Button-5 events for the scroll wheel. 187 def bind_mouse_event(event_name, target_event_name): 188 handler = functools.partial(self.redirect_mousebutton_event, 189 event_name=target_event_name) 190 self.sidebar_text.bind(event_name, handler) 191 192 for button in [2, 3, 4, 5]: 193 for event_name in (f'<Button-{button}>', 194 f'<ButtonRelease-{button}>', 195 f'<B{button}-Motion>', 196 ): 197 bind_mouse_event(event_name, target_event_name=event_name) 198 199 # Convert double- and triple-click events to normal click events, 200 # since event_generate() doesn't allow generating such events. 201 for event_name in (f'<Double-Button-{button}>', 202 f'<Triple-Button-{button}>', 203 ): 204 bind_mouse_event(event_name, 205 target_event_name=f'<Button-{button}>') 206 207 # This is set by b1_mousedown_handler() and read by 208 # drag_update_selection_and_insert_mark(), to know where dragging 209 # began. 210 start_line = None 211 # These are set by b1_motion_handler() and read by selection_handler(). 212 # last_y is passed this way since the mouse Y-coordinate is not 213 # available on selection event objects. last_yview is passed this way 214 # to recognize scrolling while the mouse isn't moving. 215 last_y = last_yview = None 216 217 def b1_mousedown_handler(event): 218 # select the entire line 219 lineno = int(float(self.sidebar_text.index(f"@0,{event.y}"))) 220 self.text.tag_remove("sel", "1.0", "end") 221 self.text.tag_add("sel", f"{lineno}.0", f"{lineno+1}.0") 222 self.text.mark_set("insert", f"{lineno+1}.0") 223 224 # remember this line in case this is the beginning of dragging 225 nonlocal start_line 226 start_line = lineno 227 self.sidebar_text.bind('<Button-1>', b1_mousedown_handler) 228 229 def b1_mouseup_handler(event): 230 # On mouse up, we're no longer dragging. Set the shared persistent 231 # variables to None to represent this. 232 nonlocal start_line 233 nonlocal last_y 234 nonlocal last_yview 235 start_line = None 236 last_y = None 237 last_yview = None 238 self.sidebar_text.bind('<ButtonRelease-1>', b1_mouseup_handler) 239 240 def drag_update_selection_and_insert_mark(y_coord): 241 """Helper function for drag and selection event handlers.""" 242 lineno = int(float(self.sidebar_text.index(f"@0,{y_coord}"))) 243 a, b = sorted([start_line, lineno]) 244 self.text.tag_remove("sel", "1.0", "end") 245 self.text.tag_add("sel", f"{a}.0", f"{b+1}.0") 246 self.text.mark_set("insert", 247 f"{lineno if lineno == a else lineno + 1}.0") 248 249 # Special handling of dragging with mouse button 1. In "normal" text 250 # widgets this selects text, but the line numbers text widget has 251 # selection disabled. Still, dragging triggers some selection-related 252 # functionality under the hood. Specifically, dragging to above or 253 # below the text widget triggers scrolling, in a way that bypasses the 254 # other scrolling synchronization mechanisms.i 255 def b1_drag_handler(event, *args): 256 nonlocal last_y 257 nonlocal last_yview 258 last_y = event.y 259 last_yview = self.sidebar_text.yview() 260 if not 0 <= last_y <= self.sidebar_text.winfo_height(): 261 self.text.yview_moveto(last_yview[0]) 262 drag_update_selection_and_insert_mark(event.y) 263 self.sidebar_text.bind('<B1-Motion>', b1_drag_handler) 264 265 # With mouse-drag scrolling fixed by the above, there is still an edge- 266 # case we need to handle: When drag-scrolling, scrolling can continue 267 # while the mouse isn't moving, leading to the above fix not scrolling 268 # properly. 269 def selection_handler(event): 270 if last_yview is None: 271 # This logic is only needed while dragging. 272 return 273 yview = self.sidebar_text.yview() 274 if yview != last_yview: 275 self.text.yview_moveto(yview[0]) 276 drag_update_selection_and_insert_mark(last_y) 277 self.sidebar_text.bind('<<Selection>>', selection_handler) 278 279 def update_colors(self): 280 """Update the sidebar text colors, usually after config changes.""" 281 colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber') 282 self._update_colors(foreground=colors['foreground'], 283 background=colors['background']) 284 285 def update_sidebar_text(self, end): 286 """ 287 Perform the following action: 288 Each line sidebar_text contains the linenumber for that line 289 Synchronize with editwin.text so that both sidebar_text and 290 editwin.text contain the same number of lines""" 291 if end == self.prev_end: 292 return 293 294 width_difference = len(str(end)) - len(str(self.prev_end)) 295 if width_difference: 296 cur_width = int(float(self.sidebar_text['width'])) 297 new_width = cur_width + width_difference 298 self.sidebar_text['width'] = self._sidebar_width_type(new_width) 299 300 self.sidebar_text.config(state=tk.NORMAL) 301 if end > self.prev_end: 302 new_text = '\n'.join(itertools.chain( 303 [''], 304 map(str, range(self.prev_end + 1, end + 1)), 305 )) 306 self.sidebar_text.insert(f'end -1c', new_text, 'linenumber') 307 else: 308 self.sidebar_text.delete(f'{end+1}.0 -1c', 'end -1c') 309 self.sidebar_text.config(state=tk.DISABLED) 310 311 self.prev_end = end 312 313 314def _linenumbers_drag_scrolling(parent): # htest # 315 from idlelib.idle_test.test_sidebar import Dummy_editwin 316 317 toplevel = tk.Toplevel(parent) 318 text_frame = tk.Frame(toplevel) 319 text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) 320 text_frame.rowconfigure(1, weight=1) 321 text_frame.columnconfigure(1, weight=1) 322 323 font = idleConf.GetFont(toplevel, 'main', 'EditorWindow') 324 text = tk.Text(text_frame, width=80, height=24, wrap=tk.NONE, font=font) 325 text.grid(row=1, column=1, sticky=tk.NSEW) 326 327 editwin = Dummy_editwin(text) 328 editwin.vbar = tk.Scrollbar(text_frame) 329 330 linenumbers = LineNumbers(editwin) 331 linenumbers.show_sidebar() 332 333 text.insert('1.0', '\n'.join('a'*i for i in range(1, 101))) 334 335 336if __name__ == '__main__': 337 from unittest import main 338 main('idlelib.idle_test.test_sidebar', verbosity=2, exit=False) 339 340 from idlelib.idle_test.htest import run 341 run(_linenumbers_drag_scrolling) 342