• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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
7
8import tkinter as tk
9from tkinter.font import Font
10from idlelib.config import idleConf
11from idlelib.delegator import Delegator
12from idlelib import macosx
13
14
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
19
20
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')
24
25
26def get_displaylines(text, index):
27    """Display height, in lines, of a logical line in a Tk text widget."""
28    return text.count(f"{index} linestart",
29                      f"{index} lineend",
30                      "displaylines", return_ints=True)
31
32def get_widget_padding(widget):
33    """Get the total padding of a Tk widget, including its border."""
34    # TODO: use also in codecontext.py
35    manager = widget.winfo_manager()
36    if manager == 'pack':
37        info = widget.pack_info()
38    elif manager == 'grid':
39        info = widget.grid_info()
40    else:
41        raise ValueError(f"Unsupported geometry manager: {manager}")
42
43    # All values are passed through getint(), since some
44    # values may be pixel objects, which can't simply be added to ints.
45    padx = sum(map(widget.tk.getint, [
46        info['padx'],
47        widget.cget('padx'),
48        widget.cget('border'),
49    ]))
50    pady = sum(map(widget.tk.getint, [
51        info['pady'],
52        widget.cget('pady'),
53        widget.cget('border'),
54    ]))
55    return padx, pady
56
57
58@contextlib.contextmanager
59def temp_enable_text_widget(text):
60    text.configure(state=tk.NORMAL)
61    try:
62        yield
63    finally:
64        text.configure(state=tk.DISABLED)
65
66
67class BaseSideBar:
68    """A base class for sidebars using Text."""
69    def __init__(self, editwin):
70        self.editwin = editwin
71        self.parent = editwin.text_frame
72        self.text = editwin.text
73
74        self.is_shown = False
75
76        self.main_widget = self.init_widgets()
77
78        self.bind_events()
79
80        self.update_font()
81        self.update_colors()
82
83    def init_widgets(self):
84        """Initialize the sidebar's widgets, returning the main widget."""
85        raise NotImplementedError
86
87    def update_font(self):
88        """Update the sidebar text font, usually after config changes."""
89        raise NotImplementedError
90
91    def update_colors(self):
92        """Update the sidebar text colors, usually after config changes."""
93        raise NotImplementedError
94
95    def grid(self):
96        """Layout the widget, always using grid layout."""
97        raise NotImplementedError
98
99    def show_sidebar(self):
100        if not self.is_shown:
101            self.grid()
102            self.is_shown = True
103
104    def hide_sidebar(self):
105        if self.is_shown:
106            self.main_widget.grid_forget()
107            self.is_shown = False
108
109    def yscroll_event(self, *args, **kwargs):
110        """Hook for vertical scrolling for sub-classes to override."""
111        raise NotImplementedError
112
113    def redirect_yscroll_event(self, *args, **kwargs):
114        """Redirect vertical scrolling to the main editor text widget.
115
116        The scroll bar is also updated.
117        """
118        self.editwin.vbar.set(*args)
119        return self.yscroll_event(*args, **kwargs)
120
121    def redirect_focusin_event(self, event):
122        """Redirect focus-in events to the main editor text widget."""
123        self.text.focus_set()
124        return 'break'
125
126    def redirect_mousebutton_event(self, event, event_name):
127        """Redirect mouse button events to the main editor text widget."""
128        self.text.focus_set()
129        self.text.event_generate(event_name, x=0, y=event.y)
130        return 'break'
131
132    def redirect_mousewheel_event(self, event):
133        """Redirect mouse wheel events to the editwin text widget."""
134        self.text.event_generate('<MouseWheel>',
135                                 x=0, y=event.y, delta=event.delta)
136        return 'break'
137
138    def bind_events(self):
139        self.text['yscrollcommand'] = self.redirect_yscroll_event
140
141        # Ensure focus is always redirected to the main editor text widget.
142        self.main_widget.bind('<FocusIn>', self.redirect_focusin_event)
143
144        # Redirect mouse scrolling to the main editor text widget.
145        #
146        # Note that without this, scrolling with the mouse only scrolls
147        # the line numbers.
148        self.main_widget.bind('<MouseWheel>', self.redirect_mousewheel_event)
149
150        # Redirect mouse button events to the main editor text widget,
151        # except for the left mouse button (1).
152        #
153        # Note: X-11 sends Button-4 and Button-5 events for the scroll wheel.
154        def bind_mouse_event(event_name, target_event_name):
155            handler = functools.partial(self.redirect_mousebutton_event,
156                                        event_name=target_event_name)
157            self.main_widget.bind(event_name, handler)
158
159        for button in [2, 3, 4, 5]:
160            for event_name in (f'<Button-{button}>',
161                               f'<ButtonRelease-{button}>',
162                               f'<B{button}-Motion>',
163                               ):
164                bind_mouse_event(event_name, target_event_name=event_name)
165
166            # Convert double- and triple-click events to normal click events,
167            # since event_generate() doesn't allow generating such events.
168            for event_name in (f'<Double-Button-{button}>',
169                               f'<Triple-Button-{button}>',
170                               ):
171                bind_mouse_event(event_name,
172                                 target_event_name=f'<Button-{button}>')
173
174        # start_line is set upon <Button-1> to allow selecting a range of rows
175        # by dragging.  It is cleared upon <ButtonRelease-1>.
176        start_line = None
177
178        # last_y is initially set upon <B1-Leave> and is continuously updated
179        # upon <B1-Motion>, until <B1-Enter> or the mouse button is released.
180        # It is used in text_auto_scroll(), which is called repeatedly and
181        # does have a mouse event available.
182        last_y = None
183
184        # auto_scrolling_after_id is set whenever text_auto_scroll is
185        # scheduled via .after().  It is used to stop the auto-scrolling
186        # upon <B1-Enter>, as well as to avoid scheduling the function several
187        # times in parallel.
188        auto_scrolling_after_id = None
189
190        def drag_update_selection_and_insert_mark(y_coord):
191            """Helper function for drag and selection event handlers."""
192            lineno = get_lineno(self.text, f"@0,{y_coord}")
193            a, b = sorted([start_line, lineno])
194            self.text.tag_remove("sel", "1.0", "end")
195            self.text.tag_add("sel", f"{a}.0", f"{b+1}.0")
196            self.text.mark_set("insert",
197                               f"{lineno if lineno == a else lineno + 1}.0")
198
199        def b1_mousedown_handler(event):
200            nonlocal start_line
201            nonlocal last_y
202            start_line = int(float(self.text.index(f"@0,{event.y}")))
203            last_y = event.y
204
205            drag_update_selection_and_insert_mark(event.y)
206        self.main_widget.bind('<Button-1>', b1_mousedown_handler)
207
208        def b1_mouseup_handler(event):
209            # On mouse up, we're no longer dragging.  Set the shared persistent
210            # variables to None to represent this.
211            nonlocal start_line
212            nonlocal last_y
213            start_line = None
214            last_y = None
215            self.text.event_generate('<ButtonRelease-1>', x=0, y=event.y)
216        self.main_widget.bind('<ButtonRelease-1>', b1_mouseup_handler)
217
218        def b1_drag_handler(event):
219            nonlocal last_y
220            if last_y is None:  # i.e. if not currently dragging
221                return
222            last_y = event.y
223            drag_update_selection_and_insert_mark(event.y)
224        self.main_widget.bind('<B1-Motion>', b1_drag_handler)
225
226        def text_auto_scroll():
227            """Mimic Text auto-scrolling when dragging outside of it."""
228            # See: https://github.com/tcltk/tk/blob/064ff9941b4b80b85916a8afe86a6c21fd388b54/library/text.tcl#L670
229            nonlocal auto_scrolling_after_id
230            y = last_y
231            if y is None:
232                self.main_widget.after_cancel(auto_scrolling_after_id)
233                auto_scrolling_after_id = None
234                return
235            elif y < 0:
236                self.text.yview_scroll(-1 + y, 'pixels')
237                drag_update_selection_and_insert_mark(y)
238            elif y > self.main_widget.winfo_height():
239                self.text.yview_scroll(1 + y - self.main_widget.winfo_height(),
240                                       'pixels')
241                drag_update_selection_and_insert_mark(y)
242            auto_scrolling_after_id = \
243                self.main_widget.after(50, text_auto_scroll)
244
245        def b1_leave_handler(event):
246            # Schedule the initial call to text_auto_scroll(), if not already
247            # scheduled.
248            nonlocal auto_scrolling_after_id
249            if auto_scrolling_after_id is None:
250                nonlocal last_y
251                last_y = event.y
252                auto_scrolling_after_id = \
253                    self.main_widget.after(0, text_auto_scroll)
254        self.main_widget.bind('<B1-Leave>', b1_leave_handler)
255
256        def b1_enter_handler(event):
257            # Cancel the scheduling of text_auto_scroll(), if it exists.
258            nonlocal auto_scrolling_after_id
259            if auto_scrolling_after_id is not None:
260                self.main_widget.after_cancel(auto_scrolling_after_id)
261                auto_scrolling_after_id = None
262        self.main_widget.bind('<B1-Enter>', b1_enter_handler)
263
264
265class EndLineDelegator(Delegator):
266    """Generate callbacks with the current end line number.
267
268    The provided callback is called after every insert and delete.
269    """
270    def __init__(self, changed_callback):
271        Delegator.__init__(self)
272        self.changed_callback = changed_callback
273
274    def insert(self, index, chars, tags=None):
275        self.delegate.insert(index, chars, tags)
276        self.changed_callback(get_end_linenumber(self.delegate))
277
278    def delete(self, index1, index2=None):
279        self.delegate.delete(index1, index2)
280        self.changed_callback(get_end_linenumber(self.delegate))
281
282
283class LineNumbers(BaseSideBar):
284    """Line numbers support for editor windows."""
285    def __init__(self, editwin):
286        super().__init__(editwin)
287
288        end_line_delegator = EndLineDelegator(self.update_sidebar_text)
289        # Insert the delegator after the undo delegator, so that line numbers
290        # are properly updated after undo and redo actions.
291        self.editwin.per.insertfilterafter(end_line_delegator,
292                                           after=self.editwin.undo)
293
294    def init_widgets(self):
295        _padx, pady = get_widget_padding(self.text)
296        self.sidebar_text = tk.Text(self.parent, width=1, wrap=tk.NONE,
297                                    padx=2, pady=pady,
298                                    borderwidth=0, highlightthickness=0)
299        self.sidebar_text.config(state=tk.DISABLED)
300
301        self.prev_end = 1
302        self._sidebar_width_type = type(self.sidebar_text['width'])
303        with temp_enable_text_widget(self.sidebar_text):
304            self.sidebar_text.insert('insert', '1', 'linenumber')
305        self.sidebar_text.config(takefocus=False, exportselection=False)
306        self.sidebar_text.tag_config('linenumber', justify=tk.RIGHT)
307
308        end = get_end_linenumber(self.text)
309        self.update_sidebar_text(end)
310
311        return self.sidebar_text
312
313    def grid(self):
314        self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW)
315
316    def update_font(self):
317        font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
318        self.sidebar_text['font'] = font
319
320    def update_colors(self):
321        """Update the sidebar text colors, usually after config changes."""
322        colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber')
323        foreground = colors['foreground']
324        background = colors['background']
325        self.sidebar_text.config(
326            fg=foreground, bg=background,
327            selectforeground=foreground, selectbackground=background,
328            inactiveselectbackground=background,
329        )
330
331    def update_sidebar_text(self, end):
332        """
333        Perform the following action:
334        Each line sidebar_text contains the linenumber for that line
335        Synchronize with editwin.text so that both sidebar_text and
336        editwin.text contain the same number of lines"""
337        if end == self.prev_end:
338            return
339
340        width_difference = len(str(end)) - len(str(self.prev_end))
341        if width_difference:
342            cur_width = int(float(self.sidebar_text['width']))
343            new_width = cur_width + width_difference
344            self.sidebar_text['width'] = self._sidebar_width_type(new_width)
345
346        with temp_enable_text_widget(self.sidebar_text):
347            if end > self.prev_end:
348                new_text = '\n'.join(itertools.chain(
349                    [''],
350                    map(str, range(self.prev_end + 1, end + 1)),
351                ))
352                self.sidebar_text.insert(f'end -1c', new_text, 'linenumber')
353            else:
354                self.sidebar_text.delete(f'{end+1}.0 -1c', 'end -1c')
355
356        self.prev_end = end
357
358    def yscroll_event(self, *args, **kwargs):
359        self.sidebar_text.yview_moveto(args[0])
360        return 'break'
361
362
363class WrappedLineHeightChangeDelegator(Delegator):
364    def __init__(self, callback):
365        """
366        callback - Callable, will be called when an insert, delete or replace
367                   action on the text widget may require updating the shell
368                   sidebar.
369        """
370        Delegator.__init__(self)
371        self.callback = callback
372
373    def insert(self, index, chars, tags=None):
374        is_single_line = '\n' not in chars
375        if is_single_line:
376            before_displaylines = get_displaylines(self, index)
377
378        self.delegate.insert(index, chars, tags)
379
380        if is_single_line:
381            after_displaylines = get_displaylines(self, index)
382            if after_displaylines == before_displaylines:
383                return  # no need to update the sidebar
384
385        self.callback()
386
387    def delete(self, index1, index2=None):
388        if index2 is None:
389            index2 = index1 + "+1c"
390        is_single_line = get_lineno(self, index1) == get_lineno(self, index2)
391        if is_single_line:
392            before_displaylines = get_displaylines(self, index1)
393
394        self.delegate.delete(index1, index2)
395
396        if is_single_line:
397            after_displaylines = get_displaylines(self, index1)
398            if after_displaylines == before_displaylines:
399                return  # no need to update the sidebar
400
401        self.callback()
402
403
404class ShellSidebar(BaseSideBar):
405    """Sidebar for the PyShell window, for prompts etc."""
406    def __init__(self, editwin):
407        self.canvas = None
408        self.line_prompts = {}
409
410        super().__init__(editwin)
411
412        change_delegator = \
413            WrappedLineHeightChangeDelegator(self.change_callback)
414        # Insert the TextChangeDelegator after the last delegator, so that
415        # the sidebar reflects final changes to the text widget contents.
416        d = self.editwin.per.top
417        if d.delegate is not self.text:
418            while d.delegate is not self.editwin.per.bottom:
419                d = d.delegate
420        self.editwin.per.insertfilterafter(change_delegator, after=d)
421
422        self.is_shown = True
423
424    def init_widgets(self):
425        self.canvas = tk.Canvas(self.parent, width=30,
426                                borderwidth=0, highlightthickness=0,
427                                takefocus=False)
428        self.update_sidebar()
429        self.grid()
430        return self.canvas
431
432    def bind_events(self):
433        super().bind_events()
434
435        self.main_widget.bind(
436            # AquaTk defines <2> as the right button, not <3>.
437            "<Button-2>" if macosx.isAquaTk() else "<Button-3>",
438            self.context_menu_event,
439        )
440
441    def context_menu_event(self, event):
442        rmenu = tk.Menu(self.main_widget, tearoff=0)
443        has_selection = bool(self.text.tag_nextrange('sel', '1.0'))
444        def mkcmd(eventname):
445            return lambda: self.text.event_generate(eventname)
446        rmenu.add_command(label='Copy',
447                          command=mkcmd('<<copy>>'),
448                          state='normal' if has_selection else 'disabled')
449        rmenu.add_command(label='Copy with prompts',
450                          command=mkcmd('<<copy-with-prompts>>'),
451                          state='normal' if has_selection else 'disabled')
452        rmenu.tk_popup(event.x_root, event.y_root)
453        return "break"
454
455    def grid(self):
456        self.canvas.grid(row=1, column=0, sticky=tk.NSEW, padx=2, pady=0)
457
458    def change_callback(self):
459        if self.is_shown:
460            self.update_sidebar()
461
462    def update_sidebar(self):
463        text = self.text
464        text_tagnames = text.tag_names
465        canvas = self.canvas
466        line_prompts = self.line_prompts = {}
467
468        canvas.delete(tk.ALL)
469
470        index = text.index("@0,0")
471        if index.split('.', 1)[1] != '0':
472            index = text.index(f'{index}+1line linestart')
473        while (lineinfo := text.dlineinfo(index)) is not None:
474            y = lineinfo[1]
475            prev_newline_tagnames = text_tagnames(f"{index} linestart -1c")
476            prompt = (
477                '>>>' if "console" in prev_newline_tagnames else
478                '...' if "stdin" in prev_newline_tagnames else
479                None
480            )
481            if prompt:
482                canvas.create_text(2, y, anchor=tk.NW, text=prompt,
483                                   font=self.font, fill=self.colors[0])
484                lineno = get_lineno(text, index)
485                line_prompts[lineno] = prompt
486            index = text.index(f'{index}+1line')
487
488    def yscroll_event(self, *args, **kwargs):
489        """Redirect vertical scrolling to the main editor text widget.
490
491        The scroll bar is also updated.
492        """
493        self.change_callback()
494        return 'break'
495
496    def update_font(self):
497        """Update the sidebar text font, usually after config changes."""
498        font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
499        tk_font = Font(self.text, font=font)
500        char_width = max(tk_font.measure(char) for char in ['>', '.'])
501        self.canvas.configure(width=char_width * 3 + 4)
502        self.font = font
503        self.change_callback()
504
505    def update_colors(self):
506        """Update the sidebar text colors, usually after config changes."""
507        linenumbers_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber')
508        prompt_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'console')
509        foreground = prompt_colors['foreground']
510        background = linenumbers_colors['background']
511        self.colors = (foreground, background)
512        self.canvas.configure(background=background)
513        self.change_callback()
514
515
516def _sidebar_number_scrolling(parent):  # htest #
517    from idlelib.idle_test.test_sidebar import Dummy_editwin
518
519    top = tk.Toplevel(parent)
520    text_frame = tk.Frame(top)
521    text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
522    text_frame.rowconfigure(1, weight=1)
523    text_frame.columnconfigure(1, weight=1)
524
525    font = idleConf.GetFont(top, 'main', 'EditorWindow')
526    text = tk.Text(text_frame, width=80, height=24, wrap=tk.NONE, font=font)
527    text.grid(row=1, column=1, sticky=tk.NSEW)
528
529    editwin = Dummy_editwin(text)
530    editwin.vbar = tk.Scrollbar(text_frame)
531
532    linenumbers = LineNumbers(editwin)
533    linenumbers.show_sidebar()
534
535    text.insert('1.0', '\n'.join('a'*i for i in range(1, 101)))
536
537
538if __name__ == '__main__':
539    from unittest import main
540    main('idlelib.idle_test.test_sidebar', verbosity=2, exit=False)
541
542    from idlelib.idle_test.htest import run
543    run(_sidebar_number_scrolling)
544