mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-12 15:33:45 +08:00
e996787af0
Introduce shared feature settings and presentation state so both UI layouts can consume the same resolved values.
160 lines
6.0 KiB
Python
160 lines
6.0 KiB
Python
"""Bitmap typography for the two device layouts, independent of application state."""
|
|
|
|
from dataclasses import dataclass
|
|
from enum import StrEnum
|
|
from pathlib import Path
|
|
import hashlib
|
|
import json
|
|
|
|
import pyray as rl
|
|
|
|
|
|
class Profile(StrEnum):
|
|
LARGE = "large"
|
|
COMPACT = "compact"
|
|
|
|
@property
|
|
def size(self) -> tuple[int, int]:
|
|
return (2160, 1080) if self == Profile.LARGE else (536, 240)
|
|
|
|
@property
|
|
def font_scale(self) -> float:
|
|
return 1.242 if self == Profile.LARGE else 1.16
|
|
|
|
|
|
class FontRole(StrEnum):
|
|
NORMAL = "normal"
|
|
MEDIUM = "medium"
|
|
BOLD = "bold"
|
|
SEMI_BOLD = "semi_bold"
|
|
BRAND = "brand"
|
|
ROMAN = "roman"
|
|
DISPLAY = "display"
|
|
FALLBACK = "fallback"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TextSize:
|
|
width: float
|
|
height: float
|
|
|
|
|
|
def font_filename(profile: Profile, role: FontRole) -> str:
|
|
if role == FontRole.NORMAL:
|
|
return "Inter-Regular.fnt" if profile == Profile.LARGE else "Inter-Medium.fnt"
|
|
return {
|
|
FontRole.MEDIUM: "Inter-Medium.fnt",
|
|
FontRole.BOLD: "Inter-Bold.fnt",
|
|
FontRole.SEMI_BOLD: "Inter-SemiBold.fnt",
|
|
FontRole.BRAND: "Sora-800.fnt",
|
|
FontRole.ROMAN: "Inter-Regular.fnt",
|
|
FontRole.DISPLAY: "Inter-Bold.fnt",
|
|
FontRole.FALLBACK: "unifont.fnt",
|
|
}[role]
|
|
|
|
|
|
def default_font_directory() -> Path:
|
|
return Path(__file__).parent / "assets/fonts"
|
|
|
|
|
|
def font_path(directory: Path, filename: str) -> Path:
|
|
# Keep the supplied Sora wordmark pinned to its bundled asset. The other
|
|
# fonts use the bundled directory by default or a validated caller override.
|
|
return default_font_directory() / filename if filename == "Sora-800.fnt" else directory / filename
|
|
|
|
|
|
def validate_bitmap_font(path: Path) -> None:
|
|
"""Accept only reviewed descriptor/atlas bytes before the native BMFont parser.
|
|
|
|
This manifest pins appearance as well as parser inputs. Changing either file
|
|
requires an explicit asset review; accepting a partial descriptor grammar is
|
|
insufficient to protect the native parser from malformed input.
|
|
"""
|
|
manifest_name = "sora-brand-font.json" if path.name == "Sora-800.fnt" else "bitmap-fonts.json"
|
|
manifest = json.loads(Path(__file__).with_name(manifest_name).read_text())
|
|
expected = {entry["file"]: entry for entry in manifest["files"]}
|
|
try:
|
|
if path.suffix != ".fnt" or path.name not in expected:
|
|
raise ValueError("descriptor is not in the reviewed font manifest")
|
|
for asset in (path, path.with_suffix(".png")):
|
|
record = expected[asset.name]
|
|
with asset.open("rb") as stream:
|
|
data = stream.read(record["bytes"] + 1)
|
|
if len(data) != record["bytes"] or hashlib.sha256(data).hexdigest() != record["sha256"]:
|
|
raise ValueError(f"{asset.name} differs from the reviewed font manifest")
|
|
except (OSError, KeyError, ValueError) as error:
|
|
raise ValueError(f"Invalid bitmap font {path.name}: {error}") from error
|
|
|
|
|
|
class BitmapFonts:
|
|
"""Own font textures without changing global text APIs or loading device services.
|
|
|
|
Construct after creating a native graphics context and close before destroying
|
|
it. A caller chooses the fallback explicitly; the brand always retains its own
|
|
glyphs. Raw draw calls avoid a second scale if an application installed its own
|
|
text wrapper. Text containing emoji is outside this component's contract.
|
|
"""
|
|
|
|
def __init__(self, profile: Profile, directory: Path, *, headless_context: bool = False):
|
|
if not (rl.is_window_ready() or headless_context):
|
|
raise RuntimeError("Bitmap fonts require an initialized graphics context")
|
|
self._headless_context = headless_context
|
|
self.profile = Profile(profile)
|
|
self._fonts: dict[str, rl.Font] = {}
|
|
self._measurements: dict[tuple, TextSize] = {}
|
|
default_texture_id = rl.get_font_default().texture.id
|
|
try:
|
|
for filename in dict.fromkeys(font_filename(self.profile, role) for role in FontRole):
|
|
path = font_path(directory, filename)
|
|
if not path.is_file():
|
|
raise FileNotFoundError(path)
|
|
validate_bitmap_font(path)
|
|
font = rl.load_font(str(path))
|
|
# Failed font/atlas loads may return Raylib's shared default resource.
|
|
# Never accept it as a requested bitmap or take ownership of its texture.
|
|
if font.texture.id in (0, default_texture_id) or font.glyphCount == 0:
|
|
raise RuntimeError(f"Unable to load bitmap font {filename}")
|
|
self._fonts[filename] = font
|
|
if filename != "unifont.fnt":
|
|
rl.gen_texture_mipmaps(font.texture)
|
|
rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_TRILINEAR)
|
|
except Exception:
|
|
self.close()
|
|
raise
|
|
|
|
def font(self, role: FontRole, *, fallback: bool = False) -> rl.Font:
|
|
if not self._fonts:
|
|
raise RuntimeError("Bitmap font resources are closed")
|
|
if fallback and role != FontRole.BRAND:
|
|
role = FontRole.FALLBACK
|
|
return self._fonts[font_filename(self.profile, role)]
|
|
|
|
def measure(self, text: str, role: FontRole, size: float, *, spacing: float = 0, fallback: bool = False) -> TextSize:
|
|
font = self.font(role, fallback=fallback)
|
|
spacing = round(spacing, 4)
|
|
key = font.texture.id, text, size, spacing
|
|
if key not in self._measurements:
|
|
measured = rl.measure_text_ex(font, text, size * self.profile.font_scale, spacing) # noqa: TID251
|
|
if len(self._measurements) >= 1024:
|
|
self._measurements.clear()
|
|
self._measurements[key] = TextSize(measured.x, measured.y)
|
|
return self._measurements[key]
|
|
|
|
def draw(self, text: str, role: FontRole, size: float, x: float, y: float, color: rl.Color = rl.WHITE,
|
|
*, spacing: float = 0, fallback: bool = False) -> None:
|
|
draw = getattr(rl, "_orig_draw_text_ex", rl.draw_text_ex)
|
|
draw(self.font(role, fallback=fallback), text, rl.Vector2(x, y), size * self.profile.font_scale, spacing, color)
|
|
|
|
def close(self) -> None:
|
|
if rl.is_window_ready() or self._headless_context:
|
|
for font in self._fonts.values():
|
|
rl.unload_font(font)
|
|
self._fonts.clear()
|
|
self._measurements.clear()
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_):
|
|
self.close()
|