diff --git a/Lib/idlelib/colorizer.py b/Lib/idlelib/colorizer.py index db1266fed3b691..be81e8a48cbbc3 100644 --- a/Lib/idlelib/colorizer.py +++ b/Lib/idlelib/colorizer.py @@ -120,12 +120,15 @@ def LoadTagDefs(self): "BUILTIN": idleConf.GetHighlight(theme, "builtin"), "STRING": idleConf.GetHighlight(theme, "string"), "DEFINITION": idleConf.GetHighlight(theme, "definition"), - "SYNC": {'background':None,'foreground':None}, - "TODO": {'background':None,'foreground':None}, + "SYNC": {'background': None, 'foreground': None}, + "TODO": {'background': None, 'foreground': None}, "ERROR": idleConf.GetHighlight(theme, "error"), - # The following is used by ReplaceDialog: + # "hit" is used by ReplaceDialog to mark matches. It shouldn't be changed by Colorizer, but + # that currently isn't technically possible. This should be moved elsewhere in the future + # when fixing the "hit" tag's visibility, or when the replace dialog is replaced with a + # non-modal alternative. "hit": idleConf.GetHighlight(theme, "hit"), - } + } if DEBUG: print('tagdefs',self.tagdefs) diff --git a/Lib/idlelib/configdialog.py b/Lib/idlelib/configdialog.py index 82596498d34611..44cf8339386192 100644 --- a/Lib/idlelib/configdialog.py +++ b/Lib/idlelib/configdialog.py @@ -233,7 +233,7 @@ def activate_config_changes(self): """ win_instances = self.parent.instance_dict.keys() for instance in win_instances: - instance.ResetColorizer() + instance.update_colors() instance.ResetFont() instance.set_notabs_indentwidth() instance.ApplyKeybindings() diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py index a178eaf93c013a..187c67de7f087e 100644 --- a/Lib/idlelib/editor.py +++ b/Lib/idlelib/editor.py @@ -60,7 +60,6 @@ class EditorWindow(object): from idlelib.sidebar import LineNumbers from idlelib.format import FormatParagraph, FormatRegion, Indents, Rstrip from idlelib.parenmatch import ParenMatch - from idlelib.squeezer import Squeezer from idlelib.zoomheight import ZoomHeight filesystemencoding = sys.getfilesystemencoding() # for file names @@ -68,6 +67,7 @@ class EditorWindow(object): allow_code_context = True allow_line_numbers = True + user_input_insert_tags = None def __init__(self, flist=None, filename=None, key=None, root=None): # Delay import: runscript imports pyshell imports EditorWindow. @@ -266,7 +266,7 @@ def __init__(self, flist=None, filename=None, key=None, root=None): io.set_filename_change_hook(self.filename_change_hook) self.good_load = False self.set_indentation_params(False) - self.color = None # initialized below in self.ResetColorizer + self.color = None # initialized below in self.update_colors() self.code_context = None # optionally initialized later below self.line_numbers = None # optionally initialized later below if filename: @@ -279,7 +279,7 @@ def __init__(self, flist=None, filename=None, key=None, root=None): io.set_filename(filename) self.good_load = True - self.ResetColorizer() + self.update_colors() self.saved_change_hook() self.update_recent_files_list() self.load_extensions() @@ -782,9 +782,7 @@ def _addcolorizer(self): self.color = self.ColorDelegator() # can add more colorizers here... if self.color: - self.per.removefilter(self.undo) - self.per.insertfilter(self.color) - self.per.insertfilter(self.undo) + self.per.insertfilterafter(filter=self.color, after=self.undo) def _rmcolorizer(self): if not self.color: @@ -794,12 +792,23 @@ def _rmcolorizer(self): self.color = None def ResetColorizer(self): - "Update the color theme" - # Called from self.filename_change_hook and from configdialog.py + """Reset the syntax colorizer. + + For example, this is called after filename changes, since the file + type can affect the highlighting. + """ self._rmcolorizer() self._addcolorizer() + + def update_colors(self): + """Update the color theme. + + For example, this is called after highlighting config changes. + """ EditorWindow.color_config(self.text) + self.ResetColorizer() + if self.code_context is not None: self.code_context.update_highlight_colors() @@ -1301,8 +1310,6 @@ def smart_backspace_event(self, event): # Debug prompt is multilined.... ncharsdeleted = 0 while 1: - if chars == self.prompt_last_line: # '' unless PyShell - break chars = chars[:-1] ncharsdeleted = ncharsdeleted + 1 have = len(chars.expandtabs(tabwidth)) @@ -1311,7 +1318,7 @@ def smart_backspace_event(self, event): text.undo_block_start() text.delete("insert-%dc" % ncharsdeleted, "insert") if have < want: - text.insert("insert", ' ' * (want - have)) + text.insert("insert", ' ' * (want - have), self.user_input_insert_tags) text.undo_block_stop() return "break" @@ -1344,7 +1351,7 @@ def smart_indent_event(self, event): effective = len(prefix.expandtabs(self.tabwidth)) n = self.indentwidth pad = ' ' * (n - effective % n) - text.insert("insert", pad) + text.insert("insert", pad, self.user_input_insert_tags) text.see("insert") return "break" finally: @@ -1375,13 +1382,13 @@ def newline_and_indent_event(self, event): if i == n: # The cursor is in or at leading indentation in a continuation # line; just inject an empty line at the start. - text.insert("insert linestart", '\n') + text.insert("insert linestart", '\n', self.user_input_insert_tags) return "break" indent = line[:i] # Strip whitespace before insert point unless it's in the prompt. i = 0 - while line and line[-1] in " \t" and line != self.prompt_last_line: + while line and line[-1] in " \t": line = line[:-1] i += 1 if i: @@ -1392,7 +1399,7 @@ def newline_and_indent_event(self, event): text.delete("insert") # Insert new line. - text.insert("insert", '\n') + text.insert("insert", '\n', self.user_input_insert_tags) # Adjust indentation for continuations and block open/close. # First need to find the last statement. @@ -1428,7 +1435,7 @@ def newline_and_indent_event(self, event): elif c == pyparse.C_STRING_NEXT_LINES: # Inside a string which started before this line; # just mimic the current indent. - text.insert("insert", indent) + text.insert("insert", indent, self.user_input_insert_tags) elif c == pyparse.C_BRACKET: # Line up with the first (if any) element of the # last open bracket structure; else indent one @@ -1442,7 +1449,7 @@ def newline_and_indent_event(self, event): # beyond leftmost =; else to beyond first chunk of # non-whitespace on initial line. if y.get_num_lines_in_stmt() > 1: - text.insert("insert", indent) + text.insert("insert", indent, self.user_input_insert_tags) else: self.reindent_to(y.compute_backslash_indent()) else: @@ -1453,7 +1460,7 @@ def newline_and_indent_event(self, event): # indentation of initial line of closest preceding # interesting statement. indent = y.get_base_indent_string() - text.insert("insert", indent) + text.insert("insert", indent, self.user_input_insert_tags) if y.is_block_opener(): self.smart_indent_event(event) elif indent and y.is_block_closer(): @@ -1500,7 +1507,7 @@ def reindent_to(self, column): if text.compare("insert linestart", "!=", "insert"): text.delete("insert linestart", "insert") if column: - text.insert("insert", self._make_blanks(column)) + text.insert("insert", self._make_blanks(column), self.user_input_insert_tags) text.undo_block_stop() # Guess indentwidth from text content. diff --git a/Lib/idlelib/history.py b/Lib/idlelib/history.py index ad44a96a9de2c0..7ce09253eff5c9 100644 --- a/Lib/idlelib/history.py +++ b/Lib/idlelib/history.py @@ -74,13 +74,13 @@ def fetch(self, reverse): else: if self.text.get("iomark", "end-1c") != prefix: self.text.delete("iomark", "end-1c") - self.text.insert("iomark", prefix) + self.text.insert("iomark", prefix, "stdin") pointer = prefix = None break item = self.history[pointer] if item[:nprefix] == prefix and len(item) > nprefix: self.text.delete("iomark", "end-1c") - self.text.insert("iomark", item) + self.text.insert("iomark", item, "stdin") break self.text.see("insert") self.text.tag_remove("sel", "1.0", "end") diff --git a/Lib/idlelib/idle_test/test_editor.py b/Lib/idlelib/idle_test/test_editor.py index 443dcf021679fc..8665d680c0118f 100644 --- a/Lib/idlelib/idle_test/test_editor.py +++ b/Lib/idlelib/idle_test/test_editor.py @@ -167,7 +167,6 @@ def test_indent_and_newline_event(self): '2.end'), ) - w.prompt_last_line = '' for test in tests: with self.subTest(label=test.label): insert(text, test.text) @@ -182,13 +181,6 @@ def test_indent_and_newline_event(self): # Deletes selected text before adding new line. eq(get('1.0', 'end'), ' def f1(self, a,\n \n return a + b\n') - # Preserves the whitespace in shell prompt. - w.prompt_last_line = '>>> ' - insert(text, '>>> \t\ta =') - text.mark_set('insert', '1.5') - nl(None) - eq(get('1.0', 'end'), '>>> \na =\n') - class RMenuTest(unittest.TestCase): diff --git a/Lib/idlelib/idle_test/test_sidebar.py b/Lib/idlelib/idle_test/test_sidebar.py index 2974a9a7b09874..1f708606da032f 100644 --- a/Lib/idlelib/idle_test/test_sidebar.py +++ b/Lib/idlelib/idle_test/test_sidebar.py @@ -1,5 +1,7 @@ """Test sidebar, coverage 93%""" -import idlelib.sidebar +from textwrap import dedent +import sys + from itertools import chain import unittest import unittest.mock @@ -7,7 +9,14 @@ import tkinter as tk from idlelib.delegator import Delegator +from idlelib.editor import fixwordbreaks +from idlelib import macosx from idlelib.percolator import Percolator +import idlelib.pyshell +from idlelib.pyshell import fix_x11_paste, PyShell, PyShellFileList +from idlelib.run import fix_scaling +import idlelib.sidebar +from idlelib.sidebar import get_end_linenumber, get_lineno class Dummy_editwin: @@ -154,7 +163,7 @@ def test_delete(self): self.assert_sidebar_n_lines(3) self.assert_state_disabled() - # Note: deleting up to "2.end" doesn't delete the final newline. + # Deleting up to "2.end" doesn't delete the final newline. self.text.delete('2.0', '2.end') self.assert_text_equals('fbarfoo\n\n\n') self.assert_sidebar_n_lines(3) @@ -165,7 +174,7 @@ def test_delete(self): self.assert_sidebar_n_lines(1) self.assert_state_disabled() - # Note: Text widgets always keep a single '\n' character at the end. + # Text widgets always keep a single '\n' character at the end. self.text.delete('1.0', 'end') self.assert_text_equals('\n') self.assert_sidebar_n_lines(1) @@ -234,7 +243,7 @@ def get_width(): self.assert_sidebar_n_lines(4) self.assertEqual(get_width(), 1) - # Note: Text widgets always keep a single '\n' character at the end. + # Text widgets always keep a single '\n' character at the end. self.text.delete('1.0', 'end -1c') self.assert_sidebar_n_lines(1) self.assertEqual(get_width(), 1) @@ -353,7 +362,7 @@ def assert_colors_are_equal(colors): ln.hide_sidebar() self.highlight_cfg = test_colors - # Nothing breaks with inactive code context. + # Nothing breaks with inactive line numbers. ln.update_colors() # Show line numbers, previous colors change is immediately effective. @@ -370,5 +379,328 @@ def assert_colors_are_equal(colors): assert_colors_are_equal(orig_colors) +def test_coroutine(test_method): + def new_method(self): + return self.run_test_coroutine(test_method(self)) + return new_method + + +class ShellSidebarTest(unittest.TestCase): + root: tk.Tk = None + shell: PyShell = None + + @classmethod + def setUpClass(cls): + requires('gui') + + idlelib.pyshell.use_subprocess = True + + cls.root = root = tk.Tk() + root.withdraw() + + fix_scaling(root) + fixwordbreaks(root) + fix_x11_paste(root) + + cls.flist = flist = PyShellFileList(root) + macosx.setupApp(root, flist) + root.update_idletasks() + + @classmethod + def tearDownClass(cls): + if cls.shell is not None: + cls.shell.executing = False + cls.shell.close() + cls.shell = None + cls.flist = None + cls.root.update_idletasks() + cls.root.destroy() + cls.root = None + + @classmethod + def init_shell(cls): + cls.shell = cls.flist.open_shell() + cls.shell.pollinterval = 10 + cls.root.update() + cls.n_preface_lines = get_lineno(cls.shell.text, 'end-1c') - 1 + + @classmethod + def reset_shell(cls): + cls.shell.per.bottom.delete(f'{cls.n_preface_lines+1}.0', 'end-1c') + cls.shell.shell_sidebar.update_sidebar() + cls.root.update() + + def setUp(self): + if self.shell is None: + self.init_shell() + else: + self.reset_shell() + + def get_sidebar_lines(self): + canvas = self.shell.shell_sidebar.canvas + texts = list(canvas.find(tk.ALL)) + texts.sort(key=lambda text: canvas.bbox(text)[1]) + return [canvas.itemcget(text, 'text') for text in texts] + + def assert_sidebar_lines_end_with(self, expected_lines): + self.shell.shell_sidebar.update_sidebar() + self.assertEqual( + self.get_sidebar_lines()[-len(expected_lines):], + expected_lines, + ) + + def get_shell_line_y_coords(self): + text = self.shell.text + y_coords = [] + index = text.index("@0,0") + while True: + lineinfo = text.dlineinfo(index) + if lineinfo is None: + break + y_coords.append(lineinfo[1]) + index = text.index(f"{index} +1line") + return y_coords + + def get_sidebar_line_y_coords(self): + canvas = self.shell.shell_sidebar.canvas + texts = list(canvas.find(tk.ALL)) + texts.sort(key=lambda text: canvas.bbox(text)[1]) + return [canvas.bbox(text)[1] for text in texts] + + def assert_sidebar_lines_synced(self): + self.assertEqual( + self.get_sidebar_line_y_coords(), + self.get_shell_line_y_coords(), + ) + + def do_input(self, input): + shell = self.shell + text = shell.text + for line_index, line in enumerate(input.split('\n')): + if line_index > 0: + text.event_generate('') + text.event_generate('') + for char in line: + text.event_generate(char) + + def run_test_coroutine(self, coroutine): + root = self.root + # Exceptions raised by self.assert...() need to be raised outside of + # the after callback in order for the test harness to capture them. + exception = None + def after_callback(): + nonlocal exception + try: + interval_multiplier = next(coroutine) or 1 + except StopIteration: + root.quit() + except Exception as exc: + exception = exc + root.quit() + else: + root.after(100 * interval_multiplier, after_callback) + root.after(0, after_callback) + root.mainloop() + + if exception: + raise exception + + def test_initial_state(self): + sidebar_lines = self.get_sidebar_lines() + self.assertEqual( + sidebar_lines, + [' '] * (len(sidebar_lines) - 1) + ['>>>'], + ) + self.assert_sidebar_lines_synced() + + @test_coroutine + def test_single_empty_input(self): + self.do_input('\n') + yield 0 + self.assert_sidebar_lines_end_with(['>>>', '>>>']) + + @test_coroutine + def test_single_line_command(self): + self.do_input('1\n') + yield 2 + self.assert_sidebar_lines_end_with(['>>>', ' ', '>>>']) + + @test_coroutine + def test_multi_line_command(self): + # Block statements are not indented because IDLE auto-indents. + self.do_input(dedent('''\ + if True: + print(1) + + ''')) + yield 2 + self.assert_sidebar_lines_end_with([ + '>>>', + '...', + '...', + '...', + ' ', + '>>>', + ]) + + @test_coroutine + def test_single_long_line_wraps(self): + self.do_input('1' * 200 + '\n') + yield 2 + self.assert_sidebar_lines_end_with(['>>>', ' ', '>>>']) + self.assert_sidebar_lines_synced() + + @test_coroutine + def test_squeeze_single_line_command(self): + shell = self.shell + text = shell.text + + self.do_input('1\n') + yield 2 + self.assert_sidebar_lines_end_with(['>>>', ' ', '>>>']) + + line = int(shell.text.index('insert -1line').split('.')[0]) + text.mark_set('insert', f"{line}.0") + text.event_generate('<>') + yield 0 + self.assert_sidebar_lines_end_with(['>>>', ' ', '>>>']) + self.assert_sidebar_lines_synced() + + shell.squeezer.expandingbuttons[0].expand() + yield 0 + self.assert_sidebar_lines_end_with(['>>>', ' ', '>>>']) + self.assert_sidebar_lines_synced() + + @test_coroutine + def test_interrupt_recall_undo_redo(self): + event_generate = self.shell.text.event_generate + # Block statements are not indented because IDLE auto-indents. + initial_sidebar_lines = self.get_sidebar_lines() + + self.do_input(dedent('''\ + if True: + print(1) + ''')) + yield 0 + self.assert_sidebar_lines_end_with(['>>>', '...', '...']) + with_block_sidebar_lines = self.get_sidebar_lines() + self.assertNotEqual(with_block_sidebar_lines, initial_sidebar_lines) + + # Control-C + event_generate('<>') + yield 0 + self.assert_sidebar_lines_end_with(['>>>', '...', '...', ' ', '>>>']) + + # Recall previous via history + event_generate('<>') + event_generate('<>') + yield 0 + self.assert_sidebar_lines_end_with(['>>>', '...', ' ', '>>>']) + + # Recall previous via recall + event_generate('') + event_generate('') + event_generate('') + yield 0 + + event_generate('<>') + yield 0 + self.assert_sidebar_lines_end_with(['>>>']) + + event_generate('<>') + yield 0 + self.assert_sidebar_lines_end_with(['>>>', '...']) + + event_generate('') + event_generate('') + yield 2 + self.assert_sidebar_lines_end_with( + ['>>>', '...', '...', '...', ' ', '>>>'] + ) + + def test_font(self): + sidebar = self.shell.shell_sidebar + + test_font = 'TkTextFont' + + def mock_idleconf_GetFont(root, configType, section): + return test_font + GetFont_patcher = unittest.mock.patch.object( + idlelib.sidebar.idleConf, 'GetFont', mock_idleconf_GetFont) + GetFont_patcher.start() + def cleanup(): + GetFont_patcher.stop() + sidebar.update_font() + self.addCleanup(cleanup) + + def get_sidebar_font(): + canvas = sidebar.canvas + texts = list(canvas.find(tk.ALL)) + fonts = {canvas.itemcget(text, 'font') for text in texts} + self.assertEqual(len(fonts), 1) + return next(iter(fonts)) + + self.assertNotEqual(get_sidebar_font(), test_font) + sidebar.update_font() + self.assertEqual(get_sidebar_font(), test_font) + + def test_highlight_colors(self): + sidebar = self.shell.shell_sidebar + + test_colors = {"background": '#abcdef', "foreground": '#123456'} + + orig_idleConf_GetHighlight = idlelib.sidebar.idleConf.GetHighlight + def mock_idleconf_GetHighlight(theme, element): + if element in ['linenumber', 'console']: + return test_colors + return orig_idleConf_GetHighlight(theme, element) + GetHighlight_patcher = unittest.mock.patch.object( + idlelib.sidebar.idleConf, 'GetHighlight', + mock_idleconf_GetHighlight) + GetHighlight_patcher.start() + def cleanup(): + GetHighlight_patcher.stop() + sidebar.update_colors() + self.addCleanup(cleanup) + + def get_sidebar_colors(): + canvas = sidebar.canvas + texts = list(canvas.find(tk.ALL)) + fgs = {canvas.itemcget(text, 'fill') for text in texts} + self.assertEqual(len(fgs), 1) + fg = next(iter(fgs)) + bg = canvas.cget('background') + return {"background": bg, "foreground": fg} + + self.assertNotEqual(get_sidebar_colors(), test_colors) + sidebar.update_colors() + self.assertEqual(get_sidebar_colors(), test_colors) + + @test_coroutine + def test_mousewheel(self): + sidebar = self.shell.shell_sidebar + text = self.shell.text + + # Press Return 50 times to get the shell screen to scroll down. + for _i in range(50): + self.do_input('\n') + yield 0 + self.assertGreater(get_lineno(text, '@0,0'), 1) + + last_lineno = get_end_linenumber(text) + self.assertIsNotNone(text.dlineinfo(text.index(f'{last_lineno}.0'))) + + # Scroll up using the event with a positive delta. + delta = -1 if sys.platform == 'darwin' else 120 + sidebar.canvas.event_generate('', x=0, y=0, delta=delta) + yield 0 + self.assertIsNone(text.dlineinfo(text.index(f'{last_lineno}.0'))) + + # Scroll back down using the event. + sidebar.canvas.event_generate('', x=0, y=0) + yield 0 + self.assertIsNotNone(text.dlineinfo(text.index(f'{last_lineno}.0'))) + + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_squeezer.py b/Lib/idlelib/idle_test/test_squeezer.py index e3912f4bbbec89..697a7e3d6813b7 100644 --- a/Lib/idlelib/idle_test/test_squeezer.py +++ b/Lib/idlelib/idle_test/test_squeezer.py @@ -7,13 +7,12 @@ from test.support import requires from idlelib.config import idleConf +from idlelib.percolator import Percolator from idlelib.squeezer import count_lines_with_wrapping, ExpandingButton, \ Squeezer from idlelib import macosx from idlelib.textview import view_text from idlelib.tooltip import Hovertip -from idlelib.pyshell import PyShell - SENTINEL_VALUE = sentinel.SENTINEL_VALUE @@ -205,8 +204,8 @@ def test_auto_squeeze(self): self.assertEqual(text_widget.get('1.0', 'end'), '\n') self.assertEqual(len(squeezer.expandingbuttons), 1) - def test_squeeze_current_text_event(self): - """Test the squeeze_current_text event.""" + def test_squeeze_current_text(self): + """Test the squeeze_current_text method.""" # Squeezing text should work for both stdout and stderr. for tag_name in ["stdout", "stderr"]: editwin = self.make_mock_editor_window(with_text_widget=True) @@ -222,7 +221,7 @@ def test_squeeze_current_text_event(self): self.assertEqual(len(squeezer.expandingbuttons), 0) # Test squeezing the current text. - retval = squeezer.squeeze_current_text_event(event=Mock()) + retval = squeezer.squeeze_current_text() self.assertEqual(retval, "break") self.assertEqual(text_widget.get('1.0', 'end'), '\n\n') self.assertEqual(len(squeezer.expandingbuttons), 1) @@ -230,11 +229,11 @@ def test_squeeze_current_text_event(self): # Test that expanding the squeezed text works and afterwards # the Text widget contains the original text. - squeezer.expandingbuttons[0].expand(event=Mock()) + squeezer.expandingbuttons[0].expand() self.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n') self.assertEqual(len(squeezer.expandingbuttons), 0) - def test_squeeze_current_text_event_no_allowed_tags(self): + def test_squeeze_current_text_no_allowed_tags(self): """Test that the event doesn't squeeze text without a relevant tag.""" editwin = self.make_mock_editor_window(with_text_widget=True) text_widget = editwin.text @@ -249,7 +248,7 @@ def test_squeeze_current_text_event_no_allowed_tags(self): self.assertEqual(len(squeezer.expandingbuttons), 0) # Test squeezing the current text. - retval = squeezer.squeeze_current_text_event(event=Mock()) + retval = squeezer.squeeze_current_text() self.assertEqual(retval, "break") self.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n') self.assertEqual(len(squeezer.expandingbuttons), 0) @@ -264,13 +263,13 @@ def test_squeeze_text_before_existing_squeezed_text(self): # Prepare some text in the Text widget and squeeze it. text_widget.insert("1.0", "SOME\nTEXT\n", "stdout") text_widget.mark_set("insert", "1.0") - squeezer.squeeze_current_text_event(event=Mock()) + squeezer.squeeze_current_text() self.assertEqual(len(squeezer.expandingbuttons), 1) # Test squeezing the current text. text_widget.insert("1.0", "MORE\nSTUFF\n", "stdout") text_widget.mark_set("insert", "1.0") - retval = squeezer.squeeze_current_text_event(event=Mock()) + retval = squeezer.squeeze_current_text() self.assertEqual(retval, "break") self.assertEqual(text_widget.get('1.0', 'end'), '\n\n\n') self.assertEqual(len(squeezer.expandingbuttons), 2) @@ -311,6 +310,7 @@ def make_mock_squeezer(self): root = get_test_tk_root(self) squeezer = Mock() squeezer.editwin.text = Text(root) + squeezer.editwin.per = Percolator(squeezer.editwin.text) # Set default values for the configuration settings. squeezer.auto_squeeze_min_lines = 50 @@ -352,14 +352,9 @@ def test_expand(self): # Insert the button into the text widget # (this is normally done by the Squeezer class). - text_widget = expandingbutton.text + text_widget = squeezer.editwin.text text_widget.window_create("1.0", window=expandingbutton) - # Set base_text to the text widget, so that changes are actually - # made to it (by ExpandingButton) and we can inspect these - # changes afterwards. - expandingbutton.base_text = expandingbutton.text - # trigger the expand event retval = expandingbutton.expand(event=Mock()) self.assertEqual(retval, None) @@ -390,11 +385,6 @@ def test_expand_dangerous_oupput(self): text_widget = expandingbutton.text text_widget.window_create("1.0", window=expandingbutton) - # Set base_text to the text widget, so that changes are actually - # made to it (by ExpandingButton) and we can inspect these - # changes afterwards. - expandingbutton.base_text = expandingbutton.text - # Patch the message box module to always return False. with patch('idlelib.squeezer.tkMessageBox') as mock_msgbox: mock_msgbox.askokcancel.return_value = False diff --git a/Lib/idlelib/percolator.py b/Lib/idlelib/percolator.py index db70304f589159..1fe34d29f54eb2 100644 --- a/Lib/idlelib/percolator.py +++ b/Lib/idlelib/percolator.py @@ -38,6 +38,21 @@ def insertfilter(self, filter): filter.setdelegate(self.top) self.top = filter + def insertfilterafter(self, filter, after): + assert isinstance(filter, Delegator) + assert isinstance(after, Delegator) + assert filter.delegate is None + + f = self.top + f.resetcache() + while f is not after: + assert f is not self.bottom + f = f.delegate + f.resetcache() + + filter.setdelegate(f.delegate) + f.setdelegate(filter) + def removefilter(self, filter): # XXX Perhaps should only support popfilter()? assert isinstance(filter, Delegator) diff --git a/Lib/idlelib/pyshell.py b/Lib/idlelib/pyshell.py index 66ae0f7435daba..512ea70424cab7 100755 --- a/Lib/idlelib/pyshell.py +++ b/Lib/idlelib/pyshell.py @@ -48,11 +48,13 @@ from idlelib.colorizer import ColorDelegator from idlelib.config import idleConf +from idlelib.delegator import Delegator from idlelib import debugger from idlelib import debugger_r from idlelib.editor import EditorWindow, fixwordbreaks from idlelib.filelist import FileList from idlelib.outwin import OutputWindow +from idlelib import replace from idlelib import rpc from idlelib.run import idle_formatwarning, StdInputFile, StdOutputFile from idlelib.undo import UndoDelegator @@ -335,34 +337,19 @@ def open_shell(self, event=None): class ModifiedColorDelegator(ColorDelegator): "Extend base class: colorizer for the shell window itself" - - def __init__(self): - ColorDelegator.__init__(self) - self.LoadTagDefs() - def recolorize_main(self): self.tag_remove("TODO", "1.0", "iomark") self.tag_add("SYNC", "1.0", "iomark") ColorDelegator.recolorize_main(self) - def LoadTagDefs(self): - ColorDelegator.LoadTagDefs(self) - theme = idleConf.CurrentTheme() - self.tagdefs.update({ - "stdin": {'background':None,'foreground':None}, - "stdout": idleConf.GetHighlight(theme, "stdout"), - "stderr": idleConf.GetHighlight(theme, "stderr"), - "console": idleConf.GetHighlight(theme, "console"), - }) - def removecolors(self): # Don't remove shell color tags before "iomark" for tag in self.tagdefs: self.tag_remove(tag, "iomark", "end") + class ModifiedUndoDelegator(UndoDelegator): "Extend base class: forbid insert/delete before the I/O mark" - def insert(self, index, chars, tags=None): try: if self.delegate.compare(index, "<", "iomark"): @@ -381,6 +368,27 @@ def delete(self, index1, index2=None): pass UndoDelegator.delete(self, index1, index2) + def undo_event(self, event): + # Temporarily monkey-patch the delegate's .insert() method to + # always use the "stdin" tag. This is needed for undo-ing + # deletions to preserve the "stdin" tag, because UndoDelegator + # doesn't preserve tags for deleted text. + orig_insert = self.delegate.insert + self.delegate.insert = \ + lambda index, chars: orig_insert(index, chars, "stdin") + try: + super().undo_event(event) + finally: + self.delegate.insert = orig_insert + + +class UserInputTaggingDelegator(Delegator): + """Delegator used to tag user input with "stdin".""" + def insert(self, index, chars, tags=None): + if tags is None: + tags = "stdin" + self.delegate.insert(index, chars, tags) + class MyRPCClient(rpc.RPCClient): @@ -832,6 +840,7 @@ def display_executing_dialog(self): class PyShell(OutputWindow): + from idlelib.squeezer import Squeezer shell_title = "Python " + python_version() + " Shell" @@ -855,9 +864,11 @@ class PyShell(OutputWindow): ] allow_line_numbers = False + user_input_insert_tags = "stdin" # New classes from idlelib.history import History + from idlelib.sidebar import ShellSidebar def __init__(self, flist=None): if use_subprocess: @@ -871,6 +882,8 @@ def __init__(self, flist=None): root.withdraw() flist = PyShellFileList(root) + self.shell_sidebar = None # initialized below + OutputWindow.__init__(self, flist, None, None) self.usetabs = True @@ -893,9 +906,9 @@ def __init__(self, flist=None): if use_subprocess: text.bind("<>", self.view_restart_mark) text.bind("<>", self.restart_shell) - squeezer = self.Squeezer(self) + self.squeezer = self.Squeezer(self) text.bind("<>", - squeezer.squeeze_current_text_event) + self.squeeze_current_text_event) self.save_stdout = sys.stdout self.save_stderr = sys.stderr @@ -926,6 +939,41 @@ def __init__(self, flist=None): # self.pollinterval = 50 # millisec + self.shell_sidebar = self.ShellSidebar(self) + + # Insert UserInputTaggingDelegator at the top of the percolator, + # but make calls to text.insert() skip it. This causes only insert + # events generated in Tcl/Tk to go through this delegator. + self.text.insert = self.per.top.insert + self.per.insertfilter(UserInputTaggingDelegator()) + + def ResetFont(self): + super().ResetFont() + # Update the sidebar widget, since its width affects + # the width of the text widget. + self.shell_sidebar.update_font() + + def update_colors(self): + super().update_colors() + + theme = idleConf.CurrentTheme() + tag_colors = { + "stdin": {'background': None, 'foreground': None}, + "stdout": idleConf.GetHighlight(theme, "stdout"), + "stderr": idleConf.GetHighlight(theme, "stderr"), + "console": idleConf.GetHighlight(theme, "console"), + } + for tag, tag_colors_config in tag_colors.items(): + self.text.tag_configure(tag, **tag_colors_config) + + # During __init__, update_colors() is called before the sidebar is created. + if self.shell_sidebar is not None: + self.shell_sidebar.update_colors() + + def replace_event(self, event): + replace.replace(self.text, insert_tags="stdin") + return "break" + def get_standard_extension_names(self): return idleConf.GetExtensions(shell_only=True) @@ -1160,13 +1208,30 @@ def enter_callback(self, event): # the current line, less a leading prompt, less leading or # trailing whitespace if self.text.compare("insert", "<", "iomark linestart"): - # Check if there's a relevant stdin range -- if so, use it + # Check if there's a relevant stdin range -- if so, use it. + # Note: "stdin" blocks may include several successive statements, + # so look for "console" tags on the newline before each statement + # (and possibly on prompts). prev = self.text.tag_prevrange("stdin", "insert") - if prev and self.text.compare("insert", "<", prev[1]): + if ( + prev and + self.text.compare("insert", "<", prev[1]) and + # The following is needed to handle empty statements. + "console" not in self.text.tag_names("insert") + ): + prev_cons = self.text.tag_prevrange("console", "insert") + if prev_cons and self.text.compare(prev_cons[1], ">=", prev[0]): + prev = (prev_cons[1], prev[1]) + next_cons = self.text.tag_nextrange("console", "insert") + if next_cons and self.text.compare(next_cons[0], "<", prev[1]): + prev = (prev[0], self.text.index(next_cons[0] + "+1c")) self.recall(self.text.get(prev[0], prev[1]), event) return "break" next = self.text.tag_nextrange("stdin", "insert") if next and self.text.compare("insert lineend", ">=", next[0]): + next_cons = self.text.tag_nextrange("console", "insert lineend") + if next_cons and self.text.compare(next_cons[0], "<", next[1]): + next = (next[0], self.text.index(next_cons[0] + "+1c")) self.recall(self.text.get(next[0], next[1]), event) return "break" # No stdin mark -- just get the current line, less any prompt @@ -1198,7 +1263,6 @@ def enter_callback(self, event): self.text.see("insert") else: self.newline_and_indent_event(event) - self.text.tag_add("stdin", "iomark", "end-1c") self.text.update_idletasks() if self.reading: self.top.quit() # Break out of recursive mainloop() @@ -1208,7 +1272,7 @@ def enter_callback(self, event): def recall(self, s, event): # remove leading and trailing empty or whitespace lines - s = re.sub(r'^\s*\n', '' , s) + s = re.sub(r'^\s*\n', '', s) s = re.sub(r'\n\s*$', '', s) lines = s.split('\n') self.text.undo_block_start() @@ -1219,7 +1283,7 @@ def recall(self, s, event): if prefix.rstrip().endswith(':'): self.newline_and_indent_event(event) prefix = self.text.get("insert linestart", "insert") - self.text.insert("insert", lines[0].strip()) + self.text.insert("insert", lines[0].strip(), self.user_input_insert_tags) if len(lines) > 1: orig_base_indent = re.search(r'^([ \t]*)', lines[0]).group(0) new_base_indent = re.search(r'^([ \t]*)', prefix).group(0) @@ -1227,24 +1291,21 @@ def recall(self, s, event): if line.startswith(orig_base_indent): # replace orig base indentation with new indentation line = new_base_indent + line[len(orig_base_indent):] - self.text.insert('insert', '\n'+line.rstrip()) + self.text.insert('insert', '\n'+line.rstrip(), self.user_input_insert_tags) finally: self.text.see("insert") self.text.undo_block_stop() def runit(self): + index_before = self.text.index("end-2c") line = self.text.get("iomark", "end-1c") # Strip off last newline and surrounding whitespace. # (To allow you to hit return twice to end a statement.) - i = len(line) - while i > 0 and line[i-1] in " \t": - i = i-1 - if i > 0 and line[i-1] == "\n": - i = i-1 - while i > 0 and line[i-1] in " \t": - i = i-1 - line = line[:i] - self.interp.runsource(line) + line = re.sub(r"[ \t]*\n?[ \t]*$", "", line) + input_is_incomplete = self.interp.runsource(line) + if not input_is_incomplete: + if self.user_input_insert_tags: + self.text.tag_remove(self.user_input_insert_tags, index_before) def open_stack_viewer(self, event=None): if self.interp.rpcclt: @@ -1270,7 +1331,14 @@ def restart_shell(self, event=None): def showprompt(self): self.resetoutput() - self.console.write(self.prompt) + + prompt = self.prompt + if self.sys_ps1 and prompt.endswith(self.sys_ps1): + prompt = prompt[:-len(self.sys_ps1)] + self.text.tag_add("console", "iomark-1c") + self.console.write(prompt) + + self.shell_sidebar.update_sidebar() self.text.mark_set("insert", "end-1c") self.set_line_and_column() self.io.reset_undo() @@ -1320,6 +1388,13 @@ def rmenu_check_paste(self): return 'disabled' return super().rmenu_check_paste() + def squeeze_current_text_event(self, event=None): + self.squeezer.squeeze_current_text() + self.shell_sidebar.update_sidebar() + + def on_squeezed_expand(self, index, text, tags): + self.shell_sidebar.update_sidebar() + def fix_x11_paste(root): "Make paste replace selection on x11. See issue #5124." diff --git a/Lib/idlelib/replace.py b/Lib/idlelib/replace.py index 6be034af9626b3..2f9ca231a05e49 100644 --- a/Lib/idlelib/replace.py +++ b/Lib/idlelib/replace.py @@ -11,7 +11,7 @@ from idlelib import searchengine -def replace(text): +def replace(text, insert_tags=None): """Create or reuse a singleton ReplaceDialog instance. The singleton dialog saves user entries and preferences @@ -25,7 +25,7 @@ def replace(text): if not hasattr(engine, "_replacedialog"): engine._replacedialog = ReplaceDialog(root, engine) dialog = engine._replacedialog - dialog.open(text) + dialog.open(text, insert_tags=insert_tags) class ReplaceDialog(SearchDialogBase): @@ -49,8 +49,9 @@ def __init__(self, root, engine): """ super().__init__(root, engine) self.replvar = StringVar(root) + self.insert_tags = None - def open(self, text): + def open(self, text, insert_tags=None): """Make dialog visible on top of others and ready to use. Also, highlight the currently selected text and set the @@ -72,6 +73,7 @@ def open(self, text): last = last or first self.show_hit(first, last) self.ok = True + self.insert_tags = insert_tags def create_entries(self): "Create base and additional label and text entry widgets." @@ -177,7 +179,7 @@ def replace_all(self, event=None): if first != last: text.delete(first, last) if new: - text.insert(first, new) + text.insert(first, new, self.insert_tags) col = i + len(new) ok = False text.undo_block_stop() @@ -231,7 +233,7 @@ def do_replace(self): if m.group(): text.delete(first, last) if new: - text.insert(first, new) + text.insert(first, new, self.insert_tags) text.undo_block_stop() self.show_hit(first, text.index("insert")) self.ok = False @@ -264,6 +266,7 @@ def close(self, event=None): "Close the dialog and remove hit tags." SearchDialogBase.close(self, event) self.text.tag_remove("hit", "1.0", "end") + self.insert_tags = None def _replace_dialog(parent): # htest # diff --git a/Lib/idlelib/sidebar.py b/Lib/idlelib/sidebar.py index 41c09684a20251..e71001092c6aa2 100644 --- a/Lib/idlelib/sidebar.py +++ b/Lib/idlelib/sidebar.py @@ -1,19 +1,33 @@ """Line numbering implementation for IDLE as an extension. Includes BaseSideBar which can be extended for other sidebar based extensions """ +import contextlib import functools import itertools import tkinter as tk +from tkinter.font import Font from idlelib.config import idleConf from idlelib.delegator import Delegator +def get_lineno(text, index): + """Return the line number of an index in a Tk text widget.""" + return int(float(text.index(index))) + + def get_end_linenumber(text): - """Utility to get the last line's number in a Tk text widget.""" - return int(float(text.index('end-1c'))) + """Return the number of the last line in a Tk text widget.""" + return get_lineno(text, 'end-1c') +def get_displaylines(text, index): + """Display height, in lines, of a logical line in a Tk text widget.""" + res = text.count(f"{index} linestart", + f"{index} lineend", + "displaylines") + return res[0] if res else 0 + def get_widget_padding(widget): """Get the total padding of a Tk widget, including its border.""" # TODO: use also in codecontext.py @@ -40,10 +54,17 @@ def get_widget_padding(widget): return padx, pady +@contextlib.contextmanager +def temp_enable_text_widget(text): + text.configure(state=tk.NORMAL) + try: + yield + finally: + text.configure(state=tk.DISABLED) + + class BaseSideBar: - """ - The base class for extensions which require a sidebar. - """ + """A base class for sidebars using Text.""" def __init__(self, editwin): self.editwin = editwin self.parent = editwin.text_frame @@ -119,14 +140,11 @@ def redirect_mousewheel_event(self, event): class EndLineDelegator(Delegator): - """Generate callbacks with the current end line number after - insert or delete operations""" + """Generate callbacks with the current end line number. + + The provided callback is called after every insert and delete. + """ def __init__(self, changed_callback): - """ - changed_callback - Callable, will be called after insert - or delete operations with the current - end line number. - """ Delegator.__init__(self) self.changed_callback = changed_callback @@ -159,16 +177,8 @@ def __init__(self, editwin): end_line_delegator = EndLineDelegator(self.update_sidebar_text) # Insert the delegator after the undo delegator, so that line numbers # are properly updated after undo and redo actions. - end_line_delegator.setdelegate(self.editwin.undo.delegate) - self.editwin.undo.setdelegate(end_line_delegator) - # Reset the delegator caches of the delegators "above" the - # end line delegator we just inserted. - delegator = self.editwin.per.top - while delegator is not end_line_delegator: - delegator.resetcache() - delegator = delegator.delegate - - self.is_shown = False + self.editwin.per.insertfilterafter(filter=end_line_delegator, + after=self.editwin.undo) def bind_events(self): # Ensure focus is always redirected to the main editor text widget. @@ -297,20 +307,206 @@ def update_sidebar_text(self, end): new_width = cur_width + width_difference self.sidebar_text['width'] = self._sidebar_width_type(new_width) - self.sidebar_text.config(state=tk.NORMAL) - if end > self.prev_end: - new_text = '\n'.join(itertools.chain( - [''], - map(str, range(self.prev_end + 1, end + 1)), - )) - self.sidebar_text.insert(f'end -1c', new_text, 'linenumber') - else: - self.sidebar_text.delete(f'{end+1}.0 -1c', 'end -1c') - self.sidebar_text.config(state=tk.DISABLED) + with temp_enable_text_widget(self.sidebar_text): + if end > self.prev_end: + new_text = '\n'.join(itertools.chain( + [''], + map(str, range(self.prev_end + 1, end + 1)), + )) + self.sidebar_text.insert(f'end -1c', new_text, 'linenumber') + else: + self.sidebar_text.delete(f'{end+1}.0 -1c', 'end -1c') self.prev_end = end +class WrappedLineHeightChangeDelegator(Delegator): + def __init__(self, callback): + """ + callback - Callable, will be called when an insert, delete or replace + action on the text widget may require updating the shell + sidebar. + """ + Delegator.__init__(self) + self.callback = callback + + def insert(self, index, chars, tags=None): + is_single_line = '\n' not in chars + if is_single_line: + before_displaylines = get_displaylines(self, index) + + self.delegate.insert(index, chars, tags) + + if is_single_line: + after_displaylines = get_displaylines(self, index) + if after_displaylines == before_displaylines: + return # no need to update the sidebar + + self.callback() + + def delete(self, index1, index2=None): + if index2 is None: + index2 = index1 + "+1c" + is_single_line = get_lineno(self, index1) == get_lineno(self, index2) + if is_single_line: + before_displaylines = get_displaylines(self, index1) + + self.delegate.delete(index1, index2) + + if is_single_line: + after_displaylines = get_displaylines(self, index1) + if after_displaylines == before_displaylines: + return # no need to update the sidebar + + self.callback() + + +class ShellSidebar: + """Sidebar for the PyShell window, for prompts etc.""" + def __init__(self, editwin): + self.editwin = editwin + self.parent = editwin.text_frame + self.text = editwin.text + + self.canvas = tk.Canvas(self.parent, width=30, + borderwidth=0, highlightthickness=0, + takefocus=False) + + self.bind_events() + + change_delegator = \ + WrappedLineHeightChangeDelegator(self.change_callback) + + # Insert the TextChangeDelegator after the last delegator, so that + # the sidebar reflects final changes to the text widget contents. + d = self.editwin.per.top + if d.delegate is not self.text: + while d.delegate is not self.editwin.per.bottom: + d = d.delegate + self.editwin.per.insertfilterafter(change_delegator, after=d) + + self.text['yscrollcommand'] = self.yscroll_event + + self.is_shown = False + + self.update_font() + self.update_colors() + self.update_sidebar() + self.canvas.grid(row=1, column=0, sticky=tk.NSEW, padx=2, pady=0) + self.is_shown = True + + def change_callback(self): + if self.is_shown: + self.update_sidebar() + + def update_sidebar(self): + text = self.text + text_tagnames = text.tag_names + canvas = self.canvas + + canvas.delete(tk.ALL) + + index = text.index("@0,0") + while True: + lineinfo = text.dlineinfo(index) + if lineinfo is None: + break + y = lineinfo[1] + prev_newline_tagnames = text_tagnames(f"{index} linestart -1c") + prompt = ( + '>>>' if "console" in prev_newline_tagnames else + '...' if "stdin" in prev_newline_tagnames else + ' ' + ) + canvas.create_text(2, y, anchor=tk.NW, text=prompt, + font=self.font, fill=self.colors[0]) + index = text.index(f'{index}+1line') + + def yscroll_event(self, *args, **kwargs): + """Redirect vertical scrolling to the main editor text widget. + + The scroll bar is also updated. + """ + self.editwin.vbar.set(*args) + self.change_callback() + return 'break' + + def update_font(self): + """Update the sidebar text font, usually after config changes.""" + font = idleConf.GetFont(self.text, 'main', 'EditorWindow') + tk_font = Font(self.text, font=font) + char_width = max(tk_font.measure(char) for char in ['>', '.']) + self.canvas.configure(width=char_width * 3 + 4) + self._update_font(font) + + def _update_font(self, font): + self.font = font + self.change_callback() + + def update_colors(self): + """Update the sidebar text colors, usually after config changes.""" + linenumbers_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber') + prompt_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'console') + self._update_colors(foreground=prompt_colors['foreground'], + background=linenumbers_colors['background']) + + def _update_colors(self, foreground, background): + self.colors = (foreground, background) + self.canvas.configure(background=self.colors[1]) + 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('', + 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('', 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('', 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'', + f'', + f'', + ): + 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'', + f'', + ): + bind_mouse_event(event_name, + target_event_name=f'') + + def _linenumbers_drag_scrolling(parent): # htest # from idlelib.idle_test.test_sidebar import Dummy_editwin diff --git a/Lib/idlelib/squeezer.py b/Lib/idlelib/squeezer.py index be1538a25fdedf..6a0b2ed5e91809 100644 --- a/Lib/idlelib/squeezer.py +++ b/Lib/idlelib/squeezer.py @@ -160,8 +160,10 @@ def expand(self, event=None): if not confirm: return "break" - self.base_text.insert(self.text.index(self), self.s, self.tags) + index = self.text.index(self) + self.base_text.insert(index, self.s, self.tags) self.base_text.delete(self) + self.editwin.on_squeezed_expand(index, self.s, self.tags) self.squeezer.expandingbuttons.remove(self) def copy(self, event=None): @@ -285,12 +287,10 @@ def count_lines(self, s): """ return count_lines_with_wrapping(s, self.editwin.width) - def squeeze_current_text_event(self, event): - """squeeze-current-text event handler + def squeeze_current_text(self): + """Squeeze the text block where the insertion cursor is. - Squeeze the block of text inside which contains the "insert" cursor. - - If the insert cursor is not in a squeezable block of text, give the + If the cursor is not in a squeezable block of text, give the user a small warning and do nothing. """ # Set tag_name to the first valid tag found on the "insert" cursor. diff --git a/Misc/NEWS.d/next/IDLE/2019-08-24-23-49-36.bpo-37903.4xjast.rst b/Misc/NEWS.d/next/IDLE/2019-08-24-23-49-36.bpo-37903.4xjast.rst new file mode 100644 index 00000000000000..56b50e2e91e467 --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2019-08-24-23-49-36.bpo-37903.4xjast.rst @@ -0,0 +1 @@ +IDLE's shell now shows prompts in a separate side-bar.