mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 00:53:45 +08:00
a82935f326
Integrate onroad views, configurable widgets, local settings, steering-profile contracts, Bluetooth and Android Auto with their device and runtime services. Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com> Co-authored-by: Daniel Koepping <elkoled@gmail.com> Co-authored-by: Danny <dannyirwin31@yahoo.com> Co-authored-by: DimitriChrysafis <Dimitri.chrysafis@gmail.com> Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com> Co-authored-by: Harald Schäfer <harald.the.engineer@gmail.com> Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu> Co-authored-by: Shane Smiskol <shane@smiskol.com> Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com> Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Co-authored-by: stef <19478336+stefpi@users.noreply.github.com> Co-authored-by: whoisdomi <dcantu83@gmail.com> Co-authored-by: Intelli <contact@intelli.software> Co-authored-by: pharmacomaniac <blittle65@gmail.com> Co-authored-by: prabhaavp <prabhaav@msu.edu>
258 lines
11 KiB
Python
258 lines
11 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 numpy as np
|
|
from PIL import Image
|
|
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"
|
|
SPEED = "speed"
|
|
SET_SPEED = "set_speed"
|
|
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.SET_SPEED: "DINish-Speed.fnt",
|
|
FontRole.SPEED: "DINish-Speed.fnt" if profile == Profile.LARGE else "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 in ("Sora-800.fnt", "DINish-Speed.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-800.fnt": "sora-brand-font.json", "DINish-Speed.fnt": "dinish-speed-font.json"}.get(path.name, "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._directory = directory
|
|
self._fonts: dict[str, rl.Font] = {}
|
|
self._measurements: dict[tuple, TextSize] = {}
|
|
self._vertical_ink: dict[tuple, tuple[float, float]] = {}
|
|
self._horizontal_ink: dict[tuple, tuple[float, float]] = {}
|
|
self._glyph_ink: dict[str, dict[int, tuple[int, int] | None]] = {}
|
|
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 _optical_style(self, role: FontRole, fallback: bool = False) -> tuple[float, float]:
|
|
if fallback:
|
|
return 1., 0.
|
|
if self.profile == Profile.LARGE and role == FontRole.SPEED:
|
|
return 1.04, .85
|
|
if self.profile == Profile.COMPACT and role == FontRole.SET_SPEED:
|
|
return 1., .65
|
|
return 1., 0.
|
|
|
|
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)
|
|
width_scale, weight = self._optical_style(role, fallback)
|
|
key = font.texture.id, text, size, spacing, width_scale, weight
|
|
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 * width_scale + (weight if text else 0), measured.y)
|
|
return self._measurements[key]
|
|
|
|
def vertical_ink(self, text: str, role: FontRole, size: float) -> tuple[float, float]:
|
|
"""Visible glyph top/bottom, independent of the font's padded line box."""
|
|
key = (text, role, size)
|
|
if key not in self._vertical_ink:
|
|
font = self.font(role)
|
|
glyphs = [rl.get_glyph_index(font, ord(char)) for char in text if not char.isspace()]
|
|
scale = size * self.profile.font_scale / font.baseSize
|
|
top = min((font.glyphs[index].offsetY for index in glyphs), default=0)
|
|
bottom = max((font.glyphs[index].offsetY + font.recs[index].height for index in glyphs), default=0)
|
|
if len(self._vertical_ink) >= 1024:
|
|
self._vertical_ink.clear()
|
|
self._vertical_ink[key] = (top * scale, bottom * scale)
|
|
return self._vertical_ink[key]
|
|
|
|
def horizontal_ink(self, text: str, role: FontRole, size: float) -> tuple[float, float]:
|
|
"""Visible glyph left/right from the draw x, without the font's side bearings."""
|
|
key = (text, role, size)
|
|
if key not in self._horizontal_ink:
|
|
font = self.font(role)
|
|
width_scale, weight = self._optical_style(role)
|
|
scale = size * self.profile.font_scale / font.baseSize
|
|
left = right = None
|
|
x = 0.
|
|
columns = self._glyph_columns(role)
|
|
for char in text:
|
|
index = rl.get_glyph_index(font, ord(char))
|
|
glyph, rec = font.glyphs[index], font.recs[index]
|
|
# Fixed-width fonts (DINish-Speed) pad each glyph cell, so use the atlas's inked columns.
|
|
ink = None if char.isspace() else columns.get(index, (0, rec.width - 1) if rec.width > 0 else None)
|
|
if ink is not None:
|
|
left = x + glyph.offsetX + ink[0] if left is None else min(left, x + glyph.offsetX + ink[0])
|
|
right = x + glyph.offsetX + ink[1] + 1 if right is None else max(right, x + glyph.offsetX + ink[1] + 1)
|
|
x += glyph.advanceX or rec.width
|
|
if left is None:
|
|
left = right = 0.
|
|
if len(self._horizontal_ink) >= 1024:
|
|
self._horizontal_ink.clear()
|
|
# Match draw(): the optical width scale applies to the whole run and the weight pass shifts right.
|
|
self._horizontal_ink[key] = (left * scale * width_scale, right * scale * width_scale + (weight if text else 0))
|
|
return self._horizontal_ink[key]
|
|
|
|
def _glyph_columns(self, role: FontRole) -> dict[int, tuple[int, int] | None]:
|
|
"""Measure the reviewed PNG, without GPU readback during a widget render.
|
|
|
|
Texture readback depends on the graphics backend and can disturb the active
|
|
framebuffer used for Galaxy's widget previews. The source atlas has the same
|
|
pixels and works independently of the current render target.
|
|
"""
|
|
filename = font_filename(self.profile, role)
|
|
if filename not in self._glyph_ink:
|
|
font = self.font(role)
|
|
with Image.open(font_path(self._directory, filename).with_suffix('.png')) as atlas:
|
|
alpha = np.asarray(atlas.convert('RGBA').getchannel('A'))
|
|
columns = {}
|
|
for index in range(font.glyphCount):
|
|
rec = font.recs[index]
|
|
cell = alpha[int(rec.y):int(rec.y + rec.height), int(rec.x):int(rec.x + rec.width)]
|
|
inked = np.flatnonzero(cell.max(0) > 40) if cell.size else []
|
|
columns[index] = (int(inked[0]), int(inked[-1])) if len(inked) else None
|
|
self._glyph_ink[filename] = columns
|
|
return self._glyph_ink[filename]
|
|
|
|
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)
|
|
width_scale, weight = self._optical_style(role, fallback)
|
|
if width_scale == 1. and weight == 0.:
|
|
draw(self.font(role, fallback=fallback), text, rl.Vector2(x, y), size * self.profile.font_scale, spacing, color)
|
|
return
|
|
rl.rl_push_matrix()
|
|
try:
|
|
rl.rl_translatef(x, y, 0)
|
|
rl.rl_scalef(width_scale, 1., 1.)
|
|
draw(self.font(role, fallback=fallback), text, rl.Vector2(0, 0), size * self.profile.font_scale, spacing, color)
|
|
if weight:
|
|
draw(self.font(role, fallback=fallback), text, rl.Vector2(weight / width_scale, 0),
|
|
size * self.profile.font_scale, spacing, color)
|
|
finally:
|
|
rl.rl_pop_matrix()
|
|
|
|
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._vertical_ink.clear()
|
|
self._horizontal_ink.clear()
|
|
self._glyph_ink.clear()
|
|
self._measurements.clear()
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_):
|
|
self.close()
|