From 1422d277055ed26b7a367e6ce2c1649d32548ee9 2012-06-10 17:48:32 From: Matthias BUSSONNIER Date: 2012-06-10 17:48:32 Subject: [PATCH] new completer for qtconsole. add a completer to the qtconsole that is navigable by arraow keys and tab. One need to call it twice to get it on focus and be able to select completion with Return. looks like zsh completer, not the gui drop down list of --gui-completer. This also try to split the completion logic from console_widget, and try to keep the old completer qui around. The plain completer that never takes focus back, and the QlistWidget completer. to switch between the 3, the --gui-completion flag as been changed to take an argument (plain, droplist, ncurses). --- diff --git a/IPython/frontend/qt/console/completion_html.py b/IPython/frontend/qt/console/completion_html.py new file mode 100644 index 0000000..0875627 --- /dev/null +++ b/IPython/frontend/qt/console/completion_html.py @@ -0,0 +1,226 @@ +# System library imports +from IPython.external.qt import QtCore, QtGui +import IPython.utils.html_utils as html_utils + + +class CompletionHtml(QtGui.QWidget): + """ A widget for tab completion, navigable by arrow keys """ + + #-------------------------------------------------------------------------- + # 'QObject' interface + #-------------------------------------------------------------------------- + + _items = () + _index = (0, 0) + _consecutive_tab = 0 + _size = (1, 1) + _old_cursor = None + _start_position = 0 + + def __init__(self, console_widget): + """ Create a completion widget that is attached to the specified Qt + text edit widget. + """ + assert isinstance(console_widget._control, (QtGui.QTextEdit, QtGui.QPlainTextEdit)) + super(CompletionHtml, self).__init__() + + self._text_edit = console_widget._control + self._console_widget = console_widget + self._text_edit.installEventFilter(self) + + # Ensure that the text edit keeps focus when widget is displayed. + self.setFocusProxy(self._text_edit) + + + def eventFilter(self, obj, event): + """ Reimplemented to handle keyboard input and to auto-hide when the + text edit loses focus. + """ + if obj == self._text_edit: + etype = event.type() + if etype == QtCore.QEvent.KeyPress: + key = event.key() + if self._consecutive_tab == 0 and key in (QtCore.Qt.Key_Tab,): + return False + elif self._consecutive_tab == 1 and key in (QtCore.Qt.Key_Tab,): + # ok , called twice, we grab focus, and show the cursor + self._consecutive_tab = self._consecutive_tab+1 + self._update_list() + return True + elif self._consecutive_tab == 2: + if key in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter): + self._complete_current() + return True + if key in (QtCore.Qt.Key_Tab,): + self.select_right() + self._update_list() + return True + elif key in ( QtCore.Qt.Key_Down,): + self.select_down() + self._update_list() + return True + elif key in (QtCore.Qt.Key_Right,): + self.select_right() + self._update_list() + return True + elif key in ( QtCore.Qt.Key_Up,): + self.select_up() + self._update_list() + return True + elif key in ( QtCore.Qt.Key_Left,): + self.select_left() + self._update_list() + return True + else : + self._cancel_completion() + else: + self._cancel_completion() + + elif etype == QtCore.QEvent.FocusOut: + self._cancel_completion() + + return super(CompletionHtml, self).eventFilter(obj, event) + + #-------------------------------------------------------------------------- + # 'CompletionHtml' interface + #-------------------------------------------------------------------------- + def _cancel_completion(self): + """Cancel the completion, reseting internal variable, clearing buffer """ + self._consecutive_tab = 0 + self._console_widget._clear_temporary_buffer() + self._index = (0, 0) + + # + # ... 2 4 4 4 4 4 4 4 4 4 4 4 4 + # 2 2 4 4 4 4 4 4 4 4 4 4 4 4 + # + #2 2 x x x x x x x x x x x 5 5 + #6 6 x x x x x x x x x x x 5 5 + #6 6 x x x x x x x x x x ? 5 5 + #6 6 x x x x x x x x x x ? 1 1 + # + #3 3 3 3 3 3 3 3 3 3 3 3 1 1 1 ... + #3 3 3 3 3 3 3 3 3 3 3 3 1 1 1 ... + def _select_index(self, row, col): + """Change the selection index, and make sure it stays in the right range + + A little more complicated than just dooing modulo the number of row columns + to be sure to cycle through all element. + + horizontaly, the element are maped like this : + to r <-- a b c d e f --> to g + to f <-- g h i j k l --> to m + to l <-- m n o p q r --> to a + + and vertically + a d g j m p + b e h k n q + c f i l o r + """ + + nr, nc = self._size + nr = nr-1 + nc = nc-1 + + # case 1 + if (row > nr and col >= nc) or (row >= nr and col > nc): + self._select_index(0, 0) + # case 2 + elif (row <= 0 and col < 0) or (row < 0 and col <= 0): + self._select_index(nr, nc) + # case 3 + elif row > nr : + self._select_index(0, col+1) + # case 4 + elif row < 0 : + self._select_index(nr, col-1) + # case 5 + elif col > nc : + self._select_index(row+1, 0) + # case 6 + elif col < 0 : + self._select_index(row-1, nc) + elif 0 <= row and row <= nr and 0 <= col and col <= nc : + self._index = (row, col) + else : + raise NotImplementedError("you'r trying to go where no completion\ + have gone before : %d:%d (%d:%d)"%(row, col, nr, nc) ) + + + + def select_up(self): + """move cursor up""" + r, c = self._index + self._select_index(r-1, c) + + def select_down(self): + """move cursor down""" + r, c = self._index + self._select_index(r+1, c) + + def select_left(self): + """move cursor left""" + r, c = self._index + self._select_index(r, c-1) + + def select_right(self): + """move cursor right""" + r, c = self._index + self._select_index(r, c+1) + + def show_items(self, cursor, items): + """ Shows the completion widget with 'items' at the position specified + by 'cursor'. + """ + if not items : + return + self._start_position = cursor.position() + self._consecutive_tab = 1 + ci = html_utils.columnize_info(items, empty=' ') + self._items = ci['item_matrix'] + self._size = (ci['rows_number'], ci['columns_number']) + self._old_cursor = cursor + self._index = (0, 0) + self._update_list(hilight=False) + + + def _update_list(self, hilight=True): + """ update the list of completion and hilight the currently selected completion """ + if len(self._items) > 100: + items = self._items[:100] + else : + items = self._items + items_m = items + + self._console_widget._clear_temporary_buffer() + if(hilight): + strng = html_utils.html_tableify(items_m, select=self._index) + else: + strng = html_utils.html_tableify(items_m, select=None) + self._console_widget._fill_temporary_buffer(self._old_cursor, strng, html=True) + + #-------------------------------------------------------------------------- + # Protected interface + #-------------------------------------------------------------------------- + + def _complete_current(self): + """ Perform the completion with the currently selected item. + """ + i = self._index + item = self._items[i[0]][i[1]] + item = item.strip() + if item : + self._current_text_cursor().insertText(item) + self._cancel_completion() + + def _current_text_cursor(self): + """ Returns a cursor with text between the start position and the + current position selected. + """ + cursor = self._text_edit.textCursor() + if cursor.position() >= self._start_position: + cursor.setPosition(self._start_position, + QtGui.QTextCursor.KeepAnchor) + return cursor + + diff --git a/IPython/frontend/qt/console/completion_plain.py b/IPython/frontend/qt/console/completion_plain.py new file mode 100644 index 0000000..8db0069 --- /dev/null +++ b/IPython/frontend/qt/console/completion_plain.py @@ -0,0 +1,73 @@ +# System library imports +from IPython.external.qt import QtCore, QtGui +import IPython.utils.html_utils as html_utils + + +class CompletionPlain(QtGui.QWidget): + """ A widget for tab completion, navigable by arrow keys """ + + #-------------------------------------------------------------------------- + # 'QObject' interface + #-------------------------------------------------------------------------- + + _items = () + _index = (0, 0) + _old_cursor = None + + def __init__(self, console_widget): + """ Create a completion widget that is attached to the specified Qt + text edit widget. + """ + assert isinstance(console_widget._control, (QtGui.QTextEdit, QtGui.QPlainTextEdit)) + super(CompletionPlain, self).__init__() + + self._text_edit = console_widget._control + self._console_widget = console_widget + + self._text_edit.installEventFilter(self) + + def eventFilter(self, obj, event): + """ Reimplemented to handle keyboard input and to auto-hide when the + text edit loses focus. + """ + if obj == self._text_edit: + etype = event.type() + + if etype == QtCore.QEvent.KeyPress: + self._cancel_completion() + + return super(CompletionPlain, self).eventFilter(obj, event) + + #-------------------------------------------------------------------------- + # 'CompletionPlain' interface + #-------------------------------------------------------------------------- + def _cancel_completion(self): + """Cancel the completion, reseting internal variable, clearing buffer """ + self._console_widget._clear_temporary_buffer() + self._index = (0, 0) + + + def show_items(self, cursor, items): + """ Shows the completion widget with 'items' at the position specified + by 'cursor'. + """ + if not items : + return + + ci = html_utils.columnize_info(items, empty=' ') + self._items = ci['item_matrix'] + self._old_cursor = cursor + self._update_list() + + + def _update_list(self): + """ update the list of completion and hilight the currently selected completion """ + if len(self._items) > 100: + items = self._items[:100] + else : + items = self._items + items_m = items + + self._console_widget._clear_temporary_buffer() + strng = html_utils.html_tableify(items_m, select=None) + self._console_widget._fill_temporary_buffer(self._old_cursor, strng, html=True) diff --git a/IPython/frontend/qt/console/completion_widget.py b/IPython/frontend/qt/console/completion_widget.py index 194b843..a3846d7 100644 --- a/IPython/frontend/qt/console/completion_widget.py +++ b/IPython/frontend/qt/console/completion_widget.py @@ -10,10 +10,11 @@ class CompletionWidget(QtGui.QListWidget): # 'QObject' interface #-------------------------------------------------------------------------- - def __init__(self, text_edit): + def __init__(self, console_widget): """ Create a completion widget that is attached to the specified Qt text edit widget. """ + text_edit = console_widget._text_edit assert isinstance(text_edit, (QtGui.QTextEdit, QtGui.QPlainTextEdit)) super(CompletionWidget, self).__init__() diff --git a/IPython/frontend/qt/console/console_widget.py b/IPython/frontend/qt/console/console_widget.py index e4b3d6c..d01181a 100644 --- a/IPython/frontend/qt/console/console_widget.py +++ b/IPython/frontend/qt/console/console_widget.py @@ -5,7 +5,6 @@ #----------------------------------------------------------------------------- # Standard library imports -import os from os.path import commonprefix import re import sys @@ -23,6 +22,8 @@ from IPython.utils.text import columnize from IPython.utils.traitlets import Bool, Enum, Integer, Unicode 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 #----------------------------------------------------------------------------- @@ -65,10 +66,19 @@ class ConsoleWidget(LoggingConfigurable, QtGui.QWidget): non-positive number disables text truncation (not recommended). """ ) - gui_completion = Bool(False, config=True, - help=""" - Use a list widget instead of plain text output for tab completion. - """ + gui_completion = Enum(['plain', 'droplist', 'ncurses'], config=True, + default_value = 'ncurses', + help=""" + The type of completer to use. Valid values are: + + 'plain' : Show the availlable completion as a text list + Below the editting area. + '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. + """ ) # NOTE: this value can only be specified during initialization. kind = Enum(['plain', 'rich'], default_value='plain', config=True, @@ -137,12 +147,12 @@ class ConsoleWidget(LoggingConfigurable, QtGui.QWidget): font_changed = QtCore.Signal(QtGui.QFont) #------ Protected class variables ------------------------------------------ - + # control handles _control = None _page_control = None _splitter = None - + # When the control key is down, these keys are mapped. _ctrl_down_remap = { QtCore.Qt.Key_B : QtCore.Qt.Key_Left, QtCore.Qt.Key_F : QtCore.Qt.Key_Right, @@ -161,6 +171,8 @@ class ConsoleWidget(LoggingConfigurable, QtGui.QWidget): [ QtCore.Qt.Key_C, QtCore.Qt.Key_G, QtCore.Qt.Key_O, QtCore.Qt.Key_V ]) + _is_completing = False + #--------------------------------------------------------------------------- # 'QObject' interface #--------------------------------------------------------------------------- @@ -211,7 +223,13 @@ class ConsoleWidget(LoggingConfigurable, QtGui.QWidget): # information for subclasses; they should be considered read-only. self._append_before_prompt_pos = 0 self._ansi_processor = QtAnsiCodeProcessor() - self._completion_widget = CompletionWidget(self._control) + 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) + self._continuation_prompt = '> ' self._continuation_prompt_html = None self._executing = False @@ -228,7 +246,6 @@ class ConsoleWidget(LoggingConfigurable, QtGui.QWidget): self._reading = False self._reading_callback = None self._tab_width = 8 - self._text_completing_pos = 0 # Set a monospaced font. self.reset_font() @@ -823,12 +840,10 @@ class ConsoleWidget(LoggingConfigurable, QtGui.QWidget): """ self._append_custom(self._insert_plain_text, text, before_prompt) - def _cancel_text_completion(self): + def _cancel_completion(self): """ If text completion is progress, cancel it. """ - if self._text_completing_pos: - self._clear_temporary_buffer() - self._text_completing_pos = 0 + self._completion_widget._cancel_completion() def _clear_temporary_buffer(self): """ Clears the "temporary text" buffer, i.e. all the text following @@ -862,7 +877,7 @@ class ConsoleWidget(LoggingConfigurable, QtGui.QWidget): def _complete_with_items(self, cursor, items): """ Performs completion with 'items' at the specified cursor location. """ - self._cancel_text_completion() + self._cancel_completion() if len(items) == 1: cursor.setPosition(self._control.textCursor().position(), @@ -877,19 +892,25 @@ class ConsoleWidget(LoggingConfigurable, QtGui.QWidget): cursor.insertText(prefix) current_pos = cursor.position() - if self.gui_completion: - cursor.movePosition(QtGui.QTextCursor.Left, n=len(prefix)) - self._completion_widget.show_items(cursor, items) - else: - cursor.beginEditBlock() - self._append_plain_text('\n') - self._page(self._format_as_columns(items)) - cursor.endEditBlock() + cursor.movePosition(QtGui.QTextCursor.Left, n=len(prefix)) + self._completion_widget.show_items(cursor, items) + self._is_completing = True + + + 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) - cursor.setPosition(current_pos) - self._control.moveCursor(QtGui.QTextCursor.End) - self._control.setTextCursor(cursor) - self._text_completing_pos = current_pos def _context_menu_make(self, pos): """ Creates a context menu for the given QPoint (in widget coordinates). @@ -951,7 +972,6 @@ class ConsoleWidget(LoggingConfigurable, QtGui.QWidget): control.viewport().installEventFilter(self) # Connect signals. - control.cursorPositionChanged.connect(self._cursor_position_changed) control.customContextMenuRequested.connect( self._custom_context_menu_requested) control.copyAvailable.connect(self.copy_available) @@ -1021,7 +1041,7 @@ class ConsoleWidget(LoggingConfigurable, QtGui.QWidget): intercepted = True # Special handling when tab completing in text mode. - self._cancel_text_completion() + self._cancel_completion() if self._in_buffer(position): # Special handling when a reading a line of raw input. @@ -1634,8 +1654,8 @@ class ConsoleWidget(LoggingConfigurable, QtGui.QWidget): def _keyboard_quit(self): """ Cancels the current editing task ala Ctrl-G in Emacs. """ - if self._text_completing_pos: - self._cancel_text_completion() + if self._is_completing: + self._cancel_completion() else: self.input_buffer = '' @@ -1853,24 +1873,6 @@ class ConsoleWidget(LoggingConfigurable, QtGui.QWidget): if diff < 0 and document.blockCount() == document.maximumBlockCount(): scrollbar.setValue(scrollbar.value() + diff) - def _cursor_position_changed(self): - """ Clears the temporary buffer based on the cursor position. - """ - if self._text_completing_pos: - document = self._control.document() - if self._text_completing_pos < document.characterCount(): - cursor = self._control.textCursor() - pos = cursor.position() - text_cursor = self._control.textCursor() - text_cursor.setPosition(self._text_completing_pos) - if pos < self._text_completing_pos or \ - cursor.blockNumber() > text_cursor.blockNumber(): - self._clear_temporary_buffer() - self._text_completing_pos = 0 - else: - self._clear_temporary_buffer() - self._text_completing_pos = 0 - def _custom_context_menu_requested(self, pos): """ Shows a context menu at the given QPoint (in widget coordinates). """ diff --git a/IPython/frontend/qt/console/qtconsoleapp.py b/IPython/frontend/qt/console/qtconsoleapp.py index 5824607..e1eac4c 100644 --- a/IPython/frontend/qt/console/qtconsoleapp.py +++ b/IPython/frontend/qt/console/qtconsoleapp.py @@ -104,11 +104,14 @@ qt_flags = { 'plain' : ({'IPythonQtConsoleApp' : {'plain' : True}}, "Disable rich text support."), } -qt_flags.update(boolean_flag( - 'gui-completion', 'ConsoleWidget.gui_completion', - "use a GUI widget for tab completion", - "use plaintext output for completion" -)) + +# not quite sure on how this works +#qt_flags.update(boolean_flag( +# 'gui-completion', 'ConsoleWidget.gui_completion', +# "use a GUI widget for tab completion", +# "use plaintext output for completion" +#)) + # and app_flags from the Console Mixin qt_flags.update(app_flags) # add frontend flags to the full set @@ -117,7 +120,6 @@ flags.update(qt_flags) # start with copy of front&backend aliases list aliases = dict(aliases) qt_aliases = dict( - style = 'IPythonWidget.syntax_style', stylesheet = 'IPythonQtConsoleApp.stylesheet', colors = 'ZMQInteractiveShell.colors', @@ -127,6 +129,7 @@ qt_aliases = dict( ) # and app_aliases from the Console Mixin qt_aliases.update(app_aliases) +qt_aliases.update({'gui-completion':'ConsoleWidget.gui_completion'}) # add frontend aliases to the full set aliases.update(qt_aliases) diff --git a/IPython/frontend/qt/console/styles.py b/IPython/frontend/qt/console/styles.py index c8e2ba7..c72808f 100644 --- a/IPython/frontend/qt/console/styles.py +++ b/IPython/frontend/qt/console/styles.py @@ -22,6 +22,7 @@ default_light_style_template = ''' .in-prompt-number { font-weight: bold; } .out-prompt { color: darkred; } .out-prompt-number { font-weight: bold; } + .inverted { background-color: %(fgcolor)s ; color:%(bgcolor)s;} ''' default_light_style_sheet = default_light_style_template%dict( bgcolor='white', fgcolor='black', select="#ccc") @@ -38,6 +39,7 @@ default_dark_style_template = ''' .in-prompt-number { color: lime; font-weight: bold; } .out-prompt { color: red; } .out-prompt-number { color: red; font-weight: bold; } + .inverted { background-color: %(fgcolor)s ; color:%(bgcolor)s;} ''' default_dark_style_sheet = default_dark_style_template%dict( bgcolor='black', fgcolor='white', select="#555") @@ -50,6 +52,7 @@ default_bw_style_sheet = ''' selection-background-color: #cccccc} .in-prompt-number { font-weight: bold; } .out-prompt-number { font-weight: bold; } + .inverted { background-color: black ; color: white;} ''' default_bw_syntax_style = 'bw' diff --git a/IPython/utils/html_utils.py b/IPython/utils/html_utils.py new file mode 100644 index 0000000..aaf2f2b --- /dev/null +++ b/IPython/utils/html_utils.py @@ -0,0 +1,124 @@ +"""some html utilis""" +from IPython.core.display import HTML + + +def columnize_info(items, separator_width=1, displaywidth=80, empty=None): + """ Get info on a list of string to display it as a multicolumns list + + returns : + --------- + + a dict containing several parameters: + + 'item_matrix' : list of list with the innermost list representing a row + 'columns_number': number of columns + 'rows_number' : number of rown + 'columns_width' : a list indicating the maximum length of the element in each columns + + Parameters : + ------------ + separator_width : when trying to ajust the number of column, consider a separator size of this much caracters + displaywidth : try to fit the columns in this width + empty : if the number of items is different from nrows * ncols, fill with empty + + """ + # Note: this code is adapted from columnize 0.3.2. + # See http://code.google.com/p/pycolumnize/ + + # Some degenerate cases. + size = len(items) + if size == 0: + return {'item_matrix' :[[empty]], + 'columns_number':1, + 'rows_number':1, + 'columns_width':[0]} + elif size == 1: + return {'item_matrix' :[[items[0]]], + 'columns_number':1, + 'rows_number':1, + 'columns_width':[len(items[0])]} + + # Special case: if any item is longer than the maximum width, there's no + # point in triggering the logic below... + item_len = map(len, items) # save these, we can reuse them below + #longest = max(item_len) + #if longest >= displaywidth: + # return (items, [longest]) + + # Try every row count from 1 upwards + array_index = lambda nrows, row, col: nrows*col + row + nrows = 1 + for nrows in range(1, size): + ncols = (size + nrows - 1) // nrows + colwidths = [] + totwidth = -separator_width + for col in range(ncols): + # Get max column width for this column + colwidth = 0 + for row in range(nrows): + i = array_index(nrows, row, col) + if i >= size: + break + len_x = item_len[i] + colwidth = max(colwidth, len_x) + colwidths.append(colwidth) + totwidth += colwidth + separator_width + if totwidth > displaywidth: + break + if totwidth <= displaywidth: + break + + # The smallest number of rows computed and the max widths for each + # column has been obtained. Now we just have to format each of the rows. + reorderd_items = [] + for row in range(nrows): + texts = [] + for col in range(ncols): + i = row + nrows*col + if i >= size: + texts.append(empty) + else: + texts.append(items[i]) + #while texts and not texts[-1]: + # del texts[-1] + #for col in range(len(texts)): + # texts[col] = texts[col].ljust(colwidths[col]) + reorderd_items.append(texts) + + return {'item_matrix' :reorderd_items, + 'columns_number':ncols, + 'rows_number':nrows, + 'columns_width':colwidths} + + +def column_table(items, select=None) : + """ return a html table of the item with a select class on one""" + items_m = columnize_info(items)['item_matrix'] + return HTML(html_tableify(items_m, select=select)) + +def html_tableify(item_matrix, select=None) : + """ returnr a string for an html table""" + if not item_matrix : + return '' + html_cols = [] + tds = lambda text : u''+text+u'' + trs = lambda text : u''+text+u'' + tds_items = [map(tds, row) for row in item_matrix ] + if select : + row, col = select + try : + tds_items[row][col] = u''\ + +item_matrix[row][col]\ + +u'' + except IndexError : + pass + #select the right item + html_cols = map(trs, (u''.join(row) for row in tds_items)) + html = (u''+(u''.join(html_cols))+u'
') + css = u""" + + """ + return css+html