From 05d9e9e8329e9813e942692f5afa5f75b1a99eda Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 26 May 2026 10:14:15 -0700 Subject: [PATCH 1/2] gh-150462: Fix cursor position when using `\\b` Fixes length miscalculation when dealing with \b character that caused the cursor to be placed in an incorrect position --- Lib/_pyrepl/utils.py | 20 ++++++++++++++------ Lib/test/test_pyrepl/test_reader.py | 6 ++++++ Lib/test/test_pyrepl/test_utils.py | 7 +++++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/Lib/_pyrepl/utils.py b/Lib/_pyrepl/utils.py index b50426c31ead53a..da881a6051a5e66 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 + cleaned = ANSI_ESCAPE_SEQUENCE.sub("", s) + for c in cleaned: + 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 From 6de4d52071fe9df5dd739d6acbea5b18432394d4 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 26 May 2026 11:48:37 -0700 Subject: [PATCH 2/2] Rename variable --- Lib/_pyrepl/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/_pyrepl/utils.py b/Lib/_pyrepl/utils.py index da881a6051a5e66..05bcae932cc8e9a 100644 --- a/Lib/_pyrepl/utils.py +++ b/Lib/_pyrepl/utils.py @@ -97,8 +97,8 @@ def wlen(s: str) -> int: return str_width(s) length = 0 # Strip ANSI escapes first - cleaned = ANSI_ESCAPE_SEQUENCE.sub("", s) - for c in cleaned: + 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)