| from collections.abc import Iterable, Sequence | |
| from dataclasses import dataclass, field | |
| from typing import Literal, Protocol, Self | |
| from _colorize import ANSIColors | |
| from .types import CursorXY | |
| from .utils import ANSI_ESCAPE_SEQUENCE, THEME, StyleRef, str_width | |
| type RenderStyle = StyleRef | str | None | |
| type LineUpdateKind = Literal[ | |
| "insert_char", | |
| "replace_char", | |
| "replace_span", | |
| "delete_then_insert", | |
| "rewrite_suffix", | |
| ] | |
| class _ThemeSyntax(Protocol): | |
| """Protocol for theme objects that map tag names to SGR escape strings.""" | |
| def __getitem__(self, key: str, /) -> str: ... | |
| @dataclass(frozen=True, slots=True) | |
| class RenderCell: | |
| """One terminal cell: a character, its column width, and SGR style. | |
| A screen row like ``>>> def`` is a sequence of cells:: | |
| > > > d e f | |
| ╰─╯╰─╯╰─╯╰─╯╰─╯╰─╯╰─╯ | |
| """ | |
| text: str | |
| width: int | |
| style: StyleRef = field(default_factory=StyleRef) | |
| controls: tuple[str, ...] = () | |
| @property | |
| def terminal_text(self) -> str: | |
| return render_cells((self,)) | |
| def _theme_style(theme: _ThemeSyntax, tag: str) -> str: | |
| return theme[tag] | |
| def _style_escape(style: StyleRef) -> str: | |
| if style.sgr: | |
| return style.sgr | |
| if style.tag is None: | |
| return "" | |
| return _theme_style(THEME(), style.tag) | |
| def _update_terminal_state(state: str, escape: str) -> str: | |
| if escape in {ANSIColors.RESET, "\x1b[m"}: | |
| return "" | |
| return state + escape | |
| def _cells_from_rendered_text(text: str) -> tuple[RenderCell, ...]: | |
| if not text: | |
| return () | |
| cells: list[RenderCell] = [] | |
| pending_controls: list[str] = [] | |
| active_sgr = "" | |
| index = 0 | |
| def append_plain_text(segment: str) -> None: | |
| nonlocal pending_controls | |
| if not segment: | |
| return | |
| if pending_controls: | |
| cells.append(RenderCell("", 0, controls=tuple(pending_controls))) | |
| pending_controls = [] | |
| for char in segment: | |
| cells.append( | |
| RenderCell( | |
| char, | |
| str_width(char), | |
| style=StyleRef.from_sgr(active_sgr), | |
| ) | |
| ) | |
| for match in ANSI_ESCAPE_SEQUENCE.finditer(text): | |
| append_plain_text(text[index : match.start()]) | |
| escape = match.group(0) | |
| if escape.endswith("m"): | |
| active_sgr = _update_terminal_state(active_sgr, escape) | |
| else: | |
| pending_controls.append(escape) | |
| index = match.end() | |
| append_plain_text(text[index:]) | |
| if pending_controls: | |
| cells.append(RenderCell("", 0, controls=tuple(pending_controls))) | |
| return tuple(cells) | |
| @dataclass(frozen=True, slots=True) | |
| class RenderLine: | |
| """One physical screen row as a tuple of :class:`RenderCell` objects. | |
| ``text`` is the pre-rendered terminal string (characters + SGR escapes); | |
| ``width`` is the total visible column count. | |
| """ | |
| cells: tuple[RenderCell, ...] | |
| text: str | |
| width: int | |
| @classmethod | |
| def from_cells(cls, cells: Iterable[RenderCell]) -> Self: | |
| cell_tuple = tuple(cells) | |
| return cls( | |
| cells=cell_tuple, | |
| text=render_cells(cell_tuple), | |
| width=sum(cell.width for cell in cell_tuple), | |
| ) | |
| @classmethod | |
| def from_parts( | |
| cls, | |
| parts: Sequence[str], | |
| widths: Sequence[int], | |
| styles: Sequence[RenderStyle] | None = None, | |
| ) -> Self: | |
| if styles is None: | |
| return cls.from_cells( | |
| RenderCell(text, width) | |
| for text, width in zip(parts, widths) | |
| ) | |
| cells: list[RenderCell] = [] | |
| for text, width, style in zip(parts, widths, styles): | |
| if isinstance(style, StyleRef): | |
| cells.append(RenderCell(text, width, style=style)) | |
| elif style is None: | |
| cells.append(RenderCell(text, width)) | |
| else: | |
| cells.append(RenderCell(text, width, style=StyleRef.from_tag(style))) | |
| return cls.from_cells(cells) | |
| @classmethod | |
| def from_rendered_text(cls, text: str) -> Self: | |
| return cls.from_cells(_cells_from_rendered_text(text)) | |
| @dataclass(frozen=True, slots=True) | |
| class ScreenOverlay: | |
| """An overlay that replaces or inserts lines at a screen position. | |
| If *insert* is True, lines are spliced in (shifting content down); | |
| if False (default), lines replace existing content at *y*. | |
| Overlays are used to display tab completion menus and status messages. | |
| For example, a tab-completion menu inserted below the input:: | |
| >>> os.path.j ← line 0 (base content) | |
| join ← ScreenOverlay(y=1, insert=True) | |
| junction ← (pushes remaining lines down) | |
| ... ← line 1 (shifted down by 2) | |
| """ | |
| y: int | |
| lines: tuple[RenderLine, ...] | |
| insert: bool = False | |
| @dataclass(frozen=True, slots=True) | |
| class RenderedScreen: | |
| """The complete screen state: content lines, cursor, and overlays. | |
| ``lines`` holds the base content; ``composed_lines`` is the final | |
| result after overlays (completion menus, messages) are applied:: | |
| lines: composed_lines: | |
| ┌──────────────────┐ ┌──────────────────┐ | |
| │>>> os.path.j │ │>>> os.path.j │ | |
| │... │ ──► │ join │ ← overlay | |
| └──────────────────┘ │... │ | |
| └──────────────────┘ | |
| """ | |
| lines: tuple[RenderLine, ...] | |
| cursor: CursorXY | |
| overlays: tuple[ScreenOverlay, ...] = () | |
| composed_lines: tuple[RenderLine, ...] = field(init=False, default=()) | |
| def __post_init__(self) -> None: | |
| object.__setattr__(self, "composed_lines", self._compose()) | |
| def _compose(self) -> tuple[RenderLine, ...]: | |
| """Apply overlays in tuple order; inserts shift subsequent positions.""" | |
| if not self.overlays: | |
| return self.lines | |
| lines = list(self.lines) | |
| y_offset = 0 | |
| for overlay in self.overlays: | |
| adjusted_y = overlay.y + y_offset | |
| assert adjusted_y >= 0, ( | |
| f"Overlay y={overlay.y} with offset={y_offset} is negative; " | |
| "overlays must be sorted by ascending y" | |
| ) | |
| if overlay.insert: | |
| # Splice overlay lines in, pushing existing content down. | |
| lines[adjusted_y:adjusted_y] = overlay.lines | |
| y_offset += len(overlay.lines) | |
| else: | |
| # Replace existing lines at the overlay position. | |
| target_len = adjusted_y + len(overlay.lines) | |
| if len(lines) < target_len: | |
| lines.extend([EMPTY_RENDER_LINE] * (target_len - len(lines))) | |
| for index, line in enumerate(overlay.lines): | |
| lines[adjusted_y + index] = line | |
| return tuple(lines) | |
| @classmethod | |
| def empty(cls) -> Self: | |
| return cls((), (0, 0), ()) | |
| @classmethod | |
| def from_screen_lines( | |
| cls, | |
| screen: Sequence[str], | |
| cursor: CursorXY, | |
| ) -> Self: | |
| return cls( | |
| tuple(RenderLine.from_rendered_text(line) for line in screen), | |
| cursor, | |
| (), | |
| ) | |
| def with_overlay( | |
| self, | |
| y: int, | |
| lines: Iterable[RenderLine], | |
| ) -> Self: | |
| return type(self)( | |
| self.lines, | |
| self.cursor, | |
| self.overlays + (ScreenOverlay(y, tuple(lines)),), | |
| ) | |
| @property | |
| def screen_lines(self) -> tuple[str, ...]: | |
| return tuple(line.text for line in self.composed_lines) | |
| @dataclass(frozen=True, slots=True) | |
| class LineDiff: | |
| """The changed region between an old and new version of one screen row. | |
| When the user types ``e`` so the row changes from | |
| ``>>> nam`` to ``>>> name``:: | |
| >>> n a m old | |
| >>> n a m e new | |
| ╰─╯ | |
| start_cell=7, new_cells=("m","e"), old_cells=("m",) | |
| """ | |
| start_cell: int | |
| start_x: int | |
| old_cells: tuple[RenderCell, ...] | |
| new_cells: tuple[RenderCell, ...] | |
| old_width: int | |
| new_width: int | |
| @property | |
| def old_text(self) -> str: | |
| return render_cells(self.old_cells) | |
| @property | |
| def new_text(self) -> str: | |
| return render_cells(self.new_cells) | |
| @property | |
| def old_changed_width(self) -> int: | |
| return sum(cell.width for cell in self.old_cells) | |
| @property | |
| def new_changed_width(self) -> int: | |
| return sum(cell.width for cell in self.new_cells) | |
| EMPTY_RENDER_LINE = RenderLine(cells=(), text="", width=0) | |
| @dataclass(frozen=True, slots=True) | |
| class LineUpdate: | |
| kind: LineUpdateKind | |
| y: int | |
| start_cell: int | |
| start_x: int | |
| """Screen x-coordinate where the update begins. Used for cursor positioning.""" | |
| cells: tuple[RenderCell, ...] | |
| char_width: int = 0 | |
| clear_eol: bool = False | |
| reset_to_margin: bool = False | |
| """If True, the console must resync the cursor position after writing | |
| (needed when cells contain non-SGR escape sequences that may move the cursor).""" | |
| text: str = field(init=False, default="") | |
| def __post_init__(self) -> None: | |
| object.__setattr__(self, "text", render_cells(self.cells)) | |
| def _controls_require_cursor_resync(controls: Sequence[str]) -> bool: | |
| # Anything beyond SGR means the cursor may no longer be where we left it. | |
| return any(not control.endswith("m") for control in controls) | |
| def requires_cursor_resync(cells: Sequence[RenderCell]) -> bool: | |
| return any(_controls_require_cursor_resync(cell.controls) for cell in cells) | |
| def render_cells( | |
| cells: Sequence[RenderCell], | |
| visual_style: str | None = None, | |
| ) -> str: | |
| """Render a sequence of cells into a terminal string with SGR escapes. | |
| Tracks the active SGR state to emit resets only when the style | |
| actually changes, minimizing output bytes. | |
| If *visual_style* is given (used by redraw visualization), it is appended | |
| to every cell's style. | |
| """ | |
| rendered: list[str] = [] | |
| active_escape = "" | |
| for cell in cells: | |
| if cell.controls: | |
| rendered.extend(cell.controls) | |
| if not cell.text: | |
| continue | |
| target_escape = _style_escape(cell.style) | |
| if visual_style is not None: | |
| target_escape += visual_style | |
| if target_escape != active_escape: | |
| if active_escape: | |
| rendered.append(ANSIColors.RESET) | |
| if target_escape: | |
| rendered.append(target_escape) | |
| active_escape = target_escape | |
| rendered.append(cell.text) | |
| if active_escape: | |
| rendered.append(ANSIColors.RESET) | |
| return "".join(rendered) | |
| def diff_render_lines(old: RenderLine, new: RenderLine) -> LineDiff | None: | |
| if old == new: | |
| return None | |
| prefix = 0 | |
| start_x = 0 | |
| max_prefix = min(len(old.cells), len(new.cells)) | |
| while prefix < max_prefix and old.cells[prefix] == new.cells[prefix]: | |
| # Stop at any cell with non-SGR controls, since those might affect | |
| # cursor position and must be re-emitted. | |
| if old.cells[prefix].controls: | |
| break | |
| start_x += old.cells[prefix].width | |
| prefix += 1 | |
| old_suffix = len(old.cells) | |
| new_suffix = len(new.cells) | |
| while old_suffix > prefix and new_suffix > prefix: | |
| old_cell = old.cells[old_suffix - 1] | |
| new_cell = new.cells[new_suffix - 1] | |
| if old_cell.controls or new_cell.controls or old_cell != new_cell: | |
| break | |
| old_suffix -= 1 | |
| new_suffix -= 1 | |
| # Extend diff range to include trailing zero-width combining characters, | |
| # so we never render a combining char without its base character. | |
| while old_suffix < len(old.cells) and old.cells[old_suffix].width == 0: | |
| old_suffix += 1 | |
| while new_suffix < len(new.cells) and new.cells[new_suffix].width == 0: | |
| new_suffix += 1 | |
| return LineDiff( | |
| start_cell=prefix, | |
| start_x=start_x, | |
| old_cells=old.cells[prefix:old_suffix], | |
| new_cells=new.cells[prefix:new_suffix], | |
| old_width=old.width, | |
| new_width=new.width, | |
| ) |