| """Wrap content lines to the terminal width before rendering.""" | |
| from dataclasses import dataclass | |
| from typing import Self | |
| from .content import ContentFragment, ContentLine | |
| lazy 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), | |
| ) |