Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 15d3861

Browse files
taleinatterryjreedy
andauthoredApr 28, 2021
bpo-37903: IDLE: Shell sidebar with prompts (pythonGH-22682)
The first followup will change shell indents to spaces. More are expected. Co-authored-by: Terry Jan Reedy <[email protected]>
1 parent 103d5e4 commit 15d3861

14 files changed

+887
-131
lines changed
 

‎Lib/idlelib/colorizer.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,6 @@ def LoadTagDefs(self):
133133
# non-modal alternative.
134134
"hit": idleConf.GetHighlight(theme, "hit"),
135135
}
136-
137136
if DEBUG: print('tagdefs', self.tagdefs)
138137

139138
def insert(self, index, chars, tags=None):

‎Lib/idlelib/editor.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,14 @@ class EditorWindow:
6060
from idlelib.sidebar import LineNumbers
6161
from idlelib.format import FormatParagraph, FormatRegion, Indents, Rstrip
6262
from idlelib.parenmatch import ParenMatch
63-
from idlelib.squeezer import Squeezer
6463
from idlelib.zoomheight import ZoomHeight
6564

6665
filesystemencoding = sys.getfilesystemencoding() # for file names
6766
help_url = None
6867

6968
allow_code_context = True
7069
allow_line_numbers = True
70+
user_input_insert_tags = None
7171

7272
def __init__(self, flist=None, filename=None, key=None, root=None):
7373
# Delay import: runscript imports pyshell imports EditorWindow.
@@ -784,9 +784,7 @@ def _addcolorizer(self):
784784
self.color = self.ColorDelegator()
785785
# can add more colorizers here...
786786
if self.color:
787-
self.per.removefilter(self.undo)
788-
self.per.insertfilter(self.color)
789-
self.per.insertfilter(self.undo)
787+
self.per.insertfilterafter(filter=self.color, after=self.undo)
790788

791789
def _rmcolorizer(self):
792790
if not self.color:
@@ -1303,8 +1301,6 @@ def smart_backspace_event(self, event):
13031301
# Debug prompt is multilined....
13041302
ncharsdeleted = 0
13051303
while 1:
1306-
if chars == self.prompt_last_line: # '' unless PyShell
1307-
break
13081304
chars = chars[:-1]
13091305
ncharsdeleted = ncharsdeleted + 1
13101306
have = len(chars.expandtabs(tabwidth))
@@ -1313,7 +1309,8 @@ def smart_backspace_event(self, event):
13131309
text.undo_block_start()
13141310
text.delete("insert-%dc" % ncharsdeleted, "insert")
13151311
if have < want:
1316-
text.insert("insert", ' ' * (want - have))
1312+
text.insert("insert", ' ' * (want - have),
1313+
self.user_input_insert_tags)
13171314
text.undo_block_stop()
13181315
return "break"
13191316

@@ -1346,7 +1343,7 @@ def smart_indent_event(self, event):
13461343
effective = len(prefix.expandtabs(self.tabwidth))
13471344
n = self.indentwidth
13481345
pad = ' ' * (n - effective % n)
1349-
text.insert("insert", pad)
1346+
text.insert("insert", pad, self.user_input_insert_tags)
13501347
text.see("insert")
13511348
return "break"
13521349
finally:
@@ -1377,13 +1374,14 @@ def newline_and_indent_event(self, event):
13771374
if i == n:
13781375
# The cursor is in or at leading indentation in a continuation
13791376
# line; just inject an empty line at the start.
1380-
text.insert("insert linestart", '\n')
1377+
text.insert("insert linestart", '\n',
1378+
self.user_input_insert_tags)
13811379
return "break"
13821380
indent = line[:i]
13831381

13841382
# Strip whitespace before insert point unless it's in the prompt.
13851383
i = 0
1386-
while line and line[-1] in " \t" and line != self.prompt_last_line:
1384+
while line and line[-1] in " \t":
13871385
line = line[:-1]
13881386
i += 1
13891387
if i:
@@ -1394,7 +1392,7 @@ def newline_and_indent_event(self, event):
13941392
text.delete("insert")
13951393

13961394
# Insert new line.
1397-
text.insert("insert", '\n')
1395+
text.insert("insert", '\n', self.user_input_insert_tags)
13981396

13991397
# Adjust indentation for continuations and block open/close.
14001398
# First need to find the last statement.
@@ -1430,7 +1428,7 @@ def newline_and_indent_event(self, event):
14301428
elif c == pyparse.C_STRING_NEXT_LINES:
14311429
# Inside a string which started before this line;
14321430
# just mimic the current indent.
1433-
text.insert("insert", indent)
1431+
text.insert("insert", indent, self.user_input_insert_tags)
14341432
elif c == pyparse.C_BRACKET:
14351433
# Line up with the first (if any) element of the
14361434
# last open bracket structure; else indent one
@@ -1444,7 +1442,8 @@ def newline_and_indent_event(self, event):
14441442
# beyond leftmost =; else to beyond first chunk of
14451443
# non-whitespace on initial line.
14461444
if y.get_num_lines_in_stmt() > 1:
1447-
text.insert("insert", indent)
1445+
text.insert("insert", indent,
1446+
self.user_input_insert_tags)
14481447
else:
14491448
self.reindent_to(y.compute_backslash_indent())
14501449
else:
@@ -1455,7 +1454,7 @@ def newline_and_indent_event(self, event):
14551454
# indentation of initial line of closest preceding
14561455
# interesting statement.
14571456
indent = y.get_base_indent_string()
1458-
text.insert("insert", indent)
1457+
text.insert("insert", indent, self.user_input_insert_tags)
14591458
if y.is_block_opener():
14601459
self.smart_indent_event(event)
14611460
elif indent and y.is_block_closer():
@@ -1502,7 +1501,8 @@ def reindent_to(self, column):
15021501
if text.compare("insert linestart", "!=", "insert"):
15031502
text.delete("insert linestart", "insert")
15041503
if column:
1505-
text.insert("insert", self._make_blanks(column))
1504+
text.insert("insert", self._make_blanks(column),
1505+
self.user_input_insert_tags)
15061506
text.undo_block_stop()
15071507

15081508
# Guess indentwidth from text content.

‎Lib/idlelib/history.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,13 @@ def fetch(self, reverse):
7474
else:
7575
if self.text.get("iomark", "end-1c") != prefix:
7676
self.text.delete("iomark", "end-1c")
77-
self.text.insert("iomark", prefix)
77+
self.text.insert("iomark", prefix, "stdin")
7878
pointer = prefix = None
7979
break
8080
item = self.history[pointer]
8181
if item[:nprefix] == prefix and len(item) > nprefix:
8282
self.text.delete("iomark", "end-1c")
83-
self.text.insert("iomark", item)
83+
self.text.insert("iomark", item, "stdin")
8484
break
8585
self.text.see("insert")
8686
self.text.tag_remove("sel", "1.0", "end")

‎Lib/idlelib/idle_test/test_editor.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,6 @@ def test_indent_and_newline_event(self):
167167
'2.end'),
168168
)
169169

170-
w.prompt_last_line = ''
171170
for test in tests:
172171
with self.subTest(label=test.label):
173172
insert(text, test.text)
@@ -182,13 +181,6 @@ def test_indent_and_newline_event(self):
182181
# Deletes selected text before adding new line.
183182
eq(get('1.0', 'end'), ' def f1(self, a,\n \n return a + b\n')
184183

185-
# Preserves the whitespace in shell prompt.
186-
w.prompt_last_line = '>>> '
187-
insert(text, '>>> \t\ta =')
188-
text.mark_set('insert', '1.5')
189-
nl(None)
190-
eq(get('1.0', 'end'), '>>> \na =\n')
191-
192184

193185
class RMenuTest(unittest.TestCase):
194186

‎Lib/idlelib/idle_test/test_pyshell.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,5 +60,89 @@ def test_init(self):
6060
## self.assertIsInstance(ps, pyshell.PyShell)
6161

6262

63+
class PyShellRemoveLastNewlineAndSurroundingWhitespaceTest(unittest.TestCase):
64+
regexp = pyshell.PyShell._last_newline_re
65+
66+
def all_removed(self, text):
67+
self.assertEqual('', self.regexp.sub('', text))
68+
69+
def none_removed(self, text):
70+
self.assertEqual(text, self.regexp.sub('', text))
71+
72+
def check_result(self, text, expected):
73+
self.assertEqual(expected, self.regexp.sub('', text))
74+
75+
def test_empty(self):
76+
self.all_removed('')
77+
78+
def test_newline(self):
79+
self.all_removed('\n')
80+
81+
def test_whitespace_no_newline(self):
82+
self.all_removed(' ')
83+
self.all_removed(' ')
84+
self.all_removed(' ')
85+
self.all_removed(' ' * 20)
86+
self.all_removed('\t')
87+
self.all_removed('\t\t')
88+
self.all_removed('\t\t\t')
89+
self.all_removed('\t' * 20)
90+
self.all_removed('\t ')
91+
self.all_removed(' \t')
92+
self.all_removed(' \t \t ')
93+
self.all_removed('\t \t \t')
94+
95+
def test_newline_with_whitespace(self):
96+
self.all_removed(' \n')
97+
self.all_removed('\t\n')
98+
self.all_removed(' \t\n')
99+
self.all_removed('\t \n')
100+
self.all_removed('\n ')
101+
self.all_removed('\n\t')
102+
self.all_removed('\n \t')
103+
self.all_removed('\n\t ')
104+
self.all_removed(' \n ')
105+
self.all_removed('\t\n ')
106+
self.all_removed(' \n\t')
107+
self.all_removed('\t\n\t')
108+
self.all_removed('\t \t \t\n')
109+
self.all_removed(' \t \t \n')
110+
self.all_removed('\n\t \t \t')
111+
self.all_removed('\n \t \t ')
112+
113+
def test_multiple_newlines(self):
114+
self.check_result('\n\n', '\n')
115+
self.check_result('\n' * 5, '\n' * 4)
116+
self.check_result('\n' * 5 + '\t', '\n' * 4)
117+
self.check_result('\n' * 20, '\n' * 19)
118+
self.check_result('\n' * 20 + ' ', '\n' * 19)
119+
self.check_result(' \n \n ', ' \n')
120+
self.check_result(' \n\n ', ' \n')
121+
self.check_result(' \n\n', ' \n')
122+
self.check_result('\t\n\n', '\t\n')
123+
self.check_result('\n\n ', '\n')
124+
self.check_result('\n\n\t', '\n')
125+
self.check_result(' \n \n ', ' \n')
126+
self.check_result('\t\n\t\n\t', '\t\n')
127+
128+
def test_non_whitespace(self):
129+
self.none_removed('a')
130+
self.check_result('a\n', 'a')
131+
self.check_result('a\n ', 'a')
132+
self.check_result('a \n ', 'a')
133+
self.check_result('a \n\t', 'a')
134+
self.none_removed('-')
135+
self.check_result('-\n', '-')
136+
self.none_removed('.')
137+
self.check_result('.\n', '.')
138+
139+
def test_unsupported_whitespace(self):
140+
self.none_removed('\v')
141+
self.none_removed('\n\v')
142+
self.check_result('\v\n', '\v')
143+
self.none_removed(' \n\v')
144+
self.check_result('\v\n ', '\v')
145+
146+
63147
if __name__ == '__main__':
64148
unittest.main(verbosity=2)

‎Lib/idlelib/idle_test/test_sidebar.py

Lines changed: 343 additions & 7 deletions
Large diffs are not rendered by default.

‎Lib/idlelib/idle_test/test_squeezer.py

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,12 @@
77
from test.support import requires
88

99
from idlelib.config import idleConf
10+
from idlelib.percolator import Percolator
1011
from idlelib.squeezer import count_lines_with_wrapping, ExpandingButton, \
1112
Squeezer
1213
from idlelib import macosx
1314
from idlelib.textview import view_text
1415
from idlelib.tooltip import Hovertip
15-
from idlelib.pyshell import PyShell
16-
1716

1817
SENTINEL_VALUE = sentinel.SENTINEL_VALUE
1918

@@ -205,8 +204,8 @@ def test_auto_squeeze(self):
205204
self.assertEqual(text_widget.get('1.0', 'end'), '\n')
206205
self.assertEqual(len(squeezer.expandingbuttons), 1)
207206

208-
def test_squeeze_current_text_event(self):
209-
"""Test the squeeze_current_text event."""
207+
def test_squeeze_current_text(self):
208+
"""Test the squeeze_current_text method."""
210209
# Squeezing text should work for both stdout and stderr.
211210
for tag_name in ["stdout", "stderr"]:
212211
editwin = self.make_mock_editor_window(with_text_widget=True)
@@ -222,19 +221,19 @@ def test_squeeze_current_text_event(self):
222221
self.assertEqual(len(squeezer.expandingbuttons), 0)
223222

224223
# Test squeezing the current text.
225-
retval = squeezer.squeeze_current_text_event(event=Mock())
224+
retval = squeezer.squeeze_current_text()
226225
self.assertEqual(retval, "break")
227226
self.assertEqual(text_widget.get('1.0', 'end'), '\n\n')
228227
self.assertEqual(len(squeezer.expandingbuttons), 1)
229228
self.assertEqual(squeezer.expandingbuttons[0].s, 'SOME\nTEXT')
230229

231230
# Test that expanding the squeezed text works and afterwards
232231
# the Text widget contains the original text.
233-
squeezer.expandingbuttons[0].expand(event=Mock())
232+
squeezer.expandingbuttons[0].expand()
234233
self.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n')
235234
self.assertEqual(len(squeezer.expandingbuttons), 0)
236235

237-
def test_squeeze_current_text_event_no_allowed_tags(self):
236+
def test_squeeze_current_text_no_allowed_tags(self):
238237
"""Test that the event doesn't squeeze text without a relevant tag."""
239238
editwin = self.make_mock_editor_window(with_text_widget=True)
240239
text_widget = editwin.text
@@ -249,7 +248,7 @@ def test_squeeze_current_text_event_no_allowed_tags(self):
249248
self.assertEqual(len(squeezer.expandingbuttons), 0)
250249

251250
# Test squeezing the current text.
252-
retval = squeezer.squeeze_current_text_event(event=Mock())
251+
retval = squeezer.squeeze_current_text()
253252
self.assertEqual(retval, "break")
254253
self.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n')
255254
self.assertEqual(len(squeezer.expandingbuttons), 0)
@@ -264,13 +263,13 @@ def test_squeeze_text_before_existing_squeezed_text(self):
264263
# Prepare some text in the Text widget and squeeze it.
265264
text_widget.insert("1.0", "SOME\nTEXT\n", "stdout")
266265
text_widget.mark_set("insert", "1.0")
267-
squeezer.squeeze_current_text_event(event=Mock())
266+
squeezer.squeeze_current_text()
268267
self.assertEqual(len(squeezer.expandingbuttons), 1)
269268

270269
# Test squeezing the current text.
271270
text_widget.insert("1.0", "MORE\nSTUFF\n", "stdout")
272271
text_widget.mark_set("insert", "1.0")
273-
retval = squeezer.squeeze_current_text_event(event=Mock())
272+
retval = squeezer.squeeze_current_text()
274273
self.assertEqual(retval, "break")
275274
self.assertEqual(text_widget.get('1.0', 'end'), '\n\n\n')
276275
self.assertEqual(len(squeezer.expandingbuttons), 2)
@@ -311,6 +310,7 @@ def make_mock_squeezer(self):
311310
root = get_test_tk_root(self)
312311
squeezer = Mock()
313312
squeezer.editwin.text = Text(root)
313+
squeezer.editwin.per = Percolator(squeezer.editwin.text)
314314

315315
# Set default values for the configuration settings.
316316
squeezer.auto_squeeze_min_lines = 50
@@ -352,14 +352,9 @@ def test_expand(self):
352352

353353
# Insert the button into the text widget
354354
# (this is normally done by the Squeezer class).
355-
text_widget = expandingbutton.text
355+
text_widget = squeezer.editwin.text
356356
text_widget.window_create("1.0", window=expandingbutton)
357357

358-
# Set base_text to the text widget, so that changes are actually
359-
# made to it (by ExpandingButton) and we can inspect these
360-
# changes afterwards.
361-
expandingbutton.base_text = expandingbutton.text
362-
363358
# trigger the expand event
364359
retval = expandingbutton.expand(event=Mock())
365360
self.assertEqual(retval, None)
@@ -390,11 +385,6 @@ def test_expand_dangerous_oupput(self):
390385
text_widget = expandingbutton.text
391386
text_widget.window_create("1.0", window=expandingbutton)
392387

393-
# Set base_text to the text widget, so that changes are actually
394-
# made to it (by ExpandingButton) and we can inspect these
395-
# changes afterwards.
396-
expandingbutton.base_text = expandingbutton.text
397-
398388
# Patch the message box module to always return False.
399389
with patch('idlelib.squeezer.messagebox') as mock_msgbox:
400390
mock_msgbox.askokcancel.return_value = False
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Utilities for testing with Tkinter"""
2+
import functools
3+
4+
5+
def run_in_tk_mainloop(test_method):
6+
"""Decorator for running a test method with a real Tk mainloop.
7+
8+
This starts a Tk mainloop before running the test, and stops it
9+
at the end. This is faster and more robust than the common
10+
alternative method of calling .update() and/or .update_idletasks().
11+
12+
Test methods using this must be written as generator functions,
13+
using "yield" to allow the mainloop to process events and "after"
14+
callbacks, and then continue the test from that point.
15+
16+
This also assumes that the test class has a .root attribute,
17+
which is a tkinter.Tk object.
18+
19+
For example (from test_sidebar.py):
20+
21+
@run_test_with_tk_mainloop
22+
def test_single_empty_input(self):
23+
self.do_input('\n')
24+
yield
25+
self.assert_sidebar_lines_end_with(['>>>', '>>>'])
26+
"""
27+
@functools.wraps(test_method)
28+
def new_test_method(self):
29+
test_generator = test_method(self)
30+
root = self.root
31+
# Exceptions raised by self.assert...() need to be raised
32+
# outside of the after() callback in order for the test
33+
# harness to capture them.
34+
exception = None
35+
def after_callback():
36+
nonlocal exception
37+
try:
38+
next(test_generator)
39+
except StopIteration:
40+
root.quit()
41+
except Exception as exc:
42+
exception = exc
43+
root.quit()
44+
else:
45+
# Schedule the Tk mainloop to call this function again,
46+
# using a robust method of ensuring that it gets a
47+
# chance to process queued events before doing so.
48+
# See: https://stackoverflow.com/q/18499082#comment65004099_38817470
49+
root.after(1, root.after_idle, after_callback)
50+
root.after(0, root.after_idle, after_callback)
51+
root.mainloop()
52+
53+
if exception:
54+
raise exception
55+
56+
return new_test_method

‎Lib/idlelib/percolator.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,21 @@ def insertfilter(self, filter):
3838
filter.setdelegate(self.top)
3939
self.top = filter
4040

41+
def insertfilterafter(self, filter, after):
42+
assert isinstance(filter, Delegator)
43+
assert isinstance(after, Delegator)
44+
assert filter.delegate is None
45+
46+
f = self.top
47+
f.resetcache()
48+
while f is not after:
49+
assert f is not self.bottom
50+
f = f.delegate
51+
f.resetcache()
52+
53+
filter.setdelegate(f.delegate)
54+
f.setdelegate(filter)
55+
4156
def removefilter(self, filter):
4257
# XXX Perhaps should only support popfilter()?
4358
assert isinstance(filter, Delegator)

‎Lib/idlelib/pyshell.py

Lines changed: 115 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,20 @@
4848

4949
from idlelib.colorizer import ColorDelegator
5050
from idlelib.config import idleConf
51+
from idlelib.delegator import Delegator
5152
from idlelib import debugger
5253
from idlelib import debugger_r
5354
from idlelib.editor import EditorWindow, fixwordbreaks
5455
from idlelib.filelist import FileList
5556
from idlelib.outwin import OutputWindow
57+
from idlelib import replace
5658
from idlelib import rpc
5759
from idlelib.run import idle_formatwarning, StdInputFile, StdOutputFile
5860
from idlelib.undo import UndoDelegator
5961

62+
# Default for testing; defaults to True in main() for running.
63+
use_subprocess = False
64+
6065
HOST = '127.0.0.1' # python execution server on localhost loopback
6166
PORT = 0 # someday pass in host, port for remote debug capability
6267

@@ -335,34 +340,19 @@ def open_shell(self, event=None):
335340

336341
class ModifiedColorDelegator(ColorDelegator):
337342
"Extend base class: colorizer for the shell window itself"
338-
339-
def __init__(self):
340-
ColorDelegator.__init__(self)
341-
self.LoadTagDefs()
342-
343343
def recolorize_main(self):
344344
self.tag_remove("TODO", "1.0", "iomark")
345345
self.tag_add("SYNC", "1.0", "iomark")
346346
ColorDelegator.recolorize_main(self)
347347

348-
def LoadTagDefs(self):
349-
ColorDelegator.LoadTagDefs(self)
350-
theme = idleConf.CurrentTheme()
351-
self.tagdefs.update({
352-
"stdin": {'background':None,'foreground':None},
353-
"stdout": idleConf.GetHighlight(theme, "stdout"),
354-
"stderr": idleConf.GetHighlight(theme, "stderr"),
355-
"console": idleConf.GetHighlight(theme, "console"),
356-
})
357-
358348
def removecolors(self):
359349
# Don't remove shell color tags before "iomark"
360350
for tag in self.tagdefs:
361351
self.tag_remove(tag, "iomark", "end")
362352

353+
363354
class ModifiedUndoDelegator(UndoDelegator):
364355
"Extend base class: forbid insert/delete before the I/O mark"
365-
366356
def insert(self, index, chars, tags=None):
367357
try:
368358
if self.delegate.compare(index, "<", "iomark"):
@@ -381,6 +371,27 @@ def delete(self, index1, index2=None):
381371
pass
382372
UndoDelegator.delete(self, index1, index2)
383373

374+
def undo_event(self, event):
375+
# Temporarily monkey-patch the delegate's .insert() method to
376+
# always use the "stdin" tag. This is needed for undo-ing
377+
# deletions to preserve the "stdin" tag, because UndoDelegator
378+
# doesn't preserve tags for deleted text.
379+
orig_insert = self.delegate.insert
380+
self.delegate.insert = \
381+
lambda index, chars: orig_insert(index, chars, "stdin")
382+
try:
383+
super().undo_event(event)
384+
finally:
385+
self.delegate.insert = orig_insert
386+
387+
388+
class UserInputTaggingDelegator(Delegator):
389+
"""Delegator used to tag user input with "stdin"."""
390+
def insert(self, index, chars, tags=None):
391+
if tags is None:
392+
tags = "stdin"
393+
self.delegate.insert(index, chars, tags)
394+
384395

385396
class MyRPCClient(rpc.RPCClient):
386397

@@ -832,6 +843,7 @@ def display_executing_dialog(self):
832843

833844

834845
class PyShell(OutputWindow):
846+
from idlelib.squeezer import Squeezer
835847

836848
shell_title = "IDLE Shell " + python_version()
837849

@@ -855,9 +867,11 @@ class PyShell(OutputWindow):
855867
]
856868

857869
allow_line_numbers = False
870+
user_input_insert_tags = "stdin"
858871

859872
# New classes
860873
from idlelib.history import History
874+
from idlelib.sidebar import ShellSidebar
861875

862876
def __init__(self, flist=None):
863877
if use_subprocess:
@@ -871,6 +885,8 @@ def __init__(self, flist=None):
871885
root.withdraw()
872886
flist = PyShellFileList(root)
873887

888+
self.shell_sidebar = None # initialized below
889+
874890
OutputWindow.__init__(self, flist, None, None)
875891

876892
self.usetabs = True
@@ -893,9 +909,9 @@ def __init__(self, flist=None):
893909
if use_subprocess:
894910
text.bind("<<view-restart>>", self.view_restart_mark)
895911
text.bind("<<restart-shell>>", self.restart_shell)
896-
squeezer = self.Squeezer(self)
912+
self.squeezer = self.Squeezer(self)
897913
text.bind("<<squeeze-current-text>>",
898-
squeezer.squeeze_current_text_event)
914+
self.squeeze_current_text_event)
899915

900916
self.save_stdout = sys.stdout
901917
self.save_stderr = sys.stderr
@@ -926,6 +942,40 @@ def __init__(self, flist=None):
926942
#
927943
self.pollinterval = 50 # millisec
928944

945+
self.shell_sidebar = self.ShellSidebar(self)
946+
947+
# Insert UserInputTaggingDelegator at the top of the percolator,
948+
# but make calls to text.insert() skip it. This causes only insert
949+
# events generated in Tcl/Tk to go through this delegator.
950+
self.text.insert = self.per.top.insert
951+
self.per.insertfilter(UserInputTaggingDelegator())
952+
953+
def ResetFont(self):
954+
super().ResetFont()
955+
956+
if self.shell_sidebar is not None:
957+
self.shell_sidebar.update_font()
958+
959+
def ResetColorizer(self):
960+
super().ResetColorizer()
961+
962+
theme = idleConf.CurrentTheme()
963+
tag_colors = {
964+
"stdin": {'background': None, 'foreground': None},
965+
"stdout": idleConf.GetHighlight(theme, "stdout"),
966+
"stderr": idleConf.GetHighlight(theme, "stderr"),
967+
"console": idleConf.GetHighlight(theme, "normal"),
968+
}
969+
for tag, tag_colors_config in tag_colors.items():
970+
self.text.tag_configure(tag, **tag_colors_config)
971+
972+
if self.shell_sidebar is not None:
973+
self.shell_sidebar.update_colors()
974+
975+
def replace_event(self, event):
976+
replace.replace(self.text, insert_tags="stdin")
977+
return "break"
978+
929979
def get_standard_extension_names(self):
930980
return idleConf.GetExtensions(shell_only=True)
931981

@@ -1166,13 +1216,30 @@ def enter_callback(self, event):
11661216
# the current line, less a leading prompt, less leading or
11671217
# trailing whitespace
11681218
if self.text.compare("insert", "<", "iomark linestart"):
1169-
# Check if there's a relevant stdin range -- if so, use it
1219+
# Check if there's a relevant stdin range -- if so, use it.
1220+
# Note: "stdin" blocks may include several successive statements,
1221+
# so look for "console" tags on the newline before each statement
1222+
# (and possibly on prompts).
11701223
prev = self.text.tag_prevrange("stdin", "insert")
1171-
if prev and self.text.compare("insert", "<", prev[1]):
1224+
if (
1225+
prev and
1226+
self.text.compare("insert", "<", prev[1]) and
1227+
# The following is needed to handle empty statements.
1228+
"console" not in self.text.tag_names("insert")
1229+
):
1230+
prev_cons = self.text.tag_prevrange("console", "insert")
1231+
if prev_cons and self.text.compare(prev_cons[1], ">=", prev[0]):
1232+
prev = (prev_cons[1], prev[1])
1233+
next_cons = self.text.tag_nextrange("console", "insert")
1234+
if next_cons and self.text.compare(next_cons[0], "<", prev[1]):
1235+
prev = (prev[0], self.text.index(next_cons[0] + "+1c"))
11721236
self.recall(self.text.get(prev[0], prev[1]), event)
11731237
return "break"
11741238
next = self.text.tag_nextrange("stdin", "insert")
11751239
if next and self.text.compare("insert lineend", ">=", next[0]):
1240+
next_cons = self.text.tag_nextrange("console", "insert lineend")
1241+
if next_cons and self.text.compare(next_cons[0], "<", next[1]):
1242+
next = (next[0], self.text.index(next_cons[0] + "+1c"))
11761243
self.recall(self.text.get(next[0], next[1]), event)
11771244
return "break"
11781245
# No stdin mark -- just get the current line, less any prompt
@@ -1204,7 +1271,6 @@ def enter_callback(self, event):
12041271
self.text.see("insert")
12051272
else:
12061273
self.newline_and_indent_event(event)
1207-
self.text.tag_add("stdin", "iomark", "end-1c")
12081274
self.text.update_idletasks()
12091275
if self.reading:
12101276
self.top.quit() # Break out of recursive mainloop()
@@ -1214,7 +1280,7 @@ def enter_callback(self, event):
12141280

12151281
def recall(self, s, event):
12161282
# remove leading and trailing empty or whitespace lines
1217-
s = re.sub(r'^\s*\n', '' , s)
1283+
s = re.sub(r'^\s*\n', '', s)
12181284
s = re.sub(r'\n\s*$', '', s)
12191285
lines = s.split('\n')
12201286
self.text.undo_block_start()
@@ -1225,32 +1291,33 @@ def recall(self, s, event):
12251291
if prefix.rstrip().endswith(':'):
12261292
self.newline_and_indent_event(event)
12271293
prefix = self.text.get("insert linestart", "insert")
1228-
self.text.insert("insert", lines[0].strip())
1294+
self.text.insert("insert", lines[0].strip(),
1295+
self.user_input_insert_tags)
12291296
if len(lines) > 1:
12301297
orig_base_indent = re.search(r'^([ \t]*)', lines[0]).group(0)
12311298
new_base_indent = re.search(r'^([ \t]*)', prefix).group(0)
12321299
for line in lines[1:]:
12331300
if line.startswith(orig_base_indent):
12341301
# replace orig base indentation with new indentation
12351302
line = new_base_indent + line[len(orig_base_indent):]
1236-
self.text.insert('insert', '\n'+line.rstrip())
1303+
self.text.insert('insert', '\n' + line.rstrip(),
1304+
self.user_input_insert_tags)
12371305
finally:
12381306
self.text.see("insert")
12391307
self.text.undo_block_stop()
12401308

1309+
_last_newline_re = re.compile(r"[ \t]*(\n[ \t]*)?\Z")
12411310
def runit(self):
1311+
index_before = self.text.index("end-2c")
12421312
line = self.text.get("iomark", "end-1c")
12431313
# Strip off last newline and surrounding whitespace.
12441314
# (To allow you to hit return twice to end a statement.)
1245-
i = len(line)
1246-
while i > 0 and line[i-1] in " \t":
1247-
i = i-1
1248-
if i > 0 and line[i-1] == "\n":
1249-
i = i-1
1250-
while i > 0 and line[i-1] in " \t":
1251-
i = i-1
1252-
line = line[:i]
1253-
self.interp.runsource(line)
1315+
line = self._last_newline_re.sub("", line)
1316+
input_is_complete = self.interp.runsource(line)
1317+
if not input_is_complete:
1318+
if self.text.get(index_before) == '\n':
1319+
self.text.tag_remove(self.user_input_insert_tags, index_before)
1320+
self.shell_sidebar.update_sidebar()
12541321

12551322
def open_stack_viewer(self, event=None):
12561323
if self.interp.rpcclt:
@@ -1276,7 +1343,14 @@ def restart_shell(self, event=None):
12761343

12771344
def showprompt(self):
12781345
self.resetoutput()
1279-
self.console.write(self.prompt)
1346+
1347+
prompt = self.prompt
1348+
if self.sys_ps1 and prompt.endswith(self.sys_ps1):
1349+
prompt = prompt[:-len(self.sys_ps1)]
1350+
self.text.tag_add("console", "iomark-1c")
1351+
self.console.write(prompt)
1352+
1353+
self.shell_sidebar.update_sidebar()
12801354
self.text.mark_set("insert", "end-1c")
12811355
self.set_line_and_column()
12821356
self.io.reset_undo()
@@ -1326,6 +1400,13 @@ def rmenu_check_paste(self):
13261400
return 'disabled'
13271401
return super().rmenu_check_paste()
13281402

1403+
def squeeze_current_text_event(self, event=None):
1404+
self.squeezer.squeeze_current_text()
1405+
self.shell_sidebar.update_sidebar()
1406+
1407+
def on_squeezed_expand(self, index, text, tags):
1408+
self.shell_sidebar.update_sidebar()
1409+
13291410

13301411
def fix_x11_paste(root):
13311412
"Make paste replace selection on x11. See issue #5124."

‎Lib/idlelib/replace.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from idlelib import searchengine
1212

1313

14-
def replace(text):
14+
def replace(text, insert_tags=None):
1515
"""Create or reuse a singleton ReplaceDialog instance.
1616
1717
The singleton dialog saves user entries and preferences
@@ -25,7 +25,7 @@ def replace(text):
2525
if not hasattr(engine, "_replacedialog"):
2626
engine._replacedialog = ReplaceDialog(root, engine)
2727
dialog = engine._replacedialog
28-
dialog.open(text)
28+
dialog.open(text, insert_tags=insert_tags)
2929

3030

3131
class ReplaceDialog(SearchDialogBase):
@@ -49,8 +49,9 @@ def __init__(self, root, engine):
4949
"""
5050
super().__init__(root, engine)
5151
self.replvar = StringVar(root)
52+
self.insert_tags = None
5253

53-
def open(self, text):
54+
def open(self, text, insert_tags=None):
5455
"""Make dialog visible on top of others and ready to use.
5556
5657
Also, highlight the currently selected text and set the
@@ -72,6 +73,7 @@ def open(self, text):
7273
last = last or first
7374
self.show_hit(first, last)
7475
self.ok = True
76+
self.insert_tags = insert_tags
7577

7678
def create_entries(self):
7779
"Create base and additional label and text entry widgets."
@@ -177,7 +179,7 @@ def replace_all(self, event=None):
177179
if first != last:
178180
text.delete(first, last)
179181
if new:
180-
text.insert(first, new)
182+
text.insert(first, new, self.insert_tags)
181183
col = i + len(new)
182184
ok = False
183185
text.undo_block_stop()
@@ -231,7 +233,7 @@ def do_replace(self):
231233
if m.group():
232234
text.delete(first, last)
233235
if new:
234-
text.insert(first, new)
236+
text.insert(first, new, self.insert_tags)
235237
text.undo_block_stop()
236238
self.show_hit(first, text.index("insert"))
237239
self.ok = False
@@ -264,6 +266,7 @@ def close(self, event=None):
264266
"Close the dialog and remove hit tags."
265267
SearchDialogBase.close(self, event)
266268
self.text.tag_remove("hit", "1.0", "end")
269+
self.insert_tags = None
267270

268271

269272
def _replace_dialog(parent): # htest #

‎Lib/idlelib/sidebar.py

Lines changed: 231 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,33 @@
11
"""Line numbering implementation for IDLE as an extension.
22
Includes BaseSideBar which can be extended for other sidebar based extensions
33
"""
4+
import contextlib
45
import functools
56
import itertools
67

78
import tkinter as tk
9+
from tkinter.font import Font
810
from idlelib.config import idleConf
911
from idlelib.delegator import Delegator
1012

1113

14+
def get_lineno(text, index):
15+
"""Return the line number of an index in a Tk text widget."""
16+
return int(float(text.index(index)))
17+
18+
1219
def get_end_linenumber(text):
13-
"""Utility to get the last line's number in a Tk text widget."""
14-
return int(float(text.index('end-1c')))
20+
"""Return the number of the last line in a Tk text widget."""
21+
return get_lineno(text, 'end-1c')
1522

1623

24+
def get_displaylines(text, index):
25+
"""Display height, in lines, of a logical line in a Tk text widget."""
26+
res = text.count(f"{index} linestart",
27+
f"{index} lineend",
28+
"displaylines")
29+
return res[0] if res else 0
30+
1731
def get_widget_padding(widget):
1832
"""Get the total padding of a Tk widget, including its border."""
1933
# TODO: use also in codecontext.py
@@ -40,10 +54,17 @@ def get_widget_padding(widget):
4054
return padx, pady
4155

4256

57+
@contextlib.contextmanager
58+
def temp_enable_text_widget(text):
59+
text.configure(state=tk.NORMAL)
60+
try:
61+
yield
62+
finally:
63+
text.configure(state=tk.DISABLED)
64+
65+
4366
class BaseSideBar:
44-
"""
45-
The base class for extensions which require a sidebar.
46-
"""
67+
"""A base class for sidebars using Text."""
4768
def __init__(self, editwin):
4869
self.editwin = editwin
4970
self.parent = editwin.text_frame
@@ -119,14 +140,11 @@ def redirect_mousewheel_event(self, event):
119140

120141

121142
class EndLineDelegator(Delegator):
122-
"""Generate callbacks with the current end line number after
123-
insert or delete operations"""
143+
"""Generate callbacks with the current end line number.
144+
145+
The provided callback is called after every insert and delete.
146+
"""
124147
def __init__(self, changed_callback):
125-
"""
126-
changed_callback - Callable, will be called after insert
127-
or delete operations with the current
128-
end line number.
129-
"""
130148
Delegator.__init__(self)
131149
self.changed_callback = changed_callback
132150

@@ -159,16 +177,8 @@ def __init__(self, editwin):
159177
end_line_delegator = EndLineDelegator(self.update_sidebar_text)
160178
# Insert the delegator after the undo delegator, so that line numbers
161179
# are properly updated after undo and redo actions.
162-
end_line_delegator.setdelegate(self.editwin.undo.delegate)
163-
self.editwin.undo.setdelegate(end_line_delegator)
164-
# Reset the delegator caches of the delegators "above" the
165-
# end line delegator we just inserted.
166-
delegator = self.editwin.per.top
167-
while delegator is not end_line_delegator:
168-
delegator.resetcache()
169-
delegator = delegator.delegate
170-
171-
self.is_shown = False
180+
self.editwin.per.insertfilterafter(filter=end_line_delegator,
181+
after=self.editwin.undo)
172182

173183
def bind_events(self):
174184
# Ensure focus is always redirected to the main editor text widget.
@@ -297,20 +307,209 @@ def update_sidebar_text(self, end):
297307
new_width = cur_width + width_difference
298308
self.sidebar_text['width'] = self._sidebar_width_type(new_width)
299309

300-
self.sidebar_text.config(state=tk.NORMAL)
301-
if end > self.prev_end:
302-
new_text = '\n'.join(itertools.chain(
303-
[''],
304-
map(str, range(self.prev_end + 1, end + 1)),
305-
))
306-
self.sidebar_text.insert(f'end -1c', new_text, 'linenumber')
307-
else:
308-
self.sidebar_text.delete(f'{end+1}.0 -1c', 'end -1c')
309-
self.sidebar_text.config(state=tk.DISABLED)
310+
with temp_enable_text_widget(self.sidebar_text):
311+
if end > self.prev_end:
312+
new_text = '\n'.join(itertools.chain(
313+
[''],
314+
map(str, range(self.prev_end + 1, end + 1)),
315+
))
316+
self.sidebar_text.insert(f'end -1c', new_text, 'linenumber')
317+
else:
318+
self.sidebar_text.delete(f'{end+1}.0 -1c', 'end -1c')
310319

311320
self.prev_end = end
312321

313322

323+
class WrappedLineHeightChangeDelegator(Delegator):
324+
def __init__(self, callback):
325+
"""
326+
callback - Callable, will be called when an insert, delete or replace
327+
action on the text widget may require updating the shell
328+
sidebar.
329+
"""
330+
Delegator.__init__(self)
331+
self.callback = callback
332+
333+
def insert(self, index, chars, tags=None):
334+
is_single_line = '\n' not in chars
335+
if is_single_line:
336+
before_displaylines = get_displaylines(self, index)
337+
338+
self.delegate.insert(index, chars, tags)
339+
340+
if is_single_line:
341+
after_displaylines = get_displaylines(self, index)
342+
if after_displaylines == before_displaylines:
343+
return # no need to update the sidebar
344+
345+
self.callback()
346+
347+
def delete(self, index1, index2=None):
348+
if index2 is None:
349+
index2 = index1 + "+1c"
350+
is_single_line = get_lineno(self, index1) == get_lineno(self, index2)
351+
if is_single_line:
352+
before_displaylines = get_displaylines(self, index1)
353+
354+
self.delegate.delete(index1, index2)
355+
356+
if is_single_line:
357+
after_displaylines = get_displaylines(self, index1)
358+
if after_displaylines == before_displaylines:
359+
return # no need to update the sidebar
360+
361+
self.callback()
362+
363+
364+
class ShellSidebar:
365+
"""Sidebar for the PyShell window, for prompts etc."""
366+
def __init__(self, editwin):
367+
self.editwin = editwin
368+
self.parent = editwin.text_frame
369+
self.text = editwin.text
370+
371+
self.canvas = tk.Canvas(self.parent, width=30,
372+
borderwidth=0, highlightthickness=0,
373+
takefocus=False)
374+
375+
self.bind_events()
376+
377+
change_delegator = \
378+
WrappedLineHeightChangeDelegator(self.change_callback)
379+
380+
# Insert the TextChangeDelegator after the last delegator, so that
381+
# the sidebar reflects final changes to the text widget contents.
382+
d = self.editwin.per.top
383+
if d.delegate is not self.text:
384+
while d.delegate is not self.editwin.per.bottom:
385+
d = d.delegate
386+
self.editwin.per.insertfilterafter(change_delegator, after=d)
387+
388+
self.text['yscrollcommand'] = self.yscroll_event
389+
390+
self.is_shown = False
391+
392+
self.update_font()
393+
self.update_colors()
394+
self.update_sidebar()
395+
self.canvas.grid(row=1, column=0, sticky=tk.NSEW, padx=2, pady=0)
396+
self.is_shown = True
397+
398+
def change_callback(self):
399+
if self.is_shown:
400+
self.update_sidebar()
401+
402+
def update_sidebar(self):
403+
text = self.text
404+
text_tagnames = text.tag_names
405+
canvas = self.canvas
406+
407+
canvas.delete(tk.ALL)
408+
409+
index = text.index("@0,0")
410+
if index.split('.', 1)[1] != '0':
411+
index = text.index(f'{index}+1line linestart')
412+
while True:
413+
lineinfo = text.dlineinfo(index)
414+
if lineinfo is None:
415+
break
416+
y = lineinfo[1]
417+
prev_newline_tagnames = text_tagnames(f"{index} linestart -1c")
418+
prompt = (
419+
'>>>' if "console" in prev_newline_tagnames else
420+
'...' if "stdin" in prev_newline_tagnames else
421+
None
422+
)
423+
if prompt:
424+
canvas.create_text(2, y, anchor=tk.NW, text=prompt,
425+
font=self.font, fill=self.colors[0])
426+
index = text.index(f'{index}+1line')
427+
428+
def yscroll_event(self, *args, **kwargs):
429+
"""Redirect vertical scrolling to the main editor text widget.
430+
431+
The scroll bar is also updated.
432+
"""
433+
self.editwin.vbar.set(*args)
434+
self.change_callback()
435+
return 'break'
436+
437+
def update_font(self):
438+
"""Update the sidebar text font, usually after config changes."""
439+
font = idleConf.GetFont(self.text, 'main', 'EditorWindow')
440+
tk_font = Font(self.text, font=font)
441+
char_width = max(tk_font.measure(char) for char in ['>', '.'])
442+
self.canvas.configure(width=char_width * 3 + 4)
443+
self._update_font(font)
444+
445+
def _update_font(self, font):
446+
self.font = font
447+
self.change_callback()
448+
449+
def update_colors(self):
450+
"""Update the sidebar text colors, usually after config changes."""
451+
linenumbers_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber')
452+
prompt_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'console')
453+
self._update_colors(foreground=prompt_colors['foreground'],
454+
background=linenumbers_colors['background'])
455+
456+
def _update_colors(self, foreground, background):
457+
self.colors = (foreground, background)
458+
self.canvas.configure(background=self.colors[1])
459+
self.change_callback()
460+
461+
def redirect_focusin_event(self, event):
462+
"""Redirect focus-in events to the main editor text widget."""
463+
self.text.focus_set()
464+
return 'break'
465+
466+
def redirect_mousebutton_event(self, event, event_name):
467+
"""Redirect mouse button events to the main editor text widget."""
468+
self.text.focus_set()
469+
self.text.event_generate(event_name, x=0, y=event.y)
470+
return 'break'
471+
472+
def redirect_mousewheel_event(self, event):
473+
"""Redirect mouse wheel events to the editwin text widget."""
474+
self.text.event_generate('<MouseWheel>',
475+
x=0, y=event.y, delta=event.delta)
476+
return 'break'
477+
478+
def bind_events(self):
479+
# Ensure focus is always redirected to the main editor text widget.
480+
self.canvas.bind('<FocusIn>', self.redirect_focusin_event)
481+
482+
# Redirect mouse scrolling to the main editor text widget.
483+
#
484+
# Note that without this, scrolling with the mouse only scrolls
485+
# the line numbers.
486+
self.canvas.bind('<MouseWheel>', self.redirect_mousewheel_event)
487+
488+
# Redirect mouse button events to the main editor text widget,
489+
# except for the left mouse button (1).
490+
#
491+
# Note: X-11 sends Button-4 and Button-5 events for the scroll wheel.
492+
def bind_mouse_event(event_name, target_event_name):
493+
handler = functools.partial(self.redirect_mousebutton_event,
494+
event_name=target_event_name)
495+
self.canvas.bind(event_name, handler)
496+
497+
for button in [2, 3, 4, 5]:
498+
for event_name in (f'<Button-{button}>',
499+
f'<ButtonRelease-{button}>',
500+
f'<B{button}-Motion>',
501+
):
502+
bind_mouse_event(event_name, target_event_name=event_name)
503+
504+
# Convert double- and triple-click events to normal click events,
505+
# since event_generate() doesn't allow generating such events.
506+
for event_name in (f'<Double-Button-{button}>',
507+
f'<Triple-Button-{button}>',
508+
):
509+
bind_mouse_event(event_name,
510+
target_event_name=f'<Button-{button}>')
511+
512+
314513
def _linenumbers_drag_scrolling(parent): # htest #
315514
from idlelib.idle_test.test_sidebar import Dummy_editwin
316515

‎Lib/idlelib/squeezer.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,10 @@ def expand(self, event=None):
160160
if not confirm:
161161
return "break"
162162

163-
self.base_text.insert(self.text.index(self), self.s, self.tags)
163+
index = self.text.index(self)
164+
self.base_text.insert(index, self.s, self.tags)
164165
self.base_text.delete(self)
166+
self.editwin.on_squeezed_expand(index, self.s, self.tags)
165167
self.squeezer.expandingbuttons.remove(self)
166168

167169
def copy(self, event=None):
@@ -285,12 +287,10 @@ def count_lines(self, s):
285287
"""
286288
return count_lines_with_wrapping(s, self.editwin.width)
287289

288-
def squeeze_current_text_event(self, event):
289-
"""squeeze-current-text event handler
290+
def squeeze_current_text(self):
291+
"""Squeeze the text block where the insertion cursor is.
290292
291-
Squeeze the block of text inside which contains the "insert" cursor.
292-
293-
If the insert cursor is not in a squeezable block of text, give the
293+
If the cursor is not in a squeezable block of text, give the
294294
user a small warning and do nothing.
295295
"""
296296
# Set tag_name to the first valid tag found on the "insert" cursor.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
IDLE's shell now shows prompts in a separate side-bar.

0 commit comments

Comments
 (0)
Please sign in to comment.