]> granicus.if.org Git - python/commitdiff
bpo-1529353: IDLE: squeeze large output in the shell (GH-7626)
authorTal Einat <taleinat+github@gmail.com>
Tue, 25 Sep 2018 12:10:14 +0000 (15:10 +0300)
committerGitHub <noreply@github.com>
Tue, 25 Sep 2018 12:10:14 +0000 (15:10 +0300)
Lib/idlelib/config-main.def
Lib/idlelib/configdialog.py
Lib/idlelib/editor.py
Lib/idlelib/idle_test/htest.py
Lib/idlelib/idle_test/test_config.py
Lib/idlelib/idle_test/test_squeezer.py [new file with mode: 0644]
Lib/idlelib/idle_test/test_textview.py
Lib/idlelib/pyshell.py
Lib/idlelib/squeezer.py [new file with mode: 0644]
Lib/idlelib/textview.py
Misc/NEWS.d/next/IDLE/2018-08-13-16-31-24.bpo-1529353.wXfQJk.rst [new file with mode: 0644]

index 16f4b0959cf13cc9f198f0426275bb884dff9cae..06e3c5adb0e35b62b0d3808fdec3e0981335fc10 100644 (file)
@@ -66,6 +66,9 @@ font-size= 10
 font-bold= 0
 encoding= none
 
+[PyShell]
+auto-squeeze-min-lines= 50
+
 [Indent]
 use-spaces= 1
 num-spaces= 4
index e682ec0da3200eb51895810fe86b04e3ad469cdf..229dc89874332259e8f20791c9ba186f14de6c5b 100644 (file)
@@ -30,10 +30,12 @@ from idlelib.autocomplete import AutoComplete
 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):
@@ -1748,9 +1750,9 @@ class KeysPage(Frame):
             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()
@@ -1817,6 +1819,10 @@ class GenPage(Frame):
                 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
@@ -1842,6 +1848,9 @@ class GenPage(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(
@@ -1855,8 +1864,10 @@ class GenPage(Frame):
                                   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')
@@ -1918,6 +1929,13 @@ class GenPage(Frame):
         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)
@@ -1943,6 +1961,7 @@ class GenPage(Frame):
         # 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)
@@ -1983,6 +2002,11 @@ class GenPage(Frame):
         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)
@@ -2018,6 +2042,10 @@ class GenPage(Frame):
         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')
@@ -2211,6 +2239,9 @@ long to highlight if cursor is not moved (0 means forever).
 
 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".
 '''
 }
 
index 227a74deb82d3f1e44f36c10f3128d9435767002..6689af64c429be123e789b6347f6f2abbcff64a3 100644 (file)
@@ -2,9 +2,7 @@ import importlib.abc
 import importlib.util
 import os
 import platform
-import re
 import string
-import sys
 import tokenize
 import traceback
 import webbrowser
@@ -50,7 +48,6 @@ class EditorWindow(object):
     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
@@ -59,6 +56,7 @@ class EditorWindow(object):
     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
@@ -319,6 +317,9 @@ class EditorWindow(object):
         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."""
index 03bee517073575a306718fc1eeed50f659c73d21..8c1c24d070cc8fd8c4b3a9cecd44645a7a859489 100644 (file)
@@ -163,7 +163,7 @@ _grep_dialog_spec = {
     '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."
     }
 
index f3d9f21dd86c7755436877f3aafb3d167c2b6ea8..8c9197284e07e48539363c2f34704d67262f8b8f 100644 (file)
@@ -356,11 +356,11 @@ class IdleConfTest(unittest.TestCase):
 
         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):
@@ -452,7 +452,7 @@ class IdleConfTest(unittest.TestCase):
 
         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('''
diff --git a/Lib/idlelib/idle_test/test_squeezer.py b/Lib/idlelib/idle_test/test_squeezer.py
new file mode 100644 (file)
index 0000000..ca8b674
--- /dev/null
@@ -0,0 +1,509 @@
+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)
index 0d11e41e0fb450f4719a7e6ae26ca0bcc150e129..6f0c1930518a51e5c4c9c9819ce927d3f337018a 100644 (file)
@@ -73,7 +73,6 @@ class TextFrameTest(unittest.TestCase):
 
     @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')
@@ -126,11 +125,15 @@ class ViewFunctionTest(unittest.TestCase):
     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):
index 52c11e30dbd50021bb477f392f567b34a1a290b0..5458c59dbd7e0b81398e37573965dfef2d06b255 100755 (executable)
@@ -856,6 +856,10 @@ class PyShell(OutputWindow):
         ("help", "_Help"),
     ]
 
+    # Extend right-click context menu
+    rmenu_specs = OutputWindow.rmenu_specs + [
+        ("Squeeze", "<<squeeze-current-text>>"),
+    ]
 
     # New classes
     from idlelib.history import History
diff --git a/Lib/idlelib/squeezer.py b/Lib/idlelib/squeezer.py
new file mode 100644 (file)
index 0000000..f5aac81
--- /dev/null
@@ -0,0 +1,355 @@
+"""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.
index 464e6ac6b94e3172e98daaf3a6bafb0fb03182ab..4867a80db1abe6da978e62cad9de801627fa8396 100644 (file)
@@ -1,17 +1,37 @@
 """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
@@ -21,27 +41,39 @@ class TextFrame(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')
@@ -55,7 +87,7 @@ class ViewFrame(Frame):
 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.
 
@@ -65,6 +97,7 @@ class ViewWindow(Toplevel):
         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.
         """
@@ -76,7 +109,7 @@ class ViewWindow(Toplevel):
         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)
@@ -96,20 +129,22 @@ class ViewWindow(Toplevel):
         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
@@ -127,7 +162,8 @@ def view_file(parent, title, filename, encoding, modal=True, _utest=False):
                   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
 
 
diff --git a/Misc/NEWS.d/next/IDLE/2018-08-13-16-31-24.bpo-1529353.wXfQJk.rst b/Misc/NEWS.d/next/IDLE/2018-08-13-16-31-24.bpo-1529353.wXfQJk.rst
new file mode 100644 (file)
index 0000000..cae4af8
--- /dev/null
@@ -0,0 +1,3 @@
+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".