Source code for simvx.graphics.draw2d_text

"""MSDF text rendering for Draw2D.

Handles font loading, glyph layout, and text measurement using the shared
TextRenderer and MSDF atlas infrastructure.
"""

import logging
from typing import TYPE_CHECKING, Any, ClassVar

from .draw2d_ops import Op, OpKind

if TYPE_CHECKING:
    from simvx.core.text import Font, MSDFAtlas

log = logging.getLogger(__name__)

[docs] class Draw2DTextMixin: """Mixin providing MSDF text rendering and measurement for Draw2D.""" # ---- TYPE_CHECKING-only sibling-attribute declarations ---- # These attributes / classmethods live on other Draw2D mixins or on the # final Draw2D class itself; declaring them here in TYPE_CHECKING blocks # makes mypy stop flagging legitimate cross-mixin access without # affecting runtime (no shadowing of the real defs). if TYPE_CHECKING: _ops: ClassVar[list[Op]] _current_clip: ClassVar[tuple[int, int, int, int] | None] _has_xf: ClassVar[bool] @classmethod def _xf_pt(cls, x: float, y: float) -> tuple[float, float]: ... @classmethod def _xf_sc(cls) -> float: ... @classmethod def _norm_colour(cls, c: Any) -> tuple[float, float, float, float]: ... # MSDF font state -- delegated to shared TextRenderer _font: "MSDFAtlas | None" = None _font_path: str | None = None _font_obj: "Font | None" = None _base_height: float = 14.0 # Logical base height for text sizing. Text dimensions in the widget # coordinate system always use this value so that font_size N renders at # N logical pixels regardless of _base_height (which only affects atlas # quality, not logical size). _LOGICAL_BASE: float = 14.0 # Readable-pixel floor: MSDF sub-pixel sampling can't reconstruct glyphs # cleanly below ~10px, so the default safety net clamps scale up to this # value. Callers who pass an explicit ``min_scale`` opt out of the floor # and take responsibility for legibility themselves. _READABLE_MIN_PIXELS: float = 10.0 _text_width_cache: dict[tuple[str, float], float] = {}
[docs] @classmethod def set_font(cls, path: str | None = None, size: int = 48) -> None: """Load an MSDF font atlas via the shared TextRenderer.""" from .text_renderer import _find_font, get_shared_text_renderer if path is None: path = _find_font() if path is None: log.warning("Draw2D: no font found, text rendering disabled") return try: tr = get_shared_text_renderer() if tr is None: log.warning("Draw2D: no text renderer available, text rendering disabled") return atlas = tr.get_atlas(path, font_size=size) cls._font = atlas cls._font_obj = atlas.font cls._font_path = path except (ImportError, OSError) as exc: log.warning("Draw2D: failed to load font %s: %s", path, exc)
@classmethod def _ensure_font(cls) -> None: """Lazy-init font on first text call.""" if cls._font is None: cls.set_font()
[docs] @classmethod def draw_text( cls, text, pos=None, *, colour=None, scale=1.0, rect=None, alignment="left", vertical_alignment="top", fit_to_width=False, min_scale=None, ): """Draw text at ``pos`` or inside ``rect`` with optional alignment. Two positioning modes: - ``pos=(x, y)``: text top-left at the given coordinate (legacy form). - ``rect=(x, y, w, h)``: text positioned by ``alignment`` (left/center/ right) and ``vertical_alignment`` (top/center/bottom) inside the rect. ``fit_to_width=True`` shrinks ``scale`` so the text fits ``rect.w``, clamped to ``min_scale``. ``min_scale`` semantics: - ``None`` (default): the readable-pixel safety net (~10px) is applied automatically. Tiny ``scale`` values are silently bumped up so MSDF glyphs stay legible. - explicit value: the caller's floor is honoured exactly. The readable safety net is bypassed: the caller has taken responsibility for legibility (e.g. shrinking labels to fit a cramped inventory cell). """ cls._ensure_font() if cls._font is None: return if rect is not None: rx, ry, rw, rh = rect[0], rect[1], rect[2], rect[3] if fit_to_width: scale = cls.fit_scale(text, rw, base_scale=scale, min_scale=min_scale) tw = cls.text_width(text, scale) # Use visible glyph height for single-line so center alignment matches # the user's eye; multi-line uses the full line-pitch block. glyph_h = cls._LOGICAL_BASE * scale n_lines = text.count("\n") + 1 th = glyph_h if n_lines == 1 else glyph_h * 1.2 * n_lines if alignment == "center": x = rx + (rw - tw) / 2 elif alignment == "right": x = rx + rw - tw else: x = rx if vertical_alignment == "center": y = ry + (rh - th) / 2 elif vertical_alignment == "bottom": y = ry + rh - th else: y = ry pos = (x, y) elif pos is None: pos = (0.0, 0.0) from .text_renderer import get_shared_text_renderer # Ensure glyphs with fallback chain (packs fallback glyphs into primary atlas) _tr = get_shared_text_renderer() if _tr is not None: _tr._ensure_with_fallback(cls._font, text) # Apply the readable-pixel safety net only when the caller didn't # specify an explicit ``min_scale``. Below ~10px MSDF text becomes # illegible; callers who opt out take responsibility for legibility. if min_scale is None: effective_scale = max(scale, cls._READABLE_MIN_PIXELS / cls._LOGICAL_BASE) else: effective_scale = scale display_scale = cls._LOGICAL_BASE * effective_scale / cls._font_obj.size if hasattr(pos, "x"): cursor_x, cursor_y = float(pos.x), float(pos.y) else: cursor_x, cursor_y = float(pos[0]), float(pos[1]) if cls._has_xf: cursor_x, cursor_y = cls._xf_pt(cursor_x, cursor_y) display_scale *= cls._xf_sc() start_x = cursor_x c = cls._norm_colour(colour) font = cls._font font_obj = cls._font_obj pad = font.glyph_padding # Snap baseline to pixel grid once -- all glyphs share this. baseline_y = round(cursor_y + font_obj.ascender * display_scale) verts: list[tuple] = [] indices: list[int] = [] for ch in text: if ch == "\n": cursor_y += font_obj.line_height * display_scale baseline_y = round(cursor_y + font_obj.ascender * display_scale) cursor_x = start_x continue if ch == " " or ch not in font.regions: if font_obj.has_glyph(ch): cursor_x += font_obj.get_glyph(ch).advance_x * display_scale else: cursor_x += font_obj.size * 0.6 * display_scale # Tofu placeholder continue region = font.regions[ch] gm = region.metrics # Snap x to pixel grid for crisp vertical stems. # Offset by -pad: atlas region includes padding around the glyph. qx = round(cursor_x + (gm.bearing_x - pad) * display_scale) # DON'T round per-glyph y -- preserves baseline alignment qy = baseline_y - (gm.bearing_y + pad) * display_scale # Don't round dimensions -- sub-pixel quad sizes preserve exact UV-to-screen # mapping, preventing per-glyph baseline drift from rounding variance. qw = max(1.0, region.w * display_scale) qh = max(1.0, region.h * display_scale) base = len(verts) verts.extend( [ (qx, qy, region.u0, region.v0, *c), # top-left (qx + qw, qy, region.u1, region.v0, *c), # top-right (qx + qw, qy + qh, region.u1, region.v1, *c), # bottom-right (qx, qy + qh, region.u0, region.v1, *c), # bottom-left ] ) indices.extend([base, base + 1, base + 2, base, base + 2, base + 3]) cursor_x += gm.advance_x * display_scale if verts: cls._ops.append(Op(OpKind.TEXT, cls._current_clip, verts, indices, -1))
[docs] @classmethod def text_height(cls, text, scale=1.0): """Height of ``text`` in pixels at ``scale``. Multi-line strings (containing ``\\n``) accumulate line heights using the font's line-height metric. Returns 0 for empty input. """ if not text: return 0 cls._ensure_font() line_count = text.count("\n") + 1 if cls._font_obj is None: return cls._LOGICAL_BASE * scale * 1.2 * line_count display_scale = cls._LOGICAL_BASE * scale / cls._font_obj.size return cls._font_obj.line_height * display_scale * line_count
[docs] @classmethod def text_size(cls, text, scale=1.0): """Return ``(width, height)`` in pixels at ``scale``.""" return (cls.text_width(text, scale), cls.text_height(text, scale))
[docs] @classmethod def fit_scale(cls, text, max_width, *, base_scale=1.0, min_scale=None): """Largest scale ≤ ``base_scale`` that fits ``text`` within ``max_width``. Returns ``base_scale`` when the text already fits or when inputs are degenerate. The returned value matches what :meth:`draw_text` would actually use, so callers can compute width metrics that align with what's drawn. ``min_scale`` semantics mirror :meth:`draw_text`: - ``None`` (default): clamps from below by the readable-pixel floor (~10px) so the returned scale never produces illegible MSDF output. - explicit value: the caller's floor is honoured exactly, bypassing the readable safety net. """ floor = cls._READABLE_MIN_PIXELS / cls._LOGICAL_BASE if min_scale is None else min_scale if not text or max_width <= 0: return base_scale w = cls.text_width(text, base_scale) if w <= max_width: return base_scale return max(floor, base_scale * max_width / w)
[docs] @classmethod def text_width(cls, text, scale=1): """Width of ``text`` in pixels at ``scale``. Multi-line strings (containing ``\\n``) return the widest line; newlines themselves contribute no horizontal advance. """ if not text: return 0 key = (text, scale) cached = cls._text_width_cache.get(key) if cached is not None: return cached cls._ensure_font() if cls._font is None: return 0 display_scale = cls._LOGICAL_BASE * scale / cls._font_obj.size from .text_renderer import get_shared_text_renderer _tr = get_shared_text_renderer() if _tr is not None: _tr._ensure_with_fallback(cls._font, text) regions = cls._font.regions font_obj = cls._font_obj tofu = font_obj.size * 0.6 max_line = 0.0 line = 0.0 for ch in text: if ch == "\n": if line > max_line: max_line = line line = 0.0 continue region = regions.get(ch) if region is not None: line += region.metrics.advance_x elif font_obj.has_glyph(ch): line += font_obj.get_glyph(ch).advance_x else: line += tofu if line > max_line: max_line = line result = max_line * display_scale cls._text_width_cache[key] = result return result