##// END OF EJS Templates
add ConsoleWidget.include_other_output...
MinRK -
Show More
@@ -1,150 +1,158 b''
1 1 """ Defines a convenient mix-in class for implementing Qt frontends.
2 2 """
3 3
4 4 class BaseFrontendMixin(object):
5 5 """ A mix-in class for implementing Qt frontends.
6 6
7 7 To handle messages of a particular type, frontends need only define an
8 8 appropriate handler method. For example, to handle 'stream' messaged, define
9 9 a '_handle_stream(msg)' method.
10 10 """
11 11
12 12 #---------------------------------------------------------------------------
13 13 # 'BaseFrontendMixin' concrete interface
14 14 #---------------------------------------------------------------------------
15 15 _kernel_client = None
16 16 _kernel_manager = None
17 17
18 18 @property
19 19 def kernel_client(self):
20 20 """Returns the current kernel client."""
21 21 return self._kernel_client
22 22
23 23 @kernel_client.setter
24 24 def kernel_client(self, kernel_client):
25 25 """Disconnect from the current kernel client (if any) and set a new
26 26 kernel client.
27 27 """
28 28 # Disconnect the old kernel client, if necessary.
29 29 old_client = self._kernel_client
30 30 if old_client is not None:
31 31 old_client.started_channels.disconnect(self._started_channels)
32 32 old_client.stopped_channels.disconnect(self._stopped_channels)
33 33
34 34 # Disconnect the old kernel client's channels.
35 35 old_client.iopub_channel.message_received.disconnect(self._dispatch)
36 36 old_client.shell_channel.message_received.disconnect(self._dispatch)
37 37 old_client.stdin_channel.message_received.disconnect(self._dispatch)
38 38 old_client.hb_channel.kernel_died.disconnect(
39 39 self._handle_kernel_died)
40 40
41 41 # Handle the case where the old kernel client is still listening.
42 42 if old_client.channels_running:
43 43 self._stopped_channels()
44 44
45 45 # Set the new kernel client.
46 46 self._kernel_client = kernel_client
47 47 if kernel_client is None:
48 48 return
49 49
50 50 # Connect the new kernel client.
51 51 kernel_client.started_channels.connect(self._started_channels)
52 52 kernel_client.stopped_channels.connect(self._stopped_channels)
53 53
54 54 # Connect the new kernel client's channels.
55 55 kernel_client.iopub_channel.message_received.connect(self._dispatch)
56 56 kernel_client.shell_channel.message_received.connect(self._dispatch)
57 57 kernel_client.stdin_channel.message_received.connect(self._dispatch)
58 58 # hb_channel
59 59 kernel_client.hb_channel.kernel_died.connect(self._handle_kernel_died)
60 60
61 61 # Handle the case where the kernel client started channels before
62 62 # we connected.
63 63 if kernel_client.channels_running:
64 64 self._started_channels()
65 65
66 66 @property
67 67 def kernel_manager(self):
68 68 """The kernel manager, if any"""
69 69 return self._kernel_manager
70 70
71 71 @kernel_manager.setter
72 72 def kernel_manager(self, kernel_manager):
73 73 old_man = self._kernel_manager
74 74 if old_man is not None:
75 75 old_man.kernel_restarted.disconnect(self._handle_kernel_restarted)
76 76
77 77 self._kernel_manager = kernel_manager
78 78 if kernel_manager is None:
79 79 return
80 80
81 81 kernel_manager.kernel_restarted.connect(self._handle_kernel_restarted)
82 82
83 83 #---------------------------------------------------------------------------
84 84 # 'BaseFrontendMixin' abstract interface
85 85 #---------------------------------------------------------------------------
86 86
87 87 def _handle_kernel_died(self, since_last_heartbeat):
88 88 """ This is called when the ``kernel_died`` signal is emitted.
89 89
90 90 This method is called when the kernel heartbeat has not been
91 91 active for a certain amount of time.
92 92 This is a strictly passive notification -
93 93 the kernel is likely being restarted by its KernelManager.
94 94
95 95 Parameters
96 96 ----------
97 97 since_last_heartbeat : float
98 98 The time since the heartbeat was last received.
99 99 """
100 100
101 101 def _handle_kernel_restarted(self):
102 102 """ This is called when the ``kernel_restarted`` signal is emitted.
103 103
104 104 This method is called when the kernel has been restarted by the
105 105 autorestart mechanism.
106 106
107 107 Parameters
108 108 ----------
109 109 since_last_heartbeat : float
110 110 The time since the heartbeat was last received.
111 111 """
112 112 def _started_kernel(self):
113 113 """Called when the KernelManager starts (or restarts) the kernel subprocess.
114 114 Channels may or may not be running at this point.
115 115 """
116 116
117 117 def _started_channels(self):
118 118 """ Called when the KernelManager channels have started listening or
119 119 when the frontend is assigned an already listening KernelManager.
120 120 """
121 121
122 122 def _stopped_channels(self):
123 123 """ Called when the KernelManager channels have stopped listening or
124 124 when a listening KernelManager is removed from the frontend.
125 125 """
126 126
127 127 #---------------------------------------------------------------------------
128 128 # 'BaseFrontendMixin' protected interface
129 129 #---------------------------------------------------------------------------
130 130
131 131 def _dispatch(self, msg):
132 132 """ Calls the frontend handler associated with the message type of the
133 133 given message.
134 134 """
135 135 msg_type = msg['header']['msg_type']
136 136 handler = getattr(self, '_handle_' + msg_type, None)
137 137 if handler:
138 138 handler(msg)
139
140 def _is_from_this_session(self, msg):
141 """ Returns whether a reply from the kernel originated from a request
142 from this frontend.
143 """
144 session = self._kernel_client.session.session
145 parent = msg['parent_header']
146 if not parent:
147 # if the message has no parent, assume it is meant for all frontends
139
140 def from_here(self, msg):
141 """Return whether a message is from this session"""
142 session_id = self._kernel_client.session.session
143 return msg['parent_header'].get("session", session_id) == session_id
144
145 def include_output(self, msg):
146 """Return whether we should include a given output message"""
147 if self._hidden:
148 return False
149 from_here = self.from_here(msg)
150 if msg['msg_type'] == 'execute_input':
151 # only echo inputs not from here
152 return self.include_other_output and not from_here
153
154 if self.include_other_output:
148 155 return True
149 156 else:
150 return parent.get('session') == session
157 return from_here
158
@@ -1,2155 +1,2163 b''
1 1 """ An abstract base class for console-type widgets.
2 2 """
3 3 #-----------------------------------------------------------------------------
4 4 # Imports
5 5 #-----------------------------------------------------------------------------
6 6
7 7 # Standard library imports
8 8 import os.path
9 9 import re
10 10 import sys
11 11 from textwrap import dedent
12 12 import time
13 13 from unicodedata import category
14 14 import webbrowser
15 15
16 16 # System library imports
17 17 from IPython.external.qt import QtCore, QtGui
18 18
19 19 # Local imports
20 20 from IPython.config.configurable import LoggingConfigurable
21 21 from IPython.core.inputsplitter import ESC_SEQUENCES
22 22 from IPython.qt.rich_text import HtmlExporter
23 23 from IPython.qt.util import MetaQObjectHasTraits, get_font
24 24 from IPython.utils.text import columnize
25 25 from IPython.utils.traitlets import Bool, Enum, Integer, Unicode
26 26 from .ansi_code_processor import QtAnsiCodeProcessor
27 27 from .completion_widget import CompletionWidget
28 28 from .completion_html import CompletionHtml
29 29 from .completion_plain import CompletionPlain
30 30 from .kill_ring import QtKillRing
31 31
32 32
33 33 #-----------------------------------------------------------------------------
34 34 # Functions
35 35 #-----------------------------------------------------------------------------
36 36
37 37 ESCAPE_CHARS = ''.join(ESC_SEQUENCES)
38 38 ESCAPE_RE = re.compile("^["+ESCAPE_CHARS+"]+")
39 39
40 40 def commonprefix(items):
41 41 """Get common prefix for completions
42 42
43 43 Return the longest common prefix of a list of strings, but with special
44 44 treatment of escape characters that might precede commands in IPython,
45 45 such as %magic functions. Used in tab completion.
46 46
47 47 For a more general function, see os.path.commonprefix
48 48 """
49 49 # the last item will always have the least leading % symbol
50 50 # min / max are first/last in alphabetical order
51 51 first_match = ESCAPE_RE.match(min(items))
52 52 last_match = ESCAPE_RE.match(max(items))
53 53 # common suffix is (common prefix of reversed items) reversed
54 54 if first_match and last_match:
55 55 prefix = os.path.commonprefix((first_match.group(0)[::-1], last_match.group(0)[::-1]))[::-1]
56 56 else:
57 57 prefix = ''
58 58
59 59 items = [s.lstrip(ESCAPE_CHARS) for s in items]
60 60 return prefix+os.path.commonprefix(items)
61 61
62 62 def is_letter_or_number(char):
63 63 """ Returns whether the specified unicode character is a letter or a number.
64 64 """
65 65 cat = category(char)
66 66 return cat.startswith('L') or cat.startswith('N')
67 67
68 68 #-----------------------------------------------------------------------------
69 69 # Classes
70 70 #-----------------------------------------------------------------------------
71 71
72 72 class ConsoleWidget(MetaQObjectHasTraits('NewBase', (LoggingConfigurable, QtGui.QWidget), {})):
73 73 """ An abstract base class for console-type widgets. This class has
74 74 functionality for:
75 75
76 76 * Maintaining a prompt and editing region
77 77 * Providing the traditional Unix-style console keyboard shortcuts
78 78 * Performing tab completion
79 79 * Paging text
80 80 * Handling ANSI escape codes
81 81
82 82 ConsoleWidget also provides a number of utility methods that will be
83 83 convenient to implementors of a console-style widget.
84 84 """
85 85
86 86 #------ Configuration ------------------------------------------------------
87 87
88 88 ansi_codes = Bool(True, config=True,
89 89 help="Whether to process ANSI escape codes."
90 90 )
91 91 buffer_size = Integer(500, config=True,
92 92 help="""
93 93 The maximum number of lines of text before truncation. Specifying a
94 94 non-positive number disables text truncation (not recommended).
95 95 """
96 96 )
97 97 execute_on_complete_input = Bool(True, config=True,
98 98 help="""Whether to automatically execute on syntactically complete input.
99 99
100 100 If False, Shift-Enter is required to submit each execution.
101 101 Disabling this is mainly useful for non-Python kernels,
102 102 where the completion check would be wrong.
103 103 """
104 104 )
105 105 gui_completion = Enum(['plain', 'droplist', 'ncurses'], config=True,
106 106 default_value = 'ncurses',
107 107 help="""
108 108 The type of completer to use. Valid values are:
109 109
110 110 'plain' : Show the available completion as a text list
111 111 Below the editing area.
112 112 'droplist': Show the completion in a drop down list navigable
113 113 by the arrow keys, and from which you can select
114 114 completion by pressing Return.
115 115 'ncurses' : Show the completion as a text list which is navigable by
116 116 `tab` and arrow keys.
117 117 """
118 118 )
119 119 # NOTE: this value can only be specified during initialization.
120 120 kind = Enum(['plain', 'rich'], default_value='plain', config=True,
121 121 help="""
122 122 The type of underlying text widget to use. Valid values are 'plain',
123 123 which specifies a QPlainTextEdit, and 'rich', which specifies a
124 124 QTextEdit.
125 125 """
126 126 )
127 127 # NOTE: this value can only be specified during initialization.
128 128 paging = Enum(['inside', 'hsplit', 'vsplit', 'custom', 'none'],
129 129 default_value='inside', config=True,
130 130 help="""
131 131 The type of paging to use. Valid values are:
132 132
133 133 'inside'
134 134 The widget pages like a traditional terminal.
135 135 'hsplit'
136 136 When paging is requested, the widget is split horizontally. The top
137 137 pane contains the console, and the bottom pane contains the paged text.
138 138 'vsplit'
139 139 Similar to 'hsplit', except that a vertical splitter is used.
140 140 'custom'
141 141 No action is taken by the widget beyond emitting a
142 142 'custom_page_requested(str)' signal.
143 143 'none'
144 144 The text is written directly to the console.
145 145 """)
146 146
147 147 font_family = Unicode(config=True,
148 148 help="""The font family to use for the console.
149 149 On OSX this defaults to Monaco, on Windows the default is
150 150 Consolas with fallback of Courier, and on other platforms
151 151 the default is Monospace.
152 152 """)
153 153 def _font_family_default(self):
154 154 if sys.platform == 'win32':
155 155 # Consolas ships with Vista/Win7, fallback to Courier if needed
156 156 return 'Consolas'
157 157 elif sys.platform == 'darwin':
158 158 # OSX always has Monaco, no need for a fallback
159 159 return 'Monaco'
160 160 else:
161 161 # Monospace should always exist, no need for a fallback
162 162 return 'Monospace'
163 163
164 164 font_size = Integer(config=True,
165 165 help="""The font size. If unconfigured, Qt will be entrusted
166 166 with the size of the font.
167 167 """)
168 168
169 169 width = Integer(81, config=True,
170 170 help="""The width of the console at start time in number
171 171 of characters (will double with `hsplit` paging)
172 172 """)
173 173
174 174 height = Integer(25, config=True,
175 175 help="""The height of the console at start time in number
176 176 of characters (will double with `vsplit` paging)
177 177 """)
178 178
179 179 # Whether to override ShortcutEvents for the keybindings defined by this
180 180 # widget (Ctrl+n, Ctrl+a, etc). Enable this if you want this widget to take
181 181 # priority (when it has focus) over, e.g., window-level menu shortcuts.
182 182 override_shortcuts = Bool(False)
183 183
184 184 # ------ Custom Qt Widgets -------------------------------------------------
185 185
186 186 # For other projects to easily override the Qt widgets used by the console
187 187 # (e.g. Spyder)
188 188 custom_control = None
189 189 custom_page_control = None
190 190
191 191 #------ Signals ------------------------------------------------------------
192 192
193 193 # Signals that indicate ConsoleWidget state.
194 194 copy_available = QtCore.Signal(bool)
195 195 redo_available = QtCore.Signal(bool)
196 196 undo_available = QtCore.Signal(bool)
197 197
198 198 # Signal emitted when paging is needed and the paging style has been
199 199 # specified as 'custom'.
200 200 custom_page_requested = QtCore.Signal(object)
201 201
202 202 # Signal emitted when the font is changed.
203 203 font_changed = QtCore.Signal(QtGui.QFont)
204 204
205 205 #------ Protected class variables ------------------------------------------
206 206
207 207 # control handles
208 208 _control = None
209 209 _page_control = None
210 210 _splitter = None
211 211
212 212 # When the control key is down, these keys are mapped.
213 213 _ctrl_down_remap = { QtCore.Qt.Key_B : QtCore.Qt.Key_Left,
214 214 QtCore.Qt.Key_F : QtCore.Qt.Key_Right,
215 215 QtCore.Qt.Key_A : QtCore.Qt.Key_Home,
216 216 QtCore.Qt.Key_P : QtCore.Qt.Key_Up,
217 217 QtCore.Qt.Key_N : QtCore.Qt.Key_Down,
218 218 QtCore.Qt.Key_H : QtCore.Qt.Key_Backspace, }
219 219 if not sys.platform == 'darwin':
220 220 # On OS X, Ctrl-E already does the right thing, whereas End moves the
221 221 # cursor to the bottom of the buffer.
222 222 _ctrl_down_remap[QtCore.Qt.Key_E] = QtCore.Qt.Key_End
223 223
224 224 # The shortcuts defined by this widget. We need to keep track of these to
225 225 # support 'override_shortcuts' above.
226 226 _shortcuts = set(_ctrl_down_remap.keys()) | \
227 227 { QtCore.Qt.Key_C, QtCore.Qt.Key_G, QtCore.Qt.Key_O,
228 228 QtCore.Qt.Key_V }
229 229
230 230 _temp_buffer_filled = False
231 231
232 232 #---------------------------------------------------------------------------
233 233 # 'QObject' interface
234 234 #---------------------------------------------------------------------------
235 235
236 236 def __init__(self, parent=None, **kw):
237 237 """ Create a ConsoleWidget.
238 238
239 239 Parameters
240 240 ----------
241 241 parent : QWidget, optional [default None]
242 242 The parent for this widget.
243 243 """
244 244 QtGui.QWidget.__init__(self, parent)
245 245 LoggingConfigurable.__init__(self, **kw)
246 246
247 247 # While scrolling the pager on Mac OS X, it tears badly. The
248 248 # NativeGesture is platform and perhaps build-specific hence
249 249 # we take adequate precautions here.
250 250 self._pager_scroll_events = [QtCore.QEvent.Wheel]
251 251 if hasattr(QtCore.QEvent, 'NativeGesture'):
252 252 self._pager_scroll_events.append(QtCore.QEvent.NativeGesture)
253 253
254 254 # Create the layout and underlying text widget.
255 255 layout = QtGui.QStackedLayout(self)
256 256 layout.setContentsMargins(0, 0, 0, 0)
257 257 self._control = self._create_control()
258 258 if self.paging in ('hsplit', 'vsplit'):
259 259 self._splitter = QtGui.QSplitter()
260 260 if self.paging == 'hsplit':
261 261 self._splitter.setOrientation(QtCore.Qt.Horizontal)
262 262 else:
263 263 self._splitter.setOrientation(QtCore.Qt.Vertical)
264 264 self._splitter.addWidget(self._control)
265 265 layout.addWidget(self._splitter)
266 266 else:
267 267 layout.addWidget(self._control)
268 268
269 269 # Create the paging widget, if necessary.
270 270 if self.paging in ('inside', 'hsplit', 'vsplit'):
271 271 self._page_control = self._create_page_control()
272 272 if self._splitter:
273 273 self._page_control.hide()
274 274 self._splitter.addWidget(self._page_control)
275 275 else:
276 276 layout.addWidget(self._page_control)
277 277
278 278 # Initialize protected variables. Some variables contain useful state
279 279 # information for subclasses; they should be considered read-only.
280 280 self._append_before_prompt_pos = 0
281 281 self._ansi_processor = QtAnsiCodeProcessor()
282 282 if self.gui_completion == 'ncurses':
283 283 self._completion_widget = CompletionHtml(self)
284 284 elif self.gui_completion == 'droplist':
285 285 self._completion_widget = CompletionWidget(self)
286 286 elif self.gui_completion == 'plain':
287 287 self._completion_widget = CompletionPlain(self)
288 288
289 289 self._continuation_prompt = '> '
290 290 self._continuation_prompt_html = None
291 291 self._executing = False
292 292 self._filter_resize = False
293 293 self._html_exporter = HtmlExporter(self._control)
294 294 self._input_buffer_executing = ''
295 295 self._input_buffer_pending = ''
296 296 self._kill_ring = QtKillRing(self._control)
297 297 self._prompt = ''
298 298 self._prompt_html = None
299 299 self._prompt_pos = 0
300 300 self._prompt_sep = ''
301 301 self._reading = False
302 302 self._reading_callback = None
303 303 self._tab_width = 8
304 304
305 305 # List of strings pending to be appended as plain text in the widget.
306 306 # The text is not immediately inserted when available to not
307 307 # choke the Qt event loop with paint events for the widget in
308 308 # case of lots of output from kernel.
309 309 self._pending_insert_text = []
310 310
311 311 # Timer to flush the pending stream messages. The interval is adjusted
312 312 # later based on actual time taken for flushing a screen (buffer_size)
313 313 # of output text.
314 314 self._pending_text_flush_interval = QtCore.QTimer(self._control)
315 315 self._pending_text_flush_interval.setInterval(100)
316 316 self._pending_text_flush_interval.setSingleShot(True)
317 317 self._pending_text_flush_interval.timeout.connect(
318 318 self._on_flush_pending_stream_timer)
319 319
320 320 # Set a monospaced font.
321 321 self.reset_font()
322 322
323 323 # Configure actions.
324 324 action = QtGui.QAction('Print', None)
325 325 action.setEnabled(True)
326 326 printkey = QtGui.QKeySequence(QtGui.QKeySequence.Print)
327 327 if printkey.matches("Ctrl+P") and sys.platform != 'darwin':
328 328 # Only override the default if there is a collision.
329 329 # Qt ctrl = cmd on OSX, so the match gets a false positive on OSX.
330 330 printkey = "Ctrl+Shift+P"
331 331 action.setShortcut(printkey)
332 332 action.setShortcutContext(QtCore.Qt.WidgetWithChildrenShortcut)
333 333 action.triggered.connect(self.print_)
334 334 self.addAction(action)
335 335 self.print_action = action
336 336
337 337 action = QtGui.QAction('Save as HTML/XML', None)
338 338 action.setShortcut(QtGui.QKeySequence.Save)
339 339 action.setShortcutContext(QtCore.Qt.WidgetWithChildrenShortcut)
340 340 action.triggered.connect(self.export_html)
341 341 self.addAction(action)
342 342 self.export_action = action
343 343
344 344 action = QtGui.QAction('Select All', None)
345 345 action.setEnabled(True)
346 346 selectall = QtGui.QKeySequence(QtGui.QKeySequence.SelectAll)
347 347 if selectall.matches("Ctrl+A") and sys.platform != 'darwin':
348 348 # Only override the default if there is a collision.
349 349 # Qt ctrl = cmd on OSX, so the match gets a false positive on OSX.
350 350 selectall = "Ctrl+Shift+A"
351 351 action.setShortcut(selectall)
352 352 action.setShortcutContext(QtCore.Qt.WidgetWithChildrenShortcut)
353 353 action.triggered.connect(self.select_all)
354 354 self.addAction(action)
355 355 self.select_all_action = action
356 356
357 357 self.increase_font_size = QtGui.QAction("Bigger Font",
358 358 self,
359 359 shortcut=QtGui.QKeySequence.ZoomIn,
360 360 shortcutContext=QtCore.Qt.WidgetWithChildrenShortcut,
361 361 statusTip="Increase the font size by one point",
362 362 triggered=self._increase_font_size)
363 363 self.addAction(self.increase_font_size)
364 364
365 365 self.decrease_font_size = QtGui.QAction("Smaller Font",
366 366 self,
367 367 shortcut=QtGui.QKeySequence.ZoomOut,
368 368 shortcutContext=QtCore.Qt.WidgetWithChildrenShortcut,
369 369 statusTip="Decrease the font size by one point",
370 370 triggered=self._decrease_font_size)
371 371 self.addAction(self.decrease_font_size)
372 372
373 373 self.reset_font_size = QtGui.QAction("Normal Font",
374 374 self,
375 375 shortcut="Ctrl+0",
376 376 shortcutContext=QtCore.Qt.WidgetWithChildrenShortcut,
377 377 statusTip="Restore the Normal font size",
378 378 triggered=self.reset_font)
379 379 self.addAction(self.reset_font_size)
380 380
381 381 # Accept drag and drop events here. Drops were already turned off
382 382 # in self._control when that widget was created.
383 383 self.setAcceptDrops(True)
384 384
385 385 #---------------------------------------------------------------------------
386 386 # Drag and drop support
387 387 #---------------------------------------------------------------------------
388 388
389 389 def dragEnterEvent(self, e):
390 390 if e.mimeData().hasUrls():
391 391 # The link action should indicate to that the drop will insert
392 392 # the file anme.
393 393 e.setDropAction(QtCore.Qt.LinkAction)
394 394 e.accept()
395 395 elif e.mimeData().hasText():
396 396 # By changing the action to copy we don't need to worry about
397 397 # the user accidentally moving text around in the widget.
398 398 e.setDropAction(QtCore.Qt.CopyAction)
399 399 e.accept()
400 400
401 401 def dragMoveEvent(self, e):
402 402 if e.mimeData().hasUrls():
403 403 pass
404 404 elif e.mimeData().hasText():
405 405 cursor = self._control.cursorForPosition(e.pos())
406 406 if self._in_buffer(cursor.position()):
407 407 e.setDropAction(QtCore.Qt.CopyAction)
408 408 self._control.setTextCursor(cursor)
409 409 else:
410 410 e.setDropAction(QtCore.Qt.IgnoreAction)
411 411 e.accept()
412 412
413 413 def dropEvent(self, e):
414 414 if e.mimeData().hasUrls():
415 415 self._keep_cursor_in_buffer()
416 416 cursor = self._control.textCursor()
417 417 filenames = [url.toLocalFile() for url in e.mimeData().urls()]
418 418 text = ', '.join("'" + f.replace("'", "'\"'\"'") + "'"
419 419 for f in filenames)
420 420 self._insert_plain_text_into_buffer(cursor, text)
421 421 elif e.mimeData().hasText():
422 422 cursor = self._control.cursorForPosition(e.pos())
423 423 if self._in_buffer(cursor.position()):
424 424 text = e.mimeData().text()
425 425 self._insert_plain_text_into_buffer(cursor, text)
426 426
427 427 def eventFilter(self, obj, event):
428 428 """ Reimplemented to ensure a console-like behavior in the underlying
429 429 text widgets.
430 430 """
431 431 etype = event.type()
432 432 if etype == QtCore.QEvent.KeyPress:
433 433
434 434 # Re-map keys for all filtered widgets.
435 435 key = event.key()
436 436 if self._control_key_down(event.modifiers()) and \
437 437 key in self._ctrl_down_remap:
438 438 new_event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress,
439 439 self._ctrl_down_remap[key],
440 440 QtCore.Qt.NoModifier)
441 441 QtGui.qApp.sendEvent(obj, new_event)
442 442 return True
443 443
444 444 elif obj == self._control:
445 445 return self._event_filter_console_keypress(event)
446 446
447 447 elif obj == self._page_control:
448 448 return self._event_filter_page_keypress(event)
449 449
450 450 # Make middle-click paste safe.
451 451 elif etype == QtCore.QEvent.MouseButtonRelease and \
452 452 event.button() == QtCore.Qt.MidButton and \
453 453 obj == self._control.viewport():
454 454 cursor = self._control.cursorForPosition(event.pos())
455 455 self._control.setTextCursor(cursor)
456 456 self.paste(QtGui.QClipboard.Selection)
457 457 return True
458 458
459 459 # Manually adjust the scrollbars *after* a resize event is dispatched.
460 460 elif etype == QtCore.QEvent.Resize and not self._filter_resize:
461 461 self._filter_resize = True
462 462 QtGui.qApp.sendEvent(obj, event)
463 463 self._adjust_scrollbars()
464 464 self._filter_resize = False
465 465 return True
466 466
467 467 # Override shortcuts for all filtered widgets.
468 468 elif etype == QtCore.QEvent.ShortcutOverride and \
469 469 self.override_shortcuts and \
470 470 self._control_key_down(event.modifiers()) and \
471 471 event.key() in self._shortcuts:
472 472 event.accept()
473 473
474 474 # Handle scrolling of the vsplit pager. This hack attempts to solve
475 475 # problems with tearing of the help text inside the pager window. This
476 476 # happens only on Mac OS X with both PySide and PyQt. This fix isn't
477 477 # perfect but makes the pager more usable.
478 478 elif etype in self._pager_scroll_events and \
479 479 obj == self._page_control:
480 480 self._page_control.repaint()
481 481 return True
482 482
483 483 elif etype == QtCore.QEvent.MouseMove:
484 484 anchor = self._control.anchorAt(event.pos())
485 485 QtGui.QToolTip.showText(event.globalPos(), anchor)
486 486
487 487 return super(ConsoleWidget, self).eventFilter(obj, event)
488 488
489 489 #---------------------------------------------------------------------------
490 490 # 'QWidget' interface
491 491 #---------------------------------------------------------------------------
492 492
493 493 def sizeHint(self):
494 494 """ Reimplemented to suggest a size that is 80 characters wide and
495 495 25 lines high.
496 496 """
497 497 font_metrics = QtGui.QFontMetrics(self.font)
498 498 margin = (self._control.frameWidth() +
499 499 self._control.document().documentMargin()) * 2
500 500 style = self.style()
501 501 splitwidth = style.pixelMetric(QtGui.QStyle.PM_SplitterWidth)
502 502
503 503 # Note 1: Despite my best efforts to take the various margins into
504 504 # account, the width is still coming out a bit too small, so we include
505 505 # a fudge factor of one character here.
506 506 # Note 2: QFontMetrics.maxWidth is not used here or anywhere else due
507 507 # to a Qt bug on certain Mac OS systems where it returns 0.
508 508 width = font_metrics.width(' ') * self.width + margin
509 509 width += style.pixelMetric(QtGui.QStyle.PM_ScrollBarExtent)
510 510 if self.paging == 'hsplit':
511 511 width = width * 2 + splitwidth
512 512
513 513 height = font_metrics.height() * self.height + margin
514 514 if self.paging == 'vsplit':
515 515 height = height * 2 + splitwidth
516 516
517 517 return QtCore.QSize(width, height)
518 518
519 519 #---------------------------------------------------------------------------
520 520 # 'ConsoleWidget' public interface
521 521 #---------------------------------------------------------------------------
522
522
523 include_other_output = Bool(False, config=True,
524 help="""Whether to include output from clients
525 other than this one sharing the same kernel.
526
527 Outputs are not displayed until enter is pressed.
528 """
529 )
530
523 531 def can_copy(self):
524 532 """ Returns whether text can be copied to the clipboard.
525 533 """
526 534 return self._control.textCursor().hasSelection()
527 535
528 536 def can_cut(self):
529 537 """ Returns whether text can be cut to the clipboard.
530 538 """
531 539 cursor = self._control.textCursor()
532 540 return (cursor.hasSelection() and
533 541 self._in_buffer(cursor.anchor()) and
534 542 self._in_buffer(cursor.position()))
535 543
536 544 def can_paste(self):
537 545 """ Returns whether text can be pasted from the clipboard.
538 546 """
539 547 if self._control.textInteractionFlags() & QtCore.Qt.TextEditable:
540 548 return bool(QtGui.QApplication.clipboard().text())
541 549 return False
542 550
543 551 def clear(self, keep_input=True):
544 552 """ Clear the console.
545 553
546 554 Parameters
547 555 ----------
548 556 keep_input : bool, optional (default True)
549 557 If set, restores the old input buffer if a new prompt is written.
550 558 """
551 559 if self._executing:
552 560 self._control.clear()
553 561 else:
554 562 if keep_input:
555 563 input_buffer = self.input_buffer
556 564 self._control.clear()
557 565 self._show_prompt()
558 566 if keep_input:
559 567 self.input_buffer = input_buffer
560 568
561 569 def copy(self):
562 570 """ Copy the currently selected text to the clipboard.
563 571 """
564 572 self.layout().currentWidget().copy()
565 573
566 574 def copy_anchor(self, anchor):
567 575 """ Copy anchor text to the clipboard
568 576 """
569 577 QtGui.QApplication.clipboard().setText(anchor)
570 578
571 579 def cut(self):
572 580 """ Copy the currently selected text to the clipboard and delete it
573 581 if it's inside the input buffer.
574 582 """
575 583 self.copy()
576 584 if self.can_cut():
577 585 self._control.textCursor().removeSelectedText()
578 586
579 587 def execute(self, source=None, hidden=False, interactive=False):
580 588 """ Executes source or the input buffer, possibly prompting for more
581 589 input.
582 590
583 591 Parameters
584 592 ----------
585 593 source : str, optional
586 594
587 595 The source to execute. If not specified, the input buffer will be
588 596 used. If specified and 'hidden' is False, the input buffer will be
589 597 replaced with the source before execution.
590 598
591 599 hidden : bool, optional (default False)
592 600
593 601 If set, no output will be shown and the prompt will not be modified.
594 602 In other words, it will be completely invisible to the user that
595 603 an execution has occurred.
596 604
597 605 interactive : bool, optional (default False)
598 606
599 607 Whether the console is to treat the source as having been manually
600 608 entered by the user. The effect of this parameter depends on the
601 609 subclass implementation.
602 610
603 611 Raises
604 612 ------
605 613 RuntimeError
606 614 If incomplete input is given and 'hidden' is True. In this case,
607 615 it is not possible to prompt for more input.
608 616
609 617 Returns
610 618 -------
611 619 A boolean indicating whether the source was executed.
612 620 """
613 621 # WARNING: The order in which things happen here is very particular, in
614 622 # large part because our syntax highlighting is fragile. If you change
615 623 # something, test carefully!
616 624
617 625 # Decide what to execute.
618 626 if source is None:
619 627 source = self.input_buffer
620 628 if not hidden:
621 629 # A newline is appended later, but it should be considered part
622 630 # of the input buffer.
623 631 source += '\n'
624 632 elif not hidden:
625 633 self.input_buffer = source
626 634
627 635 # Execute the source or show a continuation prompt if it is incomplete.
628 636 if self.execute_on_complete_input:
629 637 complete = self._is_complete(source, interactive)
630 638 else:
631 639 complete = not interactive
632 640 if hidden:
633 641 if complete or not self.execute_on_complete_input:
634 642 self._execute(source, hidden)
635 643 else:
636 644 error = 'Incomplete noninteractive input: "%s"'
637 645 raise RuntimeError(error % source)
638 646 else:
639 647 if complete:
640 648 self._append_plain_text('\n')
641 649 self._input_buffer_executing = self.input_buffer
642 650 self._executing = True
643 651 self._prompt_finished()
644 652
645 653 # The maximum block count is only in effect during execution.
646 654 # This ensures that _prompt_pos does not become invalid due to
647 655 # text truncation.
648 656 self._control.document().setMaximumBlockCount(self.buffer_size)
649 657
650 658 # Setting a positive maximum block count will automatically
651 659 # disable the undo/redo history, but just to be safe:
652 660 self._control.setUndoRedoEnabled(False)
653 661
654 662 # Perform actual execution.
655 663 self._execute(source, hidden)
656 664
657 665 else:
658 666 # Do this inside an edit block so continuation prompts are
659 667 # removed seamlessly via undo/redo.
660 668 cursor = self._get_end_cursor()
661 669 cursor.beginEditBlock()
662 670 cursor.insertText('\n')
663 671 self._insert_continuation_prompt(cursor)
664 672 cursor.endEditBlock()
665 673
666 674 # Do not do this inside the edit block. It works as expected
667 675 # when using a QPlainTextEdit control, but does not have an
668 676 # effect when using a QTextEdit. I believe this is a Qt bug.
669 677 self._control.moveCursor(QtGui.QTextCursor.End)
670 678
671 679 return complete
672 680
673 681 def export_html(self):
674 682 """ Shows a dialog to export HTML/XML in various formats.
675 683 """
676 684 self._html_exporter.export()
677 685
678 686 def _get_input_buffer(self, force=False):
679 687 """ The text that the user has entered entered at the current prompt.
680 688
681 689 If the console is currently executing, the text that is executing will
682 690 always be returned.
683 691 """
684 692 # If we're executing, the input buffer may not even exist anymore due to
685 693 # the limit imposed by 'buffer_size'. Therefore, we store it.
686 694 if self._executing and not force:
687 695 return self._input_buffer_executing
688 696
689 697 cursor = self._get_end_cursor()
690 698 cursor.setPosition(self._prompt_pos, QtGui.QTextCursor.KeepAnchor)
691 699 input_buffer = cursor.selection().toPlainText()
692 700
693 701 # Strip out continuation prompts.
694 702 return input_buffer.replace('\n' + self._continuation_prompt, '\n')
695 703
696 704 def _set_input_buffer(self, string):
697 705 """ Sets the text in the input buffer.
698 706
699 707 If the console is currently executing, this call has no *immediate*
700 708 effect. When the execution is finished, the input buffer will be updated
701 709 appropriately.
702 710 """
703 711 # If we're executing, store the text for later.
704 712 if self._executing:
705 713 self._input_buffer_pending = string
706 714 return
707 715
708 716 # Remove old text.
709 717 cursor = self._get_end_cursor()
710 718 cursor.beginEditBlock()
711 719 cursor.setPosition(self._prompt_pos, QtGui.QTextCursor.KeepAnchor)
712 720 cursor.removeSelectedText()
713 721
714 722 # Insert new text with continuation prompts.
715 723 self._insert_plain_text_into_buffer(self._get_prompt_cursor(), string)
716 724 cursor.endEditBlock()
717 725 self._control.moveCursor(QtGui.QTextCursor.End)
718 726
719 727 input_buffer = property(_get_input_buffer, _set_input_buffer)
720 728
721 729 def _get_font(self):
722 730 """ The base font being used by the ConsoleWidget.
723 731 """
724 732 return self._control.document().defaultFont()
725 733
726 734 def _set_font(self, font):
727 735 """ Sets the base font for the ConsoleWidget to the specified QFont.
728 736 """
729 737 font_metrics = QtGui.QFontMetrics(font)
730 738 self._control.setTabStopWidth(self.tab_width * font_metrics.width(' '))
731 739
732 740 self._completion_widget.setFont(font)
733 741 self._control.document().setDefaultFont(font)
734 742 if self._page_control:
735 743 self._page_control.document().setDefaultFont(font)
736 744
737 745 self.font_changed.emit(font)
738 746
739 747 font = property(_get_font, _set_font)
740 748
741 749 def open_anchor(self, anchor):
742 750 """ Open selected anchor in the default webbrowser
743 751 """
744 752 webbrowser.open( anchor )
745 753
746 754 def paste(self, mode=QtGui.QClipboard.Clipboard):
747 755 """ Paste the contents of the clipboard into the input region.
748 756
749 757 Parameters
750 758 ----------
751 759 mode : QClipboard::Mode, optional [default QClipboard::Clipboard]
752 760
753 761 Controls which part of the system clipboard is used. This can be
754 762 used to access the selection clipboard in X11 and the Find buffer
755 763 in Mac OS. By default, the regular clipboard is used.
756 764 """
757 765 if self._control.textInteractionFlags() & QtCore.Qt.TextEditable:
758 766 # Make sure the paste is safe.
759 767 self._keep_cursor_in_buffer()
760 768 cursor = self._control.textCursor()
761 769
762 770 # Remove any trailing newline, which confuses the GUI and forces the
763 771 # user to backspace.
764 772 text = QtGui.QApplication.clipboard().text(mode).rstrip()
765 773 self._insert_plain_text_into_buffer(cursor, dedent(text))
766 774
767 775 def print_(self, printer = None):
768 776 """ Print the contents of the ConsoleWidget to the specified QPrinter.
769 777 """
770 778 if (not printer):
771 779 printer = QtGui.QPrinter()
772 780 if(QtGui.QPrintDialog(printer).exec_() != QtGui.QDialog.Accepted):
773 781 return
774 782 self._control.print_(printer)
775 783
776 784 def prompt_to_top(self):
777 785 """ Moves the prompt to the top of the viewport.
778 786 """
779 787 if not self._executing:
780 788 prompt_cursor = self._get_prompt_cursor()
781 789 if self._get_cursor().blockNumber() < prompt_cursor.blockNumber():
782 790 self._set_cursor(prompt_cursor)
783 791 self._set_top_cursor(prompt_cursor)
784 792
785 793 def redo(self):
786 794 """ Redo the last operation. If there is no operation to redo, nothing
787 795 happens.
788 796 """
789 797 self._control.redo()
790 798
791 799 def reset_font(self):
792 800 """ Sets the font to the default fixed-width font for this platform.
793 801 """
794 802 if sys.platform == 'win32':
795 803 # Consolas ships with Vista/Win7, fallback to Courier if needed
796 804 fallback = 'Courier'
797 805 elif sys.platform == 'darwin':
798 806 # OSX always has Monaco
799 807 fallback = 'Monaco'
800 808 else:
801 809 # Monospace should always exist
802 810 fallback = 'Monospace'
803 811 font = get_font(self.font_family, fallback)
804 812 if self.font_size:
805 813 font.setPointSize(self.font_size)
806 814 else:
807 815 font.setPointSize(QtGui.qApp.font().pointSize())
808 816 font.setStyleHint(QtGui.QFont.TypeWriter)
809 817 self._set_font(font)
810 818
811 819 def change_font_size(self, delta):
812 820 """Change the font size by the specified amount (in points).
813 821 """
814 822 font = self.font
815 823 size = max(font.pointSize() + delta, 1) # minimum 1 point
816 824 font.setPointSize(size)
817 825 self._set_font(font)
818 826
819 827 def _increase_font_size(self):
820 828 self.change_font_size(1)
821 829
822 830 def _decrease_font_size(self):
823 831 self.change_font_size(-1)
824 832
825 833 def select_all(self):
826 834 """ Selects all the text in the buffer.
827 835 """
828 836 self._control.selectAll()
829 837
830 838 def _get_tab_width(self):
831 839 """ The width (in terms of space characters) for tab characters.
832 840 """
833 841 return self._tab_width
834 842
835 843 def _set_tab_width(self, tab_width):
836 844 """ Sets the width (in terms of space characters) for tab characters.
837 845 """
838 846 font_metrics = QtGui.QFontMetrics(self.font)
839 847 self._control.setTabStopWidth(tab_width * font_metrics.width(' '))
840 848
841 849 self._tab_width = tab_width
842 850
843 851 tab_width = property(_get_tab_width, _set_tab_width)
844 852
845 853 def undo(self):
846 854 """ Undo the last operation. If there is no operation to undo, nothing
847 855 happens.
848 856 """
849 857 self._control.undo()
850 858
851 859 #---------------------------------------------------------------------------
852 860 # 'ConsoleWidget' abstract interface
853 861 #---------------------------------------------------------------------------
854 862
855 863 def _is_complete(self, source, interactive):
856 864 """ Returns whether 'source' can be executed. When triggered by an
857 865 Enter/Return key press, 'interactive' is True; otherwise, it is
858 866 False.
859 867 """
860 868 raise NotImplementedError
861 869
862 870 def _execute(self, source, hidden):
863 871 """ Execute 'source'. If 'hidden', do not show any output.
864 872 """
865 873 raise NotImplementedError
866 874
867 875 def _prompt_started_hook(self):
868 876 """ Called immediately after a new prompt is displayed.
869 877 """
870 878 pass
871 879
872 880 def _prompt_finished_hook(self):
873 881 """ Called immediately after a prompt is finished, i.e. when some input
874 882 will be processed and a new prompt displayed.
875 883 """
876 884 pass
877 885
878 886 def _up_pressed(self, shift_modifier):
879 887 """ Called when the up key is pressed. Returns whether to continue
880 888 processing the event.
881 889 """
882 890 return True
883 891
884 892 def _down_pressed(self, shift_modifier):
885 893 """ Called when the down key is pressed. Returns whether to continue
886 894 processing the event.
887 895 """
888 896 return True
889 897
890 898 def _tab_pressed(self):
891 899 """ Called when the tab key is pressed. Returns whether to continue
892 900 processing the event.
893 901 """
894 902 return False
895 903
896 904 #--------------------------------------------------------------------------
897 905 # 'ConsoleWidget' protected interface
898 906 #--------------------------------------------------------------------------
899 907
900 908 def _append_custom(self, insert, input, before_prompt=False, *args, **kwargs):
901 909 """ A low-level method for appending content to the end of the buffer.
902 910
903 911 If 'before_prompt' is enabled, the content will be inserted before the
904 912 current prompt, if there is one.
905 913 """
906 914 # Determine where to insert the content.
907 915 cursor = self._control.textCursor()
908 916 if before_prompt and (self._reading or not self._executing):
909 917 self._flush_pending_stream()
910 918 cursor.setPosition(self._append_before_prompt_pos)
911 919 else:
912 920 if insert != self._insert_plain_text:
913 921 self._flush_pending_stream()
914 922 cursor.movePosition(QtGui.QTextCursor.End)
915 923 start_pos = cursor.position()
916 924
917 925 # Perform the insertion.
918 926 result = insert(cursor, input, *args, **kwargs)
919 927
920 928 # Adjust the prompt position if we have inserted before it. This is safe
921 929 # because buffer truncation is disabled when not executing.
922 930 if before_prompt and (self._reading or not self._executing):
923 931 diff = cursor.position() - start_pos
924 932 self._append_before_prompt_pos += diff
925 933 self._prompt_pos += diff
926 934
927 935 return result
928 936
929 937 def _append_block(self, block_format=None, before_prompt=False):
930 938 """ Appends an new QTextBlock to the end of the console buffer.
931 939 """
932 940 self._append_custom(self._insert_block, block_format, before_prompt)
933 941
934 942 def _append_html(self, html, before_prompt=False):
935 943 """ Appends HTML at the end of the console buffer.
936 944 """
937 945 self._append_custom(self._insert_html, html, before_prompt)
938 946
939 947 def _append_html_fetching_plain_text(self, html, before_prompt=False):
940 948 """ Appends HTML, then returns the plain text version of it.
941 949 """
942 950 return self._append_custom(self._insert_html_fetching_plain_text,
943 951 html, before_prompt)
944 952
945 953 def _append_plain_text(self, text, before_prompt=False):
946 954 """ Appends plain text, processing ANSI codes if enabled.
947 955 """
948 956 self._append_custom(self._insert_plain_text, text, before_prompt)
949 957
950 958 def _cancel_completion(self):
951 959 """ If text completion is progress, cancel it.
952 960 """
953 961 self._completion_widget.cancel_completion()
954 962
955 963 def _clear_temporary_buffer(self):
956 964 """ Clears the "temporary text" buffer, i.e. all the text following
957 965 the prompt region.
958 966 """
959 967 # Select and remove all text below the input buffer.
960 968 cursor = self._get_prompt_cursor()
961 969 prompt = self._continuation_prompt.lstrip()
962 970 if(self._temp_buffer_filled):
963 971 self._temp_buffer_filled = False
964 972 while cursor.movePosition(QtGui.QTextCursor.NextBlock):
965 973 temp_cursor = QtGui.QTextCursor(cursor)
966 974 temp_cursor.select(QtGui.QTextCursor.BlockUnderCursor)
967 975 text = temp_cursor.selection().toPlainText().lstrip()
968 976 if not text.startswith(prompt):
969 977 break
970 978 else:
971 979 # We've reached the end of the input buffer and no text follows.
972 980 return
973 981 cursor.movePosition(QtGui.QTextCursor.Left) # Grab the newline.
974 982 cursor.movePosition(QtGui.QTextCursor.End,
975 983 QtGui.QTextCursor.KeepAnchor)
976 984 cursor.removeSelectedText()
977 985
978 986 # After doing this, we have no choice but to clear the undo/redo
979 987 # history. Otherwise, the text is not "temporary" at all, because it
980 988 # can be recalled with undo/redo. Unfortunately, Qt does not expose
981 989 # fine-grained control to the undo/redo system.
982 990 if self._control.isUndoRedoEnabled():
983 991 self._control.setUndoRedoEnabled(False)
984 992 self._control.setUndoRedoEnabled(True)
985 993
986 994 def _complete_with_items(self, cursor, items):
987 995 """ Performs completion with 'items' at the specified cursor location.
988 996 """
989 997 self._cancel_completion()
990 998
991 999 if len(items) == 1:
992 1000 cursor.setPosition(self._control.textCursor().position(),
993 1001 QtGui.QTextCursor.KeepAnchor)
994 1002 cursor.insertText(items[0])
995 1003
996 1004 elif len(items) > 1:
997 1005 current_pos = self._control.textCursor().position()
998 1006 prefix = commonprefix(items)
999 1007 if prefix:
1000 1008 cursor.setPosition(current_pos, QtGui.QTextCursor.KeepAnchor)
1001 1009 cursor.insertText(prefix)
1002 1010 current_pos = cursor.position()
1003 1011
1004 1012 cursor.movePosition(QtGui.QTextCursor.Left, n=len(prefix))
1005 1013 self._completion_widget.show_items(cursor, items)
1006 1014
1007 1015
1008 1016 def _fill_temporary_buffer(self, cursor, text, html=False):
1009 1017 """fill the area below the active editting zone with text"""
1010 1018
1011 1019 current_pos = self._control.textCursor().position()
1012 1020
1013 1021 cursor.beginEditBlock()
1014 1022 self._append_plain_text('\n')
1015 1023 self._page(text, html=html)
1016 1024 cursor.endEditBlock()
1017 1025
1018 1026 cursor.setPosition(current_pos)
1019 1027 self._control.moveCursor(QtGui.QTextCursor.End)
1020 1028 self._control.setTextCursor(cursor)
1021 1029
1022 1030 self._temp_buffer_filled = True
1023 1031
1024 1032
1025 1033 def _context_menu_make(self, pos):
1026 1034 """ Creates a context menu for the given QPoint (in widget coordinates).
1027 1035 """
1028 1036 menu = QtGui.QMenu(self)
1029 1037
1030 1038 self.cut_action = menu.addAction('Cut', self.cut)
1031 1039 self.cut_action.setEnabled(self.can_cut())
1032 1040 self.cut_action.setShortcut(QtGui.QKeySequence.Cut)
1033 1041
1034 1042 self.copy_action = menu.addAction('Copy', self.copy)
1035 1043 self.copy_action.setEnabled(self.can_copy())
1036 1044 self.copy_action.setShortcut(QtGui.QKeySequence.Copy)
1037 1045
1038 1046 self.paste_action = menu.addAction('Paste', self.paste)
1039 1047 self.paste_action.setEnabled(self.can_paste())
1040 1048 self.paste_action.setShortcut(QtGui.QKeySequence.Paste)
1041 1049
1042 1050 anchor = self._control.anchorAt(pos)
1043 1051 if anchor:
1044 1052 menu.addSeparator()
1045 1053 self.copy_link_action = menu.addAction(
1046 1054 'Copy Link Address', lambda: self.copy_anchor(anchor=anchor))
1047 1055 self.open_link_action = menu.addAction(
1048 1056 'Open Link', lambda: self.open_anchor(anchor=anchor))
1049 1057
1050 1058 menu.addSeparator()
1051 1059 menu.addAction(self.select_all_action)
1052 1060
1053 1061 menu.addSeparator()
1054 1062 menu.addAction(self.export_action)
1055 1063 menu.addAction(self.print_action)
1056 1064
1057 1065 return menu
1058 1066
1059 1067 def _control_key_down(self, modifiers, include_command=False):
1060 1068 """ Given a KeyboardModifiers flags object, return whether the Control
1061 1069 key is down.
1062 1070
1063 1071 Parameters
1064 1072 ----------
1065 1073 include_command : bool, optional (default True)
1066 1074 Whether to treat the Command key as a (mutually exclusive) synonym
1067 1075 for Control when in Mac OS.
1068 1076 """
1069 1077 # Note that on Mac OS, ControlModifier corresponds to the Command key
1070 1078 # while MetaModifier corresponds to the Control key.
1071 1079 if sys.platform == 'darwin':
1072 1080 down = include_command and (modifiers & QtCore.Qt.ControlModifier)
1073 1081 return bool(down) ^ bool(modifiers & QtCore.Qt.MetaModifier)
1074 1082 else:
1075 1083 return bool(modifiers & QtCore.Qt.ControlModifier)
1076 1084
1077 1085 def _create_control(self):
1078 1086 """ Creates and connects the underlying text widget.
1079 1087 """
1080 1088 # Create the underlying control.
1081 1089 if self.custom_control:
1082 1090 control = self.custom_control()
1083 1091 elif self.kind == 'plain':
1084 1092 control = QtGui.QPlainTextEdit()
1085 1093 elif self.kind == 'rich':
1086 1094 control = QtGui.QTextEdit()
1087 1095 control.setAcceptRichText(False)
1088 1096 control.setMouseTracking(True)
1089 1097
1090 1098 # Prevent the widget from handling drops, as we already provide
1091 1099 # the logic in this class.
1092 1100 control.setAcceptDrops(False)
1093 1101
1094 1102 # Install event filters. The filter on the viewport is needed for
1095 1103 # mouse events.
1096 1104 control.installEventFilter(self)
1097 1105 control.viewport().installEventFilter(self)
1098 1106
1099 1107 # Connect signals.
1100 1108 control.customContextMenuRequested.connect(
1101 1109 self._custom_context_menu_requested)
1102 1110 control.copyAvailable.connect(self.copy_available)
1103 1111 control.redoAvailable.connect(self.redo_available)
1104 1112 control.undoAvailable.connect(self.undo_available)
1105 1113
1106 1114 # Hijack the document size change signal to prevent Qt from adjusting
1107 1115 # the viewport's scrollbar. We are relying on an implementation detail
1108 1116 # of Q(Plain)TextEdit here, which is potentially dangerous, but without
1109 1117 # this functionality we cannot create a nice terminal interface.
1110 1118 layout = control.document().documentLayout()
1111 1119 layout.documentSizeChanged.disconnect()
1112 1120 layout.documentSizeChanged.connect(self._adjust_scrollbars)
1113 1121
1114 1122 # Configure the control.
1115 1123 control.setAttribute(QtCore.Qt.WA_InputMethodEnabled, True)
1116 1124 control.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
1117 1125 control.setReadOnly(True)
1118 1126 control.setUndoRedoEnabled(False)
1119 1127 control.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn)
1120 1128 return control
1121 1129
1122 1130 def _create_page_control(self):
1123 1131 """ Creates and connects the underlying paging widget.
1124 1132 """
1125 1133 if self.custom_page_control:
1126 1134 control = self.custom_page_control()
1127 1135 elif self.kind == 'plain':
1128 1136 control = QtGui.QPlainTextEdit()
1129 1137 elif self.kind == 'rich':
1130 1138 control = QtGui.QTextEdit()
1131 1139 control.installEventFilter(self)
1132 1140 viewport = control.viewport()
1133 1141 viewport.installEventFilter(self)
1134 1142 control.setReadOnly(True)
1135 1143 control.setUndoRedoEnabled(False)
1136 1144 control.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn)
1137 1145 return control
1138 1146
1139 1147 def _event_filter_console_keypress(self, event):
1140 1148 """ Filter key events for the underlying text widget to create a
1141 1149 console-like interface.
1142 1150 """
1143 1151 intercepted = False
1144 1152 cursor = self._control.textCursor()
1145 1153 position = cursor.position()
1146 1154 key = event.key()
1147 1155 ctrl_down = self._control_key_down(event.modifiers())
1148 1156 alt_down = event.modifiers() & QtCore.Qt.AltModifier
1149 1157 shift_down = event.modifiers() & QtCore.Qt.ShiftModifier
1150 1158
1151 1159 #------ Special sequences ----------------------------------------------
1152 1160
1153 1161 if event.matches(QtGui.QKeySequence.Copy):
1154 1162 self.copy()
1155 1163 intercepted = True
1156 1164
1157 1165 elif event.matches(QtGui.QKeySequence.Cut):
1158 1166 self.cut()
1159 1167 intercepted = True
1160 1168
1161 1169 elif event.matches(QtGui.QKeySequence.Paste):
1162 1170 self.paste()
1163 1171 intercepted = True
1164 1172
1165 1173 #------ Special modifier logic -----------------------------------------
1166 1174
1167 1175 elif key in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter):
1168 1176 intercepted = True
1169 1177
1170 1178 # Special handling when tab completing in text mode.
1171 1179 self._cancel_completion()
1172 1180
1173 1181 if self._in_buffer(position):
1174 1182 # Special handling when a reading a line of raw input.
1175 1183 if self._reading:
1176 1184 self._append_plain_text('\n')
1177 1185 self._reading = False
1178 1186 if self._reading_callback:
1179 1187 self._reading_callback()
1180 1188
1181 1189 # If the input buffer is a single line or there is only
1182 1190 # whitespace after the cursor, execute. Otherwise, split the
1183 1191 # line with a continuation prompt.
1184 1192 elif not self._executing:
1185 1193 cursor.movePosition(QtGui.QTextCursor.End,
1186 1194 QtGui.QTextCursor.KeepAnchor)
1187 1195 at_end = len(cursor.selectedText().strip()) == 0
1188 1196 single_line = (self._get_end_cursor().blockNumber() ==
1189 1197 self._get_prompt_cursor().blockNumber())
1190 1198 if (at_end or shift_down or single_line) and not ctrl_down:
1191 1199 self.execute(interactive = not shift_down)
1192 1200 else:
1193 1201 # Do this inside an edit block for clean undo/redo.
1194 1202 cursor.beginEditBlock()
1195 1203 cursor.setPosition(position)
1196 1204 cursor.insertText('\n')
1197 1205 self._insert_continuation_prompt(cursor)
1198 1206 cursor.endEditBlock()
1199 1207
1200 1208 # Ensure that the whole input buffer is visible.
1201 1209 # FIXME: This will not be usable if the input buffer is
1202 1210 # taller than the console widget.
1203 1211 self._control.moveCursor(QtGui.QTextCursor.End)
1204 1212 self._control.setTextCursor(cursor)
1205 1213
1206 1214 #------ Control/Cmd modifier -------------------------------------------
1207 1215
1208 1216 elif ctrl_down:
1209 1217 if key == QtCore.Qt.Key_G:
1210 1218 self._keyboard_quit()
1211 1219 intercepted = True
1212 1220
1213 1221 elif key == QtCore.Qt.Key_K:
1214 1222 if self._in_buffer(position):
1215 1223 cursor.clearSelection()
1216 1224 cursor.movePosition(QtGui.QTextCursor.EndOfLine,
1217 1225 QtGui.QTextCursor.KeepAnchor)
1218 1226 if not cursor.hasSelection():
1219 1227 # Line deletion (remove continuation prompt)
1220 1228 cursor.movePosition(QtGui.QTextCursor.NextBlock,
1221 1229 QtGui.QTextCursor.KeepAnchor)
1222 1230 cursor.movePosition(QtGui.QTextCursor.Right,
1223 1231 QtGui.QTextCursor.KeepAnchor,
1224 1232 len(self._continuation_prompt))
1225 1233 self._kill_ring.kill_cursor(cursor)
1226 1234 self._set_cursor(cursor)
1227 1235 intercepted = True
1228 1236
1229 1237 elif key == QtCore.Qt.Key_L:
1230 1238 self.prompt_to_top()
1231 1239 intercepted = True
1232 1240
1233 1241 elif key == QtCore.Qt.Key_O:
1234 1242 if self._page_control and self._page_control.isVisible():
1235 1243 self._page_control.setFocus()
1236 1244 intercepted = True
1237 1245
1238 1246 elif key == QtCore.Qt.Key_U:
1239 1247 if self._in_buffer(position):
1240 1248 cursor.clearSelection()
1241 1249 start_line = cursor.blockNumber()
1242 1250 if start_line == self._get_prompt_cursor().blockNumber():
1243 1251 offset = len(self._prompt)
1244 1252 else:
1245 1253 offset = len(self._continuation_prompt)
1246 1254 cursor.movePosition(QtGui.QTextCursor.StartOfBlock,
1247 1255 QtGui.QTextCursor.KeepAnchor)
1248 1256 cursor.movePosition(QtGui.QTextCursor.Right,
1249 1257 QtGui.QTextCursor.KeepAnchor, offset)
1250 1258 self._kill_ring.kill_cursor(cursor)
1251 1259 self._set_cursor(cursor)
1252 1260 intercepted = True
1253 1261
1254 1262 elif key == QtCore.Qt.Key_Y:
1255 1263 self._keep_cursor_in_buffer()
1256 1264 self._kill_ring.yank()
1257 1265 intercepted = True
1258 1266
1259 1267 elif key in (QtCore.Qt.Key_Backspace, QtCore.Qt.Key_Delete):
1260 1268 if key == QtCore.Qt.Key_Backspace:
1261 1269 cursor = self._get_word_start_cursor(position)
1262 1270 else: # key == QtCore.Qt.Key_Delete
1263 1271 cursor = self._get_word_end_cursor(position)
1264 1272 cursor.setPosition(position, QtGui.QTextCursor.KeepAnchor)
1265 1273 self._kill_ring.kill_cursor(cursor)
1266 1274 intercepted = True
1267 1275
1268 1276 elif key == QtCore.Qt.Key_D:
1269 1277 if len(self.input_buffer) == 0:
1270 1278 self.exit_requested.emit(self)
1271 1279 else:
1272 1280 new_event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress,
1273 1281 QtCore.Qt.Key_Delete,
1274 1282 QtCore.Qt.NoModifier)
1275 1283 QtGui.qApp.sendEvent(self._control, new_event)
1276 1284 intercepted = True
1277 1285
1278 1286 #------ Alt modifier ---------------------------------------------------
1279 1287
1280 1288 elif alt_down:
1281 1289 if key == QtCore.Qt.Key_B:
1282 1290 self._set_cursor(self._get_word_start_cursor(position))
1283 1291 intercepted = True
1284 1292
1285 1293 elif key == QtCore.Qt.Key_F:
1286 1294 self._set_cursor(self._get_word_end_cursor(position))
1287 1295 intercepted = True
1288 1296
1289 1297 elif key == QtCore.Qt.Key_Y:
1290 1298 self._kill_ring.rotate()
1291 1299 intercepted = True
1292 1300
1293 1301 elif key == QtCore.Qt.Key_Backspace:
1294 1302 cursor = self._get_word_start_cursor(position)
1295 1303 cursor.setPosition(position, QtGui.QTextCursor.KeepAnchor)
1296 1304 self._kill_ring.kill_cursor(cursor)
1297 1305 intercepted = True
1298 1306
1299 1307 elif key == QtCore.Qt.Key_D:
1300 1308 cursor = self._get_word_end_cursor(position)
1301 1309 cursor.setPosition(position, QtGui.QTextCursor.KeepAnchor)
1302 1310 self._kill_ring.kill_cursor(cursor)
1303 1311 intercepted = True
1304 1312
1305 1313 elif key == QtCore.Qt.Key_Delete:
1306 1314 intercepted = True
1307 1315
1308 1316 elif key == QtCore.Qt.Key_Greater:
1309 1317 self._control.moveCursor(QtGui.QTextCursor.End)
1310 1318 intercepted = True
1311 1319
1312 1320 elif key == QtCore.Qt.Key_Less:
1313 1321 self._control.setTextCursor(self._get_prompt_cursor())
1314 1322 intercepted = True
1315 1323
1316 1324 #------ No modifiers ---------------------------------------------------
1317 1325
1318 1326 else:
1319 1327 if shift_down:
1320 1328 anchormode = QtGui.QTextCursor.KeepAnchor
1321 1329 else:
1322 1330 anchormode = QtGui.QTextCursor.MoveAnchor
1323 1331
1324 1332 if key == QtCore.Qt.Key_Escape:
1325 1333 self._keyboard_quit()
1326 1334 intercepted = True
1327 1335
1328 1336 elif key == QtCore.Qt.Key_Up:
1329 1337 if self._reading or not self._up_pressed(shift_down):
1330 1338 intercepted = True
1331 1339 else:
1332 1340 prompt_line = self._get_prompt_cursor().blockNumber()
1333 1341 intercepted = cursor.blockNumber() <= prompt_line
1334 1342
1335 1343 elif key == QtCore.Qt.Key_Down:
1336 1344 if self._reading or not self._down_pressed(shift_down):
1337 1345 intercepted = True
1338 1346 else:
1339 1347 end_line = self._get_end_cursor().blockNumber()
1340 1348 intercepted = cursor.blockNumber() == end_line
1341 1349
1342 1350 elif key == QtCore.Qt.Key_Tab:
1343 1351 if not self._reading:
1344 1352 if self._tab_pressed():
1345 1353 # real tab-key, insert four spaces
1346 1354 cursor.insertText(' '*4)
1347 1355 intercepted = True
1348 1356
1349 1357 elif key == QtCore.Qt.Key_Left:
1350 1358
1351 1359 # Move to the previous line
1352 1360 line, col = cursor.blockNumber(), cursor.columnNumber()
1353 1361 if line > self._get_prompt_cursor().blockNumber() and \
1354 1362 col == len(self._continuation_prompt):
1355 1363 self._control.moveCursor(QtGui.QTextCursor.PreviousBlock,
1356 1364 mode=anchormode)
1357 1365 self._control.moveCursor(QtGui.QTextCursor.EndOfBlock,
1358 1366 mode=anchormode)
1359 1367 intercepted = True
1360 1368
1361 1369 # Regular left movement
1362 1370 else:
1363 1371 intercepted = not self._in_buffer(position - 1)
1364 1372
1365 1373 elif key == QtCore.Qt.Key_Right:
1366 1374 original_block_number = cursor.blockNumber()
1367 1375 self._control.moveCursor(QtGui.QTextCursor.Right,
1368 1376 mode=anchormode)
1369 1377 if cursor.blockNumber() != original_block_number:
1370 1378 self._control.moveCursor(QtGui.QTextCursor.Right,
1371 1379 n=len(self._continuation_prompt),
1372 1380 mode=anchormode)
1373 1381 intercepted = True
1374 1382
1375 1383 elif key == QtCore.Qt.Key_Home:
1376 1384 start_line = cursor.blockNumber()
1377 1385 if start_line == self._get_prompt_cursor().blockNumber():
1378 1386 start_pos = self._prompt_pos
1379 1387 else:
1380 1388 cursor.movePosition(QtGui.QTextCursor.StartOfBlock,
1381 1389 QtGui.QTextCursor.KeepAnchor)
1382 1390 start_pos = cursor.position()
1383 1391 start_pos += len(self._continuation_prompt)
1384 1392 cursor.setPosition(position)
1385 1393 if shift_down and self._in_buffer(position):
1386 1394 cursor.setPosition(start_pos, QtGui.QTextCursor.KeepAnchor)
1387 1395 else:
1388 1396 cursor.setPosition(start_pos)
1389 1397 self._set_cursor(cursor)
1390 1398 intercepted = True
1391 1399
1392 1400 elif key == QtCore.Qt.Key_Backspace:
1393 1401
1394 1402 # Line deletion (remove continuation prompt)
1395 1403 line, col = cursor.blockNumber(), cursor.columnNumber()
1396 1404 if not self._reading and \
1397 1405 col == len(self._continuation_prompt) and \
1398 1406 line > self._get_prompt_cursor().blockNumber():
1399 1407 cursor.beginEditBlock()
1400 1408 cursor.movePosition(QtGui.QTextCursor.StartOfBlock,
1401 1409 QtGui.QTextCursor.KeepAnchor)
1402 1410 cursor.removeSelectedText()
1403 1411 cursor.deletePreviousChar()
1404 1412 cursor.endEditBlock()
1405 1413 intercepted = True
1406 1414
1407 1415 # Regular backwards deletion
1408 1416 else:
1409 1417 anchor = cursor.anchor()
1410 1418 if anchor == position:
1411 1419 intercepted = not self._in_buffer(position - 1)
1412 1420 else:
1413 1421 intercepted = not self._in_buffer(min(anchor, position))
1414 1422
1415 1423 elif key == QtCore.Qt.Key_Delete:
1416 1424
1417 1425 # Line deletion (remove continuation prompt)
1418 1426 if not self._reading and self._in_buffer(position) and \
1419 1427 cursor.atBlockEnd() and not cursor.hasSelection():
1420 1428 cursor.movePosition(QtGui.QTextCursor.NextBlock,
1421 1429 QtGui.QTextCursor.KeepAnchor)
1422 1430 cursor.movePosition(QtGui.QTextCursor.Right,
1423 1431 QtGui.QTextCursor.KeepAnchor,
1424 1432 len(self._continuation_prompt))
1425 1433 cursor.removeSelectedText()
1426 1434 intercepted = True
1427 1435
1428 1436 # Regular forwards deletion:
1429 1437 else:
1430 1438 anchor = cursor.anchor()
1431 1439 intercepted = (not self._in_buffer(anchor) or
1432 1440 not self._in_buffer(position))
1433 1441
1434 1442 # Don't move the cursor if Control/Cmd is pressed to allow copy-paste
1435 1443 # using the keyboard in any part of the buffer. Also, permit scrolling
1436 1444 # with Page Up/Down keys. Finally, if we're executing, don't move the
1437 1445 # cursor (if even this made sense, we can't guarantee that the prompt
1438 1446 # position is still valid due to text truncation).
1439 1447 if not (self._control_key_down(event.modifiers(), include_command=True)
1440 1448 or key in (QtCore.Qt.Key_PageUp, QtCore.Qt.Key_PageDown)
1441 1449 or (self._executing and not self._reading)):
1442 1450 self._keep_cursor_in_buffer()
1443 1451
1444 1452 return intercepted
1445 1453
1446 1454 def _event_filter_page_keypress(self, event):
1447 1455 """ Filter key events for the paging widget to create console-like
1448 1456 interface.
1449 1457 """
1450 1458 key = event.key()
1451 1459 ctrl_down = self._control_key_down(event.modifiers())
1452 1460 alt_down = event.modifiers() & QtCore.Qt.AltModifier
1453 1461
1454 1462 if ctrl_down:
1455 1463 if key == QtCore.Qt.Key_O:
1456 1464 self._control.setFocus()
1457 1465 intercept = True
1458 1466
1459 1467 elif alt_down:
1460 1468 if key == QtCore.Qt.Key_Greater:
1461 1469 self._page_control.moveCursor(QtGui.QTextCursor.End)
1462 1470 intercepted = True
1463 1471
1464 1472 elif key == QtCore.Qt.Key_Less:
1465 1473 self._page_control.moveCursor(QtGui.QTextCursor.Start)
1466 1474 intercepted = True
1467 1475
1468 1476 elif key in (QtCore.Qt.Key_Q, QtCore.Qt.Key_Escape):
1469 1477 if self._splitter:
1470 1478 self._page_control.hide()
1471 1479 self._control.setFocus()
1472 1480 else:
1473 1481 self.layout().setCurrentWidget(self._control)
1474 1482 # re-enable buffer truncation after paging
1475 1483 self._control.document().setMaximumBlockCount(self.buffer_size)
1476 1484 return True
1477 1485
1478 1486 elif key in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return,
1479 1487 QtCore.Qt.Key_Tab):
1480 1488 new_event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress,
1481 1489 QtCore.Qt.Key_PageDown,
1482 1490 QtCore.Qt.NoModifier)
1483 1491 QtGui.qApp.sendEvent(self._page_control, new_event)
1484 1492 return True
1485 1493
1486 1494 elif key == QtCore.Qt.Key_Backspace:
1487 1495 new_event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress,
1488 1496 QtCore.Qt.Key_PageUp,
1489 1497 QtCore.Qt.NoModifier)
1490 1498 QtGui.qApp.sendEvent(self._page_control, new_event)
1491 1499 return True
1492 1500
1493 1501 # vi/less -like key bindings
1494 1502 elif key == QtCore.Qt.Key_J:
1495 1503 new_event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress,
1496 1504 QtCore.Qt.Key_Down,
1497 1505 QtCore.Qt.NoModifier)
1498 1506 QtGui.qApp.sendEvent(self._page_control, new_event)
1499 1507 return True
1500 1508
1501 1509 # vi/less -like key bindings
1502 1510 elif key == QtCore.Qt.Key_K:
1503 1511 new_event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress,
1504 1512 QtCore.Qt.Key_Up,
1505 1513 QtCore.Qt.NoModifier)
1506 1514 QtGui.qApp.sendEvent(self._page_control, new_event)
1507 1515 return True
1508 1516
1509 1517 return False
1510 1518
1511 1519 def _on_flush_pending_stream_timer(self):
1512 1520 """ Flush the pending stream output and change the
1513 1521 prompt position appropriately.
1514 1522 """
1515 1523 cursor = self._control.textCursor()
1516 1524 cursor.movePosition(QtGui.QTextCursor.End)
1517 1525 pos = cursor.position()
1518 1526 self._flush_pending_stream()
1519 1527 cursor.movePosition(QtGui.QTextCursor.End)
1520 1528 diff = cursor.position() - pos
1521 1529 if diff > 0:
1522 1530 self._prompt_pos += diff
1523 1531 self._append_before_prompt_pos += diff
1524 1532
1525 1533 def _flush_pending_stream(self):
1526 1534 """ Flush out pending text into the widget. """
1527 1535 text = self._pending_insert_text
1528 1536 self._pending_insert_text = []
1529 1537 buffer_size = self._control.document().maximumBlockCount()
1530 1538 if buffer_size > 0:
1531 1539 text = self._get_last_lines_from_list(text, buffer_size)
1532 1540 text = ''.join(text)
1533 1541 t = time.time()
1534 1542 self._insert_plain_text(self._get_end_cursor(), text, flush=True)
1535 1543 # Set the flush interval to equal the maximum time to update text.
1536 1544 self._pending_text_flush_interval.setInterval(max(100,
1537 1545 (time.time()-t)*1000))
1538 1546
1539 1547 def _format_as_columns(self, items, separator=' '):
1540 1548 """ Transform a list of strings into a single string with columns.
1541 1549
1542 1550 Parameters
1543 1551 ----------
1544 1552 items : sequence of strings
1545 1553 The strings to process.
1546 1554
1547 1555 separator : str, optional [default is two spaces]
1548 1556 The string that separates columns.
1549 1557
1550 1558 Returns
1551 1559 -------
1552 1560 The formatted string.
1553 1561 """
1554 1562 # Calculate the number of characters available.
1555 1563 width = self._control.viewport().width()
1556 1564 char_width = QtGui.QFontMetrics(self.font).width(' ')
1557 1565 displaywidth = max(10, (width / char_width) - 1)
1558 1566
1559 1567 return columnize(items, separator, displaywidth)
1560 1568
1561 1569 def _get_block_plain_text(self, block):
1562 1570 """ Given a QTextBlock, return its unformatted text.
1563 1571 """
1564 1572 cursor = QtGui.QTextCursor(block)
1565 1573 cursor.movePosition(QtGui.QTextCursor.StartOfBlock)
1566 1574 cursor.movePosition(QtGui.QTextCursor.EndOfBlock,
1567 1575 QtGui.QTextCursor.KeepAnchor)
1568 1576 return cursor.selection().toPlainText()
1569 1577
1570 1578 def _get_cursor(self):
1571 1579 """ Convenience method that returns a cursor for the current position.
1572 1580 """
1573 1581 return self._control.textCursor()
1574 1582
1575 1583 def _get_end_cursor(self):
1576 1584 """ Convenience method that returns a cursor for the last character.
1577 1585 """
1578 1586 cursor = self._control.textCursor()
1579 1587 cursor.movePosition(QtGui.QTextCursor.End)
1580 1588 return cursor
1581 1589
1582 1590 def _get_input_buffer_cursor_column(self):
1583 1591 """ Returns the column of the cursor in the input buffer, excluding the
1584 1592 contribution by the prompt, or -1 if there is no such column.
1585 1593 """
1586 1594 prompt = self._get_input_buffer_cursor_prompt()
1587 1595 if prompt is None:
1588 1596 return -1
1589 1597 else:
1590 1598 cursor = self._control.textCursor()
1591 1599 return cursor.columnNumber() - len(prompt)
1592 1600
1593 1601 def _get_input_buffer_cursor_line(self):
1594 1602 """ Returns the text of the line of the input buffer that contains the
1595 1603 cursor, or None if there is no such line.
1596 1604 """
1597 1605 prompt = self._get_input_buffer_cursor_prompt()
1598 1606 if prompt is None:
1599 1607 return None
1600 1608 else:
1601 1609 cursor = self._control.textCursor()
1602 1610 text = self._get_block_plain_text(cursor.block())
1603 1611 return text[len(prompt):]
1604 1612
1605 1613 def _get_input_buffer_cursor_pos(self):
1606 1614 """Return the cursor position within the input buffer."""
1607 1615 cursor = self._control.textCursor()
1608 1616 cursor.setPosition(self._prompt_pos, QtGui.QTextCursor.KeepAnchor)
1609 1617 input_buffer = cursor.selection().toPlainText()
1610 1618
1611 1619 # Don't count continuation prompts
1612 1620 return len(input_buffer.replace('\n' + self._continuation_prompt, '\n'))
1613 1621
1614 1622 def _get_input_buffer_cursor_prompt(self):
1615 1623 """ Returns the (plain text) prompt for line of the input buffer that
1616 1624 contains the cursor, or None if there is no such line.
1617 1625 """
1618 1626 if self._executing:
1619 1627 return None
1620 1628 cursor = self._control.textCursor()
1621 1629 if cursor.position() >= self._prompt_pos:
1622 1630 if cursor.blockNumber() == self._get_prompt_cursor().blockNumber():
1623 1631 return self._prompt
1624 1632 else:
1625 1633 return self._continuation_prompt
1626 1634 else:
1627 1635 return None
1628 1636
1629 1637 def _get_last_lines(self, text, num_lines, return_count=False):
1630 1638 """ Return last specified number of lines of text (like `tail -n`).
1631 1639 If return_count is True, returns a tuple of clipped text and the
1632 1640 number of lines in the clipped text.
1633 1641 """
1634 1642 pos = len(text)
1635 1643 if pos < num_lines:
1636 1644 if return_count:
1637 1645 return text, text.count('\n') if return_count else text
1638 1646 else:
1639 1647 return text
1640 1648 i = 0
1641 1649 while i < num_lines:
1642 1650 pos = text.rfind('\n', None, pos)
1643 1651 if pos == -1:
1644 1652 pos = None
1645 1653 break
1646 1654 i += 1
1647 1655 if return_count:
1648 1656 return text[pos:], i
1649 1657 else:
1650 1658 return text[pos:]
1651 1659
1652 1660 def _get_last_lines_from_list(self, text_list, num_lines):
1653 1661 """ Return the list of text clipped to last specified lines.
1654 1662 """
1655 1663 ret = []
1656 1664 lines_pending = num_lines
1657 1665 for text in reversed(text_list):
1658 1666 text, lines_added = self._get_last_lines(text, lines_pending,
1659 1667 return_count=True)
1660 1668 ret.append(text)
1661 1669 lines_pending -= lines_added
1662 1670 if lines_pending <= 0:
1663 1671 break
1664 1672 return ret[::-1]
1665 1673
1666 1674 def _get_prompt_cursor(self):
1667 1675 """ Convenience method that returns a cursor for the prompt position.
1668 1676 """
1669 1677 cursor = self._control.textCursor()
1670 1678 cursor.setPosition(self._prompt_pos)
1671 1679 return cursor
1672 1680
1673 1681 def _get_selection_cursor(self, start, end):
1674 1682 """ Convenience method that returns a cursor with text selected between
1675 1683 the positions 'start' and 'end'.
1676 1684 """
1677 1685 cursor = self._control.textCursor()
1678 1686 cursor.setPosition(start)
1679 1687 cursor.setPosition(end, QtGui.QTextCursor.KeepAnchor)
1680 1688 return cursor
1681 1689
1682 1690 def _get_word_start_cursor(self, position):
1683 1691 """ Find the start of the word to the left the given position. If a
1684 1692 sequence of non-word characters precedes the first word, skip over
1685 1693 them. (This emulates the behavior of bash, emacs, etc.)
1686 1694 """
1687 1695 document = self._control.document()
1688 1696 position -= 1
1689 1697 while position >= self._prompt_pos and \
1690 1698 not is_letter_or_number(document.characterAt(position)):
1691 1699 position -= 1
1692 1700 while position >= self._prompt_pos and \
1693 1701 is_letter_or_number(document.characterAt(position)):
1694 1702 position -= 1
1695 1703 cursor = self._control.textCursor()
1696 1704 cursor.setPosition(position + 1)
1697 1705 return cursor
1698 1706
1699 1707 def _get_word_end_cursor(self, position):
1700 1708 """ Find the end of the word to the right the given position. If a
1701 1709 sequence of non-word characters precedes the first word, skip over
1702 1710 them. (This emulates the behavior of bash, emacs, etc.)
1703 1711 """
1704 1712 document = self._control.document()
1705 1713 end = self._get_end_cursor().position()
1706 1714 while position < end and \
1707 1715 not is_letter_or_number(document.characterAt(position)):
1708 1716 position += 1
1709 1717 while position < end and \
1710 1718 is_letter_or_number(document.characterAt(position)):
1711 1719 position += 1
1712 1720 cursor = self._control.textCursor()
1713 1721 cursor.setPosition(position)
1714 1722 return cursor
1715 1723
1716 1724 def _insert_continuation_prompt(self, cursor):
1717 1725 """ Inserts new continuation prompt using the specified cursor.
1718 1726 """
1719 1727 if self._continuation_prompt_html is None:
1720 1728 self._insert_plain_text(cursor, self._continuation_prompt)
1721 1729 else:
1722 1730 self._continuation_prompt = self._insert_html_fetching_plain_text(
1723 1731 cursor, self._continuation_prompt_html)
1724 1732
1725 1733 def _insert_block(self, cursor, block_format=None):
1726 1734 """ Inserts an empty QTextBlock using the specified cursor.
1727 1735 """
1728 1736 if block_format is None:
1729 1737 block_format = QtGui.QTextBlockFormat()
1730 1738 cursor.insertBlock(block_format)
1731 1739
1732 1740 def _insert_html(self, cursor, html):
1733 1741 """ Inserts HTML using the specified cursor in such a way that future
1734 1742 formatting is unaffected.
1735 1743 """
1736 1744 cursor.beginEditBlock()
1737 1745 cursor.insertHtml(html)
1738 1746
1739 1747 # After inserting HTML, the text document "remembers" it's in "html
1740 1748 # mode", which means that subsequent calls adding plain text will result
1741 1749 # in unwanted formatting, lost tab characters, etc. The following code
1742 1750 # hacks around this behavior, which I consider to be a bug in Qt, by
1743 1751 # (crudely) resetting the document's style state.
1744 1752 cursor.movePosition(QtGui.QTextCursor.Left,
1745 1753 QtGui.QTextCursor.KeepAnchor)
1746 1754 if cursor.selection().toPlainText() == ' ':
1747 1755 cursor.removeSelectedText()
1748 1756 else:
1749 1757 cursor.movePosition(QtGui.QTextCursor.Right)
1750 1758 cursor.insertText(' ', QtGui.QTextCharFormat())
1751 1759 cursor.endEditBlock()
1752 1760
1753 1761 def _insert_html_fetching_plain_text(self, cursor, html):
1754 1762 """ Inserts HTML using the specified cursor, then returns its plain text
1755 1763 version.
1756 1764 """
1757 1765 cursor.beginEditBlock()
1758 1766 cursor.removeSelectedText()
1759 1767
1760 1768 start = cursor.position()
1761 1769 self._insert_html(cursor, html)
1762 1770 end = cursor.position()
1763 1771 cursor.setPosition(start, QtGui.QTextCursor.KeepAnchor)
1764 1772 text = cursor.selection().toPlainText()
1765 1773
1766 1774 cursor.setPosition(end)
1767 1775 cursor.endEditBlock()
1768 1776 return text
1769 1777
1770 1778 def _insert_plain_text(self, cursor, text, flush=False):
1771 1779 """ Inserts plain text using the specified cursor, processing ANSI codes
1772 1780 if enabled.
1773 1781 """
1774 1782 # maximumBlockCount() can be different from self.buffer_size in
1775 1783 # case input prompt is active.
1776 1784 buffer_size = self._control.document().maximumBlockCount()
1777 1785
1778 1786 if (self._executing and not flush and
1779 1787 self._pending_text_flush_interval.isActive() and
1780 1788 cursor.position() == self._get_end_cursor().position()):
1781 1789 # Queue the text to insert in case it is being inserted at end
1782 1790 self._pending_insert_text.append(text)
1783 1791 if buffer_size > 0:
1784 1792 self._pending_insert_text = self._get_last_lines_from_list(
1785 1793 self._pending_insert_text, buffer_size)
1786 1794 return
1787 1795
1788 1796 if self._executing and not self._pending_text_flush_interval.isActive():
1789 1797 self._pending_text_flush_interval.start()
1790 1798
1791 1799 # Clip the text to last `buffer_size` lines.
1792 1800 if buffer_size > 0:
1793 1801 text = self._get_last_lines(text, buffer_size)
1794 1802
1795 1803 cursor.beginEditBlock()
1796 1804 if self.ansi_codes:
1797 1805 for substring in self._ansi_processor.split_string(text):
1798 1806 for act in self._ansi_processor.actions:
1799 1807
1800 1808 # Unlike real terminal emulators, we don't distinguish
1801 1809 # between the screen and the scrollback buffer. A screen
1802 1810 # erase request clears everything.
1803 1811 if act.action == 'erase' and act.area == 'screen':
1804 1812 cursor.select(QtGui.QTextCursor.Document)
1805 1813 cursor.removeSelectedText()
1806 1814
1807 1815 # Simulate a form feed by scrolling just past the last line.
1808 1816 elif act.action == 'scroll' and act.unit == 'page':
1809 1817 cursor.insertText('\n')
1810 1818 cursor.endEditBlock()
1811 1819 self._set_top_cursor(cursor)
1812 1820 cursor.joinPreviousEditBlock()
1813 1821 cursor.deletePreviousChar()
1814 1822
1815 1823 elif act.action == 'carriage-return':
1816 1824 cursor.movePosition(
1817 1825 cursor.StartOfLine, cursor.KeepAnchor)
1818 1826
1819 1827 elif act.action == 'beep':
1820 1828 QtGui.qApp.beep()
1821 1829
1822 1830 elif act.action == 'backspace':
1823 1831 if not cursor.atBlockStart():
1824 1832 cursor.movePosition(
1825 1833 cursor.PreviousCharacter, cursor.KeepAnchor)
1826 1834
1827 1835 elif act.action == 'newline':
1828 1836 cursor.movePosition(cursor.EndOfLine)
1829 1837
1830 1838 format = self._ansi_processor.get_format()
1831 1839
1832 1840 selection = cursor.selectedText()
1833 1841 if len(selection) == 0:
1834 1842 cursor.insertText(substring, format)
1835 1843 elif substring is not None:
1836 1844 # BS and CR are treated as a change in print
1837 1845 # position, rather than a backwards character
1838 1846 # deletion for output equivalence with (I)Python
1839 1847 # terminal.
1840 1848 if len(substring) >= len(selection):
1841 1849 cursor.insertText(substring, format)
1842 1850 else:
1843 1851 old_text = selection[len(substring):]
1844 1852 cursor.insertText(substring + old_text, format)
1845 1853 cursor.movePosition(cursor.PreviousCharacter,
1846 1854 cursor.KeepAnchor, len(old_text))
1847 1855 else:
1848 1856 cursor.insertText(text)
1849 1857 cursor.endEditBlock()
1850 1858
1851 1859 def _insert_plain_text_into_buffer(self, cursor, text):
1852 1860 """ Inserts text into the input buffer using the specified cursor (which
1853 1861 must be in the input buffer), ensuring that continuation prompts are
1854 1862 inserted as necessary.
1855 1863 """
1856 1864 lines = text.splitlines(True)
1857 1865 if lines:
1858 1866 cursor.beginEditBlock()
1859 1867 cursor.insertText(lines[0])
1860 1868 for line in lines[1:]:
1861 1869 if self._continuation_prompt_html is None:
1862 1870 cursor.insertText(self._continuation_prompt)
1863 1871 else:
1864 1872 self._continuation_prompt = \
1865 1873 self._insert_html_fetching_plain_text(
1866 1874 cursor, self._continuation_prompt_html)
1867 1875 cursor.insertText(line)
1868 1876 cursor.endEditBlock()
1869 1877
1870 1878 def _in_buffer(self, position=None):
1871 1879 """ Returns whether the current cursor (or, if specified, a position) is
1872 1880 inside the editing region.
1873 1881 """
1874 1882 cursor = self._control.textCursor()
1875 1883 if position is None:
1876 1884 position = cursor.position()
1877 1885 else:
1878 1886 cursor.setPosition(position)
1879 1887 line = cursor.blockNumber()
1880 1888 prompt_line = self._get_prompt_cursor().blockNumber()
1881 1889 if line == prompt_line:
1882 1890 return position >= self._prompt_pos
1883 1891 elif line > prompt_line:
1884 1892 cursor.movePosition(QtGui.QTextCursor.StartOfBlock)
1885 1893 prompt_pos = cursor.position() + len(self._continuation_prompt)
1886 1894 return position >= prompt_pos
1887 1895 return False
1888 1896
1889 1897 def _keep_cursor_in_buffer(self):
1890 1898 """ Ensures that the cursor is inside the editing region. Returns
1891 1899 whether the cursor was moved.
1892 1900 """
1893 1901 moved = not self._in_buffer()
1894 1902 if moved:
1895 1903 cursor = self._control.textCursor()
1896 1904 cursor.movePosition(QtGui.QTextCursor.End)
1897 1905 self._control.setTextCursor(cursor)
1898 1906 return moved
1899 1907
1900 1908 def _keyboard_quit(self):
1901 1909 """ Cancels the current editing task ala Ctrl-G in Emacs.
1902 1910 """
1903 1911 if self._temp_buffer_filled :
1904 1912 self._cancel_completion()
1905 1913 self._clear_temporary_buffer()
1906 1914 else:
1907 1915 self.input_buffer = ''
1908 1916
1909 1917 def _page(self, text, html=False):
1910 1918 """ Displays text using the pager if it exceeds the height of the
1911 1919 viewport.
1912 1920
1913 1921 Parameters
1914 1922 ----------
1915 1923 html : bool, optional (default False)
1916 1924 If set, the text will be interpreted as HTML instead of plain text.
1917 1925 """
1918 1926 line_height = QtGui.QFontMetrics(self.font).height()
1919 1927 minlines = self._control.viewport().height() / line_height
1920 1928 if self.paging != 'none' and \
1921 1929 re.match("(?:[^\n]*\n){%i}" % minlines, text):
1922 1930 if self.paging == 'custom':
1923 1931 self.custom_page_requested.emit(text)
1924 1932 else:
1925 1933 # disable buffer truncation during paging
1926 1934 self._control.document().setMaximumBlockCount(0)
1927 1935 self._page_control.clear()
1928 1936 cursor = self._page_control.textCursor()
1929 1937 if html:
1930 1938 self._insert_html(cursor, text)
1931 1939 else:
1932 1940 self._insert_plain_text(cursor, text)
1933 1941 self._page_control.moveCursor(QtGui.QTextCursor.Start)
1934 1942
1935 1943 self._page_control.viewport().resize(self._control.size())
1936 1944 if self._splitter:
1937 1945 self._page_control.show()
1938 1946 self._page_control.setFocus()
1939 1947 else:
1940 1948 self.layout().setCurrentWidget(self._page_control)
1941 1949 elif html:
1942 1950 self._append_html(text)
1943 1951 else:
1944 1952 self._append_plain_text(text)
1945 1953
1946 1954 def _set_paging(self, paging):
1947 1955 """
1948 1956 Change the pager to `paging` style.
1949 1957
1950 1958 Parameters
1951 1959 ----------
1952 1960 paging : string
1953 1961 Either "hsplit", "vsplit", or "inside"
1954 1962 """
1955 1963 if self._splitter is None:
1956 1964 raise NotImplementedError("""can only switch if --paging=hsplit or
1957 1965 --paging=vsplit is used.""")
1958 1966 if paging == 'hsplit':
1959 1967 self._splitter.setOrientation(QtCore.Qt.Horizontal)
1960 1968 elif paging == 'vsplit':
1961 1969 self._splitter.setOrientation(QtCore.Qt.Vertical)
1962 1970 elif paging == 'inside':
1963 1971 raise NotImplementedError("""switching to 'inside' paging not
1964 1972 supported yet.""")
1965 1973 else:
1966 1974 raise ValueError("unknown paging method '%s'" % paging)
1967 1975 self.paging = paging
1968 1976
1969 1977 def _prompt_finished(self):
1970 1978 """ Called immediately after a prompt is finished, i.e. when some input
1971 1979 will be processed and a new prompt displayed.
1972 1980 """
1973 1981 self._control.setReadOnly(True)
1974 1982 self._prompt_finished_hook()
1975 1983
1976 1984 def _prompt_started(self):
1977 1985 """ Called immediately after a new prompt is displayed.
1978 1986 """
1979 1987 # Temporarily disable the maximum block count to permit undo/redo and
1980 1988 # to ensure that the prompt position does not change due to truncation.
1981 1989 self._control.document().setMaximumBlockCount(0)
1982 1990 self._control.setUndoRedoEnabled(True)
1983 1991
1984 1992 # Work around bug in QPlainTextEdit: input method is not re-enabled
1985 1993 # when read-only is disabled.
1986 1994 self._control.setReadOnly(False)
1987 1995 self._control.setAttribute(QtCore.Qt.WA_InputMethodEnabled, True)
1988 1996
1989 1997 if not self._reading:
1990 1998 self._executing = False
1991 1999 self._prompt_started_hook()
1992 2000
1993 2001 # If the input buffer has changed while executing, load it.
1994 2002 if self._input_buffer_pending:
1995 2003 self.input_buffer = self._input_buffer_pending
1996 2004 self._input_buffer_pending = ''
1997 2005
1998 2006 self._control.moveCursor(QtGui.QTextCursor.End)
1999 2007
2000 2008 def _readline(self, prompt='', callback=None):
2001 2009 """ Reads one line of input from the user.
2002 2010
2003 2011 Parameters
2004 2012 ----------
2005 2013 prompt : str, optional
2006 2014 The prompt to print before reading the line.
2007 2015
2008 2016 callback : callable, optional
2009 2017 A callback to execute with the read line. If not specified, input is
2010 2018 read *synchronously* and this method does not return until it has
2011 2019 been read.
2012 2020
2013 2021 Returns
2014 2022 -------
2015 2023 If a callback is specified, returns nothing. Otherwise, returns the
2016 2024 input string with the trailing newline stripped.
2017 2025 """
2018 2026 if self._reading:
2019 2027 raise RuntimeError('Cannot read a line. Widget is already reading.')
2020 2028
2021 2029 if not callback and not self.isVisible():
2022 2030 # If the user cannot see the widget, this function cannot return.
2023 2031 raise RuntimeError('Cannot synchronously read a line if the widget '
2024 2032 'is not visible!')
2025 2033
2026 2034 self._reading = True
2027 2035 self._show_prompt(prompt, newline=False)
2028 2036
2029 2037 if callback is None:
2030 2038 self._reading_callback = None
2031 2039 while self._reading:
2032 2040 QtCore.QCoreApplication.processEvents()
2033 2041 return self._get_input_buffer(force=True).rstrip('\n')
2034 2042
2035 2043 else:
2036 2044 self._reading_callback = lambda: \
2037 2045 callback(self._get_input_buffer(force=True).rstrip('\n'))
2038 2046
2039 2047 def _set_continuation_prompt(self, prompt, html=False):
2040 2048 """ Sets the continuation prompt.
2041 2049
2042 2050 Parameters
2043 2051 ----------
2044 2052 prompt : str
2045 2053 The prompt to show when more input is needed.
2046 2054
2047 2055 html : bool, optional (default False)
2048 2056 If set, the prompt will be inserted as formatted HTML. Otherwise,
2049 2057 the prompt will be treated as plain text, though ANSI color codes
2050 2058 will be handled.
2051 2059 """
2052 2060 if html:
2053 2061 self._continuation_prompt_html = prompt
2054 2062 else:
2055 2063 self._continuation_prompt = prompt
2056 2064 self._continuation_prompt_html = None
2057 2065
2058 2066 def _set_cursor(self, cursor):
2059 2067 """ Convenience method to set the current cursor.
2060 2068 """
2061 2069 self._control.setTextCursor(cursor)
2062 2070
2063 2071 def _set_top_cursor(self, cursor):
2064 2072 """ Scrolls the viewport so that the specified cursor is at the top.
2065 2073 """
2066 2074 scrollbar = self._control.verticalScrollBar()
2067 2075 scrollbar.setValue(scrollbar.maximum())
2068 2076 original_cursor = self._control.textCursor()
2069 2077 self._control.setTextCursor(cursor)
2070 2078 self._control.ensureCursorVisible()
2071 2079 self._control.setTextCursor(original_cursor)
2072 2080
2073 2081 def _show_prompt(self, prompt=None, html=False, newline=True):
2074 2082 """ Writes a new prompt at the end of the buffer.
2075 2083
2076 2084 Parameters
2077 2085 ----------
2078 2086 prompt : str, optional
2079 2087 The prompt to show. If not specified, the previous prompt is used.
2080 2088
2081 2089 html : bool, optional (default False)
2082 2090 Only relevant when a prompt is specified. If set, the prompt will
2083 2091 be inserted as formatted HTML. Otherwise, the prompt will be treated
2084 2092 as plain text, though ANSI color codes will be handled.
2085 2093
2086 2094 newline : bool, optional (default True)
2087 2095 If set, a new line will be written before showing the prompt if
2088 2096 there is not already a newline at the end of the buffer.
2089 2097 """
2090 2098 # Save the current end position to support _append*(before_prompt=True).
2091 2099 self._flush_pending_stream()
2092 2100 cursor = self._get_end_cursor()
2093 2101 self._append_before_prompt_pos = cursor.position()
2094 2102
2095 2103 # Insert a preliminary newline, if necessary.
2096 2104 if newline and cursor.position() > 0:
2097 2105 cursor.movePosition(QtGui.QTextCursor.Left,
2098 2106 QtGui.QTextCursor.KeepAnchor)
2099 2107 if cursor.selection().toPlainText() != '\n':
2100 2108 self._append_block()
2101 2109 self._append_before_prompt_pos += 1
2102 2110
2103 2111 # Write the prompt.
2104 2112 self._append_plain_text(self._prompt_sep)
2105 2113 if prompt is None:
2106 2114 if self._prompt_html is None:
2107 2115 self._append_plain_text(self._prompt)
2108 2116 else:
2109 2117 self._append_html(self._prompt_html)
2110 2118 else:
2111 2119 if html:
2112 2120 self._prompt = self._append_html_fetching_plain_text(prompt)
2113 2121 self._prompt_html = prompt
2114 2122 else:
2115 2123 self._append_plain_text(prompt)
2116 2124 self._prompt = prompt
2117 2125 self._prompt_html = None
2118 2126
2119 2127 self._flush_pending_stream()
2120 2128 self._prompt_pos = self._get_end_cursor().position()
2121 2129 self._prompt_started()
2122 2130
2123 2131 #------ Signal handlers ----------------------------------------------------
2124 2132
2125 2133 def _adjust_scrollbars(self):
2126 2134 """ Expands the vertical scrollbar beyond the range set by Qt.
2127 2135 """
2128 2136 # This code is adapted from _q_adjustScrollbars in qplaintextedit.cpp
2129 2137 # and qtextedit.cpp.
2130 2138 document = self._control.document()
2131 2139 scrollbar = self._control.verticalScrollBar()
2132 2140 viewport_height = self._control.viewport().height()
2133 2141 if isinstance(self._control, QtGui.QPlainTextEdit):
2134 2142 maximum = max(0, document.lineCount() - 1)
2135 2143 step = viewport_height / self._control.fontMetrics().lineSpacing()
2136 2144 else:
2137 2145 # QTextEdit does not do line-based layout and blocks will not in
2138 2146 # general have the same height. Therefore it does not make sense to
2139 2147 # attempt to scroll in line height increments.
2140 2148 maximum = document.size().height()
2141 2149 step = viewport_height
2142 2150 diff = maximum - scrollbar.maximum()
2143 2151 scrollbar.setRange(0, maximum)
2144 2152 scrollbar.setPageStep(step)
2145 2153
2146 2154 # Compensate for undesirable scrolling that occurs automatically due to
2147 2155 # maximumBlockCount() text truncation.
2148 2156 if diff < 0 and document.blockCount() == document.maximumBlockCount():
2149 2157 scrollbar.setValue(scrollbar.value() + diff)
2150 2158
2151 2159 def _custom_context_menu_requested(self, pos):
2152 2160 """ Shows a context menu at the given QPoint (in widget coordinates).
2153 2161 """
2154 2162 menu = self._context_menu_make(pos)
2155 2163 menu.exec_(self._control.mapToGlobal(pos))
@@ -1,824 +1,824 b''
1 1 """Frontend widget for the Qt Console"""
2 2
3 3 # Copyright (c) IPython Development Team.
4 4 # Distributed under the terms of the Modified BSD License.
5 5
6 6 from __future__ import print_function
7 7
8 8 from collections import namedtuple
9 9 import sys
10 10 import uuid
11 11
12 12 from IPython.external import qt
13 13 from IPython.external.qt import QtCore, QtGui
14 14 from IPython.utils import py3compat
15 15 from IPython.utils.importstring import import_item
16 16
17 17 from IPython.core.inputsplitter import InputSplitter, IPythonInputSplitter
18 18 from IPython.core.inputtransformer import classic_prompt
19 19 from IPython.core.oinspect import call_tip
20 20 from IPython.qt.base_frontend_mixin import BaseFrontendMixin
21 21 from IPython.utils.traitlets import Any, Bool, Instance, Unicode, DottedObjectName
22 22 from .bracket_matcher import BracketMatcher
23 23 from .call_tip_widget import CallTipWidget
24 24 from .completion_lexer import CompletionLexer
25 25 from .history_console_widget import HistoryConsoleWidget
26 26 from .pygments_highlighter import PygmentsHighlighter
27 27
28 28
29 29 class FrontendHighlighter(PygmentsHighlighter):
30 30 """ A PygmentsHighlighter that understands and ignores prompts.
31 31 """
32 32
33 33 def __init__(self, frontend, lexer=None):
34 34 super(FrontendHighlighter, self).__init__(frontend._control.document(), lexer=lexer)
35 35 self._current_offset = 0
36 36 self._frontend = frontend
37 37 self.highlighting_on = False
38 38
39 39 def highlightBlock(self, string):
40 40 """ Highlight a block of text. Reimplemented to highlight selectively.
41 41 """
42 42 if not self.highlighting_on:
43 43 return
44 44
45 45 # The input to this function is a unicode string that may contain
46 46 # paragraph break characters, non-breaking spaces, etc. Here we acquire
47 47 # the string as plain text so we can compare it.
48 48 current_block = self.currentBlock()
49 49 string = self._frontend._get_block_plain_text(current_block)
50 50
51 51 # Decide whether to check for the regular or continuation prompt.
52 52 if current_block.contains(self._frontend._prompt_pos):
53 53 prompt = self._frontend._prompt
54 54 else:
55 55 prompt = self._frontend._continuation_prompt
56 56
57 57 # Only highlight if we can identify a prompt, but make sure not to
58 58 # highlight the prompt.
59 59 if string.startswith(prompt):
60 60 self._current_offset = len(prompt)
61 61 string = string[len(prompt):]
62 62 super(FrontendHighlighter, self).highlightBlock(string)
63 63
64 64 def rehighlightBlock(self, block):
65 65 """ Reimplemented to temporarily enable highlighting if disabled.
66 66 """
67 67 old = self.highlighting_on
68 68 self.highlighting_on = True
69 69 super(FrontendHighlighter, self).rehighlightBlock(block)
70 70 self.highlighting_on = old
71 71
72 72 def setFormat(self, start, count, format):
73 73 """ Reimplemented to highlight selectively.
74 74 """
75 75 start += self._current_offset
76 76 super(FrontendHighlighter, self).setFormat(start, count, format)
77 77
78 78
79 79 class FrontendWidget(HistoryConsoleWidget, BaseFrontendMixin):
80 80 """ A Qt frontend for a generic Python kernel.
81 81 """
82 82
83 83 # The text to show when the kernel is (re)started.
84 84 banner = Unicode(config=True)
85 85 kernel_banner = Unicode()
86 86
87 87 # An option and corresponding signal for overriding the default kernel
88 88 # interrupt behavior.
89 89 custom_interrupt = Bool(False)
90 90 custom_interrupt_requested = QtCore.Signal()
91 91
92 92 # An option and corresponding signals for overriding the default kernel
93 93 # restart behavior.
94 94 custom_restart = Bool(False)
95 95 custom_restart_kernel_died = QtCore.Signal(float)
96 96 custom_restart_requested = QtCore.Signal()
97 97
98 98 # Whether to automatically show calltips on open-parentheses.
99 99 enable_calltips = Bool(True, config=True,
100 100 help="Whether to draw information calltips on open-parentheses.")
101 101
102 102 clear_on_kernel_restart = Bool(True, config=True,
103 103 help="Whether to clear the console when the kernel is restarted")
104 104
105 105 confirm_restart = Bool(True, config=True,
106 106 help="Whether to ask for user confirmation when restarting kernel")
107 107
108 108 lexer_class = DottedObjectName(config=True,
109 109 help="The pygments lexer class to use."
110 110 )
111 111 def _lexer_class_changed(self, name, old, new):
112 112 lexer_class = import_item(new)
113 113 self.lexer = lexer_class()
114 114
115 115 def _lexer_class_default(self):
116 116 if py3compat.PY3:
117 117 return 'pygments.lexers.Python3Lexer'
118 118 else:
119 119 return 'pygments.lexers.PythonLexer'
120 120
121 121 lexer = Any()
122 122 def _lexer_default(self):
123 123 lexer_class = import_item(self.lexer_class)
124 124 return lexer_class()
125 125
126 126 # Emitted when a user visible 'execute_request' has been submitted to the
127 127 # kernel from the FrontendWidget. Contains the code to be executed.
128 128 executing = QtCore.Signal(object)
129 129
130 130 # Emitted when a user-visible 'execute_reply' has been received from the
131 131 # kernel and processed by the FrontendWidget. Contains the response message.
132 132 executed = QtCore.Signal(object)
133 133
134 134 # Emitted when an exit request has been received from the kernel.
135 135 exit_requested = QtCore.Signal(object)
136 136
137 137 # Protected class variables.
138 138 _prompt_transformer = IPythonInputSplitter(physical_line_transforms=[classic_prompt()],
139 139 logical_line_transforms=[],
140 140 python_line_transforms=[],
141 141 )
142 142 _CallTipRequest = namedtuple('_CallTipRequest', ['id', 'pos'])
143 143 _CompletionRequest = namedtuple('_CompletionRequest', ['id', 'pos'])
144 144 _ExecutionRequest = namedtuple('_ExecutionRequest', ['id', 'kind'])
145 145 _input_splitter_class = InputSplitter
146 146 _local_kernel = False
147 147 _highlighter = Instance(FrontendHighlighter)
148 148
149 149 #---------------------------------------------------------------------------
150 150 # 'object' interface
151 151 #---------------------------------------------------------------------------
152 152
153 153 def __init__(self, *args, **kw):
154 154 super(FrontendWidget, self).__init__(*args, **kw)
155 155 # FIXME: remove this when PySide min version is updated past 1.0.7
156 156 # forcefully disable calltips if PySide is < 1.0.7, because they crash
157 157 if qt.QT_API == qt.QT_API_PYSIDE:
158 158 import PySide
159 159 if PySide.__version_info__ < (1,0,7):
160 160 self.log.warn("PySide %s < 1.0.7 detected, disabling calltips" % PySide.__version__)
161 161 self.enable_calltips = False
162 162
163 163 # FrontendWidget protected variables.
164 164 self._bracket_matcher = BracketMatcher(self._control)
165 165 self._call_tip_widget = CallTipWidget(self._control)
166 166 self._completion_lexer = CompletionLexer(self.lexer)
167 167 self._copy_raw_action = QtGui.QAction('Copy (Raw Text)', None)
168 168 self._hidden = False
169 169 self._highlighter = FrontendHighlighter(self, lexer=self.lexer)
170 170 self._input_splitter = self._input_splitter_class()
171 171 self._kernel_manager = None
172 172 self._kernel_client = None
173 173 self._request_info = {}
174 174 self._request_info['execute'] = {};
175 175 self._callback_dict = {}
176 176
177 177 # Configure the ConsoleWidget.
178 178 self.tab_width = 4
179 179 self._set_continuation_prompt('... ')
180 180
181 181 # Configure the CallTipWidget.
182 182 self._call_tip_widget.setFont(self.font)
183 183 self.font_changed.connect(self._call_tip_widget.setFont)
184 184
185 185 # Configure actions.
186 186 action = self._copy_raw_action
187 187 key = QtCore.Qt.CTRL | QtCore.Qt.SHIFT | QtCore.Qt.Key_C
188 188 action.setEnabled(False)
189 189 action.setShortcut(QtGui.QKeySequence(key))
190 190 action.setShortcutContext(QtCore.Qt.WidgetWithChildrenShortcut)
191 191 action.triggered.connect(self.copy_raw)
192 192 self.copy_available.connect(action.setEnabled)
193 193 self.addAction(action)
194 194
195 195 # Connect signal handlers.
196 196 document = self._control.document()
197 197 document.contentsChange.connect(self._document_contents_change)
198 198
199 199 # Set flag for whether we are connected via localhost.
200 200 self._local_kernel = kw.get('local_kernel',
201 201 FrontendWidget._local_kernel)
202 202
203 203 # Whether or not a clear_output call is pending new output.
204 204 self._pending_clearoutput = False
205 205
206 206 #---------------------------------------------------------------------------
207 207 # 'ConsoleWidget' public interface
208 208 #---------------------------------------------------------------------------
209 209
210 210 def copy(self):
211 211 """ Copy the currently selected text to the clipboard, removing prompts.
212 212 """
213 213 if self._page_control is not None and self._page_control.hasFocus():
214 214 self._page_control.copy()
215 215 elif self._control.hasFocus():
216 216 text = self._control.textCursor().selection().toPlainText()
217 217 if text:
218 218 was_newline = text[-1] == '\n'
219 219 text = self._prompt_transformer.transform_cell(text)
220 220 if not was_newline: # user doesn't need newline
221 221 text = text[:-1]
222 222 QtGui.QApplication.clipboard().setText(text)
223 223 else:
224 224 self.log.debug("frontend widget : unknown copy target")
225 225
226 226 #---------------------------------------------------------------------------
227 227 # 'ConsoleWidget' abstract interface
228 228 #---------------------------------------------------------------------------
229 229
230 230 def _is_complete(self, source, interactive):
231 231 """ Returns whether 'source' can be completely processed and a new
232 232 prompt created. When triggered by an Enter/Return key press,
233 233 'interactive' is True; otherwise, it is False.
234 234 """
235 235 self._input_splitter.reset()
236 236 try:
237 237 complete = self._input_splitter.push(source)
238 238 except SyntaxError:
239 239 return True
240 240 if interactive:
241 241 complete = not self._input_splitter.push_accepts_more()
242 242 return complete
243 243
244 244 def _execute(self, source, hidden):
245 245 """ Execute 'source'. If 'hidden', do not show any output.
246 246
247 247 See parent class :meth:`execute` docstring for full details.
248 248 """
249 249 msg_id = self.kernel_client.execute(source, hidden)
250 250 self._request_info['execute'][msg_id] = self._ExecutionRequest(msg_id, 'user')
251 251 self._hidden = hidden
252 252 if not hidden:
253 253 self.executing.emit(source)
254 254
255 255 def _prompt_started_hook(self):
256 256 """ Called immediately after a new prompt is displayed.
257 257 """
258 258 if not self._reading:
259 259 self._highlighter.highlighting_on = True
260 260
261 261 def _prompt_finished_hook(self):
262 262 """ Called immediately after a prompt is finished, i.e. when some input
263 263 will be processed and a new prompt displayed.
264 264 """
265 265 # Flush all state from the input splitter so the next round of
266 266 # reading input starts with a clean buffer.
267 267 self._input_splitter.reset()
268 268
269 269 if not self._reading:
270 270 self._highlighter.highlighting_on = False
271 271
272 272 def _tab_pressed(self):
273 273 """ Called when the tab key is pressed. Returns whether to continue
274 274 processing the event.
275 275 """
276 276 # Perform tab completion if:
277 277 # 1) The cursor is in the input buffer.
278 278 # 2) There is a non-whitespace character before the cursor.
279 279 text = self._get_input_buffer_cursor_line()
280 280 if text is None:
281 281 return False
282 282 complete = bool(text[:self._get_input_buffer_cursor_column()].strip())
283 283 if complete:
284 284 self._complete()
285 285 return not complete
286 286
287 287 #---------------------------------------------------------------------------
288 288 # 'ConsoleWidget' protected interface
289 289 #---------------------------------------------------------------------------
290 290
291 291 def _context_menu_make(self, pos):
292 292 """ Reimplemented to add an action for raw copy.
293 293 """
294 294 menu = super(FrontendWidget, self)._context_menu_make(pos)
295 295 for before_action in menu.actions():
296 296 if before_action.shortcut().matches(QtGui.QKeySequence.Paste) == \
297 297 QtGui.QKeySequence.ExactMatch:
298 298 menu.insertAction(before_action, self._copy_raw_action)
299 299 break
300 300 return menu
301 301
302 302 def request_interrupt_kernel(self):
303 303 if self._executing:
304 304 self.interrupt_kernel()
305 305
306 306 def request_restart_kernel(self):
307 307 message = 'Are you sure you want to restart the kernel?'
308 308 self.restart_kernel(message, now=False)
309 309
310 310 def _event_filter_console_keypress(self, event):
311 311 """ Reimplemented for execution interruption and smart backspace.
312 312 """
313 313 key = event.key()
314 314 if self._control_key_down(event.modifiers(), include_command=False):
315 315
316 316 if key == QtCore.Qt.Key_C and self._executing:
317 317 self.request_interrupt_kernel()
318 318 return True
319 319
320 320 elif key == QtCore.Qt.Key_Period:
321 321 self.request_restart_kernel()
322 322 return True
323 323
324 324 elif not event.modifiers() & QtCore.Qt.AltModifier:
325 325
326 326 # Smart backspace: remove four characters in one backspace if:
327 327 # 1) everything left of the cursor is whitespace
328 328 # 2) the four characters immediately left of the cursor are spaces
329 329 if key == QtCore.Qt.Key_Backspace:
330 330 col = self._get_input_buffer_cursor_column()
331 331 cursor = self._control.textCursor()
332 332 if col > 3 and not cursor.hasSelection():
333 333 text = self._get_input_buffer_cursor_line()[:col]
334 334 if text.endswith(' ') and not text.strip():
335 335 cursor.movePosition(QtGui.QTextCursor.Left,
336 336 QtGui.QTextCursor.KeepAnchor, 4)
337 337 cursor.removeSelectedText()
338 338 return True
339 339
340 340 return super(FrontendWidget, self)._event_filter_console_keypress(event)
341 341
342 342 def _insert_continuation_prompt(self, cursor):
343 343 """ Reimplemented for auto-indentation.
344 344 """
345 345 super(FrontendWidget, self)._insert_continuation_prompt(cursor)
346 346 cursor.insertText(' ' * self._input_splitter.indent_spaces)
347 347
348 348 #---------------------------------------------------------------------------
349 349 # 'BaseFrontendMixin' abstract interface
350 350 #---------------------------------------------------------------------------
351 351 def _handle_clear_output(self, msg):
352 352 """Handle clear output messages."""
353 if not self._hidden and self._is_from_this_session(msg):
353 if include_output(msg):
354 354 wait = msg['content'].get('wait', True)
355 355 if wait:
356 356 self._pending_clearoutput = True
357 357 else:
358 358 self.clear_output()
359 359
360 360 def _handle_complete_reply(self, rep):
361 361 """ Handle replies for tab completion.
362 362 """
363 363 self.log.debug("complete: %s", rep.get('content', ''))
364 364 cursor = self._get_cursor()
365 365 info = self._request_info.get('complete')
366 366 if info and info.id == rep['parent_header']['msg_id'] and \
367 367 info.pos == cursor.position():
368 368 text = '.'.join(self._get_context())
369 369 cursor.movePosition(QtGui.QTextCursor.Left, n=len(text))
370 370 self._complete_with_items(cursor, rep['content']['matches'])
371 371
372 372 def _silent_exec_callback(self, expr, callback):
373 373 """Silently execute `expr` in the kernel and call `callback` with reply
374 374
375 375 the `expr` is evaluated silently in the kernel (without) output in
376 376 the frontend. Call `callback` with the
377 377 `repr <http://docs.python.org/library/functions.html#repr> `_ as first argument
378 378
379 379 Parameters
380 380 ----------
381 381 expr : string
382 382 valid string to be executed by the kernel.
383 383 callback : function
384 384 function accepting one argument, as a string. The string will be
385 385 the `repr` of the result of evaluating `expr`
386 386
387 387 The `callback` is called with the `repr()` of the result of `expr` as
388 388 first argument. To get the object, do `eval()` on the passed value.
389 389
390 390 See Also
391 391 --------
392 392 _handle_exec_callback : private method, deal with calling callback with reply
393 393
394 394 """
395 395
396 396 # generate uuid, which would be used as an indication of whether or
397 397 # not the unique request originated from here (can use msg id ?)
398 398 local_uuid = str(uuid.uuid1())
399 399 msg_id = self.kernel_client.execute('',
400 400 silent=True, user_expressions={ local_uuid:expr })
401 401 self._callback_dict[local_uuid] = callback
402 402 self._request_info['execute'][msg_id] = self._ExecutionRequest(msg_id, 'silent_exec_callback')
403 403
404 404 def _handle_exec_callback(self, msg):
405 405 """Execute `callback` corresponding to `msg` reply, after ``_silent_exec_callback``
406 406
407 407 Parameters
408 408 ----------
409 409 msg : raw message send by the kernel containing an `user_expressions`
410 410 and having a 'silent_exec_callback' kind.
411 411
412 412 Notes
413 413 -----
414 414 This function will look for a `callback` associated with the
415 415 corresponding message id. Association has been made by
416 416 `_silent_exec_callback`. `callback` is then called with the `repr()`
417 417 of the value of corresponding `user_expressions` as argument.
418 418 `callback` is then removed from the known list so that any message
419 419 coming again with the same id won't trigger it.
420 420
421 421 """
422 422
423 423 user_exp = msg['content'].get('user_expressions')
424 424 if not user_exp:
425 425 return
426 426 for expression in user_exp:
427 427 if expression in self._callback_dict:
428 428 self._callback_dict.pop(expression)(user_exp[expression])
429 429
430 430 def _handle_execute_reply(self, msg):
431 431 """ Handles replies for code execution.
432 432 """
433 433 self.log.debug("execute: %s", msg.get('content', ''))
434 434 msg_id = msg['parent_header']['msg_id']
435 435 info = self._request_info['execute'].get(msg_id)
436 436 # unset reading flag, because if execute finished, raw_input can't
437 437 # still be pending.
438 438 self._reading = False
439 439 if info and info.kind == 'user' and not self._hidden:
440 440 # Make sure that all output from the SUB channel has been processed
441 441 # before writing a new prompt.
442 442 self.kernel_client.iopub_channel.flush()
443 443
444 444 # Reset the ANSI style information to prevent bad text in stdout
445 445 # from messing up our colors. We're not a true terminal so we're
446 446 # allowed to do this.
447 447 if self.ansi_codes:
448 448 self._ansi_processor.reset_sgr()
449 449
450 450 content = msg['content']
451 451 status = content['status']
452 452 if status == 'ok':
453 453 self._process_execute_ok(msg)
454 454 elif status == 'error':
455 455 self._process_execute_error(msg)
456 456 elif status == 'aborted':
457 457 self._process_execute_abort(msg)
458 458
459 459 self._show_interpreter_prompt_for_reply(msg)
460 460 self.executed.emit(msg)
461 461 self._request_info['execute'].pop(msg_id)
462 462 elif info and info.kind == 'silent_exec_callback' and not self._hidden:
463 463 self._handle_exec_callback(msg)
464 464 self._request_info['execute'].pop(msg_id)
465 465 else:
466 466 super(FrontendWidget, self)._handle_execute_reply(msg)
467 467
468 468 def _handle_input_request(self, msg):
469 469 """ Handle requests for raw_input.
470 470 """
471 471 self.log.debug("input: %s", msg.get('content', ''))
472 472 if self._hidden:
473 473 raise RuntimeError('Request for raw input during hidden execution.')
474 474
475 475 # Make sure that all output from the SUB channel has been processed
476 476 # before entering readline mode.
477 477 self.kernel_client.iopub_channel.flush()
478 478
479 479 def callback(line):
480 480 self.kernel_client.stdin_channel.input(line)
481 481 if self._reading:
482 482 self.log.debug("Got second input request, assuming first was interrupted.")
483 483 self._reading = False
484 484 self._readline(msg['content']['prompt'], callback=callback)
485 485
486 486 def _kernel_restarted_message(self, died=True):
487 487 msg = "Kernel died, restarting" if died else "Kernel restarting"
488 488 self._append_html("<br>%s<hr><br>" % msg,
489 489 before_prompt=False
490 490 )
491 491
492 492 def _handle_kernel_died(self, since_last_heartbeat):
493 493 """Handle the kernel's death (if we do not own the kernel).
494 494 """
495 495 self.log.warn("kernel died: %s", since_last_heartbeat)
496 496 if self.custom_restart:
497 497 self.custom_restart_kernel_died.emit(since_last_heartbeat)
498 498 else:
499 499 self._kernel_restarted_message(died=True)
500 500 self.reset()
501 501
502 502 def _handle_kernel_restarted(self, died=True):
503 503 """Notice that the autorestarter restarted the kernel.
504 504
505 505 There's nothing to do but show a message.
506 506 """
507 507 self.log.warn("kernel restarted")
508 508 self._kernel_restarted_message(died=died)
509 509 self.reset()
510 510
511 511 def _handle_inspect_reply(self, rep):
512 512 """Handle replies for call tips."""
513 513 self.log.debug("oinfo: %s", rep.get('content', ''))
514 514 cursor = self._get_cursor()
515 515 info = self._request_info.get('call_tip')
516 516 if info and info.id == rep['parent_header']['msg_id'] and \
517 517 info.pos == cursor.position():
518 518 content = rep['content']
519 519 if content.get('status') == 'ok':
520 520 self._call_tip_widget.show_inspect_data(content)
521 521
522 522 def _handle_execute_result(self, msg):
523 523 """ Handle display hook output.
524 524 """
525 525 self.log.debug("execute_result: %s", msg.get('content', ''))
526 if not self._hidden and self._is_from_this_session(msg):
526 if self.include_output(msg):
527 527 self.flush_clearoutput()
528 528 text = msg['content']['data']
529 529 self._append_plain_text(text + '\n', before_prompt=True)
530 530
531 531 def _handle_stream(self, msg):
532 532 """ Handle stdout, stderr, and stdin.
533 533 """
534 534 self.log.debug("stream: %s", msg.get('content', ''))
535 if not self._hidden and self._is_from_this_session(msg):
535 if self.include_output(msg):
536 536 self.flush_clearoutput()
537 537 self.append_stream(msg['content']['text'])
538 538
539 539 def _handle_shutdown_reply(self, msg):
540 540 """ Handle shutdown signal, only if from other console.
541 541 """
542 542 self.log.info("shutdown: %s", msg.get('content', ''))
543 543 restart = msg.get('content', {}).get('restart', False)
544 if not self._hidden and not self._is_from_this_session(msg):
544 if not self._hidden and not self.from_here(msg):
545 545 # got shutdown reply, request came from session other than ours
546 546 if restart:
547 547 # someone restarted the kernel, handle it
548 548 self._handle_kernel_restarted(died=False)
549 549 else:
550 550 # kernel was shutdown permanently
551 551 # this triggers exit_requested if the kernel was local,
552 552 # and a dialog if the kernel was remote,
553 553 # so we don't suddenly clear the qtconsole without asking.
554 554 if self._local_kernel:
555 555 self.exit_requested.emit(self)
556 556 else:
557 557 title = self.window().windowTitle()
558 558 reply = QtGui.QMessageBox.question(self, title,
559 559 "Kernel has been shutdown permanently. "
560 560 "Close the Console?",
561 561 QtGui.QMessageBox.Yes,QtGui.QMessageBox.No)
562 562 if reply == QtGui.QMessageBox.Yes:
563 563 self.exit_requested.emit(self)
564 564
565 565 def _handle_status(self, msg):
566 566 """Handle status message"""
567 567 # This is where a busy/idle indicator would be triggered,
568 568 # when we make one.
569 569 state = msg['content'].get('execution_state', '')
570 570 if state == 'starting':
571 571 # kernel started while we were running
572 572 if self._executing:
573 573 self._handle_kernel_restarted(died=True)
574 574 elif state == 'idle':
575 575 pass
576 576 elif state == 'busy':
577 577 pass
578 578
579 579 def _started_channels(self):
580 580 """ Called when the KernelManager channels have started listening or
581 581 when the frontend is assigned an already listening KernelManager.
582 582 """
583 583 self.reset(clear=True)
584 584
585 585 #---------------------------------------------------------------------------
586 586 # 'FrontendWidget' public interface
587 587 #---------------------------------------------------------------------------
588 588
589 589 def copy_raw(self):
590 590 """ Copy the currently selected text to the clipboard without attempting
591 591 to remove prompts or otherwise alter the text.
592 592 """
593 593 self._control.copy()
594 594
595 595 def execute_file(self, path, hidden=False):
596 596 """ Attempts to execute file with 'path'. If 'hidden', no output is
597 597 shown.
598 598 """
599 599 self.execute('execfile(%r)' % path, hidden=hidden)
600 600
601 601 def interrupt_kernel(self):
602 602 """ Attempts to interrupt the running kernel.
603 603
604 604 Also unsets _reading flag, to avoid runtime errors
605 605 if raw_input is called again.
606 606 """
607 607 if self.custom_interrupt:
608 608 self._reading = False
609 609 self.custom_interrupt_requested.emit()
610 610 elif self.kernel_manager:
611 611 self._reading = False
612 612 self.kernel_manager.interrupt_kernel()
613 613 else:
614 614 self._append_plain_text('Cannot interrupt a kernel I did not start.\n')
615 615
616 616 def reset(self, clear=False):
617 617 """ Resets the widget to its initial state if ``clear`` parameter
618 618 is True, otherwise
619 619 prints a visual indication of the fact that the kernel restarted, but
620 620 does not clear the traces from previous usage of the kernel before it
621 621 was restarted. With ``clear=True``, it is similar to ``%clear``, but
622 622 also re-writes the banner and aborts execution if necessary.
623 623 """
624 624 if self._executing:
625 625 self._executing = False
626 626 self._request_info['execute'] = {}
627 627 self._reading = False
628 628 self._highlighter.highlighting_on = False
629 629
630 630 if clear:
631 631 self._control.clear()
632 632 self._append_plain_text(self.banner)
633 633 if self.kernel_banner:
634 634 self._append_plain_text(self.kernel_banner)
635 635
636 636 # update output marker for stdout/stderr, so that startup
637 637 # messages appear after banner:
638 638 self._append_before_prompt_pos = self._get_cursor().position()
639 639 self._show_interpreter_prompt()
640 640
641 641 def restart_kernel(self, message, now=False):
642 642 """ Attempts to restart the running kernel.
643 643 """
644 644 # FIXME: now should be configurable via a checkbox in the dialog. Right
645 645 # now at least the heartbeat path sets it to True and the manual restart
646 646 # to False. But those should just be the pre-selected states of a
647 647 # checkbox that the user could override if so desired. But I don't know
648 648 # enough Qt to go implementing the checkbox now.
649 649
650 650 if self.custom_restart:
651 651 self.custom_restart_requested.emit()
652 652 return
653 653
654 654 if self.kernel_manager:
655 655 # Pause the heart beat channel to prevent further warnings.
656 656 self.kernel_client.hb_channel.pause()
657 657
658 658 # Prompt the user to restart the kernel. Un-pause the heartbeat if
659 659 # they decline. (If they accept, the heartbeat will be un-paused
660 660 # automatically when the kernel is restarted.)
661 661 if self.confirm_restart:
662 662 buttons = QtGui.QMessageBox.Yes | QtGui.QMessageBox.No
663 663 result = QtGui.QMessageBox.question(self, 'Restart kernel?',
664 664 message, buttons)
665 665 do_restart = result == QtGui.QMessageBox.Yes
666 666 else:
667 667 # confirm_restart is False, so we don't need to ask user
668 668 # anything, just do the restart
669 669 do_restart = True
670 670 if do_restart:
671 671 try:
672 672 self.kernel_manager.restart_kernel(now=now)
673 673 except RuntimeError as e:
674 674 self._append_plain_text(
675 675 'Error restarting kernel: %s\n' % e,
676 676 before_prompt=True
677 677 )
678 678 else:
679 679 self._append_html("<br>Restarting kernel...\n<hr><br>",
680 680 before_prompt=True,
681 681 )
682 682 else:
683 683 self.kernel_client.hb_channel.unpause()
684 684
685 685 else:
686 686 self._append_plain_text(
687 687 'Cannot restart a Kernel I did not start\n',
688 688 before_prompt=True
689 689 )
690 690
691 691 def append_stream(self, text):
692 692 """Appends text to the output stream."""
693 693 # Most consoles treat tabs as being 8 space characters. Convert tabs
694 694 # to spaces so that output looks as expected regardless of this
695 695 # widget's tab width.
696 696 text = text.expandtabs(8)
697 697 self._append_plain_text(text, before_prompt=True)
698 698 self._control.moveCursor(QtGui.QTextCursor.End)
699 699
700 700 def flush_clearoutput(self):
701 701 """If a clearoutput is pending, execute it."""
702 702 if self._pending_clearoutput:
703 703 self._pending_clearoutput = False
704 704 self.clear_output()
705 705
706 706 def clear_output(self):
707 707 """Clears the current line of output."""
708 708 cursor = self._control.textCursor()
709 709 cursor.beginEditBlock()
710 710 cursor.movePosition(cursor.StartOfLine, cursor.KeepAnchor)
711 711 cursor.insertText('')
712 712 cursor.endEditBlock()
713 713
714 714 #---------------------------------------------------------------------------
715 715 # 'FrontendWidget' protected interface
716 716 #---------------------------------------------------------------------------
717 717
718 718 def _call_tip(self):
719 719 """ Shows a call tip, if appropriate, at the current cursor location.
720 720 """
721 721 # Decide if it makes sense to show a call tip
722 722 if not self.enable_calltips or not self.kernel_client.shell_channel.is_alive():
723 723 return False
724 724 cursor_pos = self._get_input_buffer_cursor_pos()
725 725 code = self.input_buffer
726 726 # Send the metadata request to the kernel
727 727 msg_id = self.kernel_client.inspect(code, cursor_pos)
728 728 pos = self._get_cursor().position()
729 729 self._request_info['call_tip'] = self._CallTipRequest(msg_id, pos)
730 730 return True
731 731
732 732 def _complete(self):
733 733 """ Performs completion at the current cursor location.
734 734 """
735 735 context = self._get_context()
736 736 if context:
737 737 # Send the completion request to the kernel
738 738 msg_id = self.kernel_client.complete(
739 739 code=self.input_buffer,
740 740 cursor_pos=self._get_input_buffer_cursor_pos(),
741 741 )
742 742 pos = self._get_cursor().position()
743 743 info = self._CompletionRequest(msg_id, pos)
744 744 self._request_info['complete'] = info
745 745
746 746 def _get_context(self, cursor=None):
747 747 """ Gets the context for the specified cursor (or the current cursor
748 748 if none is specified).
749 749 """
750 750 if cursor is None:
751 751 cursor = self._get_cursor()
752 752 cursor.movePosition(QtGui.QTextCursor.StartOfBlock,
753 753 QtGui.QTextCursor.KeepAnchor)
754 754 text = cursor.selection().toPlainText()
755 755 return self._completion_lexer.get_context(text)
756 756
757 757 def _process_execute_abort(self, msg):
758 758 """ Process a reply for an aborted execution request.
759 759 """
760 760 self._append_plain_text("ERROR: execution aborted\n")
761 761
762 762 def _process_execute_error(self, msg):
763 763 """ Process a reply for an execution request that resulted in an error.
764 764 """
765 765 content = msg['content']
766 766 # If a SystemExit is passed along, this means exit() was called - also
767 767 # all the ipython %exit magic syntax of '-k' to be used to keep
768 768 # the kernel running
769 769 if content['ename']=='SystemExit':
770 770 keepkernel = content['evalue']=='-k' or content['evalue']=='True'
771 771 self._keep_kernel_on_exit = keepkernel
772 772 self.exit_requested.emit(self)
773 773 else:
774 774 traceback = ''.join(content['traceback'])
775 775 self._append_plain_text(traceback)
776 776
777 777 def _process_execute_ok(self, msg):
778 778 """ Process a reply for a successful execution request.
779 779 """
780 780 payload = msg['content']['payload']
781 781 for item in payload:
782 782 if not self._process_execute_payload(item):
783 783 warning = 'Warning: received unknown payload of type %s'
784 784 print(warning % repr(item['source']))
785 785
786 786 def _process_execute_payload(self, item):
787 787 """ Process a single payload item from the list of payload items in an
788 788 execution reply. Returns whether the payload was handled.
789 789 """
790 790 # The basic FrontendWidget doesn't handle payloads, as they are a
791 791 # mechanism for going beyond the standard Python interpreter model.
792 792 return False
793 793
794 794 def _show_interpreter_prompt(self):
795 795 """ Shows a prompt for the interpreter.
796 796 """
797 797 self._show_prompt('>>> ')
798 798
799 799 def _show_interpreter_prompt_for_reply(self, msg):
800 800 """ Shows a prompt for the interpreter given an 'execute_reply' message.
801 801 """
802 802 self._show_interpreter_prompt()
803 803
804 804 #------ Signal handlers ----------------------------------------------------
805 805
806 806 def _document_contents_change(self, position, removed, added):
807 807 """ Called whenever the document's content changes. Display a call tip
808 808 if appropriate.
809 809 """
810 810 # Calculate where the cursor should be *after* the change:
811 811 position += added
812 812
813 813 document = self._control.document()
814 814 if position == self._get_cursor().position():
815 815 self._call_tip()
816 816
817 817 #------ Trait default initializers -----------------------------------------
818 818
819 819 def _banner_default(self):
820 820 """ Returns the standard Python banner.
821 821 """
822 822 banner = 'Python %s on %s\nType "help", "copyright", "credits" or ' \
823 823 '"license" for more information.'
824 824 return banner % (sys.version, sys.platform)
@@ -1,581 +1,591 b''
1 1 """A FrontendWidget that emulates the interface of the console IPython.
2 2
3 3 This supports the additional functionality provided by the IPython kernel.
4 4 """
5 5
6 6 # Copyright (c) IPython Development Team.
7 7 # Distributed under the terms of the Modified BSD License.
8 8
9 9 from collections import namedtuple
10 10 import os.path
11 11 import re
12 12 from subprocess import Popen
13 13 import sys
14 14 import time
15 15 from textwrap import dedent
16 16
17 17 from IPython.external.qt import QtCore, QtGui
18 18
19 19 from IPython.core.inputsplitter import IPythonInputSplitter
20 20 from IPython.core.release import version
21 21 from IPython.core.inputtransformer import ipy_prompt
22 22 from IPython.utils.traitlets import Bool, Unicode
23 23 from .frontend_widget import FrontendWidget
24 24 from . import styles
25 25
26 26 #-----------------------------------------------------------------------------
27 27 # Constants
28 28 #-----------------------------------------------------------------------------
29 29
30 30 # Default strings to build and display input and output prompts (and separators
31 31 # in between)
32 32 default_in_prompt = 'In [<span class="in-prompt-number">%i</span>]: '
33 33 default_out_prompt = 'Out[<span class="out-prompt-number">%i</span>]: '
34 34 default_input_sep = '\n'
35 35 default_output_sep = ''
36 36 default_output_sep2 = ''
37 37
38 38 # Base path for most payload sources.
39 39 zmq_shell_source = 'IPython.kernel.zmq.zmqshell.ZMQInteractiveShell'
40 40
41 41 if sys.platform.startswith('win'):
42 42 default_editor = 'notepad'
43 43 else:
44 44 default_editor = ''
45 45
46 46 #-----------------------------------------------------------------------------
47 47 # IPythonWidget class
48 48 #-----------------------------------------------------------------------------
49 49
50 50 class IPythonWidget(FrontendWidget):
51 51 """ A FrontendWidget for an IPython kernel.
52 52 """
53 53
54 54 # If set, the 'custom_edit_requested(str, int)' signal will be emitted when
55 55 # an editor is needed for a file. This overrides 'editor' and 'editor_line'
56 56 # settings.
57 57 custom_edit = Bool(False)
58 58 custom_edit_requested = QtCore.Signal(object, object)
59 59
60 60 editor = Unicode(default_editor, config=True,
61 61 help="""
62 62 A command for invoking a system text editor. If the string contains a
63 63 {filename} format specifier, it will be used. Otherwise, the filename
64 64 will be appended to the end the command.
65 65 """)
66 66
67 67 editor_line = Unicode(config=True,
68 68 help="""
69 69 The editor command to use when a specific line number is requested. The
70 70 string should contain two format specifiers: {line} and {filename}. If
71 71 this parameter is not specified, the line number option to the %edit
72 72 magic will be ignored.
73 73 """)
74 74
75 75 style_sheet = Unicode(config=True,
76 76 help="""
77 77 A CSS stylesheet. The stylesheet can contain classes for:
78 78 1. Qt: QPlainTextEdit, QFrame, QWidget, etc
79 79 2. Pygments: .c, .k, .o, etc. (see PygmentsHighlighter)
80 80 3. IPython: .error, .in-prompt, .out-prompt, etc
81 81 """)
82 82
83 83 syntax_style = Unicode(config=True,
84 84 help="""
85 85 If not empty, use this Pygments style for syntax highlighting.
86 86 Otherwise, the style sheet is queried for Pygments style
87 87 information.
88 88 """)
89 89
90 90 # Prompts.
91 91 in_prompt = Unicode(default_in_prompt, config=True)
92 92 out_prompt = Unicode(default_out_prompt, config=True)
93 93 input_sep = Unicode(default_input_sep, config=True)
94 94 output_sep = Unicode(default_output_sep, config=True)
95 95 output_sep2 = Unicode(default_output_sep2, config=True)
96 96
97 97 # FrontendWidget protected class variables.
98 98 _input_splitter_class = IPythonInputSplitter
99 99 _prompt_transformer = IPythonInputSplitter(physical_line_transforms=[ipy_prompt()],
100 100 logical_line_transforms=[],
101 101 python_line_transforms=[],
102 102 )
103 103
104 104 # IPythonWidget protected class variables.
105 105 _PromptBlock = namedtuple('_PromptBlock', ['block', 'length', 'number'])
106 106 _payload_source_edit = 'edit_magic'
107 107 _payload_source_exit = 'ask_exit'
108 108 _payload_source_next_input = 'set_next_input'
109 109 _payload_source_page = 'page'
110 110 _retrying_history_request = False
111 111 _starting = False
112 112
113 113 #---------------------------------------------------------------------------
114 114 # 'object' interface
115 115 #---------------------------------------------------------------------------
116 116
117 117 def __init__(self, *args, **kw):
118 118 super(IPythonWidget, self).__init__(*args, **kw)
119 119
120 120 # IPythonWidget protected variables.
121 121 self._payload_handlers = {
122 122 self._payload_source_edit : self._handle_payload_edit,
123 123 self._payload_source_exit : self._handle_payload_exit,
124 124 self._payload_source_page : self._handle_payload_page,
125 125 self._payload_source_next_input : self._handle_payload_next_input }
126 126 self._previous_prompt_obj = None
127 127 self._keep_kernel_on_exit = None
128 128
129 129 # Initialize widget styling.
130 130 if self.style_sheet:
131 131 self._style_sheet_changed()
132 132 self._syntax_style_changed()
133 133 else:
134 134 self.set_default_style()
135 135
136 136 self._guiref_loaded = False
137 137
138 138 #---------------------------------------------------------------------------
139 139 # 'BaseFrontendMixin' abstract interface
140 140 #---------------------------------------------------------------------------
141 141 def _handle_complete_reply(self, rep):
142 142 """ Reimplemented to support IPython's improved completion machinery.
143 143 """
144 144 self.log.debug("complete: %s", rep.get('content', ''))
145 145 cursor = self._get_cursor()
146 146 info = self._request_info.get('complete')
147 147 if info and info.id == rep['parent_header']['msg_id'] and \
148 148 info.pos == cursor.position():
149 149 content = rep['content']
150 150 matches = content['matches']
151 151 start = content['cursor_start']
152 152 end = content['cursor_end']
153 153
154 154 start = max(start, 0)
155 155 end = max(end, start + 1)
156 156
157 157 # Move the control's cursor to the desired end point
158 158 cursor_pos = self._get_input_buffer_cursor_pos()
159 159 if end < cursor_pos:
160 160 cursor.movePosition(QtGui.QTextCursor.Left,
161 161 n=(cursor_pos - end))
162 162 elif end > cursor_pos:
163 163 cursor.movePosition(QtGui.QTextCursor.Right,
164 164 n=(end - cursor_pos))
165 165 # This line actually applies the move to control's cursor
166 166 self._control.setTextCursor(cursor)
167 167
168 168 offset = end - start
169 169 # Move the local cursor object to the start of the match and
170 170 # complete.
171 171 cursor.movePosition(QtGui.QTextCursor.Left, n=offset)
172 172 self._complete_with_items(cursor, matches)
173 173
174 174 def _handle_execute_reply(self, msg):
175 175 """ Reimplemented to support prompt requests.
176 176 """
177 177 msg_id = msg['parent_header'].get('msg_id')
178 178 info = self._request_info['execute'].get(msg_id)
179 179 if info and info.kind == 'prompt':
180 180 content = msg['content']
181 181 if content['status'] == 'aborted':
182 182 self._show_interpreter_prompt()
183 183 else:
184 184 number = content['execution_count'] + 1
185 185 self._show_interpreter_prompt(number)
186 186 self._request_info['execute'].pop(msg_id)
187 187 else:
188 188 super(IPythonWidget, self)._handle_execute_reply(msg)
189 189
190 190 def _handle_history_reply(self, msg):
191 191 """ Implemented to handle history tail replies, which are only supported
192 192 by the IPython kernel.
193 193 """
194 194 content = msg['content']
195 195 if 'history' not in content:
196 196 self.log.error("History request failed: %r"%content)
197 197 if content.get('status', '') == 'aborted' and \
198 198 not self._retrying_history_request:
199 199 # a *different* action caused this request to be aborted, so
200 200 # we should try again.
201 201 self.log.error("Retrying aborted history request")
202 202 # prevent multiple retries of aborted requests:
203 203 self._retrying_history_request = True
204 204 # wait out the kernel's queue flush, which is currently timed at 0.1s
205 205 time.sleep(0.25)
206 206 self.kernel_client.shell_channel.history(hist_access_type='tail',n=1000)
207 207 else:
208 208 self._retrying_history_request = False
209 209 return
210 210 # reset retry flag
211 211 self._retrying_history_request = False
212 212 history_items = content['history']
213 213 self.log.debug("Received history reply with %i entries", len(history_items))
214 214 items = []
215 215 last_cell = u""
216 216 for _, _, cell in history_items:
217 217 cell = cell.rstrip()
218 218 if cell != last_cell:
219 219 items.append(cell)
220 220 last_cell = cell
221 221 self._set_history(items)
222 222
223 def _handle_execute_input(self, msg):
224 """Handle an execute_input message"""
225 self.log.debug("execute_input: %s", msg.get('content', ''))
226 if self.include_output(msg):
227 content = msg['content']
228 prompt_number = content.get('execution_count', 0)
229 self._append_html(self._make_in_prompt(prompt_number), True)
230 self._append_plain_text(content['code'], True)
231
232
223 233 def _handle_execute_result(self, msg):
224 234 """ Reimplemented for IPython-style "display hook".
225 235 """
226 236 self.log.debug("execute_result: %s", msg.get('content', ''))
227 if not self._hidden and self._is_from_this_session(msg):
237 if self.include_output(msg):
228 238 self.flush_clearoutput()
229 239 content = msg['content']
230 240 prompt_number = content.get('execution_count', 0)
231 241 data = content['data']
232 242 if 'text/plain' in data:
233 243 self._append_plain_text(self.output_sep, True)
234 244 self._append_html(self._make_out_prompt(prompt_number), True)
235 245 text = data['text/plain']
236 246 # If the repr is multiline, make sure we start on a new line,
237 247 # so that its lines are aligned.
238 248 if "\n" in text and not self.output_sep.endswith("\n"):
239 249 self._append_plain_text('\n', True)
240 250 self._append_plain_text(text + self.output_sep2, True)
241 251
242 252 def _handle_display_data(self, msg):
243 253 """ The base handler for the ``display_data`` message.
244 254 """
245 255 self.log.debug("display: %s", msg.get('content', ''))
246 256 # For now, we don't display data from other frontends, but we
247 257 # eventually will as this allows all frontends to monitor the display
248 258 # data. But we need to figure out how to handle this in the GUI.
249 if not self._hidden and self._is_from_this_session(msg):
259 if self.include_output(msg):
250 260 self.flush_clearoutput()
251 261 data = msg['content']['data']
252 262 metadata = msg['content']['metadata']
253 263 # In the regular IPythonWidget, we simply print the plain text
254 264 # representation.
255 265 if 'text/plain' in data:
256 266 text = data['text/plain']
257 267 self._append_plain_text(text, True)
258 268 # This newline seems to be needed for text and html output.
259 269 self._append_plain_text(u'\n', True)
260 270
261 271 def _handle_kernel_info_reply(self, rep):
262 272 """Handle kernel info replies."""
263 273 content = rep['content']
264 274 if not self._guiref_loaded:
265 275 if content.get('language') == 'python':
266 276 self._load_guiref_magic()
267 277 self._guiref_loaded = True
268 278
269 279 self.kernel_banner = content.get('banner', '')
270 280 if self._starting:
271 281 # finish handling started channels
272 282 self._starting = False
273 283 super(IPythonWidget, self)._started_channels()
274 284
275 285 def _started_channels(self):
276 286 """Reimplemented to make a history request and load %guiref."""
277 287 self._starting = True
278 288 # The reply will trigger %guiref load provided language=='python'
279 289 self.kernel_client.kernel_info()
280 290
281 291 self.kernel_client.shell_channel.history(hist_access_type='tail',
282 292 n=1000)
283 293
284 294 def _load_guiref_magic(self):
285 295 """Load %guiref magic."""
286 296 self.kernel_client.shell_channel.execute('\n'.join([
287 297 "try:",
288 298 " _usage",
289 299 "except:",
290 300 " from IPython.core import usage as _usage",
291 301 " get_ipython().register_magic_function(_usage.page_guiref, 'line', 'guiref')",
292 302 " del _usage",
293 303 ]), silent=True)
294 304
295 305 #---------------------------------------------------------------------------
296 306 # 'ConsoleWidget' public interface
297 307 #---------------------------------------------------------------------------
298 308
299 309 #---------------------------------------------------------------------------
300 310 # 'FrontendWidget' public interface
301 311 #---------------------------------------------------------------------------
302 312
303 313 def execute_file(self, path, hidden=False):
304 314 """ Reimplemented to use the 'run' magic.
305 315 """
306 316 # Use forward slashes on Windows to avoid escaping each separator.
307 317 if sys.platform == 'win32':
308 318 path = os.path.normpath(path).replace('\\', '/')
309 319
310 320 # Perhaps we should not be using %run directly, but while we
311 321 # are, it is necessary to quote or escape filenames containing spaces
312 322 # or quotes.
313 323
314 324 # In earlier code here, to minimize escaping, we sometimes quoted the
315 325 # filename with single quotes. But to do this, this code must be
316 326 # platform-aware, because run uses shlex rather than python string
317 327 # parsing, so that:
318 328 # * In Win: single quotes can be used in the filename without quoting,
319 329 # and we cannot use single quotes to quote the filename.
320 330 # * In *nix: we can escape double quotes in a double quoted filename,
321 331 # but can't escape single quotes in a single quoted filename.
322 332
323 333 # So to keep this code non-platform-specific and simple, we now only
324 334 # use double quotes to quote filenames, and escape when needed:
325 335 if ' ' in path or "'" in path or '"' in path:
326 336 path = '"%s"' % path.replace('"', '\\"')
327 337 self.execute('%%run %s' % path, hidden=hidden)
328 338
329 339 #---------------------------------------------------------------------------
330 340 # 'FrontendWidget' protected interface
331 341 #---------------------------------------------------------------------------
332 342
333 343 def _process_execute_error(self, msg):
334 344 """ Reimplemented for IPython-style traceback formatting.
335 345 """
336 346 content = msg['content']
337 347 traceback = '\n'.join(content['traceback']) + '\n'
338 348 if False:
339 349 # FIXME: For now, tracebacks come as plain text, so we can't use
340 350 # the html renderer yet. Once we refactor ultratb to produce
341 351 # properly styled tracebacks, this branch should be the default
342 352 traceback = traceback.replace(' ', '&nbsp;')
343 353 traceback = traceback.replace('\n', '<br/>')
344 354
345 355 ename = content['ename']
346 356 ename_styled = '<span class="error">%s</span>' % ename
347 357 traceback = traceback.replace(ename, ename_styled)
348 358
349 359 self._append_html(traceback)
350 360 else:
351 361 # This is the fallback for now, using plain text with ansi escapes
352 362 self._append_plain_text(traceback)
353 363
354 364 def _process_execute_payload(self, item):
355 365 """ Reimplemented to dispatch payloads to handler methods.
356 366 """
357 367 handler = self._payload_handlers.get(item['source'])
358 368 if handler is None:
359 369 # We have no handler for this type of payload, simply ignore it
360 370 return False
361 371 else:
362 372 handler(item)
363 373 return True
364 374
365 375 def _show_interpreter_prompt(self, number=None):
366 376 """ Reimplemented for IPython-style prompts.
367 377 """
368 378 # If a number was not specified, make a prompt number request.
369 379 if number is None:
370 380 msg_id = self.kernel_client.shell_channel.execute('', silent=True)
371 381 info = self._ExecutionRequest(msg_id, 'prompt')
372 382 self._request_info['execute'][msg_id] = info
373 383 return
374 384
375 385 # Show a new prompt and save information about it so that it can be
376 386 # updated later if the prompt number turns out to be wrong.
377 387 self._prompt_sep = self.input_sep
378 388 self._show_prompt(self._make_in_prompt(number), html=True)
379 389 block = self._control.document().lastBlock()
380 390 length = len(self._prompt)
381 391 self._previous_prompt_obj = self._PromptBlock(block, length, number)
382 392
383 393 # Update continuation prompt to reflect (possibly) new prompt length.
384 394 self._set_continuation_prompt(
385 395 self._make_continuation_prompt(self._prompt), html=True)
386 396
387 397 def _show_interpreter_prompt_for_reply(self, msg):
388 398 """ Reimplemented for IPython-style prompts.
389 399 """
390 400 # Update the old prompt number if necessary.
391 401 content = msg['content']
392 402 # abort replies do not have any keys:
393 403 if content['status'] == 'aborted':
394 404 if self._previous_prompt_obj:
395 405 previous_prompt_number = self._previous_prompt_obj.number
396 406 else:
397 407 previous_prompt_number = 0
398 408 else:
399 409 previous_prompt_number = content['execution_count']
400 410 if self._previous_prompt_obj and \
401 411 self._previous_prompt_obj.number != previous_prompt_number:
402 412 block = self._previous_prompt_obj.block
403 413
404 414 # Make sure the prompt block has not been erased.
405 415 if block.isValid() and block.text():
406 416
407 417 # Remove the old prompt and insert a new prompt.
408 418 cursor = QtGui.QTextCursor(block)
409 419 cursor.movePosition(QtGui.QTextCursor.Right,
410 420 QtGui.QTextCursor.KeepAnchor,
411 421 self._previous_prompt_obj.length)
412 422 prompt = self._make_in_prompt(previous_prompt_number)
413 423 self._prompt = self._insert_html_fetching_plain_text(
414 424 cursor, prompt)
415 425
416 426 # When the HTML is inserted, Qt blows away the syntax
417 427 # highlighting for the line, so we need to rehighlight it.
418 428 self._highlighter.rehighlightBlock(cursor.block())
419 429
420 430 self._previous_prompt_obj = None
421 431
422 432 # Show a new prompt with the kernel's estimated prompt number.
423 433 self._show_interpreter_prompt(previous_prompt_number + 1)
424 434
425 435 #---------------------------------------------------------------------------
426 436 # 'IPythonWidget' interface
427 437 #---------------------------------------------------------------------------
428 438
429 439 def set_default_style(self, colors='lightbg'):
430 440 """ Sets the widget style to the class defaults.
431 441
432 442 Parameters
433 443 ----------
434 444 colors : str, optional (default lightbg)
435 445 Whether to use the default IPython light background or dark
436 446 background or B&W style.
437 447 """
438 448 colors = colors.lower()
439 449 if colors=='lightbg':
440 450 self.style_sheet = styles.default_light_style_sheet
441 451 self.syntax_style = styles.default_light_syntax_style
442 452 elif colors=='linux':
443 453 self.style_sheet = styles.default_dark_style_sheet
444 454 self.syntax_style = styles.default_dark_syntax_style
445 455 elif colors=='nocolor':
446 456 self.style_sheet = styles.default_bw_style_sheet
447 457 self.syntax_style = styles.default_bw_syntax_style
448 458 else:
449 459 raise KeyError("No such color scheme: %s"%colors)
450 460
451 461 #---------------------------------------------------------------------------
452 462 # 'IPythonWidget' protected interface
453 463 #---------------------------------------------------------------------------
454 464
455 465 def _edit(self, filename, line=None):
456 466 """ Opens a Python script for editing.
457 467
458 468 Parameters
459 469 ----------
460 470 filename : str
461 471 A path to a local system file.
462 472
463 473 line : int, optional
464 474 A line of interest in the file.
465 475 """
466 476 if self.custom_edit:
467 477 self.custom_edit_requested.emit(filename, line)
468 478 elif not self.editor:
469 479 self._append_plain_text('No default editor available.\n'
470 480 'Specify a GUI text editor in the `IPythonWidget.editor` '
471 481 'configurable to enable the %edit magic')
472 482 else:
473 483 try:
474 484 filename = '"%s"' % filename
475 485 if line and self.editor_line:
476 486 command = self.editor_line.format(filename=filename,
477 487 line=line)
478 488 else:
479 489 try:
480 490 command = self.editor.format()
481 491 except KeyError:
482 492 command = self.editor.format(filename=filename)
483 493 else:
484 494 command += ' ' + filename
485 495 except KeyError:
486 496 self._append_plain_text('Invalid editor command.\n')
487 497 else:
488 498 try:
489 499 Popen(command, shell=True)
490 500 except OSError:
491 501 msg = 'Opening editor with command "%s" failed.\n'
492 502 self._append_plain_text(msg % command)
493 503
494 504 def _make_in_prompt(self, number):
495 505 """ Given a prompt number, returns an HTML In prompt.
496 506 """
497 507 try:
498 508 body = self.in_prompt % number
499 509 except TypeError:
500 510 # allow in_prompt to leave out number, e.g. '>>> '
501 511 from xml.sax.saxutils import escape
502 512 body = escape(self.in_prompt)
503 513 return '<span class="in-prompt">%s</span>' % body
504 514
505 515 def _make_continuation_prompt(self, prompt):
506 516 """ Given a plain text version of an In prompt, returns an HTML
507 517 continuation prompt.
508 518 """
509 519 end_chars = '...: '
510 520 space_count = len(prompt.lstrip('\n')) - len(end_chars)
511 521 body = '&nbsp;' * space_count + end_chars
512 522 return '<span class="in-prompt">%s</span>' % body
513 523
514 524 def _make_out_prompt(self, number):
515 525 """ Given a prompt number, returns an HTML Out prompt.
516 526 """
517 527 try:
518 528 body = self.out_prompt % number
519 529 except TypeError:
520 530 # allow out_prompt to leave out number, e.g. '<<< '
521 531 from xml.sax.saxutils import escape
522 532 body = escape(self.out_prompt)
523 533 return '<span class="out-prompt">%s</span>' % body
524 534
525 535 #------ Payload handlers --------------------------------------------------
526 536
527 537 # Payload handlers with a generic interface: each takes the opaque payload
528 538 # dict, unpacks it and calls the underlying functions with the necessary
529 539 # arguments.
530 540
531 541 def _handle_payload_edit(self, item):
532 542 self._edit(item['filename'], item['line_number'])
533 543
534 544 def _handle_payload_exit(self, item):
535 545 self._keep_kernel_on_exit = item['keepkernel']
536 546 self.exit_requested.emit(self)
537 547
538 548 def _handle_payload_next_input(self, item):
539 549 self.input_buffer = item['text']
540 550
541 551 def _handle_payload_page(self, item):
542 552 # Since the plain text widget supports only a very small subset of HTML
543 553 # and we have no control over the HTML source, we only page HTML
544 554 # payloads in the rich text widget.
545 555 data = item['data']
546 556 if 'text/html' in data and self.kind == 'rich':
547 557 self._page(data['text/html'], html=True)
548 558 else:
549 559 self._page(data['text/plain'], html=False)
550 560
551 561 #------ Trait change handlers --------------------------------------------
552 562
553 563 def _style_sheet_changed(self):
554 564 """ Set the style sheets of the underlying widgets.
555 565 """
556 566 self.setStyleSheet(self.style_sheet)
557 567 if self._control is not None:
558 568 self._control.document().setDefaultStyleSheet(self.style_sheet)
559 569 bg_color = self._control.palette().window().color()
560 570 self._ansi_processor.set_background_color(bg_color)
561 571
562 572 if self._page_control is not None:
563 573 self._page_control.document().setDefaultStyleSheet(self.style_sheet)
564 574
565 575
566 576
567 577 def _syntax_style_changed(self):
568 578 """ Set the style for the syntax highlighter.
569 579 """
570 580 if self._highlighter is None:
571 581 # ignore premature calls
572 582 return
573 583 if self.syntax_style:
574 584 self._highlighter.set_style(self.syntax_style)
575 585 else:
576 586 self._highlighter.set_style_sheet(self.style_sheet)
577 587
578 588 #------ Trait default initializers -----------------------------------------
579 589
580 590 def _banner_default(self):
581 591 return "IPython QtConsole {version}\n".format(version=version)
@@ -1,347 +1,347 b''
1 1 # Copyright (c) IPython Development Team.
2 2 # Distributed under the terms of the Modified BSD License.
3 3
4 4 from base64 import decodestring
5 5 import os
6 6 import re
7 7
8 8 from IPython.external.qt import QtCore, QtGui
9 9
10 10 from IPython.lib.latextools import latex_to_png
11 11 from IPython.utils.path import ensure_dir_exists
12 12 from IPython.utils.traitlets import Bool
13 13 from IPython.qt.svg import save_svg, svg_to_clipboard, svg_to_image
14 14 from .ipython_widget import IPythonWidget
15 15
16 16
17 17 class RichIPythonWidget(IPythonWidget):
18 18 """ An IPythonWidget that supports rich text, including lists, images, and
19 19 tables. Note that raw performance will be reduced compared to the plain
20 20 text version.
21 21 """
22 22
23 23 # RichIPythonWidget protected class variables.
24 24 _payload_source_plot = 'IPython.kernel.zmq.pylab.backend_payload.add_plot_payload'
25 25 _jpg_supported = Bool(False)
26 26
27 27 # Used to determine whether a given html export attempt has already
28 28 # displayed a warning about being unable to convert a png to svg.
29 29 _svg_warning_displayed = False
30 30
31 31 #---------------------------------------------------------------------------
32 32 # 'object' interface
33 33 #---------------------------------------------------------------------------
34 34
35 35 def __init__(self, *args, **kw):
36 36 """ Create a RichIPythonWidget.
37 37 """
38 38 kw['kind'] = 'rich'
39 39 super(RichIPythonWidget, self).__init__(*args, **kw)
40 40
41 41 # Configure the ConsoleWidget HTML exporter for our formats.
42 42 self._html_exporter.image_tag = self._get_image_tag
43 43
44 44 # Dictionary for resolving document resource names to SVG data.
45 45 self._name_to_svg_map = {}
46 46
47 47 # Do we support jpg ?
48 48 # it seems that sometime jpg support is a plugin of QT, so try to assume
49 49 # it is not always supported.
50 50 _supported_format = map(str, QtGui.QImageReader.supportedImageFormats())
51 51 self._jpg_supported = 'jpeg' in _supported_format
52 52
53 53
54 54 #---------------------------------------------------------------------------
55 55 # 'ConsoleWidget' public interface overides
56 56 #---------------------------------------------------------------------------
57 57
58 58 def export_html(self):
59 59 """ Shows a dialog to export HTML/XML in various formats.
60 60
61 61 Overridden in order to reset the _svg_warning_displayed flag prior
62 62 to the export running.
63 63 """
64 64 self._svg_warning_displayed = False
65 65 super(RichIPythonWidget, self).export_html()
66 66
67 67
68 68 #---------------------------------------------------------------------------
69 69 # 'ConsoleWidget' protected interface
70 70 #---------------------------------------------------------------------------
71 71
72 72 def _context_menu_make(self, pos):
73 73 """ Reimplemented to return a custom context menu for images.
74 74 """
75 75 format = self._control.cursorForPosition(pos).charFormat()
76 76 name = format.stringProperty(QtGui.QTextFormat.ImageName)
77 77 if name:
78 78 menu = QtGui.QMenu()
79 79
80 80 menu.addAction('Copy Image', lambda: self._copy_image(name))
81 81 menu.addAction('Save Image As...', lambda: self._save_image(name))
82 82 menu.addSeparator()
83 83
84 84 svg = self._name_to_svg_map.get(name, None)
85 85 if svg is not None:
86 86 menu.addSeparator()
87 87 menu.addAction('Copy SVG', lambda: svg_to_clipboard(svg))
88 88 menu.addAction('Save SVG As...',
89 89 lambda: save_svg(svg, self._control))
90 90 else:
91 91 menu = super(RichIPythonWidget, self)._context_menu_make(pos)
92 92 return menu
93 93
94 94 #---------------------------------------------------------------------------
95 95 # 'BaseFrontendMixin' abstract interface
96 96 #---------------------------------------------------------------------------
97 97 def _pre_image_append(self, msg, prompt_number):
98 98 """ Append the Out[] prompt and make the output nicer
99 99
100 100 Shared code for some the following if statement
101 101 """
102 102 self.log.debug("execute_result: %s", msg.get('content', ''))
103 103 self._append_plain_text(self.output_sep, True)
104 104 self._append_html(self._make_out_prompt(prompt_number), True)
105 105 self._append_plain_text('\n', True)
106 106
107 107 def _handle_execute_result(self, msg):
108 108 """ Overridden to handle rich data types, like SVG.
109 109 """
110 if not self._hidden and self._is_from_this_session(msg):
110 if self.include_output(msg):
111 111 self.flush_clearoutput()
112 112 content = msg['content']
113 113 prompt_number = content.get('execution_count', 0)
114 114 data = content['data']
115 115 metadata = msg['content']['metadata']
116 116 if 'image/svg+xml' in data:
117 117 self._pre_image_append(msg, prompt_number)
118 118 self._append_svg(data['image/svg+xml'], True)
119 119 self._append_html(self.output_sep2, True)
120 120 elif 'image/png' in data:
121 121 self._pre_image_append(msg, prompt_number)
122 122 png = decodestring(data['image/png'].encode('ascii'))
123 123 self._append_png(png, True, metadata=metadata.get('image/png', None))
124 124 self._append_html(self.output_sep2, True)
125 125 elif 'image/jpeg' in data and self._jpg_supported:
126 126 self._pre_image_append(msg, prompt_number)
127 127 jpg = decodestring(data['image/jpeg'].encode('ascii'))
128 128 self._append_jpg(jpg, True, metadata=metadata.get('image/jpeg', None))
129 129 self._append_html(self.output_sep2, True)
130 130 elif 'text/latex' in data:
131 131 self._pre_image_append(msg, prompt_number)
132 132 latex = data['text/latex'].encode('ascii')
133 133 # latex_to_png takes care of handling $
134 134 latex = latex.strip('$')
135 135 png = latex_to_png(latex, wrap=True)
136 136 if png is not None:
137 137 self._append_png(png, True)
138 138 self._append_html(self.output_sep2, True)
139 139 else:
140 140 # Print plain text if png can't be generated
141 141 return super(RichIPythonWidget, self)._handle_execute_result(msg)
142 142 else:
143 143 # Default back to the plain text representation.
144 144 return super(RichIPythonWidget, self)._handle_execute_result(msg)
145 145
146 146 def _handle_display_data(self, msg):
147 147 """ Overridden to handle rich data types, like SVG.
148 148 """
149 if not self._hidden and self._is_from_this_session(msg):
149 if self.include_output(msg):
150 150 self.flush_clearoutput()
151 151 data = msg['content']['data']
152 152 metadata = msg['content']['metadata']
153 153 # Try to use the svg or html representations.
154 154 # FIXME: Is this the right ordering of things to try?
155 155 if 'image/svg+xml' in data:
156 156 self.log.debug("display: %s", msg.get('content', ''))
157 157 svg = data['image/svg+xml']
158 158 self._append_svg(svg, True)
159 159 elif 'image/png' in data:
160 160 self.log.debug("display: %s", msg.get('content', ''))
161 161 # PNG data is base64 encoded as it passes over the network
162 162 # in a JSON structure so we decode it.
163 163 png = decodestring(data['image/png'].encode('ascii'))
164 164 self._append_png(png, True, metadata=metadata.get('image/png', None))
165 165 elif 'image/jpeg' in data and self._jpg_supported:
166 166 self.log.debug("display: %s", msg.get('content', ''))
167 167 jpg = decodestring(data['image/jpeg'].encode('ascii'))
168 168 self._append_jpg(jpg, True, metadata=metadata.get('image/jpeg', None))
169 169 else:
170 170 # Default back to the plain text representation.
171 171 return super(RichIPythonWidget, self)._handle_display_data(msg)
172 172
173 173 #---------------------------------------------------------------------------
174 174 # 'RichIPythonWidget' protected interface
175 175 #---------------------------------------------------------------------------
176 176
177 177 def _append_jpg(self, jpg, before_prompt=False, metadata=None):
178 178 """ Append raw JPG data to the widget."""
179 179 self._append_custom(self._insert_jpg, jpg, before_prompt, metadata=metadata)
180 180
181 181 def _append_png(self, png, before_prompt=False, metadata=None):
182 182 """ Append raw PNG data to the widget.
183 183 """
184 184 self._append_custom(self._insert_png, png, before_prompt, metadata=metadata)
185 185
186 186 def _append_svg(self, svg, before_prompt=False):
187 187 """ Append raw SVG data to the widget.
188 188 """
189 189 self._append_custom(self._insert_svg, svg, before_prompt)
190 190
191 191 def _add_image(self, image):
192 192 """ Adds the specified QImage to the document and returns a
193 193 QTextImageFormat that references it.
194 194 """
195 195 document = self._control.document()
196 196 name = str(image.cacheKey())
197 197 document.addResource(QtGui.QTextDocument.ImageResource,
198 198 QtCore.QUrl(name), image)
199 199 format = QtGui.QTextImageFormat()
200 200 format.setName(name)
201 201 return format
202 202
203 203 def _copy_image(self, name):
204 204 """ Copies the ImageResource with 'name' to the clipboard.
205 205 """
206 206 image = self._get_image(name)
207 207 QtGui.QApplication.clipboard().setImage(image)
208 208
209 209 def _get_image(self, name):
210 210 """ Returns the QImage stored as the ImageResource with 'name'.
211 211 """
212 212 document = self._control.document()
213 213 image = document.resource(QtGui.QTextDocument.ImageResource,
214 214 QtCore.QUrl(name))
215 215 return image
216 216
217 217 def _get_image_tag(self, match, path = None, format = "png"):
218 218 """ Return (X)HTML mark-up for the image-tag given by match.
219 219
220 220 Parameters
221 221 ----------
222 222 match : re.SRE_Match
223 223 A match to an HTML image tag as exported by Qt, with
224 224 match.group("Name") containing the matched image ID.
225 225
226 226 path : string|None, optional [default None]
227 227 If not None, specifies a path to which supporting files may be
228 228 written (e.g., for linked images). If None, all images are to be
229 229 included inline.
230 230
231 231 format : "png"|"svg"|"jpg", optional [default "png"]
232 232 Format for returned or referenced images.
233 233 """
234 234 if format in ("png","jpg"):
235 235 try:
236 236 image = self._get_image(match.group("name"))
237 237 except KeyError:
238 238 return "<b>Couldn't find image %s</b>" % match.group("name")
239 239
240 240 if path is not None:
241 241 ensure_dir_exists(path)
242 242 relpath = os.path.basename(path)
243 243 if image.save("%s/qt_img%s.%s" % (path, match.group("name"), format),
244 244 "PNG"):
245 245 return '<img src="%s/qt_img%s.%s">' % (relpath,
246 246 match.group("name"),format)
247 247 else:
248 248 return "<b>Couldn't save image!</b>"
249 249 else:
250 250 ba = QtCore.QByteArray()
251 251 buffer_ = QtCore.QBuffer(ba)
252 252 buffer_.open(QtCore.QIODevice.WriteOnly)
253 253 image.save(buffer_, format.upper())
254 254 buffer_.close()
255 255 return '<img src="data:image/%s;base64,\n%s\n" />' % (
256 256 format,re.sub(r'(.{60})',r'\1\n',str(ba.toBase64())))
257 257
258 258 elif format == "svg":
259 259 try:
260 260 svg = str(self._name_to_svg_map[match.group("name")])
261 261 except KeyError:
262 262 if not self._svg_warning_displayed:
263 263 QtGui.QMessageBox.warning(self, 'Error converting PNG to SVG.',
264 264 'Cannot convert PNG images to SVG, export with PNG figures instead. '
265 265 'If you want to export matplotlib figures as SVG, add '
266 266 'to your ipython config:\n\n'
267 267 '\tc.InlineBackend.figure_format = \'svg\'\n\n'
268 268 'And regenerate the figures.',
269 269 QtGui.QMessageBox.Ok)
270 270 self._svg_warning_displayed = True
271 271 return ("<b>Cannot convert PNG images to SVG.</b> "
272 272 "You must export this session with PNG images. "
273 273 "If you want to export matplotlib figures as SVG, add to your config "
274 274 "<span>c.InlineBackend.figure_format = 'svg'</span> "
275 275 "and regenerate the figures.")
276 276
277 277 # Not currently checking path, because it's tricky to find a
278 278 # cross-browser way to embed external SVG images (e.g., via
279 279 # object or embed tags).
280 280
281 281 # Chop stand-alone header from matplotlib SVG
282 282 offset = svg.find("<svg")
283 283 assert(offset > -1)
284 284
285 285 return svg[offset:]
286 286
287 287 else:
288 288 return '<b>Unrecognized image format</b>'
289 289
290 290 def _insert_jpg(self, cursor, jpg, metadata=None):
291 291 """ Insert raw PNG data into the widget."""
292 292 self._insert_img(cursor, jpg, 'jpg', metadata=metadata)
293 293
294 294 def _insert_png(self, cursor, png, metadata=None):
295 295 """ Insert raw PNG data into the widget.
296 296 """
297 297 self._insert_img(cursor, png, 'png', metadata=metadata)
298 298
299 299 def _insert_img(self, cursor, img, fmt, metadata=None):
300 300 """ insert a raw image, jpg or png """
301 301 if metadata:
302 302 width = metadata.get('width', None)
303 303 height = metadata.get('height', None)
304 304 else:
305 305 width = height = None
306 306 try:
307 307 image = QtGui.QImage()
308 308 image.loadFromData(img, fmt.upper())
309 309 if width and height:
310 310 image = image.scaled(width, height, transformMode=QtCore.Qt.SmoothTransformation)
311 311 elif width and not height:
312 312 image = image.scaledToWidth(width, transformMode=QtCore.Qt.SmoothTransformation)
313 313 elif height and not width:
314 314 image = image.scaledToHeight(height, transformMode=QtCore.Qt.SmoothTransformation)
315 315 except ValueError:
316 316 self._insert_plain_text(cursor, 'Received invalid %s data.'%fmt)
317 317 else:
318 318 format = self._add_image(image)
319 319 cursor.insertBlock()
320 320 cursor.insertImage(format)
321 321 cursor.insertBlock()
322 322
323 323 def _insert_svg(self, cursor, svg):
324 324 """ Insert raw SVG data into the widet.
325 325 """
326 326 try:
327 327 image = svg_to_image(svg)
328 328 except ValueError:
329 329 self._insert_plain_text(cursor, 'Received invalid SVG data.')
330 330 else:
331 331 format = self._add_image(image)
332 332 self._name_to_svg_map[format.name()] = svg
333 333 cursor.insertBlock()
334 334 cursor.insertImage(format)
335 335 cursor.insertBlock()
336 336
337 337 def _save_image(self, name, format='PNG'):
338 338 """ Shows a save dialog for the ImageResource with 'name'.
339 339 """
340 340 dialog = QtGui.QFileDialog(self._control, 'Save Image')
341 341 dialog.setAcceptMode(QtGui.QFileDialog.AcceptSave)
342 342 dialog.setDefaultSuffix(format.lower())
343 343 dialog.setNameFilter('%s file (*.%s)' % (format, format.lower()))
344 344 if dialog.exec_():
345 345 filename = dialog.selectedFiles()[0]
346 346 image = self._get_image(name)
347 347 image.save(filename, format)
General Comments 0
You need to be logged in to leave comments. Login now