From b43cc31a270d0dacbc69e35d6c6fbdb5edd7e711 Mon Sep 17 00:00:00 2001 From: Tal Einat <532281+taleinat@users.noreply.github.com> Date: Mon, 3 May 2021 05:27:38 +0300 Subject: [PATCH] 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 --- Doc/whatsnew/3.10.rst | 26 + Lib/idlelib/NEWS.txt | 15 +- Lib/idlelib/autocomplete.py | 5 +- Lib/idlelib/autocomplete_w.py | 7 +- Lib/idlelib/editor.py | 2 +- Lib/idlelib/idle_test/test_autocomplete_w.py | 2 +- Lib/idlelib/idle_test/test_sidebar.py | 61 ++- Lib/idlelib/pyshell.py | 45 ++ Lib/idlelib/sidebar.py | 447 +++++++++--------- .../2021-05-02-20-25-53.bpo-37903.VQ6VTU.rst | 4 + 10 files changed, 384 insertions(+), 230 deletions(-) create mode 100644 Misc/NEWS.d/next/IDLE/2021-05-02-20-25-53.bpo-37903.VQ6VTU.rst diff --git a/Doc/whatsnew/3.10.rst b/Doc/whatsnew/3.10.rst index 223ab65cfc3..eb452b07f55 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 83afe3ecac9..ed1142653d9 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 e1e9e17311e..bb7ee035c4f 100644 --- a/Lib/idlelib/autocomplete.py +++ b/Lib/idlelib/autocomplete.py @@ -31,10 +31,11 @@ TRIGGERS = f".{SEPS}" 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 @@ class AutoComplete: "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 fe7a6be83d5..d3d1e6982bf 100644 --- a/Lib/idlelib/autocomplete_w.py +++ b/Lib/idlelib/autocomplete_w.py @@ -26,9 +26,11 @@ DOUBLECLICK_SEQUENCE = "" 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 @@ class AutoCompleteWindow: "%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 8b544407da2..fcc8a3f08cc 100644 --- a/Lib/idlelib/editor.py +++ b/Lib/idlelib/editor.py @@ -311,7 +311,7 @@ class EditorWindow: # 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_event) text.bind("<>", 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 b1bdc6c7c6e..a59a375c90f 100644 --- a/Lib/idlelib/idle_test/test_autocomplete_w.py +++ b/Lib/idlelib/idle_test/test_autocomplete_w.py @@ -15,7 +15,7 @@ class AutoCompleteWindowTest(unittest.TestCase): 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 0497f6d0513..43e8137d707 100644 --- a/Lib/idlelib/idle_test/test_sidebar.py +++ b/Lib/idlelib/idle_test/test_sidebar.py @@ -270,7 +270,6 @@ class LineNumbersTest(unittest.TestCase): 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 @@ class ShellSidebarTest(unittest.TestCase): 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('<>') + 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('<>') + 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 447e9ec3e47..4e7440038ac 100755 --- a/Lib/idlelib/pyshell.py +++ b/Lib/idlelib/pyshell.py @@ -33,6 +33,7 @@ if TkVersion < 8.5: 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", "<>"), ] + _idx = 1 + len(list(itertools.takewhile( + lambda rmenu_item: rmenu_item[0] != "Copy", rmenu_specs) + )) + rmenu_specs.insert(_idx, ("Copy with prompts", + "<>", + "rmenu_check_copy")) + del _idx allow_line_numbers = False user_input_insert_tags = "stdin" @@ -906,6 +914,7 @@ class PyShell(OutputWindow): text.bind("<>", self.open_stack_viewer) text.bind("<>", self.toggle_debugger) text.bind("<>", self.toggle_jit_stack_viewer) + text.bind("<>", self.copy_with_prompts_callback) if use_subprocess: text.bind("<>", self.view_restart_mark) text.bind("<>", self.restart_shell) @@ -979,6 +988,42 @@ class PyShell(OutputWindow): 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 a947961b858..018c368f421 100644 --- a/Lib/idlelib/sidebar.py +++ b/Lib/idlelib/sidebar.py @@ -9,11 +9,13 @@ import tkinter as tk 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 @@ class BaseSideBar: 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,6 +136,132 @@ class BaseSideBar: x=0, y=event.y, delta=event.delta) return 'break' + def bind_events(self): + self.text['yscrollcommand'] = self.redirect_yscroll_event + + # Ensure focus is always redirected to the main editor text widget. + self.main_widget.bind('', 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.main_widget.bind('', 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.main_widget.bind(event_name, handler) + + for button in [2, 3, 4, 5]: + for event_name in (f'', + f'', + f'', + ): + 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'', + f'', + ): + bind_mouse_event(event_name, + target_event_name=f'') + + # start_line is set upon to allow selecting a range of rows + # by dragging. It is cleared upon . + start_line = None + + # last_y is initially set upon and is continuously updated + # upon , until 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 , 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"{a}.0", f"{b+1}.0") + self.text.mark_set("insert", + f"{lineno if lineno == a else lineno + 1}.0") + + def b1_mousedown_handler(event): + nonlocal start_line + 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('', 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 + start_line = None + last_y = None + self.text.event_generate('', x=0, y=event.y) + self.main_widget.bind('', b1_mouseup_handler) + + def b1_drag_handler(event): + nonlocal last_y + if last_y is None: # i.e. if not currently dragging + return + last_y = event.y + drag_update_selection_and_insert_mark(event.y) + self.main_widget.bind('', 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 + 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_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_handler) + class EndLineDelegator(Delegator): """Generate callbacks with the current end line number. @@ -160,137 +284,50 @@ class EndLineDelegator(Delegator): 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) + 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(filter=end_line_delegator, + self.editwin.per.insertfilterafter(end_line_delegator, after=self.editwin.undo) - def bind_events(self): - # Ensure focus is always redirected to the main editor text widget. - self.sidebar_text.bind('', self.redirect_focusin_event) + 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) - # 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('', self.redirect_mousewheel_event) + 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) - # 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.sidebar_text.bind(event_name, handler) + end = get_end_linenumber(self.text) + self.update_sidebar_text(end) - for button in [2, 3, 4, 5]: - for event_name in (f'', - f'', - f'', - ): - bind_mouse_event(event_name, target_event_name=event_name) + return self.sidebar_text - # Convert double- and triple-click events to normal click events, - # since event_generate() doesn't allow generating such events. - for event_name in (f'', - f'', - ): - bind_mouse_event(event_name, - target_event_name=f'') + def grid(self): + self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW) - # This is set by b1_mousedown_handler() and read by - # drag_update_selection_and_insert_mark(), to know where dragging - # began. - 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}"))) - 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") - - # remember this line in case this is the beginning of dragging - nonlocal start_line - start_line = lineno - self.sidebar_text.bind('', 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('', 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): - nonlocal last_y - nonlocal last_yview - 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_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. - 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_handler) + 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 @@ class LineNumbers(BaseSideBar): 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 @@ class WrappedLineHeightChangeDelegator(Delegator): 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,16 +420,42 @@ class ShellSidebar: d = d.delegate self.editwin.per.insertfilterafter(change_delegator, after=d) - self.text['yscrollcommand'] = self.yscroll_event - - self.is_shown = False - - self.update_font() - self.update_colors() - self.update_sidebar() - self.canvas.grid(row=1, column=0, sticky=tk.NSEW, padx=2, pady=0) self.is_shown = True + 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>. + "" if macosx.isAquaTk() else "", + 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('<>'), + state='normal' if has_selection else 'disabled') + rmenu.add_command(label='Copy with prompts', + command=mkcmd('<>'), + 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) + def change_callback(self): if self.is_shown: self.update_sidebar() @@ -403,6 +464,7 @@ class ShellSidebar: text = self.text text_tagnames = text.tag_names canvas = self.canvas + line_prompts = self.line_prompts = {} canvas.delete(tk.ALL) @@ -423,6 +485,8 @@ class ShellSidebar: 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 @@ class ShellSidebar: The scroll bar is also updated. """ - self.editwin.vbar.set(*args) self.change_callback() return 'break' @@ -440,9 +503,6 @@ class ShellSidebar: 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 @@ class ShellSidebar: """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('', - 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('', 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('', 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'', - f'', - f'', - ): - 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'', - f'', - ): - bind_mouse_event(event_name, - target_event_name=f'') - 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 00000000000..28b11e60f0f --- /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.