Repository: ipython/ipython

Issue:
Qtconsole - nonstandard \a and \b
Issue #1089 reported lack of support for \b (backspace) and \r (return)
PR #629 purported to fix these, and did fix \r, but it mapped \b to bell rather than to backspace.
The standard mapping for bell is \a.



Discussion:

Resolving patch:
diff --git a/IPython/frontend/qt/console/ansi_code_processor.py b/IPython/frontend/qt/console/ansi_code_processor.py
--- a/IPython/frontend/qt/console/ansi_code_processor.py
+++ b/IPython/frontend/qt/console/ansi_code_processor.py
@@ -29,16 +29,22 @@
 # An action for the carriage return character
 CarriageReturnAction = namedtuple('CarriageReturnAction', ['action'])
 
+# An action for the \n character
+NewLineAction = namedtuple('NewLineAction', ['action'])
+
 # An action for the beep character
 BeepAction = namedtuple('BeepAction', ['action'])
 
+# An action for backspace
+BackSpaceAction = namedtuple('BackSpaceAction', ['action'])
+
 # Regular expressions.
 CSI_COMMANDS = 'ABCDEFGHJKSTfmnsu'
 CSI_SUBPATTERN = '\[(.*?)([%s])' % CSI_COMMANDS
 OSC_SUBPATTERN = '\](.*?)[\x07\x1b]'
 ANSI_PATTERN = ('\x01?\x1b(%s|%s)\x02?' % \
                 (CSI_SUBPATTERN, OSC_SUBPATTERN))
-ANSI_OR_SPECIAL_PATTERN = re.compile('(\b|\r(?!\n))|(?:%s)' % ANSI_PATTERN)
+ANSI_OR_SPECIAL_PATTERN = re.compile('(\a|\b|\r(?!\n)|\r?\n)|(?:%s)' % ANSI_PATTERN)
 SPECIAL_PATTERN = re.compile('([\f])')
 
 #-----------------------------------------------------------------------------
@@ -83,24 +89,41 @@ def split_string(self, string):
         self.actions = []
         start = 0
 
+        # strings ending with \r are assumed to be ending in \r\n since
+        # \n is appended to output strings automatically.  Accounting
+        # for that, here.
+        last_char = '\n' if len(string) > 0 and string[-1] == '\n' else None
+        string = string[:-1] if last_char is not None else string
+
         for match in ANSI_OR_SPECIAL_PATTERN.finditer(string):
             raw = string[start:match.start()]
             substring = SPECIAL_PATTERN.sub(self._replace_special, raw)
             if substring or self.actions:
                 yield substring
+                self.actions = []
             start = match.end()
 
-            self.actions = []
             groups = filter(lambda x: x is not None, match.groups())
-            if groups[0] == '\r':
-                self.actions.append(CarriageReturnAction('carriage-return'))
-                yield ''
-            elif groups[0] == '\b':
+            g0 = groups[0]
+            if g0 == '\a':
                 self.actions.append(BeepAction('beep'))
-                yield ''
+                yield None
+                self.actions = []
+            elif g0 == '\r':
+                self.actions.append(CarriageReturnAction('carriage-return'))
+                yield None
+                self.actions = []
+            elif g0 == '\b':
+                self.actions.append(BackSpaceAction('backspace'))
+                yield None
+                self.actions = []
+            elif g0 == '\n' or g0 == '\r\n':
+                self.actions.append(NewLineAction('newline'))
+                yield g0
+                self.actions = []
             else:
                 params = [ param for param in groups[1].split(';') if param ]
-                if groups[0].startswith('['):
+                if g0.startswith('['):
                     # Case 1: CSI code.
                     try:
                         params = map(int, params)
@@ -110,7 +133,7 @@ def split_string(self, string):
                     else:
                         self.set_csi_code(groups[2], params)
 
-                elif groups[0].startswith(']'):
+                elif g0.startswith(']'):
                     # Case 2: OSC code.
                     self.set_osc_code(params)
 
@@ -119,6 +142,10 @@ def split_string(self, string):
         if substring or self.actions:
             yield substring
 
+        if last_char is not None:
+            self.actions.append(NewLineAction('newline'))
+            yield last_char
+
     def set_csi_code(self, command, params=[]):
         """ Set attributes based on CSI (Control Sequence Introducer) code.
 
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
@@ -1553,8 +1553,31 @@ def _insert_plain_text(self, cursor, text):
                     elif act.action == 'beep':
                         QtGui.qApp.beep()
 
+                    elif act.action == 'backspace':
+                        if not cursor.atBlockStart():
+                            cursor.movePosition(
+                                cursor.PreviousCharacter, cursor.KeepAnchor)
+
+                    elif act.action == 'newline':
+                        cursor.movePosition(cursor.EndOfLine)
+
                 format = self._ansi_processor.get_format()
-                cursor.insertText(substring, format)
+
+                selection = cursor.selectedText()
+                if len(selection) == 0:
+                    cursor.insertText(substring, format)
+                elif substring is not None:
+                    # BS and CR are treated as a change in print
+                    # position, rather than a backwards character
+                    # deletion for output equivalence with (I)Python
+                    # terminal.
+                    if len(substring) >= len(selection):
+                        cursor.insertText(substring, format)
+                    else:
+                        old_text = selection[len(substring):]
+                        cursor.insertText(substring + old_text, format)
+                        cursor.movePosition(cursor.PreviousCharacter,
+                               cursor.KeepAnchor, len(old_text))
         else:
             cursor.insertText(text)
         cursor.endEditBlock()


Test patch:
diff --git a/IPython/frontend/qt/console/tests/test_ansi_code_processor.py b/IPython/frontend/qt/console/tests/test_ansi_code_processor.py
--- a/IPython/frontend/qt/console/tests/test_ansi_code_processor.py
+++ b/IPython/frontend/qt/console/tests/test_ansi_code_processor.py
@@ -106,28 +106,65 @@ def test_carriage_return(self):
         """ Are carriage return characters processed correctly?
         """
         string = 'foo\rbar' # carriage return
-        self.assertEquals(list(self.processor.split_string(string)), ['foo', '', 'bar'])
-        self.assertEquals(len(self.processor.actions), 1)
-        action = self.processor.actions[0]
-        self.assertEquals(action.action, 'carriage-return')
+        splits = []
+        actions = []
+        for split in self.processor.split_string(string):
+            splits.append(split)
+            actions.append([action.action for action in self.processor.actions])
+        self.assertEquals(splits, ['foo', None, 'bar'])
+        self.assertEquals(actions, [[], ['carriage-return'], []])
 
     def test_carriage_return_newline(self):
         """transform CRLF to LF"""
-        string = 'foo\rbar\r\ncat\r\n' # carriage return and newline
+        string = 'foo\rbar\r\ncat\r\n\n' # carriage return and newline
         # only one CR action should occur, and '\r\n' should transform to '\n'
-        self.assertEquals(list(self.processor.split_string(string)), ['foo', '', 'bar\r\ncat\r\n'])
-        self.assertEquals(len(self.processor.actions), 1)
-        action = self.processor.actions[0]
-        self.assertEquals(action.action, 'carriage-return')
+        splits = []
+        actions = []
+        for split in self.processor.split_string(string):
+            splits.append(split)
+            actions.append([action.action for action in self.processor.actions])
+        self.assertEquals(splits, ['foo', None, 'bar', '\r\n', 'cat', '\r\n', '\n'])
+        self.assertEquals(actions, [[], ['carriage-return'], [], ['newline'], [], ['newline'], ['newline']])
 
     def test_beep(self):
         """ Are beep characters processed correctly?
         """
-        string = 'foo\bbar' # form feed
-        self.assertEquals(list(self.processor.split_string(string)), ['foo', '', 'bar'])
-        self.assertEquals(len(self.processor.actions), 1)
-        action = self.processor.actions[0]
-        self.assertEquals(action.action, 'beep')
+        string = 'foo\abar' # bell
+        splits = []
+        actions = []
+        for split in self.processor.split_string(string):
+            splits.append(split)
+            actions.append([action.action for action in self.processor.actions])
+        self.assertEquals(splits, ['foo', None, 'bar'])
+        self.assertEquals(actions, [[], ['beep'], []])
+
+    def test_backspace(self):
+        """ Are backspace characters processed correctly?
+        """
+        string = 'foo\bbar' # backspace
+        splits = []
+        actions = []
+        for split in self.processor.split_string(string):
+            splits.append(split)
+            actions.append([action.action for action in self.processor.actions])
+        self.assertEquals(splits, ['foo', None, 'bar'])
+        self.assertEquals(actions, [[], ['backspace'], []])
+
+    def test_combined(self):
+        """ Are CR and BS characters processed correctly in combination?
+
+        BS is treated as a change in print position, rather than a
+        backwards character deletion.  Therefore a BS at EOL is
+        effectively ignored.
+        """
+        string = 'abc\rdef\b' # CR and backspace
+        splits = []
+        actions = []
+        for split in self.processor.split_string(string):
+            splits.append(split)
+            actions.append([action.action for action in self.processor.actions])
+        self.assertEquals(splits, ['abc', None, 'def', None])
+        self.assertEquals(actions, [[], ['carriage-return'], [], ['backspace']])
 
 
 if __name__ == '__main__':
diff --git a/IPython/frontend/qt/console/tests/test_console_widget.py b/IPython/frontend/qt/console/tests/test_console_widget.py
new file mode 100644
--- /dev/null
+++ b/IPython/frontend/qt/console/tests/test_console_widget.py
@@ -0,0 +1,40 @@
+# Standard library imports
+import unittest
+
+# System library imports
+from IPython.external.qt import QtGui
+
+# Local imports
+from IPython.frontend.qt.console.console_widget import ConsoleWidget
+
+
+class TestConsoleWidget(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_special_characters(self):
+        """ Are special characters displayed correctly?
+        """
+        w = ConsoleWidget()
+        cursor = w._get_prompt_cursor()
+
+        test_inputs = ['xyz\b\b=\n', 'foo\b\nbar\n', 'foo\b\nbar\r\n', 'abc\rxyz\b\b=']
+        expected_outputs = [u'x=z\u2029', u'foo\u2029bar\u2029', u'foo\u2029bar\u2029', 'x=z']
+        for i, text in enumerate(test_inputs):
+            w._insert_plain_text(cursor, text)
+            cursor.select(cursor.Document)
+            selection = cursor.selectedText()
+            self.assertEquals(expected_outputs[i], selection)
+            # clear all the text
+            cursor.insertText('')