Skip to content

bpo-37903: implement shell sidebar mouse interactions #25708

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
May 3, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Doc/whatsnew/3.10.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
------------------

Expand Down
15 changes: 12 additions & 3 deletions Lib/idlelib/NEWS.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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

Expand All @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions Lib/idlelib/autocomplete.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@

class AutoComplete:

def __init__(self, editwin=None):
def __init__(self, editwin=None, tags=None):
self.editwin = editwin
if editwin is not None: # not in subprocess or no-gui test
self.text = editwin.text
self.tags = tags
self.autocompletewindow = None
# id of delayed call, and the index of the text insert when
# the delayed call was issued. If _delayed_completion_id is
Expand All @@ -48,7 +49,7 @@ def reload(cls):
"extensions", "AutoComplete", "popupwait", type="int", default=0)

def _make_autocomplete_window(self): # Makes mocking easier.
return autocomplete_w.AutoCompleteWindow(self.text)
return autocomplete_w.AutoCompleteWindow(self.text, tags=self.tags)

def _remove_autocomplete_window(self, event=None):
if self.autocompletewindow:
Expand Down
7 changes: 5 additions & 2 deletions Lib/idlelib/autocomplete_w.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@

class AutoCompleteWindow:

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

def _binary_search(self, s):
Expand Down
2 changes: 1 addition & 1 deletion Lib/idlelib/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ def __init__(self, flist=None, filename=None, key=None, root=None):

# Former extension bindings depends on frame.text being packed
# (called from self.ResetColorizer()).
autocomplete = self.AutoComplete(self)
autocomplete = self.AutoComplete(self, self.user_input_insert_tags)
text.bind("<<autocomplete>>", autocomplete.autocomplete_event)
text.bind("<<try-open-completions>>",
autocomplete.try_open_completions_event)
Expand Down
2 changes: 1 addition & 1 deletion Lib/idlelib/idle_test/test_autocomplete_w.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def setUpClass(cls):
cls.root = Tk()
cls.root.withdraw()
cls.text = Text(cls.root)
cls.acw = acw.AutoCompleteWindow(cls.text)
cls.acw = acw.AutoCompleteWindow(cls.text, tags=None)

@classmethod
def tearDownClass(cls):
Expand Down
61 changes: 60 additions & 1 deletion Lib/idlelib/idle_test/test_sidebar.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,6 @@ def test_click_selection(self):

self.assertEqual(self.get_selection(), ('2.0', '3.0'))

@unittest.skip('test disabled')
def simulate_drag(self, start_line, end_line):
start_x, start_y = self.get_line_screen_position(start_line)
end_x, end_y = self.get_line_screen_position(end_line)
Expand Down Expand Up @@ -704,6 +703,66 @@ def test_mousewheel(self):
yield
self.assertIsNotNone(text.dlineinfo(text.index(f'{last_lineno}.0')))

@run_in_tk_mainloop
def test_copy(self):
sidebar = self.shell.shell_sidebar
text = self.shell.text

first_line = get_end_linenumber(text)

self.do_input(dedent('''\
if True:
print(1)

'''))
yield

text.tag_add('sel', f'{first_line}.0', 'end-1c')
selected_text = text.get('sel.first', 'sel.last')
self.assertTrue(selected_text.startswith('if True:\n'))
self.assertIn('\n1\n', selected_text)

text.event_generate('<<copy>>')
self.addCleanup(text.clipboard_clear)

copied_text = text.clipboard_get()
self.assertEqual(copied_text, selected_text)

@run_in_tk_mainloop
def test_copy_with_prompts(self):
sidebar = self.shell.shell_sidebar
text = self.shell.text

first_line = get_end_linenumber(text)
self.do_input(dedent('''\
if True:
print(1)

'''))
yield

text.tag_add('sel', f'{first_line}.3', 'end-1c')
selected_text = text.get('sel.first', 'sel.last')
self.assertTrue(selected_text.startswith('True:\n'))

selected_lines_text = text.get('sel.first linestart', 'sel.last')
selected_lines = selected_lines_text.split('\n')
# Expect a block of input, a single output line, and a new prompt
expected_prompts = \
['>>>'] + ['...'] * (len(selected_lines) - 3) + [None, '>>>']
selected_text_with_prompts = '\n'.join(
line if prompt is None else prompt + ' ' + line
for prompt, line in zip(expected_prompts,
selected_lines,
strict=True)
) + '\n'

text.event_generate('<<copy-with-prompts>>')
self.addCleanup(text.clipboard_clear)

copied_text = text.clipboard_get()
self.assertEqual(copied_text, selected_text_with_prompts)


if __name__ == '__main__':
unittest.main(verbosity=2)
45 changes: 45 additions & 0 deletions Lib/idlelib/pyshell.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
raise SystemExit(1)

from code import InteractiveInterpreter
import itertools
import linecache
import os
import os.path
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -906,6 +914,7 @@ def __init__(self, flist=None):
text.bind("<<open-stack-viewer>>", self.open_stack_viewer)
text.bind("<<toggle-debugger>>", self.toggle_debugger)
text.bind("<<toggle-jit-stack-viewer>>", self.toggle_jit_stack_viewer)
text.bind("<<copy-with-prompts>>", self.copy_with_prompts_callback)
if use_subprocess:
text.bind("<<view-restart>>", self.view_restart_mark)
text.bind("<<restart-shell>>", self.restart_shell)
Expand Down Expand Up @@ -979,6 +988,42 @@ def replace_event(self, event):
def get_standard_extension_names(self):
return idleConf.GetExtensions(shell_only=True)

def copy_with_prompts_callback(self, event=None):
"""Copy selected lines to the clipboard, with prompts.

This makes the copied text useful for doc-tests and interactive
shell code examples.

This always copies entire lines, even if only part of the first
and/or last lines is selected.
"""
text = self.text

selection_indexes = (
self.text.index("sel.first linestart"),
self.text.index("sel.last +1line linestart"),
)
if selection_indexes[0] is None:
# There is no selection, so do nothing.
return

selected_text = self.text.get(*selection_indexes)
selection_lineno_range = range(
int(float(selection_indexes[0])),
int(float(selection_indexes[1]))
)
prompts = [
self.shell_sidebar.line_prompts.get(lineno)
for lineno in selection_lineno_range
]
selected_text_with_prompts = "\n".join(
line if prompt is None else f"{prompt} {line}"
for prompt, line in zip(prompts, selected_text.splitlines())
) + "\n"

text.clipboard_clear()
text.clipboard_append(selected_text_with_prompts)

reading = False
executing = False
canceled = False
Expand Down
Loading