From 15d386185659683fc044ccaa300aa8cd7d49cc1a Mon Sep 17 00:00:00 2001 From: Tal Einat <532281+taleinat@users.noreply.github.com> Date: Thu, 29 Apr 2021 01:27:55 +0300 Subject: bpo-37903: IDLE: Shell sidebar with prompts (GH-22682) The first followup will change shell indents to spaces. More are expected. Co-authored-by: Terry Jan Reedy --- Lib/idlelib/colorizer.py | 1 - Lib/idlelib/editor.py | 30 +- Lib/idlelib/history.py | 4 +- Lib/idlelib/idle_test/test_editor.py | 8 - Lib/idlelib/idle_test/test_pyshell.py | 84 +++++ Lib/idlelib/idle_test/test_sidebar.py | 350 ++++++++++++++++++++- Lib/idlelib/idle_test/test_squeezer.py | 32 +- Lib/idlelib/idle_test/tkinter_testing_utils.py | 56 ++++ Lib/idlelib/percolator.py | 15 + Lib/idlelib/pyshell.py | 149 +++++++-- Lib/idlelib/replace.py | 13 +- Lib/idlelib/sidebar.py | 263 ++++++++++++++-- Lib/idlelib/squeezer.py | 12 +- .../IDLE/2019-08-24-23-49-36.bpo-37903.4xjast.rst | 1 + 14 files changed, 887 insertions(+), 131 deletions(-) create mode 100644 Lib/idlelib/idle_test/tkinter_testing_utils.py create mode 100644 Misc/NEWS.d/next/IDLE/2019-08-24-23-49-36.bpo-37903.4xjast.rst diff --git a/Lib/idlelib/colorizer.py b/Lib/idlelib/colorizer.py index 0aae177..3c52740 100644 --- a/Lib/idlelib/colorizer.py +++ b/Lib/idlelib/colorizer.py @@ -133,7 +133,6 @@ class ColorDelegator(Delegator): # non-modal alternative. "hit": idleConf.GetHighlight(theme, "hit"), } - if DEBUG: print('tagdefs', self.tagdefs) def insert(self, index, chars, tags=None): diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py index b9cb502..8b54440 100644 --- a/Lib/idlelib/editor.py +++ b/Lib/idlelib/editor.py @@ -60,7 +60,6 @@ class EditorWindow: 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: 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. @@ -784,9 +784,7 @@ class EditorWindow: 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: @@ -1303,8 +1301,6 @@ class EditorWindow: # 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)) @@ -1313,7 +1309,8 @@ class EditorWindow: 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" @@ -1346,7 +1343,7 @@ class EditorWindow: 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: @@ -1377,13 +1374,14 @@ class EditorWindow: 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: @@ -1394,7 +1392,7 @@ class EditorWindow: 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. @@ -1430,7 +1428,7 @@ class EditorWindow: 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 @@ -1444,7 +1442,8 @@ class EditorWindow: # 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: @@ -1455,7 +1454,7 @@ class EditorWindow: # 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(): @@ -1502,7 +1501,8 @@ class EditorWindow: 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 ad44a96..7ce0925 100644 --- a/Lib/idlelib/history.py +++ b/Lib/idlelib/history.py @@ -74,13 +74,13 @@ class History: 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 443dcf0..8665d68 100644 --- a/Lib/idlelib/idle_test/test_editor.py +++ b/Lib/idlelib/idle_test/test_editor.py @@ -167,7 +167,6 @@ class IndentAndNewlineTest(unittest.TestCase): '2.end'), ) - w.prompt_last_line = '' for test in tests: with self.subTest(label=test.label): insert(text, test.text) @@ -182,13 +181,6 @@ class IndentAndNewlineTest(unittest.TestCase): # 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_pyshell.py b/Lib/idlelib/idle_test/test_pyshell.py index 4a09667..7067039 100644 --- a/Lib/idlelib/idle_test/test_pyshell.py +++ b/Lib/idlelib/idle_test/test_pyshell.py @@ -60,5 +60,89 @@ class PyShellFileListTest(unittest.TestCase): ## self.assertIsInstance(ps, pyshell.PyShell) +class PyShellRemoveLastNewlineAndSurroundingWhitespaceTest(unittest.TestCase): + regexp = pyshell.PyShell._last_newline_re + + def all_removed(self, text): + self.assertEqual('', self.regexp.sub('', text)) + + def none_removed(self, text): + self.assertEqual(text, self.regexp.sub('', text)) + + def check_result(self, text, expected): + self.assertEqual(expected, self.regexp.sub('', text)) + + def test_empty(self): + self.all_removed('') + + def test_newline(self): + self.all_removed('\n') + + def test_whitespace_no_newline(self): + self.all_removed(' ') + self.all_removed(' ') + self.all_removed(' ') + self.all_removed(' ' * 20) + self.all_removed('\t') + self.all_removed('\t\t') + self.all_removed('\t\t\t') + self.all_removed('\t' * 20) + self.all_removed('\t ') + self.all_removed(' \t') + self.all_removed(' \t \t ') + self.all_removed('\t \t \t') + + def test_newline_with_whitespace(self): + self.all_removed(' \n') + self.all_removed('\t\n') + self.all_removed(' \t\n') + self.all_removed('\t \n') + self.all_removed('\n ') + self.all_removed('\n\t') + self.all_removed('\n \t') + self.all_removed('\n\t ') + self.all_removed(' \n ') + self.all_removed('\t\n ') + self.all_removed(' \n\t') + self.all_removed('\t\n\t') + self.all_removed('\t \t \t\n') + self.all_removed(' \t \t \n') + self.all_removed('\n\t \t \t') + self.all_removed('\n \t \t ') + + def test_multiple_newlines(self): + self.check_result('\n\n', '\n') + self.check_result('\n' * 5, '\n' * 4) + self.check_result('\n' * 5 + '\t', '\n' * 4) + self.check_result('\n' * 20, '\n' * 19) + self.check_result('\n' * 20 + ' ', '\n' * 19) + self.check_result(' \n \n ', ' \n') + self.check_result(' \n\n ', ' \n') + self.check_result(' \n\n', ' \n') + self.check_result('\t\n\n', '\t\n') + self.check_result('\n\n ', '\n') + self.check_result('\n\n\t', '\n') + self.check_result(' \n \n ', ' \n') + self.check_result('\t\n\t\n\t', '\t\n') + + def test_non_whitespace(self): + self.none_removed('a') + self.check_result('a\n', 'a') + self.check_result('a\n ', 'a') + self.check_result('a \n ', 'a') + self.check_result('a \n\t', 'a') + self.none_removed('-') + self.check_result('-\n', '-') + self.none_removed('.') + self.check_result('.\n', '.') + + def test_unsupported_whitespace(self): + self.none_removed('\v') + self.none_removed('\n\v') + self.check_result('\v\n', '\v') + self.none_removed(' \n\v') + self.check_result('\v\n ', '\v') + + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_sidebar.py b/Lib/idlelib/idle_test/test_sidebar.py index 2974a9a..7228d0e 100644 --- a/Lib/idlelib/idle_test/test_sidebar.py +++ b/Lib/idlelib/idle_test/test_sidebar.py @@ -1,13 +1,23 @@ -"""Test sidebar, coverage 93%""" -import idlelib.sidebar +"""Test sidebar, coverage 85%""" +from textwrap import dedent +import sys + from itertools import chain import unittest import unittest.mock -from test.support import requires +from test.support import requires, swap_attr import tkinter as tk +from .tkinter_testing_utils import run_in_tk_mainloop 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: @@ -31,6 +41,7 @@ class LineNumbersTest(unittest.TestCase): def setUpClass(cls): requires('gui') cls.root = tk.Tk() + cls.root.withdraw() cls.text_frame = tk.Frame(cls.root) cls.text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) @@ -154,7 +165,7 @@ class LineNumbersTest(unittest.TestCase): 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 +176,7 @@ class LineNumbersTest(unittest.TestCase): 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,11 +245,19 @@ class LineNumbersTest(unittest.TestCase): 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) + # The following tests are temporarily disabled due to relying on + # simulated user input and inspecting which text is selected, which + # are fragile and can fail when several GUI tests are run in parallel + # or when the windows created by the test lose focus. + # + # TODO: Re-work these tests or remove them from the test suite. + + @unittest.skip('test disabled') def test_click_selection(self): self.linenumber.show_sidebar() self.text.insert('1.0', 'one\ntwo\nthree\nfour\n') @@ -252,6 +271,7 @@ class LineNumbersTest(unittest.TestCase): self.assertEqual(self.get_selection(), ('2.0', '3.0')) + @unittest.skip('test disabled') def simulate_drag(self, start_line, end_line): start_x, start_y = self.get_line_screen_position(start_line) end_x, end_y = self.get_line_screen_position(end_line) @@ -277,6 +297,7 @@ class LineNumbersTest(unittest.TestCase): x=end_x, y=end_y) self.root.update() + @unittest.skip('test disabled') def test_drag_selection_down(self): self.linenumber.show_sidebar() self.text.insert('1.0', 'one\ntwo\nthree\nfour\nfive\n') @@ -286,6 +307,7 @@ class LineNumbersTest(unittest.TestCase): self.simulate_drag(2, 4) self.assertEqual(self.get_selection(), ('2.0', '5.0')) + @unittest.skip('test disabled') def test_drag_selection_up(self): self.linenumber.show_sidebar() self.text.insert('1.0', 'one\ntwo\nthree\nfour\nfive\n') @@ -353,7 +375,7 @@ class LineNumbersTest(unittest.TestCase): 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 +392,319 @@ class LineNumbersTest(unittest.TestCase): assert_colors_are_equal(orig_colors) +class ShellSidebarTest(unittest.TestCase): + root: tk.Tk = None + shell: PyShell = None + + @classmethod + def setUpClass(cls): + requires('gui') + + 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() + + cls.init_shell() + + @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): + # In some test environments, e.g. Azure Pipelines (as of + # Apr. 2021), sys.stdout is changed between tests. However, + # PyShell relies on overriding sys.stdout when run without a + # sub-process (as done here; see setUpClass). + self._saved_stdout = None + if sys.stdout != self.shell.stdout: + self._saved_stdout = sys.stdout + sys.stdout = self.shell.stdout + + self.reset_shell() + + def tearDown(self): + if self._saved_stdout is not None: + sys.stdout = self._saved_stdout + + def get_sidebar_lines(self): + canvas = self.shell.shell_sidebar.canvas + texts = list(canvas.find(tk.ALL)) + texts_by_y_coords = { + canvas.bbox(text)[1]: canvas.itemcget(text, 'text') + for text in texts + } + line_y_coords = self.get_shell_line_y_coords() + return [texts_by_y_coords.get(y, None) for y in line_y_coords] + + 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") + if index.split('.', 1)[1] != '0': + index = text.index(f"{index} +1line linestart") + 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.assertLessEqual( + set(self.get_sidebar_line_y_coords()), + set(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.insert('insert', line, 'stdin') + + def test_initial_state(self): + sidebar_lines = self.get_sidebar_lines() + self.assertEqual( + sidebar_lines, + [None] * (len(sidebar_lines) - 1) + ['>>>'], + ) + self.assert_sidebar_lines_synced() + + @run_in_tk_mainloop + def test_single_empty_input(self): + self.do_input('\n') + yield + self.assert_sidebar_lines_end_with(['>>>', '>>>']) + + @run_in_tk_mainloop + def test_single_line_statement(self): + self.do_input('1\n') + yield + self.assert_sidebar_lines_end_with(['>>>', None, '>>>']) + + @run_in_tk_mainloop + def test_multi_line_statement(self): + # Block statements are not indented because IDLE auto-indents. + self.do_input(dedent('''\ + if True: + print(1) + + ''')) + yield + self.assert_sidebar_lines_end_with([ + '>>>', + '...', + '...', + '...', + None, + '>>>', + ]) + + @run_in_tk_mainloop + def test_single_long_line_wraps(self): + self.do_input('1' * 200 + '\n') + yield + self.assert_sidebar_lines_end_with(['>>>', None, '>>>']) + self.assert_sidebar_lines_synced() + + @run_in_tk_mainloop + def test_squeeze_multi_line_output(self): + shell = self.shell + text = shell.text + + self.do_input('print("a\\nb\\nc")\n') + yield + self.assert_sidebar_lines_end_with(['>>>', None, None, None, '>>>']) + + text.mark_set('insert', f'insert -1line linestart') + text.event_generate('<>') + yield + self.assert_sidebar_lines_end_with(['>>>', None, '>>>']) + self.assert_sidebar_lines_synced() + + shell.squeezer.expandingbuttons[0].expand() + yield + self.assert_sidebar_lines_end_with(['>>>', None, None, None, '>>>']) + self.assert_sidebar_lines_synced() + + @run_in_tk_mainloop + def test_interrupt_recall_undo_redo(self): + text = self.shell.text + # 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 + 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 + text.event_generate('<>') + yield + self.assert_sidebar_lines_end_with(['>>>', '...', '...', None, '>>>']) + + # Recall previous via history + text.event_generate('<>') + text.event_generate('<>') + yield + self.assert_sidebar_lines_end_with(['>>>', '...', None, '>>>']) + + # Recall previous via recall + text.mark_set('insert', text.index('insert -2l')) + text.event_generate('<>') + yield + + text.event_generate('<>') + yield + self.assert_sidebar_lines_end_with(['>>>']) + + text.event_generate('<>') + yield + self.assert_sidebar_lines_end_with(['>>>', '...']) + + text.event_generate('<>') + text.event_generate('<>') + yield + self.assert_sidebar_lines_end_with( + ['>>>', '...', '...', '...', None, '>>>'] + ) + + @run_in_tk_mainloop + def test_very_long_wrapped_line(self): + with swap_attr(self.shell, 'squeezer', None): + self.do_input('x = ' + '1'*10_000 + '\n') + yield + self.assertEqual(self.get_sidebar_lines(), ['>>>']) + + 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) + + @run_in_tk_mainloop + def test_mousewheel(self): + sidebar = self.shell.shell_sidebar + text = self.shell.text + + # Enter a 100-line string to scroll the shell screen down. + self.do_input('x = """' + '\n'*100 + '"""\n') + yield + 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. + # The meaning delta is platform-dependant. + delta = -1 if sys.platform == 'darwin' else 120 + sidebar.canvas.event_generate('', x=0, y=0, delta=delta) + yield + 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 + 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 ee1bbd7..eaf81a5 100644 --- a/Lib/idlelib/idle_test/test_squeezer.py +++ b/Lib/idlelib/idle_test/test_squeezer.py @@ -7,13 +7,12 @@ from unittest.mock import Mock, NonCallableMagicMock, patch, sentinel, ANY 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 @@ class SqueezerTest(unittest.TestCase): 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 @@ class SqueezerTest(unittest.TestCase): 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 @@ class SqueezerTest(unittest.TestCase): # 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 @@ class SqueezerTest(unittest.TestCase): 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 @@ class SqueezerTest(unittest.TestCase): # 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 @@ class ExpandingButtonTest(unittest.TestCase): 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 @@ class ExpandingButtonTest(unittest.TestCase): # 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 @@ class ExpandingButtonTest(unittest.TestCase): 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.messagebox') as mock_msgbox: mock_msgbox.askokcancel.return_value = False diff --git a/Lib/idlelib/idle_test/tkinter_testing_utils.py b/Lib/idlelib/idle_test/tkinter_testing_utils.py new file mode 100644 index 0000000..a9f8386 --- /dev/null +++ b/Lib/idlelib/idle_test/tkinter_testing_utils.py @@ -0,0 +1,56 @@ +"""Utilities for testing with Tkinter""" +import functools + + +def run_in_tk_mainloop(test_method): + """Decorator for running a test method with a real Tk mainloop. + + This starts a Tk mainloop before running the test, and stops it + at the end. This is faster and more robust than the common + alternative method of calling .update() and/or .update_idletasks(). + + Test methods using this must be written as generator functions, + using "yield" to allow the mainloop to process events and "after" + callbacks, and then continue the test from that point. + + This also assumes that the test class has a .root attribute, + which is a tkinter.Tk object. + + For example (from test_sidebar.py): + + @run_test_with_tk_mainloop + def test_single_empty_input(self): + self.do_input('\n') + yield + self.assert_sidebar_lines_end_with(['>>>', '>>>']) + """ + @functools.wraps(test_method) + def new_test_method(self): + test_generator = test_method(self) + 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: + next(test_generator) + except StopIteration: + root.quit() + except Exception as exc: + exception = exc + root.quit() + else: + # Schedule the Tk mainloop to call this function again, + # using a robust method of ensuring that it gets a + # chance to process queued events before doing so. + # See: https://stackoverflow.com/q/18499082#comment65004099_38817470 + root.after(1, root.after_idle, after_callback) + root.after(0, root.after_idle, after_callback) + root.mainloop() + + if exception: + raise exception + + return new_test_method diff --git a/Lib/idlelib/percolator.py b/Lib/idlelib/percolator.py index db70304..1fe34d2 100644 --- a/Lib/idlelib/percolator.py +++ b/Lib/idlelib/percolator.py @@ -38,6 +38,21 @@ class Percolator: 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 0ee2254..5830b7a 100755 --- a/Lib/idlelib/pyshell.py +++ b/Lib/idlelib/pyshell.py @@ -48,15 +48,20 @@ import warnings 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 +# Default for testing; defaults to True in main() for running. +use_subprocess = False + HOST = '127.0.0.1' # python execution server on localhost loopback PORT = 0 # someday pass in host, port for remote debug capability @@ -335,34 +340,19 @@ class PyShellFileList(FileList): 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 +371,27 @@ class ModifiedUndoDelegator(UndoDelegator): 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 +843,7 @@ class ModifiedInterpreter(InteractiveInterpreter): class PyShell(OutputWindow): + from idlelib.squeezer import Squeezer shell_title = "IDLE Shell " + python_version() @@ -855,9 +867,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 +885,8 @@ class PyShell(OutputWindow): root.withdraw() flist = PyShellFileList(root) + self.shell_sidebar = None # initialized below + OutputWindow.__init__(self, flist, None, None) self.usetabs = True @@ -893,9 +909,9 @@ class PyShell(OutputWindow): 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 +942,40 @@ class PyShell(OutputWindow): # 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() + + if self.shell_sidebar is not None: + self.shell_sidebar.update_font() + + def ResetColorizer(self): + super().ResetColorizer() + + theme = idleConf.CurrentTheme() + tag_colors = { + "stdin": {'background': None, 'foreground': None}, + "stdout": idleConf.GetHighlight(theme, "stdout"), + "stderr": idleConf.GetHighlight(theme, "stderr"), + "console": idleConf.GetHighlight(theme, "normal"), + } + for tag, tag_colors_config in tag_colors.items(): + self.text.tag_configure(tag, **tag_colors_config) + + 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) @@ -1166,13 +1216,30 @@ class PyShell(OutputWindow): # 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 @@ -1204,7 +1271,6 @@ class PyShell(OutputWindow): 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() @@ -1214,7 +1280,7 @@ class PyShell(OutputWindow): 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() @@ -1225,7 +1291,8 @@ class PyShell(OutputWindow): 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) @@ -1233,24 +1300,24 @@ class PyShell(OutputWindow): 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() + _last_newline_re = re.compile(r"[ \t]*(\n[ \t]*)?\Z") 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 = self._last_newline_re.sub("", line) + input_is_complete = self.interp.runsource(line) + if not input_is_complete: + if self.text.get(index_before) == '\n': + self.text.tag_remove(self.user_input_insert_tags, index_before) + self.shell_sidebar.update_sidebar() def open_stack_viewer(self, event=None): if self.interp.rpcclt: @@ -1276,7 +1343,14 @@ class PyShell(OutputWindow): 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() @@ -1326,6 +1400,13 @@ class PyShell(OutputWindow): 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 6be034a..2f9ca23 100644 --- a/Lib/idlelib/replace.py +++ b/Lib/idlelib/replace.py @@ -11,7 +11,7 @@ from idlelib.searchbase import SearchDialogBase 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 @@ class ReplaceDialog(SearchDialogBase): """ 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 @@ class ReplaceDialog(SearchDialogBase): 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 @@ class ReplaceDialog(SearchDialogBase): 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 @@ class ReplaceDialog(SearchDialogBase): 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 @@ class ReplaceDialog(SearchDialogBase): "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 41c0968..a947961 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 @@ class BaseSideBar: 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 @@ class LineNumbers(BaseSideBar): 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,209 @@ class LineNumbers(BaseSideBar): 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") + if index.split('.', 1)[1] != '0': + index = text.index(f'{index}+1line linestart') + 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 + None + ) + if prompt: + 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 3046d80..929c3fd 100644 --- a/Lib/idlelib/squeezer.py +++ b/Lib/idlelib/squeezer.py @@ -160,8 +160,10 @@ class ExpandingButton(tk.Button): 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 @@ class Squeezer: """ 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 0000000..56b50e2 --- /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. -- cgit v0.12