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