font-bold= 0
encoding= none
+[PyShell]
+auto-squeeze-min-lines= 50
+
[Indent]
use-spaces= 1
num-spaces= 4
from idlelib.codecontext import CodeContext
from idlelib.parenmatch import ParenMatch
from idlelib.paragraph import FormatParagraph
+from idlelib.squeezer import Squeezer
changes = ConfigChanges()
# Reload changed options in the following classes.
-reloadables = (AutoComplete, CodeContext, ParenMatch, FormatParagraph)
+reloadables = (AutoComplete, CodeContext, ParenMatch, FormatParagraph,
+ Squeezer)
class ConfigDialog(Toplevel):
self.customlist.SetMenu(item_list, item_list[0])
# Revert to default key set.
self.keyset_source.set(idleConf.defaultCfg['main']
- .Get('Keys', 'default'))
+ .Get('Keys', 'default'))
self.builtin_name.set(idleConf.defaultCfg['main'].Get('Keys', 'name')
- or idleConf.default_keys())
+ or idleConf.default_keys())
# User can't back out of these changes, they must be applied now.
changes.save_all()
self.cd.save_all_changed_extensions()
frame_context: Frame
context_title: Label
(*)context_int: Entry - context_lines
+ frame_shell: LabelFrame
+ frame_auto_squeeze_min_lines: Frame
+ auto_squeeze_min_lines_title: Label
+ (*)auto_squeeze_min_lines_int: Entry - auto_squeeze_min_lines
frame_help: LabelFrame
frame_helplist: Frame
frame_helplist_buttons: Frame
self.paren_bell = tracers.add(
BooleanVar(self), ('extensions', 'ParenMatch', 'bell'))
+ self.auto_squeeze_min_lines = tracers.add(
+ StringVar(self), ('main', 'PyShell', 'auto-squeeze-min-lines'))
+
self.autosave = tracers.add(
IntVar(self), ('main', 'General', 'autosave'))
self.format_width = tracers.add(
text=' Window Preferences')
frame_editor = LabelFrame(self, borderwidth=2, relief=GROOVE,
text=' Editor Preferences')
+ frame_shell = LabelFrame(self, borderwidth=2, relief=GROOVE,
+ text=' Shell Preferences')
frame_help = LabelFrame(self, borderwidth=2, relief=GROOVE,
- text=' Additional Help Sources ')
+ text=' Additional Help Sources ')
# Frame_window.
frame_run = Frame(frame_window, borderwidth=0)
startup_title = Label(frame_run, text='At Startup')
self.context_int = Entry(
frame_context, textvariable=self.context_lines, width=3)
+ # Frame_shell.
+ frame_auto_squeeze_min_lines = Frame(frame_shell, borderwidth=0)
+ auto_squeeze_min_lines_title = Label(frame_auto_squeeze_min_lines,
+ text='Auto-Squeeze Min. Lines:')
+ self.auto_squeeze_min_lines_int = Entry(
+ frame_auto_squeeze_min_lines, width=4,
+ textvariable=self.auto_squeeze_min_lines)
# frame_help.
frame_helplist = Frame(frame_help)
# Body.
frame_window.pack(side=TOP, padx=5, pady=5, expand=TRUE, fill=BOTH)
frame_editor.pack(side=TOP, padx=5, pady=5, expand=TRUE, fill=BOTH)
+ frame_shell.pack(side=TOP, padx=5, pady=5, expand=TRUE, fill=BOTH)
frame_help.pack(side=TOP, padx=5, pady=5, expand=TRUE, fill=BOTH)
# frame_run.
frame_run.pack(side=TOP, padx=5, pady=0, fill=X)
context_title.pack(side=LEFT, anchor=W, padx=5, pady=5)
self.context_int.pack(side=TOP, padx=5, pady=5)
+ # frame_auto_squeeze_min_lines
+ frame_auto_squeeze_min_lines.pack(side=TOP, padx=5, pady=0, fill=X)
+ auto_squeeze_min_lines_title.pack(side=LEFT, anchor=W, padx=5, pady=5)
+ self.auto_squeeze_min_lines_int.pack(side=TOP, padx=5, pady=5)
+
# frame_help.
frame_helplist_buttons.pack(side=RIGHT, padx=5, pady=5, fill=Y)
frame_helplist.pack(side=TOP, padx=5, pady=5, expand=TRUE, fill=BOTH)
self.context_lines.set(idleConf.GetOption(
'extensions', 'CodeContext', 'maxlines', type='int'))
+ # Set variables for shell windows.
+ self.auto_squeeze_min_lines.set(idleConf.GetOption(
+ 'main', 'PyShell', 'auto-squeeze-min-lines', type='int'))
+
# Set additional help sources.
self.user_helplist = idleConf.GetAllExtraHelpSourcesList()
self.helplist.delete(0, 'end')
CodeContext: Maxlines is the maximum number of code context lines to
display when Code Context is turned on for an editor window.
+
+Shell Preferences: Auto-Squeeze Min. Lines is the minimum number of lines
+of output to automatically "squeeze".
'''
}
import importlib.util
import os
import platform
-import re
import string
-import sys
import tokenize
import traceback
import webbrowser
from idlelib.undo import UndoDelegator
from idlelib.iomenu import IOBinding, encoding
from idlelib import mainmenu
- from tkinter import Toplevel, EventType
from idlelib.statusbar import MultiStatusBar
from idlelib.autocomplete import AutoComplete
from idlelib.autoexpand import AutoExpand
from idlelib.paragraph import FormatParagraph
from idlelib.parenmatch import ParenMatch
from idlelib.rstrip import Rstrip
+ from idlelib.squeezer import Squeezer
from idlelib.zoomheight import ZoomHeight
filesystemencoding = sys.getfilesystemencoding() # for file names
text.bind("<<zoom-height>>", self.ZoomHeight(self).zoom_height_event)
text.bind("<<toggle-code-context>>",
self.CodeContext(self).toggle_code_context_event)
+ squeezer = self.Squeezer(self)
+ text.bind("<<squeeze-current-text>>",
+ squeezer.squeeze_current_text_event)
def _filename_to_unicode(self, filename):
"""Return filename as BMP unicode so diplayable in Tk."""
'msg': "Click the 'Show GrepDialog' button.\n"
"Test the various 'Find-in-files' functions.\n"
"The results should be displayed in a new '*Output*' window.\n"
- "'Right-click'->'Goto file/line' anywhere in the search results "
+ "'Right-click'->'Go to file/line' anywhere in the search results "
"should open that file \nin a new EditorWindow."
}
self.assertCountEqual(
conf.GetSectionList('default', 'main'),
- ['General', 'EditorWindow', 'Indent', 'Theme',
+ ['General', 'EditorWindow', 'PyShell', 'Indent', 'Theme',
'Keys', 'History', 'HelpFiles'])
self.assertCountEqual(
conf.GetSectionList('user', 'main'),
- ['General', 'EditorWindow', 'Indent', 'Theme',
+ ['General', 'EditorWindow', 'PyShell', 'Indent', 'Theme',
'Keys', 'History', 'HelpFiles'])
with self.assertRaises(config.InvalidConfigSet):
self.assertCountEqual(
conf.RemoveKeyBindNames(conf.GetSectionList('default', 'extensions')),
- ['AutoComplete', 'CodeContext', 'FormatParagraph', 'ParenMatch','ZzDummy'])
+ ['AutoComplete', 'CodeContext', 'FormatParagraph', 'ParenMatch', 'ZzDummy'])
def test_get_extn_name_for_event(self):
userextn.read_string('''
--- /dev/null
+from collections import namedtuple
+from tkinter import Text, Tk
+import unittest
+from unittest.mock import Mock, NonCallableMagicMock, patch, sentinel, ANY
+from test.support import requires
+
+from idlelib.config import idleConf
+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
+
+
+def get_test_tk_root(test_instance):
+ """Helper for tests: Create a root Tk object."""
+ requires('gui')
+ root = Tk()
+ root.withdraw()
+
+ def cleanup_root():
+ root.update_idletasks()
+ root.destroy()
+ test_instance.addCleanup(cleanup_root)
+
+ return root
+
+
+class CountLinesTest(unittest.TestCase):
+ """Tests for the count_lines_with_wrapping function."""
+ def check(self, expected, text, linewidth, tabwidth):
+ return self.assertEqual(
+ expected,
+ count_lines_with_wrapping(text, linewidth, tabwidth),
+ )
+
+ def test_count_empty(self):
+ """Test with an empty string."""
+ self.assertEqual(count_lines_with_wrapping(""), 0)
+
+ def test_count_begins_with_empty_line(self):
+ """Test with a string which begins with a newline."""
+ self.assertEqual(count_lines_with_wrapping("\ntext"), 2)
+
+ def test_count_ends_with_empty_line(self):
+ """Test with a string which ends with a newline."""
+ self.assertEqual(count_lines_with_wrapping("text\n"), 1)
+
+ def test_count_several_lines(self):
+ """Test with several lines of text."""
+ self.assertEqual(count_lines_with_wrapping("1\n2\n3\n"), 3)
+
+ def test_tab_width(self):
+ """Test with various tab widths and line widths."""
+ self.check(expected=1, text='\t' * 1, linewidth=8, tabwidth=4)
+ self.check(expected=1, text='\t' * 2, linewidth=8, tabwidth=4)
+ self.check(expected=2, text='\t' * 3, linewidth=8, tabwidth=4)
+ self.check(expected=2, text='\t' * 4, linewidth=8, tabwidth=4)
+ self.check(expected=3, text='\t' * 5, linewidth=8, tabwidth=4)
+
+ # test longer lines and various tab widths
+ self.check(expected=4, text='\t' * 10, linewidth=12, tabwidth=4)
+ self.check(expected=10, text='\t' * 10, linewidth=12, tabwidth=8)
+ self.check(expected=2, text='\t' * 4, linewidth=10, tabwidth=3)
+
+ # test tabwidth=1
+ self.check(expected=2, text='\t' * 9, linewidth=5, tabwidth=1)
+ self.check(expected=2, text='\t' * 10, linewidth=5, tabwidth=1)
+ self.check(expected=3, text='\t' * 11, linewidth=5, tabwidth=1)
+
+ # test for off-by-one errors
+ self.check(expected=2, text='\t' * 6, linewidth=12, tabwidth=4)
+ self.check(expected=3, text='\t' * 6, linewidth=11, tabwidth=4)
+ self.check(expected=2, text='\t' * 6, linewidth=13, tabwidth=4)
+
+
+class SqueezerTest(unittest.TestCase):
+ """Tests for the Squeezer class."""
+ def make_mock_editor_window(self):
+ """Create a mock EditorWindow instance."""
+ editwin = NonCallableMagicMock()
+ # isinstance(editwin, PyShell) must be true for Squeezer to enable
+ # auto-squeezing; in practice this will always be true
+ editwin.__class__ = PyShell
+ return editwin
+
+ def make_squeezer_instance(self, editor_window=None):
+ """Create an actual Squeezer instance with a mock EditorWindow."""
+ if editor_window is None:
+ editor_window = self.make_mock_editor_window()
+ return Squeezer(editor_window)
+
+ def test_count_lines(self):
+ """Test Squeezer.count_lines() with various inputs.
+
+ This checks that Squeezer.count_lines() calls the
+ count_lines_with_wrapping() function with the appropriate parameters.
+ """
+ for tabwidth, linewidth in [(4, 80), (1, 79), (8, 80), (3, 120)]:
+ self._test_count_lines_helper(linewidth=linewidth,
+ tabwidth=tabwidth)
+
+ def _prepare_mock_editwin_for_count_lines(self, editwin,
+ linewidth, tabwidth):
+ """Prepare a mock EditorWindow object for Squeezer.count_lines."""
+ CHAR_WIDTH = 10
+ BORDER_WIDTH = 2
+ PADDING_WIDTH = 1
+
+ # Prepare all the required functionality on the mock EditorWindow object
+ # so that the calculations in Squeezer.count_lines() can run.
+ editwin.get_tk_tabwidth.return_value = tabwidth
+ editwin.text.winfo_width.return_value = \
+ linewidth * CHAR_WIDTH + 2 * (BORDER_WIDTH + PADDING_WIDTH)
+ text_opts = {
+ 'border': BORDER_WIDTH,
+ 'padx': PADDING_WIDTH,
+ 'font': None,
+ }
+ editwin.text.cget = lambda opt: text_opts[opt]
+
+ # monkey-path tkinter.font.Font with a mock object, so that
+ # Font.measure('0') returns CHAR_WIDTH
+ mock_font = Mock()
+ def measure(char):
+ if char == '0':
+ return CHAR_WIDTH
+ raise ValueError("measure should only be called on '0'!")
+ mock_font.return_value.measure = measure
+ patcher = patch('idlelib.squeezer.Font', mock_font)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def _test_count_lines_helper(self, linewidth, tabwidth):
+ """Helper for test_count_lines."""
+ editwin = self.make_mock_editor_window()
+ self._prepare_mock_editwin_for_count_lines(editwin, linewidth, tabwidth)
+ squeezer = self.make_squeezer_instance(editwin)
+
+ mock_count_lines = Mock(return_value=SENTINEL_VALUE)
+ text = 'TEXT'
+ with patch('idlelib.squeezer.count_lines_with_wrapping',
+ mock_count_lines):
+ self.assertIs(squeezer.count_lines(text), SENTINEL_VALUE)
+ mock_count_lines.assert_called_with(text, linewidth, tabwidth)
+
+ def test_init(self):
+ """Test the creation of Squeezer instances."""
+ editwin = self.make_mock_editor_window()
+ squeezer = self.make_squeezer_instance(editwin)
+ self.assertIs(squeezer.editwin, editwin)
+ self.assertEqual(squeezer.expandingbuttons, [])
+
+ def test_write_no_tags(self):
+ """Test Squeezer's overriding of the EditorWindow's write() method."""
+ editwin = self.make_mock_editor_window()
+ for text in ['', 'TEXT', 'LONG TEXT' * 1000, 'MANY_LINES\n' * 100]:
+ editwin.write = orig_write = Mock(return_value=SENTINEL_VALUE)
+ squeezer = self.make_squeezer_instance(editwin)
+
+ self.assertEqual(squeezer.editwin.write(text, ()), SENTINEL_VALUE)
+ self.assertEqual(orig_write.call_count, 1)
+ orig_write.assert_called_with(text, ())
+ self.assertEqual(len(squeezer.expandingbuttons), 0)
+
+ def test_write_not_stdout(self):
+ """Test Squeezer's overriding of the EditorWindow's write() method."""
+ for text in ['', 'TEXT', 'LONG TEXT' * 1000, 'MANY_LINES\n' * 100]:
+ editwin = self.make_mock_editor_window()
+ editwin.write.return_value = SENTINEL_VALUE
+ orig_write = editwin.write
+ squeezer = self.make_squeezer_instance(editwin)
+
+ self.assertEqual(squeezer.editwin.write(text, "stderr"),
+ SENTINEL_VALUE)
+ self.assertEqual(orig_write.call_count, 1)
+ orig_write.assert_called_with(text, "stderr")
+ self.assertEqual(len(squeezer.expandingbuttons), 0)
+
+ def test_write_stdout(self):
+ """Test Squeezer's overriding of the EditorWindow's write() method."""
+ editwin = self.make_mock_editor_window()
+ self._prepare_mock_editwin_for_count_lines(editwin,
+ linewidth=80, tabwidth=8)
+
+ for text in ['', 'TEXT']:
+ editwin.write = orig_write = Mock(return_value=SENTINEL_VALUE)
+ squeezer = self.make_squeezer_instance(editwin)
+ squeezer.auto_squeeze_min_lines = 50
+
+ self.assertEqual(squeezer.editwin.write(text, "stdout"),
+ SENTINEL_VALUE)
+ self.assertEqual(orig_write.call_count, 1)
+ orig_write.assert_called_with(text, "stdout")
+ self.assertEqual(len(squeezer.expandingbuttons), 0)
+
+ for text in ['LONG TEXT' * 1000, 'MANY_LINES\n' * 100]:
+ editwin.write = orig_write = Mock(return_value=SENTINEL_VALUE)
+ squeezer = self.make_squeezer_instance(editwin)
+ squeezer.auto_squeeze_min_lines = 50
+
+ self.assertEqual(squeezer.editwin.write(text, "stdout"), None)
+ self.assertEqual(orig_write.call_count, 0)
+ self.assertEqual(len(squeezer.expandingbuttons), 1)
+
+ def test_auto_squeeze(self):
+ """Test that the auto-squeezing creates an ExpandingButton properly."""
+ root = get_test_tk_root(self)
+ text_widget = Text(root)
+ text_widget.mark_set("iomark", "1.0")
+
+ editwin = self.make_mock_editor_window()
+ editwin.text = text_widget
+ squeezer = self.make_squeezer_instance(editwin)
+ squeezer.auto_squeeze_min_lines = 5
+ squeezer.count_lines = Mock(return_value=6)
+
+ editwin.write('TEXT\n'*6, "stdout")
+ 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."""
+ root = get_test_tk_root(self)
+
+ # squeezing text should work for both stdout and stderr
+ for tag_name in ["stdout", "stderr"]:
+ text_widget = Text(root)
+ text_widget.mark_set("iomark", "1.0")
+
+ editwin = self.make_mock_editor_window()
+ editwin.text = editwin.per.bottom = text_widget
+ squeezer = self.make_squeezer_instance(editwin)
+ squeezer.count_lines = Mock(return_value=6)
+
+ # prepare some text in the Text widget
+ text_widget.insert("1.0", "SOME\nTEXT\n", tag_name)
+ text_widget.mark_set("insert", "1.0")
+ self.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n')
+
+ self.assertEqual(len(squeezer.expandingbuttons), 0)
+
+ # test squeezing the current text
+ retval = squeezer.squeeze_current_text_event(event=Mock())
+ self.assertEqual(retval, "break")
+ self.assertEqual(text_widget.get('1.0', 'end'), '\n\n')
+ self.assertEqual(len(squeezer.expandingbuttons), 1)
+ self.assertEqual(squeezer.expandingbuttons[0].s, 'SOME\nTEXT')
+
+ # test that expanding the squeezed text works and afterwards the
+ # Text widget contains the original text
+ squeezer.expandingbuttons[0].expand(event=Mock())
+ 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):
+ """Test that the event doesn't squeeze text without a relevant tag."""
+ root = get_test_tk_root(self)
+
+ text_widget = Text(root)
+ text_widget.mark_set("iomark", "1.0")
+
+ editwin = self.make_mock_editor_window()
+ editwin.text = editwin.per.bottom = text_widget
+ squeezer = self.make_squeezer_instance(editwin)
+ squeezer.count_lines = Mock(return_value=6)
+
+ # prepare some text in the Text widget
+ text_widget.insert("1.0", "SOME\nTEXT\n", "TAG")
+ text_widget.mark_set("insert", "1.0")
+ self.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n')
+
+ self.assertEqual(len(squeezer.expandingbuttons), 0)
+
+ # test squeezing the current text
+ retval = squeezer.squeeze_current_text_event(event=Mock())
+ self.assertEqual(retval, "break")
+ self.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n')
+ self.assertEqual(len(squeezer.expandingbuttons), 0)
+
+ def test_squeeze_text_before_existing_squeezed_text(self):
+ """Test squeezing text before existing squeezed text."""
+ root = get_test_tk_root(self)
+
+ text_widget = Text(root)
+ text_widget.mark_set("iomark", "1.0")
+
+ editwin = self.make_mock_editor_window()
+ editwin.text = editwin.per.bottom = text_widget
+ squeezer = self.make_squeezer_instance(editwin)
+ squeezer.count_lines = Mock(return_value=6)
+
+ # 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())
+ 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())
+ self.assertEqual(retval, "break")
+ self.assertEqual(text_widget.get('1.0', 'end'), '\n\n\n')
+ self.assertEqual(len(squeezer.expandingbuttons), 2)
+ self.assertTrue(text_widget.compare(
+ squeezer.expandingbuttons[0],
+ '<',
+ squeezer.expandingbuttons[1],
+ ))
+
+ GetOptionSignature = namedtuple('GetOptionSignature',
+ 'configType section option default type warn_on_default raw')
+ @classmethod
+ def _make_sig(cls, configType, section, option, default=sentinel.NOT_GIVEN,
+ type=sentinel.NOT_GIVEN,
+ warn_on_default=sentinel.NOT_GIVEN,
+ raw=sentinel.NOT_GIVEN):
+ return cls.GetOptionSignature(configType, section, option, default,
+ type, warn_on_default, raw)
+
+ @classmethod
+ def get_GetOption_signature(cls, mock_call_obj):
+ args, kwargs = mock_call_obj[-2:]
+ return cls._make_sig(*args, **kwargs)
+
+ def test_reload(self):
+ """Test the reload() class-method."""
+ self.assertIsInstance(Squeezer.auto_squeeze_min_lines, int)
+ idleConf.SetOption('main', 'PyShell', 'auto-squeeze-min-lines', '42')
+ Squeezer.reload()
+ self.assertEqual(Squeezer.auto_squeeze_min_lines, 42)
+
+
+class ExpandingButtonTest(unittest.TestCase):
+ """Tests for the ExpandingButton class."""
+ # In these tests the squeezer instance is a mock, but actual tkinter
+ # Text and Button instances are created.
+ def make_mock_squeezer(self):
+ """Helper for tests: Create a mock Squeezer object."""
+ root = get_test_tk_root(self)
+ squeezer = Mock()
+ squeezer.editwin.text = Text(root)
+
+ # Set default values for the configuration settings
+ squeezer.auto_squeeze_min_lines = 50
+ return squeezer
+
+ @patch('idlelib.squeezer.Hovertip', autospec=Hovertip)
+ def test_init(self, MockHovertip):
+ """Test the simplest creation of an ExpandingButton."""
+ squeezer = self.make_mock_squeezer()
+ text_widget = squeezer.editwin.text
+
+ expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer)
+ self.assertEqual(expandingbutton.s, 'TEXT')
+
+ # check that the underlying tkinter.Button is properly configured
+ self.assertEqual(expandingbutton.master, text_widget)
+ self.assertTrue('50 lines' in expandingbutton.cget('text'))
+
+ # check that the text widget still contains no text
+ self.assertEqual(text_widget.get('1.0', 'end'), '\n')
+
+ # check that the mouse events are bound
+ self.assertIn('<Double-Button-1>', expandingbutton.bind())
+ right_button_code = '<Button-%s>' % ('2' if macosx.isAquaTk() else '3')
+ self.assertIn(right_button_code, expandingbutton.bind())
+
+ # check that ToolTip was called once, with appropriate values
+ self.assertEqual(MockHovertip.call_count, 1)
+ MockHovertip.assert_called_with(expandingbutton, ANY, hover_delay=ANY)
+
+ # check that 'right-click' appears in the tooltip text
+ tooltip_text = MockHovertip.call_args[0][1]
+ self.assertIn('right-click', tooltip_text.lower())
+
+ def test_expand(self):
+ """Test the expand event."""
+ squeezer = self.make_mock_squeezer()
+ expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer)
+
+ # insert the button into the text widget
+ # (this is normally done by the Squeezer class)
+ 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
+
+ # trigger the expand event
+ retval = expandingbutton.expand(event=Mock())
+ self.assertEqual(retval, None)
+
+ # check that the text was inserted into the text widget
+ self.assertEqual(text_widget.get('1.0', 'end'), 'TEXT\n')
+
+ # check that the 'TAGS' tag was set on the inserted text
+ text_end_index = text_widget.index('end-1c')
+ self.assertEqual(text_widget.get('1.0', text_end_index), 'TEXT')
+ self.assertEqual(text_widget.tag_nextrange('TAGS', '1.0'),
+ ('1.0', text_end_index))
+
+ # check that the button removed itself from squeezer.expandingbuttons
+ self.assertEqual(squeezer.expandingbuttons.remove.call_count, 1)
+ squeezer.expandingbuttons.remove.assert_called_with(expandingbutton)
+
+ def test_expand_dangerous_oupput(self):
+ """Test that expanding very long output asks user for confirmation."""
+ squeezer = self.make_mock_squeezer()
+ text = 'a' * 10**5
+ expandingbutton = ExpandingButton(text, 'TAGS', 50, squeezer)
+ expandingbutton.set_is_dangerous()
+ self.assertTrue(expandingbutton.is_dangerous)
+
+ # insert the button into the text widget
+ # (this is normally done by the Squeezer class)
+ text_widget = expandingbutton.text
+ text_widget.window_create("1.0", window=expandingbutton)
+
+ # set base_text to the text widget, so that changes are actually made
+ # to it (by ExpandingButton) and we can inspect these changes afterwards
+ expandingbutton.base_text = expandingbutton.text
+
+ # patch the message box module to always return False
+ with patch('idlelib.squeezer.tkMessageBox') as mock_msgbox:
+ mock_msgbox.askokcancel.return_value = False
+ mock_msgbox.askyesno.return_value = False
+
+ # trigger the expand event
+ retval = expandingbutton.expand(event=Mock())
+
+ # check that the event chain was broken and no text was inserted
+ self.assertEqual(retval, 'break')
+ self.assertEqual(expandingbutton.text.get('1.0', 'end-1c'), '')
+
+ # patch the message box module to always return True
+ with patch('idlelib.squeezer.tkMessageBox') as mock_msgbox:
+ mock_msgbox.askokcancel.return_value = True
+ mock_msgbox.askyesno.return_value = True
+
+ # trigger the expand event
+ retval = expandingbutton.expand(event=Mock())
+
+ # check that the event chain wasn't broken and the text was inserted
+ self.assertEqual(retval, None)
+ self.assertEqual(expandingbutton.text.get('1.0', 'end-1c'), text)
+
+ def test_copy(self):
+ """Test the copy event."""
+ # testing with the actual clipboard proved problematic, so this test
+ # replaces the clipboard manipulation functions with mocks and checks
+ # that they are called appropriately
+ squeezer = self.make_mock_squeezer()
+ expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer)
+ expandingbutton.clipboard_clear = Mock()
+ expandingbutton.clipboard_append = Mock()
+
+ # trigger the copy event
+ retval = expandingbutton.copy(event=Mock())
+ self.assertEqual(retval, None)
+
+ # check that the expanding button called clipboard_clear() and
+ # clipboard_append('TEXT') once each
+ self.assertEqual(expandingbutton.clipboard_clear.call_count, 1)
+ self.assertEqual(expandingbutton.clipboard_append.call_count, 1)
+ expandingbutton.clipboard_append.assert_called_with('TEXT')
+
+ def test_view(self):
+ """Test the view event."""
+ squeezer = self.make_mock_squeezer()
+ expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer)
+ expandingbutton.selection_own = Mock()
+
+ with patch('idlelib.squeezer.view_text', autospec=view_text)\
+ as mock_view_text:
+ # trigger the view event
+ expandingbutton.view(event=Mock())
+
+ # check that the expanding button called view_text
+ self.assertEqual(mock_view_text.call_count, 1)
+
+ # check that the proper text was passed
+ self.assertEqual(mock_view_text.call_args[0][2], 'TEXT')
+
+ def test_rmenu(self):
+ """Test the context menu."""
+ squeezer = self.make_mock_squeezer()
+ expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer)
+ with patch('tkinter.Menu') as mock_Menu:
+ mock_menu = Mock()
+ mock_Menu.return_value = mock_menu
+ mock_event = Mock()
+ mock_event.x = 10
+ mock_event.y = 10
+ expandingbutton.context_menu_event(event=mock_event)
+ self.assertEqual(mock_menu.add_command.call_count,
+ len(expandingbutton.rmenu_specs))
+ for label, *data in expandingbutton.rmenu_specs:
+ mock_menu.add_command.assert_any_call(label=label, command=ANY)
+
+
+if __name__ == '__main__':
+ unittest.main(verbosity=2)
@classmethod
def setUpClass(cls):
- "By itself, this tests that file parsed without exception."
cls.root = root = Tk()
root.withdraw()
cls.frame = tv.TextFrame(root, 'test text')
def test_bad_encoding(self):
p = os.path
fn = p.abspath(p.join(p.dirname(__file__), '..', 'CREDITS.txt'))
- tv.showerror.title = None
view = tv.view_file(root, 'Title', fn, 'ascii', modal=False)
self.assertIsNone(view)
self.assertEqual(tv.showerror.title, 'Unicode Decode Error')
+ def test_nowrap(self):
+ view = tv.view_text(root, 'Title', 'test', modal=False, wrap='none')
+ text_widget = view.viewframe.textframe.text
+ self.assertEqual(text_widget.cget('wrap'), 'none')
+
# Call ViewWindow with _utest=True.
class ButtonClickTest(unittest.TestCase):
("help", "_Help"),
]
+ # Extend right-click context menu
+ rmenu_specs = OutputWindow.rmenu_specs + [
+ ("Squeeze", "<<squeeze-current-text>>"),
+ ]
# New classes
from idlelib.history import History
--- /dev/null
+"""An IDLE extension to avoid having very long texts printed in the shell.
+
+A common problem in IDLE's interactive shell is printing of large amounts of
+text into the shell. This makes looking at the previous history difficult.
+Worse, this can cause IDLE to become very slow, even to the point of being
+completely unusable.
+
+This extension will automatically replace long texts with a small button.
+Double-cliking this button will remove it and insert the original text instead.
+Middle-clicking will copy the text to the clipboard. Right-clicking will open
+the text in a separate viewing window.
+
+Additionally, any output can be manually "squeezed" by the user. This includes
+output written to the standard error stream ("stderr"), such as exception
+messages and their tracebacks.
+"""
+import re
+
+import tkinter as tk
+from tkinter.font import Font
+import tkinter.messagebox as tkMessageBox
+
+from idlelib.config import idleConf
+from idlelib.textview import view_text
+from idlelib.tooltip import Hovertip
+from idlelib import macosx
+
+
+def count_lines_with_wrapping(s, linewidth=80, tabwidth=8):
+ """Count the number of lines in a given string.
+
+ Lines are counted as if the string was wrapped so that lines are never over
+ linewidth characters long.
+
+ Tabs are considered tabwidth characters long.
+ """
+ pos = 0
+ linecount = 1
+ current_column = 0
+
+ for m in re.finditer(r"[\t\n]", s):
+ # process the normal chars up to tab or newline
+ numchars = m.start() - pos
+ pos += numchars
+ current_column += numchars
+
+ # deal with tab or newline
+ if s[pos] == '\n':
+ linecount += 1
+ current_column = 0
+ else:
+ assert s[pos] == '\t'
+ current_column += tabwidth - (current_column % tabwidth)
+
+ # if a tab passes the end of the line, consider the entire tab as
+ # being on the next line
+ if current_column > linewidth:
+ linecount += 1
+ current_column = tabwidth
+
+ pos += 1 # after the tab or newline
+
+ # avoid divmod(-1, linewidth)
+ if current_column > 0:
+ # If the length was exactly linewidth, divmod would give (1,0),
+ # even though a new line hadn't yet been started. The same is true
+ # if length is any exact multiple of linewidth. Therefore, subtract
+ # 1 before doing divmod, and later add 1 to the column to
+ # compensate.
+ lines, column = divmod(current_column - 1, linewidth)
+ linecount += lines
+ current_column = column + 1
+
+ # process remaining chars (no more tabs or newlines)
+ current_column += len(s) - pos
+ # avoid divmod(-1, linewidth)
+ if current_column > 0:
+ linecount += (current_column - 1) // linewidth
+ else:
+ # the text ended with a newline; don't count an extra line after it
+ linecount -= 1
+
+ return linecount
+
+
+class ExpandingButton(tk.Button):
+ """Class for the "squeezed" text buttons used by Squeezer
+
+ These buttons are displayed inside a Tk Text widget in place of text. A
+ user can then use the button to replace it with the original text, copy
+ the original text to the clipboard or view the original text in a separate
+ window.
+
+ Each button is tied to a Squeezer instance, and it knows to update the
+ Squeezer instance when it is expanded (and therefore removed).
+ """
+ def __init__(self, s, tags, numoflines, squeezer):
+ self.s = s
+ self.tags = tags
+ self.numoflines = numoflines
+ self.squeezer = squeezer
+ self.editwin = editwin = squeezer.editwin
+ self.text = text = editwin.text
+
+ # the base Text widget of the PyShell object, used to change text
+ # before the iomark
+ self.base_text = editwin.per.bottom
+
+ button_text = "Squeezed text (%d lines)." % self.numoflines
+ tk.Button.__init__(self, text, text=button_text,
+ background="#FFFFC0", activebackground="#FFFFE0")
+
+ button_tooltip_text = (
+ "Double-click to expand, right-click for more options."
+ )
+ Hovertip(self, button_tooltip_text, hover_delay=80)
+
+ self.bind("<Double-Button-1>", self.expand)
+ if macosx.isAquaTk():
+ # AquaTk defines <2> as the right button, not <3>.
+ self.bind("<Button-2>", self.context_menu_event)
+ else:
+ self.bind("<Button-3>", self.context_menu_event)
+ self.selection_handle(
+ lambda offset, length: s[int(offset):int(offset) + int(length)])
+
+ self.is_dangerous = None
+ self.after_idle(self.set_is_dangerous)
+
+ def set_is_dangerous(self):
+ dangerous_line_len = 50 * self.text.winfo_width()
+ self.is_dangerous = (
+ self.numoflines > 1000 or
+ len(self.s) > 50000 or
+ any(
+ len(line_match.group(0)) >= dangerous_line_len
+ for line_match in re.finditer(r'[^\n]+', self.s)
+ )
+ )
+
+ def expand(self, event=None):
+ """expand event handler
+
+ This inserts the original text in place of the button in the Text
+ widget, removes the button and updates the Squeezer instance.
+
+ If the original text is dangerously long, i.e. expanding it could
+ cause a performance degradation, ask the user for confirmation.
+ """
+ if self.is_dangerous is None:
+ self.set_is_dangerous()
+ if self.is_dangerous:
+ confirm = tkMessageBox.askokcancel(
+ title="Expand huge output?",
+ message="\n\n".join([
+ "The squeezed output is very long: %d lines, %d chars.",
+ "Expanding it could make IDLE slow or unresponsive.",
+ "It is recommended to view or copy the output instead.",
+ "Really expand?"
+ ]) % (self.numoflines, len(self.s)),
+ default=tkMessageBox.CANCEL,
+ parent=self.text)
+ if not confirm:
+ return "break"
+
+ self.base_text.insert(self.text.index(self), self.s, self.tags)
+ self.base_text.delete(self)
+ self.squeezer.expandingbuttons.remove(self)
+
+ def copy(self, event=None):
+ """copy event handler
+
+ Copy the original text to the clipboard.
+ """
+ self.clipboard_clear()
+ self.clipboard_append(self.s)
+
+ def view(self, event=None):
+ """view event handler
+
+ View the original text in a separate text viewer window.
+ """
+ view_text(self.text, "Squeezed Output Viewer", self.s,
+ modal=False, wrap='none')
+
+ rmenu_specs = (
+ # item structure: (label, method_name)
+ ('copy', 'copy'),
+ ('view', 'view'),
+ )
+
+ def context_menu_event(self, event):
+ self.text.mark_set("insert", "@%d,%d" % (event.x, event.y))
+ rmenu = tk.Menu(self.text, tearoff=0)
+ for label, method_name in self.rmenu_specs:
+ rmenu.add_command(label=label, command=getattr(self, method_name))
+ rmenu.tk_popup(event.x_root, event.y_root)
+ return "break"
+
+
+class Squeezer:
+ """Replace long outputs in the shell with a simple button.
+
+ This avoids IDLE's shell slowing down considerably, and even becoming
+ completely unresponsive, when very long outputs are written.
+ """
+ @classmethod
+ def reload(cls):
+ """Load class variables from config."""
+ cls.auto_squeeze_min_lines = idleConf.GetOption(
+ "main", "PyShell", "auto-squeeze-min-lines",
+ type="int", default=50,
+ )
+
+ def __init__(self, editwin):
+ """Initialize settings for Squeezer.
+
+ editwin is the shell's Editor window.
+ self.text is the editor window text widget.
+ self.base_test is the actual editor window Tk text widget, rather than
+ EditorWindow's wrapper.
+ self.expandingbuttons is the list of all buttons representing
+ "squeezed" output.
+ """
+ self.editwin = editwin
+ self.text = text = editwin.text
+
+ # Get the base Text widget of the PyShell object, used to change text
+ # before the iomark. PyShell deliberately disables changing text before
+ # the iomark via its 'text' attribute, which is actually a wrapper for
+ # the actual Text widget. Squeezer, however, needs to make such changes.
+ self.base_text = editwin.per.bottom
+
+ self.expandingbuttons = []
+ from idlelib.pyshell import PyShell # done here to avoid import cycle
+ if isinstance(editwin, PyShell):
+ # If we get a PyShell instance, replace its write method with a
+ # wrapper, which inserts an ExpandingButton instead of a long text.
+ def mywrite(s, tags=(), write=editwin.write):
+ # only auto-squeeze text which has just the "stdout" tag
+ if tags != "stdout":
+ return write(s, tags)
+
+ # only auto-squeeze text with at least the minimum
+ # configured number of lines
+ numoflines = self.count_lines(s)
+ if numoflines < self.auto_squeeze_min_lines:
+ return write(s, tags)
+
+ # create an ExpandingButton instance
+ expandingbutton = ExpandingButton(s, tags, numoflines,
+ self)
+
+ # insert the ExpandingButton into the Text widget
+ text.mark_gravity("iomark", tk.RIGHT)
+ text.window_create("iomark", window=expandingbutton,
+ padx=3, pady=5)
+ text.see("iomark")
+ text.update()
+ text.mark_gravity("iomark", tk.LEFT)
+
+ # add the ExpandingButton to the Squeezer's list
+ self.expandingbuttons.append(expandingbutton)
+
+ editwin.write = mywrite
+
+ def count_lines(self, s):
+ """Count the number of lines in a given text.
+
+ Before calculation, the tab width and line length of the text are
+ fetched, so that up-to-date values are used.
+
+ Lines are counted as if the string was wrapped so that lines are never
+ over linewidth characters long.
+
+ Tabs are considered tabwidth characters long.
+ """
+ # Tab width is configurable
+ tabwidth = self.editwin.get_tk_tabwidth()
+
+ # Get the Text widget's size
+ linewidth = self.editwin.text.winfo_width()
+ # Deduct the border and padding
+ linewidth -= 2*sum([int(self.editwin.text.cget(opt))
+ for opt in ('border', 'padx')])
+
+ # Get the Text widget's font
+ font = Font(self.editwin.text, name=self.editwin.text.cget('font'))
+ # Divide the size of the Text widget by the font's width.
+ # According to Tk8.5 docs, the Text widget's width is set
+ # according to the width of its font's '0' (zero) character,
+ # so we will use this as an approximation.
+ # see: http://www.tcl.tk/man/tcl8.5/TkCmd/text.htm#M-width
+ linewidth //= font.measure('0')
+
+ return count_lines_with_wrapping(s, linewidth, tabwidth)
+
+ def squeeze_current_text_event(self, event):
+ """squeeze-current-text event handler
+
+ 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
+ user a small warning and do nothing.
+ """
+ # set tag_name to the first valid tag found on the "insert" cursor
+ tag_names = self.text.tag_names(tk.INSERT)
+ for tag_name in ("stdout", "stderr"):
+ if tag_name in tag_names:
+ break
+ else:
+ # the insert cursor doesn't have a "stdout" or "stderr" tag
+ self.text.bell()
+ return "break"
+
+ # find the range to squeeze
+ start, end = self.text.tag_prevrange(tag_name, tk.INSERT + "+1c")
+ s = self.text.get(start, end)
+
+ # if the last char is a newline, remove it from the range
+ if len(s) > 0 and s[-1] == '\n':
+ end = self.text.index("%s-1c" % end)
+ s = s[:-1]
+
+ # delete the text
+ self.base_text.delete(start, end)
+
+ # prepare an ExpandingButton
+ numoflines = self.count_lines(s)
+ expandingbutton = ExpandingButton(s, tag_name, numoflines, self)
+
+ # insert the ExpandingButton to the Text
+ self.text.window_create(start, window=expandingbutton,
+ padx=3, pady=5)
+
+ # insert the ExpandingButton to the list of ExpandingButtons, while
+ # keeping the list ordered according to the position of the buttons in
+ # the Text widget
+ i = len(self.expandingbuttons)
+ while i > 0 and self.text.compare(self.expandingbuttons[i-1],
+ ">", expandingbutton):
+ i -= 1
+ self.expandingbuttons.insert(i, expandingbutton)
+
+ return "break"
+
+
+Squeezer.reload()
+
+
+if __name__ == "__main__":
+ from unittest import main
+ main('idlelib.idle_test.test_squeezer', verbosity=2, exit=False)
+
+ # Add htest.
"""Simple text browser for IDLE
"""
-from tkinter import Toplevel, Text
+from tkinter import Toplevel, Text, TclError,\
+ HORIZONTAL, VERTICAL, N, S, E, W
from tkinter.ttk import Frame, Scrollbar, Button
from tkinter.messagebox import showerror
from idlelib.colorizer import color_config
+class AutoHiddenScrollbar(Scrollbar):
+ """A scrollbar that is automatically hidden when not needed.
+
+ Only the grid geometry manager is supported.
+ """
+ def set(self, lo, hi):
+ if float(lo) > 0.0 or float(hi) < 1.0:
+ self.grid()
+ else:
+ self.grid_remove()
+ super().set(lo, hi)
+
+ def pack(self, **kwargs):
+ raise TclError(f'{self.__class__.__name__} does not support "pack"')
+
+ def place(self, **kwargs):
+ raise TclError(f'{self.__class__.__name__} does not support "place"')
+
+
class TextFrame(Frame):
"Display text with scrollbar."
- def __init__(self, parent, rawtext):
+ def __init__(self, parent, rawtext, wrap='word'):
"""Create a frame for Textview.
parent - parent widget for this frame
self['relief'] = 'sunken'
self['height'] = 700
- self.text = text = Text(self, wrap='word', highlightthickness=0)
+ self.text = text = Text(self, wrap=wrap, highlightthickness=0)
color_config(text)
- self.scroll = scroll = Scrollbar(self, orient='vertical',
- takefocus=False, command=text.yview)
- text['yscrollcommand'] = scroll.set
+ text.grid(row=0, column=0, sticky=N+S+E+W)
+ self.grid_rowconfigure(0, weight=1)
+ self.grid_columnconfigure(0, weight=1)
text.insert(0.0, rawtext)
text['state'] = 'disabled'
text.focus_set()
- scroll.pack(side='right', fill='y')
- text.pack(side='left', expand=True, fill='both')
+ # vertical scrollbar
+ self.yscroll = yscroll = AutoHiddenScrollbar(self, orient=VERTICAL,
+ takefocus=False,
+ command=text.yview)
+ text['yscrollcommand'] = yscroll.set
+ yscroll.grid(row=0, column=1, sticky=N+S)
+
+ if wrap == 'none':
+ # horizontal scrollbar
+ self.xscroll = xscroll = AutoHiddenScrollbar(self, orient=HORIZONTAL,
+ takefocus=False,
+ command=text.xview)
+ text['xscrollcommand'] = xscroll.set
+ xscroll.grid(row=1, column=0, sticky=E+W)
class ViewFrame(Frame):
"Display TextFrame and Close button."
- def __init__(self, parent, text):
+ def __init__(self, parent, text, wrap='word'):
super().__init__(parent)
self.parent = parent
self.bind('<Return>', self.ok)
self.bind('<Escape>', self.ok)
- self.textframe = TextFrame(self, text)
+ self.textframe = TextFrame(self, text, wrap=wrap)
self.button_ok = button_ok = Button(
self, text='Close', command=self.ok, takefocus=False)
self.textframe.pack(side='top', expand=True, fill='both')
class ViewWindow(Toplevel):
"A simple text viewer dialog for IDLE."
- def __init__(self, parent, title, text, modal=True,
+ def __init__(self, parent, title, text, modal=True, wrap='word',
*, _htest=False, _utest=False):
"""Show the given text in a scrollable window with a 'close' button.
parent - parent of this dialog
title - string which is title of popup dialog
text - text to display in dialog
+ wrap - type of text wrapping to use ('word', 'char' or 'none')
_htest - bool; change box location when running htest.
_utest - bool; don't wait_window when running unittest.
"""
self.geometry(f'=750x500+{x}+{y}')
self.title(title)
- self.viewframe = ViewFrame(self, text)
+ self.viewframe = ViewFrame(self, text, wrap=wrap)
self.protocol("WM_DELETE_WINDOW", self.ok)
self.button_ok = button_ok = Button(self, text='Close',
command=self.ok, takefocus=False)
self.destroy()
-def view_text(parent, title, text, modal=True, _utest=False):
+def view_text(parent, title, text, modal=True, wrap='word', _utest=False):
"""Create text viewer for given text.
parent - parent of this dialog
title - string which is the title of popup dialog
text - text to display in this dialog
+ wrap - type of text wrapping to use ('word', 'char' or 'none')
modal - controls if users can interact with other windows while this
dialog is displayed
_utest - bool; controls wait_window on unittest
"""
- return ViewWindow(parent, title, text, modal, _utest=_utest)
+ return ViewWindow(parent, title, text, modal, wrap=wrap, _utest=_utest)
-def view_file(parent, title, filename, encoding, modal=True, _utest=False):
+def view_file(parent, title, filename, encoding, modal=True, wrap='word',
+ _utest=False):
"""Create text viewer for text in filename.
Return error message if file cannot be read. Otherwise calls view_text
message=str(err),
parent=parent)
else:
- return view_text(parent, title, contents, modal, _utest=_utest)
+ return view_text(parent, title, contents, modal, wrap=wrap,
+ _utest=_utest)
return None
--- /dev/null
+Enable "squeezing" of long outputs in the shell, to avoid performance
+degradation and to clean up the history without losing it. Squeezed outputs
+may be copied, viewed in a separate window, and "unsqueezed".