mirror of https://github.com/python/cpython
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:
parent
103d5e420d
commit
15d3861856
|
@ -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):
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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):
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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 #
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
IDLE's shell now shows prompts in a separate side-bar.
|
Loading…
Reference in New Issue