Skip to content

Commit 180a8bc

Browse files
committed
Extract _pyrepl content and layout helpers
Add structured prompt, content, and wrapped-row helpers for screen calculation. Move reader layout bookkeeping onto those helpers before styling changes arrive.
1 parent 16eda40 commit 180a8bc

File tree

12 files changed

+821
-262
lines changed

12 files changed

+821
-262
lines changed

Lib/_pyrepl/completing_reader.py

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -263,20 +263,11 @@ def after_command(self, cmd: Command) -> None:
263263
def calc_screen(self) -> RenderedScreen:
264264
rendered_screen = super().calc_screen()
265265
if self.cmpltn_menu_visible:
266-
# We display the completions menu below the current prompt
267-
ly = self.lxy[1] + 1
268-
render_lines = list(rendered_screen.lines)
269-
render_lines[ly:ly] = [
270-
RenderLine.from_rendered_text(line) for line in self.cmpltn_menu
271-
]
272-
rendered_screen = RenderedScreen(tuple(render_lines), self.cxy)
266+
rendered_screen = rendered_screen.with_overlay(
267+
self.lxy[1] + 1,
268+
(RenderLine.from_rendered_text(line) for line in self.cmpltn_menu),
269+
)
273270
self.rendered_screen = rendered_screen
274-
# If we're not in the middle of multiline edit, don't append to screeninfo
275-
# since that screws up the position calculation in pos2xy function.
276-
# This is a hack to prevent the cursor jumping
277-
# into the completions menu when pressing left or down arrow.
278-
if self.pos != len(self.buffer):
279-
self.screeninfo[ly:ly] = [(0, [])]*len(self.cmpltn_menu)
280271
return rendered_screen
281272

282273
def finish(self) -> None:

Lib/_pyrepl/console.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ def sync_rendered_screen(
102102
self._rendered_screen = rendered_screen
103103
trace(
104104
"console.sync_rendered_screen lines={lines} cursor={cursor}",
105-
lines=len(rendered_screen.lines),
105+
lines=len(rendered_screen.composed_lines),
106106
cursor=posxy,
107107
)
108108

@@ -125,12 +125,6 @@ def begin_redraw_visualization(self) -> str | None:
125125
)
126126
return style
127127

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-
134128
@abstractmethod
135129
def refresh(self, rendered_screen: RenderedScreen) -> None: ...
136130

Lib/_pyrepl/content.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
5+
from .utils import ColorSpan, disp_str, unbracket, wlen
6+
7+
8+
@dataclass(frozen=True, slots=True)
9+
class ContentFragment:
10+
text: str
11+
width: int
12+
13+
14+
@dataclass(frozen=True, slots=True)
15+
class PromptContent:
16+
leading_lines: tuple[ContentFragment, ...]
17+
text: str
18+
width: int
19+
20+
21+
@dataclass(frozen=True, slots=True)
22+
class SourceLine:
23+
lineno: int
24+
text: str
25+
start_offset: int
26+
has_newline: bool
27+
cursor_index: int | None = None
28+
29+
@property
30+
def cursor_on_line(self) -> bool:
31+
return self.cursor_index is not None
32+
33+
34+
@dataclass(frozen=True, slots=True)
35+
class ContentLine:
36+
source: SourceLine
37+
prompt: PromptContent
38+
body: tuple[ContentFragment, ...]
39+
40+
41+
def process_prompt(prompt: str) -> PromptContent:
42+
r"""Return prompt content with width measured without zero-width markup."""
43+
44+
prompt_text = unbracket(prompt, including_content=False)
45+
visible_prompt = unbracket(prompt, including_content=True)
46+
leading_lines: list[ContentFragment] = []
47+
48+
while "\n" in prompt_text:
49+
leading_text, _, prompt_text = prompt_text.partition("\n")
50+
visible_leading, _, visible_prompt = visible_prompt.partition("\n")
51+
leading_lines.append(ContentFragment(leading_text, wlen(visible_leading)))
52+
53+
return PromptContent(tuple(leading_lines), prompt_text, wlen(visible_prompt))
54+
55+
56+
def build_body_fragments(
57+
buffer: str,
58+
colors: list[ColorSpan] | None,
59+
start_index: int,
60+
) -> tuple[ContentFragment, ...]:
61+
chars, char_widths = disp_str(buffer, colors, start_index)
62+
return tuple(
63+
ContentFragment(text, width)
64+
for text, width in zip(chars, char_widths)
65+
)

Lib/_pyrepl/layout.py

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
5+
from .content import ContentFragment, ContentLine
6+
7+
8+
@dataclass(frozen=True, slots=True)
9+
class LayoutRow:
10+
prompt_width: int
11+
char_widths: tuple[int, ...]
12+
suffix_width: int = 0
13+
buffer_advance: int = 0
14+
15+
@property
16+
def width(self) -> int:
17+
return self.prompt_width + sum(self.char_widths) + self.suffix_width
18+
19+
@property
20+
def screeninfo(self) -> tuple[int, list[int]]:
21+
widths = list(self.char_widths)
22+
if self.suffix_width:
23+
widths.append(self.suffix_width)
24+
return self.prompt_width, widths
25+
26+
27+
@dataclass(frozen=True, slots=True)
28+
class LayoutMap:
29+
rows: tuple[LayoutRow, ...]
30+
31+
@classmethod
32+
def empty(cls) -> LayoutMap:
33+
return cls((LayoutRow(0, ()),))
34+
35+
@property
36+
def screeninfo(self) -> list[tuple[int, list[int]]]:
37+
return [row.screeninfo for row in self.rows]
38+
39+
def max_column(self, y: int) -> int:
40+
return self.rows[y].width
41+
42+
def max_row(self) -> int:
43+
return len(self.rows) - 1
44+
45+
def pos_to_xy(self, pos: int) -> tuple[int, int]:
46+
if not self.rows:
47+
return 0, 0
48+
49+
remaining = pos
50+
for y, row in enumerate(self.rows):
51+
if remaining <= len(row.char_widths):
52+
x = row.prompt_width
53+
for width in row.char_widths[:remaining]:
54+
x += width
55+
return x, y
56+
remaining -= row.buffer_advance
57+
last_row = self.rows[-1]
58+
return last_row.width - last_row.suffix_width, len(self.rows) - 1
59+
60+
def xy_to_pos(self, x: int, y: int) -> int:
61+
pos = 0
62+
for row in self.rows[:y]:
63+
pos += row.buffer_advance
64+
65+
row = self.rows[y]
66+
cur_x = row.prompt_width
67+
for width in row.char_widths:
68+
if cur_x >= x:
69+
break
70+
if width == 0:
71+
pos += 1
72+
continue
73+
cur_x += width
74+
pos += 1
75+
return pos
76+
77+
78+
@dataclass(frozen=True, slots=True)
79+
class WrappedRow:
80+
prompt_text: str = ""
81+
prompt_width: int = 0
82+
fragments: tuple[ContentFragment, ...] = ()
83+
layout_widths: tuple[int, ...] = ()
84+
suffix: str = ""
85+
suffix_width: int = 0
86+
buffer_advance: int = 0
87+
line_end_offset: int = 0
88+
89+
90+
@dataclass(frozen=True, slots=True)
91+
class LayoutResult:
92+
wrapped_rows: tuple[WrappedRow, ...]
93+
layout_map: LayoutMap
94+
line_end_offsets: tuple[int, ...]
95+
96+
97+
def layout_content_lines(
98+
lines: tuple[ContentLine, ...],
99+
width: int,
100+
start_offset: int,
101+
) -> LayoutResult:
102+
offset = start_offset
103+
wrapped_rows: list[WrappedRow] = []
104+
layout_rows: list[LayoutRow] = []
105+
line_end_offsets: list[int] = []
106+
107+
for line in lines:
108+
for leading in line.prompt.leading_lines:
109+
line_end_offsets.append(offset)
110+
wrapped_rows.append(
111+
WrappedRow(
112+
fragments=(leading,),
113+
line_end_offset=offset,
114+
)
115+
)
116+
layout_rows.append(LayoutRow(0, (), buffer_advance=0))
117+
118+
prompt_text = line.prompt.text
119+
prompt_width = line.prompt.width
120+
remaining = list(line.body)
121+
remaining_widths = [fragment.width for fragment in remaining]
122+
123+
if not remaining_widths or (sum(remaining_widths) + prompt_width) // width == 0:
124+
offset += len(remaining) + (1 if line.source.has_newline else 0)
125+
line_end_offsets.append(offset)
126+
wrapped_rows.append(
127+
WrappedRow(
128+
prompt_text=prompt_text,
129+
prompt_width=prompt_width,
130+
fragments=tuple(remaining),
131+
layout_widths=tuple(remaining_widths),
132+
buffer_advance=len(remaining) + (1 if line.source.has_newline else 0),
133+
line_end_offset=offset,
134+
)
135+
)
136+
layout_rows.append(
137+
LayoutRow(
138+
prompt_width,
139+
tuple(remaining_widths),
140+
buffer_advance=len(remaining) + (1 if line.source.has_newline else 0),
141+
)
142+
)
143+
continue
144+
145+
current_prompt = prompt_text
146+
current_prompt_width = prompt_width
147+
while True:
148+
index_to_wrap_before = 0
149+
column = 0
150+
for char_width in remaining_widths:
151+
if column + char_width + current_prompt_width >= width:
152+
break
153+
index_to_wrap_before += 1
154+
column += char_width
155+
156+
at_line_end = len(remaining) <= index_to_wrap_before
157+
if at_line_end:
158+
offset += index_to_wrap_before + (1 if line.source.has_newline else 0)
159+
suffix = ""
160+
suffix_width = 0
161+
buffer_advance = index_to_wrap_before + (1 if line.source.has_newline else 0)
162+
else:
163+
offset += index_to_wrap_before
164+
suffix = "\\"
165+
suffix_width = 1
166+
buffer_advance = index_to_wrap_before
167+
168+
row_fragments = tuple(remaining[:index_to_wrap_before])
169+
row_widths = tuple(remaining_widths[:index_to_wrap_before])
170+
line_end_offsets.append(offset)
171+
wrapped_rows.append(
172+
WrappedRow(
173+
prompt_text=current_prompt,
174+
prompt_width=current_prompt_width,
175+
fragments=row_fragments,
176+
layout_widths=row_widths,
177+
suffix=suffix,
178+
suffix_width=suffix_width,
179+
buffer_advance=buffer_advance,
180+
line_end_offset=offset,
181+
)
182+
)
183+
layout_rows.append(
184+
LayoutRow(
185+
current_prompt_width,
186+
row_widths,
187+
suffix_width=suffix_width,
188+
buffer_advance=buffer_advance,
189+
)
190+
)
191+
192+
remaining = remaining[index_to_wrap_before:]
193+
remaining_widths = remaining_widths[index_to_wrap_before:]
194+
current_prompt = ""
195+
current_prompt_width = 0
196+
if at_line_end:
197+
break
198+
199+
return LayoutResult(
200+
tuple(wrapped_rows),
201+
LayoutMap(tuple(layout_rows)),
202+
tuple(line_end_offsets),
203+
)

0 commit comments

Comments
 (0)