[Python-checkins] bpo-37903: IDLE: add shell sidebar mouse interactions (GH-25708)

terryjreedy webhook-mailer at python.org
Sun May 2 22:27:47 EDT 2021


https://github.com/python/cpython/commit/b43cc31a270d0dacbc69e35d6c6fbdb5edd7e711
commit: b43cc31a270d0dacbc69e35d6c6fbdb5edd7e711
branch: master
author: Tal Einat <532281+taleinat at users.noreply.github.com>
committer: terryjreedy <tjreedy at udel.edu>
date: 2021-05-02T22:27:38-04:00
summary:

bpo-37903: IDLE: add shell sidebar mouse interactions (GH-25708)

Left click and drag to select lines.  With selection, right click for context menu with copy and copy-with-prompts.
Also add copy-with-prompts to the text-box context menu.

Co-authored-by: Terry Jan Reedy <tjreedy at udel.edu>

files:
A Misc/NEWS.d/next/IDLE/2021-05-02-20-25-53.bpo-37903.VQ6VTU.rst
M Doc/whatsnew/3.10.rst
M Lib/idlelib/NEWS.txt
M Lib/idlelib/autocomplete.py
M Lib/idlelib/autocomplete_w.py
M Lib/idlelib/editor.py
M Lib/idlelib/idle_test/test_autocomplete_w.py
M Lib/idlelib/idle_test/test_sidebar.py
M Lib/idlelib/pyshell.py
M Lib/idlelib/sidebar.py

diff --git a/Doc/whatsnew/3.10.rst b/Doc/whatsnew/3.10.rst
index 223ab65cfc311..eb452b07f55f6 100644
--- a/Doc/whatsnew/3.10.rst
+++ b/Doc/whatsnew/3.10.rst
@@ -994,6 +994,32 @@ hmac
 The hmac module now uses OpenSSL's HMAC implementation internally.
 (Contributed by Christian Heimes in :issue:`40645`.)
 
+IDLE and idlelib
+----------------
+
+Make IDLE invoke :func:`sys.excepthook` (when started without '-n').
+User hooks were previously ignored.  (Patch by Ken Hilton in
+:issue:`43008`.)
+
+This change was backported to a 3.9 maintenance release.
+
+Add a Shell sidebar.  Move the primary prompt ('>>>') to the sidebar.
+Add secondary prompts ('...') to the sidebar.  Left click and optional
+drag selects one or more lines of text, as with the editor
+line number sidebar.  Right click after selecting text lines displays
+a context menu with 'copy with prompts'.  This zips together prompts
+from the sidebar with lines from the selected text.  This option also
+appears on the context menu for the text.  (Contributed by Tal Einat
+in :issue:`37903`.)
+
+Use spaces instead of tabs to indent interactive code.  This makes
+interactive code entries 'look right'.  Making this feasible was a
+major motivation for adding the shell sidebar.  Contributed by
+Terry Jan Reedy in :issue:`37892`.)
+
+We expect to backport these shell changes to a future 3.9 maintenance
+release.
+
 importlib.metadata
 ------------------
 
diff --git a/Lib/idlelib/NEWS.txt b/Lib/idlelib/NEWS.txt
index 83afe3ecac908..ed1142653d953 100644
--- a/Lib/idlelib/NEWS.txt
+++ b/Lib/idlelib/NEWS.txt
@@ -4,7 +4,15 @@ Released on 2021-10-04?
 =========================
 
 
-bpo-37892: Change Shell input indents from tabs to spaces.
+bpo-37903: Add mouse actions to the shell sidebar.  Left click and
+optional drag selects one or more lines of text, as with the
+editor line number sidebar.  Right click after selecting text lines
+displays a context menu with 'copy with prompts'.  This zips together
+prompts from the sidebar with lines from the selected text.  This option
+also appears on the context menu for the text.
+
+bpo-37892: Change Shell input indents from tabs to spaces.  Shell input
+now 'looks right'.  Making this feasible motivated the shell sidebar.
 
 bpo-37903: Move the Shell input prompt to a side bar.
 
@@ -19,7 +27,8 @@ bpo-23544: Disable Debug=>Stack Viewer when user code is running or
 Debugger is active, to prevent hang or crash.  Patch by Zackery Spytz.
 
 bpo-43008: Make IDLE invoke :func:`sys.excepthook` in normal,
-2-process mode.  Patch by Ken Hilton.
+2-process mode.  User hooks were previously ignored.
+Patch by Ken Hilton.
 
 bpo-33065: Fix problem debugging user classes with __repr__ method.
 
@@ -32,7 +41,7 @@ installers built on macOS 11.
 
 bpo-42426: Fix reporting offset of the RE error in searchengine.
 
-bpo-42416: Get docstrings for IDLE calltips more often
+bpo-42416: Display docstrings in IDLE calltips in more cases,
 by using inspect.getdoc.
 
 bpo-33987: Mostly finish using ttk widgets, mainly for editor,
diff --git a/Lib/idlelib/autocomplete.py b/Lib/idlelib/autocomplete.py
index e1e9e17311eda..bb7ee035c4fef 100644
--- a/Lib/idlelib/autocomplete.py
+++ b/Lib/idlelib/autocomplete.py
@@ -31,10 +31,11 @@
 
 class AutoComplete:
 
-    def __init__(self, editwin=None):
+    def __init__(self, editwin=None, tags=None):
         self.editwin = editwin
         if editwin is not None:   # not in subprocess or no-gui test
             self.text = editwin.text
+        self.tags = tags
         self.autocompletewindow = None
         # id of delayed call, and the index of the text insert when
         # the delayed call was issued. If _delayed_completion_id is
@@ -48,7 +49,7 @@ def reload(cls):
             "extensions", "AutoComplete", "popupwait", type="int", default=0)
 
     def _make_autocomplete_window(self):  # Makes mocking easier.
-        return autocomplete_w.AutoCompleteWindow(self.text)
+        return autocomplete_w.AutoCompleteWindow(self.text, tags=self.tags)
 
     def _remove_autocomplete_window(self, event=None):
         if self.autocompletewindow:
diff --git a/Lib/idlelib/autocomplete_w.py b/Lib/idlelib/autocomplete_w.py
index fe7a6be83d586..d3d1e6982bfb2 100644
--- a/Lib/idlelib/autocomplete_w.py
+++ b/Lib/idlelib/autocomplete_w.py
@@ -26,9 +26,11 @@
 
 class AutoCompleteWindow:
 
-    def __init__(self, widget):
+    def __init__(self, widget, tags):
         # The widget (Text) on which we place the AutoCompleteWindow
         self.widget = widget
+        # Tags to mark inserted text with
+        self.tags = tags
         # The widgets we create
         self.autocompletewindow = self.listbox = self.scrollbar = None
         # The default foreground and background of a selection. Saved because
@@ -69,7 +71,8 @@ def _change_start(self, newstart):
                                "%s+%dc" % (self.startindex, len(self.start)))
         if i < len(newstart):
             self.widget.insert("%s+%dc" % (self.startindex, i),
-                               newstart[i:])
+                               newstart[i:],
+                               self.tags)
         self.start = newstart
 
     def _binary_search(self, s):
diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py
index 8b544407da2e0..fcc8a3f08ccfe 100644
--- a/Lib/idlelib/editor.py
+++ b/Lib/idlelib/editor.py
@@ -311,7 +311,7 @@ def __init__(self, flist=None, filename=None, key=None, root=None):
 
         # Former extension bindings depends on frame.text being packed
         # (called from self.ResetColorizer()).
-        autocomplete = self.AutoComplete(self)
+        autocomplete = self.AutoComplete(self, self.user_input_insert_tags)
         text.bind("<<autocomplete>>", autocomplete.autocomplete_event)
         text.bind("<<try-open-completions>>",
                   autocomplete.try_open_completions_event)
diff --git a/Lib/idlelib/idle_test/test_autocomplete_w.py b/Lib/idlelib/idle_test/test_autocomplete_w.py
index b1bdc6c7c6e1a..a59a375c90fd8 100644
--- a/Lib/idlelib/idle_test/test_autocomplete_w.py
+++ b/Lib/idlelib/idle_test/test_autocomplete_w.py
@@ -15,7 +15,7 @@ def setUpClass(cls):
         cls.root = Tk()
         cls.root.withdraw()
         cls.text = Text(cls.root)
-        cls.acw = acw.AutoCompleteWindow(cls.text)
+        cls.acw = acw.AutoCompleteWindow(cls.text, tags=None)
 
     @classmethod
     def tearDownClass(cls):
diff --git a/Lib/idlelib/idle_test/test_sidebar.py b/Lib/idlelib/idle_test/test_sidebar.py
index 0497f6d05139e..43e8137d7079c 100644
--- a/Lib/idlelib/idle_test/test_sidebar.py
+++ b/Lib/idlelib/idle_test/test_sidebar.py
@@ -270,7 +270,6 @@ def test_click_selection(self):
 
         self.assertEqual(self.get_selection(), ('2.0', '3.0'))
 
-    @unittest.skip('test disabled')
     def simulate_drag(self, start_line, end_line):
         start_x, start_y = self.get_line_screen_position(start_line)
         end_x, end_y = self.get_line_screen_position(end_line)
@@ -704,6 +703,66 @@ def test_mousewheel(self):
         yield
         self.assertIsNotNone(text.dlineinfo(text.index(f'{last_lineno}.0')))
 
+    @run_in_tk_mainloop
+    def test_copy(self):
+        sidebar = self.shell.shell_sidebar
+        text = self.shell.text
+
+        first_line = get_end_linenumber(text)
+
+        self.do_input(dedent('''\
+            if True:
+            print(1)
+
+            '''))
+        yield
+
+        text.tag_add('sel', f'{first_line}.0', 'end-1c')
+        selected_text = text.get('sel.first', 'sel.last')
+        self.assertTrue(selected_text.startswith('if True:\n'))
+        self.assertIn('\n1\n', selected_text)
+
+        text.event_generate('<<copy>>')
+        self.addCleanup(text.clipboard_clear)
+
+        copied_text = text.clipboard_get()
+        self.assertEqual(copied_text, selected_text)
+
+    @run_in_tk_mainloop
+    def test_copy_with_prompts(self):
+        sidebar = self.shell.shell_sidebar
+        text = self.shell.text
+
+        first_line = get_end_linenumber(text)
+        self.do_input(dedent('''\
+            if True:
+            print(1)
+
+            '''))
+        yield
+
+        text.tag_add('sel', f'{first_line}.3', 'end-1c')
+        selected_text = text.get('sel.first', 'sel.last')
+        self.assertTrue(selected_text.startswith('True:\n'))
+
+        selected_lines_text = text.get('sel.first linestart', 'sel.last')
+        selected_lines = selected_lines_text.split('\n')
+        # Expect a block of input, a single output line, and a new prompt
+        expected_prompts = \
+            ['>>>'] + ['...'] * (len(selected_lines) - 3) + [None, '>>>']
+        selected_text_with_prompts = '\n'.join(
+            line if prompt is None else prompt + ' ' + line
+            for prompt, line in zip(expected_prompts,
+                                    selected_lines,
+                                    strict=True)
+        ) + '\n'
+
+        text.event_generate('<<copy-with-prompts>>')
+        self.addCleanup(text.clipboard_clear)
+
+        copied_text = text.clipboard_get()
+        self.assertEqual(copied_text, selected_text_with_prompts)
+
 
 if __name__ == '__main__':
     unittest.main(verbosity=2)
diff --git a/Lib/idlelib/pyshell.py b/Lib/idlelib/pyshell.py
index 447e9ec3e4756..4e7440038ac99 100755
--- a/Lib/idlelib/pyshell.py
+++ b/Lib/idlelib/pyshell.py
@@ -33,6 +33,7 @@
     raise SystemExit(1)
 
 from code import InteractiveInterpreter
+import itertools
 import linecache
 import os
 import os.path
@@ -865,6 +866,13 @@ class PyShell(OutputWindow):
     rmenu_specs = OutputWindow.rmenu_specs + [
         ("Squeeze", "<<squeeze-current-text>>"),
     ]
+    _idx = 1 + len(list(itertools.takewhile(
+        lambda rmenu_item: rmenu_item[0] != "Copy", rmenu_specs)
+    ))
+    rmenu_specs.insert(_idx, ("Copy with prompts",
+                              "<<copy-with-prompts>>",
+                              "rmenu_check_copy"))
+    del _idx
 
     allow_line_numbers = False
     user_input_insert_tags = "stdin"
@@ -906,6 +914,7 @@ def __init__(self, flist=None):
         text.bind("<<open-stack-viewer>>", self.open_stack_viewer)
         text.bind("<<toggle-debugger>>", self.toggle_debugger)
         text.bind("<<toggle-jit-stack-viewer>>", self.toggle_jit_stack_viewer)
+        text.bind("<<copy-with-prompts>>", self.copy_with_prompts_callback)
         if use_subprocess:
             text.bind("<<view-restart>>", self.view_restart_mark)
             text.bind("<<restart-shell>>", self.restart_shell)
@@ -979,6 +988,42 @@ def replace_event(self, event):
     def get_standard_extension_names(self):
         return idleConf.GetExtensions(shell_only=True)
 
+    def copy_with_prompts_callback(self, event=None):
+        """Copy selected lines to the clipboard, with prompts.
+
+        This makes the copied text useful for doc-tests and interactive
+        shell code examples.
+
+        This always copies entire lines, even if only part of the first
+        and/or last lines is selected.
+        """
+        text = self.text
+
+        selection_indexes = (
+            self.text.index("sel.first linestart"),
+            self.text.index("sel.last +1line linestart"),
+        )
+        if selection_indexes[0] is None:
+            # There is no selection, so do nothing.
+            return
+
+        selected_text = self.text.get(*selection_indexes)
+        selection_lineno_range = range(
+            int(float(selection_indexes[0])),
+            int(float(selection_indexes[1]))
+        )
+        prompts = [
+            self.shell_sidebar.line_prompts.get(lineno)
+            for lineno in selection_lineno_range
+        ]
+        selected_text_with_prompts = "\n".join(
+            line if prompt is None else f"{prompt} {line}"
+            for prompt, line in zip(prompts, selected_text.splitlines())
+        ) + "\n"
+
+        text.clipboard_clear()
+        text.clipboard_append(selected_text_with_prompts)
+
     reading = False
     executing = False
     canceled = False
diff --git a/Lib/idlelib/sidebar.py b/Lib/idlelib/sidebar.py
index a947961b858d6..018c368f421c6 100644
--- a/Lib/idlelib/sidebar.py
+++ b/Lib/idlelib/sidebar.py
@@ -9,11 +9,13 @@
 from tkinter.font import Font
 from idlelib.config import idleConf
 from idlelib.delegator import Delegator
+from idlelib import macosx
 
 
 def get_lineno(text, index):
     """Return the line number of an index in a Tk text widget."""
-    return int(float(text.index(index)))
+    text_index = text.index(index)
+    return int(float(text_index)) if text_index else None
 
 
 def get_end_linenumber(text):
@@ -70,56 +72,52 @@ def __init__(self, editwin):
         self.parent = editwin.text_frame
         self.text = editwin.text
 
-        _padx, pady = get_widget_padding(self.text)
-        self.sidebar_text = tk.Text(self.parent, width=1, wrap=tk.NONE,
-                                    padx=2, pady=pady,
-                                    borderwidth=0, highlightthickness=0)
-        self.sidebar_text.config(state=tk.DISABLED)
-        self.text['yscrollcommand'] = self.redirect_yscroll_event
+        self.is_shown = False
+
+        self.main_widget = self.init_widgets()
+
+        self.bind_events()
+
         self.update_font()
         self.update_colors()
 
-        self.is_shown = False
+    def init_widgets(self):
+        """Initialize the sidebar's widgets, returning the main widget."""
+        raise NotImplementedError
 
     def update_font(self):
         """Update the sidebar text font, usually after config changes."""
-        font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
-        self._update_font(font)
-
-    def _update_font(self, font):
-        self.sidebar_text['font'] = font
+        raise NotImplementedError
 
     def update_colors(self):
         """Update the sidebar text colors, usually after config changes."""
-        colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'normal')
-        self._update_colors(foreground=colors['foreground'],
-                            background=colors['background'])
+        raise NotImplementedError
 
-    def _update_colors(self, foreground, background):
-        self.sidebar_text.config(
-            fg=foreground, bg=background,
-            selectforeground=foreground, selectbackground=background,
-            inactiveselectbackground=background,
-        )
+    def grid(self):
+        """Layout the widget, always using grid layout."""
+        raise NotImplementedError
 
     def show_sidebar(self):
         if not self.is_shown:
-            self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW)
+            self.grid()
             self.is_shown = True
 
     def hide_sidebar(self):
         if self.is_shown:
-            self.sidebar_text.grid_forget()
+            self.main_widget.grid_forget()
             self.is_shown = False
 
+    def yscroll_event(self, *args, **kwargs):
+        """Hook for vertical scrolling for sub-classes to override."""
+        raise NotImplementedError
+
     def redirect_yscroll_event(self, *args, **kwargs):
         """Redirect vertical scrolling to the main editor text widget.
 
         The scroll bar is also updated.
         """
         self.editwin.vbar.set(*args)
-        self.sidebar_text.yview_moveto(args[0])
-        return 'break'
+        return self.yscroll_event(*args, **kwargs)
 
     def redirect_focusin_event(self, event):
         """Redirect focus-in events to the main editor text widget."""
@@ -138,57 +136,17 @@ def redirect_mousewheel_event(self, event):
                                  x=0, y=event.y, delta=event.delta)
         return 'break'
 
-
-class EndLineDelegator(Delegator):
-    """Generate callbacks with the current end line number.
-
-    The provided callback is called after every insert and delete.
-    """
-    def __init__(self, changed_callback):
-        Delegator.__init__(self)
-        self.changed_callback = changed_callback
-
-    def insert(self, index, chars, tags=None):
-        self.delegate.insert(index, chars, tags)
-        self.changed_callback(get_end_linenumber(self.delegate))
-
-    def delete(self, index1, index2=None):
-        self.delegate.delete(index1, index2)
-        self.changed_callback(get_end_linenumber(self.delegate))
-
-
-class LineNumbers(BaseSideBar):
-    """Line numbers support for editor windows."""
-    def __init__(self, editwin):
-        BaseSideBar.__init__(self, editwin)
-        self.prev_end = 1
-        self._sidebar_width_type = type(self.sidebar_text['width'])
-        self.sidebar_text.config(state=tk.NORMAL)
-        self.sidebar_text.insert('insert', '1', 'linenumber')
-        self.sidebar_text.config(state=tk.DISABLED)
-        self.sidebar_text.config(takefocus=False, exportselection=False)
-        self.sidebar_text.tag_config('linenumber', justify=tk.RIGHT)
-
-        self.bind_events()
-
-        end = get_end_linenumber(self.text)
-        self.update_sidebar_text(end)
-
-        end_line_delegator = EndLineDelegator(self.update_sidebar_text)
-        # Insert the delegator after the undo delegator, so that line numbers
-        # are properly updated after undo and redo actions.
-        self.editwin.per.insertfilterafter(filter=end_line_delegator,
-                                           after=self.editwin.undo)
-
     def bind_events(self):
+        self.text['yscrollcommand'] = self.redirect_yscroll_event
+
         # Ensure focus is always redirected to the main editor text widget.
-        self.sidebar_text.bind('<FocusIn>', self.redirect_focusin_event)
+        self.main_widget.bind('<FocusIn>', self.redirect_focusin_event)
 
         # Redirect mouse scrolling to the main editor text widget.
         #
         # Note that without this, scrolling with the mouse only scrolls
         # the line numbers.
-        self.sidebar_text.bind('<MouseWheel>', self.redirect_mousewheel_event)
+        self.main_widget.bind('<MouseWheel>', self.redirect_mousewheel_event)
 
         # Redirect mouse button events to the main editor text widget,
         # except for the left mouse button (1).
@@ -197,7 +155,7 @@ def bind_events(self):
         def bind_mouse_event(event_name, target_event_name):
             handler = functools.partial(self.redirect_mousebutton_event,
                                         event_name=target_event_name)
-            self.sidebar_text.bind(event_name, handler)
+            self.main_widget.bind(event_name, handler)
 
         for button in [2, 3, 4, 5]:
             for event_name in (f'<Button-{button}>',
@@ -214,83 +172,162 @@ def bind_mouse_event(event_name, target_event_name):
                 bind_mouse_event(event_name,
                                  target_event_name=f'<Button-{button}>')
 
-        # This is set by b1_mousedown_handler() and read by
-        # drag_update_selection_and_insert_mark(), to know where dragging
-        # began.
+        # start_line is set upon <Button-1> to allow selecting a range of rows
+        # by dragging.  It is cleared upon <ButtonRelease-1>.
         start_line = None
-        # These are set by b1_motion_handler() and read by selection_handler().
-        # last_y is passed this way since the mouse Y-coordinate is not
-        # available on selection event objects.  last_yview is passed this way
-        # to recognize scrolling while the mouse isn't moving.
-        last_y = last_yview = None
 
-        def b1_mousedown_handler(event):
-            # select the entire line
-            lineno = int(float(self.sidebar_text.index(f"@0,{event.y}")))
+        # last_y is initially set upon <B1-Leave> and is continuously updated
+        # upon <B1-Motion>, until <B1-Enter> or the mouse button is released.
+        # It is used in text_auto_scroll(), which is called repeatedly and
+        # does have a mouse event available.
+        last_y = None
+
+        # auto_scrolling_after_id is set whenever text_auto_scroll is
+        # scheduled via .after().  It is used to stop the auto-scrolling
+        # upon <B1-Enter>, as well as to avoid scheduling the function several
+        # times in parallel.
+        auto_scrolling_after_id = None
+
+        def drag_update_selection_and_insert_mark(y_coord):
+            """Helper function for drag and selection event handlers."""
+            lineno = get_lineno(self.text, f"@0,{y_coord}")
+            a, b = sorted([start_line, lineno])
             self.text.tag_remove("sel", "1.0", "end")
-            self.text.tag_add("sel", f"{lineno}.0", f"{lineno+1}.0")
-            self.text.mark_set("insert", f"{lineno+1}.0")
+            self.text.tag_add("sel", f"{a}.0", f"{b+1}.0")
+            self.text.mark_set("insert",
+                               f"{lineno if lineno == a else lineno + 1}.0")
 
-            # remember this line in case this is the beginning of dragging
+        def b1_mousedown_handler(event):
             nonlocal start_line
-            start_line = lineno
-        self.sidebar_text.bind('<Button-1>', b1_mousedown_handler)
+            nonlocal last_y
+            start_line = int(float(self.text.index(f"@0,{event.y}")))
+            last_y = event.y
+
+            drag_update_selection_and_insert_mark(event.y)
+        self.main_widget.bind('<Button-1>', b1_mousedown_handler)
 
         def b1_mouseup_handler(event):
             # On mouse up, we're no longer dragging.  Set the shared persistent
             # variables to None to represent this.
             nonlocal start_line
             nonlocal last_y
-            nonlocal last_yview
             start_line = None
             last_y = None
-            last_yview = None
-        self.sidebar_text.bind('<ButtonRelease-1>', b1_mouseup_handler)
+            self.text.event_generate('<ButtonRelease-1>', x=0, y=event.y)
+        self.main_widget.bind('<ButtonRelease-1>', b1_mouseup_handler)
 
-        def drag_update_selection_and_insert_mark(y_coord):
-            """Helper function for drag and selection event handlers."""
-            lineno = int(float(self.sidebar_text.index(f"@0,{y_coord}")))
-            a, b = sorted([start_line, lineno])
-            self.text.tag_remove("sel", "1.0", "end")
-            self.text.tag_add("sel", f"{a}.0", f"{b+1}.0")
-            self.text.mark_set("insert",
-                               f"{lineno if lineno == a else lineno + 1}.0")
-
-        # Special handling of dragging with mouse button 1.  In "normal" text
-        # widgets this selects text, but the line numbers text widget has
-        # selection disabled.  Still, dragging triggers some selection-related
-        # functionality under the hood.  Specifically, dragging to above or
-        # below the text widget triggers scrolling, in a way that bypasses the
-        # other scrolling synchronization mechanisms.i
-        def b1_drag_handler(event, *args):
+        def b1_drag_handler(event):
             nonlocal last_y
-            nonlocal last_yview
+            if last_y is None:  # i.e. if not currently dragging
+                return
             last_y = event.y
-            last_yview = self.sidebar_text.yview()
-            if not 0 <= last_y <= self.sidebar_text.winfo_height():
-                self.text.yview_moveto(last_yview[0])
             drag_update_selection_and_insert_mark(event.y)
-        self.sidebar_text.bind('<B1-Motion>', b1_drag_handler)
-
-        # With mouse-drag scrolling fixed by the above, there is still an edge-
-        # case we need to handle: When drag-scrolling, scrolling can continue
-        # while the mouse isn't moving, leading to the above fix not scrolling
-        # properly.
-        def selection_handler(event):
-            if last_yview is None:
-                # This logic is only needed while dragging.
+        self.main_widget.bind('<B1-Motion>', b1_drag_handler)
+
+        def text_auto_scroll():
+            """Mimic Text auto-scrolling when dragging outside of it."""
+            # See: https://github.com/tcltk/tk/blob/064ff9941b4b80b85916a8afe86a6c21fd388b54/library/text.tcl#L670
+            nonlocal auto_scrolling_after_id
+            y = last_y
+            if y is None:
+                self.main_widget.after_cancel(auto_scrolling_after_id)
+                auto_scrolling_after_id = None
                 return
-            yview = self.sidebar_text.yview()
-            if yview != last_yview:
-                self.text.yview_moveto(yview[0])
-                drag_update_selection_and_insert_mark(last_y)
-        self.sidebar_text.bind('<<Selection>>', selection_handler)
+            elif y < 0:
+                self.text.yview_scroll(-1 + y, 'pixels')
+                drag_update_selection_and_insert_mark(y)
+            elif y > self.main_widget.winfo_height():
+                self.text.yview_scroll(1 + y - self.main_widget.winfo_height(),
+                                       'pixels')
+                drag_update_selection_and_insert_mark(y)
+            auto_scrolling_after_id = \
+                self.main_widget.after(50, text_auto_scroll)
+
+        def b1_leave_handler(event):
+            # Schedule the initial call to text_auto_scroll(), if not already
+            # scheduled.
+            nonlocal auto_scrolling_after_id
+            if auto_scrolling_after_id is None:
+                nonlocal last_y
+                last_y = event.y
+                auto_scrolling_after_id = \
+                    self.main_widget.after(0, text_auto_scroll)
+        self.main_widget.bind('<B1-Leave>', b1_leave_handler)
+
+        def b1_enter_handler(event):
+            # Cancel the scheduling of text_auto_scroll(), if it exists.
+            nonlocal auto_scrolling_after_id
+            if auto_scrolling_after_id is not None:
+                self.main_widget.after_cancel(auto_scrolling_after_id)
+                auto_scrolling_after_id = None
+        self.main_widget.bind('<B1-Enter>', b1_enter_handler)
+
+
+class EndLineDelegator(Delegator):
+    """Generate callbacks with the current end line number.
+
+    The provided callback is called after every insert and delete.
+    """
+    def __init__(self, changed_callback):
+        Delegator.__init__(self)
+        self.changed_callback = changed_callback
+
+    def insert(self, index, chars, tags=None):
+        self.delegate.insert(index, chars, tags)
+        self.changed_callback(get_end_linenumber(self.delegate))
+
+    def delete(self, index1, index2=None):
+        self.delegate.delete(index1, index2)
+        self.changed_callback(get_end_linenumber(self.delegate))
+
+
+class LineNumbers(BaseSideBar):
+    """Line numbers support for editor windows."""
+    def __init__(self, editwin):
+        super().__init__(editwin)
+
+        end_line_delegator = EndLineDelegator(self.update_sidebar_text)
+        # Insert the delegator after the undo delegator, so that line numbers
+        # are properly updated after undo and redo actions.
+        self.editwin.per.insertfilterafter(end_line_delegator,
+                                           after=self.editwin.undo)
+
+    def init_widgets(self):
+        _padx, pady = get_widget_padding(self.text)
+        self.sidebar_text = tk.Text(self.parent, width=1, wrap=tk.NONE,
+                                    padx=2, pady=pady,
+                                    borderwidth=0, highlightthickness=0)
+        self.sidebar_text.config(state=tk.DISABLED)
+
+        self.prev_end = 1
+        self._sidebar_width_type = type(self.sidebar_text['width'])
+        with temp_enable_text_widget(self.sidebar_text):
+            self.sidebar_text.insert('insert', '1', 'linenumber')
+        self.sidebar_text.config(takefocus=False, exportselection=False)
+        self.sidebar_text.tag_config('linenumber', justify=tk.RIGHT)
+
+        end = get_end_linenumber(self.text)
+        self.update_sidebar_text(end)
+
+        return self.sidebar_text
+
+    def grid(self):
+        self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW)
+
+    def update_font(self):
+        font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
+        self.sidebar_text['font'] = font
 
     def update_colors(self):
         """Update the sidebar text colors, usually after config changes."""
         colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber')
-        self._update_colors(foreground=colors['foreground'],
-                            background=colors['background'])
+        foreground = colors['foreground']
+        background = colors['background']
+        self.sidebar_text.config(
+            fg=foreground, bg=background,
+            selectforeground=foreground, selectbackground=background,
+            inactiveselectbackground=background,
+        )
 
     def update_sidebar_text(self, end):
         """
@@ -319,6 +356,10 @@ def update_sidebar_text(self, end):
 
         self.prev_end = end
 
+    def yscroll_event(self, *args, **kwargs):
+        self.sidebar_text.yview_moveto(args[0])
+        return 'break'
+
 
 class WrappedLineHeightChangeDelegator(Delegator):
     def __init__(self, callback):
@@ -361,22 +402,16 @@ def delete(self, index1, index2=None):
         self.callback()
 
 
-class ShellSidebar:
+class ShellSidebar(BaseSideBar):
     """Sidebar for the PyShell window, for prompts etc."""
     def __init__(self, editwin):
-        self.editwin = editwin
-        self.parent = editwin.text_frame
-        self.text = editwin.text
+        self.canvas = None
+        self.line_prompts = {}
 
-        self.canvas = tk.Canvas(self.parent, width=30,
-                                borderwidth=0, highlightthickness=0,
-                                takefocus=False)
-
-        self.bind_events()
+        super().__init__(editwin)
 
         change_delegator = \
             WrappedLineHeightChangeDelegator(self.change_callback)
-
         # Insert the TextChangeDelegator after the last delegator, so that
         # the sidebar reflects final changes to the text widget contents.
         d = self.editwin.per.top
@@ -385,15 +420,41 @@ def __init__(self, editwin):
                 d = d.delegate
         self.editwin.per.insertfilterafter(change_delegator, after=d)
 
-        self.text['yscrollcommand'] = self.yscroll_event
-
-        self.is_shown = False
+        self.is_shown = True
 
-        self.update_font()
-        self.update_colors()
+    def init_widgets(self):
+        self.canvas = tk.Canvas(self.parent, width=30,
+                                borderwidth=0, highlightthickness=0,
+                                takefocus=False)
         self.update_sidebar()
+        self.grid()
+        return self.canvas
+
+    def bind_events(self):
+        super().bind_events()
+
+        self.main_widget.bind(
+            # AquaTk defines <2> as the right button, not <3>.
+            "<Button-2>" if macosx.isAquaTk() else "<Button-3>",
+            self.context_menu_event,
+        )
+
+    def context_menu_event(self, event):
+        rmenu = tk.Menu(self.main_widget, tearoff=0)
+        has_selection = bool(self.text.tag_nextrange('sel', '1.0'))
+        def mkcmd(eventname):
+            return lambda: self.text.event_generate(eventname)
+        rmenu.add_command(label='Copy',
+                          command=mkcmd('<<copy>>'),
+                          state='normal' if has_selection else 'disabled')
+        rmenu.add_command(label='Copy with prompts',
+                          command=mkcmd('<<copy-with-prompts>>'),
+                          state='normal' if has_selection else 'disabled')
+        rmenu.tk_popup(event.x_root, event.y_root)
+        return "break"
+
+    def grid(self):
         self.canvas.grid(row=1, column=0, sticky=tk.NSEW, padx=2, pady=0)
-        self.is_shown = True
 
     def change_callback(self):
         if self.is_shown:
@@ -403,6 +464,7 @@ def update_sidebar(self):
         text = self.text
         text_tagnames = text.tag_names
         canvas = self.canvas
+        line_prompts = self.line_prompts = {}
 
         canvas.delete(tk.ALL)
 
@@ -423,6 +485,8 @@ def update_sidebar(self):
             if prompt:
                 canvas.create_text(2, y, anchor=tk.NW, text=prompt,
                                    font=self.font, fill=self.colors[0])
+                lineno = get_lineno(text, index)
+                line_prompts[lineno] = prompt
             index = text.index(f'{index}+1line')
 
     def yscroll_event(self, *args, **kwargs):
@@ -430,7 +494,6 @@ def yscroll_event(self, *args, **kwargs):
 
         The scroll bar is also updated.
         """
-        self.editwin.vbar.set(*args)
         self.change_callback()
         return 'break'
 
@@ -440,9 +503,6 @@ def update_font(self):
         tk_font = Font(self.text, font=font)
         char_width = max(tk_font.measure(char) for char in ['>', '.'])
         self.canvas.configure(width=char_width * 3 + 4)
-        self._update_font(font)
-
-    def _update_font(self, font):
         self.font = font
         self.change_callback()
 
@@ -450,65 +510,12 @@ def update_colors(self):
         """Update the sidebar text colors, usually after config changes."""
         linenumbers_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber')
         prompt_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'console')
-        self._update_colors(foreground=prompt_colors['foreground'],
-                            background=linenumbers_colors['background'])
-
-    def _update_colors(self, foreground, background):
+        foreground = prompt_colors['foreground']
+        background = linenumbers_colors['background']
         self.colors = (foreground, background)
-        self.canvas.configure(background=self.colors[1])
+        self.canvas.configure(background=background)
         self.change_callback()
 
-    def redirect_focusin_event(self, event):
-        """Redirect focus-in events to the main editor text widget."""
-        self.text.focus_set()
-        return 'break'
-
-    def redirect_mousebutton_event(self, event, event_name):
-        """Redirect mouse button events to the main editor text widget."""
-        self.text.focus_set()
-        self.text.event_generate(event_name, x=0, y=event.y)
-        return 'break'
-
-    def redirect_mousewheel_event(self, event):
-        """Redirect mouse wheel events to the editwin text widget."""
-        self.text.event_generate('<MouseWheel>',
-                                 x=0, y=event.y, delta=event.delta)
-        return 'break'
-
-    def bind_events(self):
-        # Ensure focus is always redirected to the main editor text widget.
-        self.canvas.bind('<FocusIn>', self.redirect_focusin_event)
-
-        # Redirect mouse scrolling to the main editor text widget.
-        #
-        # Note that without this, scrolling with the mouse only scrolls
-        # the line numbers.
-        self.canvas.bind('<MouseWheel>', self.redirect_mousewheel_event)
-
-        # Redirect mouse button events to the main editor text widget,
-        # except for the left mouse button (1).
-        #
-        # Note: X-11 sends Button-4 and Button-5 events for the scroll wheel.
-        def bind_mouse_event(event_name, target_event_name):
-            handler = functools.partial(self.redirect_mousebutton_event,
-                                        event_name=target_event_name)
-            self.canvas.bind(event_name, handler)
-
-        for button in [2, 3, 4, 5]:
-            for event_name in (f'<Button-{button}>',
-                               f'<ButtonRelease-{button}>',
-                               f'<B{button}-Motion>',
-                               ):
-                bind_mouse_event(event_name, target_event_name=event_name)
-
-            # Convert double- and triple-click events to normal click events,
-            # since event_generate() doesn't allow generating such events.
-            for event_name in (f'<Double-Button-{button}>',
-                               f'<Triple-Button-{button}>',
-                               ):
-                bind_mouse_event(event_name,
-                                 target_event_name=f'<Button-{button}>')
-
 
 def _linenumbers_drag_scrolling(parent):  # htest #
     from idlelib.idle_test.test_sidebar import Dummy_editwin
diff --git a/Misc/NEWS.d/next/IDLE/2021-05-02-20-25-53.bpo-37903.VQ6VTU.rst b/Misc/NEWS.d/next/IDLE/2021-05-02-20-25-53.bpo-37903.VQ6VTU.rst
new file mode 100644
index 0000000000000..28b11e60f0fb3
--- /dev/null
+++ b/Misc/NEWS.d/next/IDLE/2021-05-02-20-25-53.bpo-37903.VQ6VTU.rst
@@ -0,0 +1,4 @@
+Add mouse actions to the shell sidebar.  Left click and optional drag
+selects one or more lines, as with the editor line number sidebar.  Right
+click after selecting raises a context menu with 'copy with prompts'.  This
+zips together prompts from the sidebar with lines from the selected text.



More information about the Python-checkins mailing list