diff --git a/IPython/frontend/qt/console/call_tip_widget.py b/IPython/frontend/qt/console/call_tip_widget.py new file mode 100644 index 0000000..b86c5a2 --- /dev/null +++ b/IPython/frontend/qt/console/call_tip_widget.py @@ -0,0 +1,156 @@ +# System library imports +from PyQt4 import QtCore, QtGui + + +class CallTipWidget(QtGui.QLabel): + """ Shows call tips by parsing the current text of Q[Plain]TextEdit. + """ + + #-------------------------------------------------------------------------- + # 'QWidget' interface + #-------------------------------------------------------------------------- + + def __init__(self, parent): + """ Create a call tip manager that is attached to the specified Qt + text edit widget. + """ + assert isinstance(parent, (QtGui.QTextEdit, QtGui.QPlainTextEdit)) + QtGui.QLabel.__init__(self, parent, QtCore.Qt.ToolTip) + + self.setFont(parent.document().defaultFont()) + self.setForegroundRole(QtGui.QPalette.ToolTipText) + self.setBackgroundRole(QtGui.QPalette.ToolTipBase) + self.setPalette(QtGui.QToolTip.palette()) + + self.setAlignment(QtCore.Qt.AlignLeft) + self.setIndent(1) + self.setFrameStyle(QtGui.QFrame.NoFrame) + self.setMargin(1 + self.style().pixelMetric( + QtGui.QStyle.PM_ToolTipLabelFrameWidth, None, self)) + self.setWindowOpacity(self.style().styleHint( + QtGui.QStyle.SH_ToolTipLabel_Opacity, None, self) / 255.0) + + def hideEvent(self, event): + """ Reimplemented to disconnect the cursor movement handler. + """ + QtGui.QListWidget.hideEvent(self, event) + self.parent().cursorPositionChanged.disconnect(self._update_tip) + + def paintEvent(self, event): + """ Reimplemented to paint the background panel. + """ + painter = QtGui.QStylePainter(self) + option = QtGui.QStyleOptionFrame() + option.init(self) + painter.drawPrimitive(QtGui.QStyle.PE_PanelTipLabel, option) + painter.end() + + QtGui.QLabel.paintEvent(self, event) + + def showEvent(self, event): + """ Reimplemented to connect the cursor movement handler. + """ + QtGui.QListWidget.showEvent(self, event) + self.parent().cursorPositionChanged.connect(self._update_tip) + + #-------------------------------------------------------------------------- + # 'CallTipWidget' interface + #-------------------------------------------------------------------------- + + def show_tip(self, tip): + """ Attempts to show the specified tip at the current cursor location. + """ + text_edit = self.parent() + document = text_edit.document() + cursor = text_edit.textCursor() + search_pos = cursor.position() - 1 + self._start_position, _ = self._find_parenthesis(search_pos, + forward=False) + if self._start_position == -1: + return False + + point = text_edit.cursorRect(cursor).bottomRight() + point = text_edit.mapToGlobal(point) + self.move(point) + self.setText(tip) + if self.isVisible(): + self.resize(self.sizeHint()) + else: + self.show() + return True + + #-------------------------------------------------------------------------- + # Protected interface + #-------------------------------------------------------------------------- + + def _find_parenthesis(self, position, forward=True): + """ If 'forward' is True (resp. False), proceed forwards + (resp. backwards) through the line that contains 'position' until an + unmatched closing (resp. opening) parenthesis is found. Returns a + tuple containing the position of this parenthesis (or -1 if it is + not found) and the number commas (at depth 0) found along the way. + """ + commas = depth = 0 + document = self.parent().document() + qchar = document.characterAt(position) + while (position > 0 and qchar.isPrint() and + # Need to check explicitly for line/paragraph separators: + qchar.unicode() not in (0x2028, 0x2029)): + char = qchar.toAscii() + if char == ',' and depth == 0: + commas += 1 + elif char == ')': + if forward and depth == 0: + break + depth += 1 + elif char == '(': + if not forward and depth == 0: + break + depth -= 1 + position += 1 if forward else -1 + qchar = document.characterAt(position) + else: + position = -1 + return position, commas + + def _highlight_tip(self, tip, current_argument): + """ Highlight the current argument (arguments start at 0), ending at the + next comma or unmatched closing parenthesis. + + FIXME: This is an unreliable way to do things and it isn't being + used right now. Instead, we should use inspect.getargspec + metadata for this purpose. + """ + start = tip.find('(') + if start != -1: + for i in xrange(current_argument): + start = tip.find(',', start) + if start != -1: + end = start + 1 + while end < len(tip): + char = tip[end] + depth = 0 + if (char == ',' and depth == 0): + break + elif char == '(': + depth += 1 + elif char == ')': + if depth == 0: + break + depth -= 1 + end += 1 + tip = tip[:start+1] + '' + \ + tip[start+1:end] + '' + tip[end:] + tip = tip.replace('\n', '
') + return tip + + def _update_tip(self): + """ Updates the tip based on user cursor movement. + """ + cursor = self.parent().textCursor() + if cursor.position() <= self._start_position: + self.hide() + else: + position, commas = self._find_parenthesis(self._start_position + 1) + if position != -1: + self.hide() diff --git a/IPython/frontend/qt/console/completion_lexer.py b/IPython/frontend/qt/console/completion_lexer.py new file mode 100644 index 0000000..5eed553 --- /dev/null +++ b/IPython/frontend/qt/console/completion_lexer.py @@ -0,0 +1,57 @@ +# System library imports +from pygments.token import Token, is_token_subtype + + +class CompletionLexer(object): + """ Uses Pygments and some auxillary information to lex code snippets for + symbol contexts. + """ + + # Maps Lexer names to a list of possible name separators + separator_map = { 'C' : [ '.', '->' ], + 'C++' : [ '.', '->', '::' ], + 'Python' : [ '.' ] } + + def __init__(self, lexer): + self.lexer = lexer + + def get_context(self, string): + """ Assuming the cursor is at the end of the specified string, get the + context (a list of names) for the symbol at cursor position. + """ + context = [] + reversed_tokens = list(self._lexer.get_tokens(string)) + reversed_tokens.reverse() + + # Pygments often tacks on a newline when none is specified in the input + if reversed_tokens and reversed_tokens[0][1].endswith('\n') and \ + not string.endswith('\n'): + reversed_tokens.pop(0) + + current_op = unicode() + for token, text in reversed_tokens: + if is_token_subtype(token, Token.Name) and \ + (not context or current_op in self._name_separators): + if not context and current_op in self._name_separators: + context.insert(0, unicode()) + context.insert(0, text) + current_op = unicode() + elif token is Token.Operator or token is Token.Punctuation: + current_op = text + current_op + else: + break + + return context + + def get_lexer(self, lexer): + return self._lexer + + def set_lexer(self, lexer, name_separators=None): + self._lexer = lexer + if name_separators is None: + self._name_separators = self.separator_map.get(lexer.name, ['.']) + else: + self._name_separators = list(name_separators) + + lexer = property(get_lexer, set_lexer) + diff --git a/IPython/frontend/qt/console/completion_widget.py b/IPython/frontend/qt/console/completion_widget.py new file mode 100644 index 0000000..4290423 --- /dev/null +++ b/IPython/frontend/qt/console/completion_widget.py @@ -0,0 +1,121 @@ +# System library imports +from PyQt4 import QtCore, QtGui + + +class CompletionWidget(QtGui.QListWidget): + """ A widget for GUI tab completion. + """ + + #-------------------------------------------------------------------------- + # 'QWidget' interface + #-------------------------------------------------------------------------- + + def __init__(self, parent): + """ Create a completion widget that is attached to the specified Qt + text edit widget. + """ + assert isinstance(parent, (QtGui.QTextEdit, QtGui.QPlainTextEdit)) + QtGui.QListWidget.__init__(self, parent) + + self.setWindowFlags(QtCore.Qt.ToolTip | QtCore.Qt.WindowStaysOnTopHint) + self.setAttribute(QtCore.Qt.WA_StaticContents) + + # Ensure that parent keeps focus when widget is displayed. + self.setFocusProxy(parent) + + self.setFrameShadow(QtGui.QFrame.Plain) + self.setFrameShape(QtGui.QFrame.StyledPanel) + + self.itemActivated.connect(self._complete_current) + + def hideEvent(self, event): + """ Reimplemented to disconnect the cursor movement handler. + """ + QtGui.QListWidget.hideEvent(self, event) + self.parent().cursorPositionChanged.disconnect(self._update_current) + + def keyPressEvent(self, event): + """ Reimplemented to update the list. + """ + key, text = event.key(), event.text() + + if key in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter, + QtCore.Qt.Key_Tab): + self._complete_current() + event.accept() + + elif key == QtCore.Qt.Key_Escape: + self.hide() + event.accept() + + elif key in (QtCore.Qt.Key_Up, QtCore.Qt.Key_Down, + QtCore.Qt.Key_PageUp, QtCore.Qt.Key_PageDown, + QtCore.Qt.Key_Home, QtCore.Qt.Key_End): + QtGui.QListWidget.keyPressEvent(self, event) + event.accept() + + else: + event.ignore() + + def showEvent(self, event): + """ Reimplemented to connect the cursor movement handler. + """ + QtGui.QListWidget.showEvent(self, event) + self.parent().cursorPositionChanged.connect(self._update_current) + + #-------------------------------------------------------------------------- + # 'CompletionWidget' interface + #-------------------------------------------------------------------------- + + def show_items(self, cursor, items): + """ Shows the completion widget with 'items' at the position specified + by 'cursor'. + """ + text_edit = self.parent() + point = text_edit.cursorRect(cursor).bottomRight() + point = text_edit.mapToGlobal(point) + screen_rect = QtGui.QApplication.desktop().availableGeometry(self) + if screen_rect.size().height() - point.y() - self.height() < 0: + point = text_edit.mapToGlobal(text_edit.cursorRect().topRight()) + point.setY(point.y() - self.height()) + self.move(point) + + self._start_position = cursor.position() + self.clear() + self.addItems(items) + self.setCurrentRow(0) + self.show() + + #-------------------------------------------------------------------------- + # Protected interface + #-------------------------------------------------------------------------- + + def _complete_current(self): + """ Perform the completion with the currently selected item. + """ + self._current_text_cursor().insertText(self.currentItem().text()) + self.hide() + + def _current_text_cursor(self): + """ Returns a cursor with text between the start position and the + current position selected. + """ + cursor = self.parent().textCursor() + if cursor.position() >= self._start_position: + cursor.setPosition(self._start_position, + QtGui.QTextCursor.KeepAnchor) + return cursor + + def _update_current(self): + """ Updates the current item based on the current text. + """ + prefix = self._current_text_cursor().selectedText() + if prefix: + items = self.findItems(prefix, (QtCore.Qt.MatchStartsWith | + QtCore.Qt.MatchCaseSensitive)) + if items: + self.setCurrentItem(items[0]) + else: + self.hide() + else: + self.hide() diff --git a/IPython/frontend/qt/console/console_widget.py b/IPython/frontend/qt/console/console_widget.py new file mode 100644 index 0000000..55da0d7 --- /dev/null +++ b/IPython/frontend/qt/console/console_widget.py @@ -0,0 +1,650 @@ +# Standard library imports +import re + +# System library imports +from PyQt4 import QtCore, QtGui + +# Local imports +from completion_widget import CompletionWidget + + +class AnsiCodeProcessor(object): + """ Translates ANSI escape codes into readable attributes. + """ + + def __init__(self): + self.ansi_colors = ( # Normal, Bright/Light + ('#000000', '#7f7f7f'), # 0: black + ('#cd0000', '#ff0000'), # 1: red + ('#00cd00', '#00ff00'), # 2: green + ('#cdcd00', '#ffff00'), # 3: yellow + ('#0000ee', '#0000ff'), # 4: blue + ('#cd00cd', '#ff00ff'), # 5: magenta + ('#00cdcd', '#00ffff'), # 6: cyan + ('#e5e5e5', '#ffffff')) # 7: white + self.reset() + + def set_code(self, code): + """ Set attributes based on code. + """ + if code == 0: + self.reset() + elif code == 1: + self.intensity = 1 + self.bold = True + elif code == 3: + self.italic = True + elif code == 4: + self.underline = True + elif code == 22: + self.intensity = 0 + self.bold = False + elif code == 23: + self.italic = False + elif code == 24: + self.underline = False + elif code >= 30 and code <= 37: + self.foreground_color = code - 30 + elif code == 39: + self.foreground_color = None + elif code >= 40 and code <= 47: + self.background_color = code - 40 + elif code == 49: + self.background_color = None + + def reset(self): + """ Reset attributs to their default values. + """ + self.intensity = 0 + self.italic = False + self.bold = False + self.underline = False + self.foreground_color = None + self.background_color = None + + +class QtAnsiCodeProcessor(AnsiCodeProcessor): + """ Translates ANSI escape codes into QTextCharFormats. + """ + + def get_format(self): + """ Returns a QTextCharFormat that encodes the current style attributes. + """ + format = QtGui.QTextCharFormat() + + # Set foreground color + if self.foreground_color is not None: + color = self.ansi_colors[self.foreground_color][self.intensity] + format.setForeground(QtGui.QColor(color)) + + # Set background color + if self.background_color is not None: + color = self.ansi_colors[self.background_color][self.intensity] + format.setBackground(QtGui.QColor(color)) + + # Set font weight/style options + if self.bold: + format.setFontWeight(QtGui.QFont.Bold) + else: + format.setFontWeight(QtGui.QFont.Normal) + format.setFontItalic(self.italic) + format.setFontUnderline(self.underline) + + return format + + +class ConsoleWidget(QtGui.QPlainTextEdit): + """ Base class for console-type widgets. This class is mainly concerned with + dealing with the prompt, keeping the cursor inside the editing line, and + handling ANSI escape sequences. + """ + + # Regex to match ANSI escape sequences + _ansi_pattern = re.compile('\x01?\x1b\[(.*?)m\x02?') + + # When ctrl is pressed, map certain keys to other keys (without the ctrl): + _ctrl_down_remap = { QtCore.Qt.Key_B : QtCore.Qt.Key_Left, + QtCore.Qt.Key_F : QtCore.Qt.Key_Right, + QtCore.Qt.Key_A : QtCore.Qt.Key_Home, + QtCore.Qt.Key_E : QtCore.Qt.Key_End, + QtCore.Qt.Key_P : QtCore.Qt.Key_Up, + QtCore.Qt.Key_N : QtCore.Qt.Key_Down, + QtCore.Qt.Key_D : QtCore.Qt.Key_Delete, } + + #--------------------------------------------------------------------------- + # 'QWidget' interface + #--------------------------------------------------------------------------- + + def __init__(self, parent=None): + QtGui.QPlainTextEdit.__init__(self, parent) + + # Initialize public and protected variables + self.ansi_codes = True + self.continuation_prompt = '> ' + self.gui_completion = True + self._ansi_processor = QtAnsiCodeProcessor() + self._completion_widget = CompletionWidget(self) + self._executing = False + self._prompt = '' + self._prompt_pos = 0 + self._reading = False + + # Configure some basic QPlainTextEdit settings + self.setLineWrapMode(QtGui.QPlainTextEdit.WidgetWidth) + self.setMaximumBlockCount(500) # Limit text buffer size + self.setUndoRedoEnabled(False) + + # Set a monospaced font + point_size = QtGui.QApplication.font().pointSize() + font = QtGui.QFont('Monospace', point_size) + font.setStyleHint(QtGui.QFont.TypeWriter) + self._completion_widget.setFont(font) + self.document().setDefaultFont(font) + + # Define a custom context menu + self._context_menu = QtGui.QMenu(self) + + copy_action = QtGui.QAction('Copy', self) + copy_action.triggered.connect(self.copy) + self.copyAvailable.connect(copy_action.setEnabled) + self._context_menu.addAction(copy_action) + + self._paste_action = QtGui.QAction('Paste', self) + self._paste_action.triggered.connect(self.paste) + self._context_menu.addAction(self._paste_action) + self._context_menu.addSeparator() + + select_all_action = QtGui.QAction('Select All', self) + select_all_action.triggered.connect(self.selectAll) + self._context_menu.addAction(select_all_action) + + def contextMenuEvent(self, event): + """ Reimplemented to create a menu without destructive actions like + 'Cut' and 'Delete'. + """ + clipboard_empty = QtGui.QApplication.clipboard().text().isEmpty() + self._paste_action.setEnabled(not clipboard_empty) + + self._context_menu.exec_(event.globalPos()) + + def keyPressEvent(self, event): + """ Reimplemented to create a console-like interface. + """ + intercepted = False + cursor = self.textCursor() + position = cursor.position() + key = event.key() + ctrl_down = event.modifiers() & QtCore.Qt.ControlModifier + alt_down = event.modifiers() & QtCore.Qt.AltModifier + shift_down = event.modifiers() & QtCore.Qt.ShiftModifier + + if ctrl_down: + if key in self._ctrl_down_remap: + ctrl_down = False + key = self._ctrl_down_remap[key] + event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress, key, + QtCore.Qt.NoModifier) + + elif key == QtCore.Qt.Key_K: + if self._in_buffer(position): + cursor.movePosition(QtGui.QTextCursor.EndOfLine, + QtGui.QTextCursor.KeepAnchor) + cursor.removeSelectedText() + intercepted = True + + elif key == QtCore.Qt.Key_Y: + self.paste() + intercepted = True + + elif alt_down: + if key == QtCore.Qt.Key_B: + self.setTextCursor(self._get_word_start_cursor(position)) + intercepted = True + + elif key == QtCore.Qt.Key_F: + self.setTextCursor(self._get_word_end_cursor(position)) + intercepted = True + + elif key == QtCore.Qt.Key_Backspace: + cursor = self._get_word_start_cursor(position) + cursor.setPosition(position, QtGui.QTextCursor.KeepAnchor) + cursor.removeSelectedText() + intercepted = True + + elif key == QtCore.Qt.Key_D: + cursor = self._get_word_end_cursor(position) + cursor.setPosition(position, QtGui.QTextCursor.KeepAnchor) + cursor.removeSelectedText() + intercepted = True + + if self._completion_widget.isVisible(): + self._completion_widget.keyPressEvent(event) + intercepted = event.isAccepted() + + else: + if key in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter): + if self._reading: + self._reading = False + elif not self._executing: + self._executing = True + self.execute(interactive=True) + intercepted = True + + elif key == QtCore.Qt.Key_Up: + if self._reading or not self._up_pressed(): + intercepted = True + else: + prompt_line = self._get_prompt_cursor().blockNumber() + intercepted = cursor.blockNumber() <= prompt_line + + elif key == QtCore.Qt.Key_Down: + if self._reading or not self._down_pressed(): + intercepted = True + else: + end_line = self._get_end_cursor().blockNumber() + intercepted = cursor.blockNumber() == end_line + + elif key == QtCore.Qt.Key_Tab: + if self._reading: + intercepted = False + else: + intercepted = not self._tab_pressed() + + elif key == QtCore.Qt.Key_Left: + intercepted = not self._in_buffer(position - 1) + + elif key == QtCore.Qt.Key_Home: + cursor.movePosition(QtGui.QTextCursor.StartOfLine) + start_pos = cursor.position() + start_line = cursor.blockNumber() + if start_line == self._get_prompt_cursor().blockNumber(): + start_pos += len(self._prompt) + else: + start_pos += len(self.continuation_prompt) + if shift_down and self._in_buffer(position): + self._set_selection(position, start_pos) + else: + self._set_position(start_pos) + intercepted = True + + elif key == QtCore.Qt.Key_Backspace and not alt_down: + + # Line deletion (remove continuation prompt) + len_prompt = len(self.continuation_prompt) + if cursor.columnNumber() == len_prompt and \ + position != self._prompt_pos: + cursor.setPosition(position - len_prompt, + QtGui.QTextCursor.KeepAnchor) + cursor.removeSelectedText() + + # Regular backwards deletion + else: + anchor = cursor.anchor() + if anchor == position: + intercepted = not self._in_buffer(position - 1) + else: + intercepted = not self._in_buffer(min(anchor, position)) + + elif key == QtCore.Qt.Key_Delete: + anchor = cursor.anchor() + intercepted = not self._in_buffer(min(anchor, position)) + + # Don't move cursor if control is down to allow copy-paste using + # the keyboard in any part of the buffer + if not ctrl_down: + self._keep_cursor_in_buffer() + + if not intercepted: + QtGui.QPlainTextEdit.keyPressEvent(self, event) + + #-------------------------------------------------------------------------- + # 'QPlainTextEdit' interface + #-------------------------------------------------------------------------- + + def appendPlainText(self, text): + """ Reimplemented to not append text as a new paragraph, which doesn't + make sense for a console widget. Also, if enabled, handle ANSI + codes. + """ + cursor = self.textCursor() + cursor.movePosition(QtGui.QTextCursor.End) + + if self.ansi_codes: + format = QtGui.QTextCharFormat() + previous_end = 0 + for match in self._ansi_pattern.finditer(text): + cursor.insertText(text[previous_end:match.start()], format) + previous_end = match.end() + for code in match.group(1).split(';'): + self._ansi_processor.set_code(int(code)) + format = self._ansi_processor.get_format() + cursor.insertText(text[previous_end:], format) + else: + cursor.insertText(text) + + def paste(self): + """ Reimplemented to ensure that text is pasted in the editing region. + """ + self._keep_cursor_in_buffer() + QtGui.QPlainTextEdit.paste(self) + + #--------------------------------------------------------------------------- + # 'ConsoleWidget' public interface + #--------------------------------------------------------------------------- + + def execute(self, interactive=False): + """ Execute the text in the input buffer. Returns whether the input + buffer was completely processed and a new prompt created. + """ + self.appendPlainText('\n') + self._prompt_finished() + return self._execute(interactive=interactive) + + def _get_input_buffer(self): + cursor = self._get_end_cursor() + cursor.setPosition(self._prompt_pos, QtGui.QTextCursor.KeepAnchor) + + # Use QTextDocumentFragment intermediate object because it strips + # out the Unicode line break characters that Qt insists on inserting. + input_buffer = str(cursor.selection().toPlainText()) + + # Strip out continuation prompts + return input_buffer.replace('\n' + self.continuation_prompt, '\n') + + def _set_input_buffer(self, string): + # Add continuation prompts where necessary + lines = string.splitlines() + for i in xrange(1, len(lines)): + lines[i] = self.continuation_prompt + lines[i] + string = '\n'.join(lines) + + # Replace buffer with new text + cursor = self._get_end_cursor() + cursor.setPosition(self._prompt_pos, QtGui.QTextCursor.KeepAnchor) + cursor.insertText(string) + self.moveCursor(QtGui.QTextCursor.End) + + input_buffer = property(_get_input_buffer, _set_input_buffer) + + def _get_input_buffer_cursor_line(self): + cursor = self.textCursor() + if cursor.position() >= self._prompt_pos: + text = str(cursor.block().text()) + if cursor.blockNumber() == self._get_prompt_cursor().blockNumber(): + return text[len(self._prompt):] + else: + return text[len(self.continuation_prompt):] + else: + return None + + input_buffer_cursor_line = property(_get_input_buffer_cursor_line) + + #--------------------------------------------------------------------------- + # 'ConsoleWidget' abstract interface + #--------------------------------------------------------------------------- + + def _execute(self, interactive): + """ Called to execute the input buffer. When triggered by an the enter + key press, 'interactive' is True; otherwise, it is False. Returns + whether the input buffer was completely processed and a new prompt + created. + """ + raise NotImplementedError + + def _prompt_started_hook(self): + """ Called immediately after a new prompt is displayed. + """ + pass + + def _prompt_finished_hook(self): + """ Called immediately after a prompt is finished, i.e. when some input + will be processed and a new prompt displayed. + """ + pass + + def _up_pressed(self): + """ Called when the up key is pressed. Returns whether to continue + processing the event. + """ + return True + + def _down_pressed(self): + """ Called when the down key is pressed. Returns whether to continue + processing the event. + """ + return True + + def _tab_pressed(self): + """ Called when the tab key is pressed. Returns whether to continue + processing the event. + """ + return False + + #-------------------------------------------------------------------------- + # 'ConsoleWidget' protected interface + #-------------------------------------------------------------------------- + + def _complete_with_items(self, cursor, items): + """ Performs completion with 'items' at the specified cursor location. + """ + if len(items) == 1: + cursor.setPosition(self.textCursor().position(), + QtGui.QTextCursor.KeepAnchor) + cursor.insertText(items[0]) + elif len(items) > 1: + if self.gui_completion: + self._completion_widget.show_items(cursor, items) + else: + text = '\n'.join(items) + '\n' + self._write_text_keeping_prompt(text) + + def _get_end_cursor(self): + """ Convenience method that returns a cursor for the last character. + """ + cursor = self.textCursor() + cursor.movePosition(QtGui.QTextCursor.End) + return cursor + + def _get_prompt_cursor(self): + """ Convenience method that returns a cursor for the prompt position. + """ + cursor = self.textCursor() + cursor.setPosition(self._prompt_pos) + return cursor + + def _get_selection_cursor(self, start, end): + """ Convenience method that returns a cursor with text selected between + the positions 'start' and 'end'. + """ + cursor = self.textCursor() + cursor.setPosition(start) + cursor.setPosition(end, QtGui.QTextCursor.KeepAnchor) + return cursor + + def _get_word_start_cursor(self, position): + """ Find the start of the word to the left the given position. If a + sequence of non-word characters precedes the first word, skip over + them. (This emulates the behavior of bash, emacs, etc.) + """ + document = self.document() + position -= 1 + while self._in_buffer(position) and \ + not document.characterAt(position).isLetterOrNumber(): + position -= 1 + while self._in_buffer(position) and \ + document.characterAt(position).isLetterOrNumber(): + position -= 1 + cursor = self.textCursor() + cursor.setPosition(position + 1) + return cursor + + def _get_word_end_cursor(self, position): + """ Find the end of the word to the right the given position. If a + sequence of non-word characters precedes the first word, skip over + them. (This emulates the behavior of bash, emacs, etc.) + """ + document = self.document() + end = self._get_end_cursor().position() + while position < end and \ + not document.characterAt(position).isLetterOrNumber(): + position += 1 + while position < end and \ + document.characterAt(position).isLetterOrNumber(): + position += 1 + cursor = self.textCursor() + cursor.setPosition(position) + return cursor + + def _prompt_started(self): + """ Called immediately after a new prompt is displayed. + """ + self.moveCursor(QtGui.QTextCursor.End) + self.centerCursor() + self.setReadOnly(False) + self._executing = False + self._prompt_started_hook() + + def _prompt_finished(self): + """ Called immediately after a prompt is finished, i.e. when some input + will be processed and a new prompt displayed. + """ + self.setReadOnly(True) + self._prompt_finished_hook() + + def _set_position(self, position): + """ Convenience method to set the position of the cursor. + """ + cursor = self.textCursor() + cursor.setPosition(position) + self.setTextCursor(cursor) + + def _set_selection(self, start, end): + """ Convenience method to set the current selected text. + """ + self.setTextCursor(self._get_selection_cursor(start, end)) + + def _show_prompt(self, prompt): + """ Writes a new prompt at the end of the buffer. + """ + self.appendPlainText('\n' + prompt) + self._prompt = prompt + self._prompt_pos = self._get_end_cursor().position() + self._prompt_started() + + def _show_continuation_prompt(self): + """ Writes a new continuation prompt at the end of the buffer. + """ + self.appendPlainText(self.continuation_prompt) + self._prompt_started() + + def _write_text_keeping_prompt(self, text): + """ Writes 'text' after the current prompt, then restores the old prompt + with its old input buffer. + """ + input_buffer = self.input_buffer + self.appendPlainText('\n') + self._prompt_finished() + + self.appendPlainText(text) + self._show_prompt(self._prompt) + self.input_buffer = input_buffer + + def _in_buffer(self, position): + """ Returns whether the given position is inside the editing region. + """ + return position >= self._prompt_pos + + def _keep_cursor_in_buffer(self): + """ Ensures that the cursor is inside the editing region. Returns + whether the cursor was moved. + """ + cursor = self.textCursor() + if cursor.position() < self._prompt_pos: + cursor.movePosition(QtGui.QTextCursor.End) + self.setTextCursor(cursor) + return True + else: + return False + + +class HistoryConsoleWidget(ConsoleWidget): + """ A ConsoleWidget that keeps a history of the commands that have been + executed. + """ + + #--------------------------------------------------------------------------- + # 'QWidget' interface + #--------------------------------------------------------------------------- + + def __init__(self, parent=None): + super(HistoryConsoleWidget, self).__init__(parent) + + self._history = [] + self._history_index = 0 + + #--------------------------------------------------------------------------- + # 'ConsoleWidget' public interface + #--------------------------------------------------------------------------- + + def execute(self, interactive=False): + """ Reimplemented to the store history. + """ + stripped = self.input_buffer.rstrip() + executed = super(HistoryConsoleWidget, self).execute(interactive) + if executed: + self._history.append(stripped) + self._history_index = len(self._history) + return executed + + #--------------------------------------------------------------------------- + # 'ConsoleWidget' abstract interface + #--------------------------------------------------------------------------- + + def _up_pressed(self): + """ Called when the up key is pressed. Returns whether to continue + processing the event. + """ + prompt_cursor = self._get_prompt_cursor() + if self.textCursor().blockNumber() == prompt_cursor.blockNumber(): + self.history_previous() + + # Go to the first line of prompt for seemless history scrolling. + cursor = self._get_prompt_cursor() + cursor.movePosition(QtGui.QTextCursor.EndOfLine) + self.setTextCursor(cursor) + + return False + return True + + def _down_pressed(self): + """ Called when the down key is pressed. Returns whether to continue + processing the event. + """ + end_cursor = self._get_end_cursor() + if self.textCursor().blockNumber() == end_cursor.blockNumber(): + self.history_next() + return False + return True + + #--------------------------------------------------------------------------- + # 'HistoryConsoleWidget' interface + #--------------------------------------------------------------------------- + + def history_previous(self): + """ If possible, set the input buffer to the previous item in the + history. + """ + if self._history_index > 0: + self._history_index -= 1 + self.input_buffer = self._history[self._history_index] + + def history_next(self): + """ Set the input buffer to the next item in the history, or a blank + line if there is no subsequent item. + """ + if self._history_index < len(self._history): + self._history_index += 1 + if self._history_index < len(self._history): + self.input_buffer = self._history[self._history_index] + else: + self.input_buffer = '' diff --git a/IPython/frontend/qt/console/frontend_widget.py b/IPython/frontend/qt/console/frontend_widget.py new file mode 100644 index 0000000..d52ec9b --- /dev/null +++ b/IPython/frontend/qt/console/frontend_widget.py @@ -0,0 +1,383 @@ +# Standard library imports +from codeop import CommandCompiler +from threading import Thread +import time +import types + +# System library imports +from IPython.zmq.session import Message, Session +from pygments.lexers import PythonLexer +from PyQt4 import QtCore, QtGui +import zmq + +# ETS imports +from enthought.pyface.ui.qt4.code_editor.pygments_highlighter import \ + PygmentsHighlighter + +# Local imports +from call_tip_widget import CallTipWidget +from completion_lexer import CompletionLexer +from console_widget import HistoryConsoleWidget + + +class FrontendReplyThread(Thread, QtCore.QObject): + """ A Thread that receives a reply from the kernel for the frontend. + """ + + finished = QtCore.pyqtSignal() + output_received = QtCore.pyqtSignal(Message) + reply_received = QtCore.pyqtSignal(Message) + + def __init__(self, parent): + """ Create a FrontendReplyThread for the specified frontend. + """ + assert isinstance(parent, FrontendWidget) + QtCore.QObject.__init__(self, parent) + Thread.__init__(self) + + self.sleep_time = 0.05 + + def run(self): + """ The starting point for the thread. + """ + frontend = self.parent() + while True: + rep = frontend._recv_reply() + if rep is not None: + self._recv_output() + self.reply_received.emit(rep) + break + + self._recv_output() + time.sleep(self.sleep_time) + + self.finished.emit() + + def _recv_output(self): + """ Send any output to the frontend. + """ + frontend = self.parent() + omsgs = frontend._recv_output() + for omsg in omsgs: + self.output_received.emit(omsg) + + +class FrontendHighlighter(PygmentsHighlighter): + """ A Python PygmentsHighlighter that can be turned on and off and which + knows about continuation prompts. + """ + + def __init__(self, frontend): + PygmentsHighlighter.__init__(self, frontend.document(), PythonLexer()) + self._current_offset = 0 + self._frontend = frontend + self.highlighting_on = False + + def highlightBlock(self, qstring): + """ Highlight a block of text. Reimplemented to highlight selectively. + """ + if self.highlighting_on: + for prompt in (self._frontend._prompt, + self._frontend.continuation_prompt): + if qstring.startsWith(prompt): + qstring.remove(0, len(prompt)) + self._current_offset = len(prompt) + break + PygmentsHighlighter.highlightBlock(self, qstring) + + def setFormat(self, start, count, format): + """ Reimplemented to avoid highlighting continuation prompts. + """ + start += self._current_offset + PygmentsHighlighter.setFormat(self, start, count, format) + + +class FrontendWidget(HistoryConsoleWidget): + """ A Qt frontend for an IPython kernel. + """ + + # Emitted when an 'execute_reply' is received from the kernel. + executed = QtCore.pyqtSignal(Message) + + #--------------------------------------------------------------------------- + # 'QWidget' interface + #--------------------------------------------------------------------------- + + def __init__(self, parent=None, session=None, request_socket=None, + sub_socket=None): + super(FrontendWidget, self).__init__(parent) + self.continuation_prompt = '... ' + + self._call_tip_widget = CallTipWidget(self) + self._compile = CommandCompiler() + self._completion_lexer = CompletionLexer(PythonLexer()) + self._highlighter = FrontendHighlighter(self) + + self.session = Session() if session is None else session + self.request_socket = request_socket + self.sub_socket = sub_socket + + self.document().contentsChange.connect(self._document_contents_change) + + self._kernel_connected() # XXX + + def focusOutEvent(self, event): + """ Reimplemented to hide calltips. + """ + self._call_tip_widget.hide() + return super(FrontendWidget, self).focusOutEvent(event) + + def keyPressEvent(self, event): + """ Reimplemented to hide calltips. + """ + if event.key() == QtCore.Qt.Key_Escape: + self._call_tip_widget.hide() + return super(FrontendWidget, self).keyPressEvent(event) + + #--------------------------------------------------------------------------- + # 'ConsoleWidget' abstract interface + #--------------------------------------------------------------------------- + + def _execute(self, interactive): + """ Called to execute the input buffer. When triggered by an the enter + key press, 'interactive' is True; otherwise, it is False. Returns + whether the input buffer was completely processed and a new prompt + created. + """ + return self.execute_source(self.input_buffer, interactive=interactive) + + def _prompt_started_hook(self): + """ Called immediately after a new prompt is displayed. + """ + self._highlighter.highlighting_on = True + + def _prompt_finished_hook(self): + """ Called immediately after a prompt is finished, i.e. when some input + will be processed and a new prompt displayed. + """ + self._highlighter.highlighting_on = False + + def _tab_pressed(self): + """ Called when the tab key is pressed. Returns whether to continue + processing the event. + """ + self._keep_cursor_in_buffer() + cursor = self.textCursor() + if not self._complete(): + cursor.insertText(' ') + return False + + #--------------------------------------------------------------------------- + # 'FrontendWidget' interface + #--------------------------------------------------------------------------- + + def execute_source(self, source, hidden=False, interactive=False): + """ Execute a string containing Python code. If 'hidden', no output is + shown. Returns whether the source executed (i.e., returns True only + if no more input is necessary). + """ + try: + code = self._compile(source, symbol='single') + except (OverflowError, SyntaxError, ValueError): + # Just let IPython deal with the syntax error. + code = Exception + + # Only execute interactive multiline input if it ends with a blank line + lines = source.splitlines() + if interactive and len(lines) > 1 and lines[-1].strip() != '': + code = None + + executed = code is not None + if executed: + msg = self.session.send(self.request_socket, 'execute_request', + dict(code=source)) + thread = FrontendReplyThread(self) + if not hidden: + thread.output_received.connect(self._handle_output) + thread.reply_received.connect(self._handle_reply) + thread.finished.connect(thread.deleteLater) + thread.start() + else: + space = 0 + for char in lines[-1]: + if char == '\t': + space += 4 + elif char == ' ': + space += 1 + else: + break + if source.endswith(':') or source.endswith(':\n'): + space += 4 + self._show_continuation_prompt() + self.appendPlainText(' ' * space) + + return executed + + def execute_file(self, path, hidden=False): + """ Attempts to execute file with 'path'. If 'hidden', no output is + shown. + """ + self.execute_source('run %s' % path, hidden=hidden) + + #--------------------------------------------------------------------------- + # 'FrontendWidget' protected interface + #--------------------------------------------------------------------------- + + def _call_tip(self): + """ Shows a call tip, if appropriate, at the current cursor location. + """ + # Decide if it makes sense to show a call tip + cursor = self.textCursor() + cursor.movePosition(QtGui.QTextCursor.Left) + document = self.document() + if document.characterAt(cursor.position()).toAscii() != '(': + return False + context = self._get_context(cursor) + if not context: + return False + + # Send the metadata request to the kernel + text = '.'.join(context) + msg = self.session.send(self.request_socket, 'metadata_request', + dict(context=text)) + + # Give the kernel some time to respond + rep = self._recv_reply_now('metadata_reply') + doc = rep.content.docstring if rep else '' + + # Show the call tip + if doc: + self._call_tip_widget.show_tip(doc) + return True + + def _complete(self): + """ Performs completion at the current cursor location. + """ + # Decide if it makes sense to do completion + context = self._get_context() + if not context: + return False + + # Send the completion request to the kernel + text = '.'.join(context) + line = self.input_buffer_cursor_line + msg = self.session.send(self.request_socket, 'complete_request', + dict(text=text, line=line)) + + # Give the kernel some time to respond + rep = self._recv_reply_now('complete_reply') + matches = rep.content.matches if rep else [] + + # Show the completion at the correct location + cursor = self.textCursor() + cursor.movePosition(QtGui.QTextCursor.Left, n=len(text)) + self._complete_with_items(cursor, matches) + return True + + def _kernel_connected(self): + """ Called when the frontend is connected to a kernel. + """ + self._show_prompt('>>> ') + + def _get_context(self, cursor=None): + """ Gets the context at the current cursor location. + """ + if cursor is None: + cursor = self.textCursor() + cursor.movePosition(QtGui.QTextCursor.StartOfLine, + QtGui.QTextCursor.KeepAnchor) + text = unicode(cursor.selectedText()) + return self._completion_lexer.get_context(text) + + #------ Signal handlers ---------------------------------------------------- + + def _document_contents_change(self, position, removed, added): + """ Called whenever the document's content changes. Display a calltip + if appropriate. + """ + # Calculate where the cursor should be *after* the change: + position += added + + document = self.document() + if position == self.textCursor().position(): + self._call_tip() + + def _handle_output(self, omsg): + handler = getattr(self, '_handle_%s' % omsg.msg_type, None) + if handler is not None: + handler(omsg) + + def _handle_pyout(self, omsg): + if omsg.parent_header.session == self.session.session: + self.appendPlainText(omsg.content.data + '\n') + + def _handle_stream(self, omsg): + self.appendPlainText(omsg.content.data) + + def _handle_reply(self, rep): + if rep is not None: + if rep.msg_type == 'execute_reply': + if rep.content.status == 'error': + self.appendPlainText(rep.content.traceback[-1]) + elif rep.content.status == 'aborted': + text = "ERROR: ABORTED\n" + ab = self.messages[rep.parent_header.msg_id].content + if 'code' in ab: + text += ab.code + else: + text += ab + self.appendPlainText(text) + self._show_prompt('>>> ') + self.executed.emit(rep) + + #------ Communication methods ---------------------------------------------- + + def _recv_output(self): + omsgs = [] + while True: + omsg = self.session.recv(self.sub_socket) + if omsg is None: + break + else: + omsgs.append(omsg) + return omsgs + + def _recv_reply(self): + return self.session.recv(self.request_socket) + + def _recv_reply_now(self, msg_type): + for i in xrange(5): + rep = self._recv_reply() + if rep is not None and rep.msg_type == msg_type: + return rep + time.sleep(0.1) + return None + + +if __name__ == '__main__': + import sys + + # Defaults + ip = '127.0.0.1' + port_base = 5555 + connection = ('tcp://%s' % ip) + ':%i' + req_conn = connection % port_base + sub_conn = connection % (port_base+1) + + # Create initial sockets + c = zmq.Context() + request_socket = c.socket(zmq.XREQ) + request_socket.connect(req_conn) + sub_socket = c.socket(zmq.SUB) + sub_socket.connect(sub_conn) + sub_socket.setsockopt(zmq.SUBSCRIBE, '') + + # Launch application + app = QtGui.QApplication(sys.argv) + widget = FrontendWidget(request_socket=request_socket, + sub_socket=sub_socket) + widget.setWindowTitle('Python') + widget.resize(640, 480) + widget.show() + sys.exit(app.exec_()) + diff --git a/IPython/frontend/qt/console/pygments_highlighter.py b/IPython/frontend/qt/console/pygments_highlighter.py new file mode 100644 index 0000000..c918a04 --- /dev/null +++ b/IPython/frontend/qt/console/pygments_highlighter.py @@ -0,0 +1,221 @@ +#------------------------------------------------------------------------------ +# Copyright (c) 2010, Enthought Inc +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD license. + +# +# Author: Enthought Inc +# Description: +#------------------------------------------------------------------------------ + +from PyQt4 import QtGui + +from pygments.lexer import RegexLexer, _TokenType, Text, Error +from pygments.lexers import CLexer, CppLexer, PythonLexer +from pygments.styles.default import DefaultStyle +from pygments.token import Comment + + +def get_tokens_unprocessed(self, text, stack=('root',)): + """ Split ``text`` into (tokentype, text) pairs. + + Monkeypatched to store the final stack on the object itself. + """ + pos = 0 + tokendefs = self._tokens + if hasattr(self, '_epd_state_stack'): + statestack = list(self._epd_state_stack) + else: + statestack = list(stack) + statetokens = tokendefs[statestack[-1]] + while 1: + for rexmatch, action, new_state in statetokens: + m = rexmatch(text, pos) + if m: + if type(action) is _TokenType: + yield pos, action, m.group() + else: + for item in action(self, m): + yield item + pos = m.end() + if new_state is not None: + # state transition + if isinstance(new_state, tuple): + for state in new_state: + if state == '#pop': + statestack.pop() + elif state == '#push': + statestack.append(statestack[-1]) + else: + statestack.append(state) + elif isinstance(new_state, int): + # pop + del statestack[new_state:] + elif new_state == '#push': + statestack.append(statestack[-1]) + else: + assert False, "wrong state def: %r" % new_state + statetokens = tokendefs[statestack[-1]] + break + else: + try: + if text[pos] == '\n': + # at EOL, reset state to "root" + pos += 1 + statestack = ['root'] + statetokens = tokendefs['root'] + yield pos, Text, u'\n' + continue + yield pos, Error, text[pos] + pos += 1 + except IndexError: + break + self._epd_state_stack = list(statestack) + +# Monkeypatch! +RegexLexer.get_tokens_unprocessed = get_tokens_unprocessed + + +# Even with the above monkey patch to store state, multiline comments do not +# work since they are stateless (Pygments uses a single multiline regex for +# these comments, but Qt lexes by line). So we need to add a state for comments +# to the C and C++ lexers. This means that nested multiline comments will appear +# to be valid C/C++, but this is better than the alternative for now. + +def replace_pattern(tokens, new_pattern): + """ Given a RegexLexer token dictionary 'tokens', replace all patterns that + match the token specified in 'new_pattern' with 'new_pattern'. + """ + for state in tokens.values(): + for index, pattern in enumerate(state): + if isinstance(pattern, tuple) and pattern[1] == new_pattern[1]: + state[index] = new_pattern + +# More monkeypatching! +comment_start = (r'/\*', Comment.Multiline, 'comment') +comment_state = [ (r'[^*/]', Comment.Multiline), + (r'/\*', Comment.Multiline, '#push'), + (r'\*/', Comment.Multiline, '#pop'), + (r'[*/]', Comment.Multiline) ] +replace_pattern(CLexer.tokens, comment_start) +replace_pattern(CppLexer.tokens, comment_start) +CLexer.tokens['comment'] = comment_state +CppLexer.tokens['comment'] = comment_state + + +class BlockUserData(QtGui.QTextBlockUserData): + """ Storage for the user data associated with each line. + """ + + syntax_stack = ('root',) + + def __init__(self, **kwds): + for key, value in kwds.iteritems(): + setattr(self, key, value) + QtGui.QTextBlockUserData.__init__(self) + + def __repr__(self): + attrs = ['syntax_stack'] + kwds = ', '.join([ '%s=%r' % (attr, getattr(self, attr)) + for attr in attrs ]) + return 'BlockUserData(%s)' % kwds + + +class PygmentsHighlighter(QtGui.QSyntaxHighlighter): + """ Syntax highlighter that uses Pygments for parsing. """ + + def __init__(self, parent, lexer=None): + super(PygmentsHighlighter, self).__init__(parent) + + self._lexer = lexer if lexer else PythonLexer() + self._style = DefaultStyle + # Caches for formats and brushes. + self._brushes = {} + self._formats = {} + + def highlightBlock(self, qstring): + """ Highlight a block of text. + """ + qstring = unicode(qstring) + prev_data = self.previous_block_data() + + if prev_data is not None: + self._lexer._epd_state_stack = prev_data.syntax_stack + elif hasattr(self._lexer, '_epd_state_stack'): + del self._lexer._epd_state_stack + + index = 0 + # Lex the text using Pygments + for token, text in self._lexer.get_tokens(qstring): + l = len(text) + format = self._get_format(token) + if format is not None: + self.setFormat(index, l, format) + index += l + + if hasattr(self._lexer, '_epd_state_stack'): + data = BlockUserData(syntax_stack=self._lexer._epd_state_stack) + self.currentBlock().setUserData(data) + # Clean up for the next go-round. + del self._lexer._epd_state_stack + + def previous_block_data(self): + """ Convenience method for returning the previous block's user data. + """ + return self.currentBlock().previous().userData() + + def _get_format(self, token): + """ Returns a QTextCharFormat for token or None. + """ + if token in self._formats: + return self._formats[token] + result = None + for key, value in self._style.style_for_token(token) .items(): + if value: + if result is None: + result = QtGui.QTextCharFormat() + if key == 'color': + result.setForeground(self._get_brush(value)) + elif key == 'bgcolor': + result.setBackground(self._get_brush(value)) + elif key == 'bold': + result.setFontWeight(QtGui.QFont.Bold) + elif key == 'italic': + result.setFontItalic(True) + elif key == 'underline': + result.setUnderlineStyle( + QtGui.QTextCharFormat.SingleUnderline) + elif key == 'sans': + result.setFontStyleHint(QtGui.QFont.SansSerif) + elif key == 'roman': + result.setFontStyleHint(QtGui.QFont.Times) + elif key == 'mono': + result.setFontStyleHint(QtGui.QFont.TypeWriter) + elif key == 'border': + # Borders are normally used for errors. We can't do a border + # so instead we do a wavy underline + result.setUnderlineStyle( + QtGui.QTextCharFormat.WaveUnderline) + result.setUnderlineColor(self._get_color(value)) + self._formats[token] = result + return result + + def _get_brush(self, color): + """ Returns a brush for the color. + """ + result = self._brushes.get(color) + if result is None: + qcolor = self._get_color(color) + result = QtGui.QBrush(qcolor) + self._brushes[color] = result + + return result + + def _get_color(self, color): + qcolor = QtGui.QColor() + qcolor.setRgb(int(color[:2],base=16), + int(color[2:4], base=16), + int(color[4:6], base=16)) + return qcolor +