##// END OF EJS Templates
FrontendWidget now treats tab characters in the std* streams like most consoles....
Evan Patterson -
Show More
@@ -1,519 +1,524
1 1 # Standard library imports
2 2 from collections import namedtuple
3 3 import signal
4 4 import sys
5 5
6 6 # System library imports
7 7 from pygments.lexers import PythonLexer
8 8 from PyQt4 import QtCore, QtGui
9 9
10 10 # Local imports
11 11 from IPython.core.inputsplitter import InputSplitter, transform_classic_prompt
12 12 from IPython.frontend.qt.base_frontend_mixin import BaseFrontendMixin
13 13 from IPython.utils.io import raw_print
14 14 from IPython.utils.traitlets import Bool
15 15 from bracket_matcher import BracketMatcher
16 16 from call_tip_widget import CallTipWidget
17 17 from completion_lexer import CompletionLexer
18 18 from history_console_widget import HistoryConsoleWidget
19 19 from pygments_highlighter import PygmentsHighlighter
20 20
21 21
22 22 class FrontendHighlighter(PygmentsHighlighter):
23 23 """ A PygmentsHighlighter that can be turned on and off and that ignores
24 24 prompts.
25 25 """
26 26
27 27 def __init__(self, frontend):
28 28 super(FrontendHighlighter, self).__init__(frontend._control.document())
29 29 self._current_offset = 0
30 30 self._frontend = frontend
31 31 self.highlighting_on = False
32 32
33 33 def highlightBlock(self, qstring):
34 34 """ Highlight a block of text. Reimplemented to highlight selectively.
35 35 """
36 36 if not self.highlighting_on:
37 37 return
38 38
39 39 # The input to this function is unicode string that may contain
40 40 # paragraph break characters, non-breaking spaces, etc. Here we acquire
41 41 # the string as plain text so we can compare it.
42 42 current_block = self.currentBlock()
43 43 string = self._frontend._get_block_plain_text(current_block)
44 44
45 45 # Decide whether to check for the regular or continuation prompt.
46 46 if current_block.contains(self._frontend._prompt_pos):
47 47 prompt = self._frontend._prompt
48 48 else:
49 49 prompt = self._frontend._continuation_prompt
50 50
51 51 # Don't highlight the part of the string that contains the prompt.
52 52 if string.startswith(prompt):
53 53 self._current_offset = len(prompt)
54 54 qstring.remove(0, len(prompt))
55 55 else:
56 56 self._current_offset = 0
57 57
58 58 PygmentsHighlighter.highlightBlock(self, qstring)
59 59
60 60 def rehighlightBlock(self, block):
61 61 """ Reimplemented to temporarily enable highlighting if disabled.
62 62 """
63 63 old = self.highlighting_on
64 64 self.highlighting_on = True
65 65 super(FrontendHighlighter, self).rehighlightBlock(block)
66 66 self.highlighting_on = old
67 67
68 68 def setFormat(self, start, count, format):
69 69 """ Reimplemented to highlight selectively.
70 70 """
71 71 start += self._current_offset
72 72 PygmentsHighlighter.setFormat(self, start, count, format)
73 73
74 74
75 75 class FrontendWidget(HistoryConsoleWidget, BaseFrontendMixin):
76 76 """ A Qt frontend for a generic Python kernel.
77 77 """
78 78
79 79 # An option and corresponding signal for overriding the default kernel
80 80 # interrupt behavior.
81 81 custom_interrupt = Bool(False)
82 82 custom_interrupt_requested = QtCore.pyqtSignal()
83 83
84 84 # An option and corresponding signals for overriding the default kernel
85 85 # restart behavior.
86 86 custom_restart = Bool(False)
87 87 custom_restart_kernel_died = QtCore.pyqtSignal(float)
88 88 custom_restart_requested = QtCore.pyqtSignal()
89 89
90 90 # Emitted when an 'execute_reply' has been received from the kernel and
91 91 # processed by the FrontendWidget.
92 92 executed = QtCore.pyqtSignal(object)
93 93
94 94 # Emitted when an exit request has been received from the kernel.
95 95 exit_requested = QtCore.pyqtSignal()
96 96
97 97 # Protected class variables.
98 98 _CallTipRequest = namedtuple('_CallTipRequest', ['id', 'pos'])
99 99 _CompletionRequest = namedtuple('_CompletionRequest', ['id', 'pos'])
100 100 _ExecutionRequest = namedtuple('_ExecutionRequest', ['id', 'kind'])
101 101 _input_splitter_class = InputSplitter
102 102
103 103 #---------------------------------------------------------------------------
104 104 # 'object' interface
105 105 #---------------------------------------------------------------------------
106 106
107 107 def __init__(self, *args, **kw):
108 108 super(FrontendWidget, self).__init__(*args, **kw)
109 109
110 110 # FrontendWidget protected variables.
111 111 self._bracket_matcher = BracketMatcher(self._control)
112 112 self._call_tip_widget = CallTipWidget(self._control)
113 113 self._completion_lexer = CompletionLexer(PythonLexer())
114 114 self._copy_raw_action = QtGui.QAction('Copy (Raw Text)', None)
115 115 self._hidden = False
116 116 self._highlighter = FrontendHighlighter(self)
117 117 self._input_splitter = self._input_splitter_class(input_mode='block')
118 118 self._kernel_manager = None
119 119 self._possible_kernel_restart = False
120 120 self._request_info = {}
121 121
122 122 # Configure the ConsoleWidget.
123 123 self.tab_width = 4
124 124 self._set_continuation_prompt('... ')
125 125
126 126 # Configure actions.
127 127 action = self._copy_raw_action
128 128 key = QtCore.Qt.CTRL | QtCore.Qt.SHIFT | QtCore.Qt.Key_C
129 129 action.setEnabled(False)
130 130 action.setShortcut(QtGui.QKeySequence(key))
131 131 action.setShortcutContext(QtCore.Qt.WidgetWithChildrenShortcut)
132 132 action.triggered.connect(self.copy_raw)
133 133 self.copy_available.connect(action.setEnabled)
134 134 self.addAction(action)
135 135
136 136 # Connect signal handlers.
137 137 document = self._control.document()
138 138 document.contentsChange.connect(self._document_contents_change)
139 139
140 140 #---------------------------------------------------------------------------
141 141 # 'ConsoleWidget' public interface
142 142 #---------------------------------------------------------------------------
143 143
144 144 def copy(self):
145 145 """ Copy the currently selected text to the clipboard, removing prompts.
146 146 """
147 147 text = str(self._control.textCursor().selection().toPlainText())
148 148 if text:
149 149 # Remove prompts.
150 150 lines = map(transform_classic_prompt, text.splitlines())
151 151 text = '\n'.join(lines)
152 152 # Expand tabs so that we respect PEP-8.
153 153 QtGui.QApplication.clipboard().setText(text.expandtabs(4))
154 154
155 155 #---------------------------------------------------------------------------
156 156 # 'ConsoleWidget' abstract interface
157 157 #---------------------------------------------------------------------------
158 158
159 159 def _is_complete(self, source, interactive):
160 160 """ Returns whether 'source' can be completely processed and a new
161 161 prompt created. When triggered by an Enter/Return key press,
162 162 'interactive' is True; otherwise, it is False.
163 163 """
164 164 complete = self._input_splitter.push(source.expandtabs(4))
165 165 if interactive:
166 166 complete = not self._input_splitter.push_accepts_more()
167 167 return complete
168 168
169 169 def _execute(self, source, hidden):
170 170 """ Execute 'source'. If 'hidden', do not show any output.
171 171
172 172 See parent class :meth:`execute` docstring for full details.
173 173 """
174 174 msg_id = self.kernel_manager.xreq_channel.execute(source, hidden)
175 175 self._request_info['execute'] = self._ExecutionRequest(msg_id, 'user')
176 176 self._hidden = hidden
177 177
178 178 def _prompt_started_hook(self):
179 179 """ Called immediately after a new prompt is displayed.
180 180 """
181 181 if not self._reading:
182 182 self._highlighter.highlighting_on = True
183 183
184 184 def _prompt_finished_hook(self):
185 185 """ Called immediately after a prompt is finished, i.e. when some input
186 186 will be processed and a new prompt displayed.
187 187 """
188 188 if not self._reading:
189 189 self._highlighter.highlighting_on = False
190 190
191 191 def _tab_pressed(self):
192 192 """ Called when the tab key is pressed. Returns whether to continue
193 193 processing the event.
194 194 """
195 195 # Perform tab completion if:
196 196 # 1) The cursor is in the input buffer.
197 197 # 2) There is a non-whitespace character before the cursor.
198 198 text = self._get_input_buffer_cursor_line()
199 199 if text is None:
200 200 return False
201 201 complete = bool(text[:self._get_input_buffer_cursor_column()].strip())
202 202 if complete:
203 203 self._complete()
204 204 return not complete
205 205
206 206 #---------------------------------------------------------------------------
207 207 # 'ConsoleWidget' protected interface
208 208 #---------------------------------------------------------------------------
209 209
210 210 def _context_menu_make(self, pos):
211 211 """ Reimplemented to add an action for raw copy.
212 212 """
213 213 menu = super(FrontendWidget, self)._context_menu_make(pos)
214 214 for before_action in menu.actions():
215 215 if before_action.shortcut().matches(QtGui.QKeySequence.Paste) == \
216 216 QtGui.QKeySequence.ExactMatch:
217 217 menu.insertAction(before_action, self._copy_raw_action)
218 218 break
219 219 return menu
220 220
221 221 def _event_filter_console_keypress(self, event):
222 222 """ Reimplemented to allow execution interruption.
223 223 """
224 224 key = event.key()
225 225 if self._control_key_down(event.modifiers(), include_command=False):
226 226 if key == QtCore.Qt.Key_C and self._executing:
227 227 self.interrupt_kernel()
228 228 return True
229 229 elif key == QtCore.Qt.Key_Period:
230 230 message = 'Are you sure you want to restart the kernel?'
231 231 self.restart_kernel(message, instant_death=False)
232 232 return True
233 233 return super(FrontendWidget, self)._event_filter_console_keypress(event)
234 234
235 235 def _insert_continuation_prompt(self, cursor):
236 236 """ Reimplemented for auto-indentation.
237 237 """
238 238 super(FrontendWidget, self)._insert_continuation_prompt(cursor)
239 239 spaces = self._input_splitter.indent_spaces
240 240 cursor.insertText('\t' * (spaces / self.tab_width))
241 241 cursor.insertText(' ' * (spaces % self.tab_width))
242 242
243 243 #---------------------------------------------------------------------------
244 244 # 'BaseFrontendMixin' abstract interface
245 245 #---------------------------------------------------------------------------
246 246
247 247 def _handle_complete_reply(self, rep):
248 248 """ Handle replies for tab completion.
249 249 """
250 250 cursor = self._get_cursor()
251 251 info = self._request_info.get('complete')
252 252 if info and info.id == rep['parent_header']['msg_id'] and \
253 253 info.pos == cursor.position():
254 254 text = '.'.join(self._get_context())
255 255 cursor.movePosition(QtGui.QTextCursor.Left, n=len(text))
256 256 self._complete_with_items(cursor, rep['content']['matches'])
257 257
258 258 def _handle_execute_reply(self, msg):
259 259 """ Handles replies for code execution.
260 260 """
261 261 info = self._request_info.get('execute')
262 262 if info and info.id == msg['parent_header']['msg_id'] and \
263 263 info.kind == 'user' and not self._hidden:
264 264 # Make sure that all output from the SUB channel has been processed
265 265 # before writing a new prompt.
266 266 self.kernel_manager.sub_channel.flush()
267 267
268 268 content = msg['content']
269 269 status = content['status']
270 270 if status == 'ok':
271 271 self._process_execute_ok(msg)
272 272 elif status == 'error':
273 273 self._process_execute_error(msg)
274 274 elif status == 'abort':
275 275 self._process_execute_abort(msg)
276 276
277 277 self._show_interpreter_prompt_for_reply(msg)
278 278 self.executed.emit(msg)
279 279
280 280 def _handle_input_request(self, msg):
281 281 """ Handle requests for raw_input.
282 282 """
283 283 if self._hidden:
284 284 raise RuntimeError('Request for raw input during hidden execution.')
285 285
286 286 # Make sure that all output from the SUB channel has been processed
287 287 # before entering readline mode.
288 288 self.kernel_manager.sub_channel.flush()
289 289
290 290 def callback(line):
291 291 self.kernel_manager.rep_channel.input(line)
292 292 self._readline(msg['content']['prompt'], callback=callback)
293 293
294 294 def _handle_kernel_died(self, since_last_heartbeat):
295 295 """ Handle the kernel's death by asking if the user wants to restart.
296 296 """
297 297 message = 'The kernel heartbeat has been inactive for %.2f ' \
298 298 'seconds. Do you want to restart the kernel? You may ' \
299 299 'first want to check the network connection.' % \
300 300 since_last_heartbeat
301 301 if self.custom_restart:
302 302 self.custom_restart_kernel_died.emit(since_last_heartbeat)
303 303 else:
304 304 self.restart_kernel(message, instant_death=True)
305 305
306 306 def _handle_object_info_reply(self, rep):
307 307 """ Handle replies for call tips.
308 308 """
309 309 cursor = self._get_cursor()
310 310 info = self._request_info.get('call_tip')
311 311 if info and info.id == rep['parent_header']['msg_id'] and \
312 312 info.pos == cursor.position():
313 313 doc = rep['content']['docstring']
314 314 if doc:
315 315 self._call_tip_widget.show_docstring(doc)
316 316
317 317 def _handle_pyout(self, msg):
318 318 """ Handle display hook output.
319 319 """
320 320 if not self._hidden and self._is_from_this_session(msg):
321 321 self._append_plain_text(msg['content']['data'] + '\n')
322 322
323 323 def _handle_stream(self, msg):
324 324 """ Handle stdout, stderr, and stdin.
325 325 """
326 326 if not self._hidden and self._is_from_this_session(msg):
327 self._append_plain_text(msg['content']['data'])
327 # Most consoles treat tabs as being 8 space characters. Convert tabs
328 # to spaces so that output looks as expected regardless of this
329 # widget's tab width.
330 text = msg['content']['data'].expandtabs(8)
331
332 self._append_plain_text(text)
328 333 self._control.moveCursor(QtGui.QTextCursor.End)
329 334
330 335 def _started_channels(self):
331 336 """ Called when the KernelManager channels have started listening or
332 337 when the frontend is assigned an already listening KernelManager.
333 338 """
334 339 self._control.clear()
335 340 self._append_plain_text(self._get_banner())
336 341 self._show_interpreter_prompt()
337 342
338 343 def _stopped_channels(self):
339 344 """ Called when the KernelManager channels have stopped listening or
340 345 when a listening KernelManager is removed from the frontend.
341 346 """
342 347 self._executing = self._reading = False
343 348 self._highlighter.highlighting_on = False
344 349
345 350 #---------------------------------------------------------------------------
346 351 # 'FrontendWidget' public interface
347 352 #---------------------------------------------------------------------------
348 353
349 354 def copy_raw(self):
350 355 """ Copy the currently selected text to the clipboard without attempting
351 356 to remove prompts or otherwise alter the text.
352 357 """
353 358 self._control.copy()
354 359
355 360 def execute_file(self, path, hidden=False):
356 361 """ Attempts to execute file with 'path'. If 'hidden', no output is
357 362 shown.
358 363 """
359 364 self.execute('execfile("%s")' % path, hidden=hidden)
360 365
361 366 def interrupt_kernel(self):
362 367 """ Attempts to interrupt the running kernel.
363 368 """
364 369 if self.custom_interrupt:
365 370 self.custom_interrupt_requested.emit()
366 371 elif self.kernel_manager.has_kernel:
367 372 self.kernel_manager.signal_kernel(signal.SIGINT)
368 373 else:
369 374 self._append_plain_text('Kernel process is either remote or '
370 375 'unspecified. Cannot interrupt.\n')
371 376
372 377 def restart_kernel(self, message, instant_death=False):
373 378 """ Attempts to restart the running kernel.
374 379 """
375 380 # FIXME: instant_death should be configurable via a checkbox in the
376 381 # dialog. Right now at least the heartbeat path sets it to True and
377 382 # the manual restart to False. But those should just be the
378 383 # pre-selected states of a checkbox that the user could override if so
379 384 # desired. But I don't know enough Qt to go implementing the checkbox
380 385 # now.
381 386
382 387 # We want to make sure that if this dialog is already happening, that
383 388 # other signals don't trigger it again. This can happen when the
384 389 # kernel_died heartbeat signal is emitted and the user is slow to
385 390 # respond to the dialog.
386 391 if not self._possible_kernel_restart:
387 392 if self.custom_restart:
388 393 self.custom_restart_requested.emit()
389 394 elif self.kernel_manager.has_kernel:
390 395 # Setting this to True will prevent this logic from happening
391 396 # again until the current pass is completed.
392 397 self._possible_kernel_restart = True
393 398 buttons = QtGui.QMessageBox.Yes | QtGui.QMessageBox.No
394 399 result = QtGui.QMessageBox.question(self, 'Restart kernel?',
395 400 message, buttons)
396 401 if result == QtGui.QMessageBox.Yes:
397 402 try:
398 403 self.kernel_manager.restart_kernel(
399 404 instant_death=instant_death)
400 405 except RuntimeError:
401 406 message = 'Kernel started externally. Cannot restart.\n'
402 407 self._append_plain_text(message)
403 408 else:
404 409 self._stopped_channels()
405 410 self._append_plain_text('Kernel restarting...\n')
406 411 self._show_interpreter_prompt()
407 412 # This might need to be moved to another location?
408 413 self._possible_kernel_restart = False
409 414 else:
410 415 self._append_plain_text('Kernel process is either remote or '
411 416 'unspecified. Cannot restart.\n')
412 417
413 418 #---------------------------------------------------------------------------
414 419 # 'FrontendWidget' protected interface
415 420 #---------------------------------------------------------------------------
416 421
417 422 def _call_tip(self):
418 423 """ Shows a call tip, if appropriate, at the current cursor location.
419 424 """
420 425 # Decide if it makes sense to show a call tip
421 426 cursor = self._get_cursor()
422 427 cursor.movePosition(QtGui.QTextCursor.Left)
423 428 if cursor.document().characterAt(cursor.position()).toAscii() != '(':
424 429 return False
425 430 context = self._get_context(cursor)
426 431 if not context:
427 432 return False
428 433
429 434 # Send the metadata request to the kernel
430 435 name = '.'.join(context)
431 436 msg_id = self.kernel_manager.xreq_channel.object_info(name)
432 437 pos = self._get_cursor().position()
433 438 self._request_info['call_tip'] = self._CallTipRequest(msg_id, pos)
434 439 return True
435 440
436 441 def _complete(self):
437 442 """ Performs completion at the current cursor location.
438 443 """
439 444 context = self._get_context()
440 445 if context:
441 446 # Send the completion request to the kernel
442 447 msg_id = self.kernel_manager.xreq_channel.complete(
443 448 '.'.join(context), # text
444 449 self._get_input_buffer_cursor_line(), # line
445 450 self._get_input_buffer_cursor_column(), # cursor_pos
446 451 self.input_buffer) # block
447 452 pos = self._get_cursor().position()
448 453 info = self._CompletionRequest(msg_id, pos)
449 454 self._request_info['complete'] = info
450 455
451 456 def _get_banner(self):
452 457 """ Gets a banner to display at the beginning of a session.
453 458 """
454 459 banner = 'Python %s on %s\nType "help", "copyright", "credits" or ' \
455 460 '"license" for more information.'
456 461 return banner % (sys.version, sys.platform)
457 462
458 463 def _get_context(self, cursor=None):
459 464 """ Gets the context for the specified cursor (or the current cursor
460 465 if none is specified).
461 466 """
462 467 if cursor is None:
463 468 cursor = self._get_cursor()
464 469 cursor.movePosition(QtGui.QTextCursor.StartOfBlock,
465 470 QtGui.QTextCursor.KeepAnchor)
466 471 text = str(cursor.selection().toPlainText())
467 472 return self._completion_lexer.get_context(text)
468 473
469 474 def _process_execute_abort(self, msg):
470 475 """ Process a reply for an aborted execution request.
471 476 """
472 477 self._append_plain_text("ERROR: execution aborted\n")
473 478
474 479 def _process_execute_error(self, msg):
475 480 """ Process a reply for an execution request that resulted in an error.
476 481 """
477 482 content = msg['content']
478 483 traceback = ''.join(content['traceback'])
479 484 self._append_plain_text(traceback)
480 485
481 486 def _process_execute_ok(self, msg):
482 487 """ Process a reply for a successful execution equest.
483 488 """
484 489 payload = msg['content']['payload']
485 490 for item in payload:
486 491 if not self._process_execute_payload(item):
487 492 warning = 'Warning: received unknown payload of type %s'
488 493 raw_print(warning % repr(item['source']))
489 494
490 495 def _process_execute_payload(self, item):
491 496 """ Process a single payload item from the list of payload items in an
492 497 execution reply. Returns whether the payload was handled.
493 498 """
494 499 # The basic FrontendWidget doesn't handle payloads, as they are a
495 500 # mechanism for going beyond the standard Python interpreter model.
496 501 return False
497 502
498 503 def _show_interpreter_prompt(self):
499 504 """ Shows a prompt for the interpreter.
500 505 """
501 506 self._show_prompt('>>> ')
502 507
503 508 def _show_interpreter_prompt_for_reply(self, msg):
504 509 """ Shows a prompt for the interpreter given an 'execute_reply' message.
505 510 """
506 511 self._show_interpreter_prompt()
507 512
508 513 #------ Signal handlers ----------------------------------------------------
509 514
510 515 def _document_contents_change(self, position, removed, added):
511 516 """ Called whenever the document's content changes. Display a call tip
512 517 if appropriate.
513 518 """
514 519 # Calculate where the cursor should be *after* the change:
515 520 position += added
516 521
517 522 document = self._control.document()
518 523 if position == self._get_cursor().position():
519 524 self._call_tip()
General Comments 0
You need to be logged in to leave comments. Login now