diff --git a/Lib/_pyrepl/windows_console.py b/Lib/_pyrepl/windows_console.py index 303af8a354ff00..1ef9601e3923ce 100644 --- a/Lib/_pyrepl/windows_console.py +++ b/Lib/_pyrepl/windows_console.py @@ -247,6 +247,18 @@ def input_hook(self): if nt is not None and nt._is_inputhook_installed(): return nt._inputhook + def _has_wrapped_to_next_row(self, y: int) -> bool: + """ + Return True if the real console cursor wrapped to the next visible row. + """ + info = CONSOLE_SCREEN_BUFFER_INFO() + if not GetConsoleScreenBufferInfo(OutHandle, info): + raise WinError(get_last_error()) + + win_y = int(info.dwCursorPosition.Y - info.srWindow.Top) + expected = y - self.__offset + return win_y == expected + 1 + def __write_changed_line( self, y: int, oldline: str, newline: str, px_coord: int ) -> None: @@ -272,9 +284,11 @@ def __write_changed_line( self.__write(newline[x_pos:]) if wlen(newline) == self.width: - # If we wrapped we want to start at the next line - self._move_relative(0, y + 1) - self.posxy = 0, y + 1 + if self._has_wrapped_to_next_row(y): + self.posxy = 0, y + 1 + else: + # Terminal did not wrap; cursor stays at end-of-line. + self.posxy = self.width, y else: self.posxy = wlen(newline), y diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 35a1733787e7a2..c66d926f10cf32 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -12,7 +12,7 @@ import tempfile from pkgutil import ModuleInfo from unittest import TestCase, skipUnless, skipIf, SkipTest -from unittest.mock import patch +from unittest.mock import Mock, patch from test.support import force_not_colorized, make_clean_env, Py_DEBUG from test.support import has_subprocess_support, SHORT_TIMEOUT, STDLIB_DIR from test.support.import_helper import import_module @@ -2105,3 +2105,96 @@ def test_ctrl_d_single_line_end_no_newline(self): ) reader, _ = handle_all_events(events) self.assertEqual("hello", "".join(reader.buffer)) + + +@skipUnless(sys.platform == "win32", "Windows console behavior only") +class TestWindowsConsoleEolWrap(TestCase): + """ + When a line exactly fills the terminal width, Windows consoles differ on + whether the cursor immediately wraps to the next row (depends on host/mode). + We use _has_wrapped_to_next_row() to determine the actual behavior. + """ + + def _make_console_like(self, *, width: int, offset: int, vt: bool): + from _pyrepl import windows_console as wc + + con = object.__new__(wc.WindowsConsole) + + # Minimal state needed by __write_changed_line() + con.width = width + con.screen = [] + con.posxy = (0, 0) + setattr(con, "_WindowsConsole__offset", offset) + setattr(con, "_WindowsConsole__vt_support", vt) + + # Stub out side-effecting methods used by __write_changed_line() + con._hide_cursor = Mock() + con._erase_to_end = Mock() + con._move_relative = Mock() + con.move_cursor = Mock() + setattr(con, "_WindowsConsole__write", Mock()) + + return con, wc + + def _run_exact_width_case(self, *, vt: bool, did_wrap: bool): + width = 10 + y = 3 + con, wc = self._make_console_like(width=width, offset=0, vt=vt) + + with patch.object(con, "_has_wrapped_to_next_row", return_value=did_wrap): + old = "" + new = "a" * width + wc.WindowsConsole._WindowsConsole__write_changed_line(con, y, old, new, 0) + + if did_wrap: + self.assertEqual(con.posxy, (0, y + 1)) + self.assertNotIn((0, y + 1), [c.args for c in con._move_relative.mock_calls]) + else: + self.assertEqual(con.posxy, (width, y)) + self.assertNotIn((0, y + 1), [c.args for c in con._move_relative.mock_calls]) + + def test_exact_width_line_did_wrap_vt_and_legacy(self): + for vt in (True, False): + with self.subTest(vt=vt): + self._run_exact_width_case(vt=vt, did_wrap=True) + + def test_exact_width_line_did_not_wrap_vt_and_legacy(self): + for vt in (True, False): + with self.subTest(vt=vt): + self._run_exact_width_case(vt=vt, did_wrap=False) + + +@skipUnless(sys.platform == "win32", "Windows console behavior only") +class TestHasWrappedToNextRow(TestCase): + def _make_console_like(self, *, offset: int): + from _pyrepl import windows_console as wc + + con = object.__new__(wc.WindowsConsole) + setattr(con, "_WindowsConsole__offset", offset) + return con, wc + + def test_returns_true_when_wrapped(self): + con, wc = self._make_console_like(offset=0) + y = 3 + + def fake_gcsbi(_h, info): + info.srWindow.Top = 0 + info.dwCursorPosition.Y = y + 1 + return True + + with patch.object(wc, "GetConsoleScreenBufferInfo", side_effect=fake_gcsbi), \ + patch.object(wc, "OutHandle", 1): + self.assertIs(con._has_wrapped_to_next_row(y), True) + + def test_returns_false_when_not_wrapped(self): + con, wc = self._make_console_like(offset=0) + y = 3 + + def fake_gcsbi(_h, info): + info.srWindow.Top = 0 + info.dwCursorPosition.Y = y + return True + + with patch.object(wc, "GetConsoleScreenBufferInfo", side_effect=fake_gcsbi), \ + patch.object(wc, "OutHandle", 1): + self.assertIs(con._has_wrapped_to_next_row(y), False) diff --git a/Lib/test/test_pyrepl/test_windows_console.py b/Lib/test/test_pyrepl/test_windows_console.py index 3587b834f3cd07..cfa9640e466594 100644 --- a/Lib/test/test_pyrepl/test_windows_console.py +++ b/Lib/test/test_pyrepl/test_windows_console.py @@ -41,6 +41,7 @@ def console(self, events, **kwargs) -> Console: console._hide_cursor = MagicMock() console._show_cursor = MagicMock() console._getscrollbacksize = MagicMock(42) + console._has_wrapped_to_next_row = MagicMock(return_value=False) console.out = MagicMock() height = kwargs.get("height", 25) diff --git a/Misc/NEWS.d/next/Library/2026-01-28-06-50-37.gh-issue-144259.Xslknn.rst b/Misc/NEWS.d/next/Library/2026-01-28-06-50-37.gh-issue-144259.Xslknn.rst new file mode 100644 index 00000000000000..f50ecc24c17c6d --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-01-28-06-50-37.gh-issue-144259.Xslknn.rst @@ -0,0 +1 @@ +Fix Windows REPL cursor desynchronization when a line exactly fills the terminal width.