console_widget.py
650 lines
| 24.1 KiB
| text/x-python
|
PythonLexer
epatters
|
r2602 | # 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 = '' | ||||