mirror of
https://github.com/python/cpython.git
synced 2026-04-20 02:40:59 +00:00
268 lines
8.3 KiB
Python
268 lines
8.3 KiB
Python
"""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),
|
|
)
|