Skip to content

Commit 136590d

Browse files
committed
Add _pyrepl render primitives
Introduce render-cell, rendered-screen, and line-diff helpers for redraw work. Keep the new abstractions self-contained so later commits can adopt them cleanly.
1 parent 4561f64 commit 136590d

2 files changed

Lines changed: 232 additions & 0 deletions

File tree

Lib/_pyrepl/render.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Iterable, Sequence
4+
from dataclasses import dataclass
5+
6+
from .utils import ANSI_ESCAPE_SEQUENCE, str_width
7+
8+
9+
@dataclass(frozen=True, slots=True)
10+
class RenderCell:
11+
text: str
12+
width: int
13+
has_escape: bool = False
14+
15+
16+
@dataclass(frozen=True, slots=True)
17+
class RenderLine:
18+
cells: tuple[RenderCell, ...]
19+
text: str
20+
width: int
21+
22+
@classmethod
23+
def from_cells(cls, cells: Iterable[RenderCell]) -> RenderLine:
24+
cell_tuple = tuple(cells)
25+
return cls(
26+
cells=cell_tuple,
27+
text="".join(cell.text for cell in cell_tuple),
28+
width=sum(cell.width for cell in cell_tuple),
29+
)
30+
31+
@classmethod
32+
def from_parts(
33+
cls,
34+
parts: Sequence[str],
35+
widths: Sequence[int],
36+
) -> RenderLine:
37+
return cls.from_cells(
38+
RenderCell(text, width, "\x1b" in text)
39+
for text, width in zip(parts, widths)
40+
)
41+
42+
@classmethod
43+
def from_rendered_text(cls, text: str) -> RenderLine:
44+
if not text:
45+
return cls(cells=(), text="", width=0)
46+
47+
cells: list[RenderCell] = []
48+
pending_escape = ""
49+
index = 0
50+
for match in ANSI_ESCAPE_SEQUENCE.finditer(text):
51+
pending_escape = cls._append_plain_text(
52+
cells, text[index : match.start()], pending_escape
53+
)
54+
pending_escape += match.group(0)
55+
index = match.end()
56+
57+
pending_escape = cls._append_plain_text(cells, text[index:], pending_escape)
58+
59+
if pending_escape:
60+
if cells:
61+
last = cells[-1]
62+
cells[-1] = RenderCell(
63+
text=last.text + pending_escape,
64+
width=last.width,
65+
has_escape=True,
66+
)
67+
else:
68+
cells.append(RenderCell(pending_escape, 0, True))
69+
70+
return cls.from_cells(cells)
71+
72+
@staticmethod
73+
def _append_plain_text(
74+
cells: list[RenderCell],
75+
text: str,
76+
pending_escape: str,
77+
) -> str:
78+
for char in text:
79+
rendered = pending_escape + char
80+
cells.append(RenderCell(rendered, str_width(char), bool(pending_escape)))
81+
pending_escape = ""
82+
return pending_escape
83+
84+
85+
@dataclass(frozen=True, slots=True)
86+
class RenderedScreen:
87+
lines: tuple[RenderLine, ...]
88+
cursor: tuple[int, int]
89+
90+
@classmethod
91+
def empty(cls) -> RenderedScreen:
92+
return cls((), (0, 0))
93+
94+
@classmethod
95+
def from_screen_lines(
96+
cls,
97+
screen: Sequence[str],
98+
cursor: tuple[int, int],
99+
) -> RenderedScreen:
100+
return cls(
101+
tuple(RenderLine.from_rendered_text(line) for line in screen),
102+
cursor,
103+
)
104+
105+
@property
106+
def screen_lines(self) -> tuple[str, ...]:
107+
return tuple(line.text for line in self.lines)
108+
109+
110+
@dataclass(frozen=True, slots=True)
111+
class LineDiff:
112+
start_cell: int
113+
start_x: int
114+
old_cells: tuple[RenderCell, ...]
115+
new_cells: tuple[RenderCell, ...]
116+
old_width: int
117+
new_width: int
118+
119+
@property
120+
def old_text(self) -> str:
121+
return "".join(cell.text for cell in self.old_cells)
122+
123+
@property
124+
def new_text(self) -> str:
125+
return "".join(cell.text for cell in self.new_cells)
126+
127+
@property
128+
def old_changed_width(self) -> int:
129+
return sum(cell.width for cell in self.old_cells)
130+
131+
@property
132+
def new_changed_width(self) -> int:
133+
return sum(cell.width for cell in self.new_cells)
134+
135+
136+
EMPTY_RENDER_LINE = RenderLine(cells=(), text="", width=0)
137+
138+
139+
@dataclass(frozen=True, slots=True)
140+
class LineUpdate:
141+
kind: str
142+
y: int
143+
start_cell: int
144+
start_x: int
145+
text: str
146+
char_width: int = 0
147+
clear_eol: bool = False
148+
reset_to_margin: bool = False
149+
150+
151+
def diff_render_lines(old: RenderLine, new: RenderLine) -> LineDiff | None:
152+
if old == new:
153+
return None
154+
155+
prefix = 0
156+
start_x = 0
157+
max_prefix = min(len(old.cells), len(new.cells))
158+
while prefix < max_prefix and old.cells[prefix] == new.cells[prefix]:
159+
start_x += old.cells[prefix].width
160+
prefix += 1
161+
162+
old_suffix = len(old.cells)
163+
new_suffix = len(new.cells)
164+
while (
165+
old_suffix > prefix
166+
and new_suffix > prefix
167+
and old.cells[old_suffix - 1] == new.cells[new_suffix - 1]
168+
):
169+
old_suffix -= 1
170+
new_suffix -= 1
171+
172+
return LineDiff(
173+
start_cell=prefix,
174+
start_x=start_x,
175+
old_cells=old.cells[prefix:old_suffix],
176+
new_cells=new.cells[prefix:new_suffix],
177+
old_width=old.width,
178+
new_width=new.width,
179+
)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from unittest import TestCase
2+
3+
from _pyrepl.render import RenderLine, RenderedScreen, diff_render_lines
4+
5+
6+
class TestRenderLine(TestCase):
7+
def test_from_rendered_text_groups_escape_with_visible_cells(self):
8+
line = RenderLine.from_rendered_text("\x1b[31ma\x1b[0mb")
9+
10+
self.assertEqual(line.width, 2)
11+
self.assertEqual(
12+
[cell.text for cell in line.cells],
13+
["\x1b[31ma", "\x1b[0mb"],
14+
)
15+
16+
def test_from_rendered_text_keeps_trailing_escape_on_last_cell(self):
17+
line = RenderLine.from_rendered_text("\x1b[31ma\x1b[0m")
18+
19+
self.assertEqual([cell.text for cell in line.cells], ["\x1b[31ma\x1b[0m"])
20+
21+
22+
class TestLineDiff(TestCase):
23+
def test_diff_render_lines_ignores_unchanged_ansi_prefix(self):
24+
old = RenderLine.from_rendered_text("\x1b[31ma\x1b[0mb")
25+
new = RenderLine.from_rendered_text("\x1b[31ma\x1b[0mc")
26+
27+
diff = diff_render_lines(old, new)
28+
29+
self.assertIsNotNone(diff)
30+
assert diff is not None
31+
self.assertEqual(diff.start_x, 1)
32+
self.assertEqual(diff.old_text, "\x1b[0mb")
33+
self.assertEqual(diff.new_text, "\x1b[0mc")
34+
35+
def test_diff_render_lines_detects_single_cell_insertion(self):
36+
old = RenderLine.from_rendered_text("ab")
37+
new = RenderLine.from_rendered_text("acb")
38+
39+
diff = diff_render_lines(old, new)
40+
41+
self.assertIsNotNone(diff)
42+
assert diff is not None
43+
self.assertEqual(diff.start_x, 1)
44+
self.assertEqual(diff.old_text, "")
45+
self.assertEqual(diff.new_text, "c")
46+
47+
def test_rendered_screen_round_trips_screen_lines(self):
48+
screen = RenderedScreen.from_screen_lines(
49+
["a", "\x1b[31mb\x1b[0m"],
50+
(0, 1),
51+
)
52+
53+
self.assertEqual(screen.screen_lines, ("a", "\x1b[31mb\x1b[0m"))

0 commit comments

Comments
 (0)