Skip to content

Commit 2e22dce

Browse files
committed
Switch _pyrepl consoles to rendered screens
Teach the reader and terminal backends to refresh from RenderedScreen objects. This isolates the redraw-planning refactor before layout and styling changes land.
1 parent 136590d commit 2e22dce

11 files changed

Lines changed: 729 additions & 242 deletions

File tree

Lib/_pyrepl/commands.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
# finishing
3333
# [completion]
3434

35+
from .render import RenderedScreen
3536
from .trace import trace
3637

3738
# types
@@ -131,17 +132,20 @@ def do(self) -> None:
131132
class clear_screen(Command):
132133
def do(self) -> None:
133134
r = self.reader
135+
trace("command.clear_screen")
134136
r.console.clear()
135137
r.dirty = True
136138

137139

138140
class refresh(Command):
139141
def do(self) -> None:
142+
trace("command.refresh")
140143
self.reader.dirty = True
141144

142145

143146
class repaint(Command):
144147
def do(self) -> None:
148+
trace("command.repaint")
145149
self.reader.dirty = True
146150
self.reader.console.repaint()
147151

@@ -243,7 +247,8 @@ def do(self) -> None:
243247
r.pos = p
244248
# r.posxy = 0, 0 # XXX this is invalid
245249
r.dirty = True
246-
r.console.screen = []
250+
trace("command.suspend sync_rendered_screen")
251+
r.console.sync_rendered_screen(RenderedScreen.empty(), r.console.posxy)
247252

248253

249254
class up(MotionCommand):
@@ -478,8 +483,11 @@ def do(self) -> None:
478483

479484
# We need to copy over the state so that it's consistent between
480485
# console and reader, and console does not overwrite/append stuff
481-
self.reader.console.screen = self.reader.screen.copy()
482-
self.reader.console.posxy = self.reader.cxy
486+
trace("command.show_history sync_rendered_screen")
487+
self.reader.console.sync_rendered_screen(
488+
self.reader.rendered_screen,
489+
self.reader.cxy,
490+
)
483491

484492

485493
class paste_mode(Command):

Lib/_pyrepl/completing_reader.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
import re
2626
from . import commands, console, reader
27+
from .render import RenderLine, RenderedScreen
2728
from .reader import Reader
2829

2930

@@ -257,19 +258,24 @@ def after_command(self, cmd: Command) -> None:
257258
if not isinstance(cmd, (complete, self_insert)):
258259
self.cmpltn_reset()
259260

260-
def calc_screen(self) -> list[str]:
261-
screen = super().calc_screen()
261+
def calc_screen(self) -> RenderedScreen:
262+
rendered_screen = super().calc_screen()
262263
if self.cmpltn_menu_visible:
263264
# We display the completions menu below the current prompt
264265
ly = self.lxy[1] + 1
265-
screen[ly:ly] = self.cmpltn_menu
266+
render_lines = list(rendered_screen.lines)
267+
render_lines[ly:ly] = [
268+
RenderLine.from_rendered_text(line) for line in self.cmpltn_menu
269+
]
270+
rendered_screen = RenderedScreen(tuple(render_lines), self.cxy)
271+
self.rendered_screen = rendered_screen
266272
# If we're not in the middle of multiline edit, don't append to screeninfo
267273
# since that screws up the position calculation in pos2xy function.
268274
# This is a hack to prevent the cursor jumping
269275
# into the completions menu when pressing left or down arrow.
270276
if self.pos != len(self.buffer):
271277
self.screeninfo[ly:ly] = [(0, [])]*len(self.cmpltn_menu)
272-
return screen
278+
return rendered_screen
273279

274280
def finish(self) -> None:
275281
super().finish()

Lib/_pyrepl/console.py

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,20 @@
1919

2020
from __future__ import annotations
2121

22+
import os
2223
import _colorize
2324

2425
from abc import ABC, abstractmethod
2526
import ast
2627
import code
2728
import linecache
28-
from dataclasses import dataclass, field
29-
import os.path
29+
from dataclasses import dataclass
3030
import re
3131
import sys
3232

33+
from .render import RenderedScreen
34+
from .trace import trace
35+
3336

3437
TYPE_CHECKING = False
3538

@@ -47,10 +50,17 @@ class Event:
4750

4851
@dataclass
4952
class Console(ABC):
50-
posxy: tuple[int, int]
51-
screen: list[str] = field(default_factory=list)
53+
posxy: tuple[int, int] = (0, 0)
5254
height: int = 25
5355
width: int = 80
56+
_redraw_debug_palette: tuple[str, ...] = (
57+
"\x1b[41m",
58+
"\x1b[42m",
59+
"\x1b[43m",
60+
"\x1b[44m",
61+
"\x1b[45m",
62+
"\x1b[46m",
63+
)
5464

5565
def __init__(
5666
self,
@@ -71,8 +81,58 @@ def __init__(
7181
else:
7282
self.output_fd = f_out.fileno()
7383

84+
self.posxy = (0, 0)
85+
self.height = 25
86+
self.width = 80
87+
self._rendered_screen = RenderedScreen.empty()
88+
self._redraw_visual_cycle = 0
89+
90+
@property
91+
def screen(self) -> list[str]:
92+
return list(self._rendered_screen.screen_lines)
93+
94+
def sync_rendered_screen(
95+
self,
96+
rendered_screen: RenderedScreen,
97+
posxy: tuple[int, int] | None = None,
98+
) -> None:
99+
if posxy is None:
100+
posxy = rendered_screen.cursor
101+
self.posxy = posxy
102+
self._rendered_screen = rendered_screen
103+
trace(
104+
"console.sync_rendered_screen lines={lines} cursor={cursor}",
105+
lines=len(rendered_screen.lines),
106+
cursor=posxy,
107+
)
108+
109+
def invalidate_render_state(self) -> None:
110+
self._rendered_screen = RenderedScreen.empty()
111+
trace("console.invalidate_render_state")
112+
113+
def begin_redraw_visualization(self) -> str | None:
114+
if "PYREPL_VISUALIZE_REDRAWS" not in os.environ:
115+
return None
116+
117+
palette = self._redraw_debug_palette
118+
cycle = self._redraw_visual_cycle
119+
style = palette[cycle % len(palette)]
120+
self._redraw_visual_cycle = cycle + 1
121+
trace(
122+
"console.begin_redraw_visualization cycle={cycle} style={style!r}",
123+
cycle=cycle,
124+
style=style,
125+
)
126+
return style
127+
128+
@staticmethod
129+
def visualize_redraw_text(text: str, style: str | None) -> str:
130+
if style is None or not text:
131+
return text
132+
return style + text.replace("\x1b[0m", "\x1b[0m" + style) + "\x1b[0m"
133+
74134
@abstractmethod
75-
def refresh(self, screen: list[str], xy: tuple[int, int]) -> None: ...
135+
def refresh(self, rendered_screen: RenderedScreen) -> None: ...
76136

77137
@abstractmethod
78138
def prepare(self) -> None: ...

Lib/_pyrepl/reader.py

Lines changed: 65 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from dataclasses import dataclass, field, fields
2929

3030
from . import commands, console, input
31+
from .render import RenderCell, RenderLine, RenderedScreen
3132
from .utils import wlen, unbracket, disp_str, gen_colors, THEME
3233
from .trace import trace
3334

@@ -207,7 +208,7 @@ class Reader:
207208
keymap: tuple[tuple[str, str], ...] = ()
208209
input_trans: input.KeymapTranslator = field(init=False)
209210
input_trans_stack: list[input.KeymapTranslator] = field(default_factory=list)
210-
screen: list[str] = field(default_factory=list)
211+
rendered_screen: RenderedScreen = field(init=False)
211212
screeninfo: list[tuple[int, list[int]]] = field(init=False)
212213
cxy: tuple[int, int] = field(init=False)
213214
lxy: tuple[int, int] = field(init=False)
@@ -218,7 +219,7 @@ class Reader:
218219
## cached metadata to speed up screen refreshes
219220
@dataclass
220221
class RefreshCache:
221-
screen: list[str] = field(default_factory=list)
222+
render_lines: list[RenderLine] = field(default_factory=list)
222223
screeninfo: list[tuple[int, list[int]]] = field(init=False)
223224
line_end_offsets: list[int] = field(default_factory=list)
224225
pos: int = field(init=False)
@@ -228,11 +229,13 @@ class RefreshCache:
228229

229230
def update_cache(self,
230231
reader: Reader,
231-
screen: list[str],
232+
render_lines: list[RenderLine],
232233
screeninfo: list[tuple[int, list[int]]],
234+
line_end_offsets: list[int],
233235
) -> None:
234-
self.screen = screen.copy()
236+
self.render_lines = render_lines.copy()
235237
self.screeninfo = screeninfo.copy()
238+
self.line_end_offsets = line_end_offsets.copy()
236239
self.pos = reader.pos
237240
self.cxy = reader.cxy
238241
self.dimensions = reader.console.width, reader.console.height
@@ -273,18 +276,23 @@ def __post_init__(self) -> None:
273276
self.screeninfo = [(0, [])]
274277
self.cxy = self.pos2xy()
275278
self.lxy = (self.pos, 0)
279+
self.rendered_screen = RenderedScreen.empty()
276280
self.can_colorize = _colorize.can_colorize()
277281

278282
self.last_refresh_cache.screeninfo = self.screeninfo
279283
self.last_refresh_cache.pos = self.pos
280284
self.last_refresh_cache.cxy = self.cxy
281285
self.last_refresh_cache.dimensions = (0, 0)
282286

287+
@property
288+
def screen(self) -> list[str]:
289+
return list(self.rendered_screen.screen_lines)
290+
283291
def collect_keymap(self) -> tuple[tuple[KeySpec, CommandName], ...]:
284292
return default_keymap
285293

286-
def calc_screen(self) -> list[str]:
287-
"""Translate changes in self.buffer into changes in self.console.screen."""
294+
def calc_screen(self) -> RenderedScreen:
295+
"""Translate changes in self.buffer into a structured rendered screen."""
288296
# Since the last call to calc_screen:
289297
# screen and screeninfo may differ due to a completion menu being shown
290298
# pos and cxy may differ due to edits, cursor movements, or completion menus
@@ -297,14 +305,9 @@ def calc_screen(self) -> list[str]:
297305
if self.last_refresh_cache.valid(self):
298306
offset, num_common_lines = self.last_refresh_cache.get_cached_location(self)
299307

300-
screen = self.last_refresh_cache.screen
301-
del screen[num_common_lines:]
302-
303-
screeninfo = self.last_refresh_cache.screeninfo
304-
del screeninfo[num_common_lines:]
305-
306-
last_refresh_line_end_offsets = self.last_refresh_cache.line_end_offsets
307-
del last_refresh_line_end_offsets[num_common_lines:]
308+
render_lines = self.last_refresh_cache.render_lines[:num_common_lines]
309+
screeninfo = self.last_refresh_cache.screeninfo[:num_common_lines]
310+
last_refresh_line_end_offsets = self.last_refresh_cache.line_end_offsets[:num_common_lines]
308311

309312
pos = self.pos
310313
pos -= offset
@@ -339,7 +342,7 @@ def calc_screen(self) -> list[str]:
339342
while "\n" in prompt:
340343
pre_prompt, _, prompt = prompt.partition("\n")
341344
last_refresh_line_end_offsets.append(offset)
342-
screen.append(pre_prompt)
345+
render_lines.append(RenderLine.from_rendered_text(pre_prompt))
343346
screeninfo.append((0, []))
344347
pos -= line_len + 1
345348
prompt, prompt_len = self.process_prompt(prompt)
@@ -348,7 +351,8 @@ def calc_screen(self) -> list[str]:
348351
if wrapcount == 0 or not char_widths:
349352
offset += line_len + 1 # Takes all of the line plus the newline
350353
last_refresh_line_end_offsets.append(offset)
351-
screen.append(prompt + "".join(chars))
354+
render_line = self._render_line(prompt, chars, char_widths)
355+
render_lines.append(render_line)
352356
screeninfo.append((prompt_len, char_widths))
353357
else:
354358
pre = prompt
@@ -370,9 +374,14 @@ def calc_screen(self) -> list[str]:
370374
post = ""
371375
after = []
372376
last_refresh_line_end_offsets.append(offset)
373-
render = pre + "".join(chars[:index_to_wrap_before]) + post
377+
render_line = self._render_line(
378+
pre,
379+
chars[:index_to_wrap_before],
380+
char_widths[:index_to_wrap_before],
381+
post,
382+
)
374383
render_widths = char_widths[:index_to_wrap_before] + after
375-
screen.append(render)
384+
render_lines.append(render_line)
376385
screeninfo.append((prelen, render_widths))
377386
chars = chars[index_to_wrap_before:]
378387
char_widths = char_widths[index_to_wrap_before:]
@@ -382,11 +391,35 @@ def calc_screen(self) -> list[str]:
382391
self.cxy = self.pos2xy()
383392
if self.msg:
384393
for mline in self.msg.split("\n"):
385-
screen.append(mline)
394+
render_lines.append(RenderLine.from_rendered_text(mline))
386395
screeninfo.append((0, []))
387396

388-
self.last_refresh_cache.update_cache(self, screen, screeninfo)
389-
return screen
397+
self.rendered_screen = RenderedScreen(tuple(render_lines), self.cxy)
398+
self.last_refresh_cache.update_cache(
399+
self,
400+
render_lines,
401+
screeninfo,
402+
last_refresh_line_end_offsets,
403+
)
404+
return self.rendered_screen
405+
406+
@staticmethod
407+
def _render_line(
408+
prefix: str,
409+
chars: list[str],
410+
char_widths: list[int],
411+
suffix: str = "",
412+
) -> RenderLine:
413+
cells: list[RenderCell] = []
414+
if prefix:
415+
cells.extend(RenderLine.from_rendered_text(prefix).cells)
416+
cells.extend(
417+
RenderCell(text, width, "\x1b" in text)
418+
for text, width in zip(chars, char_widths)
419+
)
420+
if suffix:
421+
cells.append(RenderCell(suffix, wlen(suffix), "\x1b" in suffix))
422+
return RenderLine.from_cells(cells)
390423

391424
@staticmethod
392425
def process_prompt(prompt: str) -> tuple[str, int]:
@@ -645,8 +678,17 @@ def update_screen(self) -> None:
645678
def refresh(self) -> None:
646679
"""Recalculate and refresh the screen."""
647680
# this call sets up self.cxy, so call it first.
648-
self.screen = self.calc_screen()
649-
self.console.refresh(self.screen, self.cxy)
681+
rendered_screen = self.calc_screen()
682+
trace(
683+
"reader.refresh cursor={cursor} lines={lines} "
684+
"dims=({width},{height}) dirty={dirty}",
685+
cursor=self.cxy,
686+
lines=len(rendered_screen.lines),
687+
width=self.console.width,
688+
height=self.console.height,
689+
dirty=self.dirty,
690+
)
691+
self.console.refresh(rendered_screen)
650692
self.dirty = False
651693

652694
def do_cmd(self, cmd: tuple[str, list[str]]) -> None:

Lib/_pyrepl/trace.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,9 @@ def trace(line: str, *k: object, **kw: object) -> None:
3232
line = line.format(*k, **kw)
3333
trace_file.write(line + "\n")
3434
trace_file.flush()
35+
36+
37+
def trace_text(text: str, limit: int = 60) -> str:
38+
if len(text) > limit:
39+
text = text[:limit] + "..."
40+
return repr(text)

0 commit comments

Comments
 (0)