Skip to content

Commit 65670de

Browse files
committed
gh-132267: fix desynchronized cursor position and buffer mismatch after resize
1 parent 2fd09b0 commit 65670de

File tree

8 files changed

+72
-22
lines changed

8 files changed

+72
-22
lines changed

Lib/_pyrepl/console.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ def __init__(
7171
self.output_fd = f_out.fileno()
7272

7373
@abstractmethod
74-
def refresh(self, screen: list[str], xy: tuple[int, int]) -> None: ...
74+
def refresh(self, screen: list[str], xy: tuple[int, int], clear_to_end: bool = False) -> None: ...
7575

7676
@abstractmethod
7777
def prepare(self) -> None: ...
@@ -82,6 +82,9 @@ def restore(self) -> None: ...
8282
@abstractmethod
8383
def move_cursor(self, x: int, y: int) -> None: ...
8484

85+
@abstractmethod
86+
def reset_cursor(self) -> None: ...
87+
8588
@abstractmethod
8689
def set_cursor_vis(self, visible: bool) -> None: ...
8790

Lib/_pyrepl/reader.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,9 @@ def calc_screen(self) -> list[str]:
344344
pos -= line_len + 1
345345
prompt, prompt_len = self.process_prompt(prompt)
346346
chars, char_widths = disp_str(line, colors, offset)
347-
wrapcount = (sum(char_widths) + prompt_len) // self.console.width
347+
wrapcount = (sum(char_widths) + prompt_len) // (self.console.width - 1) # 1 for line continuations
348+
if (sum(char_widths) + prompt_len) % (self.console.width - 1) == 0:
349+
wrapcount -= 1
348350
if wrapcount == 0 or not char_widths:
349351
offset += line_len + 1 # Takes all of the line plus the newline
350352
last_refresh_line_end_offsets.append(offset)
@@ -639,6 +641,17 @@ def refresh(self) -> None:
639641
self.console.refresh(self.screen, self.cxy)
640642
self.dirty = False
641643

644+
def handle_resize(self) -> None:
645+
"""Handle a resize event."""
646+
self.console.height, self.console.width = self.console.getheightwidth()
647+
self.console.reset_cursor()
648+
ns = len(self.console.screen) * ["\000" * self.console.width]
649+
self.console.screen = ns
650+
651+
self.screen = self.calc_screen()
652+
self.console.refresh(self.screen, self.cxy, clear_to_end=True)
653+
self.dirty = True
654+
642655
def do_cmd(self, cmd: tuple[str, list[str]]) -> None:
643656
"""`cmd` is a tuple of "event_name" and "event", which in the current
644657
implementation is always just the "buffer" which happens to be a list
@@ -714,7 +727,7 @@ def handle1(self, block: bool = True) -> bool:
714727
elif event.evt == "scroll":
715728
self.refresh()
716729
elif event.evt == "resize":
717-
self.refresh()
730+
self.handle_resize()
718731
else:
719732
translate = False
720733

Lib/_pyrepl/unix_console.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ def change_encoding(self, encoding: str) -> None:
223223
"""
224224
self.encoding = encoding
225225

226-
def refresh(self, screen, c_xy):
226+
def refresh(self, screen, c_xy, clear_to_end = False):
227227
"""
228228
Refresh the console screen.
229229
@@ -271,8 +271,9 @@ def refresh(self, screen, c_xy):
271271
self.posxy = 0, old_offset
272272
for i in range(old_offset - offset):
273273
self.__write_code(self._ri)
274-
oldscr.pop(-1)
275274
oldscr.insert(0, "")
275+
if len(oldscr) > height:
276+
oldscr.pop(-1)
276277
elif old_offset < offset and self._ind:
277278
self.__hide_cursor()
278279
self.__write_code(self._cup, self.height - 1, 0)
@@ -300,6 +301,12 @@ def refresh(self, screen, c_xy):
300301
self.__write_code(self._el)
301302
y += 1
302303

304+
if clear_to_end:
305+
self.__move(wlen(newscr[-1]), len(newscr) - 1 + self.__offset)
306+
self.posxy = wlen(newscr[-1]), len(newscr) - 1 + self.__offset
307+
self.__write_code(b"\x1b[J") # clear to end of line
308+
self.flushoutput()
309+
303310
self.__show_cursor()
304311

305312
self.screen = screen.copy()
@@ -321,6 +328,10 @@ def move_cursor(self, x, y):
321328
self.posxy = x, y
322329
self.flushoutput()
323330

331+
def reset_cursor(self) -> None:
332+
self.posxy = 0, self.__offset
333+
self.__write_code(self._cup, 0, 0)
334+
324335
def prepare(self):
325336
"""
326337
Prepare the console for input/output operations.
@@ -683,13 +694,18 @@ def __write_changed_line(self, y, oldline, newline, px_coord):
683694
self.__write(newline[x_pos])
684695
self.posxy = character_width + 1, y
685696

697+
if newline[-1] != oldline[-1]:
698+
self.__move(self.width, y)
699+
self.__write(newline[-1])
700+
self.posxy = self.width - 1, y
701+
686702
else:
687703
self.__hide_cursor()
688704
self.__move(x_coord, y)
689705
if wlen(oldline) > wlen(newline):
690706
self.__write_code(self._el)
691707
self.__write(newline[x_pos:])
692-
self.posxy = wlen(newline), y
708+
self.posxy = min(wlen(newline), self.width - 1), y
693709

694710
if "\x1b" in newline:
695711
# ANSI escape characters are present, so we can't assume
@@ -752,7 +768,6 @@ def __move_tall(self, x, y):
752768
self.__write_code(self._cup, y - self.__offset, x)
753769

754770
def __sigwinch(self, signum, frame):
755-
self.height, self.width = self.getheightwidth()
756771
self.event_queue.insert(Event("resize", None))
757772

758773
def __hide_cursor(self):

Lib/_pyrepl/windows_console.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ def __init__(self, err: int | None, descr: str | None = None) -> None:
111111
MOVE_UP = "\x1b[{}A"
112112
MOVE_DOWN = "\x1b[{}B"
113113
CLEAR = "\x1b[H\x1b[J"
114+
HOME = "\x1b[H"
114115

115116
# State of control keys: https://learn.microsoft.com/en-us/windows/console/key-event-record-str
116117
ALT_ACTIVE = 0x01 | 0x02
@@ -171,7 +172,7 @@ def __init__(
171172
# Console I/O is redirected, fallback...
172173
self.out = None
173174

174-
def refresh(self, screen: list[str], c_xy: tuple[int, int]) -> None:
175+
def refresh(self, screen: list[str], c_xy: tuple[int, int], clear_to_end: bool = False) -> None:
175176
"""
176177
Refresh the console screen.
177178
@@ -234,6 +235,12 @@ def refresh(self, screen: list[str], c_xy: tuple[int, int]) -> None:
234235
self._erase_to_end()
235236
y += 1
236237

238+
if clear_to_end:
239+
self._move_relative(wlen(newscr[-1]), self.__offset + len(newscr) - 1)
240+
self.posxy = wlen(newscr[-1]), self.__offset + len(newscr) - 1
241+
self.__write("\x1b[J")
242+
self.flushoutput()
243+
237244
self._show_cursor()
238245

239246
self.screen = screen
@@ -287,7 +294,7 @@ def __write_changed_line(
287294
self._move_relative(0, y + 1)
288295
self.posxy = 0, y + 1
289296
else:
290-
self.posxy = wlen(newline), y
297+
self.posxy = min(wlen(newline), self.width - 1), y
291298

292299
if "\x1b" in newline or y != self.posxy[1] or '\x1a' in newline:
293300
# ANSI escape characters are present, so we can't assume
@@ -395,6 +402,10 @@ def move_cursor(self, x: int, y: int) -> None:
395402
self._move_relative(x, y)
396403
self.posxy = x, y
397404

405+
def reset_cursor(self) -> None:
406+
self.posxy = 0, self.__offset
407+
self.__write(HOME)
408+
398409
def set_cursor_vis(self, visible: bool) -> None:
399410
if visible:
400411
self._show_cursor()

Lib/test/test_pyrepl/support.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ def getpending(self) -> Event:
131131
def getheightwidth(self) -> tuple[int, int]:
132132
return self.height, self.width
133133

134-
def refresh(self, screen: list[str], xy: tuple[int, int]) -> None:
134+
def refresh(self, screen: list[str], xy: tuple[int, int], clear_to_end: bool = False) -> None:
135135
pass
136136

137137
def prepare(self) -> None:
@@ -143,6 +143,9 @@ def restore(self) -> None:
143143
def move_cursor(self, x: int, y: int) -> None:
144144
pass
145145

146+
def reset_cursor(self) -> None:
147+
pass
148+
146149
def set_cursor_vis(self, visible: bool) -> None:
147150
pass
148151

Lib/test/test_pyrepl/test_unix_console.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ def test_resize_bigger_on_multiline_function(self, _os_write):
258258
reader, console = handle_events_short_unix_console(events)
259259

260260
console.height = 2
261-
console.getheightwidth = MagicMock(lambda _: (2, 80))
261+
console.getheightwidth = MagicMock(return_value=(2, 80))
262262

263263
def same_reader(_):
264264
return reader
@@ -294,7 +294,7 @@ def test_resize_smaller_on_multiline_function(self, _os_write):
294294
reader, console = handle_events_unix_console_height_3(events)
295295

296296
console.height = 1
297-
console.getheightwidth = MagicMock(lambda _: (1, 80))
297+
console.getheightwidth = MagicMock(return_value=(1, 80))
298298

299299
def same_reader(_):
300300
return reader

Lib/test/test_pyrepl/test_windows_console.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
MOVE_UP,
2525
MOVE_DOWN,
2626
ERASE_IN_LINE,
27+
HOME,
2728
)
2829
import _pyrepl.windows_console as wc
2930
except ImportError:
@@ -102,7 +103,7 @@ def test_resize_wider(self):
102103

103104
console.height = 20
104105
console.width = 80
105-
console.getheightwidth = MagicMock(lambda _: (20, 80))
106+
console.getheightwidth = MagicMock(return_value=(20, 80))
106107

107108
def same_reader(_):
108109
return reader
@@ -117,9 +118,8 @@ def same_console(events):
117118
prepare_console=same_console,
118119
)
119120

120-
con.out.write.assert_any_call(self.move_right(2))
121-
con.out.write.assert_any_call(self.move_up(2))
122-
con.out.write.assert_any_call(b"567890")
121+
con.out.write.assert_any_call(self.home())
122+
con.out.write.assert_any_call(b"1234567890")
123123

124124
con.restore()
125125

@@ -130,7 +130,7 @@ def test_resize_narrower(self):
130130

131131
console.height = 20
132132
console.width = 4
133-
console.getheightwidth = MagicMock(lambda _: (20, 4))
133+
console.getheightwidth = MagicMock(return_value=(20, 4))
134134

135135
def same_reader(_):
136136
return reader
@@ -264,7 +264,7 @@ def test_resize_bigger_on_multiline_function(self):
264264
reader, console = self.handle_events_short(events)
265265

266266
console.height = 2
267-
console.getheightwidth = MagicMock(lambda _: (2, 80))
267+
console.getheightwidth = MagicMock(return_value=(2, 80))
268268

269269
def same_reader(_):
270270
return reader
@@ -280,8 +280,9 @@ def same_console(events):
280280
)
281281
con.out.write.assert_has_calls(
282282
[
283-
call(self.move_left(5)),
283+
call(self.home()),
284284
call(self.move_up()),
285+
call(self.erase_in_line()),
285286
call(b"def f():"),
286287
call(self.move_left(3)),
287288
call(self.move_down()),
@@ -302,7 +303,7 @@ def test_resize_smaller_on_multiline_function(self):
302303
reader, console = self.handle_events_height_3(events)
303304

304305
console.height = 1
305-
console.getheightwidth = MagicMock(lambda _: (1, 80))
306+
console.getheightwidth = MagicMock(return_value=(1, 80))
306307

307308
def same_reader(_):
308309
return reader
@@ -318,8 +319,7 @@ def same_console(events):
318319
)
319320
con.out.write.assert_has_calls(
320321
[
321-
call(self.move_left(5)),
322-
call(self.move_up()),
322+
call(self.home()),
323323
call(self.erase_in_line()),
324324
call(b" foo"),
325325
]
@@ -342,6 +342,9 @@ def move_right(self, cols=1):
342342
def erase_in_line(self):
343343
return ERASE_IN_LINE.encode("utf8")
344344

345+
def home(self):
346+
return HOME.encode("utf8")
347+
345348
def test_multiline_ctrl_z(self):
346349
# see gh-126332
347350
code = "abcdefghi"
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
In the New REPL, resize triggers redraw now. Also fixed cross-line width
2+
calculation and content rendering during scrolling.

0 commit comments

Comments
 (0)