blob: a947961b858d6897197f62f0c974504a35522a03 [file] [log] [blame]
Tal Einat7123ea02019-07-23 15:22:11 +03001"""Line numbering implementation for IDLE as an extension.
2Includes BaseSideBar which can be extended for other sidebar based extensions
3"""
Tal Einat15d38612021-04-29 01:27:55 +03004import contextlib
Tal Einat7123ea02019-07-23 15:22:11 +03005import functools
6import itertools
7
8import tkinter as tk
Tal Einat15d38612021-04-29 01:27:55 +03009from tkinter.font import Font
Tal Einat7123ea02019-07-23 15:22:11 +030010from idlelib.config import idleConf
11from idlelib.delegator import Delegator
12
13
Tal Einat15d38612021-04-29 01:27:55 +030014def get_lineno(text, index):
15 """Return the line number of an index in a Tk text widget."""
16 return int(float(text.index(index)))
Tal Einat7123ea02019-07-23 15:22:11 +030017
18
Tal Einat15d38612021-04-29 01:27:55 +030019def get_end_linenumber(text):
20 """Return the number of the last line in a Tk text widget."""
21 return get_lineno(text, 'end-1c')
22
23
24def get_displaylines(text, index):
25 """Display height, in lines, of a logical line in a Tk text widget."""
26 res = text.count(f"{index} linestart",
27 f"{index} lineend",
28 "displaylines")
29 return res[0] if res else 0
30
Tal Einat7123ea02019-07-23 15:22:11 +030031def get_widget_padding(widget):
32 """Get the total padding of a Tk widget, including its border."""
33 # TODO: use also in codecontext.py
34 manager = widget.winfo_manager()
35 if manager == 'pack':
36 info = widget.pack_info()
37 elif manager == 'grid':
38 info = widget.grid_info()
39 else:
40 raise ValueError(f"Unsupported geometry manager: {manager}")
41
42 # All values are passed through getint(), since some
43 # values may be pixel objects, which can't simply be added to ints.
44 padx = sum(map(widget.tk.getint, [
45 info['padx'],
46 widget.cget('padx'),
47 widget.cget('border'),
48 ]))
49 pady = sum(map(widget.tk.getint, [
50 info['pady'],
51 widget.cget('pady'),
52 widget.cget('border'),
53 ]))
54 return padx, pady
55
56
Tal Einat15d38612021-04-29 01:27:55 +030057@contextlib.contextmanager
58def temp_enable_text_widget(text):
59 text.configure(state=tk.NORMAL)
60 try:
61 yield
62 finally:
63 text.configure(state=tk.DISABLED)
64
65
Tal Einat7123ea02019-07-23 15:22:11 +030066class BaseSideBar:
Tal Einat15d38612021-04-29 01:27:55 +030067 """A base class for sidebars using Text."""
Tal Einat7123ea02019-07-23 15:22:11 +030068 def __init__(self, editwin):
69 self.editwin = editwin
70 self.parent = editwin.text_frame
71 self.text = editwin.text
72
73 _padx, pady = get_widget_padding(self.text)
74 self.sidebar_text = tk.Text(self.parent, width=1, wrap=tk.NONE,
Tal Einat46ebd4a2019-07-27 06:24:37 +030075 padx=2, pady=pady,
Tal Einat7123ea02019-07-23 15:22:11 +030076 borderwidth=0, highlightthickness=0)
77 self.sidebar_text.config(state=tk.DISABLED)
78 self.text['yscrollcommand'] = self.redirect_yscroll_event
79 self.update_font()
80 self.update_colors()
81
82 self.is_shown = False
83
84 def update_font(self):
85 """Update the sidebar text font, usually after config changes."""
86 font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
87 self._update_font(font)
88
89 def _update_font(self, font):
90 self.sidebar_text['font'] = font
91
92 def update_colors(self):
93 """Update the sidebar text colors, usually after config changes."""
94 colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'normal')
95 self._update_colors(foreground=colors['foreground'],
96 background=colors['background'])
97
98 def _update_colors(self, foreground, background):
99 self.sidebar_text.config(
100 fg=foreground, bg=background,
101 selectforeground=foreground, selectbackground=background,
102 inactiveselectbackground=background,
103 )
104
105 def show_sidebar(self):
106 if not self.is_shown:
107 self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW)
108 self.is_shown = True
109
110 def hide_sidebar(self):
111 if self.is_shown:
112 self.sidebar_text.grid_forget()
113 self.is_shown = False
114
115 def redirect_yscroll_event(self, *args, **kwargs):
116 """Redirect vertical scrolling to the main editor text widget.
117
118 The scroll bar is also updated.
119 """
120 self.editwin.vbar.set(*args)
121 self.sidebar_text.yview_moveto(args[0])
122 return 'break'
123
124 def redirect_focusin_event(self, event):
125 """Redirect focus-in events to the main editor text widget."""
126 self.text.focus_set()
127 return 'break'
128
129 def redirect_mousebutton_event(self, event, event_name):
130 """Redirect mouse button events to the main editor text widget."""
131 self.text.focus_set()
132 self.text.event_generate(event_name, x=0, y=event.y)
133 return 'break'
134
135 def redirect_mousewheel_event(self, event):
136 """Redirect mouse wheel events to the editwin text widget."""
137 self.text.event_generate('<MouseWheel>',
138 x=0, y=event.y, delta=event.delta)
139 return 'break'
140
141
142class EndLineDelegator(Delegator):
Tal Einat15d38612021-04-29 01:27:55 +0300143 """Generate callbacks with the current end line number.
144
145 The provided callback is called after every insert and delete.
146 """
Tal Einat7123ea02019-07-23 15:22:11 +0300147 def __init__(self, changed_callback):
Tal Einat7123ea02019-07-23 15:22:11 +0300148 Delegator.__init__(self)
149 self.changed_callback = changed_callback
150
151 def insert(self, index, chars, tags=None):
152 self.delegate.insert(index, chars, tags)
153 self.changed_callback(get_end_linenumber(self.delegate))
154
155 def delete(self, index1, index2=None):
156 self.delegate.delete(index1, index2)
157 self.changed_callback(get_end_linenumber(self.delegate))
158
159
160class LineNumbers(BaseSideBar):
161 """Line numbers support for editor windows."""
162 def __init__(self, editwin):
163 BaseSideBar.__init__(self, editwin)
164 self.prev_end = 1
165 self._sidebar_width_type = type(self.sidebar_text['width'])
166 self.sidebar_text.config(state=tk.NORMAL)
167 self.sidebar_text.insert('insert', '1', 'linenumber')
168 self.sidebar_text.config(state=tk.DISABLED)
169 self.sidebar_text.config(takefocus=False, exportselection=False)
170 self.sidebar_text.tag_config('linenumber', justify=tk.RIGHT)
171
172 self.bind_events()
173
174 end = get_end_linenumber(self.text)
175 self.update_sidebar_text(end)
176
177 end_line_delegator = EndLineDelegator(self.update_sidebar_text)
178 # Insert the delegator after the undo delegator, so that line numbers
179 # are properly updated after undo and redo actions.
Tal Einat15d38612021-04-29 01:27:55 +0300180 self.editwin.per.insertfilterafter(filter=end_line_delegator,
181 after=self.editwin.undo)
Tal Einat7123ea02019-07-23 15:22:11 +0300182
183 def bind_events(self):
184 # Ensure focus is always redirected to the main editor text widget.
185 self.sidebar_text.bind('<FocusIn>', self.redirect_focusin_event)
186
187 # Redirect mouse scrolling to the main editor text widget.
188 #
189 # Note that without this, scrolling with the mouse only scrolls
190 # the line numbers.
191 self.sidebar_text.bind('<MouseWheel>', self.redirect_mousewheel_event)
192
193 # Redirect mouse button events to the main editor text widget,
194 # except for the left mouse button (1).
195 #
196 # Note: X-11 sends Button-4 and Button-5 events for the scroll wheel.
197 def bind_mouse_event(event_name, target_event_name):
198 handler = functools.partial(self.redirect_mousebutton_event,
199 event_name=target_event_name)
200 self.sidebar_text.bind(event_name, handler)
201
202 for button in [2, 3, 4, 5]:
203 for event_name in (f'<Button-{button}>',
204 f'<ButtonRelease-{button}>',
205 f'<B{button}-Motion>',
206 ):
207 bind_mouse_event(event_name, target_event_name=event_name)
208
209 # Convert double- and triple-click events to normal click events,
210 # since event_generate() doesn't allow generating such events.
211 for event_name in (f'<Double-Button-{button}>',
212 f'<Triple-Button-{button}>',
213 ):
214 bind_mouse_event(event_name,
215 target_event_name=f'<Button-{button}>')
216
Tal Einat86f1a182019-08-04 19:25:27 +0300217 # This is set by b1_mousedown_handler() and read by
218 # drag_update_selection_and_insert_mark(), to know where dragging
219 # began.
Tal Einat7123ea02019-07-23 15:22:11 +0300220 start_line = None
Tal Einat86f1a182019-08-04 19:25:27 +0300221 # These are set by b1_motion_handler() and read by selection_handler().
222 # last_y is passed this way since the mouse Y-coordinate is not
223 # available on selection event objects. last_yview is passed this way
224 # to recognize scrolling while the mouse isn't moving.
225 last_y = last_yview = None
226
Tal Einat7123ea02019-07-23 15:22:11 +0300227 def b1_mousedown_handler(event):
228 # select the entire line
Tal Einat86f1a182019-08-04 19:25:27 +0300229 lineno = int(float(self.sidebar_text.index(f"@0,{event.y}")))
Tal Einat7123ea02019-07-23 15:22:11 +0300230 self.text.tag_remove("sel", "1.0", "end")
231 self.text.tag_add("sel", f"{lineno}.0", f"{lineno+1}.0")
232 self.text.mark_set("insert", f"{lineno+1}.0")
233
234 # remember this line in case this is the beginning of dragging
235 nonlocal start_line
236 start_line = lineno
237 self.sidebar_text.bind('<Button-1>', b1_mousedown_handler)
238
Tal Einat86f1a182019-08-04 19:25:27 +0300239 def b1_mouseup_handler(event):
240 # On mouse up, we're no longer dragging. Set the shared persistent
241 # variables to None to represent this.
242 nonlocal start_line
243 nonlocal last_y
244 nonlocal last_yview
245 start_line = None
246 last_y = None
247 last_yview = None
248 self.sidebar_text.bind('<ButtonRelease-1>', b1_mouseup_handler)
Tal Einat7123ea02019-07-23 15:22:11 +0300249
250 def drag_update_selection_and_insert_mark(y_coord):
251 """Helper function for drag and selection event handlers."""
Tal Einat86f1a182019-08-04 19:25:27 +0300252 lineno = int(float(self.sidebar_text.index(f"@0,{y_coord}")))
Tal Einat7123ea02019-07-23 15:22:11 +0300253 a, b = sorted([start_line, lineno])
254 self.text.tag_remove("sel", "1.0", "end")
255 self.text.tag_add("sel", f"{a}.0", f"{b+1}.0")
256 self.text.mark_set("insert",
257 f"{lineno if lineno == a else lineno + 1}.0")
258
259 # Special handling of dragging with mouse button 1. In "normal" text
260 # widgets this selects text, but the line numbers text widget has
261 # selection disabled. Still, dragging triggers some selection-related
262 # functionality under the hood. Specifically, dragging to above or
263 # below the text widget triggers scrolling, in a way that bypasses the
264 # other scrolling synchronization mechanisms.i
265 def b1_drag_handler(event, *args):
266 nonlocal last_y
267 nonlocal last_yview
268 last_y = event.y
269 last_yview = self.sidebar_text.yview()
270 if not 0 <= last_y <= self.sidebar_text.winfo_height():
271 self.text.yview_moveto(last_yview[0])
272 drag_update_selection_and_insert_mark(event.y)
273 self.sidebar_text.bind('<B1-Motion>', b1_drag_handler)
274
275 # With mouse-drag scrolling fixed by the above, there is still an edge-
276 # case we need to handle: When drag-scrolling, scrolling can continue
277 # while the mouse isn't moving, leading to the above fix not scrolling
278 # properly.
279 def selection_handler(event):
Tal Einat86f1a182019-08-04 19:25:27 +0300280 if last_yview is None:
281 # This logic is only needed while dragging.
282 return
Tal Einat7123ea02019-07-23 15:22:11 +0300283 yview = self.sidebar_text.yview()
284 if yview != last_yview:
285 self.text.yview_moveto(yview[0])
286 drag_update_selection_and_insert_mark(last_y)
287 self.sidebar_text.bind('<<Selection>>', selection_handler)
288
289 def update_colors(self):
290 """Update the sidebar text colors, usually after config changes."""
291 colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber')
292 self._update_colors(foreground=colors['foreground'],
293 background=colors['background'])
294
295 def update_sidebar_text(self, end):
296 """
297 Perform the following action:
298 Each line sidebar_text contains the linenumber for that line
299 Synchronize with editwin.text so that both sidebar_text and
300 editwin.text contain the same number of lines"""
301 if end == self.prev_end:
302 return
303
304 width_difference = len(str(end)) - len(str(self.prev_end))
305 if width_difference:
306 cur_width = int(float(self.sidebar_text['width']))
307 new_width = cur_width + width_difference
308 self.sidebar_text['width'] = self._sidebar_width_type(new_width)
309
Tal Einat15d38612021-04-29 01:27:55 +0300310 with temp_enable_text_widget(self.sidebar_text):
311 if end > self.prev_end:
312 new_text = '\n'.join(itertools.chain(
313 [''],
314 map(str, range(self.prev_end + 1, end + 1)),
315 ))
316 self.sidebar_text.insert(f'end -1c', new_text, 'linenumber')
317 else:
318 self.sidebar_text.delete(f'{end+1}.0 -1c', 'end -1c')
Tal Einat7123ea02019-07-23 15:22:11 +0300319
320 self.prev_end = end
321
322
Tal Einat15d38612021-04-29 01:27:55 +0300323class WrappedLineHeightChangeDelegator(Delegator):
324 def __init__(self, callback):
325 """
326 callback - Callable, will be called when an insert, delete or replace
327 action on the text widget may require updating the shell
328 sidebar.
329 """
330 Delegator.__init__(self)
331 self.callback = callback
332
333 def insert(self, index, chars, tags=None):
334 is_single_line = '\n' not in chars
335 if is_single_line:
336 before_displaylines = get_displaylines(self, index)
337
338 self.delegate.insert(index, chars, tags)
339
340 if is_single_line:
341 after_displaylines = get_displaylines(self, index)
342 if after_displaylines == before_displaylines:
343 return # no need to update the sidebar
344
345 self.callback()
346
347 def delete(self, index1, index2=None):
348 if index2 is None:
349 index2 = index1 + "+1c"
350 is_single_line = get_lineno(self, index1) == get_lineno(self, index2)
351 if is_single_line:
352 before_displaylines = get_displaylines(self, index1)
353
354 self.delegate.delete(index1, index2)
355
356 if is_single_line:
357 after_displaylines = get_displaylines(self, index1)
358 if after_displaylines == before_displaylines:
359 return # no need to update the sidebar
360
361 self.callback()
362
363
364class ShellSidebar:
365 """Sidebar for the PyShell window, for prompts etc."""
366 def __init__(self, editwin):
367 self.editwin = editwin
368 self.parent = editwin.text_frame
369 self.text = editwin.text
370
371 self.canvas = tk.Canvas(self.parent, width=30,
372 borderwidth=0, highlightthickness=0,
373 takefocus=False)
374
375 self.bind_events()
376
377 change_delegator = \
378 WrappedLineHeightChangeDelegator(self.change_callback)
379
380 # Insert the TextChangeDelegator after the last delegator, so that
381 # the sidebar reflects final changes to the text widget contents.
382 d = self.editwin.per.top
383 if d.delegate is not self.text:
384 while d.delegate is not self.editwin.per.bottom:
385 d = d.delegate
386 self.editwin.per.insertfilterafter(change_delegator, after=d)
387
388 self.text['yscrollcommand'] = self.yscroll_event
389
390 self.is_shown = False
391
392 self.update_font()
393 self.update_colors()
394 self.update_sidebar()
395 self.canvas.grid(row=1, column=0, sticky=tk.NSEW, padx=2, pady=0)
396 self.is_shown = True
397
398 def change_callback(self):
399 if self.is_shown:
400 self.update_sidebar()
401
402 def update_sidebar(self):
403 text = self.text
404 text_tagnames = text.tag_names
405 canvas = self.canvas
406
407 canvas.delete(tk.ALL)
408
409 index = text.index("@0,0")
410 if index.split('.', 1)[1] != '0':
411 index = text.index(f'{index}+1line linestart')
412 while True:
413 lineinfo = text.dlineinfo(index)
414 if lineinfo is None:
415 break
416 y = lineinfo[1]
417 prev_newline_tagnames = text_tagnames(f"{index} linestart -1c")
418 prompt = (
419 '>>>' if "console" in prev_newline_tagnames else
420 '...' if "stdin" in prev_newline_tagnames else
421 None
422 )
423 if prompt:
424 canvas.create_text(2, y, anchor=tk.NW, text=prompt,
425 font=self.font, fill=self.colors[0])
426 index = text.index(f'{index}+1line')
427
428 def yscroll_event(self, *args, **kwargs):
429 """Redirect vertical scrolling to the main editor text widget.
430
431 The scroll bar is also updated.
432 """
433 self.editwin.vbar.set(*args)
434 self.change_callback()
435 return 'break'
436
437 def update_font(self):
438 """Update the sidebar text font, usually after config changes."""
439 font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
440 tk_font = Font(self.text, font=font)
441 char_width = max(tk_font.measure(char) for char in ['>', '.'])
442 self.canvas.configure(width=char_width * 3 + 4)
443 self._update_font(font)
444
445 def _update_font(self, font):
446 self.font = font
447 self.change_callback()
448
449 def update_colors(self):
450 """Update the sidebar text colors, usually after config changes."""
451 linenumbers_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber')
452 prompt_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'console')
453 self._update_colors(foreground=prompt_colors['foreground'],
454 background=linenumbers_colors['background'])
455
456 def _update_colors(self, foreground, background):
457 self.colors = (foreground, background)
458 self.canvas.configure(background=self.colors[1])
459 self.change_callback()
460
461 def redirect_focusin_event(self, event):
462 """Redirect focus-in events to the main editor text widget."""
463 self.text.focus_set()
464 return 'break'
465
466 def redirect_mousebutton_event(self, event, event_name):
467 """Redirect mouse button events to the main editor text widget."""
468 self.text.focus_set()
469 self.text.event_generate(event_name, x=0, y=event.y)
470 return 'break'
471
472 def redirect_mousewheel_event(self, event):
473 """Redirect mouse wheel events to the editwin text widget."""
474 self.text.event_generate('<MouseWheel>',
475 x=0, y=event.y, delta=event.delta)
476 return 'break'
477
478 def bind_events(self):
479 # Ensure focus is always redirected to the main editor text widget.
480 self.canvas.bind('<FocusIn>', self.redirect_focusin_event)
481
482 # Redirect mouse scrolling to the main editor text widget.
483 #
484 # Note that without this, scrolling with the mouse only scrolls
485 # the line numbers.
486 self.canvas.bind('<MouseWheel>', self.redirect_mousewheel_event)
487
488 # Redirect mouse button events to the main editor text widget,
489 # except for the left mouse button (1).
490 #
491 # Note: X-11 sends Button-4 and Button-5 events for the scroll wheel.
492 def bind_mouse_event(event_name, target_event_name):
493 handler = functools.partial(self.redirect_mousebutton_event,
494 event_name=target_event_name)
495 self.canvas.bind(event_name, handler)
496
497 for button in [2, 3, 4, 5]:
498 for event_name in (f'<Button-{button}>',
499 f'<ButtonRelease-{button}>',
500 f'<B{button}-Motion>',
501 ):
502 bind_mouse_event(event_name, target_event_name=event_name)
503
504 # Convert double- and triple-click events to normal click events,
505 # since event_generate() doesn't allow generating such events.
506 for event_name in (f'<Double-Button-{button}>',
507 f'<Triple-Button-{button}>',
508 ):
509 bind_mouse_event(event_name,
510 target_event_name=f'<Button-{button}>')
511
512
Tal Einat7123ea02019-07-23 15:22:11 +0300513def _linenumbers_drag_scrolling(parent): # htest #
514 from idlelib.idle_test.test_sidebar import Dummy_editwin
515
516 toplevel = tk.Toplevel(parent)
517 text_frame = tk.Frame(toplevel)
518 text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
519 text_frame.rowconfigure(1, weight=1)
520 text_frame.columnconfigure(1, weight=1)
521
522 font = idleConf.GetFont(toplevel, 'main', 'EditorWindow')
523 text = tk.Text(text_frame, width=80, height=24, wrap=tk.NONE, font=font)
524 text.grid(row=1, column=1, sticky=tk.NSEW)
525
526 editwin = Dummy_editwin(text)
527 editwin.vbar = tk.Scrollbar(text_frame)
528
529 linenumbers = LineNumbers(editwin)
530 linenumbers.show_sidebar()
531
532 text.insert('1.0', '\n'.join('a'*i for i in range(1, 101)))
533
534
535if __name__ == '__main__':
536 from unittest import main
537 main('idlelib.idle_test.test_sidebar', verbosity=2, exit=False)
538
539 from idlelib.idle_test.htest import run
540 run(_linenumbers_drag_scrolling)