Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame^] | 1 | """Line numbering implementation for IDLE as an extension. |
| 2 | Includes BaseSideBar which can be extended for other sidebar based extensions |
| 3 | """ |
| 4 | import functools |
| 5 | import itertools |
| 6 | |
| 7 | import tkinter as tk |
| 8 | from idlelib.config import idleConf |
| 9 | from idlelib.delegator import Delegator |
| 10 | |
| 11 | |
| 12 | def 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 | |
| 17 | def 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 | |
| 43 | class 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=0, 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 | |
| 121 | class 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 | |
| 142 | class 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 | start_line = None |
| 208 | def b1_mousedown_handler(event): |
| 209 | # select the entire line |
| 210 | lineno = self.editwin.getlineno(f"@0,{event.y}") |
| 211 | self.text.tag_remove("sel", "1.0", "end") |
| 212 | self.text.tag_add("sel", f"{lineno}.0", f"{lineno+1}.0") |
| 213 | self.text.mark_set("insert", f"{lineno+1}.0") |
| 214 | |
| 215 | # remember this line in case this is the beginning of dragging |
| 216 | nonlocal start_line |
| 217 | start_line = lineno |
| 218 | self.sidebar_text.bind('<Button-1>', b1_mousedown_handler) |
| 219 | |
| 220 | # These are set by b1_motion_handler() and read by selection_handler(); |
| 221 | # see below. last_y is passed this way since the mouse Y-coordinate |
| 222 | # is not available on selection event objects. last_yview is passed |
| 223 | # this way to recognize scrolling while the mouse isn't moving. |
| 224 | last_y = last_yview = None |
| 225 | |
| 226 | def drag_update_selection_and_insert_mark(y_coord): |
| 227 | """Helper function for drag and selection event handlers.""" |
| 228 | lineno = self.editwin.getlineno(f"@0,{y_coord}") |
| 229 | a, b = sorted([start_line, lineno]) |
| 230 | self.text.tag_remove("sel", "1.0", "end") |
| 231 | self.text.tag_add("sel", f"{a}.0", f"{b+1}.0") |
| 232 | self.text.mark_set("insert", |
| 233 | f"{lineno if lineno == a else lineno + 1}.0") |
| 234 | |
| 235 | # Special handling of dragging with mouse button 1. In "normal" text |
| 236 | # widgets this selects text, but the line numbers text widget has |
| 237 | # selection disabled. Still, dragging triggers some selection-related |
| 238 | # functionality under the hood. Specifically, dragging to above or |
| 239 | # below the text widget triggers scrolling, in a way that bypasses the |
| 240 | # other scrolling synchronization mechanisms.i |
| 241 | def b1_drag_handler(event, *args): |
| 242 | nonlocal last_y |
| 243 | nonlocal last_yview |
| 244 | last_y = event.y |
| 245 | last_yview = self.sidebar_text.yview() |
| 246 | if not 0 <= last_y <= self.sidebar_text.winfo_height(): |
| 247 | self.text.yview_moveto(last_yview[0]) |
| 248 | drag_update_selection_and_insert_mark(event.y) |
| 249 | self.sidebar_text.bind('<B1-Motion>', b1_drag_handler) |
| 250 | |
| 251 | # With mouse-drag scrolling fixed by the above, there is still an edge- |
| 252 | # case we need to handle: When drag-scrolling, scrolling can continue |
| 253 | # while the mouse isn't moving, leading to the above fix not scrolling |
| 254 | # properly. |
| 255 | def selection_handler(event): |
| 256 | yview = self.sidebar_text.yview() |
| 257 | if yview != last_yview: |
| 258 | self.text.yview_moveto(yview[0]) |
| 259 | drag_update_selection_and_insert_mark(last_y) |
| 260 | self.sidebar_text.bind('<<Selection>>', selection_handler) |
| 261 | |
| 262 | def update_colors(self): |
| 263 | """Update the sidebar text colors, usually after config changes.""" |
| 264 | colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber') |
| 265 | self._update_colors(foreground=colors['foreground'], |
| 266 | background=colors['background']) |
| 267 | |
| 268 | def update_sidebar_text(self, end): |
| 269 | """ |
| 270 | Perform the following action: |
| 271 | Each line sidebar_text contains the linenumber for that line |
| 272 | Synchronize with editwin.text so that both sidebar_text and |
| 273 | editwin.text contain the same number of lines""" |
| 274 | if end == self.prev_end: |
| 275 | return |
| 276 | |
| 277 | width_difference = len(str(end)) - len(str(self.prev_end)) |
| 278 | if width_difference: |
| 279 | cur_width = int(float(self.sidebar_text['width'])) |
| 280 | new_width = cur_width + width_difference |
| 281 | self.sidebar_text['width'] = self._sidebar_width_type(new_width) |
| 282 | |
| 283 | self.sidebar_text.config(state=tk.NORMAL) |
| 284 | if end > self.prev_end: |
| 285 | new_text = '\n'.join(itertools.chain( |
| 286 | [''], |
| 287 | map(str, range(self.prev_end + 1, end + 1)), |
| 288 | )) |
| 289 | self.sidebar_text.insert(f'end -1c', new_text, 'linenumber') |
| 290 | else: |
| 291 | self.sidebar_text.delete(f'{end+1}.0 -1c', 'end -1c') |
| 292 | self.sidebar_text.config(state=tk.DISABLED) |
| 293 | |
| 294 | self.prev_end = end |
| 295 | |
| 296 | |
| 297 | def _linenumbers_drag_scrolling(parent): # htest # |
| 298 | from idlelib.idle_test.test_sidebar import Dummy_editwin |
| 299 | |
| 300 | toplevel = tk.Toplevel(parent) |
| 301 | text_frame = tk.Frame(toplevel) |
| 302 | text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) |
| 303 | text_frame.rowconfigure(1, weight=1) |
| 304 | text_frame.columnconfigure(1, weight=1) |
| 305 | |
| 306 | font = idleConf.GetFont(toplevel, 'main', 'EditorWindow') |
| 307 | text = tk.Text(text_frame, width=80, height=24, wrap=tk.NONE, font=font) |
| 308 | text.grid(row=1, column=1, sticky=tk.NSEW) |
| 309 | |
| 310 | editwin = Dummy_editwin(text) |
| 311 | editwin.vbar = tk.Scrollbar(text_frame) |
| 312 | |
| 313 | linenumbers = LineNumbers(editwin) |
| 314 | linenumbers.show_sidebar() |
| 315 | |
| 316 | text.insert('1.0', '\n'.join('a'*i for i in range(1, 101))) |
| 317 | |
| 318 | |
| 319 | if __name__ == '__main__': |
| 320 | from unittest import main |
| 321 | main('idlelib.idle_test.test_sidebar', verbosity=2, exit=False) |
| 322 | |
| 323 | from idlelib.idle_test.htest import run |
| 324 | run(_linenumbers_drag_scrolling) |