Repository: ipython/ipython

Issue:
Usability improvements to Qt console
Currently the Qt console is tricky to use with multiline cells recalled from history because it's very easy to go 'off the edge'.  If the cursor reaches the top or bottom line and you do up/down once more, you jump to the next cell.  When recalling history that's the desired behavior, but when you've already edited a cell, it's jarring to get bumped out of your editing context due to a single arrow movement.

I don't know how easy/possible it would be to implement, but my idea is the following: the console should detect when a cell has been made 'dirty' by editing (any typing other than arrow movements, pasting, etc), then the behavior should change.  At that point, the cell boundaries should become 'hard', preventing the cursor from exiting unless the person clears the cell. Basically, once the cell is being edited, it should feel like a little text editor that doesn't lose its content without some drastic action.



Discussion:
It could be made like the readline behavior. If you edit a "cell" in the history, that edited text remains even as you move up and down in the history. When you execute or cancel, the original text takes its place back in the history, and the new text (if you executed) appends to the history.

I second Robert's idea.

On Fri, Apr 8, 2011 at 11:32 AM, rkern
reply@reply.github.com
wrote:

> It could be made like the readline behavior. If you edit a "cell" in the history, that edited text remains even as you move up and down in the history. When you execute or cancel, the original text takes its place back in the history, and the new text (if you executed) appends to the history.

That sounds good, but do you mean to suggest not stopping the cursor
at cell boundaries once the cell has been edited?  Part of what's very
jarring is the jumps that happen if you accidentally up-arrow through
the first line, and your big multiline cell gets replaced by 'ls'.
Even if we can recover it by going back down, I think ui-wise it would
be better to 'lock' the cell from exit at that point.

But I have no idea if that's easy to do in qt or not...

I'm always leery of preventing people from doing stuff, especially if the only way to "unlock" things is to execute something or lose what you've written. Someone might legitimately want to go up or down in the history while they're in the middle of typing something. This is particularly important when the history is from previous sessions so the user cannot simply scroll up to see it.

Rather, I think the principle to follow is one of "safe exploration". They can move around freely without losing their work. Yes, it might be visually jarring from time to time, but that's all.

OK, I can agree to that.  I think I would prefer a "safety stop" once the cell has been edited, just requiring perhaps an extra Ctrl-arrow to get out, or somesuch... But if that's hard to implement, it's OK.

Do you guys have any available cycles to implement this over the next few weeks?  I don't know enough qt to do it... But I think it's still an important usability limitation of the console to lose edits from an accidental up-arrow.  It has happened several times to me and it's really annoying...

People have been very impressed when I've demoed the qt console, but things like this still make it a bit unappealing for actual heavy-duty work.  I think we're very close, but not quite there.

But since I can't really do the work myself right now, I can only ask :)

I will implement this, Fernando. 

For now, I will go with the readline approach since that satisfies the principle of least surprise. If time permits, I will add a configuration option for some kind of lock, with a keybinding (Shift+Up/Down?) to disable it. But I think that the locking should be off by default.

On Sun, Apr 10, 2011 at 10:03 AM, epatters
reply@reply.github.com
wrote:

> I will implement this, Fernando.
> 
> For now, I will go with the readline approach since that satisfies the principle of least surprise. If time permits, I will add a configuration option for some kind of lock, with a keybinding (Shift+Up/Down?) to disable it. But I think that the locking should be off by default.

This sounds great, Evan.  Many thanks!


Resolving patch:
diff --git a/IPython/frontend/qt/console/console_widget.py b/IPython/frontend/qt/console/console_widget.py
--- a/IPython/frontend/qt/console/console_widget.py
+++ b/IPython/frontend/qt/console/console_widget.py
@@ -652,13 +652,13 @@ def _prompt_finished_hook(self):
         """
         pass
 
-    def _up_pressed(self):
+    def _up_pressed(self, shift_modifier):
         """ Called when the up key is pressed. Returns whether to continue
             processing the event.
         """
         return True
 
-    def _down_pressed(self):
+    def _down_pressed(self, shift_modifier):
         """ Called when the down key is pressed. Returns whether to continue
             processing the event.
         """
@@ -1040,14 +1040,14 @@ def _event_filter_console_keypress(self, event):
                 intercepted = True
 
             elif key == QtCore.Qt.Key_Up:
-                if self._reading or not self._up_pressed():
+                if self._reading or not self._up_pressed(shift_down):
                     intercepted = True
                 else:
                     prompt_line = self._get_prompt_cursor().blockNumber()
                     intercepted = cursor.blockNumber() <= prompt_line
 
             elif key == QtCore.Qt.Key_Down:
-                if self._reading or not self._down_pressed():
+                if self._reading or not self._down_pressed(shift_down):
                     intercepted = True
                 else:
                     end_line = self._get_end_cursor().blockNumber()
diff --git a/IPython/frontend/qt/console/history_console_widget.py b/IPython/frontend/qt/console/history_console_widget.py
--- a/IPython/frontend/qt/console/history_console_widget.py
+++ b/IPython/frontend/qt/console/history_console_widget.py
@@ -2,6 +2,7 @@
 from IPython.external.qt import QtGui
 
 # Local imports
+from IPython.utils.traitlets import Bool
 from console_widget import ConsoleWidget
 
 
@@ -9,6 +10,13 @@ class HistoryConsoleWidget(ConsoleWidget):
     """ A ConsoleWidget that keeps a history of the commands that have been
         executed and provides a readline-esque interface to this history.
     """
+
+    #------ Configuration ------------------------------------------------------
+
+    # If enabled, the input buffer will become "locked" to history movement when
+    # an edit is made to a multi-line input buffer. To override the lock, use
+    # Shift in conjunction with the standard history cycling keys.
+    history_lock = Bool(False, config=True)
     
     #---------------------------------------------------------------------------
     # 'object' interface
@@ -19,6 +27,7 @@ def __init__(self, *args, **kw):
 
         # HistoryConsoleWidget protected variables.
         self._history = []
+        self._history_edits = {}
         self._history_index = 0
         self._history_prefix = ''
 
@@ -42,6 +51,9 @@ def execute(self, source=None, hidden=False, interactive=False):
             if history and (not self._history or self._history[-1] != history):
                 self._history.append(history)
 
+            # Emulate readline: reset all history edits.
+            self._history_edits = {}
+
             # Move the history index to the most recent item.
             self._history_index = len(self._history)
 
@@ -51,12 +63,15 @@ def execute(self, source=None, hidden=False, interactive=False):
     # 'ConsoleWidget' abstract interface
     #---------------------------------------------------------------------------
 
-    def _up_pressed(self):
+    def _up_pressed(self, shift_modifier):
         """ Called when the up key is pressed. Returns whether to continue
             processing the event.
         """
         prompt_cursor = self._get_prompt_cursor()
         if self._get_cursor().blockNumber() == prompt_cursor.blockNumber():
+            # Bail out if we're locked.
+            if self._history_locked() and not shift_modifier:
+                return False
 
             # Set a search prefix based on the cursor position.
             col = self._get_input_buffer_cursor_column()
@@ -84,21 +99,24 @@ def _up_pressed(self):
 
         return True
 
-    def _down_pressed(self):
+    def _down_pressed(self, shift_modifier):
         """ Called when the down key is pressed. Returns whether to continue
             processing the event.
         """
         end_cursor = self._get_end_cursor()
         if self._get_cursor().blockNumber() == end_cursor.blockNumber():
+            # Bail out if we're locked.
+            if self._history_locked() and not shift_modifier:
+                return False
 
             # Perform the search.
-            self.history_next(self._history_prefix)
+            replaced = self.history_next(self._history_prefix)
 
             # Emulate readline: keep the cursor position fixed for a prefix
             # search. (We don't need to move the cursor to the end of the buffer
             # in the other case because this happens automatically when the
             # input buffer is set.)
-            if self._history_prefix:
+            if self._history_prefix and replaced:
                 cursor = self._get_prompt_cursor()
                 cursor.movePosition(QtGui.QTextCursor.Right, 
                                     n=len(self._history_prefix))
@@ -113,50 +131,66 @@ def _down_pressed(self):
     #---------------------------------------------------------------------------
 
     def history_previous(self, prefix=''):
-        """ If possible, set the input buffer to a previous item in the history.
+        """ If possible, set the input buffer to a previous history item.
 
         Parameters:
         -----------
         prefix : str, optional
             If specified, search for an item with this prefix.
+
+        Returns:
+        --------
+        Whether the input buffer was changed.
         """
         index = self._history_index
+        replace = False
         while index > 0:
             index -= 1
-            history = self._history[index]
+            history = self._get_edited_history(index)
             if history.startswith(prefix):
+                replace = True
                 break
-        else:
-            history = None
         
-        if history is not None:
+        if replace:
+            self._store_edits()
             self._history_index = index
             self.input_buffer = history
 
+        return replace
+
     def history_next(self, prefix=''):
-        """ Set the input buffer to a subsequent item in the history, or to the
-        original search prefix if there is no such item.
+        """ If possible, set the input buffer to a subsequent history item.
 
         Parameters:
         -----------
         prefix : str, optional
             If specified, search for an item with this prefix.
+
+        Returns:
+        --------
+        Whether the input buffer was changed.
         """
-        while self._history_index < len(self._history) - 1:
-            self._history_index += 1
-            history = self._history[self._history_index]
+        index = self._history_index
+        replace = False
+        while self._history_index < len(self._history):
+            index += 1
+            history = self._get_edited_history(index)
             if history.startswith(prefix):
+                replace = True
                 break
-        else:
-            self._history_index = len(self._history)
-            history = prefix
-        self.input_buffer = history
+            
+        if replace:
+            self._store_edits()
+            self._history_index = index
+            self.input_buffer = history
+
+        return replace
 
     def history_tail(self, n=10):
         """ Get the local history list.
 
-        Parameters
-        ----------
+        Parameters:
+        -----------
         n : int
             The (maximum) number of history items to get.
         """
@@ -166,8 +200,35 @@ def history_tail(self, n=10):
     # 'HistoryConsoleWidget' protected interface
     #---------------------------------------------------------------------------
 
+    def _history_locked(self):
+        """ Returns whether history movement is locked.
+        """
+        return (self.history_lock and 
+                (self._get_edited_history(self._history_index) != 
+                 self.input_buffer) and
+                (self._get_prompt_cursor().blockNumber() !=
+                 self._get_end_cursor().blockNumber()))
+
+    def _get_edited_history(self, index):
+        """ Retrieves a history item, possibly with temporary edits.
+        """
+        if index in self._history_edits:
+            return self._history_edits[index]
+        elif index == len(self._history):
+            return unicode()
+        return self._history[index]
+
     def _set_history(self, history):
         """ Replace the current history with a sequence of history items.
         """
         self._history = list(history)
+        self._history_edits = {}
         self._history_index = len(self._history)
+
+    def _store_edits(self):
+        """ If there are edits to the current input buffer, store them.
+        """
+        current = self.input_buffer
+        if self._history_index == len(self._history) or \
+                self._history[self._history_index] != current:
+            self._history_edits[self._history_index] = current


Test patch: