Repository: jupyter/qtconsole
Pull request #616 (2024-07-30 03:59:45)

Add logic to handle ANSI escape sequences to move cursor
I made changes to the way the ANSI code \n is handled. Previously, whenever the \n code appeared, a new line was inserted unconditionally. After the change, a new line is inserted unless there is already a written line at the current position. In that case, instead of inserting a new line, the cursor moves one line down.

Preview with changes made:
![image](https://github.com/user-attachments/assets/dd789e51-1003-4002-a971-e5886d8a3ea9)

Comparison of the result obtained with the changes made and in Ipython with QTDM

Preview with changes made:

![PreviewTQDMQtConsole](https://github.com/user-attachments/assets/a23f53b2-bb9a-405d-aea5-0ba43dedeb37)

Preview with IPython:

![PreviewTQDMIpython](https://github.com/user-attachments/assets/c9a06199-2879-4bb1-991d-49a6bb1795b3)


Fixes: #350 
Related with: https://github.com/spyder-ide/spyder-kernels/issues/494 https://github.com/spyder-ide/spyder/issues/6172



Patch:
diff --git a/qtconsole/ansi_code_processor.py b/qtconsole/ansi_code_processor.py
index 063c906..16f2ddd 100644
--- a/qtconsole/ansi_code_processor.py
+++ b/qtconsole/ansi_code_processor.py
@@ -92,10 +92,7 @@ class AnsiCodeProcessor(object):
         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
+        last_char = None
         string = string[:-1] if last_char is not None else string
 
         for match in ANSI_OR_SPECIAL_PATTERN.finditer(string):
@@ -122,7 +119,7 @@ class AnsiCodeProcessor(object):
                 self.actions = []
             elif g0 == '\n' or g0 == '\r\n':
                 self.actions.append(NewLineAction('newline'))
-                yield g0
+                yield None
                 self.actions = []
             else:
                 params = [ param for param in groups[1].split(';') if param ]
@@ -147,7 +144,7 @@ class AnsiCodeProcessor(object):
 
         if last_char is not None:
             self.actions.append(NewLineAction('newline'))
-            yield last_char
+            yield None
 
     def set_csi_code(self, command, params=[]):
         """ Set attributes based on CSI (Control Sequence Introducer) code.
@@ -185,6 +182,22 @@ class AnsiCodeProcessor(object):
             count = params[0] if params else 1
             self.actions.append(ScrollAction('scroll', dir, 'line', count))
 
+        elif command == 'A':  # Move N lines Up
+            dir = 'up'
+            count = params[0] if params else 1
+            self.actions.append(MoveAction('move', dir, 'line', count))
+
+        elif command == 'B':  # Move N lines Down
+            dir = 'down'
+            count = params[0] if params else 1
+            self.actions.append(MoveAction('move', dir, 'line', count))
+
+        elif command == 'F':  # Goes back to the begining of the n-th previous line
+            dir = 'leftup'
+            count = params[0] if params else 1
+            self.actions.append(MoveAction('move', dir, 'line', count))
+        
+
     def set_osc_code(self, params):
         """ Set attributes based on OSC (Operating System Command) parameters.
 
diff --git a/qtconsole/console_widget.py b/qtconsole/console_widget.py
index 09bd7f7..aa492ba 100644
--- a/qtconsole/console_widget.py
+++ b/qtconsole/console_widget.py
@@ -2188,6 +2188,27 @@ class ConsoleWidget(MetaQObjectHasTraits('NewBase', (LoggingConfigurable, superQ
                             cursor.select(QtGui.QTextCursor.Document)
                             cursor.removeSelectedText()
 
+                    elif act.action == 'move' and act.unit == 'line':
+                        if act.dir == 'up':
+                            for i in range(act.count):
+                                cursor.movePosition(
+                                    QtGui.QTextCursor.Up
+                                )
+                        elif act.dir == 'down':
+                            for i in range(act.count):
+                                cursor.movePosition(
+                                    QtGui.QTextCursor.Down
+                                )
+                        elif act.dir == 'leftup':
+                            for i in range(act.count):
+                                cursor.movePosition(
+                                    QtGui.QTextCursor.Up
+                                )
+                            cursor.movePosition(
+                                QtGui.QTextCursor.StartOfLine,
+                                QtGui.QTextCursor.MoveAnchor
+                            )
+
                     elif act.action == 'carriage-return':
                         cursor.movePosition(
                             QtGui.QTextCursor.StartOfLine,
@@ -2203,7 +2224,19 @@ class ConsoleWidget(MetaQObjectHasTraits('NewBase', (LoggingConfigurable, superQ
                                 QtGui.QTextCursor.MoveAnchor)
 
                     elif act.action == 'newline':
-                        cursor.movePosition(QtGui.QTextCursor.EndOfLine)
+                        if (
+                            cursor.block() != cursor.document().lastBlock()
+                            and not cursor.document()
+                            .toPlainText()
+                            .endswith(self._prompt)
+                        ):
+                            cursor.movePosition(QtGui.QTextCursor.NextBlock)
+                        else:
+                            cursor.movePosition(
+                                QtGui.QTextCursor.EndOfLine,
+                                QtGui.QTextCursor.MoveAnchor,
+                            )
+                            cursor.insertText("\n")
 
                 # simulate replacement mode
                 if substring is not None:


Test patch:
diff --git a/qtconsole/tests/test_ansi_code_processor.py b/qtconsole/tests/test_ansi_code_processor.py
index 2b7dd71..ed00d63 100644
--- a/qtconsole/tests/test_ansi_code_processor.py
+++ b/qtconsole/tests/test_ansi_code_processor.py
@@ -139,7 +139,7 @@ class TestAnsiCodeProcessor(unittest.TestCase):
         for split in self.processor.split_string(string):
             splits.append(split)
             actions.append([action.action for action in self.processor.actions])
-        self.assertEqual(splits, ['foo', None, 'bar', '\r\n', 'cat', '\r\n', '\n'])
+        self.assertEqual(splits, ['foo', None, 'bar', None, 'cat', None, None])
         self.assertEqual(actions, [[], ['carriage-return'], [], ['newline'], [], ['newline'], ['newline']])
 
     def test_beep(self):
@@ -182,6 +182,49 @@ class TestAnsiCodeProcessor(unittest.TestCase):
         self.assertEqual(splits, ['abc', None, 'def', None])
         self.assertEqual(actions, [[], ['carriage-return'], [], ['backspace']])
 
+    def test_move_cursor_up(self):
+        """Are the ANSI commands for the cursor movement actions
+        (movement up and to the beginning of the line) processed correctly?
+        """
+        # This line moves the cursor up once, then moves it up five more lines.
+        # Next, it moves the cursor to the beginning of the previous line, and
+        # finally moves it to the beginning of the fifth line above the current 
+        # position
+        string = '\x1b[A\x1b[5A\x1b[F\x1b[5F'
+        i = -1
+        for i, substring in enumerate(self.processor.split_string(string)):
+            if i == 0:
+                self.assertEqual(len(self.processor.actions), 1)
+                action = self.processor.actions[0]
+                self.assertEqual(action.action, 'move')
+                self.assertEqual(action.dir, 'up')
+                self.assertEqual(action.unit, 'line')
+                self.assertEqual(action.count, 1)
+            elif i == 1:
+                self.assertEqual(len(self.processor.actions), 1)
+                action = self.processor.actions[0]
+                self.assertEqual(action.action, 'move')
+                self.assertEqual(action.dir, 'up')
+                self.assertEqual(action.unit, 'line')
+                self.assertEqual(action.count, 5)
+            elif i == 2:
+                self.assertEqual(len(self.processor.actions), 1)
+                action = self.processor.actions[0]
+                self.assertEqual(action.action, 'move')
+                self.assertEqual(action.dir, 'leftup')
+                self.assertEqual(action.unit, 'line')
+                self.assertEqual(action.count, 1)
+            elif i == 3:
+                self.assertEqual(len(self.processor.actions), 1)
+                action = self.processor.actions[0]
+                self.assertEqual(action.action, 'move')
+                self.assertEqual(action.dir, 'leftup')
+                self.assertEqual(action.unit, 'line')
+                self.assertEqual(action.count, 5)
+            else:
+                self.fail('Too many substrings.')
+        self.assertEqual(i, 3, 'Too few substrings.')
+
 
 if __name__ == '__main__':
     unittest.main()