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    res = text.count(f"{index} linestart",
29                     f"{index} lineend",
30                     "displaylines")
31    return res[0] if res else 0
32
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}")
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
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)
66
67
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
74
75        self.is_shown = False
76
77        self.main_widget = self.init_widgets()
78
79        self.bind_events()
80
81        self.update_font()
82        self.update_colors()
83
84    def init_widgets(self):
85        """Initialize the sidebar's widgets, returning the main widget."""
86        raise NotImplementedError
87
88    def update_font(self):
89        """Update the sidebar text font, usually after config changes."""
90        raise NotImplementedError
91
92    def update_colors(self):
93        """Update the sidebar text colors, usually after config changes."""
94        raise NotImplementedError
95
96    def grid(self):
97        """Layout the widget, always using grid layout."""
98        raise NotImplementedError
99
100    def show_sidebar(self):
101        if not self.is_shown:
102            self.grid()
103            self.is_shown = True
104
105    def hide_sidebar(self):
106        if self.is_shown:
107            self.main_widget.grid_forget()
108            self.is_shown = False
109
110    def yscroll_event(self, *args, **kwargs):
111        """Hook for vertical scrolling for sub-classes to override."""
112        raise NotImplementedError
113
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)
120        return self.yscroll_event(*args, **kwargs)
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
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
265
266class EndLineDelegator(Delegator):
267    """Generate callbacks with the current end line number.
268
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
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
284class LineNumbers(BaseSideBar):
285    """Line numbers support for editor windows."""
286    def __init__(self, editwin):
287        super().__init__(editwin)
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.
292        self.editwin.per.insertfilterafter(end_line_delegator,
293                                           after=self.editwin.undo)
294
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)
301
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)
308
309        end = get_end_linenumber(self.text)
310        self.update_sidebar_text(end)
311
312        return self.sidebar_text
313
314    def grid(self):
315        self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW)
316
317    def update_font(self):
318        font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
319        self.sidebar_text['font'] = font
320
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        )
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
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')
356
357        self.prev_end = end
358
359    def yscroll_event(self, *args, **kwargs):
360        self.sidebar_text.yview_moveto(args[0])
361        return 'break'
362
363
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
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
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 = {}
410
411        super().__init__(editwin)
412
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)
422
423        self.is_shown = True
424
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
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
467        line_prompts = self.line_prompts = {}
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])
488                lineno = get_lineno(text, index)
489                line_prompts[lineno] = prompt
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        """
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)
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')
513        foreground = prompt_colors['foreground']
514        background = linenumbers_colors['background']
515        self.colors = (foreground, background)
516        self.canvas.configure(background=background)
517        self.change_callback()
518
519
520def _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
542if __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)
548