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>
This commit is contained in:
Tal Einat 2021-05-03 05:27:38 +03:00 committed by GitHub
parent 90d523910a
commit b43cc31a27
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 384 additions and 230 deletions

View File

@ -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
------------------

View File

@ -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,

View File

@ -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:

View File

@ -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):

View File

@ -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)

View File

@ -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):

View File

@ -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)

View File

@ -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

View File

@ -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('<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.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).
#
# 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'<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}>')
# 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
# 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"{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('<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
start_line = None
last_y = None
self.text.event_generate('<ButtonRelease-1>', x=0, y=event.y)
self.main_widget.bind('<ButtonRelease-1>', 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-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
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.
@ -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('<FocusIn>', 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('<MouseWheel>', 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'<Button-{button}>',
f'<ButtonRelease-{button}>',
f'<B{button}-Motion>',
):
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'<Double-Button-{button}>',
f'<Triple-Button-{button}>',
):
bind_mouse_event(event_name,
target_event_name=f'<Button-{button}>')
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('<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)
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-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.
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)
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>.
"<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)
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('<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

View File

@ -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.