"""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), )