diff --git a/Lib/_pyrepl/utils.py b/Lib/_pyrepl/utils.py index b50426c31ead53a..05bcae932cc8e9a 100644 --- a/Lib/_pyrepl/utils.py +++ b/Lib/_pyrepl/utils.py @@ -93,13 +93,21 @@ def str_width(c: str) -> int: def wlen(s: str) -> int: - if len(s) == 1 and s != "\x1a": + if len(s) == 1 and s!= "\x1a": return str_width(s) - length = sum(str_width(i) for i in s) - # remove lengths of any escape sequences - sequence = ANSI_ESCAPE_SEQUENCE.findall(s) - ctrl_z_cnt = s.count("\x1a") - return length - sum(len(i) for i in sequence) + ctrl_z_cnt + length = 0 + # Strip ANSI escapes first + plain = ANSI_ESCAPE_SEQUENCE.sub("", s) + for c in plain: + if c == "\b": + # Backspace decreases cursor position, and cannot move before column 0. + length = max(length - 1, 0) + elif c == "\x1a": + # Control-Z is treated as width 2 to account for the fact that it will be rendered as ^Z + length += 2 + else: + length += str_width(c) + return length def unbracket(s: str, including_content: bool = False) -> str: diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index 0b32ead357c2c0d..01043ebcbd0c717 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -359,6 +359,12 @@ def test_prompt_length(self): self.assertEqual(prompt, "\033[0;32m樂>\033[0m> ") self.assertEqual(l, 5) + # Handles backspace in prompt + ps1 = "Question? _\b" + prompt, l = Reader.process_prompt(ps1) + self.assertEqual(prompt, ps1) + self.assertEqual(l, 10) + def test_prepare_with_zero_width_does_not_crash(self): console = prepare_console([], width=0) reader = ReadlineAlikeReader(console=console, config=ReadlineConfig()) diff --git a/Lib/test/test_pyrepl/test_utils.py b/Lib/test/test_pyrepl/test_utils.py index 3c55b6bdaeee9e8..637905c5e74ecc3 100644 --- a/Lib/test/test_pyrepl/test_utils.py +++ b/Lib/test/test_pyrepl/test_utils.py @@ -47,6 +47,13 @@ def test_wlen(self): self.assertEqual(wlen('e\N{COMBINING ACUTE ACCENT}'), 1) self.assertEqual(wlen('a\N{ZERO WIDTH JOINER}b'), 2) + # Backspace decreases cursor position, and cannot move before column 0. + self.assertEqual(wlen('Question? _\b'), 10) + self.assertEqual(wlen('ab\b'), 1) + self.assertEqual(wlen('ab\b\b'), 0) + self.assertEqual(wlen('\babc'), 3) + self.assertEqual(wlen('樂\b'), 1) + def test_prev_next_window(self): def gen_normal(): yield 1