##// END OF EJS Templates
Minor cleanup related to tab widths.
epatters -
Show More
@@ -1,892 +1,892 b''
1 1 # Standard library imports
2 2 import sys
3 3
4 4 # System library imports
5 5 from PyQt4 import QtCore, QtGui
6 6
7 7 # Local imports
8 8 from ansi_code_processor import QtAnsiCodeProcessor
9 9 from completion_widget import CompletionWidget
10 10
11 11
12 12 class ConsoleWidget(QtGui.QPlainTextEdit):
13 13 """ Base class for console-type widgets. This class is mainly concerned with
14 14 dealing with the prompt, keeping the cursor inside the editing line, and
15 15 handling ANSI escape sequences.
16 16 """
17 17
18 18 # Whether to process ANSI escape codes.
19 19 ansi_codes = True
20 20
21 21 # The maximum number of lines of text before truncation.
22 22 buffer_size = 500
23 23
24 24 # Whether to use a CompletionWidget or plain text output for tab completion.
25 25 gui_completion = True
26 26
27 27 # Whether to override ShortcutEvents for the keybindings defined by this
28 28 # widget (Ctrl+n, Ctrl+a, etc). Enable this if you want this widget to take
29 29 # priority (when it has focus) over, e.g., window-level menu shortcuts.
30 30 override_shortcuts = False
31 31
32 32 # The number of spaces to show for a tab character.
33 tab_width = 4
33 tab_width = 8
34 34
35 35 # Protected class variables.
36 36 _ctrl_down_remap = { QtCore.Qt.Key_B : QtCore.Qt.Key_Left,
37 37 QtCore.Qt.Key_F : QtCore.Qt.Key_Right,
38 38 QtCore.Qt.Key_A : QtCore.Qt.Key_Home,
39 39 QtCore.Qt.Key_E : QtCore.Qt.Key_End,
40 40 QtCore.Qt.Key_P : QtCore.Qt.Key_Up,
41 41 QtCore.Qt.Key_N : QtCore.Qt.Key_Down,
42 42 QtCore.Qt.Key_D : QtCore.Qt.Key_Delete, }
43 43 _shortcuts = set(_ctrl_down_remap.keys() +
44 44 [ QtCore.Qt.Key_C, QtCore.Qt.Key_V ])
45 45
46 46 #---------------------------------------------------------------------------
47 47 # 'QObject' interface
48 48 #---------------------------------------------------------------------------
49 49
50 50 def __init__(self, parent=None):
51 51 QtGui.QPlainTextEdit.__init__(self, parent)
52 52
53 53 # Initialize protected variables. Some variables contain useful state
54 54 # information for subclasses; they should be considered read-only.
55 55 self._ansi_processor = QtAnsiCodeProcessor()
56 56 self._completion_widget = CompletionWidget(self)
57 57 self._continuation_prompt = '> '
58 58 self._continuation_prompt_html = None
59 59 self._executing = False
60 60 self._prompt = ''
61 61 self._prompt_html = None
62 62 self._prompt_pos = 0
63 63 self._reading = False
64 64 self._reading_callback = None
65 65
66 66 # Set a monospaced font.
67 67 self.reset_font()
68 68
69 69 # Define a custom context menu.
70 70 self._context_menu = QtGui.QMenu(self)
71 71
72 72 copy_action = QtGui.QAction('Copy', self)
73 73 copy_action.triggered.connect(self.copy)
74 74 self.copyAvailable.connect(copy_action.setEnabled)
75 75 self._context_menu.addAction(copy_action)
76 76
77 77 self._paste_action = QtGui.QAction('Paste', self)
78 78 self._paste_action.triggered.connect(self.paste)
79 79 self._context_menu.addAction(self._paste_action)
80 80 self._context_menu.addSeparator()
81 81
82 82 select_all_action = QtGui.QAction('Select All', self)
83 83 select_all_action.triggered.connect(self.selectAll)
84 84 self._context_menu.addAction(select_all_action)
85 85
86 86 def event(self, event):
87 87 """ Reimplemented to override shortcuts, if necessary.
88 88 """
89 89 # On Mac OS, it is always unnecessary to override shortcuts, hence the
90 90 # check below. Users should just use the Control key instead of the
91 91 # Command key.
92 92 if self.override_shortcuts and \
93 93 sys.platform != 'darwin' and \
94 94 event.type() == QtCore.QEvent.ShortcutOverride and \
95 95 self._control_down(event.modifiers()) and \
96 96 event.key() in self._shortcuts:
97 97 event.accept()
98 98 return True
99 99 else:
100 100 return QtGui.QPlainTextEdit.event(self, event)
101 101
102 102 #---------------------------------------------------------------------------
103 103 # 'QWidget' interface
104 104 #---------------------------------------------------------------------------
105 105
106 106 def contextMenuEvent(self, event):
107 107 """ Reimplemented to create a menu without destructive actions like
108 108 'Cut' and 'Delete'.
109 109 """
110 110 clipboard_empty = QtGui.QApplication.clipboard().text().isEmpty()
111 111 self._paste_action.setEnabled(not clipboard_empty)
112 112
113 113 self._context_menu.exec_(event.globalPos())
114 114
115 115 def dragMoveEvent(self, event):
116 116 """ Reimplemented to disable dropping text.
117 117 """
118 118 event.ignore()
119 119
120 120 def keyPressEvent(self, event):
121 121 """ Reimplemented to create a console-like interface.
122 122 """
123 123 intercepted = False
124 124 cursor = self.textCursor()
125 125 position = cursor.position()
126 126 key = event.key()
127 127 ctrl_down = self._control_down(event.modifiers())
128 128 alt_down = event.modifiers() & QtCore.Qt.AltModifier
129 129 shift_down = event.modifiers() & QtCore.Qt.ShiftModifier
130 130
131 131 # Even though we have reimplemented 'paste', the C++ level slot is still
132 132 # called by Qt. So we intercept the key press here.
133 133 if event.matches(QtGui.QKeySequence.Paste):
134 134 self.paste()
135 135 intercepted = True
136 136
137 137 elif ctrl_down:
138 138 if key in self._ctrl_down_remap:
139 139 ctrl_down = False
140 140 key = self._ctrl_down_remap[key]
141 141 event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress, key,
142 142 QtCore.Qt.NoModifier)
143 143
144 144 elif key == QtCore.Qt.Key_K:
145 145 if self._in_buffer(position):
146 146 cursor.movePosition(QtGui.QTextCursor.EndOfLine,
147 147 QtGui.QTextCursor.KeepAnchor)
148 148 cursor.removeSelectedText()
149 149 intercepted = True
150 150
151 151 elif key == QtCore.Qt.Key_X:
152 152 intercepted = True
153 153
154 154 elif key == QtCore.Qt.Key_Y:
155 155 self.paste()
156 156 intercepted = True
157 157
158 158 elif alt_down:
159 159 if key == QtCore.Qt.Key_B:
160 160 self.setTextCursor(self._get_word_start_cursor(position))
161 161 intercepted = True
162 162
163 163 elif key == QtCore.Qt.Key_F:
164 164 self.setTextCursor(self._get_word_end_cursor(position))
165 165 intercepted = True
166 166
167 167 elif key == QtCore.Qt.Key_Backspace:
168 168 cursor = self._get_word_start_cursor(position)
169 169 cursor.setPosition(position, QtGui.QTextCursor.KeepAnchor)
170 170 cursor.removeSelectedText()
171 171 intercepted = True
172 172
173 173 elif key == QtCore.Qt.Key_D:
174 174 cursor = self._get_word_end_cursor(position)
175 175 cursor.setPosition(position, QtGui.QTextCursor.KeepAnchor)
176 176 cursor.removeSelectedText()
177 177 intercepted = True
178 178
179 179 if self._completion_widget.isVisible():
180 180 self._completion_widget.keyPressEvent(event)
181 181 intercepted = event.isAccepted()
182 182
183 183 else:
184 184 if key in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter):
185 185 if self._reading:
186 186 self.appendPlainText('\n')
187 187 self._reading = False
188 188 if self._reading_callback:
189 189 self._reading_callback()
190 190 elif not self._executing:
191 191 self.execute(interactive=True)
192 192 intercepted = True
193 193
194 194 elif key == QtCore.Qt.Key_Up:
195 195 if self._reading or not self._up_pressed():
196 196 intercepted = True
197 197 else:
198 198 prompt_line = self._get_prompt_cursor().blockNumber()
199 199 intercepted = cursor.blockNumber() <= prompt_line
200 200
201 201 elif key == QtCore.Qt.Key_Down:
202 202 if self._reading or not self._down_pressed():
203 203 intercepted = True
204 204 else:
205 205 end_line = self._get_end_cursor().blockNumber()
206 206 intercepted = cursor.blockNumber() == end_line
207 207
208 208 elif key == QtCore.Qt.Key_Tab:
209 209 if self._reading:
210 210 intercepted = False
211 211 else:
212 212 intercepted = not self._tab_pressed()
213 213
214 214 elif key == QtCore.Qt.Key_Left:
215 215 intercepted = not self._in_buffer(position - 1)
216 216
217 217 elif key == QtCore.Qt.Key_Home:
218 218 cursor.movePosition(QtGui.QTextCursor.StartOfLine)
219 219 start_pos = cursor.position()
220 220 start_line = cursor.blockNumber()
221 221 if start_line == self._get_prompt_cursor().blockNumber():
222 222 start_pos += len(self._prompt)
223 223 else:
224 224 start_pos += len(self._continuation_prompt)
225 225 if shift_down and self._in_buffer(position):
226 226 self._set_selection(position, start_pos)
227 227 else:
228 228 self._set_position(start_pos)
229 229 intercepted = True
230 230
231 231 elif key == QtCore.Qt.Key_Backspace and not alt_down:
232 232
233 233 # Line deletion (remove continuation prompt)
234 234 len_prompt = len(self._continuation_prompt)
235 235 if not self._reading and \
236 236 cursor.columnNumber() == len_prompt and \
237 237 position != self._prompt_pos:
238 238 cursor.setPosition(position - len_prompt,
239 239 QtGui.QTextCursor.KeepAnchor)
240 240 cursor.removeSelectedText()
241 241
242 242 # Regular backwards deletion
243 243 else:
244 244 anchor = cursor.anchor()
245 245 if anchor == position:
246 246 intercepted = not self._in_buffer(position - 1)
247 247 else:
248 248 intercepted = not self._in_buffer(min(anchor, position))
249 249
250 250 elif key == QtCore.Qt.Key_Delete:
251 251 anchor = cursor.anchor()
252 252 intercepted = not self._in_buffer(min(anchor, position))
253 253
254 254 # Don't move cursor if control is down to allow copy-paste using
255 255 # the keyboard in any part of the buffer.
256 256 if not ctrl_down:
257 257 self._keep_cursor_in_buffer()
258 258
259 259 if not intercepted:
260 260 QtGui.QPlainTextEdit.keyPressEvent(self, event)
261 261
262 262 #--------------------------------------------------------------------------
263 263 # 'QPlainTextEdit' interface
264 264 #--------------------------------------------------------------------------
265 265
266 266 def appendHtml(self, html):
267 267 """ Reimplemented to not append HTML as a new paragraph, which doesn't
268 268 make sense for a console widget.
269 269 """
270 270 cursor = self._get_end_cursor()
271 271 cursor.insertHtml(html)
272 272
273 273 # After appending HTML, the text document "remembers" the current
274 274 # formatting, which means that subsequent calls to 'appendPlainText'
275 275 # will be formatted similarly, a behavior that we do not want. To
276 276 # prevent this, we make sure that the last character has no formatting.
277 277 cursor.movePosition(QtGui.QTextCursor.Left,
278 278 QtGui.QTextCursor.KeepAnchor)
279 279 if cursor.selection().toPlainText().trimmed().isEmpty():
280 280 # If the last character is whitespace, it doesn't matter how it's
281 281 # formatted, so just clear the formatting.
282 282 cursor.setCharFormat(QtGui.QTextCharFormat())
283 283 else:
284 284 # Otherwise, add an unformatted space.
285 285 cursor.movePosition(QtGui.QTextCursor.Right)
286 286 cursor.insertText(' ', QtGui.QTextCharFormat())
287 287
288 288 def appendPlainText(self, text):
289 289 """ Reimplemented to not append text as a new paragraph, which doesn't
290 290 make sense for a console widget. Also, if enabled, handle ANSI
291 291 codes.
292 292 """
293 293 cursor = self._get_end_cursor()
294 294 if self.ansi_codes:
295 295 for substring in self._ansi_processor.split_string(text):
296 296 format = self._ansi_processor.get_format()
297 297 cursor.insertText(substring, format)
298 298 else:
299 299 cursor.insertText(text)
300 300
301 301 def clear(self, keep_input=False):
302 302 """ Reimplemented to write a new prompt. If 'keep_input' is set,
303 303 restores the old input buffer when the new prompt is written.
304 304 """
305 305 QtGui.QPlainTextEdit.clear(self)
306 306 if keep_input:
307 307 input_buffer = self.input_buffer
308 308 self._show_prompt()
309 309 if keep_input:
310 310 self.input_buffer = input_buffer
311 311
312 312 def paste(self):
313 313 """ Reimplemented to ensure that text is pasted in the editing region.
314 314 """
315 315 self._keep_cursor_in_buffer()
316 316 QtGui.QPlainTextEdit.paste(self)
317 317
318 318 def print_(self, printer):
319 319 """ Reimplemented to work around a bug in PyQt: the C++ level 'print_'
320 320 slot has the wrong signature.
321 321 """
322 322 QtGui.QPlainTextEdit.print_(self, printer)
323 323
324 324 #---------------------------------------------------------------------------
325 325 # 'ConsoleWidget' public interface
326 326 #---------------------------------------------------------------------------
327 327
328 328 def execute(self, source=None, hidden=False, interactive=False):
329 329 """ Executes source or the input buffer, possibly prompting for more
330 330 input.
331 331
332 332 Parameters:
333 333 -----------
334 334 source : str, optional
335 335
336 336 The source to execute. If not specified, the input buffer will be
337 337 used. If specified and 'hidden' is False, the input buffer will be
338 338 replaced with the source before execution.
339 339
340 340 hidden : bool, optional (default False)
341 341
342 342 If set, no output will be shown and the prompt will not be modified.
343 343 In other words, it will be completely invisible to the user that
344 344 an execution has occurred.
345 345
346 346 interactive : bool, optional (default False)
347 347
348 348 Whether the console is to treat the source as having been manually
349 349 entered by the user. The effect of this parameter depends on the
350 350 subclass implementation.
351 351
352 352 Raises:
353 353 -------
354 354 RuntimeError
355 355 If incomplete input is given and 'hidden' is True. In this case,
356 356 it not possible to prompt for more input.
357 357
358 358 Returns:
359 359 --------
360 360 A boolean indicating whether the source was executed.
361 361 """
362 362 if not hidden:
363 363 if source is not None:
364 364 self.input_buffer = source
365 365
366 366 self.appendPlainText('\n')
367 367 self._executing_input_buffer = self.input_buffer
368 368 self._executing = True
369 369 self._prompt_finished()
370 370
371 371 real_source = self.input_buffer if source is None else source
372 372 complete = self._is_complete(real_source, interactive)
373 373 if complete:
374 374 if not hidden:
375 375 # The maximum block count is only in effect during execution.
376 376 # This ensures that _prompt_pos does not become invalid due to
377 377 # text truncation.
378 378 self.setMaximumBlockCount(self.buffer_size)
379 379 self._execute(real_source, hidden)
380 380 elif hidden:
381 381 raise RuntimeError('Incomplete noninteractive input: "%s"' % source)
382 382 else:
383 383 self._show_continuation_prompt()
384 384
385 385 return complete
386 386
387 387 def _get_input_buffer(self):
388 388 """ The text that the user has entered entered at the current prompt.
389 389 """
390 390 # If we're executing, the input buffer may not even exist anymore due to
391 391 # the limit imposed by 'buffer_size'. Therefore, we store it.
392 392 if self._executing:
393 393 return self._executing_input_buffer
394 394
395 395 cursor = self._get_end_cursor()
396 396 cursor.setPosition(self._prompt_pos, QtGui.QTextCursor.KeepAnchor)
397 397 input_buffer = str(cursor.selection().toPlainText())
398 398
399 399 # Strip out continuation prompts.
400 400 return input_buffer.replace('\n' + self._continuation_prompt, '\n')
401 401
402 402 def _set_input_buffer(self, string):
403 403 """ Replaces the text in the input buffer with 'string'.
404 404 """
405 405 # Remove old text.
406 406 cursor = self._get_end_cursor()
407 407 cursor.setPosition(self._prompt_pos, QtGui.QTextCursor.KeepAnchor)
408 408 cursor.removeSelectedText()
409 409
410 410 # Insert new text with continuation prompts.
411 411 lines = string.splitlines(True)
412 412 if lines:
413 413 self.appendPlainText(lines[0])
414 414 for i in xrange(1, len(lines)):
415 415 if self._continuation_prompt_html is None:
416 416 self.appendPlainText(self._continuation_prompt)
417 417 else:
418 418 self.appendHtml(self._continuation_prompt_html)
419 419 self.appendPlainText(lines[i])
420 420 self.moveCursor(QtGui.QTextCursor.End)
421 421
422 422 input_buffer = property(_get_input_buffer, _set_input_buffer)
423 423
424 424 def _get_input_buffer_cursor_line(self):
425 425 """ The text in the line of the input buffer in which the user's cursor
426 426 rests. Returns a string if there is such a line; otherwise, None.
427 427 """
428 428 if self._executing:
429 429 return None
430 430 cursor = self.textCursor()
431 431 if cursor.position() >= self._prompt_pos:
432 432 text = self._get_block_plain_text(cursor.block())
433 433 if cursor.blockNumber() == self._get_prompt_cursor().blockNumber():
434 434 return text[len(self._prompt):]
435 435 else:
436 436 return text[len(self._continuation_prompt):]
437 437 else:
438 438 return None
439 439
440 440 input_buffer_cursor_line = property(_get_input_buffer_cursor_line)
441 441
442 442 def _get_font(self):
443 443 """ The base font being used by the ConsoleWidget.
444 444 """
445 445 return self.document().defaultFont()
446 446
447 447 def _set_font(self, font):
448 448 """ Sets the base font for the ConsoleWidget to the specified QFont.
449 449 """
450 450 font_metrics = QtGui.QFontMetrics(font)
451 451 self.setTabStopWidth(self.tab_width * font_metrics.width(' '))
452 452
453 453 self._completion_widget.setFont(font)
454 454 self.document().setDefaultFont(font)
455 455
456 456 font = property(_get_font, _set_font)
457 457
458 458 def reset_font(self):
459 459 """ Sets the font to the default fixed-width font for this platform.
460 460 """
461 461 if sys.platform == 'win32':
462 462 name = 'Courier'
463 463 elif sys.platform == 'darwin':
464 464 name = 'Monaco'
465 465 else:
466 466 name = 'Monospace'
467 467 font = QtGui.QFont(name, QtGui.qApp.font().pointSize())
468 468 font.setStyleHint(QtGui.QFont.TypeWriter)
469 469 self._set_font(font)
470 470
471 471 #---------------------------------------------------------------------------
472 472 # 'ConsoleWidget' abstract interface
473 473 #---------------------------------------------------------------------------
474 474
475 475 def _is_complete(self, source, interactive):
476 476 """ Returns whether 'source' can be executed. When triggered by an
477 477 Enter/Return key press, 'interactive' is True; otherwise, it is
478 478 False.
479 479 """
480 480 raise NotImplementedError
481 481
482 482 def _execute(self, source, hidden):
483 483 """ Execute 'source'. If 'hidden', do not show any output.
484 484 """
485 485 raise NotImplementedError
486 486
487 487 def _prompt_started_hook(self):
488 488 """ Called immediately after a new prompt is displayed.
489 489 """
490 490 pass
491 491
492 492 def _prompt_finished_hook(self):
493 493 """ Called immediately after a prompt is finished, i.e. when some input
494 494 will be processed and a new prompt displayed.
495 495 """
496 496 pass
497 497
498 498 def _up_pressed(self):
499 499 """ Called when the up key is pressed. Returns whether to continue
500 500 processing the event.
501 501 """
502 502 return True
503 503
504 504 def _down_pressed(self):
505 505 """ Called when the down key is pressed. Returns whether to continue
506 506 processing the event.
507 507 """
508 508 return True
509 509
510 510 def _tab_pressed(self):
511 511 """ Called when the tab key is pressed. Returns whether to continue
512 512 processing the event.
513 513 """
514 514 return False
515 515
516 516 #--------------------------------------------------------------------------
517 517 # 'ConsoleWidget' protected interface
518 518 #--------------------------------------------------------------------------
519 519
520 520 def _append_html_fetching_plain_text(self, html):
521 521 """ Appends 'html', then returns the plain text version of it.
522 522 """
523 523 anchor = self._get_end_cursor().position()
524 524 self.appendHtml(html)
525 525 cursor = self._get_end_cursor()
526 526 cursor.setPosition(anchor, QtGui.QTextCursor.KeepAnchor)
527 527 return str(cursor.selection().toPlainText())
528 528
529 529 def _append_plain_text_keeping_prompt(self, text):
530 530 """ Writes 'text' after the current prompt, then restores the old prompt
531 531 with its old input buffer.
532 532 """
533 533 input_buffer = self.input_buffer
534 534 self.appendPlainText('\n')
535 535 self._prompt_finished()
536 536
537 537 self.appendPlainText(text)
538 538 self._show_prompt()
539 539 self.input_buffer = input_buffer
540 540
541 541 def _control_down(self, modifiers):
542 542 """ Given a KeyboardModifiers flags object, return whether the Control
543 543 key is down (on Mac OS, treat the Command key as a synonym for
544 544 Control).
545 545 """
546 546 down = bool(modifiers & QtCore.Qt.ControlModifier)
547 547
548 548 # Note: on Mac OS, ControlModifier corresponds to the Command key while
549 549 # MetaModifier corresponds to the Control key.
550 550 if sys.platform == 'darwin':
551 551 down = down ^ bool(modifiers & QtCore.Qt.MetaModifier)
552 552
553 553 return down
554 554
555 555 def _complete_with_items(self, cursor, items):
556 556 """ Performs completion with 'items' at the specified cursor location.
557 557 """
558 558 if len(items) == 1:
559 559 cursor.setPosition(self.textCursor().position(),
560 560 QtGui.QTextCursor.KeepAnchor)
561 561 cursor.insertText(items[0])
562 562 elif len(items) > 1:
563 563 if self.gui_completion:
564 564 self._completion_widget.show_items(cursor, items)
565 565 else:
566 566 text = '\n'.join(items) + '\n'
567 567 self._append_plain_text_keeping_prompt(text)
568 568
569 569 def _get_block_plain_text(self, block):
570 570 """ Given a QTextBlock, return its unformatted text.
571 571 """
572 572 cursor = QtGui.QTextCursor(block)
573 573 cursor.movePosition(QtGui.QTextCursor.StartOfBlock)
574 574 cursor.movePosition(QtGui.QTextCursor.EndOfBlock,
575 575 QtGui.QTextCursor.KeepAnchor)
576 576 return str(cursor.selection().toPlainText())
577 577
578 578 def _get_end_cursor(self):
579 579 """ Convenience method that returns a cursor for the last character.
580 580 """
581 581 cursor = self.textCursor()
582 582 cursor.movePosition(QtGui.QTextCursor.End)
583 583 return cursor
584 584
585 585 def _get_prompt_cursor(self):
586 586 """ Convenience method that returns a cursor for the prompt position.
587 587 """
588 588 cursor = self.textCursor()
589 589 cursor.setPosition(self._prompt_pos)
590 590 return cursor
591 591
592 592 def _get_selection_cursor(self, start, end):
593 593 """ Convenience method that returns a cursor with text selected between
594 594 the positions 'start' and 'end'.
595 595 """
596 596 cursor = self.textCursor()
597 597 cursor.setPosition(start)
598 598 cursor.setPosition(end, QtGui.QTextCursor.KeepAnchor)
599 599 return cursor
600 600
601 601 def _get_word_start_cursor(self, position):
602 602 """ Find the start of the word to the left the given position. If a
603 603 sequence of non-word characters precedes the first word, skip over
604 604 them. (This emulates the behavior of bash, emacs, etc.)
605 605 """
606 606 document = self.document()
607 607 position -= 1
608 608 while self._in_buffer(position) and \
609 609 not document.characterAt(position).isLetterOrNumber():
610 610 position -= 1
611 611 while self._in_buffer(position) and \
612 612 document.characterAt(position).isLetterOrNumber():
613 613 position -= 1
614 614 cursor = self.textCursor()
615 615 cursor.setPosition(position + 1)
616 616 return cursor
617 617
618 618 def _get_word_end_cursor(self, position):
619 619 """ Find the end of the word to the right the given position. If a
620 620 sequence of non-word characters precedes the first word, skip over
621 621 them. (This emulates the behavior of bash, emacs, etc.)
622 622 """
623 623 document = self.document()
624 624 end = self._get_end_cursor().position()
625 625 while position < end and \
626 626 not document.characterAt(position).isLetterOrNumber():
627 627 position += 1
628 628 while position < end and \
629 629 document.characterAt(position).isLetterOrNumber():
630 630 position += 1
631 631 cursor = self.textCursor()
632 632 cursor.setPosition(position)
633 633 return cursor
634 634
635 635 def _prompt_started(self):
636 636 """ Called immediately after a new prompt is displayed.
637 637 """
638 638 # Temporarily disable the maximum block count to permit undo/redo and
639 639 # to ensure that the prompt position does not change due to truncation.
640 640 self.setMaximumBlockCount(0)
641 641 self.setUndoRedoEnabled(True)
642 642
643 643 self.setReadOnly(False)
644 644 self.moveCursor(QtGui.QTextCursor.End)
645 645 self.centerCursor()
646 646
647 647 self._executing = False
648 648 self._prompt_started_hook()
649 649
650 650 def _prompt_finished(self):
651 651 """ Called immediately after a prompt is finished, i.e. when some input
652 652 will be processed and a new prompt displayed.
653 653 """
654 654 self.setUndoRedoEnabled(False)
655 655 self.setReadOnly(True)
656 656 self._prompt_finished_hook()
657 657
658 658 def _readline(self, prompt='', callback=None):
659 659 """ Reads one line of input from the user.
660 660
661 661 Parameters
662 662 ----------
663 663 prompt : str, optional
664 664 The prompt to print before reading the line.
665 665
666 666 callback : callable, optional
667 667 A callback to execute with the read line. If not specified, input is
668 668 read *synchronously* and this method does not return until it has
669 669 been read.
670 670
671 671 Returns
672 672 -------
673 673 If a callback is specified, returns nothing. Otherwise, returns the
674 674 input string with the trailing newline stripped.
675 675 """
676 676 if self._reading:
677 677 raise RuntimeError('Cannot read a line. Widget is already reading.')
678 678
679 679 if not callback and not self.isVisible():
680 680 # If the user cannot see the widget, this function cannot return.
681 681 raise RuntimeError('Cannot synchronously read a line if the widget'
682 682 'is not visible!')
683 683
684 684 self._reading = True
685 685 self._show_prompt(prompt, newline=False)
686 686
687 687 if callback is None:
688 688 self._reading_callback = None
689 689 while self._reading:
690 690 QtCore.QCoreApplication.processEvents()
691 691 return self.input_buffer.rstrip('\n')
692 692
693 693 else:
694 694 self._reading_callback = lambda: \
695 695 callback(self.input_buffer.rstrip('\n'))
696 696
697 697 def _reset(self):
698 698 """ Clears the console and resets internal state variables.
699 699 """
700 700 QtGui.QPlainTextEdit.clear(self)
701 701 self._executing = self._reading = False
702 702
703 703 def _set_continuation_prompt(self, prompt, html=False):
704 704 """ Sets the continuation prompt.
705 705
706 706 Parameters
707 707 ----------
708 708 prompt : str
709 709 The prompt to show when more input is needed.
710 710
711 711 html : bool, optional (default False)
712 712 If set, the prompt will be inserted as formatted HTML. Otherwise,
713 713 the prompt will be treated as plain text, though ANSI color codes
714 714 will be handled.
715 715 """
716 716 if html:
717 717 self._continuation_prompt_html = prompt
718 718 else:
719 719 self._continuation_prompt = prompt
720 720 self._continuation_prompt_html = None
721 721
722 722 def _set_position(self, position):
723 723 """ Convenience method to set the position of the cursor.
724 724 """
725 725 cursor = self.textCursor()
726 726 cursor.setPosition(position)
727 727 self.setTextCursor(cursor)
728 728
729 729 def _set_selection(self, start, end):
730 730 """ Convenience method to set the current selected text.
731 731 """
732 732 self.setTextCursor(self._get_selection_cursor(start, end))
733 733
734 734 def _show_prompt(self, prompt=None, html=False, newline=True):
735 735 """ Writes a new prompt at the end of the buffer.
736 736
737 737 Parameters
738 738 ----------
739 739 prompt : str, optional
740 740 The prompt to show. If not specified, the previous prompt is used.
741 741
742 742 html : bool, optional (default False)
743 743 Only relevant when a prompt is specified. If set, the prompt will
744 744 be inserted as formatted HTML. Otherwise, the prompt will be treated
745 745 as plain text, though ANSI color codes will be handled.
746 746
747 747 newline : bool, optional (default True)
748 748 If set, a new line will be written before showing the prompt if
749 749 there is not already a newline at the end of the buffer.
750 750 """
751 751 # Insert a preliminary newline, if necessary.
752 752 if newline:
753 753 cursor = self._get_end_cursor()
754 754 if cursor.position() > 0:
755 755 cursor.movePosition(QtGui.QTextCursor.Left,
756 756 QtGui.QTextCursor.KeepAnchor)
757 757 if str(cursor.selection().toPlainText()) != '\n':
758 758 self.appendPlainText('\n')
759 759
760 760 # Write the prompt.
761 761 if prompt is None:
762 762 if self._prompt_html is None:
763 763 self.appendPlainText(self._prompt)
764 764 else:
765 765 self.appendHtml(self._prompt_html)
766 766 else:
767 767 if html:
768 768 self._prompt = self._append_html_fetching_plain_text(prompt)
769 769 self._prompt_html = prompt
770 770 else:
771 771 self.appendPlainText(prompt)
772 772 self._prompt = prompt
773 773 self._prompt_html = None
774 774
775 775 self._prompt_pos = self._get_end_cursor().position()
776 776 self._prompt_started()
777 777
778 778 def _show_continuation_prompt(self):
779 779 """ Writes a new continuation prompt at the end of the buffer.
780 780 """
781 781 if self._continuation_prompt_html is None:
782 782 self.appendPlainText(self._continuation_prompt)
783 783 else:
784 784 self._continuation_prompt = self._append_html_fetching_plain_text(
785 785 self._continuation_prompt_html)
786 786
787 787 self._prompt_started()
788 788
789 789 def _in_buffer(self, position):
790 790 """ Returns whether the given position is inside the editing region.
791 791 """
792 792 return position >= self._prompt_pos
793 793
794 794 def _keep_cursor_in_buffer(self):
795 795 """ Ensures that the cursor is inside the editing region. Returns
796 796 whether the cursor was moved.
797 797 """
798 798 cursor = self.textCursor()
799 799 if cursor.position() < self._prompt_pos:
800 800 cursor.movePosition(QtGui.QTextCursor.End)
801 801 self.setTextCursor(cursor)
802 802 return True
803 803 else:
804 804 return False
805 805
806 806
807 807 class HistoryConsoleWidget(ConsoleWidget):
808 808 """ A ConsoleWidget that keeps a history of the commands that have been
809 809 executed.
810 810 """
811 811
812 812 #---------------------------------------------------------------------------
813 813 # 'QObject' interface
814 814 #---------------------------------------------------------------------------
815 815
816 816 def __init__(self, parent=None):
817 817 super(HistoryConsoleWidget, self).__init__(parent)
818 818
819 819 self._history = []
820 820 self._history_index = 0
821 821
822 822 #---------------------------------------------------------------------------
823 823 # 'ConsoleWidget' public interface
824 824 #---------------------------------------------------------------------------
825 825
826 826 def execute(self, source=None, hidden=False, interactive=False):
827 827 """ Reimplemented to the store history.
828 828 """
829 829 if not hidden:
830 830 history = self.input_buffer if source is None else source
831 831
832 832 executed = super(HistoryConsoleWidget, self).execute(
833 833 source, hidden, interactive)
834 834
835 835 if executed and not hidden:
836 836 self._history.append(history.rstrip())
837 837 self._history_index = len(self._history)
838 838
839 839 return executed
840 840
841 841 #---------------------------------------------------------------------------
842 842 # 'ConsoleWidget' abstract interface
843 843 #---------------------------------------------------------------------------
844 844
845 845 def _up_pressed(self):
846 846 """ Called when the up key is pressed. Returns whether to continue
847 847 processing the event.
848 848 """
849 849 prompt_cursor = self._get_prompt_cursor()
850 850 if self.textCursor().blockNumber() == prompt_cursor.blockNumber():
851 851 self.history_previous()
852 852
853 853 # Go to the first line of prompt for seemless history scrolling.
854 854 cursor = self._get_prompt_cursor()
855 855 cursor.movePosition(QtGui.QTextCursor.EndOfLine)
856 856 self.setTextCursor(cursor)
857 857
858 858 return False
859 859 return True
860 860
861 861 def _down_pressed(self):
862 862 """ Called when the down key is pressed. Returns whether to continue
863 863 processing the event.
864 864 """
865 865 end_cursor = self._get_end_cursor()
866 866 if self.textCursor().blockNumber() == end_cursor.blockNumber():
867 867 self.history_next()
868 868 return False
869 869 return True
870 870
871 871 #---------------------------------------------------------------------------
872 872 # 'HistoryConsoleWidget' interface
873 873 #---------------------------------------------------------------------------
874 874
875 875 def history_previous(self):
876 876 """ If possible, set the input buffer to the previous item in the
877 877 history.
878 878 """
879 879 if self._history_index > 0:
880 880 self._history_index -= 1
881 881 self.input_buffer = self._history[self._history_index]
882 882
883 883 def history_next(self):
884 884 """ Set the input buffer to the next item in the history, or a blank
885 885 line if there is no subsequent item.
886 886 """
887 887 if self._history_index < len(self._history):
888 888 self._history_index += 1
889 889 if self._history_index < len(self._history):
890 890 self.input_buffer = self._history[self._history_index]
891 891 else:
892 892 self.input_buffer = ''
@@ -1,380 +1,384 b''
1 1 # Standard library imports
2 2 import signal
3 3 import sys
4 4
5 5 # System library imports
6 6 from pygments.lexers import PythonLexer
7 7 from PyQt4 import QtCore, QtGui
8 8 import zmq
9 9
10 10 # Local imports
11 11 from IPython.core.inputsplitter import InputSplitter
12 12 from call_tip_widget import CallTipWidget
13 13 from completion_lexer import CompletionLexer
14 14 from console_widget import HistoryConsoleWidget
15 15 from pygments_highlighter import PygmentsHighlighter
16 16
17 17
18 18 class FrontendHighlighter(PygmentsHighlighter):
19 19 """ A PygmentsHighlighter that can be turned on and off and that ignores
20 20 prompts.
21 21 """
22 22
23 23 def __init__(self, frontend):
24 24 super(FrontendHighlighter, self).__init__(frontend.document())
25 25 self._current_offset = 0
26 26 self._frontend = frontend
27 27 self.highlighting_on = False
28 28
29 29 def highlightBlock(self, qstring):
30 30 """ Highlight a block of text. Reimplemented to highlight selectively.
31 31 """
32 32 if not self.highlighting_on:
33 33 return
34 34
35 35 # The input to this function is unicode string that may contain
36 36 # paragraph break characters, non-breaking spaces, etc. Here we acquire
37 37 # the string as plain text so we can compare it.
38 38 current_block = self.currentBlock()
39 39 string = self._frontend._get_block_plain_text(current_block)
40 40
41 41 # Decide whether to check for the regular or continuation prompt.
42 42 if current_block.contains(self._frontend._prompt_pos):
43 43 prompt = self._frontend._prompt
44 44 else:
45 45 prompt = self._frontend._continuation_prompt
46 46
47 47 # Don't highlight the part of the string that contains the prompt.
48 48 if string.startswith(prompt):
49 49 self._current_offset = len(prompt)
50 50 qstring.remove(0, len(prompt))
51 51 else:
52 52 self._current_offset = 0
53 53
54 54 PygmentsHighlighter.highlightBlock(self, qstring)
55 55
56 56 def setFormat(self, start, count, format):
57 57 """ Reimplemented to highlight selectively.
58 58 """
59 59 start += self._current_offset
60 60 PygmentsHighlighter.setFormat(self, start, count, format)
61 61
62 62
63 63 class FrontendWidget(HistoryConsoleWidget):
64 64 """ A Qt frontend for a generic Python kernel.
65 65 """
66 66
67 # ConsoleWidget interface.
68 tab_width = 4
69
67 70 # Emitted when an 'execute_reply' is received from the kernel.
68 71 executed = QtCore.pyqtSignal(object)
69 72
70 73 #---------------------------------------------------------------------------
71 74 # 'QObject' interface
72 75 #---------------------------------------------------------------------------
73 76
74 77 def __init__(self, parent=None):
75 78 super(FrontendWidget, self).__init__(parent)
76 79
77 80 # FrontendWidget protected variables.
78 81 self._call_tip_widget = CallTipWidget(self)
79 82 self._completion_lexer = CompletionLexer(PythonLexer())
80 83 self._hidden = True
81 84 self._highlighter = FrontendHighlighter(self)
82 85 self._input_splitter = InputSplitter(input_mode='replace')
83 86 self._kernel_manager = None
84 87
85 88 # Configure the ConsoleWidget.
86 89 self._set_continuation_prompt('... ')
87 90
88 91 self.document().contentsChange.connect(self._document_contents_change)
89 92
90 93 #---------------------------------------------------------------------------
91 94 # 'QWidget' interface
92 95 #---------------------------------------------------------------------------
93 96
94 97 def focusOutEvent(self, event):
95 98 """ Reimplemented to hide calltips.
96 99 """
97 100 self._call_tip_widget.hide()
98 101 super(FrontendWidget, self).focusOutEvent(event)
99 102
100 103 def keyPressEvent(self, event):
101 104 """ Reimplemented to allow calltips to process events and to send
102 105 signals to the kernel.
103 106 """
104 107 if self._executing and event.key() == QtCore.Qt.Key_C and \
105 108 self._control_down(event.modifiers()):
106 109 self._interrupt_kernel()
107 110 else:
108 111 if self._call_tip_widget.isVisible():
109 112 self._call_tip_widget.keyPressEvent(event)
110 113 super(FrontendWidget, self).keyPressEvent(event)
111 114
112 115 #---------------------------------------------------------------------------
113 116 # 'ConsoleWidget' abstract interface
114 117 #---------------------------------------------------------------------------
115 118
116 119 def _is_complete(self, source, interactive):
117 120 """ Returns whether 'source' can be completely processed and a new
118 121 prompt created. When triggered by an Enter/Return key press,
119 122 'interactive' is True; otherwise, it is False.
120 123 """
121 124 complete = self._input_splitter.push(source.replace('\t', ' '))
122 125 if interactive:
123 126 complete = not self._input_splitter.push_accepts_more()
124 127 return complete
125 128
126 129 def _execute(self, source, hidden):
127 130 """ Execute 'source'. If 'hidden', do not show any output.
128 131 """
129 132 self.kernel_manager.xreq_channel.execute(source)
130 133 self._hidden = hidden
131 134
132 135 def _prompt_started_hook(self):
133 136 """ Called immediately after a new prompt is displayed.
134 137 """
135 138 if not self._reading:
136 139 self._highlighter.highlighting_on = True
137 140
138 141 # Auto-indent if this is a continuation prompt.
139 142 if self._get_prompt_cursor().blockNumber() != \
140 143 self._get_end_cursor().blockNumber():
141 144 spaces = self._input_splitter.indent_spaces
142 self.appendPlainText('\t' * (spaces / 4) + ' ' * (spaces % 4))
145 self.appendPlainText('\t' * (spaces / self.tab_width))
146 self.appendPlainText(' ' * (spaces % self.tab_width))
143 147
144 148 def _prompt_finished_hook(self):
145 149 """ Called immediately after a prompt is finished, i.e. when some input
146 150 will be processed and a new prompt displayed.
147 151 """
148 152 if not self._reading:
149 153 self._highlighter.highlighting_on = False
150 154
151 155 def _tab_pressed(self):
152 156 """ Called when the tab key is pressed. Returns whether to continue
153 157 processing the event.
154 158 """
155 159 self._keep_cursor_in_buffer()
156 160 cursor = self.textCursor()
157 161 return not self._complete()
158 162
159 163 #---------------------------------------------------------------------------
160 164 # 'FrontendWidget' interface
161 165 #---------------------------------------------------------------------------
162 166
163 167 def execute_file(self, path, hidden=False):
164 168 """ Attempts to execute file with 'path'. If 'hidden', no output is
165 169 shown.
166 170 """
167 171 self.execute('execfile("%s")' % path, hidden=hidden)
168 172
169 173 def _get_kernel_manager(self):
170 174 """ Returns the current kernel manager.
171 175 """
172 176 return self._kernel_manager
173 177
174 178 def _set_kernel_manager(self, kernel_manager):
175 179 """ Disconnect from the current kernel manager (if any) and set a new
176 180 kernel manager.
177 181 """
178 182 # Disconnect the old kernel manager, if necessary.
179 183 if self._kernel_manager is not None:
180 184 self._kernel_manager.started_channels.disconnect(
181 185 self._started_channels)
182 186 self._kernel_manager.stopped_channels.disconnect(
183 187 self._stopped_channels)
184 188
185 189 # Disconnect the old kernel manager's channels.
186 190 sub = self._kernel_manager.sub_channel
187 191 xreq = self._kernel_manager.xreq_channel
188 192 rep = self._kernel_manager.rep_channel
189 193 sub.message_received.disconnect(self._handle_sub)
190 194 xreq.execute_reply.disconnect(self._handle_execute_reply)
191 195 xreq.complete_reply.disconnect(self._handle_complete_reply)
192 196 xreq.object_info_reply.disconnect(self._handle_object_info_reply)
193 197 rep.readline_requested.disconnect(self._handle_req)
194 198
195 199 # Handle the case where the old kernel manager is still listening.
196 200 if self._kernel_manager.channels_running:
197 201 self._stopped_channels()
198 202
199 203 # Set the new kernel manager.
200 204 self._kernel_manager = kernel_manager
201 205 if kernel_manager is None:
202 206 return
203 207
204 208 # Connect the new kernel manager.
205 209 kernel_manager.started_channels.connect(self._started_channels)
206 210 kernel_manager.stopped_channels.connect(self._stopped_channels)
207 211
208 212 # Connect the new kernel manager's channels.
209 213 sub = kernel_manager.sub_channel
210 214 xreq = kernel_manager.xreq_channel
211 215 rep = kernel_manager.rep_channel
212 216 sub.message_received.connect(self._handle_sub)
213 217 xreq.execute_reply.connect(self._handle_execute_reply)
214 218 xreq.complete_reply.connect(self._handle_complete_reply)
215 219 xreq.object_info_reply.connect(self._handle_object_info_reply)
216 220 rep.readline_requested.connect(self._handle_req)
217 221
218 222 # Handle the case where the kernel manager started channels before
219 223 # we connected.
220 224 if kernel_manager.channels_running:
221 225 self._started_channels()
222 226
223 227 kernel_manager = property(_get_kernel_manager, _set_kernel_manager)
224 228
225 229 #---------------------------------------------------------------------------
226 230 # 'FrontendWidget' protected interface
227 231 #---------------------------------------------------------------------------
228 232
229 233 def _call_tip(self):
230 234 """ Shows a call tip, if appropriate, at the current cursor location.
231 235 """
232 236 # Decide if it makes sense to show a call tip
233 237 cursor = self.textCursor()
234 238 cursor.movePosition(QtGui.QTextCursor.Left)
235 239 document = self.document()
236 240 if document.characterAt(cursor.position()).toAscii() != '(':
237 241 return False
238 242 context = self._get_context(cursor)
239 243 if not context:
240 244 return False
241 245
242 246 # Send the metadata request to the kernel
243 247 name = '.'.join(context)
244 248 self._calltip_id = self.kernel_manager.xreq_channel.object_info(name)
245 249 self._calltip_pos = self.textCursor().position()
246 250 return True
247 251
248 252 def _complete(self):
249 253 """ Performs completion at the current cursor location.
250 254 """
251 255 # Decide if it makes sense to do completion
252 256 context = self._get_context()
253 257 if not context:
254 258 return False
255 259
256 260 # Send the completion request to the kernel
257 261 text = '.'.join(context)
258 262 self._complete_id = self.kernel_manager.xreq_channel.complete(
259 263 text, self.input_buffer_cursor_line, self.input_buffer)
260 264 self._complete_pos = self.textCursor().position()
261 265 return True
262 266
263 267 def _get_banner(self):
264 268 """ Gets a banner to display at the beginning of a session.
265 269 """
266 270 banner = 'Python %s on %s\nType "help", "copyright", "credits" or ' \
267 271 '"license" for more information.'
268 272 return banner % (sys.version, sys.platform)
269 273
270 274 def _get_context(self, cursor=None):
271 275 """ Gets the context at the current cursor location.
272 276 """
273 277 if cursor is None:
274 278 cursor = self.textCursor()
275 279 cursor.movePosition(QtGui.QTextCursor.StartOfLine,
276 280 QtGui.QTextCursor.KeepAnchor)
277 281 text = str(cursor.selection().toPlainText())
278 282 return self._completion_lexer.get_context(text)
279 283
280 284 def _interrupt_kernel(self):
281 285 """ Attempts to the interrupt the kernel.
282 286 """
283 287 if self.kernel_manager.has_kernel:
284 288 self.kernel_manager.signal_kernel(signal.SIGINT)
285 289 else:
286 290 self.appendPlainText('Kernel process is either remote or '
287 291 'unspecified. Cannot interrupt.\n')
288 292
289 293 def _show_interpreter_prompt(self):
290 294 """ Shows a prompt for the interpreter.
291 295 """
292 296 self._show_prompt('>>> ')
293 297
294 298 #------ Signal handlers ----------------------------------------------------
295 299
296 300 def _started_channels(self):
297 301 """ Called when the kernel manager has started listening.
298 302 """
299 303 self._reset()
300 304 self.appendPlainText(self._get_banner())
301 305 self._show_interpreter_prompt()
302 306
303 307 def _stopped_channels(self):
304 308 """ Called when the kernel manager has stopped listening.
305 309 """
306 310 # FIXME: Print a message here?
307 311 pass
308 312
309 313 def _document_contents_change(self, position, removed, added):
310 314 """ Called whenever the document's content changes. Display a calltip
311 315 if appropriate.
312 316 """
313 317 # Calculate where the cursor should be *after* the change:
314 318 position += added
315 319
316 320 document = self.document()
317 321 if position == self.textCursor().position():
318 322 self._call_tip()
319 323
320 324 def _handle_req(self, req):
321 325 # Make sure that all output from the SUB channel has been processed
322 326 # before entering readline mode.
323 327 self.kernel_manager.sub_channel.flush()
324 328
325 329 def callback(line):
326 330 self.kernel_manager.rep_channel.readline(line)
327 331 self._readline(callback=callback)
328 332
329 333 def _handle_sub(self, omsg):
330 334 if self._hidden:
331 335 return
332 336 handler = getattr(self, '_handle_%s' % omsg['msg_type'], None)
333 337 if handler is not None:
334 338 handler(omsg)
335 339
336 340 def _handle_pyout(self, omsg):
337 341 self.appendPlainText(omsg['content']['data'] + '\n')
338 342
339 343 def _handle_stream(self, omsg):
340 344 self.appendPlainText(omsg['content']['data'])
341 345 self.moveCursor(QtGui.QTextCursor.End)
342 346
343 347 def _handle_execute_reply(self, reply):
344 348 if self._hidden:
345 349 return
346 350
347 351 # Make sure that all output from the SUB channel has been processed
348 352 # before writing a new prompt.
349 353 self.kernel_manager.sub_channel.flush()
350 354
351 355 status = reply['content']['status']
352 356 if status == 'error':
353 357 self._handle_execute_error(reply)
354 358 elif status == 'aborted':
355 359 text = "ERROR: ABORTED\n"
356 360 self.appendPlainText(text)
357 361 self._hidden = True
358 362 self._show_interpreter_prompt()
359 363 self.executed.emit(reply)
360 364
361 365 def _handle_execute_error(self, reply):
362 366 content = reply['content']
363 367 traceback = ''.join(content['traceback'])
364 368 self.appendPlainText(traceback)
365 369
366 370 def _handle_complete_reply(self, rep):
367 371 cursor = self.textCursor()
368 372 if rep['parent_header']['msg_id'] == self._complete_id and \
369 373 cursor.position() == self._complete_pos:
370 374 text = '.'.join(self._get_context())
371 375 cursor.movePosition(QtGui.QTextCursor.Left, n=len(text))
372 376 self._complete_with_items(cursor, rep['content']['matches'])
373 377
374 378 def _handle_object_info_reply(self, rep):
375 379 cursor = self.textCursor()
376 380 if rep['parent_header']['msg_id'] == self._calltip_id and \
377 381 cursor.position() == self._calltip_pos:
378 382 doc = rep['content']['docstring']
379 383 if doc:
380 384 self._call_tip_widget.show_docstring(doc)
General Comments 0
You need to be logged in to leave comments. Login now