From 45d28c54c204aeae7adfd5ca4d7b69309d7d3612 2012-06-10 19:22:47 From: Fernando Perez Date: 2012-06-10 19:22:47 Subject: [PATCH] Merge pull request #1851 from Carreau/newcomp_rebase New completer for qtconsole with rich keyboard navigation during completion search. add a completer to the qtconsole that is navigable by arrows 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): ipython qtconsole --gui-completion=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..852fcf3 --- /dev/null +++ b/IPython/frontend/qt/console/completion_html.py @@ -0,0 +1,371 @@ +"""a navigable completer for the qtconsole""" +# coding : utf-8 +#----------------------------------------------------------------------------- +# Copyright (c) 2012, IPython Development Team.$ +# +# Distributed under the terms of the Modified BSD License.$ +# +# The full license is in the file COPYING.txt, distributed with this software. +#----------------------------------------------------------------------------- + +# System library imports +import IPython.utils.text as text + +from IPython.external.qt import QtCore, QtGui + +#-------------------------------------------------------------------------- +# Return an HTML table with selected item in a special class +#-------------------------------------------------------------------------- +def html_tableify(item_matrix, select=None, header=None , footer=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 + tds_items[row][col] = u''\ + +item_matrix[row][col]\ + +u' ' + #select the right item + html_cols = map(trs, (u''.join(row) for row in tds_items)) + head = '' + foot = '' + if header : + head = (u''\ + +''.join((u''+header+u'')*len(item_matrix[0]))\ + +'') + + if footer : + foot = (u''\ + +''.join((u''+footer+u'')*len(item_matrix[0]))\ + +'') + html = (u''+head+(u''.join(html_cols))+foot+u'
') + return html + +class SlidingInterval(object): + """a bound interval that follows a cursor + + internally used to scoll the completion view when the cursor + try to go beyond the edges, and show '...' when rows are hidden + """ + + _min = 0 + _max = 1 + _current = 0 + def __init__(self, maximum=1, width=6, minimum=0, sticky_lenght=1): + """Create a new bounded interval + + any value return by this will be bound between maximum and + minimum. usual width will be 'width', and sticky_length + set when the return interval should expand to max and min + """ + self._min = minimum + self._max = maximum + self._start = 0 + self._width = width + self._stop = self._start+self._width+1 + self._sticky_lenght = sticky_lenght + + @property + def current(self): + """current cursor position""" + return self._current + + @current.setter + def current(self, value): + """set current cursor position""" + current = min(max(self._min, value), self._max) + + self._current = current + + if current > self._stop : + self._stop = current + self._start = current-self._width + elif current < self._start : + self._start = current + self._stop = current + self._width + + if abs(self._start - self._min) <= self._sticky_lenght : + self._start = self._min + + if abs(self._stop - self._max) <= self._sticky_lenght : + self._stop = self._max + + @property + def start(self): + """begiiing of interval to show""" + return self._start + + @property + def stop(self): + """end of interval to show""" + return self._stop + + @property + def width(self): + return self._stop - self._start + + @property + def nth(self): + return self.current - self.start + +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 + _slice_start = 0 + _slice_len = 4 + + 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) + self._sliding_interval = None + self._justified_items = None + + # 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 + elif key in ( QtCore.Qt.Key_Escape,): + self.cancel_completion() + 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 + + should be called when the completer have to be dismissed + + This reset internal variable, clearing the temporary buffer + of the console where the completion are shown. + """ + self._consecutive_tab = 0 + self._slice_start = 0 + self._console_widget._clear_temporary_buffer() + self._index = (0, 0) + if(self._sliding_interval): + self._sliding_interval = None + + # + # ... 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) ) + + + @property + def _slice_end(self): + end = self._slice_start+self._slice_len + if end > len(self._items) : + return None + return end + + 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 + items_m, ci = text.compute_item_matrix(items, empty=' ') + self._sliding_interval = SlidingInterval(len(items_m)-1) + + self._items = items_m + self._size = (ci['rows_numbers'], ci['columns_numbers']) + self._old_cursor = cursor + self._index = (0, 0) + sjoin = lambda x : [ y.ljust(w, ' ') for y, w in zip(x, ci['columns_width'])] + self._justified_items = map(sjoin, items_m) + self._update_list(hilight=False) + + + + + def _update_list(self, hilight=True): + """ update the list of completion and hilight the currently selected completion """ + self._sliding_interval.current = self._index[0] + head = None + foot = None + if self._sliding_interval.start > 0 : + head = '...' + + if self._sliding_interval.stop < self._sliding_interval._max: + foot = '...' + items_m = self._justified_items[\ + self._sliding_interval.start:\ + self._sliding_interval.stop+1\ + ] + + self._console_widget._clear_temporary_buffer() + if(hilight): + sel = (self._sliding_interval.nth, self._index[1]) + else : + sel = None + + strng = html_tableify(items_m, select=sel, header=head, footer=foot) + 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..d6b4066 --- /dev/null +++ b/IPython/frontend/qt/console/completion_plain.py @@ -0,0 +1,62 @@ +"""a simple completer for the qtconsole""" +#----------------------------------------------------------------------------- +# Copyright (c) 2012, IPython Development Team.$ +# +# Distributed under the terms of the Modified BSD License.$ +# +# The full license is in the file COPYING.txt, distributed with this software. +#------------------------------------------------------------------- + +# System library imports +from IPython.external.qt import QtCore, QtGui +import IPython.utils.text as text + + +class CompletionPlain(QtGui.QWidget): + """ A widget for tab completion, navigable by arrow keys """ + + #-------------------------------------------------------------------------- + # 'QObject' interface + #-------------------------------------------------------------------------- + + 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 in( QtCore.QEvent.KeyPress, QtCore.QEvent.FocusOut ): + 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() + + + def show_items(self, cursor, items): + """ Shows the completion widget with 'items' at the position specified + by 'cursor'. + """ + if not items : + return + self.cancel_completion() + strng = text.columnize(items) + self._console_widget._fill_temporary_buffer(cursor, strng, html=False) diff --git a/IPython/frontend/qt/console/completion_widget.py b/IPython/frontend/qt/console/completion_widget.py index 194b843..b258775 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._control assert isinstance(text_edit, (QtGui.QTextEdit, QtGui.QPlainTextEdit)) super(CompletionWidget, self).__init__() @@ -132,3 +133,6 @@ class CompletionWidget(QtGui.QListWidget): self.hide() else: self.hide() + + def cancel_completion(self): + self.hide() diff --git a/IPython/frontend/qt/console/console_widget.py b/IPython/frontend/qt/console/console_widget.py index e4b3d6c..cd2a93c 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 ]) + _temp_buffer_filled = 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,18 +840,17 @@ 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 the prompt region. """ # Select and remove all text below the input buffer. + _temp_buffer_filled = False cursor = self._get_prompt_cursor() prompt = self._continuation_prompt.lstrip() while cursor.movePosition(QtGui.QTextCursor.NextBlock): @@ -862,7 +878,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 +893,26 @@ 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) + + + 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) + + _temp_buffer_filled = True - 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 +974,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 +1043,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 +1656,9 @@ 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._temp_buffer_filled : + self._cancel_completion() + self._clear_temporary_buffer() else: self.input_buffer = '' @@ -1853,24 +1876,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..1735d9b 100644 --- a/IPython/frontend/qt/console/qtconsoleapp.py +++ b/IPython/frontend/qt/console/qtconsoleapp.py @@ -104,11 +104,7 @@ 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" -)) + # and app_flags from the Console Mixin qt_flags.update(app_flags) # add frontend flags to the full set @@ -117,7 +113,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 +122,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/tests/test_text.py b/IPython/utils/tests/test_text.py index ce6e3d0..36b6075 100644 --- a/IPython/utils/tests/test_text.py +++ b/IPython/utils/tests/test_text.py @@ -14,6 +14,7 @@ import os import math +import random import nose.tools as nt @@ -32,13 +33,37 @@ def test_columnize(): items = [l*size for l in 'abc'] out = text.columnize(items, displaywidth=80) nt.assert_equals(out, 'aaaaa bbbbb ccccc\n') - out = text.columnize(items, displaywidth=10) + out = text.columnize(items, displaywidth=12) nt.assert_equals(out, 'aaaaa ccccc\nbbbbb\n') - + out = text.columnize(items, displaywidth=10) + nt.assert_equals(out, 'aaaaa\nbbbbb\nccccc\n') + +def test_columnize_random(): + """Test with random input to hopfully catch edge case """ + for nitems in [random.randint(2,70) for i in range(2,20)]: + displaywidth = random.randint(20,200) + rand_len = [random.randint(2,displaywidth) for i in range(nitems)] + items = ['x'*l for l in rand_len] + out = text.columnize(items, displaywidth=displaywidth) + longer_line = max([len(x) for x in out.split('\n')]) + longer_element = max(rand_len) + if longer_line > displaywidth: + print "Columnize displayed something lager than displaywidth : %s " % longer_line + print "longer element : %s " % longer_element + print "displaywidth : %s " % displaywidth + print "number of element : %s " % nitems + print "size of each element :\n %s" % rand_len + assert False + +def test_columnize_medium(): + """Test with inputs than shouldn't be wider tahn 80 """ + size = 40 + items = [l*size for l in 'abc'] + out = text.columnize(items, displaywidth=80) + nt.assert_equals(out, '\n'.join(items+[''])) def test_columnize_long(): """Test columnize with inputs longer than the display window""" - text.columnize(['a'*81, 'b'*81], displaywidth=80) size = 11 items = [l*size for l in 'abc'] out = text.columnize(items, displaywidth=size-1) diff --git a/IPython/utils/text.py b/IPython/utils/text.py index aa07f39..0df0bec 100644 --- a/IPython/utils/text.py +++ b/IPython/utils/text.py @@ -24,7 +24,7 @@ import textwrap from string import Formatter from IPython.external.path import path -from IPython.testing.skipdoctest import skip_doctest_py3 +from IPython.testing.skipdoctest import skip_doctest_py3, skip_doctest from IPython.utils import py3compat from IPython.utils.io import nlprint from IPython.utils.data import flatten @@ -660,6 +660,91 @@ class DollarFormatter(FullEvalFormatter): # Re-yield the {foo} style pattern yield (txt + literal_txt[continue_from:], field_name, format_spec, conversion) +#----------------------------------------------------------------------------- +# Utils to columnize a list of string +#----------------------------------------------------------------------------- +def _chunks(l, n): + """Yield successive n-sized chunks from l.""" + for i in xrange(0, len(l), n): + yield l[i:i+n] + +def _find_optimal(rlist , separator_size=2 , displaywidth=80): + """Calculate optimal info to columnize a list of string""" + for nrow in range(1, len(rlist)+1) : + chk = map(max,_chunks(rlist, nrow)) + sumlength = sum(chk) + ncols = len(chk) + if sumlength+separator_size*(ncols-1) <= displaywidth : + break; + return {'columns_numbers' : ncols, + 'optimal_separator_width':(displaywidth - sumlength)/(ncols-1) if (ncols -1) else 0, + 'rows_numbers' : nrow, + 'columns_width' : chk + } + +def _get_or_default(mylist, i, default=None): + """return list item number, or default if don't exist""" + if i >= len(mylist): + return default + else : + return mylist[i] + +@skip_doctest +def compute_item_matrix(items, empty=None, *args, **kwargs) : + """Returns a nested list, and info to columnize items + + Parameters : + ------------ + + items : + list of strings to columize + empty : (default None) + default value to fill list if needed + separator_size : int (default=2) + How much caracters will be used as a separation between each columns. + displaywidth : int (default=80) + The width of the area onto wich the columns should enter + + Returns : + --------- + + Returns a tuple of (strings_matrix, dict_info) + + strings_matrix : + + nested list of string, the outer most list contains as many list as + rows, the innermost lists have each as many element as colums. If the + total number of elements in `items` does not equal the product of + rows*columns, the last element of some lists are filled with `None`. + + dict_info : + some info to make columnize easier: + + columns_numbers : number of columns + rows_numbers : number of rows + columns_width : list of with of each columns + optimal_separator_width : best separator width between columns + + Exemple : + --------- + + In [1]: l = ['aaa','b','cc','d','eeeee','f','g','h','i','j','k','l'] + ...: compute_item_matrix(l,displaywidth=12) + Out[1]: + ([['aaa', 'f', 'k'], + ['b', 'g', 'l'], + ['cc', 'h', None], + ['d', 'i', None], + ['eeeee', 'j', None]], + {'columns_numbers': 3, + 'columns_width': [5, 1, 1], + 'optimal_separator_width': 2, + 'rows_numbers': 5}) + + """ + info = _find_optimal(map(len, items), *args, **kwargs) + nrow, ncol = info['rows_numbers'], info['columns_numbers'] + return ([[ _get_or_default(items, c*nrow+i, default=empty) for c in range(ncol) ] for i in range(nrow) ], info) def columnize(items, separator=' ', displaywidth=80): """ Transform a list of strings into a single string with columns. @@ -679,58 +764,9 @@ def columnize(items, separator=' ', displaywidth=80): ------- The formatted string. """ - # 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: + if not items : return '\n' - elif size == 1: - return '%s\n' % 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 '\n'.join(items+['']) - - # Try every row count from 1 upwards - array_index = lambda nrows, row, col: nrows*col + row - for nrows in range(1, size): - ncols = (size + nrows - 1) // nrows - colwidths = [] - totwidth = -len(separator) - 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 - x, len_x = items[i], item_len[i] - colwidth = max(colwidth, len_x) - colwidths.append(colwidth) - totwidth += colwidth + len(separator) - 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. - string = '' - for row in range(nrows): - texts = [] - for col in range(ncols): - i = row + nrows*col - if i >= size: - texts.append('') - 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]) - string += '%s\n' % separator.join(texts) - return string + matrix, info = compute_item_matrix(items, separator_size=len(separator), displaywidth=displaywidth) + fmatrix = [filter(None, x) for x in matrix] + sjoin = lambda x : separator.join([ y.ljust(w, ' ') for y, w in zip(x, info['columns_width'])]) + return '\n'.join(map(sjoin, fmatrix))+'\n'