Coverage for sidebar.py: 16%
304 statements
« prev ^ index » next coverage.py v7.2.5, created at 2023-05-11 13:22 -0700
« prev ^ index » next coverage.py v7.2.5, created at 2023-05-11 13:22 -0700
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
8import tkinter as tk
9from tkinter.font import Font
10from idlelib.config import idleConf
11from idlelib.delegator import Delegator
12from idlelib import macosx
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
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')
26def 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
33def 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}")
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
59@contextlib.contextmanager
60def temp_enable_text_widget(text):
61 text.configure(state=tk.NORMAL)
62 try:
63 yield
64 finally:
65 text.configure(state=tk.DISABLED)
68class BaseSideBar:
69 """A base class for sidebars using Text."""
70 def __init__(self, editwin):
71 self.editwin = editwin
72 self.parent = editwin.text_frame
73 self.text = editwin.text
75 self.is_shown = False
77 self.main_widget = self.init_widgets()
79 self.bind_events()
81 self.update_font()
82 self.update_colors()
84 def init_widgets(self):
85 """Initialize the sidebar's widgets, returning the main widget."""
86 raise NotImplementedError
88 def update_font(self):
89 """Update the sidebar text font, usually after config changes."""
90 raise NotImplementedError
92 def update_colors(self):
93 """Update the sidebar text colors, usually after config changes."""
94 raise NotImplementedError
96 def grid(self):
97 """Layout the widget, always using grid layout."""
98 raise NotImplementedError
100 def show_sidebar(self):
101 if not self.is_shown:
102 self.grid()
103 self.is_shown = True
105 def hide_sidebar(self):
106 if self.is_shown:
107 self.main_widget.grid_forget()
108 self.is_shown = False
110 def yscroll_event(self, *args, **kwargs):
111 """Hook for vertical scrolling for sub-classes to override."""
112 raise NotImplementedError
114 def redirect_yscroll_event(self, *args, **kwargs):
115 """Redirect vertical scrolling to the main editor text widget.
117 The scroll bar is also updated.
118 """
119 self.editwin.vbar.set(*args)
120 return self.yscroll_event(*args, **kwargs)
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'
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'
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'
139 def bind_events(self):
140 self.text['yscrollcommand'] = self.redirect_yscroll_event
142 # Ensure focus is always redirected to the main editor text widget.
143 self.main_widget.bind('<FocusIn>', self.redirect_focusin_event)
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)
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)
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)
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}>')
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
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
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
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")
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
206 drag_update_selection_and_insert_mark(event.y)
207 self.main_widget.bind('<Button-1>', b1_mousedown_handler)
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)
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)
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)
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)
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)
266class EndLineDelegator(Delegator):
267 """Generate callbacks with the current end line number.
269 The provided callback is called after every insert and delete.
270 """
271 def __init__(self, changed_callback):
272 Delegator.__init__(self)
273 self.changed_callback = changed_callback
275 def insert(self, index, chars, tags=None):
276 self.delegate.insert(index, chars, tags)
277 self.changed_callback(get_end_linenumber(self.delegate))
279 def delete(self, index1, index2=None):
280 self.delegate.delete(index1, index2)
281 self.changed_callback(get_end_linenumber(self.delegate))
284class LineNumbers(BaseSideBar):
285 """Line numbers support for editor windows."""
286 def __init__(self, editwin):
287 super().__init__(editwin)
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.
292 self.editwin.per.insertfilterafter(end_line_delegator,
293 after=self.editwin.undo)
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)
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)
309 end = get_end_linenumber(self.text)
310 self.update_sidebar_text(end)
312 return self.sidebar_text
314 def grid(self):
315 self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW)
317 def update_font(self):
318 font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
319 self.sidebar_text['font'] = font
321 def update_colors(self):
322 """Update the sidebar text colors, usually after config changes."""
323 colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber')
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 )
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
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)
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')
357 self.prev_end = end
359 def yscroll_event(self, *args, **kwargs):
360 self.sidebar_text.yview_moveto(args[0])
361 return 'break'
364class 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
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)
379 self.delegate.insert(index, chars, tags)
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
386 self.callback()
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)
395 self.delegate.delete(index1, index2)
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
402 self.callback()
405class ShellSidebar(BaseSideBar):
406 """Sidebar for the PyShell window, for prompts etc."""
407 def __init__(self, editwin):
408 self.canvas = None
409 self.line_prompts = {}
411 super().__init__(editwin)
413 change_delegator = \
414 WrappedLineHeightChangeDelegator(self.change_callback)
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)
423 self.is_shown = True
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
433 def bind_events(self):
434 super().bind_events()
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 )
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"
456 def grid(self):
457 self.canvas.grid(row=1, column=0, sticky=tk.NSEW, padx=2, pady=0)
459 def change_callback(self):
460 if self.is_shown:
461 self.update_sidebar()
463 def update_sidebar(self):
464 text = self.text
465 text_tagnames = text.tag_names
466 canvas = self.canvas
467 line_prompts = self.line_prompts = {}
469 canvas.delete(tk.ALL)
471 index = text.index("@0,0")
472 if index.split('.', 1)[1] != '0':
473 index = text.index(f'{index}+1line linestart')
474 while (lineinfo := text.dlineinfo(index)) is not None:
475 y = lineinfo[1]
476 prev_newline_tagnames = text_tagnames(f"{index} linestart -1c")
477 prompt = (
478 '>>>' if "console" in prev_newline_tagnames else
479 '...' if "stdin" in prev_newline_tagnames else
480 None
481 )
482 if prompt:
483 canvas.create_text(2, y, anchor=tk.NW, text=prompt,
484 font=self.font, fill=self.colors[0])
485 lineno = get_lineno(text, index)
486 line_prompts[lineno] = prompt
487 index = text.index(f'{index}+1line')
489 def yscroll_event(self, *args, **kwargs):
490 """Redirect vertical scrolling to the main editor text widget.
492 The scroll bar is also updated.
493 """
494 self.change_callback()
495 return 'break'
497 def update_font(self):
498 """Update the sidebar text font, usually after config changes."""
499 font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
500 tk_font = Font(self.text, font=font)
501 char_width = max(tk_font.measure(char) for char in ['>', '.'])
502 self.canvas.configure(width=char_width * 3 + 4)
503 self.font = font
504 self.change_callback()
506 def update_colors(self):
507 """Update the sidebar text colors, usually after config changes."""
508 linenumbers_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber')
509 prompt_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'console')
510 foreground = prompt_colors['foreground']
511 background = linenumbers_colors['background']
512 self.colors = (foreground, background)
513 self.canvas.configure(background=background)
514 self.change_callback()
517def _linenumbers_drag_scrolling(parent): # htest #
518 from idlelib.idle_test.test_sidebar import Dummy_editwin
520 toplevel = tk.Toplevel(parent)
521 text_frame = tk.Frame(toplevel)
522 text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
523 text_frame.rowconfigure(1, weight=1)
524 text_frame.columnconfigure(1, weight=1)
526 font = idleConf.GetFont(toplevel, 'main', 'EditorWindow')
527 text = tk.Text(text_frame, width=80, height=24, wrap=tk.NONE, font=font)
528 text.grid(row=1, column=1, sticky=tk.NSEW)
530 editwin = Dummy_editwin(text)
531 editwin.vbar = tk.Scrollbar(text_frame)
533 linenumbers = LineNumbers(editwin)
534 linenumbers.show_sidebar()
536 text.insert('1.0', '\n'.join('a'*i for i in range(1, 101)))
539if __name__ == '__main__': 539 ↛ 540line 539 didn't jump to line 540, because the condition on line 539 was never true
540 from unittest import main
541 main('idlelib.idle_test.test_sidebar', verbosity=2, exit=False)
543 from idlelib.idle_test.htest import run
544 run(_linenumbers_drag_scrolling)