Source code for simvx.core.ui.text_paragraph

"""TextParagraph: pure-function word-wrap helper for variable-width text.

Editor tooltips, tour-guide popups, and game dialog boxes all need the same
recipe: given a max width and a font size, break a paragraph into lines that
fit, then return the wrapped lines plus the total block height. Callers
previously hand-rolled this against ``renderer.text_width()``.

``wrap_paragraph`` is a free function so it stays usable from any draw path
(widget, ``Node2D``, or raw ``on_draw`` callback) without coupling to a
specific widget class.

Example::

    from simvx.core.ui import wrap_paragraph
    lines, h = wrap_paragraph(text, max_width=200, renderer=renderer, font_size=14)
    for i, line in enumerate(lines):
        renderer.draw_text(line, (x, y + i * (14 * 1.2)), scale=1.0)
"""

from __future__ import annotations

from dataclasses import dataclass

__all__ = ["TextParagraph", "wrap_paragraph"]

_LINE_HEIGHT_FACTOR = 1.2


[docs] @dataclass(frozen=True) class TextParagraph: """Result of :func:`wrap_paragraph`: wrapped lines + measured dimensions.""" lines: tuple[str, ...] total_height: float max_line_width: float line_height: float
[docs] def wrap_paragraph( text: str, *, max_width: float, renderer, font_size: float = 14.0, line_height_factor: float = _LINE_HEIGHT_FACTOR, ) -> TextParagraph: """Word-wrap ``text`` to fit within ``max_width`` pixels. The renderer is queried via ``renderer.text_width(line, scale)``: any renderer that implements the Draw2D contract (the real one, :class:`simvx.core.ui.DrawLog` in tests, ...) works. Hard newlines (``\\n``) are honoured as paragraph breaks. Words longer than ``max_width`` are emitted on their own line and overflow horizontally rather than being split mid-glyph (left-to-right text only: defer complex shaping to a freetype rewrite). Args: text: Source text. May contain ``\\n``. max_width: Wrap width in pixels. renderer: Object exposing ``text_width(str, scale) -> float``. font_size: Font size in pixels. Determines ``scale = font_size / 14`` for the underlying measurement. line_height_factor: Multiplier applied to ``font_size`` for the inter-line pitch (default 1.2). Returns: :class:`TextParagraph` with the wrapped lines and the total block height for vertical layout. """ scale = font_size / 14.0 line_height = font_size * line_height_factor if not text: return TextParagraph(lines=("",), total_height=line_height, max_line_width=0.0, line_height=line_height) measure = renderer.text_width paragraphs = text.split("\n") wrapped: list[str] = [] widest = 0.0 for paragraph in paragraphs: if not paragraph: wrapped.append("") continue words = paragraph.split(" ") current = "" for word in words: if not current: candidate = word else: candidate = f"{current} {word}" if measure(candidate, scale) <= max_width or not current: current = candidate else: wrapped.append(current) widest = max(widest, measure(current, scale)) current = word if current: wrapped.append(current) widest = max(widest, measure(current, scale)) return TextParagraph( lines=tuple(wrapped), total_height=line_height * len(wrapped), max_line_width=widest, line_height=line_height, )