diff options
author | Tal Einat <532281+taleinat@users.noreply.github.com> | 2021-05-03 02:27:38 (GMT) |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-05-03 02:27:38 (GMT) |
commit | b43cc31a270d0dacbc69e35d6c6fbdb5edd7e711 (patch) | |
tree | f656c691cf80d2b927037808ea51d4f75ae20a16 /Lib/idlelib | |
parent | 90d523910a61290597b4599f17363b532f0a4411 (diff) | |
download | cpython-b43cc31a270d0dacbc69e35d6c6fbdb5edd7e711.zip cpython-b43cc31a270d0dacbc69e35d6c6fbdb5edd7e711.tar.gz cpython-b43cc31a270d0dacbc69e35d6c6fbdb5edd7e711.tar.bz2 |
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@udel.edu>
Diffstat (limited to 'Lib/idlelib')
-rw-r--r-- | Lib/idlelib/NEWS.txt | 15 | ||||
-rw-r--r-- | Lib/idlelib/autocomplete.py | 5 | ||||
-rw-r--r-- | Lib/idlelib/autocomplete_w.py | 7 | ||||
-rw-r--r-- | Lib/idlelib/editor.py | 2 | ||||
-rw-r--r-- | Lib/idlelib/idle_test/test_autocomplete_w.py | 2 | ||||
-rw-r--r-- | Lib/idlelib/idle_test/test_sidebar.py | 61 | ||||
-rwxr-xr-x | Lib/idlelib/pyshell.py | 45 | ||||
-rw-r--r-- | Lib/idlelib/sidebar.py | 411 |
8 files changed, 336 insertions, 212 deletions
diff --git a/Lib/idlelib/NEWS.txt b/Lib/idlelib/NEWS.txt index 83afe3e..ed11426 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 e1e9e17..bb7ee03 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 fe7a6be..d3d1e69 100644 --- a/Lib/idlelib/autocomplete_w.py +++ b/Lib/idlelib/autocomplete_w.py @@ -26,9 +26,11 @@ DOUBLECLICK_SEQUENCE = "<B1-Double-ButtonRelease>" 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 8b54440..fcc8a3f 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.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 b1bdc6c..a59a375 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 0497f6d..43e8137 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('<<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 447e9ec..4e74400 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", "<<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 @@ class PyShell(OutputWindow): 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 @@ 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 a947961..018c368 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,57 +136,17 @@ class BaseSideBar: 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 @@ class LineNumbers(BaseSideBar): 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 @@ class LineNumbers(BaseSideBar): 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 @@ 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,15 +420,41 @@ class ShellSidebar: 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 @@ 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('<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 |