cpython/Lib/_pyrepl/layout.py

269 lines
8.3 KiB
Python
Raw Normal View History

"""Wrap content lines to the terminal width before rendering."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Self
from .content import ContentFragment, ContentLine
from .types import CursorXY, ScreenInfoRow
@dataclass(frozen=True, slots=True)
class LayoutRow:
"""Metadata for one physical screen row.
For the row ``>>> def greet(name):``::
>>> def greet(name):
4 char_widths=(1,1,1,) 16 entries
buffer_advance=17 includes the newline
"""
prompt_width: int
char_widths: tuple[int, ...]
suffix_width: int = 0
buffer_advance: int = 0
@property
def width(self) -> int:
return self.prompt_width + sum(self.char_widths) + self.suffix_width
@property
def screeninfo(self) -> ScreenInfoRow:
widths = list(self.char_widths)
if self.suffix_width:
widths.append(self.suffix_width)
return self.prompt_width, widths
@dataclass(frozen=True, slots=True)
class LayoutMap:
"""Mapping between buffer positions and screen coordinates.
Single source of truth for cursor placement. Given::
>>> def greet(name): row 0, buffer_advance=17
... return name row 1, buffer_advance=15
cursor
``pos_to_xy(31)`` ``(18, 1)``: prompt width 4 + 14 body chars.
"""
rows: tuple[LayoutRow, ...]
@classmethod
def empty(cls) -> Self:
return cls((LayoutRow(0, ()),))
@property
def screeninfo(self) -> list[ScreenInfoRow]:
return [row.screeninfo for row in self.rows]
def max_column(self, y: int) -> int:
return self.rows[y].width
def max_row(self) -> int:
return len(self.rows) - 1
def pos_to_xy(self, pos: int) -> CursorXY:
if not self.rows:
return 0, 0
remaining = pos
for y, row in enumerate(self.rows):
if remaining <= len(row.char_widths):
# Prompt-only leading rows are terminal scenery, not real
# buffer positions. Treating them as real just manufactures
# bugs.
if remaining == 0 and not row.char_widths and row.buffer_advance == 0 and y < len(self.rows) - 1:
continue
x = row.prompt_width
for width in row.char_widths[:remaining]:
x += width
return x, y
remaining -= row.buffer_advance
last_row = self.rows[-1]
return last_row.width - last_row.suffix_width, len(self.rows) - 1
def xy_to_pos(self, x: int, y: int) -> int:
if not self.rows:
return 0
pos = 0
for row in self.rows[:y]:
pos += row.buffer_advance
row = self.rows[y]
cur_x = row.prompt_width
char_widths = row.char_widths
i = 0
for i, width in enumerate(char_widths):
if cur_x >= x:
# Include trailing zero-width (combining) chars at this position
for trailing_width in char_widths[i:]:
if trailing_width == 0:
pos += 1
else:
break
return pos
if width == 0:
pos += 1
continue
cur_x += width
pos += 1
return pos
@dataclass(frozen=True, slots=True)
class WrappedRow:
"""One physical screen row after wrapping, ready for rendering.
When a line overflows the terminal width, it splits into
multiple rows with a ``\\`` continuation marker::
>>> x = "a very long li\\ ← suffix="\\", suffix_width=1
ne that wraps" ← prompt_text="" (continuation)
"""
prompt_text: str = ""
prompt_width: int = 0
fragments: tuple[ContentFragment, ...] = ()
layout_widths: tuple[int, ...] = ()
suffix: str = ""
suffix_width: int = 0
buffer_advance: int = 0
@dataclass(frozen=True, slots=True)
class LayoutResult:
wrapped_rows: tuple[WrappedRow, ...]
layout_map: LayoutMap
line_end_offsets: tuple[int, ...]
def layout_content_lines(
lines: tuple[ContentLine, ...],
width: int,
start_offset: int,
) -> LayoutResult:
"""Wrap content lines to fit *width* columns.
A short line passes through as one ``WrappedRow``; a long line is
split at the column boundary with ``\\`` markers::
>>> short = 1 one WrappedRow
>>> x = "a long stri\\ ← two WrappedRows, first has suffix="\\"
ng"
"""
if width <= 0:
return LayoutResult((), LayoutMap(()), ())
offset = start_offset
wrapped_rows: list[WrappedRow] = []
layout_rows: list[LayoutRow] = []
line_end_offsets: list[int] = []
for line in lines:
newline_advance = int(line.source.has_newline)
for leading in line.prompt.leading_lines:
line_end_offsets.append(offset)
wrapped_rows.append(
WrappedRow(
fragments=(leading,),
)
)
layout_rows.append(LayoutRow(0, (), buffer_advance=0))
prompt_text = line.prompt.text
prompt_width = line.prompt.width
body = tuple(line.body)
body_widths = tuple(fragment.width for fragment in body)
# Fast path: line fits on one row.
if not body_widths or (sum(body_widths) + prompt_width) < width:
offset += len(body) + newline_advance
line_end_offsets.append(offset)
wrapped_rows.append(
WrappedRow(
prompt_text=prompt_text,
prompt_width=prompt_width,
fragments=body,
layout_widths=body_widths,
buffer_advance=len(body) + newline_advance,
)
)
layout_rows.append(
LayoutRow(
prompt_width,
body_widths,
buffer_advance=len(body) + newline_advance,
)
)
continue
# Slow path: line needs wrapping.
current_prompt = prompt_text
current_prompt_width = prompt_width
start = 0
total = len(body)
while True:
# Find how many characters fit on this row.
index_to_wrap_before = 0
column = 0
for char_width in body_widths[start:]:
if column + char_width + current_prompt_width >= width:
break
index_to_wrap_before += 1
column += char_width
if index_to_wrap_before == 0 and start < total:
index_to_wrap_before = 1 # force progress
at_line_end = (start + index_to_wrap_before) >= total
if at_line_end:
offset += index_to_wrap_before + newline_advance
suffix = ""
suffix_width = 0
buffer_advance = index_to_wrap_before + newline_advance
else:
offset += index_to_wrap_before
suffix = "\\"
suffix_width = 1
buffer_advance = index_to_wrap_before
end = start + index_to_wrap_before
row_fragments = body[start:end]
row_widths = body_widths[start:end]
line_end_offsets.append(offset)
wrapped_rows.append(
WrappedRow(
prompt_text=current_prompt,
prompt_width=current_prompt_width,
fragments=row_fragments,
layout_widths=row_widths,
suffix=suffix,
suffix_width=suffix_width,
buffer_advance=buffer_advance,
)
)
layout_rows.append(
LayoutRow(
current_prompt_width,
row_widths,
suffix_width=suffix_width,
buffer_advance=buffer_advance,
)
)
start = end
current_prompt = ""
current_prompt_width = 0
if at_line_end:
break
return LayoutResult(
tuple(wrapped_rows),
LayoutMap(tuple(layout_rows)),
tuple(line_end_offsets),
)