diff --git a/.github/workflows/linux-tests.yml b/.github/workflows/linux-tests.yml index 361485a9..750f43e2 100644 --- a/.github/workflows/linux-tests.yml +++ b/.github/workflows/linux-tests.yml @@ -76,7 +76,7 @@ jobs: pip list - name: Run tests shell: bash -el {0} - run: xvfb-run --auto-servernum pytest -vv -s --full-trace --color=yes --cov=qtconsole qtconsole + run: xvfb-run --auto-servernum pytest -vv -s --color=yes --cov=qtconsole qtconsole env: QT_API: ${{ matrix.QT_LIB }} PYTEST_QT_API: ${{ matrix.QT_LIB }} diff --git a/qtconsole/history_console_widget.py b/qtconsole/history_console_widget.py index 3979cfe7..9e40936d 100644 --- a/qtconsole/history_console_widget.py +++ b/qtconsole/history_console_widget.py @@ -1,10 +1,26 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +import os +import os.path -from qtpy import QtGui +from qtpy import QtCore, QtGui, QtWidgets from traitlets import Bool from .console_widget import ConsoleWidget +from .completion_widget import CompletionWidget + + +class HistoryListWidget(CompletionWidget): + """ A widget for GUI list history. + """ + complete_current = QtCore.Signal(str) + + def _complete_current(self): + """ Perform the completion with the currently selected item. + """ + text = self.currentItem().data(QtCore.Qt.UserRole) + self.complete_current.emit(text) + self.hide() class HistoryConsoleWidget(ConsoleWidget): @@ -31,6 +47,15 @@ def __init__(self, *args, **kw): self._history_edits = {} self._history_index = 0 self._history_prefix = '' + self.droplist_history = QtWidgets.QAction("Show related history execution entries", + self, + shortcut="Ctrl+Shift+R", + shortcutContext=QtCore.Qt.WidgetWithChildrenShortcut, + triggered=self._show_history_droplist + ) + self.addAction(self.droplist_history) + self._history_list_widget = HistoryListWidget(self, self.gui_completion_height) + self._history_list_widget.complete_current.connect(self.change_input_buffer) #--------------------------------------------------------------------------- # 'ConsoleWidget' public interface @@ -142,6 +167,70 @@ def _down_pressed(self, shift_modifier): return True + def _show_history_droplist(self): + # Perform the search. + prompt_cursor = self._get_prompt_cursor() + if self._get_cursor().blockNumber() == prompt_cursor.blockNumber(): + + # Set a search prefix based on the cursor position. + pos = self._get_input_buffer_cursor_pos() + input_buffer = self.input_buffer + # use the *shortest* of the cursor column and the history prefix + # to determine if the prefix has changed + n = min(pos, len(self._history_prefix)) + + # prefix changed, restart search from the beginning + if self._history_prefix[:n] != input_buffer[:n]: + self._history_index = len(self._history) + + # the only time we shouldn't set the history prefix + # to the line up to the cursor is if we are already + # in a simple scroll (no prefix), + # and the cursor is at the end of the first line + + # check if we are at the end of the first line + c = self._get_cursor() + current_pos = c.position() + c.movePosition(QtGui.QTextCursor.EndOfBlock) + at_eol = c.position() == current_pos + + if ( + self._history_index == len(self._history) + or not (self._history_prefix == "" and at_eol) + or not ( + self._get_edited_history(self._history_index)[:pos] + == input_buffer[:pos] + ) + ): + self._history_prefix = input_buffer[:pos] + items = self._history + items.reverse() + if self._history_prefix: + items = [ + item + for item in items + if item.startswith(self._history_prefix) + ] + + cursor = self._get_cursor() + pos = len(self._history_prefix) + cursor_pos = self._get_input_buffer_cursor_pos() + cursor.movePosition(QtGui.QTextCursor.Left, n=(cursor_pos - pos)) + # This line actually applies the move to control's cursor + self._control.setTextCursor(cursor) + + self._history_list_widget.cancel_completion() + if len(items) == 1: + self._history_list_widget.show_items( + cursor, items + ) + elif len(items) > 1: + current_pos = self._control.textCursor().position() + prefix = os.path.commonprefix(items) + self._history_list_widget.show_items( + cursor, items, prefix_length=len(prefix) + ) + #--------------------------------------------------------------------------- # 'HistoryConsoleWidget' public interface #--------------------------------------------------------------------------- @@ -173,9 +262,7 @@ def history_previous(self, substring='', as_prefix=True): break if replace: - self._store_edits() - self._history_index = index - self.input_buffer = history + self.change_input_buffer(history, index=index) return replace @@ -206,9 +293,7 @@ def history_next(self, substring='', as_prefix=True): break if replace: - self._store_edits() - self._history_index = index - self.input_buffer = history + self.change_input_buffer(history, index=index) return replace @@ -222,6 +307,22 @@ def history_tail(self, n=10): """ return self._history[-n:] + @QtCore.Slot(str) + def change_input_buffer(self, buffer, index=None): + """Change input_buffer value while storing edits and updating history index. + + Parameters + ---------- + buffer : str + New value for the inpur buffer. + index : int, optional + History index to set. The default is 0. + """ + if index: + self._store_edits() + self._history_index = index + self.input_buffer = buffer + #--------------------------------------------------------------------------- # 'HistoryConsoleWidget' protected interface #--------------------------------------------------------------------------- diff --git a/qtconsole/tests/test_00_console_widget.py b/qtconsole/tests/test_00_console_widget.py index c9b571e8..fa49c2db 100644 --- a/qtconsole/tests/test_00_console_widget.py +++ b/qtconsole/tests/test_00_console_widget.py @@ -34,6 +34,41 @@ def qtconsole(qtbot): console.window.close() +@pytest.mark.xfail( + sys.platform.startswith("linux"), + reason="Doesn't work without a window manager on Linux" +) +def test_history_complete(qtconsole, qtbot): + """ + Test history complete widget + """ + window = qtconsole.window + shell = window.active_frontend + control = shell._control + + # Wait until the console is fully up + qtbot.waitUntil( + lambda: shell._prompt_html is not None, timeout=SHELL_TIMEOUT + ) + + with qtbot.waitSignal(shell.executed): + shell.execute("import time") + + qtbot.keyClicks(control, "imp") + + qtbot.keyClick( + control, + QtCore.Qt.Key_R, + modifier=QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier, + ) + qtbot.waitUntil(lambda: shell._history_list_widget.isVisible()) + + qtbot.keyClick(shell._history_list_widget, QtCore.Qt.Key_Enter) + qtbot.waitUntil(lambda: not shell._history_list_widget.isVisible()) + + assert shell.input_buffer == "import time" + + @flaky(max_runs=3) @pytest.mark.parametrize( "debug", [True, False])