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