Skip to content

Commit 8ed00a8

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 41e8b78 commit 8ed00a8

11 files changed

Lines changed: 732 additions & 243 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

@@ -281,19 +282,24 @@ def after_command(self, cmd: Command) -> None:
281282
if not isinstance(cmd, (complete, self_insert)):
282283
self.cmpltn_reset()
283284

284-
def calc_screen(self) -> list[str]:
285-
screen = super().calc_screen()
285+
def calc_screen(self) -> RenderedScreen:
286+
rendered_screen = super().calc_screen()
286287
if self.cmpltn_menu_visible:
287288
# We display the completions menu below the current prompt
288289
ly = self.lxy[1] + 1
289-
screen[ly:ly] = self.cmpltn_menu
290+
render_lines = list(rendered_screen.lines)
291+
render_lines[ly:ly] = [
292+
RenderLine.from_rendered_text(line) for line in self.cmpltn_menu
293+
]
294+
rendered_screen = RenderedScreen(tuple(render_lines), self.cxy)
295+
self.rendered_screen = rendered_screen
290296
# If we're not in the middle of multiline edit, don't append to screeninfo
291297
# since that screws up the position calculation in pos2xy function.
292298
# This is a hack to prevent the cursor jumping
293299
# into the completions menu when pressing left or down arrow.
294300
if self.pos != len(self.buffer):
295301
self.screeninfo[ly:ly] = [(0, [])]*len(self.cmpltn_menu)
296-
return screen
302+
return rendered_screen
297303

298304
def finish(self) -> None:
299305
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: 68 additions & 24 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:]
@@ -386,15 +395,41 @@ def calc_screen(self) -> list[str]:
386395
# If self.msg is larger than console width, make it fit
387396
# TODO: try to split between words?
388397
if not mline:
389-
screen.append("")
398+
render_lines.append(RenderLine.from_rendered_text(""))
390399
screeninfo.append((0, []))
391400
continue
392401
for r in range((len(mline) - 1) // width + 1):
393-
screen.append(mline[r * width : (r + 1) * width])
402+
render_lines.append(
403+
RenderLine.from_rendered_text(mline[r * width : (r + 1) * width])
404+
)
394405
screeninfo.append((0, []))
395406

396-
self.last_refresh_cache.update_cache(self, screen, screeninfo)
397-
return screen
407+
self.rendered_screen = RenderedScreen(tuple(render_lines), self.cxy)
408+
self.last_refresh_cache.update_cache(
409+
self,
410+
render_lines,
411+
screeninfo,
412+
last_refresh_line_end_offsets,
413+
)
414+
return self.rendered_screen
415+
416+
@staticmethod
417+
def _render_line(
418+
prefix: str,
419+
chars: list[str],
420+
char_widths: list[int],
421+
suffix: str = "",
422+
) -> RenderLine:
423+
cells: list[RenderCell] = []
424+
if prefix:
425+
cells.extend(RenderLine.from_rendered_text(prefix).cells)
426+
cells.extend(
427+
RenderCell(text, width, "\x1b" in text)
428+
for text, width in zip(chars, char_widths)
429+
)
430+
if suffix:
431+
cells.append(RenderCell(suffix, wlen(suffix), "\x1b" in suffix))
432+
return RenderLine.from_cells(cells)
398433

399434
@staticmethod
400435
def process_prompt(prompt: str) -> tuple[str, int]:
@@ -652,8 +687,17 @@ def update_screen(self) -> None:
652687
def refresh(self) -> None:
653688
"""Recalculate and refresh the screen."""
654689
# this call sets up self.cxy, so call it first.
655-
self.screen = self.calc_screen()
656-
self.console.refresh(self.screen, self.cxy)
690+
rendered_screen = self.calc_screen()
691+
trace(
692+
"reader.refresh cursor={cursor} lines={lines} "
693+
"dims=({width},{height}) dirty={dirty}",
694+
cursor=self.cxy,
695+
lines=len(rendered_screen.lines),
696+
width=self.console.width,
697+
height=self.console.height,
698+
dirty=self.dirty,
699+
)
700+
self.console.refresh(rendered_screen)
657701
self.dirty = False
658702

659703
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)