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