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.
"""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING

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.""" # 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 _text_verts: list[tuple] = [] _text_indices: list[int] = [] # 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 _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, scale=1, colour=None): if colour: cls.set_colour(*colour) cls._ensure_font() if cls._font is None: return # Ensure glyphs with fallback chain (packs fallback glyphs into primary atlas) 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) # Enforce minimum readable size -- below ~10px MSDF text becomes illegible effective_scale = max(scale, 10.0 / cls._LOGICAL_BASE) 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._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) 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(cls._text_verts) cls._text_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 ] ) cls._text_indices.extend([base, base + 1, base + 2, base, base + 2, base + 3]) cursor_x += gm.advance_x * display_scale
[docs] @classmethod def text_width(cls, text, scale=1): 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) total = 0.0 for ch in text: if ch in cls._font.regions: total += cls._font.regions[ch].metrics.advance_x elif cls._font_obj.has_glyph(ch): total += cls._font_obj.get_glyph(ch).advance_x else: total += cls._font_obj.size * 0.6 # Tofu placeholder width result = total * display_scale cls._text_width_cache[key] = result return result
[docs] @classmethod def draw_text_coloured(cls, text, x, y, scale=1, colour=None): """Draw text with colour (float 0-1 tuple) in one call.""" if colour: cls._colour = cls._norm_colour(colour) cls.draw_text(text, (x, y), scale)