from __future__ import annotations from dataclasses import dataclass from .utils import ColorSpan, StyleRef, THEME, iter_display_chars, unbracket, wlen @dataclass(frozen=True, slots=True) class ContentFragment: """A single display character with its visual width and style. The body of ``>>> def greet`` becomes one fragment per character:: d e f g r e e t ╰──┴──╯ ╰──┴──┴──┴──╯ keyword (unstyled) e.g. ``ContentFragment("d", 1, StyleRef(tag="keyword"))``. """ text: str width: int style: StyleRef = StyleRef() @dataclass(frozen=True, slots=True) class PromptContent: """The prompt split into leading full-width lines and an inline portion. For the common ``">>> "`` prompt (no newlines):: >>> def greet(name): ╰─╯ text=">>> ", width=4, leading_lines=() If ``sys.ps1`` contains newlines, e.g. ``"Python 3.13\\n>>> "``:: Python 3.13 ← leading_lines[0] >>> def greet(name): ╰─╯ text=">>> ", width=4 """ leading_lines: tuple[ContentFragment, ...] text: str width: int @dataclass(frozen=True, slots=True) class SourceLine: """One logical line from the editor buffer, before styling. Given this two-line input in the REPL:: >>> def greet(name): ... return name ▲ cursor The buffer ``"def greet(name):\\n return name"`` yields:: SourceLine(lineno=0, text="def greet(name):", start_offset=0, has_newline=True) SourceLine(lineno=1, text=" return name", start_offset=17, cursor_index=14) """ lineno: int text: str start_offset: int has_newline: bool cursor_index: int | None = None @property def cursor_on_line(self) -> bool: return self.cursor_index is not None @dataclass(frozen=True, slots=True) class ContentLine: """A logical line paired with its prompt and styled body. For ``>>> def greet(name):``:: >>> def greet(name): ╰─╯ ╰──────────────╯ prompt body: one ContentFragment per character """ source: SourceLine prompt: PromptContent body: tuple[ContentFragment, ...] def process_prompt(prompt: str) -> PromptContent: r"""Return prompt content with width measured without zero-width markup.""" prompt_text = unbracket(prompt, including_content=False) visible_prompt = unbracket(prompt, including_content=True) leading_lines: list[ContentFragment] = [] while "\n" in prompt_text: leading_text, _, prompt_text = prompt_text.partition("\n") visible_leading, _, visible_prompt = visible_prompt.partition("\n") leading_lines.append(ContentFragment(leading_text, wlen(visible_leading))) return PromptContent(tuple(leading_lines), prompt_text, wlen(visible_prompt)) def build_body_fragments( buffer: str, colors: list[ColorSpan] | None, start_index: int, ) -> tuple[ContentFragment, ...]: """Convert a line's text into styled content fragments.""" # Two separate loops to avoid the THEME() call in the common uncolored path. if colors is None: return tuple( ContentFragment( styled_char.text, styled_char.width, StyleRef(), ) for styled_char in iter_display_chars(buffer, colors, start_index) ) theme = THEME() return tuple( ContentFragment( styled_char.text, styled_char.width, StyleRef.from_tag(styled_char.tag, theme[styled_char.tag]) if styled_char.tag else StyleRef(), ) for styled_char in iter_display_chars(buffer, colors, start_index) )