bpo-1529353: IDLE: squeeze large output in the shell (GH-7626)
This commit is contained in:
parent
5b3cbcd4a0
commit
604e7b9931
|
@ -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
|
||||
|
|
|
@ -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".
|
||||
'''
|
||||
}
|
||||
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
||||
|
|
|
@ -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('''
|
||||
|
|
|
@ -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)
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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".
|
Loading…
Reference in New Issue