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. The hmac module now uses OpenSSL's HMAC implementation internally.
(Contributed by Christian Heimes in :issue:`40645`.) (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 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. 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. Debugger is active, to prevent hang or crash. Patch by Zackery Spytz.
bpo-43008: Make IDLE invoke :func:`sys.excepthook` in normal, 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. 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-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. by using inspect.getdoc.
bpo-33987: Mostly finish using ttk widgets, mainly for editor, bpo-33987: Mostly finish using ttk widgets, mainly for editor,

View File

@ -31,10 +31,11 @@ TRIGGERS = f".{SEPS}"
class AutoComplete: class AutoComplete:
def __init__(self, editwin=None): def __init__(self, editwin=None, tags=None):
self.editwin = editwin self.editwin = editwin
if editwin is not None: # not in subprocess or no-gui test if editwin is not None: # not in subprocess or no-gui test
self.text = editwin.text self.text = editwin.text
self.tags = tags
self.autocompletewindow = None self.autocompletewindow = None
# id of delayed call, and the index of the text insert when # id of delayed call, and the index of the text insert when
# the delayed call was issued. If _delayed_completion_id is # the delayed call was issued. If _delayed_completion_id is
@ -48,7 +49,7 @@ class AutoComplete:
"extensions", "AutoComplete", "popupwait", type="int", default=0) "extensions", "AutoComplete", "popupwait", type="int", default=0)
def _make_autocomplete_window(self): # Makes mocking easier. 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): def _remove_autocomplete_window(self, event=None):
if self.autocompletewindow: if self.autocompletewindow:

View File

@ -26,9 +26,11 @@ DOUBLECLICK_SEQUENCE = "<B1-Double-ButtonRelease>"
class AutoCompleteWindow: class AutoCompleteWindow:
def __init__(self, widget): def __init__(self, widget, tags):
# The widget (Text) on which we place the AutoCompleteWindow # The widget (Text) on which we place the AutoCompleteWindow
self.widget = widget self.widget = widget
# Tags to mark inserted text with
self.tags = tags
# The widgets we create # The widgets we create
self.autocompletewindow = self.listbox = self.scrollbar = None self.autocompletewindow = self.listbox = self.scrollbar = None
# The default foreground and background of a selection. Saved because # The default foreground and background of a selection. Saved because
@ -69,7 +71,8 @@ class AutoCompleteWindow:
"%s+%dc" % (self.startindex, len(self.start))) "%s+%dc" % (self.startindex, len(self.start)))
if i < len(newstart): if i < len(newstart):
self.widget.insert("%s+%dc" % (self.startindex, i), self.widget.insert("%s+%dc" % (self.startindex, i),
newstart[i:]) newstart[i:],
self.tags)
self.start = newstart self.start = newstart
def _binary_search(self, s): def _binary_search(self, s):

View File

@ -311,7 +311,7 @@ class EditorWindow:
# Former extension bindings depends on frame.text being packed # Former extension bindings depends on frame.text being packed
# (called from self.ResetColorizer()). # (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("<<autocomplete>>", autocomplete.autocomplete_event)
text.bind("<<try-open-completions>>", text.bind("<<try-open-completions>>",
autocomplete.try_open_completions_event) autocomplete.try_open_completions_event)

View File

@ -15,7 +15,7 @@ class AutoCompleteWindowTest(unittest.TestCase):
cls.root = Tk() cls.root = Tk()
cls.root.withdraw() cls.root.withdraw()
cls.text = Text(cls.root) cls.text = Text(cls.root)
cls.acw = acw.AutoCompleteWindow(cls.text) cls.acw = acw.AutoCompleteWindow(cls.text, tags=None)
@classmethod @classmethod
def tearDownClass(cls): def tearDownClass(cls):

View File

@ -270,7 +270,6 @@ class LineNumbersTest(unittest.TestCase):
self.assertEqual(self.get_selection(), ('2.0', '3.0')) self.assertEqual(self.get_selection(), ('2.0', '3.0'))
@unittest.skip('test disabled')
def simulate_drag(self, start_line, end_line): def simulate_drag(self, start_line, end_line):
start_x, start_y = self.get_line_screen_position(start_line) start_x, start_y = self.get_line_screen_position(start_line)
end_x, end_y = self.get_line_screen_position(end_line) end_x, end_y = self.get_line_screen_position(end_line)
@ -704,6 +703,66 @@ class ShellSidebarTest(unittest.TestCase):
yield yield
self.assertIsNotNone(text.dlineinfo(text.index(f'{last_lineno}.0'))) 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__': if __name__ == '__main__':
unittest.main(verbosity=2) unittest.main(verbosity=2)

View File

@ -33,6 +33,7 @@ if TkVersion < 8.5:
raise SystemExit(1) raise SystemExit(1)
from code import InteractiveInterpreter from code import InteractiveInterpreter
import itertools
import linecache import linecache
import os import os
import os.path import os.path
@ -865,6 +866,13 @@ class PyShell(OutputWindow):
rmenu_specs = OutputWindow.rmenu_specs + [ rmenu_specs = OutputWindow.rmenu_specs + [
("Squeeze", "<<squeeze-current-text>>"), ("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 allow_line_numbers = False
user_input_insert_tags = "stdin" user_input_insert_tags = "stdin"
@ -906,6 +914,7 @@ class PyShell(OutputWindow):
text.bind("<<open-stack-viewer>>", self.open_stack_viewer) text.bind("<<open-stack-viewer>>", self.open_stack_viewer)
text.bind("<<toggle-debugger>>", self.toggle_debugger) text.bind("<<toggle-debugger>>", self.toggle_debugger)
text.bind("<<toggle-jit-stack-viewer>>", self.toggle_jit_stack_viewer) text.bind("<<toggle-jit-stack-viewer>>", self.toggle_jit_stack_viewer)
text.bind("<<copy-with-prompts>>", self.copy_with_prompts_callback)
if use_subprocess: if use_subprocess:
text.bind("<<view-restart>>", self.view_restart_mark) text.bind("<<view-restart>>", self.view_restart_mark)
text.bind("<<restart-shell>>", self.restart_shell) text.bind("<<restart-shell>>", self.restart_shell)
@ -979,6 +988,42 @@ class PyShell(OutputWindow):
def get_standard_extension_names(self): def get_standard_extension_names(self):
return idleConf.GetExtensions(shell_only=True) 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 reading = False
executing = False executing = False
canceled = False canceled = False

View File

@ -9,11 +9,13 @@ import tkinter as tk
from tkinter.font import Font from tkinter.font import Font
from idlelib.config import idleConf from idlelib.config import idleConf
from idlelib.delegator import Delegator from idlelib.delegator import Delegator
from idlelib import macosx
def get_lineno(text, index): def get_lineno(text, index):
"""Return the line number of an index in a Tk text widget.""" """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): def get_end_linenumber(text):
@ -70,56 +72,52 @@ class BaseSideBar:
self.parent = editwin.text_frame self.parent = editwin.text_frame
self.text = editwin.text self.text = editwin.text
_padx, pady = get_widget_padding(self.text) self.is_shown = False
self.sidebar_text = tk.Text(self.parent, width=1, wrap=tk.NONE,
padx=2, pady=pady, self.main_widget = self.init_widgets()
borderwidth=0, highlightthickness=0)
self.sidebar_text.config(state=tk.DISABLED) self.bind_events()
self.text['yscrollcommand'] = self.redirect_yscroll_event
self.update_font() self.update_font()
self.update_colors() 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): def update_font(self):
"""Update the sidebar text font, usually after config changes.""" """Update the sidebar text font, usually after config changes."""
font = idleConf.GetFont(self.text, 'main', 'EditorWindow') raise NotImplementedError
self._update_font(font)
def _update_font(self, font):
self.sidebar_text['font'] = font
def update_colors(self): def update_colors(self):
"""Update the sidebar text colors, usually after config changes.""" """Update the sidebar text colors, usually after config changes."""
colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'normal') raise NotImplementedError
self._update_colors(foreground=colors['foreground'],
background=colors['background'])
def _update_colors(self, foreground, background): def grid(self):
self.sidebar_text.config( """Layout the widget, always using grid layout."""
fg=foreground, bg=background, raise NotImplementedError
selectforeground=foreground, selectbackground=background,
inactiveselectbackground=background,
)
def show_sidebar(self): def show_sidebar(self):
if not self.is_shown: if not self.is_shown:
self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW) self.grid()
self.is_shown = True self.is_shown = True
def hide_sidebar(self): def hide_sidebar(self):
if self.is_shown: if self.is_shown:
self.sidebar_text.grid_forget() self.main_widget.grid_forget()
self.is_shown = False 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): def redirect_yscroll_event(self, *args, **kwargs):
"""Redirect vertical scrolling to the main editor text widget. """Redirect vertical scrolling to the main editor text widget.
The scroll bar is also updated. The scroll bar is also updated.
""" """
self.editwin.vbar.set(*args) self.editwin.vbar.set(*args)
self.sidebar_text.yview_moveto(args[0]) return self.yscroll_event(*args, **kwargs)
return 'break'
def redirect_focusin_event(self, event): def redirect_focusin_event(self, event):
"""Redirect focus-in events to the main editor text widget.""" """Redirect focus-in events to the main editor text widget."""
@ -138,6 +136,132 @@ class BaseSideBar:
x=0, y=event.y, delta=event.delta) x=0, y=event.y, delta=event.delta)
return 'break' 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): class EndLineDelegator(Delegator):
"""Generate callbacks with the current end line number. """Generate callbacks with the current end line number.
@ -160,137 +284,50 @@ class EndLineDelegator(Delegator):
class LineNumbers(BaseSideBar): class LineNumbers(BaseSideBar):
"""Line numbers support for editor windows.""" """Line numbers support for editor windows."""
def __init__(self, editwin): def __init__(self, editwin):
BaseSideBar.__init__(self, editwin) super().__init__(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) end_line_delegator = EndLineDelegator(self.update_sidebar_text)
# Insert the delegator after the undo delegator, so that line numbers # Insert the delegator after the undo delegator, so that line numbers
# are properly updated after undo and redo actions. # 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) after=self.editwin.undo)
def bind_events(self): def init_widgets(self):
# Ensure focus is always redirected to the main editor text widget. _padx, pady = get_widget_padding(self.text)
self.sidebar_text.bind('<FocusIn>', self.redirect_focusin_event) 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. self.prev_end = 1
# self._sidebar_width_type = type(self.sidebar_text['width'])
# Note that without this, scrolling with the mouse only scrolls with temp_enable_text_widget(self.sidebar_text):
# the line numbers. self.sidebar_text.insert('insert', '1', 'linenumber')
self.sidebar_text.bind('<MouseWheel>', self.redirect_mousewheel_event) 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, end = get_end_linenumber(self.text)
# except for the left mouse button (1). self.update_sidebar_text(end)
#
# 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)
for button in [2, 3, 4, 5]: return self.sidebar_text
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, def grid(self):
# since event_generate() doesn't allow generating such events. self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW)
for event_name in (f'<Double-Button-{button}>',
f'<Triple-Button-{button}>',
):
bind_mouse_event(event_name,
target_event_name=f'<Button-{button}>')
# This is set by b1_mousedown_handler() and read by def update_font(self):
# drag_update_selection_and_insert_mark(), to know where dragging font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
# began. self.sidebar_text['font'] = font
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_colors(self): def update_colors(self):
"""Update the sidebar text colors, usually after config changes.""" """Update the sidebar text colors, usually after config changes."""
colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber') colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber')
self._update_colors(foreground=colors['foreground'], foreground = colors['foreground']
background=colors['background']) background = colors['background']
self.sidebar_text.config(
fg=foreground, bg=background,
selectforeground=foreground, selectbackground=background,
inactiveselectbackground=background,
)
def update_sidebar_text(self, end): def update_sidebar_text(self, end):
""" """
@ -319,6 +356,10 @@ class LineNumbers(BaseSideBar):
self.prev_end = end self.prev_end = end
def yscroll_event(self, *args, **kwargs):
self.sidebar_text.yview_moveto(args[0])
return 'break'
class WrappedLineHeightChangeDelegator(Delegator): class WrappedLineHeightChangeDelegator(Delegator):
def __init__(self, callback): def __init__(self, callback):
@ -361,22 +402,16 @@ class WrappedLineHeightChangeDelegator(Delegator):
self.callback() self.callback()
class ShellSidebar: class ShellSidebar(BaseSideBar):
"""Sidebar for the PyShell window, for prompts etc.""" """Sidebar for the PyShell window, for prompts etc."""
def __init__(self, editwin): def __init__(self, editwin):
self.editwin = editwin self.canvas = None
self.parent = editwin.text_frame self.line_prompts = {}
self.text = editwin.text
self.canvas = tk.Canvas(self.parent, width=30, super().__init__(editwin)
borderwidth=0, highlightthickness=0,
takefocus=False)
self.bind_events()
change_delegator = \ change_delegator = \
WrappedLineHeightChangeDelegator(self.change_callback) WrappedLineHeightChangeDelegator(self.change_callback)
# Insert the TextChangeDelegator after the last delegator, so that # Insert the TextChangeDelegator after the last delegator, so that
# the sidebar reflects final changes to the text widget contents. # the sidebar reflects final changes to the text widget contents.
d = self.editwin.per.top d = self.editwin.per.top
@ -385,16 +420,42 @@ class ShellSidebar:
d = d.delegate d = d.delegate
self.editwin.per.insertfilterafter(change_delegator, after=d) 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 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): def change_callback(self):
if self.is_shown: if self.is_shown:
self.update_sidebar() self.update_sidebar()
@ -403,6 +464,7 @@ class ShellSidebar:
text = self.text text = self.text
text_tagnames = text.tag_names text_tagnames = text.tag_names
canvas = self.canvas canvas = self.canvas
line_prompts = self.line_prompts = {}
canvas.delete(tk.ALL) canvas.delete(tk.ALL)
@ -423,6 +485,8 @@ class ShellSidebar:
if prompt: if prompt:
canvas.create_text(2, y, anchor=tk.NW, text=prompt, canvas.create_text(2, y, anchor=tk.NW, text=prompt,
font=self.font, fill=self.colors[0]) font=self.font, fill=self.colors[0])
lineno = get_lineno(text, index)
line_prompts[lineno] = prompt
index = text.index(f'{index}+1line') index = text.index(f'{index}+1line')
def yscroll_event(self, *args, **kwargs): def yscroll_event(self, *args, **kwargs):
@ -430,7 +494,6 @@ class ShellSidebar:
The scroll bar is also updated. The scroll bar is also updated.
""" """
self.editwin.vbar.set(*args)
self.change_callback() self.change_callback()
return 'break' return 'break'
@ -440,9 +503,6 @@ class ShellSidebar:
tk_font = Font(self.text, font=font) tk_font = Font(self.text, font=font)
char_width = max(tk_font.measure(char) for char in ['>', '.']) char_width = max(tk_font.measure(char) for char in ['>', '.'])
self.canvas.configure(width=char_width * 3 + 4) self.canvas.configure(width=char_width * 3 + 4)
self._update_font(font)
def _update_font(self, font):
self.font = font self.font = font
self.change_callback() self.change_callback()
@ -450,65 +510,12 @@ class ShellSidebar:
"""Update the sidebar text colors, usually after config changes.""" """Update the sidebar text colors, usually after config changes."""
linenumbers_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber') linenumbers_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber')
prompt_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'console') prompt_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'console')
self._update_colors(foreground=prompt_colors['foreground'], foreground = prompt_colors['foreground']
background=linenumbers_colors['background']) background = linenumbers_colors['background']
def _update_colors(self, foreground, background):
self.colors = (foreground, background) self.colors = (foreground, background)
self.canvas.configure(background=self.colors[1]) self.canvas.configure(background=background)
self.change_callback() 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 # def _linenumbers_drag_scrolling(parent): # htest #
from idlelib.idle_test.test_sidebar import Dummy_editwin 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.