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 <tjreedy@udel.edu>
This commit is contained in:
Tal Einat 2021-04-29 01:27:55 +03:00 committed by GitHub
parent 103d5e420d
commit 15d3861856
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 888 additions and 132 deletions

View File

@ -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):

View File

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

View File

@ -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")

View File

@ -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):

View File

@ -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)

View File

@ -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('<<newline-and-indent>>')
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('<<squeeze-current-text>>')
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('<<interrupt-execution>>')
yield
self.assert_sidebar_lines_end_with(['>>>', '...', '...', None, '>>>'])
# Recall previous via history
text.event_generate('<<history-previous>>')
text.event_generate('<<interrupt-execution>>')
yield
self.assert_sidebar_lines_end_with(['>>>', '...', None, '>>>'])
# Recall previous via recall
text.mark_set('insert', text.index('insert -2l'))
text.event_generate('<<newline-and-indent>>')
yield
text.event_generate('<<undo>>')
yield
self.assert_sidebar_lines_end_with(['>>>'])
text.event_generate('<<redo>>')
yield
self.assert_sidebar_lines_end_with(['>>>', '...'])
text.event_generate('<<newline-and-indent>>')
text.event_generate('<<newline-and-indent>>')
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 <MouseWheel> event.
# The meaning delta is platform-dependant.
delta = -1 if sys.platform == 'darwin' else 120
sidebar.canvas.event_generate('<MouseWheel>', x=0, y=0, delta=delta)
yield
self.assertIsNone(text.dlineinfo(text.index(f'{last_lineno}.0')))
# Scroll back down using the <Button-5> event.
sidebar.canvas.event_generate('<Button-5>', x=0, y=0)
yield
self.assertIsNotNone(text.dlineinfo(text.index(f'{last_lineno}.0')))
if __name__ == '__main__':
unittest.main(verbosity=2)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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("<<view-restart>>", self.view_restart_mark)
text.bind("<<restart-shell>>", self.restart_shell)
squeezer = self.Squeezer(self)
self.squeezer = self.Squeezer(self)
text.bind("<<squeeze-current-text>>",
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."

View File

@ -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 #

View File

@ -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_end_linenumber(text):
"""Utility to get the last line's number in a Tk text widget."""
return int(float(text.index('end-1c')))
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):
"""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('<MouseWheel>',
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('<FocusIn>', 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('<MouseWheel>', 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'<Button-{button}>',
f'<ButtonRelease-{button}>',
f'<B{button}-Motion>',
):
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'<Double-Button-{button}>',
f'<Triple-Button-{button}>',
):
bind_mouse_event(event_name,
target_event_name=f'<Button-{button}>')
def _linenumbers_drag_scrolling(parent): # htest #
from idlelib.idle_test.test_sidebar import Dummy_editwin

View File

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

View File

@ -0,0 +1 @@
IDLE's shell now shows prompts in a separate side-bar.