diff --git a/IPython/frontend/qt/console/console_widget.py b/IPython/frontend/qt/console/console_widget.py index 8674e9a..865d337 100644 --- a/IPython/frontend/qt/console/console_widget.py +++ b/IPython/frontend/qt/console/console_widget.py @@ -22,6 +22,7 @@ from IPython.frontend.qt.util import MetaQObjectHasTraits, get_font from IPython.utils.traitlets import Bool, Enum, Int from ansi_code_processor import QtAnsiCodeProcessor from completion_widget import CompletionWidget +from kill_ring import QtKillRing #----------------------------------------------------------------------------- # Functions @@ -173,6 +174,7 @@ class ConsoleWidget(Configurable, QtGui.QWidget): self._filter_drag = False self._filter_resize = False self._html_exporter = HtmlExporter(self._control) + self._kill_ring = QtKillRing(self._control) self._prompt = '' self._prompt_html = None self._prompt_pos = 0 @@ -953,7 +955,7 @@ class ConsoleWidget(Configurable, QtGui.QWidget): cursor.movePosition(QtGui.QTextCursor.Right, QtGui.QTextCursor.KeepAnchor, len(self._continuation_prompt)) - cursor.removeSelectedText() + self._kill_ring.kill_cursor(cursor) intercepted = True elif key == QtCore.Qt.Key_L: @@ -976,11 +978,12 @@ class ConsoleWidget(Configurable, QtGui.QWidget): QtGui.QTextCursor.KeepAnchor) cursor.movePosition(QtGui.QTextCursor.Right, QtGui.QTextCursor.KeepAnchor, offset) - cursor.removeSelectedText() + self._kill_ring.kill_cursor(cursor) intercepted = True elif key == QtCore.Qt.Key_Y: - self.paste() + self._keep_cursor_in_buffer() + self._kill_ring.yank() intercepted = True elif key in (QtCore.Qt.Key_Backspace, QtCore.Qt.Key_Delete): @@ -1005,16 +1008,20 @@ class ConsoleWidget(Configurable, QtGui.QWidget): self._set_cursor(self._get_word_end_cursor(position)) intercepted = True + elif key == QtCore.Qt.Key_Y: + self._kill_ring.rotate() + intercepted = True + elif key == QtCore.Qt.Key_Backspace: cursor = self._get_word_start_cursor(position) cursor.setPosition(position, QtGui.QTextCursor.KeepAnchor) - cursor.removeSelectedText() + self._kill_ring.kill_cursor(cursor) intercepted = True elif key == QtCore.Qt.Key_D: cursor = self._get_word_end_cursor(position) cursor.setPosition(position, QtGui.QTextCursor.KeepAnchor) - cursor.removeSelectedText() + self._kill_ring.kill_cursor(cursor) intercepted = True elif key == QtCore.Qt.Key_Delete: diff --git a/IPython/frontend/qt/console/kill_ring.py b/IPython/frontend/qt/console/kill_ring.py new file mode 100644 index 0000000..d6f3f69 --- /dev/null +++ b/IPython/frontend/qt/console/kill_ring.py @@ -0,0 +1,128 @@ +""" A generic Emacs-style kill ring, as well as a Qt-specific version. +""" +#----------------------------------------------------------------------------- +# Imports +#----------------------------------------------------------------------------- + +# System library imports +from IPython.external.qt import QtCore, QtGui + +#----------------------------------------------------------------------------- +# Classes +#----------------------------------------------------------------------------- + +class KillRing(object): + """ A generic Emacs-style kill ring. + """ + + def __init__(self): + self.clear() + + def clear(self): + """ Clears the kill ring. + """ + self._index = -1 + self._ring = [] + + def kill(self, text): + """ Adds some killed text to the ring. + """ + self._ring.append(text) + + def yank(self): + """ Yank back the most recently killed text. + + Returns: + -------- + A text string or None. + """ + self._index = len(self._ring) + return self.rotate() + + def rotate(self): + """ Rotate the kill ring, then yank back the new top. + + Returns: + -------- + A text string or None. + """ + self._index -= 1 + if self._index >= 0: + return self._ring[self._index] + return None + +class QtKillRing(QtCore.QObject): + """ A kill ring attached to Q[Plain]TextEdit. + """ + + #-------------------------------------------------------------------------- + # QtKillRing interface + #-------------------------------------------------------------------------- + + def __init__(self, text_edit): + """ Create a kill ring attached to the specified Qt text edit. + """ + assert isinstance(text_edit, (QtGui.QTextEdit, QtGui.QPlainTextEdit)) + super(QtKillRing, self).__init__() + + self._ring = KillRing() + self._prev_yank = None + self._skip_cursor = False + self._text_edit = text_edit + + text_edit.cursorPositionChanged.connect(self._cursor_position_changed) + + def clear(self): + """ Clears the kill ring. + """ + self._ring.clear() + self._prev_yank = None + + def kill(self, text): + """ Adds some killed text to the ring. + """ + self._ring.kill(text) + + def kill_cursor(self, cursor): + """ Kills the text selected by the give cursor. + """ + text = cursor.selectedText() + if text: + cursor.removeSelectedText() + self.kill(text) + + def yank(self): + """ Yank back the most recently killed text. + """ + text = self._ring.yank() + if text: + self._skip_cursor = True + cursor = self._text_edit.textCursor() + cursor.insertText(text) + self._prev_yank = text + + def rotate(self): + """ Rotate the kill ring, then yank back the new top. + """ + if self._prev_yank: + text = self._ring.rotate() + if text: + self._skip_cursor = True + cursor = self._text_edit.textCursor() + cursor.movePosition(QtGui.QTextCursor.Left, + QtGui.QTextCursor.KeepAnchor, + n = len(self._prev_yank)) + cursor.insertText(text) + self._prev_yank = text + + #-------------------------------------------------------------------------- + # Protected interface + #-------------------------------------------------------------------------- + + #------ Signal handlers ---------------------------------------------------- + + def _cursor_position_changed(self): + if self._skip_cursor: + self._skip_cursor = False + else: + self._prev_yank = None diff --git a/IPython/frontend/qt/console/tests/test_kill_ring.py b/IPython/frontend/qt/console/tests/test_kill_ring.py new file mode 100644 index 0000000..8b05405 --- /dev/null +++ b/IPython/frontend/qt/console/tests/test_kill_ring.py @@ -0,0 +1,83 @@ +# Standard library imports +import unittest + +# System library imports +from IPython.external.qt import QtCore, QtGui + +# Local imports +from IPython.frontend.qt.console.kill_ring import KillRing, QtKillRing + + +class TestKillRing(unittest.TestCase): + + @classmethod + def setUpClass(cls): + """ Create the application for the test case. + """ + cls._app = QtGui.QApplication([]) + cls._app.setQuitOnLastWindowClosed(False) + + @classmethod + def tearDownClass(cls): + """ Exit the application. + """ + QtGui.QApplication.quit() + + def test_generic(self): + """ Does the generic kill ring work? + """ + ring = KillRing() + self.assert_(ring.yank() is None) + self.assert_(ring.rotate() is None) + + ring.kill('foo') + self.assertEqual(ring.yank(), 'foo') + self.assert_(ring.rotate() is None) + self.assertEqual(ring.yank(), 'foo') + + ring.kill('bar') + self.assertEqual(ring.yank(), 'bar') + self.assertEqual(ring.rotate(), 'foo') + + ring.clear() + self.assert_(ring.yank() is None) + self.assert_(ring.rotate() is None) + + def test_qt_basic(self): + """ Does the Qt kill ring work? + """ + text_edit = QtGui.QPlainTextEdit() + ring = QtKillRing(text_edit) + + ring.kill('foo') + ring.kill('bar') + ring.yank() + ring.rotate() + ring.yank() + self.assertEqual(text_edit.toPlainText(), 'foobar') + + text_edit.clear() + ring.kill('baz') + ring.yank() + ring.rotate() + ring.rotate() + ring.rotate() + self.assertEqual(text_edit.toPlainText(), 'foo') + + def test_qt_cursor(self): + """ Does the Qt kill ring maintain state with cursor movement? + """ + text_edit = QtGui.QPlainTextEdit() + ring = QtKillRing(text_edit) + + ring.kill('foo') + ring.kill('bar') + ring.yank() + text_edit.moveCursor(QtGui.QTextCursor.Left) + ring.rotate() + self.assertEqual(text_edit.toPlainText(), 'bar') + + +if __name__ == '__main__': + import nose + nose.main()