console_widget.py
2133 lines
| 84.6 KiB
| text/x-python
|
PythonLexer
|
r3032 | """ An abstract base class for console-type widgets. | ||
|
r2986 | """ | ||
#----------------------------------------------------------------------------- | ||||
# Imports | ||||
#----------------------------------------------------------------------------- | ||||
|
r2602 | # Standard library imports | ||
|
r7554 | import os.path | ||
|
r2843 | import re | ||
|
r2665 | import sys | ||
|
r2763 | from textwrap import dedent | ||
|
r11519 | import time | ||
|
r3304 | from unicodedata import category | ||
|
r8532 | import webbrowser | ||
|
r2602 | |||
# System library imports | ||||
|
r3304 | from IPython.external.qt import QtCore, QtGui | ||
|
r2602 | |||
# Local imports | ||||
|
r4699 | from IPython.config.configurable import LoggingConfigurable | ||
|
r7554 | from IPython.core.inputsplitter import ESC_SEQUENCES | ||
|
r11022 | from IPython.qt.rich_text import HtmlExporter | ||
from IPython.qt.util import MetaQObjectHasTraits, get_font | ||||
|
r4537 | from IPython.utils.text import columnize | ||
|
r5344 | from IPython.utils.traitlets import Bool, Enum, Integer, Unicode | ||
|
r13347 | from .ansi_code_processor import QtAnsiCodeProcessor | ||
from .completion_widget import CompletionWidget | ||||
from .completion_html import CompletionHtml | ||||
from .completion_plain import CompletionPlain | ||||
from .kill_ring import QtKillRing | ||||
|
r2602 | |||
|
r7554 | |||
|
r2986 | #----------------------------------------------------------------------------- | ||
|
r3304 | # Functions | ||
#----------------------------------------------------------------------------- | ||||
|
r7605 | ESCAPE_CHARS = ''.join(ESC_SEQUENCES) | ||
ESCAPE_RE = re.compile("^["+ESCAPE_CHARS+"]+") | ||||
|
r7554 | def commonprefix(items): | ||
|
r7605 | """Get common prefix for completions | ||
|
r7554 | |||
|
r7605 | Return the longest common prefix of a list of strings, but with special | ||
treatment of escape characters that might precede commands in IPython, | ||||
such as %magic functions. Used in tab completion. | ||||
|
r7554 | |||
|
r7605 | For a more general function, see os.path.commonprefix | ||
|
r7554 | """ | ||
# the last item will always have the least leading % symbol | ||||
# min / max are first/last in alphabetical order | ||||
|
r7605 | first_match = ESCAPE_RE.match(min(items)) | ||
last_match = ESCAPE_RE.match(max(items)) | ||||
|
r7554 | # common suffix is (common prefix of reversed items) reversed | ||
|
r7605 | if first_match and last_match: | ||
prefix = os.path.commonprefix((first_match.group(0)[::-1], last_match.group(0)[::-1]))[::-1] | ||||
else: | ||||
prefix = '' | ||||
|
r7554 | |||
|
r7605 | items = [s.lstrip(ESCAPE_CHARS) for s in items] | ||
|
r7554 | return prefix+os.path.commonprefix(items) | ||
|
r3304 | def is_letter_or_number(char): | ||
""" Returns whether the specified unicode character is a letter or a number. | ||||
""" | ||||
cat = category(char) | ||||
return cat.startswith('L') or cat.startswith('N') | ||||
#----------------------------------------------------------------------------- | ||||
|
r2986 | # Classes | ||
#----------------------------------------------------------------------------- | ||||
|
r2602 | |||
|
r13378 | class ConsoleWidget(MetaQObjectHasTraits('NewBase', (LoggingConfigurable, QtGui.QWidget), {})): | ||
|
r4872 | """ An abstract base class for console-type widgets. This class has | ||
|
r2776 | functionality for: | ||
* Maintaining a prompt and editing region | ||||
|
r4872 | * Providing the traditional Unix-style console keyboard shortcuts | ||
|
r2776 | * Performing tab completion | ||
* Paging text | ||||
* Handling ANSI escape codes | ||||
ConsoleWidget also provides a number of utility methods that will be | ||||
convenient to implementors of a console-style widget. | ||||
|
r2602 | """ | ||
|
r3031 | #------ Configuration ------------------------------------------------------ | ||
|
r3971 | ansi_codes = Bool(True, config=True, | ||
help="Whether to process ANSI escape codes." | ||||
) | ||||
|
r5344 | buffer_size = Integer(500, config=True, | ||
|
r3971 | help=""" | ||
The maximum number of lines of text before truncation. Specifying a | ||||
non-positive number disables text truncation (not recommended). | ||||
""" | ||||
) | ||||
|
r11780 | execute_on_complete_input = Bool(True, config=True, | ||
help="""Whether to automatically execute on syntactically complete input. | ||||
If False, Shift-Enter is required to submit each execution. | ||||
|
r11779 | Disabling this is mainly useful for non-Python kernels, | ||
where the completion check would be wrong. | ||||
""" | ||||
) | ||||
|
r7389 | gui_completion = Enum(['plain', 'droplist', 'ncurses'], config=True, | ||
default_value = 'ncurses', | ||||
help=""" | ||||
The type of completer to use. Valid values are: | ||||
|
r11779 | 'plain' : Show the available completion as a text list | ||
Below the editing area. | ||||
|
r7389 | 'droplist': Show the completion in a drop down list navigable | ||
by the arrow keys, and from which you can select | ||||
completion by pressing Return. | ||||
'ncurses' : Show the completion as a text list which is navigable by | ||||
`tab` and arrow keys. | ||||
""" | ||||
|
r3971 | ) | ||
|
r2884 | # NOTE: this value can only be specified during initialization. | ||
|
r3971 | kind = Enum(['plain', 'rich'], default_value='plain', config=True, | ||
help=""" | ||||
|
r4061 | The type of underlying text widget to use. Valid values are 'plain', | ||
which specifies a QPlainTextEdit, and 'rich', which specifies a | ||||
QTextEdit. | ||||
|
r3971 | """ | ||
) | ||||
|
r2884 | # NOTE: this value can only be specified during initialization. | ||
|
r4872 | paging = Enum(['inside', 'hsplit', 'vsplit', 'custom', 'none'], | ||
|
r3971 | default_value='inside', config=True, | ||
help=""" | ||||
The type of paging to use. Valid values are: | ||||
|
r4872 | |||
|
r13504 | 'inside' | ||
The widget pages like a traditional terminal. | ||||
'hsplit' | ||||
When paging is requested, the widget is split horizontally. The top | ||||
pane contains the console, and the bottom pane contains the paged text. | ||||
'vsplit' | ||||
Similar to 'hsplit', except that a vertical splitter is used. | ||||
'custom' | ||||
No action is taken by the widget beyond emitting a | ||||
'custom_page_requested(str)' signal. | ||||
'none' | ||||
The text is written directly to the console. | ||||
|
r3971 | """) | ||
|
r2668 | |||
|
r3972 | font_family = Unicode(config=True, | ||
help="""The font family to use for the console. | ||||
On OSX this defaults to Monaco, on Windows the default is | ||||
Consolas with fallback of Courier, and on other platforms | ||||
|
r3975 | the default is Monospace. | ||
|
r3972 | """) | ||
def _font_family_default(self): | ||||
if sys.platform == 'win32': | ||||
# Consolas ships with Vista/Win7, fallback to Courier if needed | ||||
return 'Consolas' | ||||
elif sys.platform == 'darwin': | ||||
# OSX always has Monaco, no need for a fallback | ||||
return 'Monaco' | ||||
else: | ||||
|
r3975 | # Monospace should always exist, no need for a fallback | ||
return 'Monospace' | ||||
|
r3972 | |||
|
r5344 | font_size = Integer(config=True, | ||
|
r3972 | help="""The font size. If unconfigured, Qt will be entrusted | ||
with the size of the font. | ||||
""") | ||||
|
r10166 | width = Integer(81, config=True, | ||
|
r10167 | help="""The width of the console at start time in number | ||
of characters (will double with `hsplit` paging) | ||||
|
r10166 | """) | ||
height = Integer(25, config=True, | ||||
|
r10167 | help="""The height of the console at start time in number | ||
of characters (will double with `vsplit` paging) | ||||
|
r10166 | """) | ||
|
r2668 | # Whether to override ShortcutEvents for the keybindings defined by this | ||
# widget (Ctrl+n, Ctrl+a, etc). Enable this if you want this widget to take | ||||
# priority (when it has focus) over, e.g., window-level menu shortcuts. | ||||
|
r2884 | override_shortcuts = Bool(False) | ||
|
r7657 | |||
# ------ Custom Qt Widgets ------------------------------------------------- | ||||
# For other projects to easily override the Qt widgets used by the console | ||||
# (e.g. Spyder) | ||||
custom_control = None | ||||
custom_page_control = None | ||||
|
r2668 | |||
|
r3031 | #------ Signals ------------------------------------------------------------ | ||
|
r2736 | # Signals that indicate ConsoleWidget state. | ||
|
r3304 | copy_available = QtCore.Signal(bool) | ||
redo_available = QtCore.Signal(bool) | ||||
undo_available = QtCore.Signal(bool) | ||||
|
r2736 | |||
|
r2776 | # Signal emitted when paging is needed and the paging style has been | ||
# specified as 'custom'. | ||||
|
r3304 | custom_page_requested = QtCore.Signal(object) | ||
|
r2776 | |||
|
r3031 | # Signal emitted when the font is changed. | ||
|
r3304 | font_changed = QtCore.Signal(QtGui.QFont) | ||
|
r3031 | |||
#------ Protected class variables ------------------------------------------ | ||||
|
r7389 | |||
|
r6055 | # control handles | ||
_control = None | ||||
_page_control = None | ||||
_splitter = None | ||||
|
r7389 | |||
|
r3310 | # When the control key is down, these keys are mapped. | ||
|
r2602 | _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_P : QtCore.Qt.Key_Up, | ||||
QtCore.Qt.Key_N : QtCore.Qt.Key_Down, | ||||
|
r5964 | QtCore.Qt.Key_H : QtCore.Qt.Key_Backspace, } | ||
|
r3310 | if not sys.platform == 'darwin': | ||
# On OS X, Ctrl-E already does the right thing, whereas End moves the | ||||
# cursor to the bottom of the buffer. | ||||
_ctrl_down_remap[QtCore.Qt.Key_E] = QtCore.Qt.Key_End | ||||
|
r3031 | |||
|
r3310 | # The shortcuts defined by this widget. We need to keep track of these to | ||
# support 'override_shortcuts' above. | ||||
|
r13378 | _shortcuts = set(_ctrl_down_remap.keys()) | \ | ||
{ QtCore.Qt.Key_C, QtCore.Qt.Key_G, QtCore.Qt.Key_O, | ||||
QtCore.Qt.Key_V } | ||||
|
r2602 | |||
|
r7391 | _temp_buffer_filled = False | ||
|
r7389 | |||
|
r2602 | #--------------------------------------------------------------------------- | ||
|
r2669 | # 'QObject' interface | ||
|
r2602 | #--------------------------------------------------------------------------- | ||
|
r2884 | def __init__(self, parent=None, **kw): | ||
|
r2736 | """ Create a ConsoleWidget. | ||
|
r13587 | Parameters | ||
---------- | ||||
|
r2736 | parent : QWidget, optional [default None] | ||
The parent for this widget. | ||||
""" | ||||
|
r2884 | QtGui.QWidget.__init__(self, parent) | ||
|
r4699 | LoggingConfigurable.__init__(self, **kw) | ||
|
r2736 | |||
|
r5733 | # While scrolling the pager on Mac OS X, it tears badly. The | ||
# NativeGesture is platform and perhaps build-specific hence | ||||
# we take adequate precautions here. | ||||
self._pager_scroll_events = [QtCore.QEvent.Wheel] | ||||
if hasattr(QtCore.QEvent, 'NativeGesture'): | ||||
self._pager_scroll_events.append(QtCore.QEvent.NativeGesture) | ||||
|
r2776 | # Create the layout and underlying text widget. | ||
layout = QtGui.QStackedLayout(self) | ||||
|
r2882 | layout.setContentsMargins(0, 0, 0, 0) | ||
|
r2884 | self._control = self._create_control() | ||
if self.paging in ('hsplit', 'vsplit'): | ||||
|
r2776 | self._splitter = QtGui.QSplitter() | ||
|
r2884 | if self.paging == 'hsplit': | ||
|
r2776 | self._splitter.setOrientation(QtCore.Qt.Horizontal) | ||
else: | ||||
self._splitter.setOrientation(QtCore.Qt.Vertical) | ||||
self._splitter.addWidget(self._control) | ||||
layout.addWidget(self._splitter) | ||||
else: | ||||
layout.addWidget(self._control) | ||||
# Create the paging widget, if necessary. | ||||
|
r2884 | if self.paging in ('inside', 'hsplit', 'vsplit'): | ||
|
r2776 | self._page_control = self._create_page_control() | ||
if self._splitter: | ||||
self._page_control.hide() | ||||
self._splitter.addWidget(self._page_control) | ||||
else: | ||||
layout.addWidget(self._page_control) | ||||
|
r2602 | |||
|
r2715 | # Initialize protected variables. Some variables contain useful state | ||
# information for subclasses; they should be considered read-only. | ||||
|
r4058 | self._append_before_prompt_pos = 0 | ||
|
r2602 | self._ansi_processor = QtAnsiCodeProcessor() | ||
|
r7389 | if self.gui_completion == 'ncurses': | ||
self._completion_widget = CompletionHtml(self) | ||||
elif self.gui_completion == 'droplist': | ||||
self._completion_widget = CompletionWidget(self) | ||||
elif self.gui_completion == 'plain': | ||||
self._completion_widget = CompletionPlain(self) | ||||
|
r2643 | self._continuation_prompt = '> ' | ||
|
r2715 | self._continuation_prompt_html = None | ||
|
r2602 | self._executing = False | ||
|
r3043 | self._filter_resize = False | ||
|
r3361 | self._html_exporter = HtmlExporter(self._control) | ||
|
r3931 | self._input_buffer_executing = '' | ||
self._input_buffer_pending = '' | ||||
|
r3767 | self._kill_ring = QtKillRing(self._control) | ||
|
r2602 | self._prompt = '' | ||
|
r2715 | self._prompt_html = None | ||
|
r2602 | self._prompt_pos = 0 | ||
|
r2866 | self._prompt_sep = '' | ||
|
r2602 | self._reading = False | ||
|
r2706 | self._reading_callback = None | ||
|
r2723 | self._tab_width = 8 | ||
|
r2602 | |||
|
r11519 | # List of strings pending to be appended as plain text in the widget. | ||
# The text is not immediately inserted when available to not | ||||
# choke the Qt event loop with paint events for the widget in | ||||
# case of lots of output from kernel. | ||||
self._pending_insert_text = [] | ||||
# Timer to flush the pending stream messages. The interval is adjusted | ||||
# later based on actual time taken for flushing a screen (buffer_size) | ||||
# of output text. | ||||
self._pending_text_flush_interval = QtCore.QTimer(self._control) | ||||
self._pending_text_flush_interval.setInterval(100) | ||||
self._pending_text_flush_interval.setSingleShot(True) | ||||
self._pending_text_flush_interval.timeout.connect( | ||||
|
r14053 | self._on_flush_pending_stream_timer) | ||
|
r11519 | |||
|
r2736 | # Set a monospaced font. | ||
self.reset_font() | ||||
|
r2602 | |||
|
r3162 | # Configure actions. | ||
action = QtGui.QAction('Print', None) | ||||
action.setEnabled(True) | ||||
|
r3193 | printkey = QtGui.QKeySequence(QtGui.QKeySequence.Print) | ||
if printkey.matches("Ctrl+P") and sys.platform != 'darwin': | ||||
|
r3310 | # Only override the default if there is a collision. | ||
|
r3361 | # Qt ctrl = cmd on OSX, so the match gets a false positive on OSX. | ||
|
r3193 | printkey = "Ctrl+Shift+P" | ||
action.setShortcut(printkey) | ||||
|
r4718 | action.setShortcutContext(QtCore.Qt.WidgetWithChildrenShortcut) | ||
|
r3162 | action.triggered.connect(self.print_) | ||
self.addAction(action) | ||||
|
r5027 | self.print_action = action | ||
|
r3162 | |||
action = QtGui.QAction('Save as HTML/XML', None) | ||||
action.setShortcut(QtGui.QKeySequence.Save) | ||||
|
r4718 | action.setShortcutContext(QtCore.Qt.WidgetWithChildrenShortcut) | ||
|
r3365 | action.triggered.connect(self.export_html) | ||
|
r3162 | self.addAction(action) | ||
|
r5027 | self.export_action = action | ||
|
r4718 | |||
|
r3162 | action = QtGui.QAction('Select All', None) | ||
action.setEnabled(True) | ||||
|
r5140 | selectall = QtGui.QKeySequence(QtGui.QKeySequence.SelectAll) | ||
if selectall.matches("Ctrl+A") and sys.platform != 'darwin': | ||||
# Only override the default if there is a collision. | ||||
# Qt ctrl = cmd on OSX, so the match gets a false positive on OSX. | ||||
selectall = "Ctrl+Shift+A" | ||||
action.setShortcut(selectall) | ||||
|
r4718 | action.setShortcutContext(QtCore.Qt.WidgetWithChildrenShortcut) | ||
|
r3162 | action.triggered.connect(self.select_all) | ||
self.addAction(action) | ||||
|
r5027 | self.select_all_action = action | ||
self.increase_font_size = QtGui.QAction("Bigger Font", | ||||
self, | ||||
|
r5140 | shortcut=QtGui.QKeySequence.ZoomIn, | ||
|
r5192 | shortcutContext=QtCore.Qt.WidgetWithChildrenShortcut, | ||
|
r5027 | statusTip="Increase the font size by one point", | ||
triggered=self._increase_font_size) | ||||
self.addAction(self.increase_font_size) | ||||
self.decrease_font_size = QtGui.QAction("Smaller Font", | ||||
self, | ||||
|
r5140 | shortcut=QtGui.QKeySequence.ZoomOut, | ||
|
r5192 | shortcutContext=QtCore.Qt.WidgetWithChildrenShortcut, | ||
|
r5027 | statusTip="Decrease the font size by one point", | ||
triggered=self._decrease_font_size) | ||||
self.addAction(self.decrease_font_size) | ||||
self.reset_font_size = QtGui.QAction("Normal Font", | ||||
self, | ||||
shortcut="Ctrl+0", | ||||
|
r5192 | shortcutContext=QtCore.Qt.WidgetWithChildrenShortcut, | ||
|
r5027 | statusTip="Restore the Normal font size", | ||
triggered=self.reset_font) | ||||
self.addAction(self.reset_font_size) | ||||
|
r8600 | # Accept drag and drop events here. Drops were already turned off | ||
# in self._control when that widget was created. | ||||
self.setAcceptDrops(True) | ||||
|
r5027 | |||
|
r8600 | #--------------------------------------------------------------------------- | ||
# Drag and drop support | ||||
#--------------------------------------------------------------------------- | ||||
def dragEnterEvent(self, e): | ||||
|
r8601 | if e.mimeData().hasUrls(): | ||
# The link action should indicate to that the drop will insert | ||||
# the file anme. | ||||
e.setDropAction(QtCore.Qt.LinkAction) | ||||
e.accept() | ||||
elif e.mimeData().hasText(): | ||||
|
r8600 | # By changing the action to copy we don't need to worry about | ||
# the user accidentally moving text around in the widget. | ||||
e.setDropAction(QtCore.Qt.CopyAction) | ||||
e.accept() | ||||
def dragMoveEvent(self, e): | ||||
|
r8601 | if e.mimeData().hasUrls(): | ||
pass | ||||
elif e.mimeData().hasText(): | ||||
|
r8600 | cursor = self._control.cursorForPosition(e.pos()) | ||
if self._in_buffer(cursor.position()): | ||||
e.setDropAction(QtCore.Qt.CopyAction) | ||||
self._control.setTextCursor(cursor) | ||||
else: | ||||
e.setDropAction(QtCore.Qt.IgnoreAction) | ||||
e.accept() | ||||
def dropEvent(self, e): | ||||
|
r8601 | if e.mimeData().hasUrls(): | ||
self._keep_cursor_in_buffer() | ||||
cursor = self._control.textCursor() | ||||
filenames = [url.toLocalFile() for url in e.mimeData().urls()] | ||||
text = ', '.join("'" + f.replace("'", "'\"'\"'") + "'" | ||||
for f in filenames) | ||||
self._insert_plain_text_into_buffer(cursor, text) | ||||
elif e.mimeData().hasText(): | ||||
|
r8600 | cursor = self._control.cursorForPosition(e.pos()) | ||
if self._in_buffer(cursor.position()): | ||||
text = e.mimeData().text() | ||||
self._insert_plain_text_into_buffer(cursor, text) | ||||
|
r3162 | |||
|
r2736 | def eventFilter(self, obj, event): | ||
""" Reimplemented to ensure a console-like behavior in the underlying | ||||
|
r2897 | text widgets. | ||
|
r2736 | """ | ||
|
r2772 | etype = event.type() | ||
|
r2897 | if etype == QtCore.QEvent.KeyPress: | ||
# Re-map keys for all filtered widgets. | ||||
key = event.key() | ||||
if self._control_key_down(event.modifiers()) and \ | ||||
key in self._ctrl_down_remap: | ||||
|
r4872 | new_event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress, | ||
|
r2897 | self._ctrl_down_remap[key], | ||
QtCore.Qt.NoModifier) | ||||
QtGui.qApp.sendEvent(obj, new_event) | ||||
return True | ||||
elif obj == self._control: | ||||
return self._event_filter_console_keypress(event) | ||||
elif obj == self._page_control: | ||||
return self._event_filter_page_keypress(event) | ||||
|
r2736 | |||
|
r2937 | # Make middle-click paste safe. | ||
elif etype == QtCore.QEvent.MouseButtonRelease and \ | ||||
event.button() == QtCore.Qt.MidButton and \ | ||||
obj == self._control.viewport(): | ||||
|
r2939 | cursor = self._control.cursorForPosition(event.pos()) | ||
|
r2937 | self._control.setTextCursor(cursor) | ||
self.paste(QtGui.QClipboard.Selection) | ||||
return True | ||||
|
r3009 | # Manually adjust the scrollbars *after* a resize event is dispatched. | ||
|
r3043 | elif etype == QtCore.QEvent.Resize and not self._filter_resize: | ||
self._filter_resize = True | ||||
QtGui.qApp.sendEvent(obj, event) | ||||
self._adjust_scrollbars() | ||||
self._filter_resize = False | ||||
return True | ||||
|
r3009 | |||
|
r2941 | # Override shortcuts for all filtered widgets. | ||
|
r2772 | elif etype == QtCore.QEvent.ShortcutOverride and \ | ||
|
r2961 | self.override_shortcuts and \ | ||
|
r2772 | self._control_key_down(event.modifiers()) and \ | ||
event.key() in self._shortcuts: | ||||
event.accept() | ||||
|
r5733 | # Handle scrolling of the vsplit pager. This hack attempts to solve | ||
# problems with tearing of the help text inside the pager window. This | ||||
# happens only on Mac OS X with both PySide and PyQt. This fix isn't | ||||
# perfect but makes the pager more usable. | ||||
elif etype in self._pager_scroll_events and \ | ||||
|
r5714 | obj == self._page_control: | ||
self._page_control.repaint() | ||||
return True | ||||
|
r8536 | |||
elif etype == QtCore.QEvent.MouseMove: | ||||
anchor = self._control.anchorAt(event.pos()) | ||||
|
r8588 | QtGui.QToolTip.showText(event.globalPos(), anchor) | ||
|
r8536 | |||
|
r2736 | return super(ConsoleWidget, self).eventFilter(obj, event) | ||
|
r2668 | |||
|
r2669 | #--------------------------------------------------------------------------- | ||
|
r2776 | # 'QWidget' interface | ||
#--------------------------------------------------------------------------- | ||||
def sizeHint(self): | ||||
""" Reimplemented to suggest a size that is 80 characters wide and | ||||
25 lines high. | ||||
""" | ||||
font_metrics = QtGui.QFontMetrics(self.font) | ||||
|
r2882 | margin = (self._control.frameWidth() + | ||
self._control.document().documentMargin()) * 2 | ||||
style = self.style() | ||||
splitwidth = style.pixelMetric(QtGui.QStyle.PM_SplitterWidth) | ||||
|
r2776 | |||
|
r2935 | # Note 1: Despite my best efforts to take the various margins into | ||
# account, the width is still coming out a bit too small, so we include | ||||
# a fudge factor of one character here. | ||||
# Note 2: QFontMetrics.maxWidth is not used here or anywhere else due | ||||
# to a Qt bug on certain Mac OS systems where it returns 0. | ||||
|
r10166 | width = font_metrics.width(' ') * self.width + margin | ||
|
r2882 | width += style.pixelMetric(QtGui.QStyle.PM_ScrollBarExtent) | ||
|
r2884 | if self.paging == 'hsplit': | ||
|
r2776 | width = width * 2 + splitwidth | ||
|
r10166 | height = font_metrics.height() * self.height + margin | ||
|
r2884 | if self.paging == 'vsplit': | ||
|
r2776 | height = height * 2 + splitwidth | ||
return QtCore.QSize(width, height) | ||||
#--------------------------------------------------------------------------- | ||||
|
r2736 | # 'ConsoleWidget' public interface | ||
|
r2669 | #--------------------------------------------------------------------------- | ||
|
r2994 | def can_copy(self): | ||
""" Returns whether text can be copied to the clipboard. | ||||
""" | ||||
return self._control.textCursor().hasSelection() | ||||
|
r2990 | def can_cut(self): | ||
""" Returns whether text can be cut to the clipboard. | ||||
""" | ||||
cursor = self._control.textCursor() | ||||
|
r4872 | return (cursor.hasSelection() and | ||
self._in_buffer(cursor.anchor()) and | ||||
|
r2990 | self._in_buffer(cursor.position())) | ||
|
r4872 | |||
|
r2762 | def can_paste(self): | ||
""" Returns whether text can be pasted from the clipboard. | ||||
""" | ||||
if self._control.textInteractionFlags() & QtCore.Qt.TextEditable: | ||||
|
r3304 | return bool(QtGui.QApplication.clipboard().text()) | ||
|
r2762 | return False | ||
|
r2842 | def clear(self, keep_input=True): | ||
|
r4872 | """ Clear the console. | ||
|
r3033 | |||
|
r13587 | Parameters | ||
---------- | ||||
|
r3033 | keep_input : bool, optional (default True) | ||
If set, restores the old input buffer if a new prompt is written. | ||||
|
r2643 | """ | ||
|
r3033 | if self._executing: | ||
self._control.clear() | ||||
else: | ||||
if keep_input: | ||||
input_buffer = self.input_buffer | ||||
self._control.clear() | ||||
self._show_prompt() | ||||
if keep_input: | ||||
self.input_buffer = input_buffer | ||||
|
r2643 | |||
|
r2736 | def copy(self): | ||
|
r2971 | """ Copy the currently selected text to the clipboard. | ||
|
r2602 | """ | ||
|
r5563 | self.layout().currentWidget().copy() | ||
|
r2602 | |||
|
r8588 | def copy_anchor(self, anchor): | ||
|
r8532 | """ Copy anchor text to the clipboard | ||
""" | ||||
|
r8588 | QtGui.QApplication.clipboard().setText(anchor) | ||
|
r8532 | |||
|
r2990 | def cut(self): | ||
""" Copy the currently selected text to the clipboard and delete it | ||||
if it's inside the input buffer. | ||||
""" | ||||
self.copy() | ||||
if self.can_cut(): | ||||
self._control.textCursor().removeSelectedText() | ||||
|
r2688 | def execute(self, source=None, hidden=False, interactive=False): | ||
""" Executes source or the input buffer, possibly prompting for more | ||||
input. | ||||
|
r13587 | Parameters | ||
---------- | ||||
|
r2688 | source : str, optional | ||
The source to execute. If not specified, the input buffer will be | ||||
used. If specified and 'hidden' is False, the input buffer will be | ||||
replaced with the source before execution. | ||||
hidden : bool, optional (default False) | ||||
If set, no output will be shown and the prompt will not be modified. | ||||
In other words, it will be completely invisible to the user that | ||||
an execution has occurred. | ||||
interactive : bool, optional (default False) | ||||
Whether the console is to treat the source as having been manually | ||||
entered by the user. The effect of this parameter depends on the | ||||
subclass implementation. | ||||
|
r13587 | Raises | ||
------ | ||||
|
r2689 | RuntimeError | ||
If incomplete input is given and 'hidden' is True. In this case, | ||||
|
r2771 | it is not possible to prompt for more input. | ||
|
r2689 | |||
|
r13587 | Returns | ||
------- | ||||
|
r2688 | A boolean indicating whether the source was executed. | ||
""" | ||||
|
r2866 | # WARNING: The order in which things happen here is very particular, in | ||
# large part because our syntax highlighting is fragile. If you change | ||||
# something, test carefully! | ||||
|
r2688 | |||
|
r2866 | # Decide what to execute. | ||
if source is None: | ||||
source = self.input_buffer | ||||
|
r2688 | if not hidden: | ||
|
r2866 | # A newline is appended later, but it should be considered part | ||
# of the input buffer. | ||||
source += '\n' | ||||
elif not hidden: | ||||
self.input_buffer = source | ||||
|
r4872 | |||
|
r2866 | # Execute the source or show a continuation prompt if it is incomplete. | ||
|
r11780 | if self.execute_on_complete_input: | ||
|
r11779 | complete = self._is_complete(source, interactive) | ||
else: | ||||
complete = not interactive | ||||
|
r2866 | if hidden: | ||
|
r11780 | if complete or not self.execute_on_complete_input: | ||
|
r2866 | self._execute(source, hidden) | ||
else: | ||||
error = 'Incomplete noninteractive input: "%s"' | ||||
|
r4872 | raise RuntimeError(error % source) | ||
|
r2866 | else: | ||
if complete: | ||||
self._append_plain_text('\n') | ||||
|
r3931 | self._input_buffer_executing = self.input_buffer | ||
|
r2866 | self._executing = True | ||
self._prompt_finished() | ||||
|
r2688 | # The maximum block count is only in effect during execution. | ||
# This ensures that _prompt_pos does not become invalid due to | ||||
# text truncation. | ||||
|
r2736 | self._control.document().setMaximumBlockCount(self.buffer_size) | ||
|
r2864 | |||
# Setting a positive maximum block count will automatically | ||||
# disable the undo/redo history, but just to be safe: | ||||
self._control.setUndoRedoEnabled(False) | ||||
|
r3006 | # Perform actual execution. | ||
|
r2866 | self._execute(source, hidden) | ||
|
r4872 | |||
|
r2866 | else: | ||
# Do this inside an edit block so continuation prompts are | ||||
# removed seamlessly via undo/redo. | ||||
|
r2896 | cursor = self._get_end_cursor() | ||
|
r2866 | cursor.beginEditBlock() | ||
|
r2896 | cursor.insertText('\n') | ||
self._insert_continuation_prompt(cursor) | ||||
|
r2866 | cursor.endEditBlock() | ||
|
r2864 | |||
|
r2914 | # Do not do this inside the edit block. It works as expected | ||
|
r4872 | # when using a QPlainTextEdit control, but does not have an | ||
|
r2914 | # effect when using a QTextEdit. I believe this is a Qt bug. | ||
self._control.moveCursor(QtGui.QTextCursor.End) | ||||
|
r2688 | return complete | ||
|
r2602 | |||
|
r3365 | def export_html(self): | ||
""" Shows a dialog to export HTML/XML in various formats. | ||||
""" | ||||
self._html_exporter.export() | ||||
|
r4061 | def _get_input_buffer(self, force=False): | ||
|
r2665 | """ The text that the user has entered entered at the current prompt. | ||
|
r3931 | |||
If the console is currently executing, the text that is executing will | ||||
always be returned. | ||||
|
r2665 | """ | ||
# If we're executing, the input buffer may not even exist anymore due to | ||||
|
r2615 | # the limit imposed by 'buffer_size'. Therefore, we store it. | ||
|
r4061 | if self._executing and not force: | ||
|
r3931 | return self._input_buffer_executing | ||
|
r2615 | |||
|
r2602 | cursor = self._get_end_cursor() | ||
cursor.setPosition(self._prompt_pos, QtGui.QTextCursor.KeepAnchor) | ||||
|
r3304 | input_buffer = cursor.selection().toPlainText() | ||
|
r2602 | |||
|
r2665 | # Strip out continuation prompts. | ||
|
r2643 | return input_buffer.replace('\n' + self._continuation_prompt, '\n') | ||
|
r2602 | |||
def _set_input_buffer(self, string): | ||||
|
r3931 | """ Sets the text in the input buffer. | ||
If the console is currently executing, this call has no *immediate* | ||||
effect. When the execution is finished, the input buffer will be updated | ||||
appropriately. | ||||
|
r2665 | """ | ||
|
r3931 | # If we're executing, store the text for later. | ||
|
r2764 | if self._executing: | ||
|
r3931 | self._input_buffer_pending = string | ||
return | ||||
|
r2764 | |||
|
r2717 | # Remove old text. | ||
|
r2602 | cursor = self._get_end_cursor() | ||
|
r2764 | cursor.beginEditBlock() | ||
|
r2602 | cursor.setPosition(self._prompt_pos, QtGui.QTextCursor.KeepAnchor) | ||
|
r2717 | cursor.removeSelectedText() | ||
# Insert new text with continuation prompts. | ||||
|
r3048 | self._insert_plain_text_into_buffer(self._get_prompt_cursor(), string) | ||
|
r2764 | cursor.endEditBlock() | ||
|
r2736 | self._control.moveCursor(QtGui.QTextCursor.End) | ||
|
r2602 | |||
input_buffer = property(_get_input_buffer, _set_input_buffer) | ||||
|
r2665 | def _get_font(self): | ||
""" The base font being used by the ConsoleWidget. | ||||
""" | ||||
|
r2736 | return self._control.document().defaultFont() | ||
|
r2665 | |||
def _set_font(self, font): | ||||
""" Sets the base font for the ConsoleWidget to the specified QFont. | ||||
""" | ||||
|
r2716 | font_metrics = QtGui.QFontMetrics(font) | ||
|
r2736 | self._control.setTabStopWidth(self.tab_width * font_metrics.width(' ')) | ||
|
r2716 | |||
|
r2665 | self._completion_widget.setFont(font) | ||
|
r2736 | self._control.document().setDefaultFont(font) | ||
|
r2779 | if self._page_control: | ||
self._page_control.document().setDefaultFont(font) | ||||
|
r2665 | |||
|
r3031 | self.font_changed.emit(font) | ||
|
r2665 | font = property(_get_font, _set_font) | ||
|
r8588 | def open_anchor(self, anchor): | ||
|
r8532 | """ Open selected anchor in the default webbrowser | ||
""" | ||||
|
r8588 | webbrowser.open( anchor ) | ||
|
r8532 | |||
|
r5046 | def paste(self, mode=QtGui.QClipboard.Clipboard): | ||
""" Paste the contents of the clipboard into the input region. | ||||
|
r2937 | |||
|
r13587 | Parameters | ||
---------- | ||||
|
r2937 | mode : QClipboard::Mode, optional [default QClipboard::Clipboard] | ||
Controls which part of the system clipboard is used. This can be | ||||
used to access the selection clipboard in X11 and the Find buffer | ||||
in Mac OS. By default, the regular clipboard is used. | ||||
|
r2736 | """ | ||
|
r2763 | if self._control.textInteractionFlags() & QtCore.Qt.TextEditable: | ||
|
r3048 | # Make sure the paste is safe. | ||
self._keep_cursor_in_buffer() | ||||
cursor = self._control.textCursor() | ||||
|
r3029 | # Remove any trailing newline, which confuses the GUI and forces the | ||
# user to backspace. | ||||
|
r5060 | text = QtGui.QApplication.clipboard().text(mode).rstrip() | ||
|
r3048 | self._insert_plain_text_into_buffer(cursor, dedent(text)) | ||
|
r2736 | |||
|
r3091 | def print_(self, printer = None): | ||
|
r2736 | """ Print the contents of the ConsoleWidget to the specified QPrinter. | ||
""" | ||||
|
r3162 | if (not printer): | ||
|
r3091 | printer = QtGui.QPrinter() | ||
if(QtGui.QPrintDialog(printer).exec_() != QtGui.QDialog.Accepted): | ||||
return | ||||
|
r2736 | self._control.print_(printer) | ||
|
r3001 | def prompt_to_top(self): | ||
""" Moves the prompt to the top of the viewport. | ||||
""" | ||||
if not self._executing: | ||||
|
r3017 | prompt_cursor = self._get_prompt_cursor() | ||
if self._get_cursor().blockNumber() < prompt_cursor.blockNumber(): | ||||
self._set_cursor(prompt_cursor) | ||||
self._set_top_cursor(prompt_cursor) | ||||
|
r4872 | |||
|
r2736 | def redo(self): | ||
""" Redo the last operation. If there is no operation to redo, nothing | ||||
happens. | ||||
""" | ||||
self._control.redo() | ||||
|
r2665 | def reset_font(self): | ||
""" Sets the font to the default fixed-width font for this platform. | ||||
""" | ||||
if sys.platform == 'win32': | ||||
|
r2986 | # Consolas ships with Vista/Win7, fallback to Courier if needed | ||
|
r3972 | fallback = 'Courier' | ||
|
r2665 | elif sys.platform == 'darwin': | ||
|
r3972 | # OSX always has Monaco | ||
fallback = 'Monaco' | ||||
|
r2665 | else: | ||
|
r3975 | # Monospace should always exist | ||
|
r3972 | fallback = 'Monospace' | ||
font = get_font(self.font_family, fallback) | ||||
if self.font_size: | ||||
font.setPointSize(self.font_size) | ||||
else: | ||||
font.setPointSize(QtGui.qApp.font().pointSize()) | ||||
|
r2665 | font.setStyleHint(QtGui.QFont.TypeWriter) | ||
self._set_font(font) | ||||
|
r2723 | |||
|
r3081 | def change_font_size(self, delta): | ||
"""Change the font size by the specified amount (in points). | ||||
""" | ||||
font = self.font | ||||
|
r3922 | size = max(font.pointSize() + delta, 1) # minimum 1 point | ||
font.setPointSize(size) | ||||
|
r3081 | self._set_font(font) | ||
|
r5027 | def _increase_font_size(self): | ||
self.change_font_size(1) | ||||
def _decrease_font_size(self): | ||||
self.change_font_size(-1) | ||||
|
r2736 | def select_all(self): | ||
""" Selects all the text in the buffer. | ||||
""" | ||||
self._control.selectAll() | ||||
|
r2723 | def _get_tab_width(self): | ||
""" The width (in terms of space characters) for tab characters. | ||||
""" | ||||
return self._tab_width | ||||
def _set_tab_width(self, tab_width): | ||||
""" Sets the width (in terms of space characters) for tab characters. | ||||
""" | ||||
font_metrics = QtGui.QFontMetrics(self.font) | ||||
|
r2736 | self._control.setTabStopWidth(tab_width * font_metrics.width(' ')) | ||
|
r2723 | |||
self._tab_width = tab_width | ||||
tab_width = property(_get_tab_width, _set_tab_width) | ||||
|
r2736 | |||
def undo(self): | ||||
""" Undo the last operation. If there is no operation to undo, nothing | ||||
happens. | ||||
""" | ||||
self._control.undo() | ||||
|
r2602 | #--------------------------------------------------------------------------- | ||
# 'ConsoleWidget' abstract interface | ||||
#--------------------------------------------------------------------------- | ||||
|
r2688 | def _is_complete(self, source, interactive): | ||
""" Returns whether 'source' can be executed. When triggered by an | ||||
Enter/Return key press, 'interactive' is True; otherwise, it is | ||||
False. | ||||
""" | ||||
raise NotImplementedError | ||||
def _execute(self, source, hidden): | ||||
""" Execute 'source'. If 'hidden', do not show any output. | ||||
|
r2602 | """ | ||
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 | ||||
|
r3728 | def _up_pressed(self, shift_modifier): | ||
|
r2602 | """ Called when the up key is pressed. Returns whether to continue | ||
processing the event. | ||||
""" | ||||
return True | ||||
|
r3728 | def _down_pressed(self, shift_modifier): | ||
|
r2602 | """ 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 | ||||
#-------------------------------------------------------------------------- | ||||
|
r11169 | def _append_custom(self, insert, input, before_prompt=False, *args, **kwargs): | ||
|
r4056 | """ A low-level method for appending content to the end of the buffer. | ||
|
r4872 | |||
|
r4056 | If 'before_prompt' is enabled, the content will be inserted before the | ||
current prompt, if there is one. | ||||
|
r2736 | """ | ||
|
r4056 | # Determine where to insert the content. | ||
cursor = self._control.textCursor() | ||||
|
r5478 | if before_prompt and (self._reading or not self._executing): | ||
|
r11519 | self._flush_pending_stream() | ||
|
r4058 | cursor.setPosition(self._append_before_prompt_pos) | ||
|
r4056 | else: | ||
|
r11519 | if insert != self._insert_plain_text: | ||
self._flush_pending_stream() | ||||
|
r4056 | cursor.movePosition(QtGui.QTextCursor.End) | ||
start_pos = cursor.position() | ||||
|
r2736 | |||
|
r4056 | # Perform the insertion. | ||
|
r11169 | result = insert(cursor, input, *args, **kwargs) | ||
|
r4056 | |||
# Adjust the prompt position if we have inserted before it. This is safe | ||||
# because buffer truncation is disabled when not executing. | ||||
if before_prompt and not self._executing: | ||||
|
r4058 | diff = cursor.position() - start_pos | ||
self._append_before_prompt_pos += diff | ||||
self._prompt_pos += diff | ||||
|
r4872 | |||
|
r4056 | return result | ||
|
r2715 | |||
|
r8367 | def _append_block(self, block_format=None, before_prompt=False): | ||
""" Appends an new QTextBlock to the end of the console buffer. | ||||
""" | ||||
self._append_custom(self._insert_block, block_format, before_prompt) | ||||
|
r4056 | def _append_html(self, html, before_prompt=False): | ||
""" Appends HTML at the end of the console buffer. | ||||
|
r2736 | """ | ||
|
r4056 | self._append_custom(self._insert_html, html, before_prompt) | ||
|
r2736 | |||
|
r4056 | def _append_html_fetching_plain_text(self, html, before_prompt=False): | ||
""" Appends HTML, then returns the plain text version of it. | ||||
|
r2715 | """ | ||
|
r4056 | return self._append_custom(self._insert_html_fetching_plain_text, | ||
html, before_prompt) | ||||
|
r2715 | |||
|
r4056 | def _append_plain_text(self, text, before_prompt=False): | ||
""" Appends plain text, processing ANSI codes if enabled. | ||||
""" | ||||
self._append_custom(self._insert_plain_text, text, before_prompt) | ||||
|
r2715 | |||
|
r7389 | def _cancel_completion(self): | ||
|
r2939 | """ If text completion is progress, cancel it. | ||
""" | ||||
|
r7393 | self._completion_widget.cancel_completion() | ||
|
r2939 | |||
|
r2919 | def _clear_temporary_buffer(self): | ||
""" Clears the "temporary text" buffer, i.e. all the text following | ||||
the prompt region. | ||||
""" | ||||
|
r2939 | # Select and remove all text below the input buffer. | ||
|
r2919 | cursor = self._get_prompt_cursor() | ||
|
r2939 | prompt = self._continuation_prompt.lstrip() | ||
|
r7818 | if(self._temp_buffer_filled): | ||
self._temp_buffer_filled = False | ||||
while cursor.movePosition(QtGui.QTextCursor.NextBlock): | ||||
temp_cursor = QtGui.QTextCursor(cursor) | ||||
temp_cursor.select(QtGui.QTextCursor.BlockUnderCursor) | ||||
text = temp_cursor.selection().toPlainText().lstrip() | ||||
if not text.startswith(prompt): | ||||
break | ||||
|
r2939 | else: | ||
# We've reached the end of the input buffer and no text follows. | ||||
return | ||||
cursor.movePosition(QtGui.QTextCursor.Left) # Grab the newline. | ||||
cursor.movePosition(QtGui.QTextCursor.End, | ||||
QtGui.QTextCursor.KeepAnchor) | ||||
cursor.removeSelectedText() | ||||
# After doing this, we have no choice but to clear the undo/redo | ||||
# history. Otherwise, the text is not "temporary" at all, because it | ||||
# can be recalled with undo/redo. Unfortunately, Qt does not expose | ||||
# fine-grained control to the undo/redo system. | ||||
if self._control.isUndoRedoEnabled(): | ||||
self._control.setUndoRedoEnabled(False) | ||||
self._control.setUndoRedoEnabled(True) | ||||
|
r2919 | |||
|
r2736 | def _complete_with_items(self, cursor, items): | ||
""" Performs completion with 'items' at the specified cursor location. | ||||
""" | ||||
|
r7389 | self._cancel_completion() | ||
|
r2919 | |||
|
r2736 | if len(items) == 1: | ||
|
r4872 | cursor.setPosition(self._control.textCursor().position(), | ||
|
r2736 | QtGui.QTextCursor.KeepAnchor) | ||
cursor.insertText(items[0]) | ||||
|
r2919 | |||
|
r2736 | elif len(items) > 1: | ||
|
r2919 | current_pos = self._control.textCursor().position() | ||
prefix = commonprefix(items) | ||||
if prefix: | ||||
cursor.setPosition(current_pos, QtGui.QTextCursor.KeepAnchor) | ||||
cursor.insertText(prefix) | ||||
current_pos = cursor.position() | ||||
|
r7389 | cursor.movePosition(QtGui.QTextCursor.Left, n=len(prefix)) | ||
self._completion_widget.show_items(cursor, items) | ||||
def _fill_temporary_buffer(self, cursor, text, html=False): | ||||
"""fill the area below the active editting zone with text""" | ||||
current_pos = self._control.textCursor().position() | ||||
cursor.beginEditBlock() | ||||
self._append_plain_text('\n') | ||||
self._page(text, html=html) | ||||
cursor.endEditBlock() | ||||
cursor.setPosition(current_pos) | ||||
self._control.moveCursor(QtGui.QTextCursor.End) | ||||
self._control.setTextCursor(cursor) | ||||
|
r2919 | |||
|
r7818 | self._temp_buffer_filled = True | ||
|
r7391 | |||
|
r2736 | |||
|
r2990 | def _context_menu_make(self, pos): | ||
""" Creates a context menu for the given QPoint (in widget coordinates). | ||||
""" | ||||
|
r3162 | menu = QtGui.QMenu(self) | ||
|
r2990 | |||
|
r5060 | self.cut_action = menu.addAction('Cut', self.cut) | ||
self.cut_action.setEnabled(self.can_cut()) | ||||
self.cut_action.setShortcut(QtGui.QKeySequence.Cut) | ||||
|
r2990 | |||
|
r5060 | self.copy_action = menu.addAction('Copy', self.copy) | ||
self.copy_action.setEnabled(self.can_copy()) | ||||
self.copy_action.setShortcut(QtGui.QKeySequence.Copy) | ||||
|
r2990 | |||
|
r5060 | self.paste_action = menu.addAction('Paste', self.paste) | ||
self.paste_action.setEnabled(self.can_paste()) | ||||
self.paste_action.setShortcut(QtGui.QKeySequence.Paste) | ||||
|
r2990 | |||
|
r8588 | anchor = self._control.anchorAt(pos) | ||
if anchor: | ||||
|
r8532 | menu.addSeparator() | ||
|
r8588 | self.copy_link_action = menu.addAction( | ||
'Copy Link Address', lambda: self.copy_anchor(anchor=anchor)) | ||||
self.open_link_action = menu.addAction( | ||||
'Open Link', lambda: self.open_anchor(anchor=anchor)) | ||||
|
r8532 | |||
|
r2990 | menu.addSeparator() | ||
|
r5027 | menu.addAction(self.select_all_action) | ||
|
r3162 | |||
menu.addSeparator() | ||||
|
r5027 | menu.addAction(self.export_action) | ||
menu.addAction(self.print_action) | ||||
|
r3162 | |||
|
r2990 | return menu | ||
|
r3163 | def _control_key_down(self, modifiers, include_command=False): | ||
|
r2671 | """ Given a KeyboardModifiers flags object, return whether the Control | ||
|
r2941 | key is down. | ||
|
r2671 | |||
|
r13587 | Parameters | ||
---------- | ||||
|
r2941 | include_command : bool, optional (default True) | ||
Whether to treat the Command key as a (mutually exclusive) synonym | ||||
for Control when in Mac OS. | ||||
""" | ||||
# Note that on Mac OS, ControlModifier corresponds to the Command key | ||||
# while MetaModifier corresponds to the Control key. | ||||
|
r2671 | if sys.platform == 'darwin': | ||
|
r2941 | down = include_command and (modifiers & QtCore.Qt.ControlModifier) | ||
return bool(down) ^ bool(modifiers & QtCore.Qt.MetaModifier) | ||||
else: | ||||
return bool(modifiers & QtCore.Qt.ControlModifier) | ||||
|
r2736 | |||
|
r2884 | def _create_control(self): | ||
|
r2772 | """ Creates and connects the underlying text widget. | ||
|
r2736 | """ | ||
|
r2937 | # Create the underlying control. | ||
|
r7657 | if self.custom_control: | ||
|
r7671 | control = self.custom_control() | ||
|
r7657 | elif self.kind == 'plain': | ||
|
r2971 | control = QtGui.QPlainTextEdit() | ||
|
r2884 | elif self.kind == 'rich': | ||
|
r2971 | control = QtGui.QTextEdit() | ||
|
r2762 | control.setAcceptRichText(False) | ||
|
r8532 | control.setMouseTracking(True) | ||
|
r2937 | |||
|
r8600 | # Prevent the widget from handling drops, as we already provide | ||
# the logic in this class. | ||||
control.setAcceptDrops(False) | ||||
|
r2937 | # Install event filters. The filter on the viewport is needed for | ||
|
r8600 | # mouse events. | ||
|
r2736 | control.installEventFilter(self) | ||
|
r2937 | control.viewport().installEventFilter(self) | ||
# Connect signals. | ||||
|
r3001 | control.customContextMenuRequested.connect( | ||
self._custom_context_menu_requested) | ||||
|
r2736 | control.copyAvailable.connect(self.copy_available) | ||
control.redoAvailable.connect(self.redo_available) | ||||
control.undoAvailable.connect(self.undo_available) | ||||
|
r2937 | |||
|
r3001 | # Hijack the document size change signal to prevent Qt from adjusting | ||
# the viewport's scrollbar. We are relying on an implementation detail | ||||
# of Q(Plain)TextEdit here, which is potentially dangerous, but without | ||||
# this functionality we cannot create a nice terminal interface. | ||||
layout = control.document().documentLayout() | ||||
layout.documentSizeChanged.disconnect() | ||||
layout.documentSizeChanged.connect(self._adjust_scrollbars) | ||||
|
r2937 | # Configure the control. | ||
|
r3765 | control.setAttribute(QtCore.Qt.WA_InputMethodEnabled, True) | ||
|
r2937 | control.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) | ||
|
r2844 | control.setReadOnly(True) | ||
|
r2864 | control.setUndoRedoEnabled(False) | ||
|
r2772 | control.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn) | ||
|
r2736 | return control | ||
|
r2776 | def _create_page_control(self): | ||
""" Creates and connects the underlying paging widget. | ||||
""" | ||||
|
r7657 | if self.custom_page_control: | ||
|
r7671 | control = self.custom_page_control() | ||
|
r7657 | elif self.kind == 'plain': | ||
|
r3014 | control = QtGui.QPlainTextEdit() | ||
elif self.kind == 'rich': | ||||
control = QtGui.QTextEdit() | ||||
|
r2776 | control.installEventFilter(self) | ||
|
r5714 | viewport = control.viewport() | ||
viewport.installEventFilter(self) | ||||
|
r2776 | control.setReadOnly(True) | ||
|
r2864 | control.setUndoRedoEnabled(False) | ||
|
r2776 | control.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn) | ||
return control | ||||
def _event_filter_console_keypress(self, event): | ||||
|
r2736 | """ Filter key events for the underlying text widget to create a | ||
console-like interface. | ||||
""" | ||||
intercepted = False | ||||
cursor = self._control.textCursor() | ||||
position = cursor.position() | ||||
|
r2772 | key = event.key() | ||
ctrl_down = self._control_key_down(event.modifiers()) | ||||
|
r2736 | alt_down = event.modifiers() & QtCore.Qt.AltModifier | ||
shift_down = event.modifiers() & QtCore.Qt.ShiftModifier | ||||
|
r2985 | #------ Special sequences ---------------------------------------------- | ||
|
r2971 | if event.matches(QtGui.QKeySequence.Copy): | ||
self.copy() | ||||
intercepted = True | ||||
|
r2990 | elif event.matches(QtGui.QKeySequence.Cut): | ||
self.cut() | ||||
intercepted = True | ||||
|
r2971 | elif event.matches(QtGui.QKeySequence.Paste): | ||
|
r2736 | self.paste() | ||
intercepted = True | ||||
|
r2985 | #------ Special modifier logic ----------------------------------------- | ||
elif key in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter): | ||||
intercepted = True | ||||
|
r4872 | |||
|
r2985 | # Special handling when tab completing in text mode. | ||
|
r7389 | self._cancel_completion() | ||
|
r2985 | |||
if self._in_buffer(position): | ||||
|
r4620 | # Special handling when a reading a line of raw input. | ||
|
r2985 | if self._reading: | ||
self._append_plain_text('\n') | ||||
self._reading = False | ||||
if self._reading_callback: | ||||
self._reading_callback() | ||||
|
r3021 | # If the input buffer is a single line or there is only | ||
# whitespace after the cursor, execute. Otherwise, split the | ||||
# line with a continuation prompt. | ||||
|
r2985 | elif not self._executing: | ||
cursor.movePosition(QtGui.QTextCursor.End, | ||||
QtGui.QTextCursor.KeepAnchor) | ||||
|
r3304 | at_end = len(cursor.selectedText().strip()) == 0 | ||
|
r3021 | single_line = (self._get_end_cursor().blockNumber() == | ||
self._get_prompt_cursor().blockNumber()) | ||||
if (at_end or shift_down or single_line) and not ctrl_down: | ||||
|
r2985 | self.execute(interactive = not shift_down) | ||
else: | ||||
# Do this inside an edit block for clean undo/redo. | ||||
cursor.beginEditBlock() | ||||
cursor.setPosition(position) | ||||
cursor.insertText('\n') | ||||
self._insert_continuation_prompt(cursor) | ||||
cursor.endEditBlock() | ||||
# Ensure that the whole input buffer is visible. | ||||
# FIXME: This will not be usable if the input buffer is | ||||
# taller than the console widget. | ||||
self._control.moveCursor(QtGui.QTextCursor.End) | ||||
self._control.setTextCursor(cursor) | ||||
#------ Control/Cmd modifier ------------------------------------------- | ||||
|
r2736 | elif ctrl_down: | ||
|
r2924 | if key == QtCore.Qt.Key_G: | ||
self._keyboard_quit() | ||||
intercepted = True | ||||
elif key == QtCore.Qt.Key_K: | ||||
|
r2736 | if self._in_buffer(position): | ||
|
r5729 | cursor.clearSelection() | ||
|
r2736 | cursor.movePosition(QtGui.QTextCursor.EndOfLine, | ||
QtGui.QTextCursor.KeepAnchor) | ||||
|
r2886 | if not cursor.hasSelection(): | ||
# Line deletion (remove continuation prompt) | ||||
cursor.movePosition(QtGui.QTextCursor.NextBlock, | ||||
QtGui.QTextCursor.KeepAnchor) | ||||
cursor.movePosition(QtGui.QTextCursor.Right, | ||||
QtGui.QTextCursor.KeepAnchor, | ||||
len(self._continuation_prompt)) | ||||
|
r3767 | self._kill_ring.kill_cursor(cursor) | ||
|
r5729 | self._set_cursor(cursor) | ||
|
r2736 | intercepted = True | ||
|
r2880 | elif key == QtCore.Qt.Key_L: | ||
|
r3001 | self.prompt_to_top() | ||
|
r2880 | intercepted = True | ||
|
r2897 | elif key == QtCore.Qt.Key_O: | ||
if self._page_control and self._page_control.isVisible(): | ||||
self._page_control.setFocus() | ||||
|
r3081 | intercepted = True | ||
|
r2897 | |||
|
r3468 | elif key == QtCore.Qt.Key_U: | ||
if self._in_buffer(position): | ||||
|
r5729 | cursor.clearSelection() | ||
|
r3468 | start_line = cursor.blockNumber() | ||
if start_line == self._get_prompt_cursor().blockNumber(): | ||||
offset = len(self._prompt) | ||||
else: | ||||
offset = len(self._continuation_prompt) | ||||
cursor.movePosition(QtGui.QTextCursor.StartOfBlock, | ||||
QtGui.QTextCursor.KeepAnchor) | ||||
cursor.movePosition(QtGui.QTextCursor.Right, | ||||
QtGui.QTextCursor.KeepAnchor, offset) | ||||
|
r3767 | self._kill_ring.kill_cursor(cursor) | ||
|
r5729 | self._set_cursor(cursor) | ||
|
r3468 | intercepted = True | ||
|
r2736 | elif key == QtCore.Qt.Key_Y: | ||
|
r3767 | self._keep_cursor_in_buffer() | ||
self._kill_ring.yank() | ||||
|
r2736 | intercepted = True | ||
|
r2936 | elif key in (QtCore.Qt.Key_Backspace, QtCore.Qt.Key_Delete): | ||
|
r4156 | if key == QtCore.Qt.Key_Backspace: | ||
cursor = self._get_word_start_cursor(position) | ||||
else: # key == QtCore.Qt.Key_Delete | ||||
cursor = self._get_word_end_cursor(position) | ||||
cursor.setPosition(position, QtGui.QTextCursor.KeepAnchor) | ||||
self._kill_ring.kill_cursor(cursor) | ||||
|
r2936 | intercepted = True | ||
|
r5973 | |||
|
r5964 | elif key == QtCore.Qt.Key_D: | ||
if len(self.input_buffer) == 0: | ||||
self.exit_requested.emit(self) | ||||
|
r5973 | else: | ||
new_event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress, | ||||
QtCore.Qt.Key_Delete, | ||||
QtCore.Qt.NoModifier) | ||||
QtGui.qApp.sendEvent(self._control, new_event) | ||||
intercepted = True | ||||
|
r2936 | |||
|
r2985 | #------ Alt modifier --------------------------------------------------- | ||
|
r2736 | elif alt_down: | ||
if key == QtCore.Qt.Key_B: | ||||
self._set_cursor(self._get_word_start_cursor(position)) | ||||
intercepted = True | ||||
|
r4872 | |||
|
r2736 | elif key == QtCore.Qt.Key_F: | ||
self._set_cursor(self._get_word_end_cursor(position)) | ||||
intercepted = True | ||||
|
r3767 | elif key == QtCore.Qt.Key_Y: | ||
self._kill_ring.rotate() | ||||
intercepted = True | ||||
|
r2736 | elif key == QtCore.Qt.Key_Backspace: | ||
cursor = self._get_word_start_cursor(position) | ||||
cursor.setPosition(position, QtGui.QTextCursor.KeepAnchor) | ||||
|
r3767 | self._kill_ring.kill_cursor(cursor) | ||
|
r2736 | intercepted = True | ||
elif key == QtCore.Qt.Key_D: | ||||
cursor = self._get_word_end_cursor(position) | ||||
cursor.setPosition(position, QtGui.QTextCursor.KeepAnchor) | ||||
|
r3767 | self._kill_ring.kill_cursor(cursor) | ||
|
r2736 | intercepted = True | ||
|
r2936 | elif key == QtCore.Qt.Key_Delete: | ||
intercepted = True | ||||
|
r2897 | elif key == QtCore.Qt.Key_Greater: | ||
self._control.moveCursor(QtGui.QTextCursor.End) | ||||
intercepted = True | ||||
|
r4872 | |||
|
r2897 | elif key == QtCore.Qt.Key_Less: | ||
self._control.setTextCursor(self._get_prompt_cursor()) | ||||
intercepted = True | ||||
|
r2985 | #------ No modifiers --------------------------------------------------- | ||
|
r2924 | |||
|
r2985 | else: | ||
|
r3082 | if shift_down: | ||
|
r3468 | anchormode = QtGui.QTextCursor.KeepAnchor | ||
|
r3082 | else: | ||
|
r3468 | anchormode = QtGui.QTextCursor.MoveAnchor | ||
|
r4872 | |||
|
r2985 | if key == QtCore.Qt.Key_Escape: | ||
|
r2924 | self._keyboard_quit() | ||
intercepted = True | ||||
|
r2736 | elif key == QtCore.Qt.Key_Up: | ||
|
r3728 | if self._reading or not self._up_pressed(shift_down): | ||
|
r2736 | intercepted = True | ||
else: | ||||
prompt_line = self._get_prompt_cursor().blockNumber() | ||||
intercepted = cursor.blockNumber() <= prompt_line | ||||
elif key == QtCore.Qt.Key_Down: | ||||
|
r3728 | if self._reading or not self._down_pressed(shift_down): | ||
|
r2736 | intercepted = True | ||
else: | ||||
end_line = self._get_end_cursor().blockNumber() | ||||
intercepted = cursor.blockNumber() == end_line | ||||
elif key == QtCore.Qt.Key_Tab: | ||||
|
r2849 | if not self._reading: | ||
|
r5148 | if self._tab_pressed(): | ||
# real tab-key, insert four spaces | ||||
cursor.insertText(' '*4) | ||||
intercepted = True | ||||
|
r2736 | |||
elif key == QtCore.Qt.Key_Left: | ||||
|
r3016 | |||
# Move to the previous line | ||||
line, col = cursor.blockNumber(), cursor.columnNumber() | ||||
if line > self._get_prompt_cursor().blockNumber() and \ | ||||
col == len(self._continuation_prompt): | ||||
|
r4872 | self._control.moveCursor(QtGui.QTextCursor.PreviousBlock, | ||
|
r3468 | mode=anchormode) | ||
|
r4872 | self._control.moveCursor(QtGui.QTextCursor.EndOfBlock, | ||
|
r3468 | mode=anchormode) | ||
|
r3016 | intercepted = True | ||
# Regular left movement | ||||
else: | ||||
intercepted = not self._in_buffer(position - 1) | ||||
|
r4872 | |||
|
r3016 | elif key == QtCore.Qt.Key_Right: | ||
original_block_number = cursor.blockNumber() | ||||
|
r4872 | cursor.movePosition(QtGui.QTextCursor.Right, | ||
|
r3082 | mode=anchormode) | ||
|
r3016 | if cursor.blockNumber() != original_block_number: | ||
cursor.movePosition(QtGui.QTextCursor.Right, | ||||
|
r3082 | n=len(self._continuation_prompt), | ||
mode=anchormode) | ||||
|
r3016 | self._set_cursor(cursor) | ||
intercepted = True | ||||
|
r2736 | |||
elif key == QtCore.Qt.Key_Home: | ||||
start_line = cursor.blockNumber() | ||||
if start_line == self._get_prompt_cursor().blockNumber(): | ||||
start_pos = self._prompt_pos | ||||
else: | ||||
|
r2886 | cursor.movePosition(QtGui.QTextCursor.StartOfBlock, | ||
QtGui.QTextCursor.KeepAnchor) | ||||
|
r2736 | start_pos = cursor.position() | ||
start_pos += len(self._continuation_prompt) | ||||
|
r2886 | cursor.setPosition(position) | ||
|
r2736 | if shift_down and self._in_buffer(position): | ||
|
r2886 | cursor.setPosition(start_pos, QtGui.QTextCursor.KeepAnchor) | ||
|
r2736 | else: | ||
|
r2886 | cursor.setPosition(start_pos) | ||
self._set_cursor(cursor) | ||||
|
r2736 | intercepted = True | ||
|
r2764 | elif key == QtCore.Qt.Key_Backspace: | ||
|
r2736 | |||
# Line deletion (remove continuation prompt) | ||||
|
r2936 | line, col = cursor.blockNumber(), cursor.columnNumber() | ||
|
r2736 | if not self._reading and \ | ||
|
r2936 | col == len(self._continuation_prompt) and \ | ||
line > self._get_prompt_cursor().blockNumber(): | ||||
|
r2864 | cursor.beginEditBlock() | ||
|
r2764 | cursor.movePosition(QtGui.QTextCursor.StartOfBlock, | ||
QtGui.QTextCursor.KeepAnchor) | ||||
|
r2736 | cursor.removeSelectedText() | ||
|
r2785 | cursor.deletePreviousChar() | ||
|
r2864 | cursor.endEditBlock() | ||
|
r2785 | intercepted = True | ||
|
r2736 | |||
# 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: | ||||
|
r2864 | |||
# Line deletion (remove continuation prompt) | ||||
|
r2936 | if not self._reading and self._in_buffer(position) and \ | ||
cursor.atBlockEnd() and not cursor.hasSelection(): | ||||
|
r2864 | cursor.movePosition(QtGui.QTextCursor.NextBlock, | ||
QtGui.QTextCursor.KeepAnchor) | ||||
cursor.movePosition(QtGui.QTextCursor.Right, | ||||
QtGui.QTextCursor.KeepAnchor, | ||||
len(self._continuation_prompt)) | ||||
cursor.removeSelectedText() | ||||
intercepted = True | ||||
# Regular forwards deletion: | ||||
else: | ||||
anchor = cursor.anchor() | ||||
intercepted = (not self._in_buffer(anchor) or | ||||
not self._in_buffer(position)) | ||||
|
r2736 | |||
|
r3308 | # Don't move the cursor if Control/Cmd is pressed to allow copy-paste | ||
|
r4279 | # using the keyboard in any part of the buffer. Also, permit scrolling | ||
|
r4620 | # with Page Up/Down keys. Finally, if we're executing, don't move the | ||
# cursor (if even this made sense, we can't guarantee that the prompt | ||||
# position is still valid due to text truncation). | ||||
|
r4279 | if not (self._control_key_down(event.modifiers(), include_command=True) | ||
|
r4620 | or key in (QtCore.Qt.Key_PageUp, QtCore.Qt.Key_PageDown) | ||
|
r4621 | or (self._executing and not self._reading)): | ||
|
r2736 | self._keep_cursor_in_buffer() | ||
return intercepted | ||||
|
r2715 | |||
|
r2776 | def _event_filter_page_keypress(self, event): | ||
|
r4872 | """ Filter key events for the paging widget to create console-like | ||
|
r2776 | interface. | ||
""" | ||||
key = event.key() | ||||
|
r2897 | ctrl_down = self._control_key_down(event.modifiers()) | ||
alt_down = event.modifiers() & QtCore.Qt.AltModifier | ||||
if ctrl_down: | ||||
if key == QtCore.Qt.Key_O: | ||||
self._control.setFocus() | ||||
intercept = True | ||||
|
r2776 | |||
|
r2897 | elif alt_down: | ||
if key == QtCore.Qt.Key_Greater: | ||||
self._page_control.moveCursor(QtGui.QTextCursor.End) | ||||
intercepted = True | ||||
|
r4872 | |||
|
r2897 | elif key == QtCore.Qt.Key_Less: | ||
self._page_control.moveCursor(QtGui.QTextCursor.Start) | ||||
intercepted = True | ||||
elif key in (QtCore.Qt.Key_Q, QtCore.Qt.Key_Escape): | ||||
|
r2776 | if self._splitter: | ||
self._page_control.hide() | ||||
|
r4976 | self._control.setFocus() | ||
|
r2776 | else: | ||
self.layout().setCurrentWidget(self._control) | ||||
return True | ||||
|
r4912 | elif key in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return, | ||
QtCore.Qt.Key_Tab): | ||||
|
r4872 | new_event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress, | ||
QtCore.Qt.Key_PageDown, | ||||
|
r2897 | QtCore.Qt.NoModifier) | ||
QtGui.qApp.sendEvent(self._page_control, new_event) | ||||
return True | ||||
elif key == QtCore.Qt.Key_Backspace: | ||||
new_event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress, | ||||
|
r4872 | QtCore.Qt.Key_PageUp, | ||
|
r2776 | QtCore.Qt.NoModifier) | ||
QtGui.qApp.sendEvent(self._page_control, new_event) | ||||
return True | ||||
return False | ||||
|
r14053 | def _on_flush_pending_stream_timer(self): | ||
""" Flush the pending stream output and change the | ||||
prompt position appropriately. | ||||
""" | ||||
cursor = self._control.textCursor() | ||||
cursor.movePosition(QtGui.QTextCursor.End) | ||||
pos = cursor.position() | ||||
self._flush_pending_stream() | ||||
cursor.movePosition(QtGui.QTextCursor.End) | ||||
diff = cursor.position() - pos | ||||
if diff > 0: | ||||
self._prompt_pos += diff | ||||
self._append_before_prompt_pos += diff | ||||
|
r11519 | def _flush_pending_stream(self): | ||
""" Flush out pending text into the widget. """ | ||||
text = self._pending_insert_text | ||||
self._pending_insert_text = [] | ||||
buffer_size = self._control.document().maximumBlockCount() | ||||
if buffer_size > 0: | ||||
text = self._get_last_lines_from_list(text, buffer_size) | ||||
text = ''.join(text) | ||||
t = time.time() | ||||
self._insert_plain_text(self._get_end_cursor(), text, flush=True) | ||||
# Set the flush interval to equal the maximum time to update text. | ||||
self._pending_text_flush_interval.setInterval(max(100, | ||||
(time.time()-t)*1000)) | ||||
|
r2725 | def _format_as_columns(self, items, separator=' '): | ||
|
r2723 | """ Transform a list of strings into a single string with columns. | ||
Parameters | ||||
---------- | ||||
|
r2725 | items : sequence of strings | ||
|
r2723 | The strings to process. | ||
separator : str, optional [default is two spaces] | ||||
The string that separates columns. | ||||
Returns | ||||
------- | ||||
The formatted string. | ||||
""" | ||||
|
r2867 | # Calculate the number of characters available. | ||
|
r2772 | width = self._control.viewport().width() | ||
|
r2935 | char_width = QtGui.QFontMetrics(self.font).width(' ') | ||
|
r2867 | displaywidth = max(10, (width / char_width) - 1) | ||
|
r4872 | |||
|
r4537 | return columnize(items, separator, displaywidth) | ||
|
r2723 | |||
|
r2715 | def _get_block_plain_text(self, block): | ||
""" Given a QTextBlock, return its unformatted text. | ||||
""" | ||||
cursor = QtGui.QTextCursor(block) | ||||
cursor.movePosition(QtGui.QTextCursor.StartOfBlock) | ||||
|
r4872 | cursor.movePosition(QtGui.QTextCursor.EndOfBlock, | ||
|
r2715 | QtGui.QTextCursor.KeepAnchor) | ||
|
r3304 | return cursor.selection().toPlainText() | ||
|
r2736 | |||
def _get_cursor(self): | ||||
""" Convenience method that returns a cursor for the current position. | ||||
""" | ||||
return self._control.textCursor() | ||||
|
r4872 | |||
|
r2602 | def _get_end_cursor(self): | ||
""" Convenience method that returns a cursor for the last character. | ||||
""" | ||||
|
r2736 | cursor = self._control.textCursor() | ||
|
r2602 | cursor.movePosition(QtGui.QTextCursor.End) | ||
return cursor | ||||
|
r2841 | def _get_input_buffer_cursor_column(self): | ||
""" Returns the column of the cursor in the input buffer, excluding the | ||||
contribution by the prompt, or -1 if there is no such column. | ||||
""" | ||||
prompt = self._get_input_buffer_cursor_prompt() | ||||
if prompt is None: | ||||
return -1 | ||||
else: | ||||
cursor = self._control.textCursor() | ||||
return cursor.columnNumber() - len(prompt) | ||||
|
r2763 | def _get_input_buffer_cursor_line(self): | ||
|
r3022 | """ Returns the text of the line of the input buffer that contains the | ||
cursor, or None if there is no such line. | ||||
|
r2841 | """ | ||
prompt = self._get_input_buffer_cursor_prompt() | ||||
if prompt is None: | ||||
return None | ||||
else: | ||||
cursor = self._control.textCursor() | ||||
text = self._get_block_plain_text(cursor.block()) | ||||
return text[len(prompt):] | ||||
|
r16580 | |||
def _get_input_buffer_cursor_pos(self): | ||||
"""Return the cursor position within the input buffer.""" | ||||
cursor = self._control.textCursor() | ||||
cursor.setPosition(self._prompt_pos, QtGui.QTextCursor.KeepAnchor) | ||||
input_buffer = cursor.selection().toPlainText() | ||||
# Don't count continuation prompts | ||||
return len(input_buffer.replace('\n' + self._continuation_prompt, '\n')) | ||||
|
r2841 | def _get_input_buffer_cursor_prompt(self): | ||
""" Returns the (plain text) prompt for line of the input buffer that | ||||
contains the cursor, or None if there is no such line. | ||||
|
r2763 | """ | ||
if self._executing: | ||||
return None | ||||
cursor = self._control.textCursor() | ||||
if cursor.position() >= self._prompt_pos: | ||||
if cursor.blockNumber() == self._get_prompt_cursor().blockNumber(): | ||||
|
r2841 | return self._prompt | ||
|
r2763 | else: | ||
|
r2841 | return self._continuation_prompt | ||
|
r2763 | else: | ||
return None | ||||
|
r11519 | def _get_last_lines(self, text, num_lines, return_count=False): | ||
""" Return last specified number of lines of text (like `tail -n`). | ||||
If return_count is True, returns a tuple of clipped text and the | ||||
number of lines in the clipped text. | ||||
""" | ||||
pos = len(text) | ||||
if pos < num_lines: | ||||
if return_count: | ||||
return text, text.count('\n') if return_count else text | ||||
else: | ||||
return text | ||||
i = 0 | ||||
while i < num_lines: | ||||
pos = text.rfind('\n', None, pos) | ||||
if pos == -1: | ||||
pos = None | ||||
break | ||||
i += 1 | ||||
if return_count: | ||||
return text[pos:], i | ||||
else: | ||||
return text[pos:] | ||||
def _get_last_lines_from_list(self, text_list, num_lines): | ||||
""" Return the list of text clipped to last specified lines. | ||||
""" | ||||
ret = [] | ||||
lines_pending = num_lines | ||||
for text in reversed(text_list): | ||||
text, lines_added = self._get_last_lines(text, lines_pending, | ||||
return_count=True) | ||||
ret.append(text) | ||||
lines_pending -= lines_added | ||||
if lines_pending <= 0: | ||||
break | ||||
return ret[::-1] | ||||
|
r2602 | def _get_prompt_cursor(self): | ||
""" Convenience method that returns a cursor for the prompt position. | ||||
""" | ||||
|
r2736 | cursor = self._control.textCursor() | ||
|
r2602 | 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'. | ||||
""" | ||||
|
r2736 | cursor = self._control.textCursor() | ||
|
r2602 | 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.) | ||||
""" | ||||
|
r2736 | document = self._control.document() | ||
|
r2602 | position -= 1 | ||
|
r2764 | while position >= self._prompt_pos and \ | ||
|
r3304 | not is_letter_or_number(document.characterAt(position)): | ||
|
r2602 | position -= 1 | ||
|
r2764 | while position >= self._prompt_pos and \ | ||
|
r3304 | is_letter_or_number(document.characterAt(position)): | ||
|
r2602 | position -= 1 | ||
|
r2736 | cursor = self._control.textCursor() | ||
|
r2602 | 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.) | ||||
""" | ||||
|
r2736 | document = self._control.document() | ||
|
r2602 | end = self._get_end_cursor().position() | ||
while position < end and \ | ||||
|
r3304 | not is_letter_or_number(document.characterAt(position)): | ||
|
r2602 | position += 1 | ||
while position < end and \ | ||||
|
r3304 | is_letter_or_number(document.characterAt(position)): | ||
|
r2602 | position += 1 | ||
|
r2736 | cursor = self._control.textCursor() | ||
|
r2602 | cursor.setPosition(position) | ||
return cursor | ||||
|
r2896 | def _insert_continuation_prompt(self, cursor): | ||
""" Inserts new continuation prompt using the specified cursor. | ||||
""" | ||||
if self._continuation_prompt_html is None: | ||||
self._insert_plain_text(cursor, self._continuation_prompt) | ||||
else: | ||||
self._continuation_prompt = self._insert_html_fetching_plain_text( | ||||
cursor, self._continuation_prompt_html) | ||||
|
r8367 | def _insert_block(self, cursor, block_format=None): | ||
""" Inserts an empty QTextBlock using the specified cursor. | ||||
""" | ||||
if block_format is None: | ||||
block_format = QtGui.QTextBlockFormat() | ||||
cursor.insertBlock(block_format) | ||||
|
r2733 | def _insert_html(self, cursor, html): | ||
|
r2787 | """ Inserts HTML using the specified cursor in such a way that future | ||
|
r2733 | formatting is unaffected. | ||
""" | ||||
|
r2764 | cursor.beginEditBlock() | ||
|
r2733 | cursor.insertHtml(html) | ||
|
r2763 | # After inserting HTML, the text document "remembers" it's in "html | ||
# mode", which means that subsequent calls adding plain text will result | ||||
# in unwanted formatting, lost tab characters, etc. The following code | ||||
|
r2797 | # hacks around this behavior, which I consider to be a bug in Qt, by | ||
# (crudely) resetting the document's style state. | ||||
cursor.movePosition(QtGui.QTextCursor.Left, | ||||
|
r2733 | QtGui.QTextCursor.KeepAnchor) | ||
|
r2763 | if cursor.selection().toPlainText() == ' ': | ||
cursor.removeSelectedText() | ||||
|
r2797 | else: | ||
cursor.movePosition(QtGui.QTextCursor.Right) | ||||
|
r2763 | cursor.insertText(' ', QtGui.QTextCharFormat()) | ||
|
r2764 | cursor.endEditBlock() | ||
|
r2763 | |||
|
r2787 | def _insert_html_fetching_plain_text(self, cursor, html): | ||
""" Inserts HTML using the specified cursor, then returns its plain text | ||||
version. | ||||
""" | ||||
|
r2800 | cursor.beginEditBlock() | ||
cursor.removeSelectedText() | ||||
|
r2787 | start = cursor.position() | ||
self._insert_html(cursor, html) | ||||
end = cursor.position() | ||||
cursor.setPosition(start, QtGui.QTextCursor.KeepAnchor) | ||||
|
r3304 | text = cursor.selection().toPlainText() | ||
|
r2800 | |||
|
r2787 | cursor.setPosition(end) | ||
|
r2800 | cursor.endEditBlock() | ||
|
r2787 | return text | ||
|
r11519 | def _insert_plain_text(self, cursor, text, flush=False): | ||
|
r2776 | """ Inserts plain text using the specified cursor, processing ANSI codes | ||
if enabled. | ||||
""" | ||||
|
r11519 | # maximumBlockCount() can be different from self.buffer_size in | ||
# case input prompt is active. | ||||
buffer_size = self._control.document().maximumBlockCount() | ||||
if self._executing and not flush and \ | ||||
self._pending_text_flush_interval.isActive(): | ||||
self._pending_insert_text.append(text) | ||||
if buffer_size > 0: | ||||
self._pending_insert_text = self._get_last_lines_from_list( | ||||
self._pending_insert_text, buffer_size) | ||||
return | ||||
if self._executing and not self._pending_text_flush_interval.isActive(): | ||||
self._pending_text_flush_interval.start() | ||||
# Clip the text to last `buffer_size` lines. | ||||
if buffer_size > 0: | ||||
text = self._get_last_lines(text, buffer_size) | ||||
|
r2776 | cursor.beginEditBlock() | ||
if self.ansi_codes: | ||||
for substring in self._ansi_processor.split_string(text): | ||||
|
r2998 | for act in self._ansi_processor.actions: | ||
|
r3006 | |||
# Unlike real terminal emulators, we don't distinguish | ||||
# between the screen and the scrollback buffer. A screen | ||||
# erase request clears everything. | ||||
if act.action == 'erase' and act.area == 'screen': | ||||
|
r2783 | cursor.select(QtGui.QTextCursor.Document) | ||
cursor.removeSelectedText() | ||||
|
r3006 | |||
# Simulate a form feed by scrolling just past the last line. | ||||
elif act.action == 'scroll' and act.unit == 'page': | ||||
cursor.insertText('\n') | ||||
cursor.endEditBlock() | ||||
self._set_top_cursor(cursor) | ||||
cursor.joinPreviousEditBlock() | ||||
cursor.deletePreviousChar() | ||||
|
r4872 | |||
|
r5558 | elif act.action == 'carriage-return': | ||
cursor.movePosition( | ||||
cursor.StartOfLine, cursor.KeepAnchor) | ||||
|
r5559 | elif act.action == 'beep': | ||
QtGui.qApp.beep() | ||||
|
r6436 | elif act.action == 'backspace': | ||
if not cursor.atBlockStart(): | ||||
cursor.movePosition( | ||||
cursor.PreviousCharacter, cursor.KeepAnchor) | ||||
elif act.action == 'newline': | ||||
cursor.movePosition(cursor.EndOfLine) | ||||
|
r2776 | format = self._ansi_processor.get_format() | ||
|
r6436 | |||
selection = cursor.selectedText() | ||||
if len(selection) == 0: | ||||
cursor.insertText(substring, format) | ||||
elif substring is not None: | ||||
# BS and CR are treated as a change in print | ||||
# position, rather than a backwards character | ||||
# deletion for output equivalence with (I)Python | ||||
# terminal. | ||||
if len(substring) >= len(selection): | ||||
cursor.insertText(substring, format) | ||||
else: | ||||
old_text = selection[len(substring):] | ||||
cursor.insertText(substring + old_text, format) | ||||
cursor.movePosition(cursor.PreviousCharacter, | ||||
cursor.KeepAnchor, len(old_text)) | ||||
|
r2776 | else: | ||
cursor.insertText(text) | ||||
cursor.endEditBlock() | ||||
|
r3048 | def _insert_plain_text_into_buffer(self, cursor, text): | ||
""" Inserts text into the input buffer using the specified cursor (which | ||||
must be in the input buffer), ensuring that continuation prompts are | ||||
inserted as necessary. | ||||
|
r2763 | """ | ||
|
r3304 | lines = text.splitlines(True) | ||
|
r2763 | if lines: | ||
cursor.beginEditBlock() | ||||
cursor.insertText(lines[0]) | ||||
for line in lines[1:]: | ||||
if self._continuation_prompt_html is None: | ||||
cursor.insertText(self._continuation_prompt) | ||||
else: | ||||
|
r2787 | self._continuation_prompt = \ | ||
self._insert_html_fetching_plain_text( | ||||
cursor, self._continuation_prompt_html) | ||||
|
r2763 | cursor.insertText(line) | ||
cursor.endEditBlock() | ||||
|
r2733 | |||
|
r2847 | def _in_buffer(self, position=None): | ||
""" Returns whether the current cursor (or, if specified, a position) is | ||||
inside the editing region. | ||||
|
r2736 | """ | ||
|
r2764 | cursor = self._control.textCursor() | ||
|
r2847 | if position is None: | ||
position = cursor.position() | ||||
else: | ||||
cursor.setPosition(position) | ||||
|
r2764 | line = cursor.blockNumber() | ||
prompt_line = self._get_prompt_cursor().blockNumber() | ||||
if line == prompt_line: | ||||
return position >= self._prompt_pos | ||||
elif line > prompt_line: | ||||
cursor.movePosition(QtGui.QTextCursor.StartOfBlock) | ||||
prompt_pos = cursor.position() + len(self._continuation_prompt) | ||||
return position >= prompt_pos | ||||
return False | ||||
|
r2736 | |||
def _keep_cursor_in_buffer(self): | ||||
""" Ensures that the cursor is inside the editing region. Returns | ||||
whether the cursor was moved. | ||||
""" | ||||
|
r2847 | moved = not self._in_buffer() | ||
if moved: | ||||
cursor = self._control.textCursor() | ||||
|
r2736 | cursor.movePosition(QtGui.QTextCursor.End) | ||
self._control.setTextCursor(cursor) | ||||
|
r2847 | return moved | ||
|
r2924 | |||
def _keyboard_quit(self): | ||||
""" Cancels the current editing task ala Ctrl-G in Emacs. | ||||
""" | ||||
|
r7391 | if self._temp_buffer_filled : | ||
|
r7389 | self._cancel_completion() | ||
|
r7391 | self._clear_temporary_buffer() | ||
|
r2924 | else: | ||
self.input_buffer = '' | ||||
|
r4872 | |||
|
r3014 | def _page(self, text, html=False): | ||
|
r3017 | """ Displays text using the pager if it exceeds the height of the | ||
viewport. | ||||
|
r3014 | |||
|
r13587 | Parameters | ||
---------- | ||||
|
r3014 | html : bool, optional (default False) | ||
If set, the text will be interpreted as HTML instead of plain text. | ||||
""" | ||||
line_height = QtGui.QFontMetrics(self.font).height() | ||||
minlines = self._control.viewport().height() / line_height | ||||
|
r3017 | if self.paging != 'none' and \ | ||
re.match("(?:[^\n]*\n){%i}" % minlines, text): | ||||
|
r3014 | if self.paging == 'custom': | ||
self.custom_page_requested.emit(text) | ||||
else: | ||||
self._page_control.clear() | ||||
cursor = self._page_control.textCursor() | ||||
if html: | ||||
self._insert_html(cursor, text) | ||||
|
r2843 | else: | ||
self._insert_plain_text(cursor, text) | ||||
|
r3014 | self._page_control.moveCursor(QtGui.QTextCursor.Start) | ||
|
r2843 | |||
|
r3014 | self._page_control.viewport().resize(self._control.size()) | ||
if self._splitter: | ||||
self._page_control.show() | ||||
self._page_control.setFocus() | ||||
else: | ||||
self.layout().setCurrentWidget(self._page_control) | ||||
elif html: | ||||
|
r6903 | self._append_html(text) | ||
|
r3014 | else: | ||
self._append_plain_text(text) | ||||
|
r2736 | |||
|
r6861 | def _set_paging(self, paging): | ||
""" | ||||
Change the pager to `paging` style. | ||||
|
r13587 | Parameters | ||
---------- | ||||
|
r6861 | paging : string | ||
Either "hsplit", "vsplit", or "inside" | ||||
""" | ||||
if self._splitter is None: | ||||
raise NotImplementedError("""can only switch if --paging=hsplit or | ||||
--paging=vsplit is used.""") | ||||
if paging == 'hsplit': | ||||
self._splitter.setOrientation(QtCore.Qt.Horizontal) | ||||
elif paging == 'vsplit': | ||||
self._splitter.setOrientation(QtCore.Qt.Vertical) | ||||
elif paging == 'inside': | ||||
raise NotImplementedError("""switching to 'inside' paging not | ||||
supported yet.""") | ||||
else: | ||||
raise ValueError("unknown paging method '%s'" % paging) | ||||
self.paging = paging | ||||
|
r3001 | 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._control.setReadOnly(True) | ||||
self._prompt_finished_hook() | ||||
|
r2602 | def _prompt_started(self): | ||
""" Called immediately after a new prompt is displayed. | ||||
""" | ||||
|
r4872 | # Temporarily disable the maximum block count to permit undo/redo and | ||
|
r2688 | # to ensure that the prompt position does not change due to truncation. | ||
|
r3006 | self._control.document().setMaximumBlockCount(0) | ||
|
r2736 | self._control.setUndoRedoEnabled(True) | ||
|
r2615 | |||
|
r3765 | # Work around bug in QPlainTextEdit: input method is not re-enabled | ||
# when read-only is disabled. | ||||
|
r2736 | self._control.setReadOnly(False) | ||
|
r3765 | self._control.setAttribute(QtCore.Qt.WA_InputMethodEnabled, True) | ||
|
r4061 | if not self._reading: | ||
self._executing = False | ||||
|
r2602 | self._prompt_started_hook() | ||
|
r3931 | # If the input buffer has changed while executing, load it. | ||
if self._input_buffer_pending: | ||||
self.input_buffer = self._input_buffer_pending | ||||
self._input_buffer_pending = '' | ||||
self._control.moveCursor(QtGui.QTextCursor.End) | ||||
|
r2706 | def _readline(self, prompt='', callback=None): | ||
|
r4872 | """ Reads one line of input from the user. | ||
|
r2706 | |||
Parameters | ||||
---------- | ||||
prompt : str, optional | ||||
The prompt to print before reading the line. | ||||
callback : callable, optional | ||||
A callback to execute with the read line. If not specified, input is | ||||
read *synchronously* and this method does not return until it has | ||||
been read. | ||||
Returns | ||||
------- | ||||
If a callback is specified, returns nothing. Otherwise, returns the | ||||
input string with the trailing newline stripped. | ||||
|
r2705 | """ | ||
|
r2706 | if self._reading: | ||
raise RuntimeError('Cannot read a line. Widget is already reading.') | ||||
if not callback and not self.isVisible(): | ||||
# If the user cannot see the widget, this function cannot return. | ||||
|
r2866 | raise RuntimeError('Cannot synchronously read a line if the widget ' | ||
|
r2706 | 'is not visible!') | ||
|
r2705 | |||
self._reading = True | ||||
|
r2708 | self._show_prompt(prompt, newline=False) | ||
|
r2706 | |||
if callback is None: | ||||
self._reading_callback = None | ||||
while self._reading: | ||||
QtCore.QCoreApplication.processEvents() | ||||
|
r4061 | return self._get_input_buffer(force=True).rstrip('\n') | ||
|
r2706 | |||
else: | ||||
self._reading_callback = lambda: \ | ||||
|
r4061 | callback(self._get_input_buffer(force=True).rstrip('\n')) | ||
|
r2705 | |||
|
r2715 | def _set_continuation_prompt(self, prompt, html=False): | ||
""" Sets the continuation prompt. | ||||
Parameters | ||||
---------- | ||||
prompt : str | ||||
The prompt to show when more input is needed. | ||||
html : bool, optional (default False) | ||||
If set, the prompt will be inserted as formatted HTML. Otherwise, | ||||
the prompt will be treated as plain text, though ANSI color codes | ||||
will be handled. | ||||
""" | ||||
if html: | ||||
self._continuation_prompt_html = prompt | ||||
else: | ||||
self._continuation_prompt = prompt | ||||
self._continuation_prompt_html = None | ||||
|
r4872 | |||
|
r2736 | def _set_cursor(self, cursor): | ||
""" Convenience method to set the current cursor. | ||||
""" | ||||
self._control.setTextCursor(cursor) | ||||
|
r2715 | |||
|
r3006 | def _set_top_cursor(self, cursor): | ||
""" Scrolls the viewport so that the specified cursor is at the top. | ||||
""" | ||||
scrollbar = self._control.verticalScrollBar() | ||||
scrollbar.setValue(scrollbar.maximum()) | ||||
original_cursor = self._control.textCursor() | ||||
self._control.setTextCursor(cursor) | ||||
self._control.ensureCursorVisible() | ||||
self._control.setTextCursor(original_cursor) | ||||
|
r2715 | def _show_prompt(self, prompt=None, html=False, newline=True): | ||
|
r2708 | """ Writes a new prompt at the end of the buffer. | ||
Parameters | ||||
---------- | ||||
prompt : str, optional | ||||
The prompt to show. If not specified, the previous prompt is used. | ||||
|
r2715 | html : bool, optional (default False) | ||
Only relevant when a prompt is specified. If set, the prompt will | ||||
be inserted as formatted HTML. Otherwise, the prompt will be treated | ||||
as plain text, though ANSI color codes will be handled. | ||||
|
r2708 | newline : bool, optional (default True) | ||
|
r4872 | If set, a new line will be written before showing the prompt if | ||
|
r2708 | there is not already a newline at the end of the buffer. | ||
""" | ||||
|
r4058 | # Save the current end position to support _append*(before_prompt=True). | ||
|
r14053 | self._flush_pending_stream() | ||
|
r4058 | cursor = self._get_end_cursor() | ||
self._append_before_prompt_pos = cursor.position() | ||||
|
r4872 | |||
|
r2715 | # Insert a preliminary newline, if necessary. | ||
|
r4058 | if newline and cursor.position() > 0: | ||
|
r4872 | cursor.movePosition(QtGui.QTextCursor.Left, | ||
|
r4058 | QtGui.QTextCursor.KeepAnchor) | ||
if cursor.selection().toPlainText() != '\n': | ||||
|
r8367 | self._append_block() | ||
|
r14053 | self._append_before_prompt_pos += 1 | ||
|
r2705 | |||
|
r2715 | # Write the prompt. | ||
|
r2866 | self._append_plain_text(self._prompt_sep) | ||
|
r2715 | if prompt is None: | ||
if self._prompt_html is None: | ||||
|
r2736 | self._append_plain_text(self._prompt) | ||
|
r2715 | else: | ||
|
r2736 | self._append_html(self._prompt_html) | ||
|
r2715 | else: | ||
if html: | ||||
self._prompt = self._append_html_fetching_plain_text(prompt) | ||||
self._prompt_html = prompt | ||||
else: | ||||
|
r2736 | self._append_plain_text(prompt) | ||
|
r2715 | self._prompt = prompt | ||
self._prompt_html = None | ||||
|
r2705 | |||
|
r2602 | self._prompt_pos = self._get_end_cursor().position() | ||
self._prompt_started() | ||||
|
r2919 | #------ Signal handlers ---------------------------------------------------- | ||
|
r3001 | def _adjust_scrollbars(self): | ||
""" Expands the vertical scrollbar beyond the range set by Qt. | ||||
""" | ||||
# This code is adapted from _q_adjustScrollbars in qplaintextedit.cpp | ||||
# and qtextedit.cpp. | ||||
document = self._control.document() | ||||
scrollbar = self._control.verticalScrollBar() | ||||
viewport_height = self._control.viewport().height() | ||||
if isinstance(self._control, QtGui.QPlainTextEdit): | ||||
|
r3007 | maximum = max(0, document.lineCount() - 1) | ||
|
r3001 | step = viewport_height / self._control.fontMetrics().lineSpacing() | ||
else: | ||||
|
r3007 | # QTextEdit does not do line-based layout and blocks will not in | ||
# general have the same height. Therefore it does not make sense to | ||||
# attempt to scroll in line height increments. | ||||
maximum = document.size().height() | ||||
|
r3001 | step = viewport_height | ||
|
r3007 | diff = maximum - scrollbar.maximum() | ||
scrollbar.setRange(0, maximum) | ||||
|
r3001 | scrollbar.setPageStep(step) | ||
|
r3361 | |||
|
r3007 | # Compensate for undesirable scrolling that occurs automatically due to | ||
# maximumBlockCount() text truncation. | ||||
|
r3017 | if diff < 0 and document.blockCount() == document.maximumBlockCount(): | ||
|
r3007 | scrollbar.setValue(scrollbar.value() + diff) | ||
|
r3001 | |||
def _custom_context_menu_requested(self, pos): | ||||
""" Shows a context menu at the given QPoint (in widget coordinates). | ||||
""" | ||||
menu = self._context_menu_make(pos) | ||||
menu.exec_(self._control.mapToGlobal(pos)) | ||||