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