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