##// END OF EJS Templates
Fixed more issues with ConsoleWidget scrollbar management.
epatters -
Show More
@@ -1,1596 +1,1601
1 1 """A base class for console-type widgets.
2 2 """
3 3 #-----------------------------------------------------------------------------
4 4 # Imports
5 5 #-----------------------------------------------------------------------------
6 6
7 7 # Standard library imports
8 8 from os.path import commonprefix
9 9 import re
10 10 import sys
11 11 from textwrap import dedent
12 12
13 13 # System library imports
14 14 from PyQt4 import QtCore, QtGui
15 15
16 16 # Local imports
17 17 from IPython.config.configurable import Configurable
18 18 from IPython.frontend.qt.util import MetaQObjectHasTraits, get_font
19 19 from IPython.utils.traitlets import Bool, Enum, Int
20 20 from ansi_code_processor import QtAnsiCodeProcessor
21 21 from completion_widget import CompletionWidget
22 22
23 23 #-----------------------------------------------------------------------------
24 24 # Classes
25 25 #-----------------------------------------------------------------------------
26 26
27 27 class ConsoleWidget(Configurable, QtGui.QWidget):
28 28 """ An abstract base class for console-type widgets. This class has
29 29 functionality for:
30 30
31 31 * Maintaining a prompt and editing region
32 32 * Providing the traditional Unix-style console keyboard shortcuts
33 33 * Performing tab completion
34 34 * Paging text
35 35 * Handling ANSI escape codes
36 36
37 37 ConsoleWidget also provides a number of utility methods that will be
38 38 convenient to implementors of a console-style widget.
39 39 """
40 40 __metaclass__ = MetaQObjectHasTraits
41 41
42 42 # Whether to process ANSI escape codes.
43 43 ansi_codes = Bool(True, config=True)
44 44
45 45 # The maximum number of lines of text before truncation. Specifying a
46 46 # non-positive number disables text truncation (not recommended).
47 47 buffer_size = Int(500, config=True)
48 48
49 49 # Whether to use a list widget or plain text output for tab completion.
50 50 gui_completion = Bool(False, config=True)
51 51
52 52 # The type of underlying text widget to use. Valid values are 'plain', which
53 53 # specifies a QPlainTextEdit, and 'rich', which specifies a QTextEdit.
54 54 # NOTE: this value can only be specified during initialization.
55 55 kind = Enum(['plain', 'rich'], default_value='plain', config=True)
56 56
57 57 # The type of paging to use. Valid values are:
58 58 # 'inside' : The widget pages like a traditional terminal pager.
59 59 # 'hsplit' : When paging is requested, the widget is split
60 60 # horizontally. The top pane contains the console, and the
61 61 # bottom pane contains the paged text.
62 62 # 'vsplit' : Similar to 'hsplit', except that a vertical splitter used.
63 63 # 'custom' : No action is taken by the widget beyond emitting a
64 64 # 'custom_page_requested(str)' signal.
65 65 # 'none' : The text is written directly to the console.
66 66 # NOTE: this value can only be specified during initialization.
67 67 paging = Enum(['inside', 'hsplit', 'vsplit', 'custom', 'none'],
68 68 default_value='inside', config=True)
69 69
70 70 # Whether to override ShortcutEvents for the keybindings defined by this
71 71 # widget (Ctrl+n, Ctrl+a, etc). Enable this if you want this widget to take
72 72 # priority (when it has focus) over, e.g., window-level menu shortcuts.
73 73 override_shortcuts = Bool(False)
74 74
75 75 # Signals that indicate ConsoleWidget state.
76 76 copy_available = QtCore.pyqtSignal(bool)
77 77 redo_available = QtCore.pyqtSignal(bool)
78 78 undo_available = QtCore.pyqtSignal(bool)
79 79
80 80 # Signal emitted when paging is needed and the paging style has been
81 81 # specified as 'custom'.
82 82 custom_page_requested = QtCore.pyqtSignal(object)
83 83
84 84 # Protected class variables.
85 85 _ctrl_down_remap = { QtCore.Qt.Key_B : QtCore.Qt.Key_Left,
86 86 QtCore.Qt.Key_F : QtCore.Qt.Key_Right,
87 87 QtCore.Qt.Key_A : QtCore.Qt.Key_Home,
88 88 QtCore.Qt.Key_E : QtCore.Qt.Key_End,
89 89 QtCore.Qt.Key_P : QtCore.Qt.Key_Up,
90 90 QtCore.Qt.Key_N : QtCore.Qt.Key_Down,
91 91 QtCore.Qt.Key_D : QtCore.Qt.Key_Delete, }
92 92 _shortcuts = set(_ctrl_down_remap.keys() +
93 93 [ QtCore.Qt.Key_C, QtCore.Qt.Key_G, QtCore.Qt.Key_O,
94 94 QtCore.Qt.Key_V ])
95 95
96 96 #---------------------------------------------------------------------------
97 97 # 'QObject' interface
98 98 #---------------------------------------------------------------------------
99 99
100 100 def __init__(self, parent=None, **kw):
101 101 """ Create a ConsoleWidget.
102 102
103 103 Parameters:
104 104 -----------
105 105 parent : QWidget, optional [default None]
106 106 The parent for this widget.
107 107 """
108 108 QtGui.QWidget.__init__(self, parent)
109 109 Configurable.__init__(self, **kw)
110 110
111 111 # Create the layout and underlying text widget.
112 112 layout = QtGui.QStackedLayout(self)
113 113 layout.setContentsMargins(0, 0, 0, 0)
114 114 self._control = self._create_control()
115 115 self._page_control = None
116 116 self._splitter = None
117 117 if self.paging in ('hsplit', 'vsplit'):
118 118 self._splitter = QtGui.QSplitter()
119 119 if self.paging == 'hsplit':
120 120 self._splitter.setOrientation(QtCore.Qt.Horizontal)
121 121 else:
122 122 self._splitter.setOrientation(QtCore.Qt.Vertical)
123 123 self._splitter.addWidget(self._control)
124 124 layout.addWidget(self._splitter)
125 125 else:
126 126 layout.addWidget(self._control)
127 127
128 128 # Create the paging widget, if necessary.
129 129 if self.paging in ('inside', 'hsplit', 'vsplit'):
130 130 self._page_control = self._create_page_control()
131 131 if self._splitter:
132 132 self._page_control.hide()
133 133 self._splitter.addWidget(self._page_control)
134 134 else:
135 135 layout.addWidget(self._page_control)
136 136
137 137 # Initialize protected variables. Some variables contain useful state
138 138 # information for subclasses; they should be considered read-only.
139 139 self._ansi_processor = QtAnsiCodeProcessor()
140 140 self._completion_widget = CompletionWidget(self._control)
141 141 self._continuation_prompt = '> '
142 142 self._continuation_prompt_html = None
143 143 self._executing = False
144 144 self._prompt = ''
145 145 self._prompt_html = None
146 146 self._prompt_pos = 0
147 147 self._prompt_sep = ''
148 148 self._reading = False
149 149 self._reading_callback = None
150 150 self._tab_width = 8
151 151 self._text_completing_pos = 0
152 152
153 153 # Set a monospaced font.
154 154 self.reset_font()
155 155
156 156 def eventFilter(self, obj, event):
157 157 """ Reimplemented to ensure a console-like behavior in the underlying
158 158 text widgets.
159 159 """
160 160 etype = event.type()
161 161 if etype == QtCore.QEvent.KeyPress:
162 162
163 163 # Re-map keys for all filtered widgets.
164 164 key = event.key()
165 165 if self._control_key_down(event.modifiers()) and \
166 166 key in self._ctrl_down_remap:
167 167 new_event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress,
168 168 self._ctrl_down_remap[key],
169 169 QtCore.Qt.NoModifier)
170 170 QtGui.qApp.sendEvent(obj, new_event)
171 171 return True
172 172
173 173 elif obj == self._control:
174 174 return self._event_filter_console_keypress(event)
175 175
176 176 elif obj == self._page_control:
177 177 return self._event_filter_page_keypress(event)
178 178
179 179 # Make middle-click paste safe.
180 180 elif etype == QtCore.QEvent.MouseButtonRelease and \
181 181 event.button() == QtCore.Qt.MidButton and \
182 182 obj == self._control.viewport():
183 183 cursor = self._control.cursorForPosition(event.pos())
184 184 self._control.setTextCursor(cursor)
185 185 self.paste(QtGui.QClipboard.Selection)
186 186 return True
187 187
188 188 # Manually adjust the scrollbars *after* a resize event is dispatched.
189 189 elif etype == QtCore.QEvent.Resize:
190 190 QtCore.QTimer.singleShot(0, self._adjust_scrollbars)
191 191
192 192 # Override shortcuts for all filtered widgets.
193 193 elif etype == QtCore.QEvent.ShortcutOverride and \
194 194 self.override_shortcuts and \
195 195 self._control_key_down(event.modifiers()) and \
196 196 event.key() in self._shortcuts:
197 197 event.accept()
198 198 return False
199 199
200 200 # Prevent text from being moved by drag and drop.
201 201 elif etype in (QtCore.QEvent.DragEnter, QtCore.QEvent.DragLeave,
202 202 QtCore.QEvent.DragMove, QtCore.QEvent.Drop):
203 203 return True
204 204
205 205 return super(ConsoleWidget, self).eventFilter(obj, event)
206 206
207 207 #---------------------------------------------------------------------------
208 208 # 'QWidget' interface
209 209 #---------------------------------------------------------------------------
210 210
211 211 def sizeHint(self):
212 212 """ Reimplemented to suggest a size that is 80 characters wide and
213 213 25 lines high.
214 214 """
215 215 font_metrics = QtGui.QFontMetrics(self.font)
216 216 margin = (self._control.frameWidth() +
217 217 self._control.document().documentMargin()) * 2
218 218 style = self.style()
219 219 splitwidth = style.pixelMetric(QtGui.QStyle.PM_SplitterWidth)
220 220
221 221 # Note 1: Despite my best efforts to take the various margins into
222 222 # account, the width is still coming out a bit too small, so we include
223 223 # a fudge factor of one character here.
224 224 # Note 2: QFontMetrics.maxWidth is not used here or anywhere else due
225 225 # to a Qt bug on certain Mac OS systems where it returns 0.
226 226 width = font_metrics.width(' ') * 81 + margin
227 227 width += style.pixelMetric(QtGui.QStyle.PM_ScrollBarExtent)
228 228 if self.paging == 'hsplit':
229 229 width = width * 2 + splitwidth
230 230
231 231 height = font_metrics.height() * 25 + margin
232 232 if self.paging == 'vsplit':
233 233 height = height * 2 + splitwidth
234 234
235 235 return QtCore.QSize(width, height)
236 236
237 237 #---------------------------------------------------------------------------
238 238 # 'ConsoleWidget' public interface
239 239 #---------------------------------------------------------------------------
240 240
241 241 def can_copy(self):
242 242 """ Returns whether text can be copied to the clipboard.
243 243 """
244 244 return self._control.textCursor().hasSelection()
245 245
246 246 def can_cut(self):
247 247 """ Returns whether text can be cut to the clipboard.
248 248 """
249 249 cursor = self._control.textCursor()
250 250 return (cursor.hasSelection() and
251 251 self._in_buffer(cursor.anchor()) and
252 252 self._in_buffer(cursor.position()))
253 253
254 254 def can_paste(self):
255 255 """ Returns whether text can be pasted from the clipboard.
256 256 """
257 257 # Only accept text that can be ASCII encoded.
258 258 if self._control.textInteractionFlags() & QtCore.Qt.TextEditable:
259 259 text = QtGui.QApplication.clipboard().text()
260 260 if not text.isEmpty():
261 261 try:
262 262 str(text)
263 263 return True
264 264 except UnicodeEncodeError:
265 265 pass
266 266 return False
267 267
268 268 def clear(self, keep_input=True):
269 269 """ Clear the console, then write a new prompt. If 'keep_input' is set,
270 270 restores the old input buffer when the new prompt is written.
271 271 """
272 272 if keep_input:
273 273 input_buffer = self.input_buffer
274 274 self._control.clear()
275 275 self._show_prompt()
276 276 if keep_input:
277 277 self.input_buffer = input_buffer
278 278
279 279 def copy(self):
280 280 """ Copy the currently selected text to the clipboard.
281 281 """
282 282 self._control.copy()
283 283
284 284 def cut(self):
285 285 """ Copy the currently selected text to the clipboard and delete it
286 286 if it's inside the input buffer.
287 287 """
288 288 self.copy()
289 289 if self.can_cut():
290 290 self._control.textCursor().removeSelectedText()
291 291
292 292 def execute(self, source=None, hidden=False, interactive=False):
293 293 """ Executes source or the input buffer, possibly prompting for more
294 294 input.
295 295
296 296 Parameters:
297 297 -----------
298 298 source : str, optional
299 299
300 300 The source to execute. If not specified, the input buffer will be
301 301 used. If specified and 'hidden' is False, the input buffer will be
302 302 replaced with the source before execution.
303 303
304 304 hidden : bool, optional (default False)
305 305
306 306 If set, no output will be shown and the prompt will not be modified.
307 307 In other words, it will be completely invisible to the user that
308 308 an execution has occurred.
309 309
310 310 interactive : bool, optional (default False)
311 311
312 312 Whether the console is to treat the source as having been manually
313 313 entered by the user. The effect of this parameter depends on the
314 314 subclass implementation.
315 315
316 316 Raises:
317 317 -------
318 318 RuntimeError
319 319 If incomplete input is given and 'hidden' is True. In this case,
320 320 it is not possible to prompt for more input.
321 321
322 322 Returns:
323 323 --------
324 324 A boolean indicating whether the source was executed.
325 325 """
326 326 # WARNING: The order in which things happen here is very particular, in
327 327 # large part because our syntax highlighting is fragile. If you change
328 328 # something, test carefully!
329 329
330 330 # Decide what to execute.
331 331 if source is None:
332 332 source = self.input_buffer
333 333 if not hidden:
334 334 # A newline is appended later, but it should be considered part
335 335 # of the input buffer.
336 336 source += '\n'
337 337 elif not hidden:
338 338 self.input_buffer = source
339 339
340 340 # Execute the source or show a continuation prompt if it is incomplete.
341 341 complete = self._is_complete(source, interactive)
342 342 if hidden:
343 343 if complete:
344 344 self._execute(source, hidden)
345 345 else:
346 346 error = 'Incomplete noninteractive input: "%s"'
347 347 raise RuntimeError(error % source)
348 348 else:
349 349 if complete:
350 350 self._append_plain_text('\n')
351 351 self._executing_input_buffer = self.input_buffer
352 352 self._executing = True
353 353 self._prompt_finished()
354 354
355 355 # The maximum block count is only in effect during execution.
356 356 # This ensures that _prompt_pos does not become invalid due to
357 357 # text truncation.
358 358 self._control.document().setMaximumBlockCount(self.buffer_size)
359 359
360 360 # Setting a positive maximum block count will automatically
361 361 # disable the undo/redo history, but just to be safe:
362 362 self._control.setUndoRedoEnabled(False)
363 363
364 364 # Perform actual execution.
365 365 self._execute(source, hidden)
366 366
367 367 else:
368 368 # Do this inside an edit block so continuation prompts are
369 369 # removed seamlessly via undo/redo.
370 370 cursor = self._get_end_cursor()
371 371 cursor.beginEditBlock()
372 372 cursor.insertText('\n')
373 373 self._insert_continuation_prompt(cursor)
374 374 cursor.endEditBlock()
375 375
376 376 # Do not do this inside the edit block. It works as expected
377 377 # when using a QPlainTextEdit control, but does not have an
378 378 # effect when using a QTextEdit. I believe this is a Qt bug.
379 379 self._control.moveCursor(QtGui.QTextCursor.End)
380 380
381 381 return complete
382 382
383 383 def _get_input_buffer(self):
384 384 """ The text that the user has entered entered at the current prompt.
385 385 """
386 386 # If we're executing, the input buffer may not even exist anymore due to
387 387 # the limit imposed by 'buffer_size'. Therefore, we store it.
388 388 if self._executing:
389 389 return self._executing_input_buffer
390 390
391 391 cursor = self._get_end_cursor()
392 392 cursor.setPosition(self._prompt_pos, QtGui.QTextCursor.KeepAnchor)
393 393 input_buffer = str(cursor.selection().toPlainText())
394 394
395 395 # Strip out continuation prompts.
396 396 return input_buffer.replace('\n' + self._continuation_prompt, '\n')
397 397
398 398 def _set_input_buffer(self, string):
399 399 """ Replaces the text in the input buffer with 'string'.
400 400 """
401 401 # For now, it is an error to modify the input buffer during execution.
402 402 if self._executing:
403 403 raise RuntimeError("Cannot change input buffer during execution.")
404 404
405 405 # Remove old text.
406 406 cursor = self._get_end_cursor()
407 407 cursor.beginEditBlock()
408 408 cursor.setPosition(self._prompt_pos, QtGui.QTextCursor.KeepAnchor)
409 409 cursor.removeSelectedText()
410 410
411 411 # Insert new text with continuation prompts.
412 412 lines = string.splitlines(True)
413 413 if lines:
414 414 self._append_plain_text(lines[0])
415 415 for i in xrange(1, len(lines)):
416 416 if self._continuation_prompt_html is None:
417 417 self._append_plain_text(self._continuation_prompt)
418 418 else:
419 419 self._append_html(self._continuation_prompt_html)
420 420 self._append_plain_text(lines[i])
421 421 cursor.endEditBlock()
422 422 self._control.moveCursor(QtGui.QTextCursor.End)
423 423
424 424 input_buffer = property(_get_input_buffer, _set_input_buffer)
425 425
426 426 def _get_font(self):
427 427 """ The base font being used by the ConsoleWidget.
428 428 """
429 429 return self._control.document().defaultFont()
430 430
431 431 def _set_font(self, font):
432 432 """ Sets the base font for the ConsoleWidget to the specified QFont.
433 433 """
434 434 font_metrics = QtGui.QFontMetrics(font)
435 435 self._control.setTabStopWidth(self.tab_width * font_metrics.width(' '))
436 436
437 437 self._completion_widget.setFont(font)
438 438 self._control.document().setDefaultFont(font)
439 439 if self._page_control:
440 440 self._page_control.document().setDefaultFont(font)
441 441
442 442 font = property(_get_font, _set_font)
443 443
444 444 def paste(self, mode=QtGui.QClipboard.Clipboard):
445 445 """ Paste the contents of the clipboard into the input region.
446 446
447 447 Parameters:
448 448 -----------
449 449 mode : QClipboard::Mode, optional [default QClipboard::Clipboard]
450 450
451 451 Controls which part of the system clipboard is used. This can be
452 452 used to access the selection clipboard in X11 and the Find buffer
453 453 in Mac OS. By default, the regular clipboard is used.
454 454 """
455 455 if self._control.textInteractionFlags() & QtCore.Qt.TextEditable:
456 456 try:
457 457 # Remove any trailing newline, which confuses the GUI and
458 458 # forces the user to backspace.
459 459 text = str(QtGui.QApplication.clipboard().text(mode)).rstrip()
460 460 except UnicodeEncodeError:
461 461 pass
462 462 else:
463 463 self._insert_plain_text_into_buffer(dedent(text))
464 464
465 465 def print_(self, printer):
466 466 """ Print the contents of the ConsoleWidget to the specified QPrinter.
467 467 """
468 468 self._control.print_(printer)
469 469
470 470 def prompt_to_top(self):
471 471 """ Moves the prompt to the top of the viewport.
472 472 """
473 473 if not self._executing:
474 self._set_top_cursor(self._get_prompt_cursor())
474 prompt_cursor = self._get_prompt_cursor()
475 if self._get_cursor().blockNumber() < prompt_cursor.blockNumber():
476 self._set_cursor(prompt_cursor)
477 self._set_top_cursor(prompt_cursor)
475 478
476 479 def redo(self):
477 480 """ Redo the last operation. If there is no operation to redo, nothing
478 481 happens.
479 482 """
480 483 self._control.redo()
481 484
482 485 def reset_font(self):
483 486 """ Sets the font to the default fixed-width font for this platform.
484 487 """
485 488 if sys.platform == 'win32':
486 489 # Consolas ships with Vista/Win7, fallback to Courier if needed
487 490 family, fallback = 'Consolas', 'Courier'
488 491 elif sys.platform == 'darwin':
489 492 # OSX always has Monaco, no need for a fallback
490 493 family, fallback = 'Monaco', None
491 494 else:
492 495 # FIXME: remove Consolas as a default on Linux once our font
493 496 # selections are configurable by the user.
494 497 family, fallback = 'Consolas', 'Monospace'
495 498 font = get_font(family, fallback)
496 499 font.setPointSize(QtGui.qApp.font().pointSize())
497 500 font.setStyleHint(QtGui.QFont.TypeWriter)
498 501 self._set_font(font)
499 502
500 503 def select_all(self):
501 504 """ Selects all the text in the buffer.
502 505 """
503 506 self._control.selectAll()
504 507
505 508 def _get_tab_width(self):
506 509 """ The width (in terms of space characters) for tab characters.
507 510 """
508 511 return self._tab_width
509 512
510 513 def _set_tab_width(self, tab_width):
511 514 """ Sets the width (in terms of space characters) for tab characters.
512 515 """
513 516 font_metrics = QtGui.QFontMetrics(self.font)
514 517 self._control.setTabStopWidth(tab_width * font_metrics.width(' '))
515 518
516 519 self._tab_width = tab_width
517 520
518 521 tab_width = property(_get_tab_width, _set_tab_width)
519 522
520 523 def undo(self):
521 524 """ Undo the last operation. If there is no operation to undo, nothing
522 525 happens.
523 526 """
524 527 self._control.undo()
525 528
526 529 #---------------------------------------------------------------------------
527 530 # 'ConsoleWidget' abstract interface
528 531 #---------------------------------------------------------------------------
529 532
530 533 def _is_complete(self, source, interactive):
531 534 """ Returns whether 'source' can be executed. When triggered by an
532 535 Enter/Return key press, 'interactive' is True; otherwise, it is
533 536 False.
534 537 """
535 538 raise NotImplementedError
536 539
537 540 def _execute(self, source, hidden):
538 541 """ Execute 'source'. If 'hidden', do not show any output.
539 542 """
540 543 raise NotImplementedError
541 544
542 545 def _prompt_started_hook(self):
543 546 """ Called immediately after a new prompt is displayed.
544 547 """
545 548 pass
546 549
547 550 def _prompt_finished_hook(self):
548 551 """ Called immediately after a prompt is finished, i.e. when some input
549 552 will be processed and a new prompt displayed.
550 553 """
551 554 pass
552 555
553 556 def _up_pressed(self):
554 557 """ Called when the up key is pressed. Returns whether to continue
555 558 processing the event.
556 559 """
557 560 return True
558 561
559 562 def _down_pressed(self):
560 563 """ Called when the down key is pressed. Returns whether to continue
561 564 processing the event.
562 565 """
563 566 return True
564 567
565 568 def _tab_pressed(self):
566 569 """ Called when the tab key is pressed. Returns whether to continue
567 570 processing the event.
568 571 """
569 572 return False
570 573
571 574 #--------------------------------------------------------------------------
572 575 # 'ConsoleWidget' protected interface
573 576 #--------------------------------------------------------------------------
574 577
575 578 def _append_html(self, html):
576 579 """ Appends html at the end of the console buffer.
577 580 """
578 581 cursor = self._get_end_cursor()
579 582 self._insert_html(cursor, html)
580 583
581 584 def _append_html_fetching_plain_text(self, html):
582 585 """ Appends 'html', then returns the plain text version of it.
583 586 """
584 587 cursor = self._get_end_cursor()
585 588 return self._insert_html_fetching_plain_text(cursor, html)
586 589
587 590 def _append_plain_text(self, text):
588 591 """ Appends plain text at the end of the console buffer, processing
589 592 ANSI codes if enabled.
590 593 """
591 594 cursor = self._get_end_cursor()
592 595 self._insert_plain_text(cursor, text)
593 596
594 597 def _append_plain_text_keeping_prompt(self, text):
595 598 """ Writes 'text' after the current prompt, then restores the old prompt
596 599 with its old input buffer.
597 600 """
598 601 input_buffer = self.input_buffer
599 602 self._append_plain_text('\n')
600 603 self._prompt_finished()
601 604
602 605 self._append_plain_text(text)
603 606 self._show_prompt()
604 607 self.input_buffer = input_buffer
605 608
606 609 def _cancel_text_completion(self):
607 610 """ If text completion is progress, cancel it.
608 611 """
609 612 if self._text_completing_pos:
610 613 self._clear_temporary_buffer()
611 614 self._text_completing_pos = 0
612 615
613 616 def _clear_temporary_buffer(self):
614 617 """ Clears the "temporary text" buffer, i.e. all the text following
615 618 the prompt region.
616 619 """
617 620 # Select and remove all text below the input buffer.
618 621 cursor = self._get_prompt_cursor()
619 622 prompt = self._continuation_prompt.lstrip()
620 623 while cursor.movePosition(QtGui.QTextCursor.NextBlock):
621 624 temp_cursor = QtGui.QTextCursor(cursor)
622 625 temp_cursor.select(QtGui.QTextCursor.BlockUnderCursor)
623 626 text = str(temp_cursor.selection().toPlainText()).lstrip()
624 627 if not text.startswith(prompt):
625 628 break
626 629 else:
627 630 # We've reached the end of the input buffer and no text follows.
628 631 return
629 632 cursor.movePosition(QtGui.QTextCursor.Left) # Grab the newline.
630 633 cursor.movePosition(QtGui.QTextCursor.End,
631 634 QtGui.QTextCursor.KeepAnchor)
632 635 cursor.removeSelectedText()
633 636
634 637 # After doing this, we have no choice but to clear the undo/redo
635 638 # history. Otherwise, the text is not "temporary" at all, because it
636 639 # can be recalled with undo/redo. Unfortunately, Qt does not expose
637 640 # fine-grained control to the undo/redo system.
638 641 if self._control.isUndoRedoEnabled():
639 642 self._control.setUndoRedoEnabled(False)
640 643 self._control.setUndoRedoEnabled(True)
641 644
642 645 def _complete_with_items(self, cursor, items):
643 646 """ Performs completion with 'items' at the specified cursor location.
644 647 """
645 648 self._cancel_text_completion()
646 649
647 650 if len(items) == 1:
648 651 cursor.setPosition(self._control.textCursor().position(),
649 652 QtGui.QTextCursor.KeepAnchor)
650 653 cursor.insertText(items[0])
651 654
652 655 elif len(items) > 1:
653 656 current_pos = self._control.textCursor().position()
654 657 prefix = commonprefix(items)
655 658 if prefix:
656 659 cursor.setPosition(current_pos, QtGui.QTextCursor.KeepAnchor)
657 660 cursor.insertText(prefix)
658 661 current_pos = cursor.position()
659 662
660 663 if self.gui_completion:
661 664 cursor.movePosition(QtGui.QTextCursor.Left, n=len(prefix))
662 665 self._completion_widget.show_items(cursor, items)
663 666 else:
664 667 cursor.beginEditBlock()
665 668 self._append_plain_text('\n')
666 669 self._page(self._format_as_columns(items))
667 670 cursor.endEditBlock()
668 671
669 672 cursor.setPosition(current_pos)
670 673 self._control.moveCursor(QtGui.QTextCursor.End)
671 674 self._control.setTextCursor(cursor)
672 675 self._text_completing_pos = current_pos
673 676
674 677 def _context_menu_make(self, pos):
675 678 """ Creates a context menu for the given QPoint (in widget coordinates).
676 679 """
677 680 menu = QtGui.QMenu()
678 681
679 682 cut_action = menu.addAction('Cut', self.cut)
680 683 cut_action.setEnabled(self.can_cut())
681 684 cut_action.setShortcut(QtGui.QKeySequence.Cut)
682 685
683 686 copy_action = menu.addAction('Copy', self.copy)
684 687 copy_action.setEnabled(self.can_copy())
685 688 copy_action.setShortcut(QtGui.QKeySequence.Copy)
686 689
687 690 paste_action = menu.addAction('Paste', self.paste)
688 691 paste_action.setEnabled(self.can_paste())
689 692 paste_action.setShortcut(QtGui.QKeySequence.Paste)
690 693
691 694 menu.addSeparator()
692 695 menu.addAction('Select All', self.select_all)
693 696
694 697 return menu
695 698
696 699 def _control_key_down(self, modifiers, include_command=True):
697 700 """ Given a KeyboardModifiers flags object, return whether the Control
698 701 key is down.
699 702
700 703 Parameters:
701 704 -----------
702 705 include_command : bool, optional (default True)
703 706 Whether to treat the Command key as a (mutually exclusive) synonym
704 707 for Control when in Mac OS.
705 708 """
706 709 # Note that on Mac OS, ControlModifier corresponds to the Command key
707 710 # while MetaModifier corresponds to the Control key.
708 711 if sys.platform == 'darwin':
709 712 down = include_command and (modifiers & QtCore.Qt.ControlModifier)
710 713 return bool(down) ^ bool(modifiers & QtCore.Qt.MetaModifier)
711 714 else:
712 715 return bool(modifiers & QtCore.Qt.ControlModifier)
713 716
714 717 def _create_control(self):
715 718 """ Creates and connects the underlying text widget.
716 719 """
717 720 # Create the underlying control.
718 721 if self.kind == 'plain':
719 722 control = QtGui.QPlainTextEdit()
720 723 elif self.kind == 'rich':
721 724 control = QtGui.QTextEdit()
722 725 control.setAcceptRichText(False)
723 726
724 727 # Install event filters. The filter on the viewport is needed for
725 728 # mouse events and drag events.
726 729 control.installEventFilter(self)
727 730 control.viewport().installEventFilter(self)
728 731
729 732 # Connect signals.
730 733 control.cursorPositionChanged.connect(self._cursor_position_changed)
731 734 control.customContextMenuRequested.connect(
732 735 self._custom_context_menu_requested)
733 736 control.copyAvailable.connect(self.copy_available)
734 737 control.redoAvailable.connect(self.redo_available)
735 738 control.undoAvailable.connect(self.undo_available)
736 739
737 740 # Hijack the document size change signal to prevent Qt from adjusting
738 741 # the viewport's scrollbar. We are relying on an implementation detail
739 742 # of Q(Plain)TextEdit here, which is potentially dangerous, but without
740 743 # this functionality we cannot create a nice terminal interface.
741 744 layout = control.document().documentLayout()
742 745 layout.documentSizeChanged.disconnect()
743 746 layout.documentSizeChanged.connect(self._adjust_scrollbars)
744 747
745 748 # Configure the control.
746 749 control.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
747 750 control.setReadOnly(True)
748 751 control.setUndoRedoEnabled(False)
749 752 control.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn)
750 753 return control
751 754
752 755 def _create_page_control(self):
753 756 """ Creates and connects the underlying paging widget.
754 757 """
755 758 if self.kind == 'plain':
756 759 control = QtGui.QPlainTextEdit()
757 760 elif self.kind == 'rich':
758 761 control = QtGui.QTextEdit()
759 762 control.installEventFilter(self)
760 763 control.setReadOnly(True)
761 764 control.setUndoRedoEnabled(False)
762 765 control.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn)
763 766 return control
764 767
765 768 def _event_filter_console_keypress(self, event):
766 769 """ Filter key events for the underlying text widget to create a
767 770 console-like interface.
768 771 """
769 772 intercepted = False
770 773 cursor = self._control.textCursor()
771 774 position = cursor.position()
772 775 key = event.key()
773 776 ctrl_down = self._control_key_down(event.modifiers())
774 777 alt_down = event.modifiers() & QtCore.Qt.AltModifier
775 778 shift_down = event.modifiers() & QtCore.Qt.ShiftModifier
776 779
777 780 #------ Special sequences ----------------------------------------------
778 781
779 782 if event.matches(QtGui.QKeySequence.Copy):
780 783 self.copy()
781 784 intercepted = True
782 785
783 786 elif event.matches(QtGui.QKeySequence.Cut):
784 787 self.cut()
785 788 intercepted = True
786 789
787 790 elif event.matches(QtGui.QKeySequence.Paste):
788 791 self.paste()
789 792 intercepted = True
790 793
791 794 #------ Special modifier logic -----------------------------------------
792 795
793 796 elif key in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter):
794 797 intercepted = True
795 798
796 799 # Special handling when tab completing in text mode.
797 800 self._cancel_text_completion()
798 801
799 802 if self._in_buffer(position):
800 803 if self._reading:
801 804 self._append_plain_text('\n')
802 805 self._reading = False
803 806 if self._reading_callback:
804 807 self._reading_callback()
805 808
806 809 # If there is only whitespace after the cursor, execute.
807 810 # Otherwise, split the line with a continuation prompt.
808 811 elif not self._executing:
809 812 cursor.movePosition(QtGui.QTextCursor.End,
810 813 QtGui.QTextCursor.KeepAnchor)
811 814 at_end = cursor.selectedText().trimmed().isEmpty()
812 815 if (at_end or shift_down) and not ctrl_down:
813 816 self.execute(interactive = not shift_down)
814 817 else:
815 818 # Do this inside an edit block for clean undo/redo.
816 819 cursor.beginEditBlock()
817 820 cursor.setPosition(position)
818 821 cursor.insertText('\n')
819 822 self._insert_continuation_prompt(cursor)
820 823 cursor.endEditBlock()
821 824
822 825 # Ensure that the whole input buffer is visible.
823 826 # FIXME: This will not be usable if the input buffer is
824 827 # taller than the console widget.
825 828 self._control.moveCursor(QtGui.QTextCursor.End)
826 829 self._control.setTextCursor(cursor)
827 830
828 831 #------ Control/Cmd modifier -------------------------------------------
829 832
830 833 elif ctrl_down:
831 834 if key == QtCore.Qt.Key_G:
832 835 self._keyboard_quit()
833 836 intercepted = True
834 837
835 838 elif key == QtCore.Qt.Key_K:
836 839 if self._in_buffer(position):
837 840 cursor.movePosition(QtGui.QTextCursor.EndOfLine,
838 841 QtGui.QTextCursor.KeepAnchor)
839 842 if not cursor.hasSelection():
840 843 # Line deletion (remove continuation prompt)
841 844 cursor.movePosition(QtGui.QTextCursor.NextBlock,
842 845 QtGui.QTextCursor.KeepAnchor)
843 846 cursor.movePosition(QtGui.QTextCursor.Right,
844 847 QtGui.QTextCursor.KeepAnchor,
845 848 len(self._continuation_prompt))
846 849 cursor.removeSelectedText()
847 850 intercepted = True
848 851
849 852 elif key == QtCore.Qt.Key_L:
850 853 self.prompt_to_top()
851 854 intercepted = True
852 855
853 856 elif key == QtCore.Qt.Key_O:
854 857 if self._page_control and self._page_control.isVisible():
855 858 self._page_control.setFocus()
856 859 intercept = True
857 860
858 861 elif key == QtCore.Qt.Key_Y:
859 862 self.paste()
860 863 intercepted = True
861 864
862 865 elif key in (QtCore.Qt.Key_Backspace, QtCore.Qt.Key_Delete):
863 866 intercepted = True
864 867
865 868 #------ Alt modifier ---------------------------------------------------
866 869
867 870 elif alt_down:
868 871 if key == QtCore.Qt.Key_B:
869 872 self._set_cursor(self._get_word_start_cursor(position))
870 873 intercepted = True
871 874
872 875 elif key == QtCore.Qt.Key_F:
873 876 self._set_cursor(self._get_word_end_cursor(position))
874 877 intercepted = True
875 878
876 879 elif key == QtCore.Qt.Key_Backspace:
877 880 cursor = self._get_word_start_cursor(position)
878 881 cursor.setPosition(position, QtGui.QTextCursor.KeepAnchor)
879 882 cursor.removeSelectedText()
880 883 intercepted = True
881 884
882 885 elif key == QtCore.Qt.Key_D:
883 886 cursor = self._get_word_end_cursor(position)
884 887 cursor.setPosition(position, QtGui.QTextCursor.KeepAnchor)
885 888 cursor.removeSelectedText()
886 889 intercepted = True
887 890
888 891 elif key == QtCore.Qt.Key_Delete:
889 892 intercepted = True
890 893
891 894 elif key == QtCore.Qt.Key_Greater:
892 895 self._control.moveCursor(QtGui.QTextCursor.End)
893 896 intercepted = True
894 897
895 898 elif key == QtCore.Qt.Key_Less:
896 899 self._control.setTextCursor(self._get_prompt_cursor())
897 900 intercepted = True
898 901
899 902 #------ No modifiers ---------------------------------------------------
900 903
901 904 else:
902 905 if key == QtCore.Qt.Key_Escape:
903 906 self._keyboard_quit()
904 907 intercepted = True
905 908
906 909 elif key == QtCore.Qt.Key_Up:
907 910 if self._reading or not self._up_pressed():
908 911 intercepted = True
909 912 else:
910 913 prompt_line = self._get_prompt_cursor().blockNumber()
911 914 intercepted = cursor.blockNumber() <= prompt_line
912 915
913 916 elif key == QtCore.Qt.Key_Down:
914 917 if self._reading or not self._down_pressed():
915 918 intercepted = True
916 919 else:
917 920 end_line = self._get_end_cursor().blockNumber()
918 921 intercepted = cursor.blockNumber() == end_line
919 922
920 923 elif key == QtCore.Qt.Key_Tab:
921 924 if not self._reading:
922 925 intercepted = not self._tab_pressed()
923 926
924 927 elif key == QtCore.Qt.Key_Left:
925 928
926 929 # Move to the previous line
927 930 line, col = cursor.blockNumber(), cursor.columnNumber()
928 931 if line > self._get_prompt_cursor().blockNumber() and \
929 932 col == len(self._continuation_prompt):
930 933 self._control.moveCursor(QtGui.QTextCursor.PreviousBlock)
931 934 self._control.moveCursor(QtGui.QTextCursor.EndOfBlock)
932 935 intercepted = True
933 936
934 937 # Regular left movement
935 938 else:
936 939 intercepted = not self._in_buffer(position - 1)
937 940
938 941 elif key == QtCore.Qt.Key_Right:
939 942 original_block_number = cursor.blockNumber()
940 943 cursor.movePosition(QtGui.QTextCursor.Right)
941 944 if cursor.blockNumber() != original_block_number:
942 945 cursor.movePosition(QtGui.QTextCursor.Right,
943 946 n=len(self._continuation_prompt))
944 947 self._set_cursor(cursor)
945 948 intercepted = True
946 949
947 950 elif key == QtCore.Qt.Key_Home:
948 951 start_line = cursor.blockNumber()
949 952 if start_line == self._get_prompt_cursor().blockNumber():
950 953 start_pos = self._prompt_pos
951 954 else:
952 955 cursor.movePosition(QtGui.QTextCursor.StartOfBlock,
953 956 QtGui.QTextCursor.KeepAnchor)
954 957 start_pos = cursor.position()
955 958 start_pos += len(self._continuation_prompt)
956 959 cursor.setPosition(position)
957 960 if shift_down and self._in_buffer(position):
958 961 cursor.setPosition(start_pos, QtGui.QTextCursor.KeepAnchor)
959 962 else:
960 963 cursor.setPosition(start_pos)
961 964 self._set_cursor(cursor)
962 965 intercepted = True
963 966
964 967 elif key == QtCore.Qt.Key_Backspace:
965 968
966 969 # Line deletion (remove continuation prompt)
967 970 line, col = cursor.blockNumber(), cursor.columnNumber()
968 971 if not self._reading and \
969 972 col == len(self._continuation_prompt) and \
970 973 line > self._get_prompt_cursor().blockNumber():
971 974 cursor.beginEditBlock()
972 975 cursor.movePosition(QtGui.QTextCursor.StartOfBlock,
973 976 QtGui.QTextCursor.KeepAnchor)
974 977 cursor.removeSelectedText()
975 978 cursor.deletePreviousChar()
976 979 cursor.endEditBlock()
977 980 intercepted = True
978 981
979 982 # Regular backwards deletion
980 983 else:
981 984 anchor = cursor.anchor()
982 985 if anchor == position:
983 986 intercepted = not self._in_buffer(position - 1)
984 987 else:
985 988 intercepted = not self._in_buffer(min(anchor, position))
986 989
987 990 elif key == QtCore.Qt.Key_Delete:
988 991
989 992 # Line deletion (remove continuation prompt)
990 993 if not self._reading and self._in_buffer(position) and \
991 994 cursor.atBlockEnd() and not cursor.hasSelection():
992 995 cursor.movePosition(QtGui.QTextCursor.NextBlock,
993 996 QtGui.QTextCursor.KeepAnchor)
994 997 cursor.movePosition(QtGui.QTextCursor.Right,
995 998 QtGui.QTextCursor.KeepAnchor,
996 999 len(self._continuation_prompt))
997 1000 cursor.removeSelectedText()
998 1001 intercepted = True
999 1002
1000 1003 # Regular forwards deletion:
1001 1004 else:
1002 1005 anchor = cursor.anchor()
1003 1006 intercepted = (not self._in_buffer(anchor) or
1004 1007 not self._in_buffer(position))
1005 1008
1006 1009 # Don't move the cursor if control is down to allow copy-paste using
1007 1010 # the keyboard in any part of the buffer.
1008 1011 if not ctrl_down:
1009 1012 self._keep_cursor_in_buffer()
1010 1013
1011 1014 return intercepted
1012 1015
1013 1016 def _event_filter_page_keypress(self, event):
1014 1017 """ Filter key events for the paging widget to create console-like
1015 1018 interface.
1016 1019 """
1017 1020 key = event.key()
1018 1021 ctrl_down = self._control_key_down(event.modifiers())
1019 1022 alt_down = event.modifiers() & QtCore.Qt.AltModifier
1020 1023
1021 1024 if ctrl_down:
1022 1025 if key == QtCore.Qt.Key_O:
1023 1026 self._control.setFocus()
1024 1027 intercept = True
1025 1028
1026 1029 elif alt_down:
1027 1030 if key == QtCore.Qt.Key_Greater:
1028 1031 self._page_control.moveCursor(QtGui.QTextCursor.End)
1029 1032 intercepted = True
1030 1033
1031 1034 elif key == QtCore.Qt.Key_Less:
1032 1035 self._page_control.moveCursor(QtGui.QTextCursor.Start)
1033 1036 intercepted = True
1034 1037
1035 1038 elif key in (QtCore.Qt.Key_Q, QtCore.Qt.Key_Escape):
1036 1039 if self._splitter:
1037 1040 self._page_control.hide()
1038 1041 else:
1039 1042 self.layout().setCurrentWidget(self._control)
1040 1043 return True
1041 1044
1042 1045 elif key in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return):
1043 1046 new_event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress,
1044 1047 QtCore.Qt.Key_PageDown,
1045 1048 QtCore.Qt.NoModifier)
1046 1049 QtGui.qApp.sendEvent(self._page_control, new_event)
1047 1050 return True
1048 1051
1049 1052 elif key == QtCore.Qt.Key_Backspace:
1050 1053 new_event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress,
1051 1054 QtCore.Qt.Key_PageUp,
1052 1055 QtCore.Qt.NoModifier)
1053 1056 QtGui.qApp.sendEvent(self._page_control, new_event)
1054 1057 return True
1055 1058
1056 1059 return False
1057 1060
1058 1061 def _format_as_columns(self, items, separator=' '):
1059 1062 """ Transform a list of strings into a single string with columns.
1060 1063
1061 1064 Parameters
1062 1065 ----------
1063 1066 items : sequence of strings
1064 1067 The strings to process.
1065 1068
1066 1069 separator : str, optional [default is two spaces]
1067 1070 The string that separates columns.
1068 1071
1069 1072 Returns
1070 1073 -------
1071 1074 The formatted string.
1072 1075 """
1073 1076 # Note: this code is adapted from columnize 0.3.2.
1074 1077 # See http://code.google.com/p/pycolumnize/
1075 1078
1076 1079 # Calculate the number of characters available.
1077 1080 width = self._control.viewport().width()
1078 1081 char_width = QtGui.QFontMetrics(self.font).width(' ')
1079 1082 displaywidth = max(10, (width / char_width) - 1)
1080 1083
1081 1084 # Some degenerate cases.
1082 1085 size = len(items)
1083 1086 if size == 0:
1084 1087 return '\n'
1085 1088 elif size == 1:
1086 1089 return '%s\n' % str(items[0])
1087 1090
1088 1091 # Try every row count from 1 upwards
1089 1092 array_index = lambda nrows, row, col: nrows*col + row
1090 1093 for nrows in range(1, size):
1091 1094 ncols = (size + nrows - 1) // nrows
1092 1095 colwidths = []
1093 1096 totwidth = -len(separator)
1094 1097 for col in range(ncols):
1095 1098 # Get max column width for this column
1096 1099 colwidth = 0
1097 1100 for row in range(nrows):
1098 1101 i = array_index(nrows, row, col)
1099 1102 if i >= size: break
1100 1103 x = items[i]
1101 1104 colwidth = max(colwidth, len(x))
1102 1105 colwidths.append(colwidth)
1103 1106 totwidth += colwidth + len(separator)
1104 1107 if totwidth > displaywidth:
1105 1108 break
1106 1109 if totwidth <= displaywidth:
1107 1110 break
1108 1111
1109 1112 # The smallest number of rows computed and the max widths for each
1110 1113 # column has been obtained. Now we just have to format each of the rows.
1111 1114 string = ''
1112 1115 for row in range(nrows):
1113 1116 texts = []
1114 1117 for col in range(ncols):
1115 1118 i = row + nrows*col
1116 1119 if i >= size:
1117 1120 texts.append('')
1118 1121 else:
1119 1122 texts.append(items[i])
1120 1123 while texts and not texts[-1]:
1121 1124 del texts[-1]
1122 1125 for col in range(len(texts)):
1123 1126 texts[col] = texts[col].ljust(colwidths[col])
1124 1127 string += '%s\n' % str(separator.join(texts))
1125 1128 return string
1126 1129
1127 1130 def _get_block_plain_text(self, block):
1128 1131 """ Given a QTextBlock, return its unformatted text.
1129 1132 """
1130 1133 cursor = QtGui.QTextCursor(block)
1131 1134 cursor.movePosition(QtGui.QTextCursor.StartOfBlock)
1132 1135 cursor.movePosition(QtGui.QTextCursor.EndOfBlock,
1133 1136 QtGui.QTextCursor.KeepAnchor)
1134 1137 return str(cursor.selection().toPlainText())
1135 1138
1136 1139 def _get_cursor(self):
1137 1140 """ Convenience method that returns a cursor for the current position.
1138 1141 """
1139 1142 return self._control.textCursor()
1140 1143
1141 1144 def _get_end_cursor(self):
1142 1145 """ Convenience method that returns a cursor for the last character.
1143 1146 """
1144 1147 cursor = self._control.textCursor()
1145 1148 cursor.movePosition(QtGui.QTextCursor.End)
1146 1149 return cursor
1147 1150
1148 1151 def _get_input_buffer_cursor_column(self):
1149 1152 """ Returns the column of the cursor in the input buffer, excluding the
1150 1153 contribution by the prompt, or -1 if there is no such column.
1151 1154 """
1152 1155 prompt = self._get_input_buffer_cursor_prompt()
1153 1156 if prompt is None:
1154 1157 return -1
1155 1158 else:
1156 1159 cursor = self._control.textCursor()
1157 1160 return cursor.columnNumber() - len(prompt)
1158 1161
1159 1162 def _get_input_buffer_cursor_line(self):
1160 1163 """ Returns line of the input buffer that contains the cursor, or None
1161 1164 if there is no such line.
1162 1165 """
1163 1166 prompt = self._get_input_buffer_cursor_prompt()
1164 1167 if prompt is None:
1165 1168 return None
1166 1169 else:
1167 1170 cursor = self._control.textCursor()
1168 1171 text = self._get_block_plain_text(cursor.block())
1169 1172 return text[len(prompt):]
1170 1173
1171 1174 def _get_input_buffer_cursor_prompt(self):
1172 1175 """ Returns the (plain text) prompt for line of the input buffer that
1173 1176 contains the cursor, or None if there is no such line.
1174 1177 """
1175 1178 if self._executing:
1176 1179 return None
1177 1180 cursor = self._control.textCursor()
1178 1181 if cursor.position() >= self._prompt_pos:
1179 1182 if cursor.blockNumber() == self._get_prompt_cursor().blockNumber():
1180 1183 return self._prompt
1181 1184 else:
1182 1185 return self._continuation_prompt
1183 1186 else:
1184 1187 return None
1185 1188
1186 1189 def _get_prompt_cursor(self):
1187 1190 """ Convenience method that returns a cursor for the prompt position.
1188 1191 """
1189 1192 cursor = self._control.textCursor()
1190 1193 cursor.setPosition(self._prompt_pos)
1191 1194 return cursor
1192 1195
1193 1196 def _get_selection_cursor(self, start, end):
1194 1197 """ Convenience method that returns a cursor with text selected between
1195 1198 the positions 'start' and 'end'.
1196 1199 """
1197 1200 cursor = self._control.textCursor()
1198 1201 cursor.setPosition(start)
1199 1202 cursor.setPosition(end, QtGui.QTextCursor.KeepAnchor)
1200 1203 return cursor
1201 1204
1202 1205 def _get_word_start_cursor(self, position):
1203 1206 """ Find the start of the word to the left the given position. If a
1204 1207 sequence of non-word characters precedes the first word, skip over
1205 1208 them. (This emulates the behavior of bash, emacs, etc.)
1206 1209 """
1207 1210 document = self._control.document()
1208 1211 position -= 1
1209 1212 while position >= self._prompt_pos and \
1210 1213 not document.characterAt(position).isLetterOrNumber():
1211 1214 position -= 1
1212 1215 while position >= self._prompt_pos and \
1213 1216 document.characterAt(position).isLetterOrNumber():
1214 1217 position -= 1
1215 1218 cursor = self._control.textCursor()
1216 1219 cursor.setPosition(position + 1)
1217 1220 return cursor
1218 1221
1219 1222 def _get_word_end_cursor(self, position):
1220 1223 """ Find the end of the word to the right the given position. If a
1221 1224 sequence of non-word characters precedes the first word, skip over
1222 1225 them. (This emulates the behavior of bash, emacs, etc.)
1223 1226 """
1224 1227 document = self._control.document()
1225 1228 end = self._get_end_cursor().position()
1226 1229 while position < end and \
1227 1230 not document.characterAt(position).isLetterOrNumber():
1228 1231 position += 1
1229 1232 while position < end and \
1230 1233 document.characterAt(position).isLetterOrNumber():
1231 1234 position += 1
1232 1235 cursor = self._control.textCursor()
1233 1236 cursor.setPosition(position)
1234 1237 return cursor
1235 1238
1236 1239 def _insert_continuation_prompt(self, cursor):
1237 1240 """ Inserts new continuation prompt using the specified cursor.
1238 1241 """
1239 1242 if self._continuation_prompt_html is None:
1240 1243 self._insert_plain_text(cursor, self._continuation_prompt)
1241 1244 else:
1242 1245 self._continuation_prompt = self._insert_html_fetching_plain_text(
1243 1246 cursor, self._continuation_prompt_html)
1244 1247
1245 1248 def _insert_html(self, cursor, html):
1246 1249 """ Inserts HTML using the specified cursor in such a way that future
1247 1250 formatting is unaffected.
1248 1251 """
1249 1252 cursor.beginEditBlock()
1250 1253 cursor.insertHtml(html)
1251 1254
1252 1255 # After inserting HTML, the text document "remembers" it's in "html
1253 1256 # mode", which means that subsequent calls adding plain text will result
1254 1257 # in unwanted formatting, lost tab characters, etc. The following code
1255 1258 # hacks around this behavior, which I consider to be a bug in Qt, by
1256 1259 # (crudely) resetting the document's style state.
1257 1260 cursor.movePosition(QtGui.QTextCursor.Left,
1258 1261 QtGui.QTextCursor.KeepAnchor)
1259 1262 if cursor.selection().toPlainText() == ' ':
1260 1263 cursor.removeSelectedText()
1261 1264 else:
1262 1265 cursor.movePosition(QtGui.QTextCursor.Right)
1263 1266 cursor.insertText(' ', QtGui.QTextCharFormat())
1264 1267 cursor.endEditBlock()
1265 1268
1266 1269 def _insert_html_fetching_plain_text(self, cursor, html):
1267 1270 """ Inserts HTML using the specified cursor, then returns its plain text
1268 1271 version.
1269 1272 """
1270 1273 cursor.beginEditBlock()
1271 1274 cursor.removeSelectedText()
1272 1275
1273 1276 start = cursor.position()
1274 1277 self._insert_html(cursor, html)
1275 1278 end = cursor.position()
1276 1279 cursor.setPosition(start, QtGui.QTextCursor.KeepAnchor)
1277 1280 text = str(cursor.selection().toPlainText())
1278 1281
1279 1282 cursor.setPosition(end)
1280 1283 cursor.endEditBlock()
1281 1284 return text
1282 1285
1283 1286 def _insert_plain_text(self, cursor, text):
1284 1287 """ Inserts plain text using the specified cursor, processing ANSI codes
1285 1288 if enabled.
1286 1289 """
1287 1290 cursor.beginEditBlock()
1288 1291 if self.ansi_codes:
1289 1292 for substring in self._ansi_processor.split_string(text):
1290 1293 for act in self._ansi_processor.actions:
1291 1294
1292 1295 # Unlike real terminal emulators, we don't distinguish
1293 1296 # between the screen and the scrollback buffer. A screen
1294 1297 # erase request clears everything.
1295 1298 if act.action == 'erase' and act.area == 'screen':
1296 1299 cursor.select(QtGui.QTextCursor.Document)
1297 1300 cursor.removeSelectedText()
1298 1301
1299 1302 # Simulate a form feed by scrolling just past the last line.
1300 1303 elif act.action == 'scroll' and act.unit == 'page':
1301 1304 cursor.insertText('\n')
1302 1305 cursor.endEditBlock()
1303 1306 self._set_top_cursor(cursor)
1304 1307 cursor.joinPreviousEditBlock()
1305 1308 cursor.deletePreviousChar()
1306 1309
1307 1310 format = self._ansi_processor.get_format()
1308 1311 cursor.insertText(substring, format)
1309 1312 else:
1310 1313 cursor.insertText(text)
1311 1314 cursor.endEditBlock()
1312 1315
1313 1316 def _insert_plain_text_into_buffer(self, text):
1314 1317 """ Inserts text into the input buffer at the current cursor position,
1315 1318 ensuring that continuation prompts are inserted as necessary.
1316 1319 """
1317 1320 lines = str(text).splitlines(True)
1318 1321 if lines:
1319 1322 self._keep_cursor_in_buffer()
1320 1323 cursor = self._control.textCursor()
1321 1324 cursor.beginEditBlock()
1322 1325 cursor.insertText(lines[0])
1323 1326 for line in lines[1:]:
1324 1327 if self._continuation_prompt_html is None:
1325 1328 cursor.insertText(self._continuation_prompt)
1326 1329 else:
1327 1330 self._continuation_prompt = \
1328 1331 self._insert_html_fetching_plain_text(
1329 1332 cursor, self._continuation_prompt_html)
1330 1333 cursor.insertText(line)
1331 1334 cursor.endEditBlock()
1332 1335 self._control.setTextCursor(cursor)
1333 1336
1334 1337 def _in_buffer(self, position=None):
1335 1338 """ Returns whether the current cursor (or, if specified, a position) is
1336 1339 inside the editing region.
1337 1340 """
1338 1341 cursor = self._control.textCursor()
1339 1342 if position is None:
1340 1343 position = cursor.position()
1341 1344 else:
1342 1345 cursor.setPosition(position)
1343 1346 line = cursor.blockNumber()
1344 1347 prompt_line = self._get_prompt_cursor().blockNumber()
1345 1348 if line == prompt_line:
1346 1349 return position >= self._prompt_pos
1347 1350 elif line > prompt_line:
1348 1351 cursor.movePosition(QtGui.QTextCursor.StartOfBlock)
1349 1352 prompt_pos = cursor.position() + len(self._continuation_prompt)
1350 1353 return position >= prompt_pos
1351 1354 return False
1352 1355
1353 1356 def _keep_cursor_in_buffer(self):
1354 1357 """ Ensures that the cursor is inside the editing region. Returns
1355 1358 whether the cursor was moved.
1356 1359 """
1357 1360 moved = not self._in_buffer()
1358 1361 if moved:
1359 1362 cursor = self._control.textCursor()
1360 1363 cursor.movePosition(QtGui.QTextCursor.End)
1361 1364 self._control.setTextCursor(cursor)
1362 1365 return moved
1363 1366
1364 1367 def _keyboard_quit(self):
1365 1368 """ Cancels the current editing task ala Ctrl-G in Emacs.
1366 1369 """
1367 1370 if self._text_completing_pos:
1368 1371 self._cancel_text_completion()
1369 1372 else:
1370 1373 self.input_buffer = ''
1371 1374
1372 1375 def _page(self, text, html=False):
1373 """ Displays text using the pager if it exceeds the height of the viewport.
1376 """ Displays text using the pager if it exceeds the height of the
1377 viewport.
1374 1378
1375 1379 Parameters:
1376 1380 -----------
1377 1381 html : bool, optional (default False)
1378 1382 If set, the text will be interpreted as HTML instead of plain text.
1379 1383 """
1380 1384 line_height = QtGui.QFontMetrics(self.font).height()
1381 1385 minlines = self._control.viewport().height() / line_height
1382 if self.paging != 'none' and re.match("(?:[^\n]*\n){%i}" % minlines, text):
1386 if self.paging != 'none' and \
1387 re.match("(?:[^\n]*\n){%i}" % minlines, text):
1383 1388 if self.paging == 'custom':
1384 1389 self.custom_page_requested.emit(text)
1385 1390 else:
1386 1391 self._page_control.clear()
1387 1392 cursor = self._page_control.textCursor()
1388 1393 if html:
1389 1394 self._insert_html(cursor, text)
1390 1395 else:
1391 1396 self._insert_plain_text(cursor, text)
1392 1397 self._page_control.moveCursor(QtGui.QTextCursor.Start)
1393 1398
1394 1399 self._page_control.viewport().resize(self._control.size())
1395 1400 if self._splitter:
1396 1401 self._page_control.show()
1397 1402 self._page_control.setFocus()
1398 1403 else:
1399 1404 self.layout().setCurrentWidget(self._page_control)
1400 1405 elif html:
1401 1406 self._append_plain_html(text)
1402 1407 else:
1403 1408 self._append_plain_text(text)
1404 1409
1405 1410 def _prompt_finished(self):
1406 1411 """ Called immediately after a prompt is finished, i.e. when some input
1407 1412 will be processed and a new prompt displayed.
1408 1413 """
1409 1414 # Flush all state from the input splitter so the next round of
1410 1415 # reading input starts with a clean buffer.
1411 1416 self._input_splitter.reset()
1412 1417
1413 1418 self._control.setReadOnly(True)
1414 1419 self._prompt_finished_hook()
1415 1420
1416 1421 def _prompt_started(self):
1417 1422 """ Called immediately after a new prompt is displayed.
1418 1423 """
1419 1424 # Temporarily disable the maximum block count to permit undo/redo and
1420 1425 # to ensure that the prompt position does not change due to truncation.
1421 1426 self._control.document().setMaximumBlockCount(0)
1422 1427 self._control.setUndoRedoEnabled(True)
1423 1428
1424 1429 self._control.setReadOnly(False)
1425 1430 self._control.moveCursor(QtGui.QTextCursor.End)
1426 1431 self._executing = False
1427 1432 self._prompt_started_hook()
1428 1433
1429 1434 def _readline(self, prompt='', callback=None):
1430 1435 """ Reads one line of input from the user.
1431 1436
1432 1437 Parameters
1433 1438 ----------
1434 1439 prompt : str, optional
1435 1440 The prompt to print before reading the line.
1436 1441
1437 1442 callback : callable, optional
1438 1443 A callback to execute with the read line. If not specified, input is
1439 1444 read *synchronously* and this method does not return until it has
1440 1445 been read.
1441 1446
1442 1447 Returns
1443 1448 -------
1444 1449 If a callback is specified, returns nothing. Otherwise, returns the
1445 1450 input string with the trailing newline stripped.
1446 1451 """
1447 1452 if self._reading:
1448 1453 raise RuntimeError('Cannot read a line. Widget is already reading.')
1449 1454
1450 1455 if not callback and not self.isVisible():
1451 1456 # If the user cannot see the widget, this function cannot return.
1452 1457 raise RuntimeError('Cannot synchronously read a line if the widget '
1453 1458 'is not visible!')
1454 1459
1455 1460 self._reading = True
1456 1461 self._show_prompt(prompt, newline=False)
1457 1462
1458 1463 if callback is None:
1459 1464 self._reading_callback = None
1460 1465 while self._reading:
1461 1466 QtCore.QCoreApplication.processEvents()
1462 1467 return self.input_buffer.rstrip('\n')
1463 1468
1464 1469 else:
1465 1470 self._reading_callback = lambda: \
1466 1471 callback(self.input_buffer.rstrip('\n'))
1467 1472
1468 1473 def _set_continuation_prompt(self, prompt, html=False):
1469 1474 """ Sets the continuation prompt.
1470 1475
1471 1476 Parameters
1472 1477 ----------
1473 1478 prompt : str
1474 1479 The prompt to show when more input is needed.
1475 1480
1476 1481 html : bool, optional (default False)
1477 1482 If set, the prompt will be inserted as formatted HTML. Otherwise,
1478 1483 the prompt will be treated as plain text, though ANSI color codes
1479 1484 will be handled.
1480 1485 """
1481 1486 if html:
1482 1487 self._continuation_prompt_html = prompt
1483 1488 else:
1484 1489 self._continuation_prompt = prompt
1485 1490 self._continuation_prompt_html = None
1486 1491
1487 1492 def _set_cursor(self, cursor):
1488 1493 """ Convenience method to set the current cursor.
1489 1494 """
1490 1495 self._control.setTextCursor(cursor)
1491 1496
1492 1497 def _set_top_cursor(self, cursor):
1493 1498 """ Scrolls the viewport so that the specified cursor is at the top.
1494 1499 """
1495 1500 scrollbar = self._control.verticalScrollBar()
1496 1501 scrollbar.setValue(scrollbar.maximum())
1497 1502 original_cursor = self._control.textCursor()
1498 1503 self._control.setTextCursor(cursor)
1499 1504 self._control.ensureCursorVisible()
1500 1505 self._control.setTextCursor(original_cursor)
1501 1506
1502 1507 def _show_prompt(self, prompt=None, html=False, newline=True):
1503 1508 """ Writes a new prompt at the end of the buffer.
1504 1509
1505 1510 Parameters
1506 1511 ----------
1507 1512 prompt : str, optional
1508 1513 The prompt to show. If not specified, the previous prompt is used.
1509 1514
1510 1515 html : bool, optional (default False)
1511 1516 Only relevant when a prompt is specified. If set, the prompt will
1512 1517 be inserted as formatted HTML. Otherwise, the prompt will be treated
1513 1518 as plain text, though ANSI color codes will be handled.
1514 1519
1515 1520 newline : bool, optional (default True)
1516 1521 If set, a new line will be written before showing the prompt if
1517 1522 there is not already a newline at the end of the buffer.
1518 1523 """
1519 1524 # Insert a preliminary newline, if necessary.
1520 1525 if newline:
1521 1526 cursor = self._get_end_cursor()
1522 1527 if cursor.position() > 0:
1523 1528 cursor.movePosition(QtGui.QTextCursor.Left,
1524 1529 QtGui.QTextCursor.KeepAnchor)
1525 1530 if str(cursor.selection().toPlainText()) != '\n':
1526 1531 self._append_plain_text('\n')
1527 1532
1528 1533 # Write the prompt.
1529 1534 self._append_plain_text(self._prompt_sep)
1530 1535 if prompt is None:
1531 1536 if self._prompt_html is None:
1532 1537 self._append_plain_text(self._prompt)
1533 1538 else:
1534 1539 self._append_html(self._prompt_html)
1535 1540 else:
1536 1541 if html:
1537 1542 self._prompt = self._append_html_fetching_plain_text(prompt)
1538 1543 self._prompt_html = prompt
1539 1544 else:
1540 1545 self._append_plain_text(prompt)
1541 1546 self._prompt = prompt
1542 1547 self._prompt_html = None
1543 1548
1544 1549 self._prompt_pos = self._get_end_cursor().position()
1545 1550 self._prompt_started()
1546 1551
1547 1552 #------ Signal handlers ----------------------------------------------------
1548 1553
1549 1554 def _adjust_scrollbars(self):
1550 1555 """ Expands the vertical scrollbar beyond the range set by Qt.
1551 1556 """
1552 1557 # This code is adapted from _q_adjustScrollbars in qplaintextedit.cpp
1553 1558 # and qtextedit.cpp.
1554 1559 document = self._control.document()
1555 1560 scrollbar = self._control.verticalScrollBar()
1556 1561 viewport_height = self._control.viewport().height()
1557 1562 if isinstance(self._control, QtGui.QPlainTextEdit):
1558 1563 maximum = max(0, document.lineCount() - 1)
1559 1564 step = viewport_height / self._control.fontMetrics().lineSpacing()
1560 1565 else:
1561 1566 # QTextEdit does not do line-based layout and blocks will not in
1562 1567 # general have the same height. Therefore it does not make sense to
1563 1568 # attempt to scroll in line height increments.
1564 1569 maximum = document.size().height()
1565 1570 step = viewport_height
1566 1571 diff = maximum - scrollbar.maximum()
1567 1572 scrollbar.setRange(0, maximum)
1568 1573 scrollbar.setPageStep(step)
1569 1574 # Compensate for undesirable scrolling that occurs automatically due to
1570 1575 # maximumBlockCount() text truncation.
1571 if diff < 0:
1576 if diff < 0 and document.blockCount() == document.maximumBlockCount():
1572 1577 scrollbar.setValue(scrollbar.value() + diff)
1573 1578
1574 1579 def _cursor_position_changed(self):
1575 1580 """ Clears the temporary buffer based on the cursor position.
1576 1581 """
1577 1582 if self._text_completing_pos:
1578 1583 document = self._control.document()
1579 1584 if self._text_completing_pos < document.characterCount():
1580 1585 cursor = self._control.textCursor()
1581 1586 pos = cursor.position()
1582 1587 text_cursor = self._control.textCursor()
1583 1588 text_cursor.setPosition(self._text_completing_pos)
1584 1589 if pos < self._text_completing_pos or \
1585 1590 cursor.blockNumber() > text_cursor.blockNumber():
1586 1591 self._clear_temporary_buffer()
1587 1592 self._text_completing_pos = 0
1588 1593 else:
1589 1594 self._clear_temporary_buffer()
1590 1595 self._text_completing_pos = 0
1591 1596
1592 1597 def _custom_context_menu_requested(self, pos):
1593 1598 """ Shows a context menu at the given QPoint (in widget coordinates).
1594 1599 """
1595 1600 menu = self._context_menu_make(pos)
1596 1601 menu.exec_(self._control.mapToGlobal(pos))
General Comments 0
You need to be logged in to leave comments. Login now