Skip to content

Commit 4fe773e

Browse files
pablogsalclaude
andcommitted
Fix _pyrepl rendering bugs and harden refresh pipeline
- Fix cleared_lines using viewport-relative indices instead of absolute coordinates in both unix_console and windows_console (crashes in tall mode when viewport is scrolled) - Fix _build_source_lines guard failing for single-line buffers on the reuse_full cache path (phantom blank prompt row) - Add assertion in reuse_full path to catch buffer mutations without invalidation - Extract _compute_lxy() helper to deduplicate cursor position logic and replace buf[::-1].index() with str.rindex() - Give RefreshCache fields proper defaults instead of field(init=False) - Add assertion and docstring to overlay _compose() method - Remove dead WrappedRow.line_end_offset field - Add docstring to RefreshCache.get_cached_location() - Add explanatory comments for combining-char diff extension and build_body_fragments dual-loop optimization - Use str.replace() instead of list comprehension for \x1a substitution Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bb69ad9 commit 4fe773e

File tree

6 files changed

+41
-19
lines changed

6 files changed

+41
-19
lines changed

Lib/_pyrepl/content.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def build_body_fragments(
5959
colors: list[ColorSpan] | None,
6060
start_index: int,
6161
) -> tuple[ContentFragment, ...]:
62+
# Two separate loops to avoid the THEME() call in the common uncolored path.
6263
if colors is None:
6364
return tuple(
6465
ContentFragment(

Lib/_pyrepl/layout.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,6 @@ class WrappedRow:
102102
suffix: str = ""
103103
suffix_width: int = 0
104104
buffer_advance: int = 0
105-
line_end_offset: int = 0
106105

107106

108107
@dataclass(frozen=True, slots=True)
@@ -132,7 +131,6 @@ def layout_content_lines(
132131
wrapped_rows.append(
133132
WrappedRow(
134133
fragments=(leading,),
135-
line_end_offset=offset,
136134
)
137135
)
138136
layout_rows.append(LayoutRow(0, (), buffer_advance=0))
@@ -152,7 +150,6 @@ def layout_content_lines(
152150
fragments=body,
153151
layout_widths=body_widths,
154152
buffer_advance=len(body) + newline_advance,
155-
line_end_offset=offset,
156153
)
157154
)
158155
layout_rows.append(
@@ -205,7 +202,6 @@ def layout_content_lines(
205202
suffix=suffix,
206203
suffix_width=suffix_width,
207204
buffer_advance=buffer_advance,
208-
line_end_offset=offset,
209205
)
210206
)
211207
layout_rows.append(

Lib/_pyrepl/reader.py

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -309,10 +309,10 @@ class Reader:
309309
@dataclass
310310
class RefreshCache:
311311
render_lines: list[RenderLine] = field(default_factory=list)
312-
layout_rows: list[LayoutRow] = field(init=False)
312+
layout_rows: list[LayoutRow] = field(default_factory=list)
313313
line_end_offsets: list[int] = field(default_factory=list)
314-
pos: int = field(init=False)
315-
dimensions: Dimensions = field(init=False)
314+
pos: int = 0
315+
dimensions: Dimensions = (0, 0)
316316

317317
def update_cache(self,
318318
reader: Reader,
@@ -338,6 +338,13 @@ def get_cached_location(
338338
*,
339339
reuse_full: bool = False,
340340
) -> tuple[int, int]:
341+
"""Return (buffer_offset, num_reusable_lines) for incremental refresh.
342+
343+
Three paths:
344+
- reuse_full (overlay/message-only): reuse all cached lines.
345+
- buffer_from_pos=None (full rebuild): rewind to common cursor pos.
346+
- explicit buffer_from_pos: reuse lines before that position.
347+
"""
341348
if reuse_full:
342349
if self.line_end_offsets:
343350
last_offset = self.line_end_offsets[-1]
@@ -409,6 +416,9 @@ def calc_screen(self) -> RenderedScreen:
409416
self,
410417
reuse_full=True,
411418
)
419+
assert not self.last_refresh_cache.line_end_offsets or (
420+
self.last_refresh_cache.line_end_offsets[-1] >= len(self.buffer)
421+
), "Buffer modified without invalidate_buffer() call"
412422
else:
413423
offset, num_common_lines = self.last_refresh_cache.get_cached_location(
414424
self,
@@ -435,14 +445,7 @@ def calc_screen(self) -> RenderedScreen:
435445
if not source_lines:
436446
# reuse_full path: _build_source_lines didn't run,
437447
# so lxy wasn't updated. Derive it from the buffer.
438-
buf = self.buffer[:self.pos]
439-
lineno = buf.count("\n")
440-
if lineno:
441-
last_nl = len(buf) - 1 - buf[::-1].index("\n")
442-
col = self.pos - last_nl - 1
443-
else:
444-
col = self.pos
445-
self.lxy = col, lineno
448+
self.lxy = self._compute_lxy()
446449
self.last_refresh_cache.update_cache(
447450
self,
448451
base_render_lines,
@@ -461,12 +464,22 @@ def _buffer_refresh_from_pos(self) -> int:
461464
return buffer_from_pos
462465
return 0
463466

467+
def _compute_lxy(self) -> CursorXY:
468+
"""Derive logical cursor (col, lineno) from the buffer and pos."""
469+
text = "".join(self.buffer[:self.pos])
470+
lineno = text.count("\n")
471+
if lineno:
472+
col = self.pos - text.rindex("\n") - 1
473+
else:
474+
col = self.pos
475+
return col, lineno
476+
464477
def _build_source_lines(
465478
self,
466479
offset: int,
467480
first_lineno: int,
468481
) -> tuple[SourceLine, ...]:
469-
if offset == len(self.buffer) and first_lineno > 0:
482+
if offset == len(self.buffer) and offset > 0:
470483
return ()
471484

472485
pos = self.pos - offset

Lib/_pyrepl/render.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,11 @@ def from_rendered_text(cls, text: str) -> Self:
137137

138138
@dataclass(frozen=True, slots=True)
139139
class ScreenOverlay:
140+
"""An overlay that replaces or inserts lines at a screen position.
141+
142+
If insert is True, lines are spliced in (shifting content down);
143+
if False (default), lines replace existing content at y.
144+
"""
140145
y: int
141146
lines: tuple[RenderLine, ...]
142147
insert: bool = False
@@ -153,13 +158,18 @@ def __post_init__(self) -> None:
153158
object.__setattr__(self, "composed_lines", self._compose())
154159

155160
def _compose(self) -> tuple[RenderLine, ...]:
161+
"""Apply overlays in tuple order; inserts shift subsequent positions."""
156162
if not self.overlays:
157163
return self.lines
158164

159165
lines = list(self.lines)
160166
y_offset = 0
161167
for overlay in self.overlays:
162168
adjusted_y = overlay.y + y_offset
169+
assert adjusted_y >= 0, (
170+
f"Overlay y={overlay.y} with offset={y_offset} is negative; "
171+
"overlays must be sorted by ascending y"
172+
)
163173
if overlay.insert:
164174
lines[adjusted_y:adjusted_y] = overlay.lines
165175
y_offset += len(overlay.lines)
@@ -308,6 +318,8 @@ def diff_render_lines(old: RenderLine, new: RenderLine) -> LineDiff | None:
308318
old_suffix -= 1
309319
new_suffix -= 1
310320

321+
# Extend diff range to include trailing zero-width combining characters,
322+
# so we never render a combining char without its base character.
311323
while old_suffix < len(old.cells) and old.cells[old_suffix].width == 0:
312324
old_suffix += 1
313325
while new_suffix < len(new.cells) and new.cells[new_suffix].width == 0:

Lib/_pyrepl/unix_console.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ def __plan_refresh(
346346
if update is not None:
347347
line_updates.append(update)
348348

349-
cleared_lines = tuple(range(len(newscr), len(oldscr)))
349+
cleared_lines = tuple(range(offset + len(newscr), offset + len(oldscr)))
350350
console_rendered_screen = RenderedScreen(tuple(next_lines), c_xy)
351351
trace(
352352
"unix.refresh plan grow={grow} tall={tall} offset={offset} "

Lib/_pyrepl/windows_console.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ def __plan_refresh(
248248
if update is not None:
249249
line_updates.append(update)
250250

251-
cleared_lines = tuple(range(len(newscr), len(oldscr)))
251+
cleared_lines = tuple(range(offset + len(newscr), offset + len(oldscr)))
252252
console_rendered_screen = RenderedScreen(tuple(next_lines), c_xy)
253253
trace(
254254
"windows.refresh plan grow={grow} offset={offset} scroll_lines={scroll_lines} "
@@ -446,7 +446,7 @@ def _disable_bracketed_paste(self) -> None:
446446

447447
def __write(self, text: str) -> None:
448448
if "\x1a" in text:
449-
text = ''.join(["^Z" if x == '\x1a' else x for x in text])
449+
text = text.replace("\x1a", "^Z")
450450

451451
if self.out is not None:
452452
self.out.write(text.encode(self.encoding, "replace"))

0 commit comments

Comments
 (0)