##// END OF EJS Templates
Merge pull request #1569 from punchagan/support-backspace-and-beep-sequences...
Fernando Perez -
r6438:ed4fe905 merge
parent child Browse files
Show More
@@ -0,0 +1,40 b''
1 # Standard library imports
2 import unittest
3
4 # System library imports
5 from IPython.external.qt import QtGui
6
7 # Local imports
8 from IPython.frontend.qt.console.console_widget import ConsoleWidget
9
10
11 class TestConsoleWidget(unittest.TestCase):
12
13 @classmethod
14 def setUpClass(cls):
15 """ Create the application for the test case.
16 """
17 cls._app = QtGui.QApplication([])
18 cls._app.setQuitOnLastWindowClosed(False)
19
20 @classmethod
21 def tearDownClass(cls):
22 """ Exit the application.
23 """
24 QtGui.QApplication.quit()
25
26 def test_special_characters(self):
27 """ Are special characters displayed correctly?
28 """
29 w = ConsoleWidget()
30 cursor = w._get_prompt_cursor()
31
32 test_inputs = ['xyz\b\b=\n', 'foo\b\nbar\n', 'foo\b\nbar\r\n', 'abc\rxyz\b\b=']
33 expected_outputs = [u'x=z\u2029', u'foo\u2029bar\u2029', u'foo\u2029bar\u2029', 'x=z']
34 for i, text in enumerate(test_inputs):
35 w._insert_plain_text(cursor, text)
36 cursor.select(cursor.Document)
37 selection = cursor.selectedText()
38 self.assertEquals(expected_outputs[i], selection)
39 # clear all the text
40 cursor.insertText('')
@@ -1,348 +1,375 b''
1 1 """ Utilities for processing ANSI escape codes and special ASCII characters.
2 2 """
3 3 #-----------------------------------------------------------------------------
4 4 # Imports
5 5 #-----------------------------------------------------------------------------
6 6
7 7 # Standard library imports
8 8 from collections import namedtuple
9 9 import re
10 10
11 11 # System library imports
12 12 from IPython.external.qt import QtCore, QtGui
13 13
14 14 #-----------------------------------------------------------------------------
15 15 # Constants and datatypes
16 16 #-----------------------------------------------------------------------------
17 17
18 18 # An action for erase requests (ED and EL commands).
19 19 EraseAction = namedtuple('EraseAction', ['action', 'area', 'erase_to'])
20 20
21 21 # An action for cursor move requests (CUU, CUD, CUF, CUB, CNL, CPL, CHA, CUP,
22 22 # and HVP commands).
23 23 # FIXME: Not implemented in AnsiCodeProcessor.
24 24 MoveAction = namedtuple('MoveAction', ['action', 'dir', 'unit', 'count'])
25 25
26 26 # An action for scroll requests (SU and ST) and form feeds.
27 27 ScrollAction = namedtuple('ScrollAction', ['action', 'dir', 'unit', 'count'])
28 28
29 29 # An action for the carriage return character
30 30 CarriageReturnAction = namedtuple('CarriageReturnAction', ['action'])
31 31
32 # An action for the \n character
33 NewLineAction = namedtuple('NewLineAction', ['action'])
34
32 35 # An action for the beep character
33 36 BeepAction = namedtuple('BeepAction', ['action'])
34 37
38 # An action for backspace
39 BackSpaceAction = namedtuple('BackSpaceAction', ['action'])
40
35 41 # Regular expressions.
36 42 CSI_COMMANDS = 'ABCDEFGHJKSTfmnsu'
37 43 CSI_SUBPATTERN = '\[(.*?)([%s])' % CSI_COMMANDS
38 44 OSC_SUBPATTERN = '\](.*?)[\x07\x1b]'
39 45 ANSI_PATTERN = ('\x01?\x1b(%s|%s)\x02?' % \
40 46 (CSI_SUBPATTERN, OSC_SUBPATTERN))
41 ANSI_OR_SPECIAL_PATTERN = re.compile('(\b|\r(?!\n))|(?:%s)' % ANSI_PATTERN)
47 ANSI_OR_SPECIAL_PATTERN = re.compile('(\a|\b|\r(?!\n)|\r?\n)|(?:%s)' % ANSI_PATTERN)
42 48 SPECIAL_PATTERN = re.compile('([\f])')
43 49
44 50 #-----------------------------------------------------------------------------
45 51 # Classes
46 52 #-----------------------------------------------------------------------------
47 53
48 54 class AnsiCodeProcessor(object):
49 55 """ Translates special ASCII characters and ANSI escape codes into readable
50 56 attributes. It also supports a few non-standard, xterm-specific codes.
51 57 """
52 58
53 59 # Whether to increase intensity or set boldness for SGR code 1.
54 60 # (Different terminals handle this in different ways.)
55 61 bold_text_enabled = False
56 62
57 63 # We provide an empty default color map because subclasses will likely want
58 64 # to use a custom color format.
59 65 default_color_map = {}
60 66
61 67 #---------------------------------------------------------------------------
62 68 # AnsiCodeProcessor interface
63 69 #---------------------------------------------------------------------------
64 70
65 71 def __init__(self):
66 72 self.actions = []
67 73 self.color_map = self.default_color_map.copy()
68 74 self.reset_sgr()
69 75
70 76 def reset_sgr(self):
71 77 """ Reset graphics attributs to their default values.
72 78 """
73 79 self.intensity = 0
74 80 self.italic = False
75 81 self.bold = False
76 82 self.underline = False
77 83 self.foreground_color = None
78 84 self.background_color = None
79 85
80 86 def split_string(self, string):
81 87 """ Yields substrings for which the same escape code applies.
82 88 """
83 89 self.actions = []
84 90 start = 0
85 91
92 # strings ending with \r are assumed to be ending in \r\n since
93 # \n is appended to output strings automatically. Accounting
94 # for that, here.
95 last_char = '\n' if len(string) > 0 and string[-1] == '\n' else None
96 string = string[:-1] if last_char is not None else string
97
86 98 for match in ANSI_OR_SPECIAL_PATTERN.finditer(string):
87 99 raw = string[start:match.start()]
88 100 substring = SPECIAL_PATTERN.sub(self._replace_special, raw)
89 101 if substring or self.actions:
90 102 yield substring
103 self.actions = []
91 104 start = match.end()
92 105
93 self.actions = []
94 106 groups = filter(lambda x: x is not None, match.groups())
95 if groups[0] == '\r':
96 self.actions.append(CarriageReturnAction('carriage-return'))
97 yield ''
98 elif groups[0] == '\b':
107 g0 = groups[0]
108 if g0 == '\a':
99 109 self.actions.append(BeepAction('beep'))
100 yield ''
110 yield None
111 self.actions = []
112 elif g0 == '\r':
113 self.actions.append(CarriageReturnAction('carriage-return'))
114 yield None
115 self.actions = []
116 elif g0 == '\b':
117 self.actions.append(BackSpaceAction('backspace'))
118 yield None
119 self.actions = []
120 elif g0 == '\n' or g0 == '\r\n':
121 self.actions.append(NewLineAction('newline'))
122 yield g0
123 self.actions = []
101 124 else:
102 125 params = [ param for param in groups[1].split(';') if param ]
103 if groups[0].startswith('['):
126 if g0.startswith('['):
104 127 # Case 1: CSI code.
105 128 try:
106 129 params = map(int, params)
107 130 except ValueError:
108 131 # Silently discard badly formed codes.
109 132 pass
110 133 else:
111 134 self.set_csi_code(groups[2], params)
112 135
113 elif groups[0].startswith(']'):
136 elif g0.startswith(']'):
114 137 # Case 2: OSC code.
115 138 self.set_osc_code(params)
116 139
117 140 raw = string[start:]
118 141 substring = SPECIAL_PATTERN.sub(self._replace_special, raw)
119 142 if substring or self.actions:
120 143 yield substring
121 144
145 if last_char is not None:
146 self.actions.append(NewLineAction('newline'))
147 yield last_char
148
122 149 def set_csi_code(self, command, params=[]):
123 150 """ Set attributes based on CSI (Control Sequence Introducer) code.
124 151
125 152 Parameters
126 153 ----------
127 154 command : str
128 155 The code identifier, i.e. the final character in the sequence.
129 156
130 157 params : sequence of integers, optional
131 158 The parameter codes for the command.
132 159 """
133 160 if command == 'm': # SGR - Select Graphic Rendition
134 161 if params:
135 162 self.set_sgr_code(params)
136 163 else:
137 164 self.set_sgr_code([0])
138 165
139 166 elif (command == 'J' or # ED - Erase Data
140 167 command == 'K'): # EL - Erase in Line
141 168 code = params[0] if params else 0
142 169 if 0 <= code <= 2:
143 170 area = 'screen' if command == 'J' else 'line'
144 171 if code == 0:
145 172 erase_to = 'end'
146 173 elif code == 1:
147 174 erase_to = 'start'
148 175 elif code == 2:
149 176 erase_to = 'all'
150 177 self.actions.append(EraseAction('erase', area, erase_to))
151 178
152 179 elif (command == 'S' or # SU - Scroll Up
153 180 command == 'T'): # SD - Scroll Down
154 181 dir = 'up' if command == 'S' else 'down'
155 182 count = params[0] if params else 1
156 183 self.actions.append(ScrollAction('scroll', dir, 'line', count))
157 184
158 185 def set_osc_code(self, params):
159 186 """ Set attributes based on OSC (Operating System Command) parameters.
160 187
161 188 Parameters
162 189 ----------
163 190 params : sequence of str
164 191 The parameters for the command.
165 192 """
166 193 try:
167 194 command = int(params.pop(0))
168 195 except (IndexError, ValueError):
169 196 return
170 197
171 198 if command == 4:
172 199 # xterm-specific: set color number to color spec.
173 200 try:
174 201 color = int(params.pop(0))
175 202 spec = params.pop(0)
176 203 self.color_map[color] = self._parse_xterm_color_spec(spec)
177 204 except (IndexError, ValueError):
178 205 pass
179 206
180 207 def set_sgr_code(self, params):
181 208 """ Set attributes based on SGR (Select Graphic Rendition) codes.
182 209
183 210 Parameters
184 211 ----------
185 212 params : sequence of ints
186 213 A list of SGR codes for one or more SGR commands. Usually this
187 214 sequence will have one element per command, although certain
188 215 xterm-specific commands requires multiple elements.
189 216 """
190 217 # Always consume the first parameter.
191 218 if not params:
192 219 return
193 220 code = params.pop(0)
194 221
195 222 if code == 0:
196 223 self.reset_sgr()
197 224 elif code == 1:
198 225 if self.bold_text_enabled:
199 226 self.bold = True
200 227 else:
201 228 self.intensity = 1
202 229 elif code == 2:
203 230 self.intensity = 0
204 231 elif code == 3:
205 232 self.italic = True
206 233 elif code == 4:
207 234 self.underline = True
208 235 elif code == 22:
209 236 self.intensity = 0
210 237 self.bold = False
211 238 elif code == 23:
212 239 self.italic = False
213 240 elif code == 24:
214 241 self.underline = False
215 242 elif code >= 30 and code <= 37:
216 243 self.foreground_color = code - 30
217 244 elif code == 38 and params and params.pop(0) == 5:
218 245 # xterm-specific: 256 color support.
219 246 if params:
220 247 self.foreground_color = params.pop(0)
221 248 elif code == 39:
222 249 self.foreground_color = None
223 250 elif code >= 40 and code <= 47:
224 251 self.background_color = code - 40
225 252 elif code == 48 and params and params.pop(0) == 5:
226 253 # xterm-specific: 256 color support.
227 254 if params:
228 255 self.background_color = params.pop(0)
229 256 elif code == 49:
230 257 self.background_color = None
231 258
232 259 # Recurse with unconsumed parameters.
233 260 self.set_sgr_code(params)
234 261
235 262 #---------------------------------------------------------------------------
236 263 # Protected interface
237 264 #---------------------------------------------------------------------------
238 265
239 266 def _parse_xterm_color_spec(self, spec):
240 267 if spec.startswith('rgb:'):
241 268 return tuple(map(lambda x: int(x, 16), spec[4:].split('/')))
242 269 elif spec.startswith('rgbi:'):
243 270 return tuple(map(lambda x: int(float(x) * 255),
244 271 spec[5:].split('/')))
245 272 elif spec == '?':
246 273 raise ValueError('Unsupported xterm color spec')
247 274 return spec
248 275
249 276 def _replace_special(self, match):
250 277 special = match.group(1)
251 278 if special == '\f':
252 279 self.actions.append(ScrollAction('scroll', 'down', 'page', 1))
253 280 return ''
254 281
255 282
256 283 class QtAnsiCodeProcessor(AnsiCodeProcessor):
257 284 """ Translates ANSI escape codes into QTextCharFormats.
258 285 """
259 286
260 287 # A map from ANSI color codes to SVG color names or RGB(A) tuples.
261 288 darkbg_color_map = {
262 289 0 : 'black', # black
263 290 1 : 'darkred', # red
264 291 2 : 'darkgreen', # green
265 292 3 : 'brown', # yellow
266 293 4 : 'darkblue', # blue
267 294 5 : 'darkviolet', # magenta
268 295 6 : 'steelblue', # cyan
269 296 7 : 'grey', # white
270 297 8 : 'grey', # black (bright)
271 298 9 : 'red', # red (bright)
272 299 10 : 'lime', # green (bright)
273 300 11 : 'yellow', # yellow (bright)
274 301 12 : 'deepskyblue', # blue (bright)
275 302 13 : 'magenta', # magenta (bright)
276 303 14 : 'cyan', # cyan (bright)
277 304 15 : 'white' } # white (bright)
278 305
279 306 # Set the default color map for super class.
280 307 default_color_map = darkbg_color_map.copy()
281 308
282 309 def get_color(self, color, intensity=0):
283 310 """ Returns a QColor for a given color code, or None if one cannot be
284 311 constructed.
285 312 """
286 313 if color is None:
287 314 return None
288 315
289 316 # Adjust for intensity, if possible.
290 317 if color < 8 and intensity > 0:
291 318 color += 8
292 319
293 320 constructor = self.color_map.get(color, None)
294 321 if isinstance(constructor, basestring):
295 322 # If this is an X11 color name, we just hope there is a close SVG
296 323 # color name. We could use QColor's static method
297 324 # 'setAllowX11ColorNames()', but this is global and only available
298 325 # on X11. It seems cleaner to aim for uniformity of behavior.
299 326 return QtGui.QColor(constructor)
300 327
301 328 elif isinstance(constructor, (tuple, list)):
302 329 return QtGui.QColor(*constructor)
303 330
304 331 return None
305 332
306 333 def get_format(self):
307 334 """ Returns a QTextCharFormat that encodes the current style attributes.
308 335 """
309 336 format = QtGui.QTextCharFormat()
310 337
311 338 # Set foreground color
312 339 qcolor = self.get_color(self.foreground_color, self.intensity)
313 340 if qcolor is not None:
314 341 format.setForeground(qcolor)
315 342
316 343 # Set background color
317 344 qcolor = self.get_color(self.background_color, self.intensity)
318 345 if qcolor is not None:
319 346 format.setBackground(qcolor)
320 347
321 348 # Set font weight/style options
322 349 if self.bold:
323 350 format.setFontWeight(QtGui.QFont.Bold)
324 351 else:
325 352 format.setFontWeight(QtGui.QFont.Normal)
326 353 format.setFontItalic(self.italic)
327 354 format.setFontUnderline(self.underline)
328 355
329 356 return format
330 357
331 358 def set_background_color(self, color):
332 359 """ Given a background color (a QColor), attempt to set a color map
333 360 that will be aesthetically pleasing.
334 361 """
335 362 # Set a new default color map.
336 363 self.default_color_map = self.darkbg_color_map.copy()
337 364
338 365 if color.value() >= 127:
339 366 # Colors appropriate for a terminal with a light background. For
340 367 # now, only use non-bright colors...
341 368 for i in xrange(8):
342 369 self.default_color_map[i + 8] = self.default_color_map[i]
343 370
344 371 # ...and replace white with black.
345 372 self.default_color_map[7] = self.default_color_map[15] = 'black'
346 373
347 374 # Update the current color map with the new defaults.
348 375 self.color_map.update(self.default_color_map)
@@ -1,1855 +1,1878 b''
1 1 """ An abstract base class for console-type widgets.
2 2 """
3 3 #-----------------------------------------------------------------------------
4 4 # Imports
5 5 #-----------------------------------------------------------------------------
6 6
7 7 # Standard library imports
8 8 import os
9 9 from os.path import commonprefix
10 10 import re
11 11 import sys
12 12 from textwrap import dedent
13 13 from unicodedata import category
14 14
15 15 # System library imports
16 16 from IPython.external.qt import QtCore, QtGui
17 17
18 18 # Local imports
19 19 from IPython.config.configurable import LoggingConfigurable
20 20 from IPython.frontend.qt.rich_text import HtmlExporter
21 21 from IPython.frontend.qt.util import MetaQObjectHasTraits, get_font
22 22 from IPython.utils.text import columnize
23 23 from IPython.utils.traitlets import Bool, Enum, Integer, Unicode
24 24 from ansi_code_processor import QtAnsiCodeProcessor
25 25 from completion_widget import CompletionWidget
26 26 from kill_ring import QtKillRing
27 27
28 28 #-----------------------------------------------------------------------------
29 29 # Functions
30 30 #-----------------------------------------------------------------------------
31 31
32 32 def is_letter_or_number(char):
33 33 """ Returns whether the specified unicode character is a letter or a number.
34 34 """
35 35 cat = category(char)
36 36 return cat.startswith('L') or cat.startswith('N')
37 37
38 38 #-----------------------------------------------------------------------------
39 39 # Classes
40 40 #-----------------------------------------------------------------------------
41 41
42 42 class ConsoleWidget(LoggingConfigurable, QtGui.QWidget):
43 43 """ An abstract base class for console-type widgets. This class has
44 44 functionality for:
45 45
46 46 * Maintaining a prompt and editing region
47 47 * Providing the traditional Unix-style console keyboard shortcuts
48 48 * Performing tab completion
49 49 * Paging text
50 50 * Handling ANSI escape codes
51 51
52 52 ConsoleWidget also provides a number of utility methods that will be
53 53 convenient to implementors of a console-style widget.
54 54 """
55 55 __metaclass__ = MetaQObjectHasTraits
56 56
57 57 #------ Configuration ------------------------------------------------------
58 58
59 59 ansi_codes = Bool(True, config=True,
60 60 help="Whether to process ANSI escape codes."
61 61 )
62 62 buffer_size = Integer(500, config=True,
63 63 help="""
64 64 The maximum number of lines of text before truncation. Specifying a
65 65 non-positive number disables text truncation (not recommended).
66 66 """
67 67 )
68 68 gui_completion = Bool(False, config=True,
69 69 help="""
70 70 Use a list widget instead of plain text output for tab completion.
71 71 """
72 72 )
73 73 # NOTE: this value can only be specified during initialization.
74 74 kind = Enum(['plain', 'rich'], default_value='plain', config=True,
75 75 help="""
76 76 The type of underlying text widget to use. Valid values are 'plain',
77 77 which specifies a QPlainTextEdit, and 'rich', which specifies a
78 78 QTextEdit.
79 79 """
80 80 )
81 81 # NOTE: this value can only be specified during initialization.
82 82 paging = Enum(['inside', 'hsplit', 'vsplit', 'custom', 'none'],
83 83 default_value='inside', config=True,
84 84 help="""
85 85 The type of paging to use. Valid values are:
86 86
87 87 'inside' : The widget pages like a traditional terminal.
88 88 'hsplit' : When paging is requested, the widget is split
89 89 horizontally. The top pane contains the console, and the
90 90 bottom pane contains the paged text.
91 91 'vsplit' : Similar to 'hsplit', except that a vertical splitter
92 92 used.
93 93 'custom' : No action is taken by the widget beyond emitting a
94 94 'custom_page_requested(str)' signal.
95 95 'none' : The text is written directly to the console.
96 96 """)
97 97
98 98 font_family = Unicode(config=True,
99 99 help="""The font family to use for the console.
100 100 On OSX this defaults to Monaco, on Windows the default is
101 101 Consolas with fallback of Courier, and on other platforms
102 102 the default is Monospace.
103 103 """)
104 104 def _font_family_default(self):
105 105 if sys.platform == 'win32':
106 106 # Consolas ships with Vista/Win7, fallback to Courier if needed
107 107 return 'Consolas'
108 108 elif sys.platform == 'darwin':
109 109 # OSX always has Monaco, no need for a fallback
110 110 return 'Monaco'
111 111 else:
112 112 # Monospace should always exist, no need for a fallback
113 113 return 'Monospace'
114 114
115 115 font_size = Integer(config=True,
116 116 help="""The font size. If unconfigured, Qt will be entrusted
117 117 with the size of the font.
118 118 """)
119 119
120 120 # Whether to override ShortcutEvents for the keybindings defined by this
121 121 # widget (Ctrl+n, Ctrl+a, etc). Enable this if you want this widget to take
122 122 # priority (when it has focus) over, e.g., window-level menu shortcuts.
123 123 override_shortcuts = Bool(False)
124 124
125 125 #------ Signals ------------------------------------------------------------
126 126
127 127 # Signals that indicate ConsoleWidget state.
128 128 copy_available = QtCore.Signal(bool)
129 129 redo_available = QtCore.Signal(bool)
130 130 undo_available = QtCore.Signal(bool)
131 131
132 132 # Signal emitted when paging is needed and the paging style has been
133 133 # specified as 'custom'.
134 134 custom_page_requested = QtCore.Signal(object)
135 135
136 136 # Signal emitted when the font is changed.
137 137 font_changed = QtCore.Signal(QtGui.QFont)
138 138
139 139 #------ Protected class variables ------------------------------------------
140 140
141 141 # control handles
142 142 _control = None
143 143 _page_control = None
144 144 _splitter = None
145 145
146 146 # When the control key is down, these keys are mapped.
147 147 _ctrl_down_remap = { QtCore.Qt.Key_B : QtCore.Qt.Key_Left,
148 148 QtCore.Qt.Key_F : QtCore.Qt.Key_Right,
149 149 QtCore.Qt.Key_A : QtCore.Qt.Key_Home,
150 150 QtCore.Qt.Key_P : QtCore.Qt.Key_Up,
151 151 QtCore.Qt.Key_N : QtCore.Qt.Key_Down,
152 152 QtCore.Qt.Key_H : QtCore.Qt.Key_Backspace, }
153 153 if not sys.platform == 'darwin':
154 154 # On OS X, Ctrl-E already does the right thing, whereas End moves the
155 155 # cursor to the bottom of the buffer.
156 156 _ctrl_down_remap[QtCore.Qt.Key_E] = QtCore.Qt.Key_End
157 157
158 158 # The shortcuts defined by this widget. We need to keep track of these to
159 159 # support 'override_shortcuts' above.
160 160 _shortcuts = set(_ctrl_down_remap.keys() +
161 161 [ QtCore.Qt.Key_C, QtCore.Qt.Key_G, QtCore.Qt.Key_O,
162 162 QtCore.Qt.Key_V ])
163 163
164 164 #---------------------------------------------------------------------------
165 165 # 'QObject' interface
166 166 #---------------------------------------------------------------------------
167 167
168 168 def __init__(self, parent=None, **kw):
169 169 """ Create a ConsoleWidget.
170 170
171 171 Parameters:
172 172 -----------
173 173 parent : QWidget, optional [default None]
174 174 The parent for this widget.
175 175 """
176 176 QtGui.QWidget.__init__(self, parent)
177 177 LoggingConfigurable.__init__(self, **kw)
178 178
179 179 # While scrolling the pager on Mac OS X, it tears badly. The
180 180 # NativeGesture is platform and perhaps build-specific hence
181 181 # we take adequate precautions here.
182 182 self._pager_scroll_events = [QtCore.QEvent.Wheel]
183 183 if hasattr(QtCore.QEvent, 'NativeGesture'):
184 184 self._pager_scroll_events.append(QtCore.QEvent.NativeGesture)
185 185
186 186 # Create the layout and underlying text widget.
187 187 layout = QtGui.QStackedLayout(self)
188 188 layout.setContentsMargins(0, 0, 0, 0)
189 189 self._control = self._create_control()
190 190 if self.paging in ('hsplit', 'vsplit'):
191 191 self._splitter = QtGui.QSplitter()
192 192 if self.paging == 'hsplit':
193 193 self._splitter.setOrientation(QtCore.Qt.Horizontal)
194 194 else:
195 195 self._splitter.setOrientation(QtCore.Qt.Vertical)
196 196 self._splitter.addWidget(self._control)
197 197 layout.addWidget(self._splitter)
198 198 else:
199 199 layout.addWidget(self._control)
200 200
201 201 # Create the paging widget, if necessary.
202 202 if self.paging in ('inside', 'hsplit', 'vsplit'):
203 203 self._page_control = self._create_page_control()
204 204 if self._splitter:
205 205 self._page_control.hide()
206 206 self._splitter.addWidget(self._page_control)
207 207 else:
208 208 layout.addWidget(self._page_control)
209 209
210 210 # Initialize protected variables. Some variables contain useful state
211 211 # information for subclasses; they should be considered read-only.
212 212 self._append_before_prompt_pos = 0
213 213 self._ansi_processor = QtAnsiCodeProcessor()
214 214 self._completion_widget = CompletionWidget(self._control)
215 215 self._continuation_prompt = '> '
216 216 self._continuation_prompt_html = None
217 217 self._executing = False
218 218 self._filter_drag = False
219 219 self._filter_resize = False
220 220 self._html_exporter = HtmlExporter(self._control)
221 221 self._input_buffer_executing = ''
222 222 self._input_buffer_pending = ''
223 223 self._kill_ring = QtKillRing(self._control)
224 224 self._prompt = ''
225 225 self._prompt_html = None
226 226 self._prompt_pos = 0
227 227 self._prompt_sep = ''
228 228 self._reading = False
229 229 self._reading_callback = None
230 230 self._tab_width = 8
231 231 self._text_completing_pos = 0
232 232
233 233 # Set a monospaced font.
234 234 self.reset_font()
235 235
236 236 # Configure actions.
237 237 action = QtGui.QAction('Print', None)
238 238 action.setEnabled(True)
239 239 printkey = QtGui.QKeySequence(QtGui.QKeySequence.Print)
240 240 if printkey.matches("Ctrl+P") and sys.platform != 'darwin':
241 241 # Only override the default if there is a collision.
242 242 # Qt ctrl = cmd on OSX, so the match gets a false positive on OSX.
243 243 printkey = "Ctrl+Shift+P"
244 244 action.setShortcut(printkey)
245 245 action.setShortcutContext(QtCore.Qt.WidgetWithChildrenShortcut)
246 246 action.triggered.connect(self.print_)
247 247 self.addAction(action)
248 248 self.print_action = action
249 249
250 250 action = QtGui.QAction('Save as HTML/XML', None)
251 251 action.setShortcut(QtGui.QKeySequence.Save)
252 252 action.setShortcutContext(QtCore.Qt.WidgetWithChildrenShortcut)
253 253 action.triggered.connect(self.export_html)
254 254 self.addAction(action)
255 255 self.export_action = action
256 256
257 257 action = QtGui.QAction('Select All', None)
258 258 action.setEnabled(True)
259 259 selectall = QtGui.QKeySequence(QtGui.QKeySequence.SelectAll)
260 260 if selectall.matches("Ctrl+A") and sys.platform != 'darwin':
261 261 # Only override the default if there is a collision.
262 262 # Qt ctrl = cmd on OSX, so the match gets a false positive on OSX.
263 263 selectall = "Ctrl+Shift+A"
264 264 action.setShortcut(selectall)
265 265 action.setShortcutContext(QtCore.Qt.WidgetWithChildrenShortcut)
266 266 action.triggered.connect(self.select_all)
267 267 self.addAction(action)
268 268 self.select_all_action = action
269 269
270 270 self.increase_font_size = QtGui.QAction("Bigger Font",
271 271 self,
272 272 shortcut=QtGui.QKeySequence.ZoomIn,
273 273 shortcutContext=QtCore.Qt.WidgetWithChildrenShortcut,
274 274 statusTip="Increase the font size by one point",
275 275 triggered=self._increase_font_size)
276 276 self.addAction(self.increase_font_size)
277 277
278 278 self.decrease_font_size = QtGui.QAction("Smaller Font",
279 279 self,
280 280 shortcut=QtGui.QKeySequence.ZoomOut,
281 281 shortcutContext=QtCore.Qt.WidgetWithChildrenShortcut,
282 282 statusTip="Decrease the font size by one point",
283 283 triggered=self._decrease_font_size)
284 284 self.addAction(self.decrease_font_size)
285 285
286 286 self.reset_font_size = QtGui.QAction("Normal Font",
287 287 self,
288 288 shortcut="Ctrl+0",
289 289 shortcutContext=QtCore.Qt.WidgetWithChildrenShortcut,
290 290 statusTip="Restore the Normal font size",
291 291 triggered=self.reset_font)
292 292 self.addAction(self.reset_font_size)
293 293
294 294
295 295
296 296 def eventFilter(self, obj, event):
297 297 """ Reimplemented to ensure a console-like behavior in the underlying
298 298 text widgets.
299 299 """
300 300 etype = event.type()
301 301 if etype == QtCore.QEvent.KeyPress:
302 302
303 303 # Re-map keys for all filtered widgets.
304 304 key = event.key()
305 305 if self._control_key_down(event.modifiers()) and \
306 306 key in self._ctrl_down_remap:
307 307 new_event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress,
308 308 self._ctrl_down_remap[key],
309 309 QtCore.Qt.NoModifier)
310 310 QtGui.qApp.sendEvent(obj, new_event)
311 311 return True
312 312
313 313 elif obj == self._control:
314 314 return self._event_filter_console_keypress(event)
315 315
316 316 elif obj == self._page_control:
317 317 return self._event_filter_page_keypress(event)
318 318
319 319 # Make middle-click paste safe.
320 320 elif etype == QtCore.QEvent.MouseButtonRelease and \
321 321 event.button() == QtCore.Qt.MidButton and \
322 322 obj == self._control.viewport():
323 323 cursor = self._control.cursorForPosition(event.pos())
324 324 self._control.setTextCursor(cursor)
325 325 self.paste(QtGui.QClipboard.Selection)
326 326 return True
327 327
328 328 # Manually adjust the scrollbars *after* a resize event is dispatched.
329 329 elif etype == QtCore.QEvent.Resize and not self._filter_resize:
330 330 self._filter_resize = True
331 331 QtGui.qApp.sendEvent(obj, event)
332 332 self._adjust_scrollbars()
333 333 self._filter_resize = False
334 334 return True
335 335
336 336 # Override shortcuts for all filtered widgets.
337 337 elif etype == QtCore.QEvent.ShortcutOverride and \
338 338 self.override_shortcuts and \
339 339 self._control_key_down(event.modifiers()) and \
340 340 event.key() in self._shortcuts:
341 341 event.accept()
342 342
343 343 # Ensure that drags are safe. The problem is that the drag starting
344 344 # logic, which determines whether the drag is a Copy or Move, is locked
345 345 # down in QTextControl. If the widget is editable, which it must be if
346 346 # we're not executing, the drag will be a Move. The following hack
347 347 # prevents QTextControl from deleting the text by clearing the selection
348 348 # when a drag leave event originating from this widget is dispatched.
349 349 # The fact that we have to clear the user's selection is unfortunate,
350 350 # but the alternative--trying to prevent Qt from using its hardwired
351 351 # drag logic and writing our own--is worse.
352 352 elif etype == QtCore.QEvent.DragEnter and \
353 353 obj == self._control.viewport() and \
354 354 event.source() == self._control.viewport():
355 355 self._filter_drag = True
356 356 elif etype == QtCore.QEvent.DragLeave and \
357 357 obj == self._control.viewport() and \
358 358 self._filter_drag:
359 359 cursor = self._control.textCursor()
360 360 cursor.clearSelection()
361 361 self._control.setTextCursor(cursor)
362 362 self._filter_drag = False
363 363
364 364 # Ensure that drops are safe.
365 365 elif etype == QtCore.QEvent.Drop and obj == self._control.viewport():
366 366 cursor = self._control.cursorForPosition(event.pos())
367 367 if self._in_buffer(cursor.position()):
368 368 text = event.mimeData().text()
369 369 self._insert_plain_text_into_buffer(cursor, text)
370 370
371 371 # Qt is expecting to get something here--drag and drop occurs in its
372 372 # own event loop. Send a DragLeave event to end it.
373 373 QtGui.qApp.sendEvent(obj, QtGui.QDragLeaveEvent())
374 374 return True
375 375
376 376 # Handle scrolling of the vsplit pager. This hack attempts to solve
377 377 # problems with tearing of the help text inside the pager window. This
378 378 # happens only on Mac OS X with both PySide and PyQt. This fix isn't
379 379 # perfect but makes the pager more usable.
380 380 elif etype in self._pager_scroll_events and \
381 381 obj == self._page_control:
382 382 self._page_control.repaint()
383 383 return True
384 384 return super(ConsoleWidget, self).eventFilter(obj, event)
385 385
386 386 #---------------------------------------------------------------------------
387 387 # 'QWidget' interface
388 388 #---------------------------------------------------------------------------
389 389
390 390 def sizeHint(self):
391 391 """ Reimplemented to suggest a size that is 80 characters wide and
392 392 25 lines high.
393 393 """
394 394 font_metrics = QtGui.QFontMetrics(self.font)
395 395 margin = (self._control.frameWidth() +
396 396 self._control.document().documentMargin()) * 2
397 397 style = self.style()
398 398 splitwidth = style.pixelMetric(QtGui.QStyle.PM_SplitterWidth)
399 399
400 400 # Note 1: Despite my best efforts to take the various margins into
401 401 # account, the width is still coming out a bit too small, so we include
402 402 # a fudge factor of one character here.
403 403 # Note 2: QFontMetrics.maxWidth is not used here or anywhere else due
404 404 # to a Qt bug on certain Mac OS systems where it returns 0.
405 405 width = font_metrics.width(' ') * 81 + margin
406 406 width += style.pixelMetric(QtGui.QStyle.PM_ScrollBarExtent)
407 407 if self.paging == 'hsplit':
408 408 width = width * 2 + splitwidth
409 409
410 410 height = font_metrics.height() * 25 + margin
411 411 if self.paging == 'vsplit':
412 412 height = height * 2 + splitwidth
413 413
414 414 return QtCore.QSize(width, height)
415 415
416 416 #---------------------------------------------------------------------------
417 417 # 'ConsoleWidget' public interface
418 418 #---------------------------------------------------------------------------
419 419
420 420 def can_copy(self):
421 421 """ Returns whether text can be copied to the clipboard.
422 422 """
423 423 return self._control.textCursor().hasSelection()
424 424
425 425 def can_cut(self):
426 426 """ Returns whether text can be cut to the clipboard.
427 427 """
428 428 cursor = self._control.textCursor()
429 429 return (cursor.hasSelection() and
430 430 self._in_buffer(cursor.anchor()) and
431 431 self._in_buffer(cursor.position()))
432 432
433 433 def can_paste(self):
434 434 """ Returns whether text can be pasted from the clipboard.
435 435 """
436 436 if self._control.textInteractionFlags() & QtCore.Qt.TextEditable:
437 437 return bool(QtGui.QApplication.clipboard().text())
438 438 return False
439 439
440 440 def clear(self, keep_input=True):
441 441 """ Clear the console.
442 442
443 443 Parameters:
444 444 -----------
445 445 keep_input : bool, optional (default True)
446 446 If set, restores the old input buffer if a new prompt is written.
447 447 """
448 448 if self._executing:
449 449 self._control.clear()
450 450 else:
451 451 if keep_input:
452 452 input_buffer = self.input_buffer
453 453 self._control.clear()
454 454 self._show_prompt()
455 455 if keep_input:
456 456 self.input_buffer = input_buffer
457 457
458 458 def copy(self):
459 459 """ Copy the currently selected text to the clipboard.
460 460 """
461 461 self.layout().currentWidget().copy()
462 462
463 463 def cut(self):
464 464 """ Copy the currently selected text to the clipboard and delete it
465 465 if it's inside the input buffer.
466 466 """
467 467 self.copy()
468 468 if self.can_cut():
469 469 self._control.textCursor().removeSelectedText()
470 470
471 471 def execute(self, source=None, hidden=False, interactive=False):
472 472 """ Executes source or the input buffer, possibly prompting for more
473 473 input.
474 474
475 475 Parameters:
476 476 -----------
477 477 source : str, optional
478 478
479 479 The source to execute. If not specified, the input buffer will be
480 480 used. If specified and 'hidden' is False, the input buffer will be
481 481 replaced with the source before execution.
482 482
483 483 hidden : bool, optional (default False)
484 484
485 485 If set, no output will be shown and the prompt will not be modified.
486 486 In other words, it will be completely invisible to the user that
487 487 an execution has occurred.
488 488
489 489 interactive : bool, optional (default False)
490 490
491 491 Whether the console is to treat the source as having been manually
492 492 entered by the user. The effect of this parameter depends on the
493 493 subclass implementation.
494 494
495 495 Raises:
496 496 -------
497 497 RuntimeError
498 498 If incomplete input is given and 'hidden' is True. In this case,
499 499 it is not possible to prompt for more input.
500 500
501 501 Returns:
502 502 --------
503 503 A boolean indicating whether the source was executed.
504 504 """
505 505 # WARNING: The order in which things happen here is very particular, in
506 506 # large part because our syntax highlighting is fragile. If you change
507 507 # something, test carefully!
508 508
509 509 # Decide what to execute.
510 510 if source is None:
511 511 source = self.input_buffer
512 512 if not hidden:
513 513 # A newline is appended later, but it should be considered part
514 514 # of the input buffer.
515 515 source += '\n'
516 516 elif not hidden:
517 517 self.input_buffer = source
518 518
519 519 # Execute the source or show a continuation prompt if it is incomplete.
520 520 complete = self._is_complete(source, interactive)
521 521 if hidden:
522 522 if complete:
523 523 self._execute(source, hidden)
524 524 else:
525 525 error = 'Incomplete noninteractive input: "%s"'
526 526 raise RuntimeError(error % source)
527 527 else:
528 528 if complete:
529 529 self._append_plain_text('\n')
530 530 self._input_buffer_executing = self.input_buffer
531 531 self._executing = True
532 532 self._prompt_finished()
533 533
534 534 # The maximum block count is only in effect during execution.
535 535 # This ensures that _prompt_pos does not become invalid due to
536 536 # text truncation.
537 537 self._control.document().setMaximumBlockCount(self.buffer_size)
538 538
539 539 # Setting a positive maximum block count will automatically
540 540 # disable the undo/redo history, but just to be safe:
541 541 self._control.setUndoRedoEnabled(False)
542 542
543 543 # Perform actual execution.
544 544 self._execute(source, hidden)
545 545
546 546 else:
547 547 # Do this inside an edit block so continuation prompts are
548 548 # removed seamlessly via undo/redo.
549 549 cursor = self._get_end_cursor()
550 550 cursor.beginEditBlock()
551 551 cursor.insertText('\n')
552 552 self._insert_continuation_prompt(cursor)
553 553 cursor.endEditBlock()
554 554
555 555 # Do not do this inside the edit block. It works as expected
556 556 # when using a QPlainTextEdit control, but does not have an
557 557 # effect when using a QTextEdit. I believe this is a Qt bug.
558 558 self._control.moveCursor(QtGui.QTextCursor.End)
559 559
560 560 return complete
561 561
562 562 def export_html(self):
563 563 """ Shows a dialog to export HTML/XML in various formats.
564 564 """
565 565 self._html_exporter.export()
566 566
567 567 def _get_input_buffer(self, force=False):
568 568 """ The text that the user has entered entered at the current prompt.
569 569
570 570 If the console is currently executing, the text that is executing will
571 571 always be returned.
572 572 """
573 573 # If we're executing, the input buffer may not even exist anymore due to
574 574 # the limit imposed by 'buffer_size'. Therefore, we store it.
575 575 if self._executing and not force:
576 576 return self._input_buffer_executing
577 577
578 578 cursor = self._get_end_cursor()
579 579 cursor.setPosition(self._prompt_pos, QtGui.QTextCursor.KeepAnchor)
580 580 input_buffer = cursor.selection().toPlainText()
581 581
582 582 # Strip out continuation prompts.
583 583 return input_buffer.replace('\n' + self._continuation_prompt, '\n')
584 584
585 585 def _set_input_buffer(self, string):
586 586 """ Sets the text in the input buffer.
587 587
588 588 If the console is currently executing, this call has no *immediate*
589 589 effect. When the execution is finished, the input buffer will be updated
590 590 appropriately.
591 591 """
592 592 # If we're executing, store the text for later.
593 593 if self._executing:
594 594 self._input_buffer_pending = string
595 595 return
596 596
597 597 # Remove old text.
598 598 cursor = self._get_end_cursor()
599 599 cursor.beginEditBlock()
600 600 cursor.setPosition(self._prompt_pos, QtGui.QTextCursor.KeepAnchor)
601 601 cursor.removeSelectedText()
602 602
603 603 # Insert new text with continuation prompts.
604 604 self._insert_plain_text_into_buffer(self._get_prompt_cursor(), string)
605 605 cursor.endEditBlock()
606 606 self._control.moveCursor(QtGui.QTextCursor.End)
607 607
608 608 input_buffer = property(_get_input_buffer, _set_input_buffer)
609 609
610 610 def _get_font(self):
611 611 """ The base font being used by the ConsoleWidget.
612 612 """
613 613 return self._control.document().defaultFont()
614 614
615 615 def _set_font(self, font):
616 616 """ Sets the base font for the ConsoleWidget to the specified QFont.
617 617 """
618 618 font_metrics = QtGui.QFontMetrics(font)
619 619 self._control.setTabStopWidth(self.tab_width * font_metrics.width(' '))
620 620
621 621 self._completion_widget.setFont(font)
622 622 self._control.document().setDefaultFont(font)
623 623 if self._page_control:
624 624 self._page_control.document().setDefaultFont(font)
625 625
626 626 self.font_changed.emit(font)
627 627
628 628 font = property(_get_font, _set_font)
629 629
630 630 def paste(self, mode=QtGui.QClipboard.Clipboard):
631 631 """ Paste the contents of the clipboard into the input region.
632 632
633 633 Parameters:
634 634 -----------
635 635 mode : QClipboard::Mode, optional [default QClipboard::Clipboard]
636 636
637 637 Controls which part of the system clipboard is used. This can be
638 638 used to access the selection clipboard in X11 and the Find buffer
639 639 in Mac OS. By default, the regular clipboard is used.
640 640 """
641 641 if self._control.textInteractionFlags() & QtCore.Qt.TextEditable:
642 642 # Make sure the paste is safe.
643 643 self._keep_cursor_in_buffer()
644 644 cursor = self._control.textCursor()
645 645
646 646 # Remove any trailing newline, which confuses the GUI and forces the
647 647 # user to backspace.
648 648 text = QtGui.QApplication.clipboard().text(mode).rstrip()
649 649 self._insert_plain_text_into_buffer(cursor, dedent(text))
650 650
651 651 def print_(self, printer = None):
652 652 """ Print the contents of the ConsoleWidget to the specified QPrinter.
653 653 """
654 654 if (not printer):
655 655 printer = QtGui.QPrinter()
656 656 if(QtGui.QPrintDialog(printer).exec_() != QtGui.QDialog.Accepted):
657 657 return
658 658 self._control.print_(printer)
659 659
660 660 def prompt_to_top(self):
661 661 """ Moves the prompt to the top of the viewport.
662 662 """
663 663 if not self._executing:
664 664 prompt_cursor = self._get_prompt_cursor()
665 665 if self._get_cursor().blockNumber() < prompt_cursor.blockNumber():
666 666 self._set_cursor(prompt_cursor)
667 667 self._set_top_cursor(prompt_cursor)
668 668
669 669 def redo(self):
670 670 """ Redo the last operation. If there is no operation to redo, nothing
671 671 happens.
672 672 """
673 673 self._control.redo()
674 674
675 675 def reset_font(self):
676 676 """ Sets the font to the default fixed-width font for this platform.
677 677 """
678 678 if sys.platform == 'win32':
679 679 # Consolas ships with Vista/Win7, fallback to Courier if needed
680 680 fallback = 'Courier'
681 681 elif sys.platform == 'darwin':
682 682 # OSX always has Monaco
683 683 fallback = 'Monaco'
684 684 else:
685 685 # Monospace should always exist
686 686 fallback = 'Monospace'
687 687 font = get_font(self.font_family, fallback)
688 688 if self.font_size:
689 689 font.setPointSize(self.font_size)
690 690 else:
691 691 font.setPointSize(QtGui.qApp.font().pointSize())
692 692 font.setStyleHint(QtGui.QFont.TypeWriter)
693 693 self._set_font(font)
694 694
695 695 def change_font_size(self, delta):
696 696 """Change the font size by the specified amount (in points).
697 697 """
698 698 font = self.font
699 699 size = max(font.pointSize() + delta, 1) # minimum 1 point
700 700 font.setPointSize(size)
701 701 self._set_font(font)
702 702
703 703 def _increase_font_size(self):
704 704 self.change_font_size(1)
705 705
706 706 def _decrease_font_size(self):
707 707 self.change_font_size(-1)
708 708
709 709 def select_all(self):
710 710 """ Selects all the text in the buffer.
711 711 """
712 712 self._control.selectAll()
713 713
714 714 def _get_tab_width(self):
715 715 """ The width (in terms of space characters) for tab characters.
716 716 """
717 717 return self._tab_width
718 718
719 719 def _set_tab_width(self, tab_width):
720 720 """ Sets the width (in terms of space characters) for tab characters.
721 721 """
722 722 font_metrics = QtGui.QFontMetrics(self.font)
723 723 self._control.setTabStopWidth(tab_width * font_metrics.width(' '))
724 724
725 725 self._tab_width = tab_width
726 726
727 727 tab_width = property(_get_tab_width, _set_tab_width)
728 728
729 729 def undo(self):
730 730 """ Undo the last operation. If there is no operation to undo, nothing
731 731 happens.
732 732 """
733 733 self._control.undo()
734 734
735 735 #---------------------------------------------------------------------------
736 736 # 'ConsoleWidget' abstract interface
737 737 #---------------------------------------------------------------------------
738 738
739 739 def _is_complete(self, source, interactive):
740 740 """ Returns whether 'source' can be executed. When triggered by an
741 741 Enter/Return key press, 'interactive' is True; otherwise, it is
742 742 False.
743 743 """
744 744 raise NotImplementedError
745 745
746 746 def _execute(self, source, hidden):
747 747 """ Execute 'source'. If 'hidden', do not show any output.
748 748 """
749 749 raise NotImplementedError
750 750
751 751 def _prompt_started_hook(self):
752 752 """ Called immediately after a new prompt is displayed.
753 753 """
754 754 pass
755 755
756 756 def _prompt_finished_hook(self):
757 757 """ Called immediately after a prompt is finished, i.e. when some input
758 758 will be processed and a new prompt displayed.
759 759 """
760 760 pass
761 761
762 762 def _up_pressed(self, shift_modifier):
763 763 """ Called when the up key is pressed. Returns whether to continue
764 764 processing the event.
765 765 """
766 766 return True
767 767
768 768 def _down_pressed(self, shift_modifier):
769 769 """ Called when the down key is pressed. Returns whether to continue
770 770 processing the event.
771 771 """
772 772 return True
773 773
774 774 def _tab_pressed(self):
775 775 """ Called when the tab key is pressed. Returns whether to continue
776 776 processing the event.
777 777 """
778 778 return False
779 779
780 780 #--------------------------------------------------------------------------
781 781 # 'ConsoleWidget' protected interface
782 782 #--------------------------------------------------------------------------
783 783
784 784 def _append_custom(self, insert, input, before_prompt=False):
785 785 """ A low-level method for appending content to the end of the buffer.
786 786
787 787 If 'before_prompt' is enabled, the content will be inserted before the
788 788 current prompt, if there is one.
789 789 """
790 790 # Determine where to insert the content.
791 791 cursor = self._control.textCursor()
792 792 if before_prompt and (self._reading or not self._executing):
793 793 cursor.setPosition(self._append_before_prompt_pos)
794 794 else:
795 795 cursor.movePosition(QtGui.QTextCursor.End)
796 796 start_pos = cursor.position()
797 797
798 798 # Perform the insertion.
799 799 result = insert(cursor, input)
800 800
801 801 # Adjust the prompt position if we have inserted before it. This is safe
802 802 # because buffer truncation is disabled when not executing.
803 803 if before_prompt and not self._executing:
804 804 diff = cursor.position() - start_pos
805 805 self._append_before_prompt_pos += diff
806 806 self._prompt_pos += diff
807 807
808 808 return result
809 809
810 810 def _append_html(self, html, before_prompt=False):
811 811 """ Appends HTML at the end of the console buffer.
812 812 """
813 813 self._append_custom(self._insert_html, html, before_prompt)
814 814
815 815 def _append_html_fetching_plain_text(self, html, before_prompt=False):
816 816 """ Appends HTML, then returns the plain text version of it.
817 817 """
818 818 return self._append_custom(self._insert_html_fetching_plain_text,
819 819 html, before_prompt)
820 820
821 821 def _append_plain_text(self, text, before_prompt=False):
822 822 """ Appends plain text, processing ANSI codes if enabled.
823 823 """
824 824 self._append_custom(self._insert_plain_text, text, before_prompt)
825 825
826 826 def _cancel_text_completion(self):
827 827 """ If text completion is progress, cancel it.
828 828 """
829 829 if self._text_completing_pos:
830 830 self._clear_temporary_buffer()
831 831 self._text_completing_pos = 0
832 832
833 833 def _clear_temporary_buffer(self):
834 834 """ Clears the "temporary text" buffer, i.e. all the text following
835 835 the prompt region.
836 836 """
837 837 # Select and remove all text below the input buffer.
838 838 cursor = self._get_prompt_cursor()
839 839 prompt = self._continuation_prompt.lstrip()
840 840 while cursor.movePosition(QtGui.QTextCursor.NextBlock):
841 841 temp_cursor = QtGui.QTextCursor(cursor)
842 842 temp_cursor.select(QtGui.QTextCursor.BlockUnderCursor)
843 843 text = temp_cursor.selection().toPlainText().lstrip()
844 844 if not text.startswith(prompt):
845 845 break
846 846 else:
847 847 # We've reached the end of the input buffer and no text follows.
848 848 return
849 849 cursor.movePosition(QtGui.QTextCursor.Left) # Grab the newline.
850 850 cursor.movePosition(QtGui.QTextCursor.End,
851 851 QtGui.QTextCursor.KeepAnchor)
852 852 cursor.removeSelectedText()
853 853
854 854 # After doing this, we have no choice but to clear the undo/redo
855 855 # history. Otherwise, the text is not "temporary" at all, because it
856 856 # can be recalled with undo/redo. Unfortunately, Qt does not expose
857 857 # fine-grained control to the undo/redo system.
858 858 if self._control.isUndoRedoEnabled():
859 859 self._control.setUndoRedoEnabled(False)
860 860 self._control.setUndoRedoEnabled(True)
861 861
862 862 def _complete_with_items(self, cursor, items):
863 863 """ Performs completion with 'items' at the specified cursor location.
864 864 """
865 865 self._cancel_text_completion()
866 866
867 867 if len(items) == 1:
868 868 cursor.setPosition(self._control.textCursor().position(),
869 869 QtGui.QTextCursor.KeepAnchor)
870 870 cursor.insertText(items[0])
871 871
872 872 elif len(items) > 1:
873 873 current_pos = self._control.textCursor().position()
874 874 prefix = commonprefix(items)
875 875 if prefix:
876 876 cursor.setPosition(current_pos, QtGui.QTextCursor.KeepAnchor)
877 877 cursor.insertText(prefix)
878 878 current_pos = cursor.position()
879 879
880 880 if self.gui_completion:
881 881 cursor.movePosition(QtGui.QTextCursor.Left, n=len(prefix))
882 882 self._completion_widget.show_items(cursor, items)
883 883 else:
884 884 cursor.beginEditBlock()
885 885 self._append_plain_text('\n')
886 886 self._page(self._format_as_columns(items))
887 887 cursor.endEditBlock()
888 888
889 889 cursor.setPosition(current_pos)
890 890 self._control.moveCursor(QtGui.QTextCursor.End)
891 891 self._control.setTextCursor(cursor)
892 892 self._text_completing_pos = current_pos
893 893
894 894 def _context_menu_make(self, pos):
895 895 """ Creates a context menu for the given QPoint (in widget coordinates).
896 896 """
897 897 menu = QtGui.QMenu(self)
898 898
899 899 self.cut_action = menu.addAction('Cut', self.cut)
900 900 self.cut_action.setEnabled(self.can_cut())
901 901 self.cut_action.setShortcut(QtGui.QKeySequence.Cut)
902 902
903 903 self.copy_action = menu.addAction('Copy', self.copy)
904 904 self.copy_action.setEnabled(self.can_copy())
905 905 self.copy_action.setShortcut(QtGui.QKeySequence.Copy)
906 906
907 907 self.paste_action = menu.addAction('Paste', self.paste)
908 908 self.paste_action.setEnabled(self.can_paste())
909 909 self.paste_action.setShortcut(QtGui.QKeySequence.Paste)
910 910
911 911 menu.addSeparator()
912 912 menu.addAction(self.select_all_action)
913 913
914 914 menu.addSeparator()
915 915 menu.addAction(self.export_action)
916 916 menu.addAction(self.print_action)
917 917
918 918 return menu
919 919
920 920 def _control_key_down(self, modifiers, include_command=False):
921 921 """ Given a KeyboardModifiers flags object, return whether the Control
922 922 key is down.
923 923
924 924 Parameters:
925 925 -----------
926 926 include_command : bool, optional (default True)
927 927 Whether to treat the Command key as a (mutually exclusive) synonym
928 928 for Control when in Mac OS.
929 929 """
930 930 # Note that on Mac OS, ControlModifier corresponds to the Command key
931 931 # while MetaModifier corresponds to the Control key.
932 932 if sys.platform == 'darwin':
933 933 down = include_command and (modifiers & QtCore.Qt.ControlModifier)
934 934 return bool(down) ^ bool(modifiers & QtCore.Qt.MetaModifier)
935 935 else:
936 936 return bool(modifiers & QtCore.Qt.ControlModifier)
937 937
938 938 def _create_control(self):
939 939 """ Creates and connects the underlying text widget.
940 940 """
941 941 # Create the underlying control.
942 942 if self.kind == 'plain':
943 943 control = QtGui.QPlainTextEdit()
944 944 elif self.kind == 'rich':
945 945 control = QtGui.QTextEdit()
946 946 control.setAcceptRichText(False)
947 947
948 948 # Install event filters. The filter on the viewport is needed for
949 949 # mouse events and drag events.
950 950 control.installEventFilter(self)
951 951 control.viewport().installEventFilter(self)
952 952
953 953 # Connect signals.
954 954 control.cursorPositionChanged.connect(self._cursor_position_changed)
955 955 control.customContextMenuRequested.connect(
956 956 self._custom_context_menu_requested)
957 957 control.copyAvailable.connect(self.copy_available)
958 958 control.redoAvailable.connect(self.redo_available)
959 959 control.undoAvailable.connect(self.undo_available)
960 960
961 961 # Hijack the document size change signal to prevent Qt from adjusting
962 962 # the viewport's scrollbar. We are relying on an implementation detail
963 963 # of Q(Plain)TextEdit here, which is potentially dangerous, but without
964 964 # this functionality we cannot create a nice terminal interface.
965 965 layout = control.document().documentLayout()
966 966 layout.documentSizeChanged.disconnect()
967 967 layout.documentSizeChanged.connect(self._adjust_scrollbars)
968 968
969 969 # Configure the control.
970 970 control.setAttribute(QtCore.Qt.WA_InputMethodEnabled, True)
971 971 control.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
972 972 control.setReadOnly(True)
973 973 control.setUndoRedoEnabled(False)
974 974 control.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn)
975 975 return control
976 976
977 977 def _create_page_control(self):
978 978 """ Creates and connects the underlying paging widget.
979 979 """
980 980 if self.kind == 'plain':
981 981 control = QtGui.QPlainTextEdit()
982 982 elif self.kind == 'rich':
983 983 control = QtGui.QTextEdit()
984 984 control.installEventFilter(self)
985 985 viewport = control.viewport()
986 986 viewport.installEventFilter(self)
987 987 control.setReadOnly(True)
988 988 control.setUndoRedoEnabled(False)
989 989 control.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn)
990 990 return control
991 991
992 992 def _event_filter_console_keypress(self, event):
993 993 """ Filter key events for the underlying text widget to create a
994 994 console-like interface.
995 995 """
996 996 intercepted = False
997 997 cursor = self._control.textCursor()
998 998 position = cursor.position()
999 999 key = event.key()
1000 1000 ctrl_down = self._control_key_down(event.modifiers())
1001 1001 alt_down = event.modifiers() & QtCore.Qt.AltModifier
1002 1002 shift_down = event.modifiers() & QtCore.Qt.ShiftModifier
1003 1003
1004 1004 #------ Special sequences ----------------------------------------------
1005 1005
1006 1006 if event.matches(QtGui.QKeySequence.Copy):
1007 1007 self.copy()
1008 1008 intercepted = True
1009 1009
1010 1010 elif event.matches(QtGui.QKeySequence.Cut):
1011 1011 self.cut()
1012 1012 intercepted = True
1013 1013
1014 1014 elif event.matches(QtGui.QKeySequence.Paste):
1015 1015 self.paste()
1016 1016 intercepted = True
1017 1017
1018 1018 #------ Special modifier logic -----------------------------------------
1019 1019
1020 1020 elif key in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter):
1021 1021 intercepted = True
1022 1022
1023 1023 # Special handling when tab completing in text mode.
1024 1024 self._cancel_text_completion()
1025 1025
1026 1026 if self._in_buffer(position):
1027 1027 # Special handling when a reading a line of raw input.
1028 1028 if self._reading:
1029 1029 self._append_plain_text('\n')
1030 1030 self._reading = False
1031 1031 if self._reading_callback:
1032 1032 self._reading_callback()
1033 1033
1034 1034 # If the input buffer is a single line or there is only
1035 1035 # whitespace after the cursor, execute. Otherwise, split the
1036 1036 # line with a continuation prompt.
1037 1037 elif not self._executing:
1038 1038 cursor.movePosition(QtGui.QTextCursor.End,
1039 1039 QtGui.QTextCursor.KeepAnchor)
1040 1040 at_end = len(cursor.selectedText().strip()) == 0
1041 1041 single_line = (self._get_end_cursor().blockNumber() ==
1042 1042 self._get_prompt_cursor().blockNumber())
1043 1043 if (at_end or shift_down or single_line) and not ctrl_down:
1044 1044 self.execute(interactive = not shift_down)
1045 1045 else:
1046 1046 # Do this inside an edit block for clean undo/redo.
1047 1047 cursor.beginEditBlock()
1048 1048 cursor.setPosition(position)
1049 1049 cursor.insertText('\n')
1050 1050 self._insert_continuation_prompt(cursor)
1051 1051 cursor.endEditBlock()
1052 1052
1053 1053 # Ensure that the whole input buffer is visible.
1054 1054 # FIXME: This will not be usable if the input buffer is
1055 1055 # taller than the console widget.
1056 1056 self._control.moveCursor(QtGui.QTextCursor.End)
1057 1057 self._control.setTextCursor(cursor)
1058 1058
1059 1059 #------ Control/Cmd modifier -------------------------------------------
1060 1060
1061 1061 elif ctrl_down:
1062 1062 if key == QtCore.Qt.Key_G:
1063 1063 self._keyboard_quit()
1064 1064 intercepted = True
1065 1065
1066 1066 elif key == QtCore.Qt.Key_K:
1067 1067 if self._in_buffer(position):
1068 1068 cursor.clearSelection()
1069 1069 cursor.movePosition(QtGui.QTextCursor.EndOfLine,
1070 1070 QtGui.QTextCursor.KeepAnchor)
1071 1071 if not cursor.hasSelection():
1072 1072 # Line deletion (remove continuation prompt)
1073 1073 cursor.movePosition(QtGui.QTextCursor.NextBlock,
1074 1074 QtGui.QTextCursor.KeepAnchor)
1075 1075 cursor.movePosition(QtGui.QTextCursor.Right,
1076 1076 QtGui.QTextCursor.KeepAnchor,
1077 1077 len(self._continuation_prompt))
1078 1078 self._kill_ring.kill_cursor(cursor)
1079 1079 self._set_cursor(cursor)
1080 1080 intercepted = True
1081 1081
1082 1082 elif key == QtCore.Qt.Key_L:
1083 1083 self.prompt_to_top()
1084 1084 intercepted = True
1085 1085
1086 1086 elif key == QtCore.Qt.Key_O:
1087 1087 if self._page_control and self._page_control.isVisible():
1088 1088 self._page_control.setFocus()
1089 1089 intercepted = True
1090 1090
1091 1091 elif key == QtCore.Qt.Key_U:
1092 1092 if self._in_buffer(position):
1093 1093 cursor.clearSelection()
1094 1094 start_line = cursor.blockNumber()
1095 1095 if start_line == self._get_prompt_cursor().blockNumber():
1096 1096 offset = len(self._prompt)
1097 1097 else:
1098 1098 offset = len(self._continuation_prompt)
1099 1099 cursor.movePosition(QtGui.QTextCursor.StartOfBlock,
1100 1100 QtGui.QTextCursor.KeepAnchor)
1101 1101 cursor.movePosition(QtGui.QTextCursor.Right,
1102 1102 QtGui.QTextCursor.KeepAnchor, offset)
1103 1103 self._kill_ring.kill_cursor(cursor)
1104 1104 self._set_cursor(cursor)
1105 1105 intercepted = True
1106 1106
1107 1107 elif key == QtCore.Qt.Key_Y:
1108 1108 self._keep_cursor_in_buffer()
1109 1109 self._kill_ring.yank()
1110 1110 intercepted = True
1111 1111
1112 1112 elif key in (QtCore.Qt.Key_Backspace, QtCore.Qt.Key_Delete):
1113 1113 if key == QtCore.Qt.Key_Backspace:
1114 1114 cursor = self._get_word_start_cursor(position)
1115 1115 else: # key == QtCore.Qt.Key_Delete
1116 1116 cursor = self._get_word_end_cursor(position)
1117 1117 cursor.setPosition(position, QtGui.QTextCursor.KeepAnchor)
1118 1118 self._kill_ring.kill_cursor(cursor)
1119 1119 intercepted = True
1120 1120
1121 1121 elif key == QtCore.Qt.Key_D:
1122 1122 if len(self.input_buffer) == 0:
1123 1123 self.exit_requested.emit(self)
1124 1124 else:
1125 1125 new_event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress,
1126 1126 QtCore.Qt.Key_Delete,
1127 1127 QtCore.Qt.NoModifier)
1128 1128 QtGui.qApp.sendEvent(self._control, new_event)
1129 1129 intercepted = True
1130 1130
1131 1131 #------ Alt modifier ---------------------------------------------------
1132 1132
1133 1133 elif alt_down:
1134 1134 if key == QtCore.Qt.Key_B:
1135 1135 self._set_cursor(self._get_word_start_cursor(position))
1136 1136 intercepted = True
1137 1137
1138 1138 elif key == QtCore.Qt.Key_F:
1139 1139 self._set_cursor(self._get_word_end_cursor(position))
1140 1140 intercepted = True
1141 1141
1142 1142 elif key == QtCore.Qt.Key_Y:
1143 1143 self._kill_ring.rotate()
1144 1144 intercepted = True
1145 1145
1146 1146 elif key == QtCore.Qt.Key_Backspace:
1147 1147 cursor = self._get_word_start_cursor(position)
1148 1148 cursor.setPosition(position, QtGui.QTextCursor.KeepAnchor)
1149 1149 self._kill_ring.kill_cursor(cursor)
1150 1150 intercepted = True
1151 1151
1152 1152 elif key == QtCore.Qt.Key_D:
1153 1153 cursor = self._get_word_end_cursor(position)
1154 1154 cursor.setPosition(position, QtGui.QTextCursor.KeepAnchor)
1155 1155 self._kill_ring.kill_cursor(cursor)
1156 1156 intercepted = True
1157 1157
1158 1158 elif key == QtCore.Qt.Key_Delete:
1159 1159 intercepted = True
1160 1160
1161 1161 elif key == QtCore.Qt.Key_Greater:
1162 1162 self._control.moveCursor(QtGui.QTextCursor.End)
1163 1163 intercepted = True
1164 1164
1165 1165 elif key == QtCore.Qt.Key_Less:
1166 1166 self._control.setTextCursor(self._get_prompt_cursor())
1167 1167 intercepted = True
1168 1168
1169 1169 #------ No modifiers ---------------------------------------------------
1170 1170
1171 1171 else:
1172 1172 if shift_down:
1173 1173 anchormode = QtGui.QTextCursor.KeepAnchor
1174 1174 else:
1175 1175 anchormode = QtGui.QTextCursor.MoveAnchor
1176 1176
1177 1177 if key == QtCore.Qt.Key_Escape:
1178 1178 self._keyboard_quit()
1179 1179 intercepted = True
1180 1180
1181 1181 elif key == QtCore.Qt.Key_Up:
1182 1182 if self._reading or not self._up_pressed(shift_down):
1183 1183 intercepted = True
1184 1184 else:
1185 1185 prompt_line = self._get_prompt_cursor().blockNumber()
1186 1186 intercepted = cursor.blockNumber() <= prompt_line
1187 1187
1188 1188 elif key == QtCore.Qt.Key_Down:
1189 1189 if self._reading or not self._down_pressed(shift_down):
1190 1190 intercepted = True
1191 1191 else:
1192 1192 end_line = self._get_end_cursor().blockNumber()
1193 1193 intercepted = cursor.blockNumber() == end_line
1194 1194
1195 1195 elif key == QtCore.Qt.Key_Tab:
1196 1196 if not self._reading:
1197 1197 if self._tab_pressed():
1198 1198 # real tab-key, insert four spaces
1199 1199 cursor.insertText(' '*4)
1200 1200 intercepted = True
1201 1201
1202 1202 elif key == QtCore.Qt.Key_Left:
1203 1203
1204 1204 # Move to the previous line
1205 1205 line, col = cursor.blockNumber(), cursor.columnNumber()
1206 1206 if line > self._get_prompt_cursor().blockNumber() and \
1207 1207 col == len(self._continuation_prompt):
1208 1208 self._control.moveCursor(QtGui.QTextCursor.PreviousBlock,
1209 1209 mode=anchormode)
1210 1210 self._control.moveCursor(QtGui.QTextCursor.EndOfBlock,
1211 1211 mode=anchormode)
1212 1212 intercepted = True
1213 1213
1214 1214 # Regular left movement
1215 1215 else:
1216 1216 intercepted = not self._in_buffer(position - 1)
1217 1217
1218 1218 elif key == QtCore.Qt.Key_Right:
1219 1219 original_block_number = cursor.blockNumber()
1220 1220 cursor.movePosition(QtGui.QTextCursor.Right,
1221 1221 mode=anchormode)
1222 1222 if cursor.blockNumber() != original_block_number:
1223 1223 cursor.movePosition(QtGui.QTextCursor.Right,
1224 1224 n=len(self._continuation_prompt),
1225 1225 mode=anchormode)
1226 1226 self._set_cursor(cursor)
1227 1227 intercepted = True
1228 1228
1229 1229 elif key == QtCore.Qt.Key_Home:
1230 1230 start_line = cursor.blockNumber()
1231 1231 if start_line == self._get_prompt_cursor().blockNumber():
1232 1232 start_pos = self._prompt_pos
1233 1233 else:
1234 1234 cursor.movePosition(QtGui.QTextCursor.StartOfBlock,
1235 1235 QtGui.QTextCursor.KeepAnchor)
1236 1236 start_pos = cursor.position()
1237 1237 start_pos += len(self._continuation_prompt)
1238 1238 cursor.setPosition(position)
1239 1239 if shift_down and self._in_buffer(position):
1240 1240 cursor.setPosition(start_pos, QtGui.QTextCursor.KeepAnchor)
1241 1241 else:
1242 1242 cursor.setPosition(start_pos)
1243 1243 self._set_cursor(cursor)
1244 1244 intercepted = True
1245 1245
1246 1246 elif key == QtCore.Qt.Key_Backspace:
1247 1247
1248 1248 # Line deletion (remove continuation prompt)
1249 1249 line, col = cursor.blockNumber(), cursor.columnNumber()
1250 1250 if not self._reading and \
1251 1251 col == len(self._continuation_prompt) and \
1252 1252 line > self._get_prompt_cursor().blockNumber():
1253 1253 cursor.beginEditBlock()
1254 1254 cursor.movePosition(QtGui.QTextCursor.StartOfBlock,
1255 1255 QtGui.QTextCursor.KeepAnchor)
1256 1256 cursor.removeSelectedText()
1257 1257 cursor.deletePreviousChar()
1258 1258 cursor.endEditBlock()
1259 1259 intercepted = True
1260 1260
1261 1261 # Regular backwards deletion
1262 1262 else:
1263 1263 anchor = cursor.anchor()
1264 1264 if anchor == position:
1265 1265 intercepted = not self._in_buffer(position - 1)
1266 1266 else:
1267 1267 intercepted = not self._in_buffer(min(anchor, position))
1268 1268
1269 1269 elif key == QtCore.Qt.Key_Delete:
1270 1270
1271 1271 # Line deletion (remove continuation prompt)
1272 1272 if not self._reading and self._in_buffer(position) and \
1273 1273 cursor.atBlockEnd() and not cursor.hasSelection():
1274 1274 cursor.movePosition(QtGui.QTextCursor.NextBlock,
1275 1275 QtGui.QTextCursor.KeepAnchor)
1276 1276 cursor.movePosition(QtGui.QTextCursor.Right,
1277 1277 QtGui.QTextCursor.KeepAnchor,
1278 1278 len(self._continuation_prompt))
1279 1279 cursor.removeSelectedText()
1280 1280 intercepted = True
1281 1281
1282 1282 # Regular forwards deletion:
1283 1283 else:
1284 1284 anchor = cursor.anchor()
1285 1285 intercepted = (not self._in_buffer(anchor) or
1286 1286 not self._in_buffer(position))
1287 1287
1288 1288 # Don't move the cursor if Control/Cmd is pressed to allow copy-paste
1289 1289 # using the keyboard in any part of the buffer. Also, permit scrolling
1290 1290 # with Page Up/Down keys. Finally, if we're executing, don't move the
1291 1291 # cursor (if even this made sense, we can't guarantee that the prompt
1292 1292 # position is still valid due to text truncation).
1293 1293 if not (self._control_key_down(event.modifiers(), include_command=True)
1294 1294 or key in (QtCore.Qt.Key_PageUp, QtCore.Qt.Key_PageDown)
1295 1295 or (self._executing and not self._reading)):
1296 1296 self._keep_cursor_in_buffer()
1297 1297
1298 1298 return intercepted
1299 1299
1300 1300 def _event_filter_page_keypress(self, event):
1301 1301 """ Filter key events for the paging widget to create console-like
1302 1302 interface.
1303 1303 """
1304 1304 key = event.key()
1305 1305 ctrl_down = self._control_key_down(event.modifiers())
1306 1306 alt_down = event.modifiers() & QtCore.Qt.AltModifier
1307 1307
1308 1308 if ctrl_down:
1309 1309 if key == QtCore.Qt.Key_O:
1310 1310 self._control.setFocus()
1311 1311 intercept = True
1312 1312
1313 1313 elif alt_down:
1314 1314 if key == QtCore.Qt.Key_Greater:
1315 1315 self._page_control.moveCursor(QtGui.QTextCursor.End)
1316 1316 intercepted = True
1317 1317
1318 1318 elif key == QtCore.Qt.Key_Less:
1319 1319 self._page_control.moveCursor(QtGui.QTextCursor.Start)
1320 1320 intercepted = True
1321 1321
1322 1322 elif key in (QtCore.Qt.Key_Q, QtCore.Qt.Key_Escape):
1323 1323 if self._splitter:
1324 1324 self._page_control.hide()
1325 1325 self._control.setFocus()
1326 1326 else:
1327 1327 self.layout().setCurrentWidget(self._control)
1328 1328 return True
1329 1329
1330 1330 elif key in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return,
1331 1331 QtCore.Qt.Key_Tab):
1332 1332 new_event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress,
1333 1333 QtCore.Qt.Key_PageDown,
1334 1334 QtCore.Qt.NoModifier)
1335 1335 QtGui.qApp.sendEvent(self._page_control, new_event)
1336 1336 return True
1337 1337
1338 1338 elif key == QtCore.Qt.Key_Backspace:
1339 1339 new_event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress,
1340 1340 QtCore.Qt.Key_PageUp,
1341 1341 QtCore.Qt.NoModifier)
1342 1342 QtGui.qApp.sendEvent(self._page_control, new_event)
1343 1343 return True
1344 1344
1345 1345 return False
1346 1346
1347 1347 def _format_as_columns(self, items, separator=' '):
1348 1348 """ Transform a list of strings into a single string with columns.
1349 1349
1350 1350 Parameters
1351 1351 ----------
1352 1352 items : sequence of strings
1353 1353 The strings to process.
1354 1354
1355 1355 separator : str, optional [default is two spaces]
1356 1356 The string that separates columns.
1357 1357
1358 1358 Returns
1359 1359 -------
1360 1360 The formatted string.
1361 1361 """
1362 1362 # Calculate the number of characters available.
1363 1363 width = self._control.viewport().width()
1364 1364 char_width = QtGui.QFontMetrics(self.font).width(' ')
1365 1365 displaywidth = max(10, (width / char_width) - 1)
1366 1366
1367 1367 return columnize(items, separator, displaywidth)
1368 1368
1369 1369 def _get_block_plain_text(self, block):
1370 1370 """ Given a QTextBlock, return its unformatted text.
1371 1371 """
1372 1372 cursor = QtGui.QTextCursor(block)
1373 1373 cursor.movePosition(QtGui.QTextCursor.StartOfBlock)
1374 1374 cursor.movePosition(QtGui.QTextCursor.EndOfBlock,
1375 1375 QtGui.QTextCursor.KeepAnchor)
1376 1376 return cursor.selection().toPlainText()
1377 1377
1378 1378 def _get_cursor(self):
1379 1379 """ Convenience method that returns a cursor for the current position.
1380 1380 """
1381 1381 return self._control.textCursor()
1382 1382
1383 1383 def _get_end_cursor(self):
1384 1384 """ Convenience method that returns a cursor for the last character.
1385 1385 """
1386 1386 cursor = self._control.textCursor()
1387 1387 cursor.movePosition(QtGui.QTextCursor.End)
1388 1388 return cursor
1389 1389
1390 1390 def _get_input_buffer_cursor_column(self):
1391 1391 """ Returns the column of the cursor in the input buffer, excluding the
1392 1392 contribution by the prompt, or -1 if there is no such column.
1393 1393 """
1394 1394 prompt = self._get_input_buffer_cursor_prompt()
1395 1395 if prompt is None:
1396 1396 return -1
1397 1397 else:
1398 1398 cursor = self._control.textCursor()
1399 1399 return cursor.columnNumber() - len(prompt)
1400 1400
1401 1401 def _get_input_buffer_cursor_line(self):
1402 1402 """ Returns the text of the line of the input buffer that contains the
1403 1403 cursor, or None if there is no such line.
1404 1404 """
1405 1405 prompt = self._get_input_buffer_cursor_prompt()
1406 1406 if prompt is None:
1407 1407 return None
1408 1408 else:
1409 1409 cursor = self._control.textCursor()
1410 1410 text = self._get_block_plain_text(cursor.block())
1411 1411 return text[len(prompt):]
1412 1412
1413 1413 def _get_input_buffer_cursor_prompt(self):
1414 1414 """ Returns the (plain text) prompt for line of the input buffer that
1415 1415 contains the cursor, or None if there is no such line.
1416 1416 """
1417 1417 if self._executing:
1418 1418 return None
1419 1419 cursor = self._control.textCursor()
1420 1420 if cursor.position() >= self._prompt_pos:
1421 1421 if cursor.blockNumber() == self._get_prompt_cursor().blockNumber():
1422 1422 return self._prompt
1423 1423 else:
1424 1424 return self._continuation_prompt
1425 1425 else:
1426 1426 return None
1427 1427
1428 1428 def _get_prompt_cursor(self):
1429 1429 """ Convenience method that returns a cursor for the prompt position.
1430 1430 """
1431 1431 cursor = self._control.textCursor()
1432 1432 cursor.setPosition(self._prompt_pos)
1433 1433 return cursor
1434 1434
1435 1435 def _get_selection_cursor(self, start, end):
1436 1436 """ Convenience method that returns a cursor with text selected between
1437 1437 the positions 'start' and 'end'.
1438 1438 """
1439 1439 cursor = self._control.textCursor()
1440 1440 cursor.setPosition(start)
1441 1441 cursor.setPosition(end, QtGui.QTextCursor.KeepAnchor)
1442 1442 return cursor
1443 1443
1444 1444 def _get_word_start_cursor(self, position):
1445 1445 """ Find the start of the word to the left the given position. If a
1446 1446 sequence of non-word characters precedes the first word, skip over
1447 1447 them. (This emulates the behavior of bash, emacs, etc.)
1448 1448 """
1449 1449 document = self._control.document()
1450 1450 position -= 1
1451 1451 while position >= self._prompt_pos and \
1452 1452 not is_letter_or_number(document.characterAt(position)):
1453 1453 position -= 1
1454 1454 while position >= self._prompt_pos and \
1455 1455 is_letter_or_number(document.characterAt(position)):
1456 1456 position -= 1
1457 1457 cursor = self._control.textCursor()
1458 1458 cursor.setPosition(position + 1)
1459 1459 return cursor
1460 1460
1461 1461 def _get_word_end_cursor(self, position):
1462 1462 """ Find the end of the word to the right the given position. If a
1463 1463 sequence of non-word characters precedes the first word, skip over
1464 1464 them. (This emulates the behavior of bash, emacs, etc.)
1465 1465 """
1466 1466 document = self._control.document()
1467 1467 end = self._get_end_cursor().position()
1468 1468 while position < end and \
1469 1469 not is_letter_or_number(document.characterAt(position)):
1470 1470 position += 1
1471 1471 while position < end and \
1472 1472 is_letter_or_number(document.characterAt(position)):
1473 1473 position += 1
1474 1474 cursor = self._control.textCursor()
1475 1475 cursor.setPosition(position)
1476 1476 return cursor
1477 1477
1478 1478 def _insert_continuation_prompt(self, cursor):
1479 1479 """ Inserts new continuation prompt using the specified cursor.
1480 1480 """
1481 1481 if self._continuation_prompt_html is None:
1482 1482 self._insert_plain_text(cursor, self._continuation_prompt)
1483 1483 else:
1484 1484 self._continuation_prompt = self._insert_html_fetching_plain_text(
1485 1485 cursor, self._continuation_prompt_html)
1486 1486
1487 1487 def _insert_html(self, cursor, html):
1488 1488 """ Inserts HTML using the specified cursor in such a way that future
1489 1489 formatting is unaffected.
1490 1490 """
1491 1491 cursor.beginEditBlock()
1492 1492 cursor.insertHtml(html)
1493 1493
1494 1494 # After inserting HTML, the text document "remembers" it's in "html
1495 1495 # mode", which means that subsequent calls adding plain text will result
1496 1496 # in unwanted formatting, lost tab characters, etc. The following code
1497 1497 # hacks around this behavior, which I consider to be a bug in Qt, by
1498 1498 # (crudely) resetting the document's style state.
1499 1499 cursor.movePosition(QtGui.QTextCursor.Left,
1500 1500 QtGui.QTextCursor.KeepAnchor)
1501 1501 if cursor.selection().toPlainText() == ' ':
1502 1502 cursor.removeSelectedText()
1503 1503 else:
1504 1504 cursor.movePosition(QtGui.QTextCursor.Right)
1505 1505 cursor.insertText(' ', QtGui.QTextCharFormat())
1506 1506 cursor.endEditBlock()
1507 1507
1508 1508 def _insert_html_fetching_plain_text(self, cursor, html):
1509 1509 """ Inserts HTML using the specified cursor, then returns its plain text
1510 1510 version.
1511 1511 """
1512 1512 cursor.beginEditBlock()
1513 1513 cursor.removeSelectedText()
1514 1514
1515 1515 start = cursor.position()
1516 1516 self._insert_html(cursor, html)
1517 1517 end = cursor.position()
1518 1518 cursor.setPosition(start, QtGui.QTextCursor.KeepAnchor)
1519 1519 text = cursor.selection().toPlainText()
1520 1520
1521 1521 cursor.setPosition(end)
1522 1522 cursor.endEditBlock()
1523 1523 return text
1524 1524
1525 1525 def _insert_plain_text(self, cursor, text):
1526 1526 """ Inserts plain text using the specified cursor, processing ANSI codes
1527 1527 if enabled.
1528 1528 """
1529 1529 cursor.beginEditBlock()
1530 1530 if self.ansi_codes:
1531 1531 for substring in self._ansi_processor.split_string(text):
1532 1532 for act in self._ansi_processor.actions:
1533 1533
1534 1534 # Unlike real terminal emulators, we don't distinguish
1535 1535 # between the screen and the scrollback buffer. A screen
1536 1536 # erase request clears everything.
1537 1537 if act.action == 'erase' and act.area == 'screen':
1538 1538 cursor.select(QtGui.QTextCursor.Document)
1539 1539 cursor.removeSelectedText()
1540 1540
1541 1541 # Simulate a form feed by scrolling just past the last line.
1542 1542 elif act.action == 'scroll' and act.unit == 'page':
1543 1543 cursor.insertText('\n')
1544 1544 cursor.endEditBlock()
1545 1545 self._set_top_cursor(cursor)
1546 1546 cursor.joinPreviousEditBlock()
1547 1547 cursor.deletePreviousChar()
1548 1548
1549 1549 elif act.action == 'carriage-return':
1550 1550 cursor.movePosition(
1551 1551 cursor.StartOfLine, cursor.KeepAnchor)
1552 1552
1553 1553 elif act.action == 'beep':
1554 1554 QtGui.qApp.beep()
1555 1555
1556 elif act.action == 'backspace':
1557 if not cursor.atBlockStart():
1558 cursor.movePosition(
1559 cursor.PreviousCharacter, cursor.KeepAnchor)
1560
1561 elif act.action == 'newline':
1562 cursor.movePosition(cursor.EndOfLine)
1563
1556 1564 format = self._ansi_processor.get_format()
1565
1566 selection = cursor.selectedText()
1567 if len(selection) == 0:
1568 cursor.insertText(substring, format)
1569 elif substring is not None:
1570 # BS and CR are treated as a change in print
1571 # position, rather than a backwards character
1572 # deletion for output equivalence with (I)Python
1573 # terminal.
1574 if len(substring) >= len(selection):
1557 1575 cursor.insertText(substring, format)
1558 1576 else:
1577 old_text = selection[len(substring):]
1578 cursor.insertText(substring + old_text, format)
1579 cursor.movePosition(cursor.PreviousCharacter,
1580 cursor.KeepAnchor, len(old_text))
1581 else:
1559 1582 cursor.insertText(text)
1560 1583 cursor.endEditBlock()
1561 1584
1562 1585 def _insert_plain_text_into_buffer(self, cursor, text):
1563 1586 """ Inserts text into the input buffer using the specified cursor (which
1564 1587 must be in the input buffer), ensuring that continuation prompts are
1565 1588 inserted as necessary.
1566 1589 """
1567 1590 lines = text.splitlines(True)
1568 1591 if lines:
1569 1592 cursor.beginEditBlock()
1570 1593 cursor.insertText(lines[0])
1571 1594 for line in lines[1:]:
1572 1595 if self._continuation_prompt_html is None:
1573 1596 cursor.insertText(self._continuation_prompt)
1574 1597 else:
1575 1598 self._continuation_prompt = \
1576 1599 self._insert_html_fetching_plain_text(
1577 1600 cursor, self._continuation_prompt_html)
1578 1601 cursor.insertText(line)
1579 1602 cursor.endEditBlock()
1580 1603
1581 1604 def _in_buffer(self, position=None):
1582 1605 """ Returns whether the current cursor (or, if specified, a position) is
1583 1606 inside the editing region.
1584 1607 """
1585 1608 cursor = self._control.textCursor()
1586 1609 if position is None:
1587 1610 position = cursor.position()
1588 1611 else:
1589 1612 cursor.setPosition(position)
1590 1613 line = cursor.blockNumber()
1591 1614 prompt_line = self._get_prompt_cursor().blockNumber()
1592 1615 if line == prompt_line:
1593 1616 return position >= self._prompt_pos
1594 1617 elif line > prompt_line:
1595 1618 cursor.movePosition(QtGui.QTextCursor.StartOfBlock)
1596 1619 prompt_pos = cursor.position() + len(self._continuation_prompt)
1597 1620 return position >= prompt_pos
1598 1621 return False
1599 1622
1600 1623 def _keep_cursor_in_buffer(self):
1601 1624 """ Ensures that the cursor is inside the editing region. Returns
1602 1625 whether the cursor was moved.
1603 1626 """
1604 1627 moved = not self._in_buffer()
1605 1628 if moved:
1606 1629 cursor = self._control.textCursor()
1607 1630 cursor.movePosition(QtGui.QTextCursor.End)
1608 1631 self._control.setTextCursor(cursor)
1609 1632 return moved
1610 1633
1611 1634 def _keyboard_quit(self):
1612 1635 """ Cancels the current editing task ala Ctrl-G in Emacs.
1613 1636 """
1614 1637 if self._text_completing_pos:
1615 1638 self._cancel_text_completion()
1616 1639 else:
1617 1640 self.input_buffer = ''
1618 1641
1619 1642 def _page(self, text, html=False):
1620 1643 """ Displays text using the pager if it exceeds the height of the
1621 1644 viewport.
1622 1645
1623 1646 Parameters:
1624 1647 -----------
1625 1648 html : bool, optional (default False)
1626 1649 If set, the text will be interpreted as HTML instead of plain text.
1627 1650 """
1628 1651 line_height = QtGui.QFontMetrics(self.font).height()
1629 1652 minlines = self._control.viewport().height() / line_height
1630 1653 if self.paging != 'none' and \
1631 1654 re.match("(?:[^\n]*\n){%i}" % minlines, text):
1632 1655 if self.paging == 'custom':
1633 1656 self.custom_page_requested.emit(text)
1634 1657 else:
1635 1658 self._page_control.clear()
1636 1659 cursor = self._page_control.textCursor()
1637 1660 if html:
1638 1661 self._insert_html(cursor, text)
1639 1662 else:
1640 1663 self._insert_plain_text(cursor, text)
1641 1664 self._page_control.moveCursor(QtGui.QTextCursor.Start)
1642 1665
1643 1666 self._page_control.viewport().resize(self._control.size())
1644 1667 if self._splitter:
1645 1668 self._page_control.show()
1646 1669 self._page_control.setFocus()
1647 1670 else:
1648 1671 self.layout().setCurrentWidget(self._page_control)
1649 1672 elif html:
1650 1673 self._append_plain_html(text)
1651 1674 else:
1652 1675 self._append_plain_text(text)
1653 1676
1654 1677 def _prompt_finished(self):
1655 1678 """ Called immediately after a prompt is finished, i.e. when some input
1656 1679 will be processed and a new prompt displayed.
1657 1680 """
1658 1681 self._control.setReadOnly(True)
1659 1682 self._prompt_finished_hook()
1660 1683
1661 1684 def _prompt_started(self):
1662 1685 """ Called immediately after a new prompt is displayed.
1663 1686 """
1664 1687 # Temporarily disable the maximum block count to permit undo/redo and
1665 1688 # to ensure that the prompt position does not change due to truncation.
1666 1689 self._control.document().setMaximumBlockCount(0)
1667 1690 self._control.setUndoRedoEnabled(True)
1668 1691
1669 1692 # Work around bug in QPlainTextEdit: input method is not re-enabled
1670 1693 # when read-only is disabled.
1671 1694 self._control.setReadOnly(False)
1672 1695 self._control.setAttribute(QtCore.Qt.WA_InputMethodEnabled, True)
1673 1696
1674 1697 if not self._reading:
1675 1698 self._executing = False
1676 1699 self._prompt_started_hook()
1677 1700
1678 1701 # If the input buffer has changed while executing, load it.
1679 1702 if self._input_buffer_pending:
1680 1703 self.input_buffer = self._input_buffer_pending
1681 1704 self._input_buffer_pending = ''
1682 1705
1683 1706 self._control.moveCursor(QtGui.QTextCursor.End)
1684 1707
1685 1708 def _readline(self, prompt='', callback=None):
1686 1709 """ Reads one line of input from the user.
1687 1710
1688 1711 Parameters
1689 1712 ----------
1690 1713 prompt : str, optional
1691 1714 The prompt to print before reading the line.
1692 1715
1693 1716 callback : callable, optional
1694 1717 A callback to execute with the read line. If not specified, input is
1695 1718 read *synchronously* and this method does not return until it has
1696 1719 been read.
1697 1720
1698 1721 Returns
1699 1722 -------
1700 1723 If a callback is specified, returns nothing. Otherwise, returns the
1701 1724 input string with the trailing newline stripped.
1702 1725 """
1703 1726 if self._reading:
1704 1727 raise RuntimeError('Cannot read a line. Widget is already reading.')
1705 1728
1706 1729 if not callback and not self.isVisible():
1707 1730 # If the user cannot see the widget, this function cannot return.
1708 1731 raise RuntimeError('Cannot synchronously read a line if the widget '
1709 1732 'is not visible!')
1710 1733
1711 1734 self._reading = True
1712 1735 self._show_prompt(prompt, newline=False)
1713 1736
1714 1737 if callback is None:
1715 1738 self._reading_callback = None
1716 1739 while self._reading:
1717 1740 QtCore.QCoreApplication.processEvents()
1718 1741 return self._get_input_buffer(force=True).rstrip('\n')
1719 1742
1720 1743 else:
1721 1744 self._reading_callback = lambda: \
1722 1745 callback(self._get_input_buffer(force=True).rstrip('\n'))
1723 1746
1724 1747 def _set_continuation_prompt(self, prompt, html=False):
1725 1748 """ Sets the continuation prompt.
1726 1749
1727 1750 Parameters
1728 1751 ----------
1729 1752 prompt : str
1730 1753 The prompt to show when more input is needed.
1731 1754
1732 1755 html : bool, optional (default False)
1733 1756 If set, the prompt will be inserted as formatted HTML. Otherwise,
1734 1757 the prompt will be treated as plain text, though ANSI color codes
1735 1758 will be handled.
1736 1759 """
1737 1760 if html:
1738 1761 self._continuation_prompt_html = prompt
1739 1762 else:
1740 1763 self._continuation_prompt = prompt
1741 1764 self._continuation_prompt_html = None
1742 1765
1743 1766 def _set_cursor(self, cursor):
1744 1767 """ Convenience method to set the current cursor.
1745 1768 """
1746 1769 self._control.setTextCursor(cursor)
1747 1770
1748 1771 def _set_top_cursor(self, cursor):
1749 1772 """ Scrolls the viewport so that the specified cursor is at the top.
1750 1773 """
1751 1774 scrollbar = self._control.verticalScrollBar()
1752 1775 scrollbar.setValue(scrollbar.maximum())
1753 1776 original_cursor = self._control.textCursor()
1754 1777 self._control.setTextCursor(cursor)
1755 1778 self._control.ensureCursorVisible()
1756 1779 self._control.setTextCursor(original_cursor)
1757 1780
1758 1781 def _show_prompt(self, prompt=None, html=False, newline=True):
1759 1782 """ Writes a new prompt at the end of the buffer.
1760 1783
1761 1784 Parameters
1762 1785 ----------
1763 1786 prompt : str, optional
1764 1787 The prompt to show. If not specified, the previous prompt is used.
1765 1788
1766 1789 html : bool, optional (default False)
1767 1790 Only relevant when a prompt is specified. If set, the prompt will
1768 1791 be inserted as formatted HTML. Otherwise, the prompt will be treated
1769 1792 as plain text, though ANSI color codes will be handled.
1770 1793
1771 1794 newline : bool, optional (default True)
1772 1795 If set, a new line will be written before showing the prompt if
1773 1796 there is not already a newline at the end of the buffer.
1774 1797 """
1775 1798 # Save the current end position to support _append*(before_prompt=True).
1776 1799 cursor = self._get_end_cursor()
1777 1800 self._append_before_prompt_pos = cursor.position()
1778 1801
1779 1802 # Insert a preliminary newline, if necessary.
1780 1803 if newline and cursor.position() > 0:
1781 1804 cursor.movePosition(QtGui.QTextCursor.Left,
1782 1805 QtGui.QTextCursor.KeepAnchor)
1783 1806 if cursor.selection().toPlainText() != '\n':
1784 1807 self._append_plain_text('\n')
1785 1808
1786 1809 # Write the prompt.
1787 1810 self._append_plain_text(self._prompt_sep)
1788 1811 if prompt is None:
1789 1812 if self._prompt_html is None:
1790 1813 self._append_plain_text(self._prompt)
1791 1814 else:
1792 1815 self._append_html(self._prompt_html)
1793 1816 else:
1794 1817 if html:
1795 1818 self._prompt = self._append_html_fetching_plain_text(prompt)
1796 1819 self._prompt_html = prompt
1797 1820 else:
1798 1821 self._append_plain_text(prompt)
1799 1822 self._prompt = prompt
1800 1823 self._prompt_html = None
1801 1824
1802 1825 self._prompt_pos = self._get_end_cursor().position()
1803 1826 self._prompt_started()
1804 1827
1805 1828 #------ Signal handlers ----------------------------------------------------
1806 1829
1807 1830 def _adjust_scrollbars(self):
1808 1831 """ Expands the vertical scrollbar beyond the range set by Qt.
1809 1832 """
1810 1833 # This code is adapted from _q_adjustScrollbars in qplaintextedit.cpp
1811 1834 # and qtextedit.cpp.
1812 1835 document = self._control.document()
1813 1836 scrollbar = self._control.verticalScrollBar()
1814 1837 viewport_height = self._control.viewport().height()
1815 1838 if isinstance(self._control, QtGui.QPlainTextEdit):
1816 1839 maximum = max(0, document.lineCount() - 1)
1817 1840 step = viewport_height / self._control.fontMetrics().lineSpacing()
1818 1841 else:
1819 1842 # QTextEdit does not do line-based layout and blocks will not in
1820 1843 # general have the same height. Therefore it does not make sense to
1821 1844 # attempt to scroll in line height increments.
1822 1845 maximum = document.size().height()
1823 1846 step = viewport_height
1824 1847 diff = maximum - scrollbar.maximum()
1825 1848 scrollbar.setRange(0, maximum)
1826 1849 scrollbar.setPageStep(step)
1827 1850
1828 1851 # Compensate for undesirable scrolling that occurs automatically due to
1829 1852 # maximumBlockCount() text truncation.
1830 1853 if diff < 0 and document.blockCount() == document.maximumBlockCount():
1831 1854 scrollbar.setValue(scrollbar.value() + diff)
1832 1855
1833 1856 def _cursor_position_changed(self):
1834 1857 """ Clears the temporary buffer based on the cursor position.
1835 1858 """
1836 1859 if self._text_completing_pos:
1837 1860 document = self._control.document()
1838 1861 if self._text_completing_pos < document.characterCount():
1839 1862 cursor = self._control.textCursor()
1840 1863 pos = cursor.position()
1841 1864 text_cursor = self._control.textCursor()
1842 1865 text_cursor.setPosition(self._text_completing_pos)
1843 1866 if pos < self._text_completing_pos or \
1844 1867 cursor.blockNumber() > text_cursor.blockNumber():
1845 1868 self._clear_temporary_buffer()
1846 1869 self._text_completing_pos = 0
1847 1870 else:
1848 1871 self._clear_temporary_buffer()
1849 1872 self._text_completing_pos = 0
1850 1873
1851 1874 def _custom_context_menu_requested(self, pos):
1852 1875 """ Shows a context menu at the given QPoint (in widget coordinates).
1853 1876 """
1854 1877 menu = self._context_menu_make(pos)
1855 1878 menu.exec_(self._control.mapToGlobal(pos))
@@ -1,134 +1,171 b''
1 1 # Standard library imports
2 2 import unittest
3 3
4 4 # Local imports
5 5 from IPython.frontend.qt.console.ansi_code_processor import AnsiCodeProcessor
6 6
7 7
8 8 class TestAnsiCodeProcessor(unittest.TestCase):
9 9
10 10 def setUp(self):
11 11 self.processor = AnsiCodeProcessor()
12 12
13 13 def test_clear(self):
14 14 """ Do control sequences for clearing the console work?
15 15 """
16 16 string = '\x1b[2J\x1b[K'
17 17 i = -1
18 18 for i, substring in enumerate(self.processor.split_string(string)):
19 19 if i == 0:
20 20 self.assertEquals(len(self.processor.actions), 1)
21 21 action = self.processor.actions[0]
22 22 self.assertEquals(action.action, 'erase')
23 23 self.assertEquals(action.area, 'screen')
24 24 self.assertEquals(action.erase_to, 'all')
25 25 elif i == 1:
26 26 self.assertEquals(len(self.processor.actions), 1)
27 27 action = self.processor.actions[0]
28 28 self.assertEquals(action.action, 'erase')
29 29 self.assertEquals(action.area, 'line')
30 30 self.assertEquals(action.erase_to, 'end')
31 31 else:
32 32 self.fail('Too many substrings.')
33 33 self.assertEquals(i, 1, 'Too few substrings.')
34 34
35 35 def test_colors(self):
36 36 """ Do basic controls sequences for colors work?
37 37 """
38 38 string = 'first\x1b[34mblue\x1b[0mlast'
39 39 i = -1
40 40 for i, substring in enumerate(self.processor.split_string(string)):
41 41 if i == 0:
42 42 self.assertEquals(substring, 'first')
43 43 self.assertEquals(self.processor.foreground_color, None)
44 44 elif i == 1:
45 45 self.assertEquals(substring, 'blue')
46 46 self.assertEquals(self.processor.foreground_color, 4)
47 47 elif i == 2:
48 48 self.assertEquals(substring, 'last')
49 49 self.assertEquals(self.processor.foreground_color, None)
50 50 else:
51 51 self.fail('Too many substrings.')
52 52 self.assertEquals(i, 2, 'Too few substrings.')
53 53
54 54 def test_colors_xterm(self):
55 55 """ Do xterm-specific control sequences for colors work?
56 56 """
57 57 string = '\x1b]4;20;rgb:ff/ff/ff\x1b' \
58 58 '\x1b]4;25;rgbi:1.0/1.0/1.0\x1b'
59 59 substrings = list(self.processor.split_string(string))
60 60 desired = { 20 : (255, 255, 255),
61 61 25 : (255, 255, 255) }
62 62 self.assertEquals(self.processor.color_map, desired)
63 63
64 64 string = '\x1b[38;5;20m\x1b[48;5;25m'
65 65 substrings = list(self.processor.split_string(string))
66 66 self.assertEquals(self.processor.foreground_color, 20)
67 67 self.assertEquals(self.processor.background_color, 25)
68 68
69 69 def test_scroll(self):
70 70 """ Do control sequences for scrolling the buffer work?
71 71 """
72 72 string = '\x1b[5S\x1b[T'
73 73 i = -1
74 74 for i, substring in enumerate(self.processor.split_string(string)):
75 75 if i == 0:
76 76 self.assertEquals(len(self.processor.actions), 1)
77 77 action = self.processor.actions[0]
78 78 self.assertEquals(action.action, 'scroll')
79 79 self.assertEquals(action.dir, 'up')
80 80 self.assertEquals(action.unit, 'line')
81 81 self.assertEquals(action.count, 5)
82 82 elif i == 1:
83 83 self.assertEquals(len(self.processor.actions), 1)
84 84 action = self.processor.actions[0]
85 85 self.assertEquals(action.action, 'scroll')
86 86 self.assertEquals(action.dir, 'down')
87 87 self.assertEquals(action.unit, 'line')
88 88 self.assertEquals(action.count, 1)
89 89 else:
90 90 self.fail('Too many substrings.')
91 91 self.assertEquals(i, 1, 'Too few substrings.')
92 92
93 93 def test_formfeed(self):
94 94 """ Are formfeed characters processed correctly?
95 95 """
96 96 string = '\f' # form feed
97 97 self.assertEquals(list(self.processor.split_string(string)), [''])
98 98 self.assertEquals(len(self.processor.actions), 1)
99 99 action = self.processor.actions[0]
100 100 self.assertEquals(action.action, 'scroll')
101 101 self.assertEquals(action.dir, 'down')
102 102 self.assertEquals(action.unit, 'page')
103 103 self.assertEquals(action.count, 1)
104 104
105 105 def test_carriage_return(self):
106 106 """ Are carriage return characters processed correctly?
107 107 """
108 108 string = 'foo\rbar' # carriage return
109 self.assertEquals(list(self.processor.split_string(string)), ['foo', '', 'bar'])
110 self.assertEquals(len(self.processor.actions), 1)
111 action = self.processor.actions[0]
112 self.assertEquals(action.action, 'carriage-return')
109 splits = []
110 actions = []
111 for split in self.processor.split_string(string):
112 splits.append(split)
113 actions.append([action.action for action in self.processor.actions])
114 self.assertEquals(splits, ['foo', None, 'bar'])
115 self.assertEquals(actions, [[], ['carriage-return'], []])
113 116
114 117 def test_carriage_return_newline(self):
115 118 """transform CRLF to LF"""
116 string = 'foo\rbar\r\ncat\r\n' # carriage return and newline
119 string = 'foo\rbar\r\ncat\r\n\n' # carriage return and newline
117 120 # only one CR action should occur, and '\r\n' should transform to '\n'
118 self.assertEquals(list(self.processor.split_string(string)), ['foo', '', 'bar\r\ncat\r\n'])
119 self.assertEquals(len(self.processor.actions), 1)
120 action = self.processor.actions[0]
121 self.assertEquals(action.action, 'carriage-return')
121 splits = []
122 actions = []
123 for split in self.processor.split_string(string):
124 splits.append(split)
125 actions.append([action.action for action in self.processor.actions])
126 self.assertEquals(splits, ['foo', None, 'bar', '\r\n', 'cat', '\r\n', '\n'])
127 self.assertEquals(actions, [[], ['carriage-return'], [], ['newline'], [], ['newline'], ['newline']])
122 128
123 129 def test_beep(self):
124 130 """ Are beep characters processed correctly?
125 131 """
126 string = 'foo\bbar' # form feed
127 self.assertEquals(list(self.processor.split_string(string)), ['foo', '', 'bar'])
128 self.assertEquals(len(self.processor.actions), 1)
129 action = self.processor.actions[0]
130 self.assertEquals(action.action, 'beep')
132 string = 'foo\abar' # bell
133 splits = []
134 actions = []
135 for split in self.processor.split_string(string):
136 splits.append(split)
137 actions.append([action.action for action in self.processor.actions])
138 self.assertEquals(splits, ['foo', None, 'bar'])
139 self.assertEquals(actions, [[], ['beep'], []])
140
141 def test_backspace(self):
142 """ Are backspace characters processed correctly?
143 """
144 string = 'foo\bbar' # backspace
145 splits = []
146 actions = []
147 for split in self.processor.split_string(string):
148 splits.append(split)
149 actions.append([action.action for action in self.processor.actions])
150 self.assertEquals(splits, ['foo', None, 'bar'])
151 self.assertEquals(actions, [[], ['backspace'], []])
152
153 def test_combined(self):
154 """ Are CR and BS characters processed correctly in combination?
155
156 BS is treated as a change in print position, rather than a
157 backwards character deletion. Therefore a BS at EOL is
158 effectively ignored.
159 """
160 string = 'abc\rdef\b' # CR and backspace
161 splits = []
162 actions = []
163 for split in self.processor.split_string(string):
164 splits.append(split)
165 actions.append([action.action for action in self.processor.actions])
166 self.assertEquals(splits, ['abc', None, 'def', None])
167 self.assertEquals(actions, [[], ['carriage-return'], [], ['backspace']])
131 168
132 169
133 170 if __name__ == '__main__':
134 171 unittest.main()
General Comments 0
You need to be logged in to leave comments. Login now