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 | """ |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 4 | import contextlib |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 5 | import functools |
| 6 | import itertools |
| 7 | |
| 8 | import tkinter as tk |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 9 | from tkinter.font import Font |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 10 | from idlelib.config import idleConf |
| 11 | from idlelib.delegator import Delegator |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 12 | from idlelib import macosx |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 13 | |
| 14 | |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 15 | def get_lineno(text, index): |
| 16 | """Return the line number of an index in a Tk text widget.""" |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 17 | text_index = text.index(index) |
| 18 | return int(float(text_index)) if text_index else None |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 19 | |
| 20 | |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 21 | def 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 | |
| 26 | def 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 | |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 33 | def 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 | |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 59 | @contextlib.contextmanager |
| 60 | def 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 | |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 68 | class BaseSideBar: |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 69 | """A base class for sidebars using Text.""" |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 70 | def __init__(self, editwin): |
| 71 | self.editwin = editwin |
| 72 | self.parent = editwin.text_frame |
| 73 | self.text = editwin.text |
| 74 | |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 75 | self.is_shown = False |
| 76 | |
| 77 | self.main_widget = self.init_widgets() |
| 78 | |
| 79 | self.bind_events() |
| 80 | |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 81 | self.update_font() |
| 82 | self.update_colors() |
| 83 | |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 84 | def init_widgets(self): |
| 85 | """Initialize the sidebar's widgets, returning the main widget.""" |
| 86 | raise NotImplementedError |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 87 | |
| 88 | def update_font(self): |
| 89 | """Update the sidebar text font, usually after config changes.""" |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 90 | raise NotImplementedError |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 91 | |
| 92 | def update_colors(self): |
| 93 | """Update the sidebar text colors, usually after config changes.""" |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 94 | raise NotImplementedError |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 95 | |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 96 | def grid(self): |
| 97 | """Layout the widget, always using grid layout.""" |
| 98 | raise NotImplementedError |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 99 | |
| 100 | def show_sidebar(self): |
| 101 | if not self.is_shown: |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 102 | self.grid() |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 103 | self.is_shown = True |
| 104 | |
| 105 | def hide_sidebar(self): |
| 106 | if self.is_shown: |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 107 | self.main_widget.grid_forget() |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 108 | self.is_shown = False |
| 109 | |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 110 | def yscroll_event(self, *args, **kwargs): |
| 111 | """Hook for vertical scrolling for sub-classes to override.""" |
| 112 | raise NotImplementedError |
| 113 | |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 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) |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 120 | return self.yscroll_event(*args, **kwargs) |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 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 | |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 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 | |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 265 | |
| 266 | class EndLineDelegator(Delegator): |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 267 | """Generate callbacks with the current end line number. |
| 268 | |
| 269 | The provided callback is called after every insert and delete. |
| 270 | """ |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 271 | def __init__(self, changed_callback): |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 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 | |
| 284 | class LineNumbers(BaseSideBar): |
| 285 | """Line numbers support for editor windows.""" |
| 286 | def __init__(self, editwin): |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 287 | super().__init__(editwin) |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 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. |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 292 | self.editwin.per.insertfilterafter(end_line_delegator, |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 293 | after=self.editwin.undo) |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 294 | |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 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) |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 301 | |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 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) |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 308 | |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 309 | end = get_end_linenumber(self.text) |
| 310 | self.update_sidebar_text(end) |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 311 | |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 312 | return self.sidebar_text |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 313 | |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 314 | def grid(self): |
| 315 | self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW) |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 316 | |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 317 | def update_font(self): |
| 318 | font = idleConf.GetFont(self.text, 'main', 'EditorWindow') |
| 319 | self.sidebar_text['font'] = font |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 320 | |
| 321 | def update_colors(self): |
| 322 | """Update the sidebar text colors, usually after config changes.""" |
| 323 | colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber') |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 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 | ) |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 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 | |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 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') |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 356 | |
| 357 | self.prev_end = end |
| 358 | |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 359 | def yscroll_event(self, *args, **kwargs): |
| 360 | self.sidebar_text.yview_moveto(args[0]) |
| 361 | return 'break' |
| 362 | |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 363 | |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 364 | class 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 | |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 405 | class ShellSidebar(BaseSideBar): |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 406 | """Sidebar for the PyShell window, for prompts etc.""" |
| 407 | def __init__(self, editwin): |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 408 | self.canvas = None |
| 409 | self.line_prompts = {} |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 410 | |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 411 | super().__init__(editwin) |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 412 | |
| 413 | change_delegator = \ |
| 414 | WrappedLineHeightChangeDelegator(self.change_callback) |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 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 | |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 423 | self.is_shown = True |
| 424 | |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 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 | |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 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 |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 467 | line_prompts = self.line_prompts = {} |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 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]) |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 488 | lineno = get_lineno(text, index) |
| 489 | line_prompts[lineno] = prompt |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 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 | """ |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 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) |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 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') |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 513 | foreground = prompt_colors['foreground'] |
| 514 | background = linenumbers_colors['background'] |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 515 | self.colors = (foreground, background) |
Tal Einat | b43cc31 | 2021-05-03 05:27:38 +0300 | [diff] [blame^] | 516 | self.canvas.configure(background=background) |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 517 | self.change_callback() |
| 518 | |
Tal Einat | 15d3861 | 2021-04-29 01:27:55 +0300 | [diff] [blame] | 519 | |
Tal Einat | 7123ea0 | 2019-07-23 15:22:11 +0300 | [diff] [blame] | 520 | def _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 | |
| 542 | if __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) |