Skip to content

Commit b43cc31

Browse files
bpo-37903: IDLE: add shell sidebar mouse interactions (pythonGH-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>
1 parent 90d5239 commit b43cc31

File tree

10 files changed

+366
-212
lines changed

10 files changed

+366
-212
lines changed

Doc/whatsnew/3.10.rst

+26
Original file line numberDiff line numberDiff line change
@@ -994,6 +994,32 @@ hmac
994994
The hmac module now uses OpenSSL's HMAC implementation internally.
995995
(Contributed by Christian Heimes in :issue:`40645`.)
996996
997+
IDLE and idlelib
998+
----------------
999+
1000+
Make IDLE invoke :func:`sys.excepthook` (when started without '-n').
1001+
User hooks were previously ignored. (Patch by Ken Hilton in
1002+
:issue:`43008`.)
1003+
1004+
This change was backported to a 3.9 maintenance release.
1005+
1006+
Add a Shell sidebar. Move the primary prompt ('>>>') to the sidebar.
1007+
Add secondary prompts ('...') to the sidebar. Left click and optional
1008+
drag selects one or more lines of text, as with the editor
1009+
line number sidebar. Right click after selecting text lines displays
1010+
a context menu with 'copy with prompts'. This zips together prompts
1011+
from the sidebar with lines from the selected text. This option also
1012+
appears on the context menu for the text. (Contributed by Tal Einat
1013+
in :issue:`37903`.)
1014+
1015+
Use spaces instead of tabs to indent interactive code. This makes
1016+
interactive code entries 'look right'. Making this feasible was a
1017+
major motivation for adding the shell sidebar. Contributed by
1018+
Terry Jan Reedy in :issue:`37892`.)
1019+
1020+
We expect to backport these shell changes to a future 3.9 maintenance
1021+
release.
1022+
9971023
importlib.metadata
9981024
------------------
9991025

Lib/idlelib/NEWS.txt

+12-3
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,15 @@ Released on 2021-10-04?
44
=========================
55

66

7-
bpo-37892: Change Shell input indents from tabs to spaces.
7+
bpo-37903: Add mouse actions to the shell sidebar. Left click and
8+
optional drag selects one or more lines of text, as with the
9+
editor line number sidebar. Right click after selecting text lines
10+
displays a context menu with 'copy with prompts'. This zips together
11+
prompts from the sidebar with lines from the selected text. This option
12+
also appears on the context menu for the text.
13+
14+
bpo-37892: Change Shell input indents from tabs to spaces. Shell input
15+
now 'looks right'. Making this feasible motivated the shell sidebar.
816

917
bpo-37903: Move the Shell input prompt to a side bar.
1018

@@ -19,7 +27,8 @@ bpo-23544: Disable Debug=>Stack Viewer when user code is running or
1927
Debugger is active, to prevent hang or crash. Patch by Zackery Spytz.
2028

2129
bpo-43008: Make IDLE invoke :func:`sys.excepthook` in normal,
22-
2-process mode. Patch by Ken Hilton.
30+
2-process mode. User hooks were previously ignored.
31+
Patch by Ken Hilton.
2332

2433
bpo-33065: Fix problem debugging user classes with __repr__ method.
2534

@@ -32,7 +41,7 @@ installers built on macOS 11.
3241

3342
bpo-42426: Fix reporting offset of the RE error in searchengine.
3443

35-
bpo-42416: Get docstrings for IDLE calltips more often
44+
bpo-42416: Display docstrings in IDLE calltips in more cases,
3645
by using inspect.getdoc.
3746

3847
bpo-33987: Mostly finish using ttk widgets, mainly for editor,

Lib/idlelib/autocomplete.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,11 @@
3131

3232
class AutoComplete:
3333

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

5051
def _make_autocomplete_window(self): # Makes mocking easier.
51-
return autocomplete_w.AutoCompleteWindow(self.text)
52+
return autocomplete_w.AutoCompleteWindow(self.text, tags=self.tags)
5253

5354
def _remove_autocomplete_window(self, event=None):
5455
if self.autocompletewindow:

Lib/idlelib/autocomplete_w.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@
2626

2727
class AutoCompleteWindow:
2828

29-
def __init__(self, widget):
29+
def __init__(self, widget, tags):
3030
# The widget (Text) on which we place the AutoCompleteWindow
3131
self.widget = widget
32+
# Tags to mark inserted text with
33+
self.tags = tags
3234
# The widgets we create
3335
self.autocompletewindow = self.listbox = self.scrollbar = None
3436
# The default foreground and background of a selection. Saved because
@@ -69,7 +71,8 @@ def _change_start(self, newstart):
6971
"%s+%dc" % (self.startindex, len(self.start)))
7072
if i < len(newstart):
7173
self.widget.insert("%s+%dc" % (self.startindex, i),
72-
newstart[i:])
74+
newstart[i:],
75+
self.tags)
7376
self.start = newstart
7477

7578
def _binary_search(self, s):

Lib/idlelib/editor.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ def __init__(self, flist=None, filename=None, key=None, root=None):
311311

312312
# Former extension bindings depends on frame.text being packed
313313
# (called from self.ResetColorizer()).
314-
autocomplete = self.AutoComplete(self)
314+
autocomplete = self.AutoComplete(self, self.user_input_insert_tags)
315315
text.bind("<<autocomplete>>", autocomplete.autocomplete_event)
316316
text.bind("<<try-open-completions>>",
317317
autocomplete.try_open_completions_event)

Lib/idlelib/idle_test/test_autocomplete_w.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def setUpClass(cls):
1515
cls.root = Tk()
1616
cls.root.withdraw()
1717
cls.text = Text(cls.root)
18-
cls.acw = acw.AutoCompleteWindow(cls.text)
18+
cls.acw = acw.AutoCompleteWindow(cls.text, tags=None)
1919

2020
@classmethod
2121
def tearDownClass(cls):

Lib/idlelib/idle_test/test_sidebar.py

+60-1
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,6 @@ def test_click_selection(self):
270270

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

273-
@unittest.skip('test disabled')
274273
def simulate_drag(self, start_line, end_line):
275274
start_x, start_y = self.get_line_screen_position(start_line)
276275
end_x, end_y = self.get_line_screen_position(end_line)
@@ -704,6 +703,66 @@ def test_mousewheel(self):
704703
yield
705704
self.assertIsNotNone(text.dlineinfo(text.index(f'{last_lineno}.0')))
706705

706+
@run_in_tk_mainloop
707+
def test_copy(self):
708+
sidebar = self.shell.shell_sidebar
709+
text = self.shell.text
710+
711+
first_line = get_end_linenumber(text)
712+
713+
self.do_input(dedent('''\
714+
if True:
715+
print(1)
716+
717+
'''))
718+
yield
719+
720+
text.tag_add('sel', f'{first_line}.0', 'end-1c')
721+
selected_text = text.get('sel.first', 'sel.last')
722+
self.assertTrue(selected_text.startswith('if True:\n'))
723+
self.assertIn('\n1\n', selected_text)
724+
725+
text.event_generate('<<copy>>')
726+
self.addCleanup(text.clipboard_clear)
727+
728+
copied_text = text.clipboard_get()
729+
self.assertEqual(copied_text, selected_text)
730+
731+
@run_in_tk_mainloop
732+
def test_copy_with_prompts(self):
733+
sidebar = self.shell.shell_sidebar
734+
text = self.shell.text
735+
736+
first_line = get_end_linenumber(text)
737+
self.do_input(dedent('''\
738+
if True:
739+
print(1)
740+
741+
'''))
742+
yield
743+
744+
text.tag_add('sel', f'{first_line}.3', 'end-1c')
745+
selected_text = text.get('sel.first', 'sel.last')
746+
self.assertTrue(selected_text.startswith('True:\n'))
747+
748+
selected_lines_text = text.get('sel.first linestart', 'sel.last')
749+
selected_lines = selected_lines_text.split('\n')
750+
# Expect a block of input, a single output line, and a new prompt
751+
expected_prompts = \
752+
['>>>'] + ['...'] * (len(selected_lines) - 3) + [None, '>>>']
753+
selected_text_with_prompts = '\n'.join(
754+
line if prompt is None else prompt + ' ' + line
755+
for prompt, line in zip(expected_prompts,
756+
selected_lines,
757+
strict=True)
758+
) + '\n'
759+
760+
text.event_generate('<<copy-with-prompts>>')
761+
self.addCleanup(text.clipboard_clear)
762+
763+
copied_text = text.clipboard_get()
764+
self.assertEqual(copied_text, selected_text_with_prompts)
765+
707766

708767
if __name__ == '__main__':
709768
unittest.main(verbosity=2)

Lib/idlelib/pyshell.py

+45
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
raise SystemExit(1)
3434

3535
from code import InteractiveInterpreter
36+
import itertools
3637
import linecache
3738
import os
3839
import os.path
@@ -865,6 +866,13 @@ class PyShell(OutputWindow):
865866
rmenu_specs = OutputWindow.rmenu_specs + [
866867
("Squeeze", "<<squeeze-current-text>>"),
867868
]
869+
_idx = 1 + len(list(itertools.takewhile(
870+
lambda rmenu_item: rmenu_item[0] != "Copy", rmenu_specs)
871+
))
872+
rmenu_specs.insert(_idx, ("Copy with prompts",
873+
"<<copy-with-prompts>>",
874+
"rmenu_check_copy"))
875+
del _idx
868876

869877
allow_line_numbers = False
870878
user_input_insert_tags = "stdin"
@@ -906,6 +914,7 @@ def __init__(self, flist=None):
906914
text.bind("<<open-stack-viewer>>", self.open_stack_viewer)
907915
text.bind("<<toggle-debugger>>", self.toggle_debugger)
908916
text.bind("<<toggle-jit-stack-viewer>>", self.toggle_jit_stack_viewer)
917+
text.bind("<<copy-with-prompts>>", self.copy_with_prompts_callback)
909918
if use_subprocess:
910919
text.bind("<<view-restart>>", self.view_restart_mark)
911920
text.bind("<<restart-shell>>", self.restart_shell)
@@ -979,6 +988,42 @@ def replace_event(self, event):
979988
def get_standard_extension_names(self):
980989
return idleConf.GetExtensions(shell_only=True)
981990

991+
def copy_with_prompts_callback(self, event=None):
992+
"""Copy selected lines to the clipboard, with prompts.
993+
994+
This makes the copied text useful for doc-tests and interactive
995+
shell code examples.
996+
997+
This always copies entire lines, even if only part of the first
998+
and/or last lines is selected.
999+
"""
1000+
text = self.text
1001+
1002+
selection_indexes = (
1003+
self.text.index("sel.first linestart"),
1004+
self.text.index("sel.last +1line linestart"),
1005+
)
1006+
if selection_indexes[0] is None:
1007+
# There is no selection, so do nothing.
1008+
return
1009+
1010+
selected_text = self.text.get(*selection_indexes)
1011+
selection_lineno_range = range(
1012+
int(float(selection_indexes[0])),
1013+
int(float(selection_indexes[1]))
1014+
)
1015+
prompts = [
1016+
self.shell_sidebar.line_prompts.get(lineno)
1017+
for lineno in selection_lineno_range
1018+
]
1019+
selected_text_with_prompts = "\n".join(
1020+
line if prompt is None else f"{prompt} {line}"
1021+
for prompt, line in zip(prompts, selected_text.splitlines())
1022+
) + "\n"
1023+
1024+
text.clipboard_clear()
1025+
text.clipboard_append(selected_text_with_prompts)
1026+
9821027
reading = False
9831028
executing = False
9841029
canceled = False

0 commit comments

Comments
 (0)