diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 5510b3db..1b398a41 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -8,6 +8,13 @@ Changes in Jupyter Qt console 5.6 ~~~ +5.6.1 +----- + +`5.6.1 on GitHub `__ + +* Handle ANSI escape sequences that move the cursor. + 5.6.0 ----- diff --git a/qtconsole/ansi_code_processor.py b/qtconsole/ansi_code_processor.py index 063c9067..16f2ddde 100644 --- a/qtconsole/ansi_code_processor.py +++ b/qtconsole/ansi_code_processor.py @@ -92,10 +92,7 @@ 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 + 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 @@ def split_string(self, string): 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 @@ def split_string(self, string): 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 @@ def set_csi_code(self, command, params=[]): 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 92a30435..38806a59 100644 --- a/qtconsole/console_widget.py +++ b/qtconsole/console_widget.py @@ -2222,6 +2222,27 @@ def _insert_plain_text(self, cursor, text, flush=False): 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, @@ -2237,7 +2258,19 @@ def _insert_plain_text(self, cursor, text, flush=False): 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: diff --git a/qtconsole/tests/test_ansi_code_processor.py b/qtconsole/tests/test_ansi_code_processor.py index 2b7dd71f..ed00d631 100644 --- a/qtconsole/tests/test_ansi_code_processor.py +++ b/qtconsole/tests/test_ansi_code_processor.py @@ -139,7 +139,7 @@ def test_carriage_return_newline(self): 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 @@ def test_combined(self): 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()