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