Shared UI Data

Introduce shared feature settings and presentation state so both UI layouts can consume the same resolved values.

Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com>
This commit is contained in:
firestar5683
2026-09-25 14:53:20 -05:00
parent ec8d7eb98b
commit 5a01c2bacb
3 changed files with 655 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Custom presentation components with explicit state and rendering boundaries."""
@@ -0,0 +1,495 @@
"""Immutable saved-feature settings presentation and guarded user intent."""
from dataclasses import dataclass
from enum import StrEnum
import math
from collections import deque
from collections.abc import Callable
FEATURE_HEADER_HEIGHT = 88
FEATURE_BACK_WIDTH = 208
FEATURE_ROW_TOP = 112
FEATURE_ROW_HEIGHT = 154
FEATURE_VISIBLE_ROWS = 5
FEATURE_CONTROL_LEFT = 1748
FEATURE_CONTROL_RIGHT = 2118
FEATURE_BUTTON_TOP = 64
FEATURE_BUTTON_HEIGHT = 80
FEATURE_ACTION_MARGIN = 36
FEATURE_PAGE_COUNTER_WIDTH = 160
FEATURE_FOOTER_CENTER = 1015
FEATURE_FOOTER_BOTTOM = 1080
FEATURE_FOOTER_BUTTON_WIDTH = 540
FEATURE_FOOTER_BUTTON_HEIGHT = 84
FEATURE_TAP_SLOP = 36
FEATURE_SWIPE_DISTANCE = 120
FEATURE_FLICK_DISTANCE = 60
FEATURE_FLICK_VELOCITY = 800
FEATURE_FLICK_WINDOW = 0.1
FEATURE_FLICK_PAUSE = 0.06
def is_long_confirm_action(key: str) -> bool:
return key.startswith(("long_repair:", "long_reset:"))
class FeaturePage(StrEnum):
HUB = "hub"
VEHICLE = "vehicle"
SLC = "slc"
LANE = "lane"
LANE_CHANGE = "lane_change"
PROFILES = "profiles"
AGGRESSIVE = "aggressive"
STANDARD = "standard"
RELAXED = "relaxed"
TRAFFIC = "traffic"
CURVE = "curve"
TORQUE = "torque"
AOL = "aol"
WHEEL = "wheel"
CONDITIONAL = "conditional"
CONDITIONAL_CEM = "conditional/cem"
CONDITIONAL_CCM = "conditional/ccm"
TORQUE_CONFIRM_ACTIONS = frozenset(("torque_adopt", "torque_reset", "torque_reset_profile", "torque_rebase", "torque_prepare_firestar", "torque_gain_rebase"))
SLC_CONFIRM_ACTIONS = frozenset(("slc_adopt", "slc_reset"))
LANE_CHANGE_CONFIRM_ACTIONS = frozenset(("lane_change:reset",))
CURVE_CONFIRM_ACTIONS = frozenset(("curve_reset",))
CONDITIONAL_CONFIRM_ACTIONS = frozenset(("conditional:reset", "conditional:manual_reset"))
FEATURE_CONFIRM_ACTIONS = (TORQUE_CONFIRM_ACTIONS | SLC_CONFIRM_ACTIONS | LANE_CHANGE_CONFIRM_ACTIONS |
CURVE_CONFIRM_ACTIONS | CONDITIONAL_CONFIRM_ACTIONS | {"reset_profiles", "pip:reset", "sentry:reset"})
@dataclass(frozen=True)
class FeatureRow:
key: str
label: str
value: str
source: bytes | None = None
choices: tuple[str, ...] = ()
step: float = 0.0
minimum: float = 0.0
maximum: float = 0.0
unit: str = ""
available: bool = False
reason: str = ""
page: str = ""
related_source: bytes | None = None
vehicle_fingerprint: str | None = None
capability: tuple | None = None
dependencies: tuple[tuple[str, bytes | None], ...] = ()
display_unit: str = ""
repair_value: str = ""
default_value: str | None = None
default_key: str = ""
actions: tuple[tuple[str, bool], ...] = ()
presets: tuple[tuple[str, str], ...] = ()
default_reset_available: bool = False
@dataclass(frozen=True)
class FeatureSettingsState:
page: str = FeaturePage.HUB
title: str = "Driving Controls"
subtitle: str = ""
rows: tuple[FeatureRow, ...] = ()
parked: bool = False
scroll: int = 0
sidebar_expanded: bool = True
parent_title: str = "StarPilot"
editor: bool = False
save_hint: str = "Changes save automatically"
def feature_row_top(state: FeatureSettingsState) -> int:
return FEATURE_ROW_TOP + (52 if state.subtitle else 0)
def value_editor_rect(state: FeatureSettingsState) -> tuple[float, float, float, float]:
left = (520 if state.sidebar_expanded else 20) + 55
top = feature_row_top(state) + 24
return left, top, 2094 - left, 1020 - top
def value_done_rect(state: FeatureSettingsState) -> tuple[float, float, float, float]:
x, y, width, height = value_editor_rect(state)
return x, y + height - 140, width, 140
def value_buttons(state: FeatureSettingsState, row: FeatureRow, y: float = 0) -> tuple[tuple[str, tuple[float, float, float, float], bool], ...]:
"""Shared value-editor geometry; limits and choices come from the saved row."""
if not state.editor or len(state.rows) != 1 or not row.presets or row.repair_value:
return ()
left, _, editor_width, _ = value_editor_rect(state)
step_width, gap, height = 200, 24, 200
presets = tuple(value for value, _ in row.presets)
width = (editor_width - (len(presets) - 1) * gap) / len(presets)
if row.step:
try:
current = float(row.value)
except ValueError:
current = None
minus = current is not None and current > row.minimum
plus = current is not None and current < row.maximum
else:
index = row.choices.index(row.value) if row.value in row.choices else -1
minus, plus = index > 0, 0 <= index < len(row.choices) - 1
buttons = [("-", (left, y + 110, step_width, height),
row.available and minus)]
buttons.extend((value, (left + slot * (width + gap), y + 356, width, height),
row.available and row.value != value)
for slot, value in enumerate(presets))
buttons.append(("+", (left + editor_width - step_width, y + 110, step_width, height),
row.available and plus))
return tuple(buttons)
def value_text(row: FeatureRow) -> str:
label = dict(row.presets).get(row.value)
if label is not None:
return "Muted" if label == "Mute" else label
return row.value + (("" if row.unit == "%" else " ") + row.unit if row.unit and row.value != "Auto" else "")
def feature_page_counter_left(state: FeatureSettingsState) -> float:
left = 520 if state.sidebar_expanded else 20
return (left + 25 + 2120 - FEATURE_PAGE_COUNTER_WIDTH) / 2
def feature_footer_top(state: FeatureSettingsState) -> float:
return feature_row_top(state) + FEATURE_VISIBLE_ROWS * FEATURE_ROW_HEIGHT
def feature_footer_buttons(state: FeatureSettingsState) -> tuple[tuple[float, float, float, float], tuple[float, float, float, float]]:
left = 520 if state.sidebar_expanded else 20
y = FEATURE_FOOTER_CENTER - FEATURE_FOOTER_BUTTON_HEIGHT / 2
buttons = tuple((center - FEATURE_FOOTER_BUTTON_WIDTH / 2, y, FEATURE_FOOTER_BUTTON_WIDTH, FEATURE_FOOTER_BUTTON_HEIGHT)
for center in ((left + 25 + 1320) / 2, (1320 + 2120) / 2))
return buttons[0], buttons[1]
def feature_footer_target(x: float, y: float, state: FeatureSettingsState) -> int | None:
left = 520 if state.sidebar_expanded else 20
if state.editor or not feature_footer_top(state) <= y <= FEATURE_FOOTER_BOTTOM:
return None
counter_left = feature_page_counter_left(state)
if left <= x < counter_left:
return -1
if counter_left + FEATURE_PAGE_COUNTER_WIDTH < x <= 2140:
return 1
return None
def feature_parent_page(page: str) -> str | None:
if page == FeaturePage.HUB:
return None
if "/" in page:
return page.split("/", maxsplit=1)[0]
if page in (FeaturePage.AGGRESSIVE, FeaturePage.STANDARD, FeaturePage.RELAXED, FeaturePage.TRAFFIC):
return FeaturePage.PROFILES
return FeaturePage.HUB
def feature_parent_title(page: str) -> str:
parent = feature_parent_page(page)
return {None: "StarPilot", FeaturePage.HUB: "Driving Controls", FeaturePage.PROFILES: "Long Planner",
FeaturePage.CONDITIONAL: "Conditional Driving Modes"}.get(parent, (parent or "").replace("_", " ").title())
def boolean_value(row: FeatureRow) -> bool | None:
if (not row.key or row.actions or row.repair_value or row.step or row.key in FEATURE_CONFIRM_ACTIONS or
row.key.startswith("pip:format:") or is_long_confirm_action(row.key) or row.choices != ("Off", "On")):
return None
if row.value in ("Off", "On"):
return row.value == "On"
if row.key == "SLCFallback" and row.value == "Off (saved mode 0 or 1)":
return False
return None
def feature_action_left(row: FeatureRow) -> float:
if row.page and boolean_value(row) is not None:
return FEATURE_CONTROL_LEFT - 280
return feature_action_rects(row, 0)[0][0] if row.actions else FEATURE_CONTROL_LEFT
def feature_action_rects(row: FeatureRow, y: float) -> tuple[tuple[float, float, float, float], ...]:
width = 300 if row.key == "drive_state" else FEATURE_CONTROL_RIGHT - FEATURE_CONTROL_LEFT
margin = 14 if row.key == "drive_state" else FEATURE_ACTION_MARGIN
left = FEATURE_CONTROL_RIGHT - len(row.actions) * width - max(0, len(row.actions) - 1) * 16
return tuple((left + index * (width + 16), y + margin, width, FEATURE_ROW_HEIGHT - 2 * margin)
for index in range(len(row.actions)))
def feature_scroll(scroll: int, direction: int, row_count: int) -> int:
last_page = max(0, row_count - 1) // FEATURE_VISIBLE_ROWS * FEATURE_VISIBLE_ROWS
return max(0, min(last_page, scroll + direction * FEATURE_VISIBLE_ROWS))
@dataclass(frozen=True)
class FeatureSettingsRequest:
key: str
expected: bytes | None
value: str
confirmation: bool = False
related_source: bytes | None = None
vehicle_fingerprint: str | None = None
capability: tuple | None = None
dependencies: tuple[tuple[str, bytes | None], ...] = ()
display_unit: str = ""
direction: int = 0
@dataclass(frozen=True)
class FeatureUiAction:
kind: str
row: FeatureRow | None = None
direction: int = 1
class FeatureInput:
"""One touch: movement cancels its tap; a horizontal swipe pages on release."""
def __init__(self, emit: Callable[[FeatureUiAction], None]):
self.emit = emit
self.held: tuple[float, float, FeatureUiAction | None, int, str, bool] | None = None
self._released: tuple[float, float, FeatureUiAction | None, int, str, bool] | None = None
self._samples: deque[tuple[float, float]] = deque(maxlen=16)
self._drag_x = 0.0
def pressed_action(self, state: FeatureSettingsState) -> FeatureUiAction | None:
"""Keep taps completed between renders visible for one frame."""
held = self.held or self._released
self._released = None
if held is not None:
x, y, action, scroll, page, sidebar = held
if ((scroll, page, sidebar) == (state.scroll, state.page, state.sidebar_expanded) and
self.target(x, y, state) == action):
return action
return None
@property
def drag_x(self) -> float:
return self._drag_x
@staticmethod
def _in_body(x: float, y: float, state: FeatureSettingsState, *, include_sidebar: bool = False) -> bool:
left = (520 if state.sidebar_expanded else 20) + 25
if include_sidebar and state.sidebar_expanded:
left = 0
return left <= x <= 2125 and feature_row_top(state) <= y < feature_row_top(state) + FEATURE_VISIBLE_ROWS * FEATURE_ROW_HEIGHT
@staticmethod
def target(x: float, y: float, state: FeatureSettingsState) -> FeatureUiAction | None:
left = 520 if state.sidebar_expanded else 20
row_top = feature_row_top(state)
if not left <= x <= 2140:
return None
if 12 <= y <= 12 + FEATURE_HEADER_HEIGHT:
return FeatureUiAction("back") if not state.sidebar_expanded and x < left + FEATURE_BACK_WIDTH else None
if 12 + FEATURE_HEADER_HEIGHT < y < row_top:
return FeatureUiAction("details") if state.subtitle else None
if state.editor:
bx, by, width, height = value_done_rect(state)
if bx <= x <= bx + width and by <= y <= by + height:
return FeatureUiAction("back")
if not state.editor and y >= feature_footer_top(state):
direction = feature_footer_target(x, y, state)
return FeatureUiAction("scroll", direction=direction) if direction is not None else None
if not row_top <= y < row_top + FEATURE_VISIBLE_ROWS * FEATURE_ROW_HEIGHT:
return None
if len(state.rows) == 1 and (controls := value_buttons(state, state.rows[0], row_top)):
row = state.rows[0]
for button, (value, (bx, by, width, height), enabled) in enumerate(controls):
if bx <= x <= bx + width and by <= y <= by + height:
if not enabled:
return None
return FeatureUiAction("change", row, -1 if value == "-" else 1) if value in ("-", "+") else FeatureUiAction("action", row, button)
return FeatureUiAction("details", row) if controls[0][1][1] <= y <= controls[0][1][1] + controls[0][1][3] else None
visible = int((y - row_top) // FEATURE_ROW_HEIGHT)
index = state.scroll + visible
if 0 <= index < len(state.rows):
row = state.rows[index]
if row.actions:
for action, (bx, by, width, height) in enumerate(feature_action_rects(row, row_top + visible * FEATURE_ROW_HEIGHT)):
if bx <= x <= bx + width and by <= y <= by + height:
return FeatureUiAction("action", row, action) if row.available and row.actions[action][1] else None
return FeatureUiAction("details", row) if x < feature_action_left(row) else None
if row.page and row.choices == ("Off", "On"):
if boolean_value(row) is None or x < FEATURE_CONTROL_LEFT - 24:
return FeatureUiAction("open", row)
local_y = y - row_top - visible * FEATURE_ROW_HEIGHT
if row.available and FEATURE_CONTROL_LEFT <= x <= FEATURE_CONTROL_RIGHT and 18 <= local_y <= FEATURE_ROW_HEIGHT - 18:
return FeatureUiAction("change", row, -1 if x < 1930 else 1)
return None
if row.page and row.available:
return FeatureUiAction("open", row)
if FEATURE_CONTROL_LEFT <= x <= FEATURE_CONTROL_RIGHT:
if not row.available:
return None
local_y = y - row_top - visible * FEATURE_ROW_HEIGHT
if row.key in FEATURE_CONFIRM_ACTIONS or row.key.startswith("pip:format:") or is_long_confirm_action(row.key):
return FeatureUiAction("reset", row) if FEATURE_ACTION_MARGIN <= local_y <= FEATURE_ROW_HEIGHT - FEATURE_ACTION_MARGIN else None
if row.repair_value:
return FeatureUiAction("change", row) if FEATURE_ACTION_MARGIN <= local_y <= FEATURE_ROW_HEIGHT - FEATURE_ACTION_MARGIN else None
if boolean_value(row) is not None:
return FeatureUiAction("change", row, -1 if x < 1930 else 1) if 18 <= local_y <= FEATURE_ROW_HEIGHT - 18 else None
if row.key and FEATURE_BUTTON_TOP <= local_y <= FEATURE_BUTTON_TOP + FEATURE_BUTTON_HEIGHT:
if 1760 <= x <= 1915 or 1940 <= x <= 2095:
return FeatureUiAction("change", row, -1 if x < 1930 else 1)
if row.key or row.value or row.reason:
return FeatureUiAction("details", row)
return None
def press(self, x: float, y: float, state: FeatureSettingsState) -> None:
self.cancel()
target = self.target(x, y, state)
self.held = ((x, y, target, state.scroll, state.page, state.sidebar_expanded)
if target is not None or self._in_body(x, y, state, include_sidebar=True) else None)
def cancel_tap(self) -> None:
"""Suppress the held control action while preserving the pagination gesture."""
if self.held is not None:
x, y, _, scroll, page, sidebar = self.held
self.held = (x, y, None, scroll, page, sidebar)
def move(self, x: float, y: float, state: FeatureSettingsState, now: float | None = None) -> None:
if self.held is not None:
px, py, action, scroll, page, sidebar = self.held
if state.scroll != scroll or state.page != page or state.sidebar_expanded != sidebar:
self.cancel()
return
dx, dy = abs(x - px), abs(y - py)
moved = max(dx, dy) > FEATURE_TAP_SLOP
body = self._in_body(px, py, state, include_sidebar=True)
# Keep horizontal tracking across the sidebar boundary.
if body and (not self._in_body(px, y, state, include_sidebar=True) or (moved and dy > dx)):
self.cancel()
return
if action is not None and (moved or self.target(x, y, state) != action):
self.held = (px, py, None, scroll, page, sidebar) if body else None
signed_dx = x - px
can_drag = body and (self._in_body(px, py, state) or self._in_body(x, y, state))
self._drag_x = signed_dx if can_drag and dx > FEATURE_TAP_SLOP and dx >= 2 * dy else 0.0
if body and now is not None:
if self._samples and now <= self._samples[-1][0]:
self._samples.clear()
self._samples.append((now, x))
while now - self._samples[0][0] > FEATURE_FLICK_WINDOW:
self._samples.popleft()
def _is_flick(self, px: float, dx: float, now: float | None) -> bool:
samples = self._samples
if (now is None or len(samples) < 3 or not 0 <= now - samples[-1][0] <= FEATURE_FLICK_PAUSE or
samples[-1][0] - samples[0][0] < 0.01):
return False
travel = samples[-1][1] - px
reversal = samples[-1][1] - min(x for _, x in samples) if dx < 0 else max(x for _, x in samples) - samples[-1][1]
if abs(travel) < FEATURE_FLICK_DISTANCE or travel * dx <= 0 or reversal > FEATURE_TAP_SLOP:
return False
# Fit recent movement to reduce polling jitter; release positions are never sampled.
mean_t = sum(t for t, _ in samples) / len(samples)
mean_x = sum(x for _, x in samples) / len(samples)
velocity = sum((t - mean_t) * (x - mean_x) for t, x in samples) / sum((t - mean_t) ** 2 for t, _ in samples)
return abs(velocity) >= FEATURE_FLICK_VELOCITY and velocity * dx > 0
def release(self, x: float, y: float, state: FeatureSettingsState, now: float | None = None) -> None:
self.move(x, y, state)
held = self.held
action = None
if self.held is not None:
px, py, action, *_ = self.held
dx, dy = x - px, abs(y - py)
direction = 1 if dx < 0 else -1
swipe = abs(dx) >= FEATURE_SWIPE_DISTANCE or (abs(dx) >= FEATURE_FLICK_DISTANCE and self._is_flick(px, dx, now))
if (self._in_body(px, py, state, include_sidebar=True) and
(self._in_body(px, py, state) or self._in_body(x, y, state)) and swipe and abs(dx) >= 2 * dy and
feature_scroll(state.scroll, direction, len(state.rows)) != state.scroll):
action = FeatureUiAction("scroll", direction=direction)
self.cancel()
if action is not None:
self._released = held
self.emit(action)
def cancel(self) -> None:
self.held = None
self._released = None
self._samples.clear()
self._drag_x = 0.0
def row_request(row: FeatureRow, value: str) -> FeatureSettingsRequest:
return FeatureSettingsRequest(row.key, row.source, value, related_source=row.related_source,
vehicle_fingerprint=row.vehicle_fingerprint, capability=row.capability,
dependencies=row.dependencies, display_unit=row.display_unit)
def value_request(state: FeatureSettingsState, action: FeatureUiAction) -> FeatureSettingsRequest | None:
row = action.row
if row is None or row not in state.rows or not row.available:
return None
buttons = value_buttons(state, row)
if action.kind == "action":
if not 0 <= action.direction < len(buttons):
return None
value, _, enabled = buttons[action.direction]
return row_request(row, value) if enabled and value not in ("-", "+") else None
if action.kind == "change":
if buttons and (action.direction not in (-1, 1) or not any(
value == ("-" if action.direction == -1 else "+") and enabled for value, _, enabled in buttons)):
return None
return row_change(row, action.direction)
return None
def row_change(row: FeatureRow, direction: int = 1) -> FeatureSettingsRequest | None:
"""Bind a press to the displayed source, never a later refreshed value."""
if not row.available:
return None
if row.key in ("Offset1", "Offset2", "Offset3", "Offset4", "Offset5", "Offset6", "Offset7"):
if direction not in (-1, 1):
return None
return FeatureSettingsRequest(row.key, row.source, "", related_source=row.related_source,
vehicle_fingerprint=row.vehicle_fingerprint, capability=row.capability,
dependencies=row.dependencies, display_unit=row.display_unit, direction=direction)
if row.repair_value:
value = row.repair_value
elif row.choices and not (row.choices == ("Auto",) and row.step and row.value != "Auto"):
if row.key == "SLCFallback":
value = "Off" if row.value == "On" else "On"
else:
if row.value not in row.choices:
return None
value = row.choices[(row.choices.index(row.value) + direction) % len(row.choices)]
elif row.step:
try:
current = float(row.value)
except ValueError:
return None
precision = 8 if row.key.startswith("torque:") else 4
value = str(round(max(row.minimum, min(row.maximum, current + row.step * direction)), precision))
if float(value) == current:
return None
else:
return None
return row_request(row, value)
def row_default(row: FeatureRow, *, allow_inactive: bool = False) -> FeatureSettingsRequest | None:
if (not row.available and not (allow_inactive and row.default_reset_available)) or not row.key or row.page or row.default_value is None:
return None
if not row.default_key and row.default_value not in row.choices:
if row.step:
try:
number = float(row.default_value)
except ValueError:
return None
if not math.isfinite(number) or not row.minimum <= number <= row.maximum:
return None
elif row.choices:
return None
return FeatureSettingsRequest(row.default_key or row.key, row.source, row.default_value, confirmation=True,
related_source=row.related_source, vehicle_fingerprint=row.vehicle_fingerprint,
capability=row.capability, dependencies=row.dependencies, display_unit=row.display_unit)
+159
View File
@@ -0,0 +1,159 @@
"""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()