Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/device.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ always apply. The device commands are in [TESTING.md](../TESTING.md).

| Item | Value |
|---|---|
| OS | SpruceOS ≥ 4.5.0. Needs PyUI views, `spruce/scripts/appEnv.sh` and RAOfflineProxy 2.x. |
| OS | The SpruceOS it ships in: Cheevos is bundled with Spruce, so the bridge needs only the PyUI it was verified against (`pyui-tested-commit`). Needs PyUI views, `spruce/scripts/appEnv.sh` and RAOfflineProxy 2.x. |
| Devices | Every device supported by SpruceOS. |
| Resolutions | Every resolution the default SPRUCE theme ships: 640×480, 720×480, 752×560, 720×720, 960×720, 1024×768, 1280×720, 480×800. |
| Runtime | Spruce's CPython **3.10.18**: `$SPRUCE_PYTHON` (32-bit: `spruce/bin/python/bin/python3.10`; 64-bit: `spruce/flip/bin/python3.10`). |
Expand Down
8 changes: 5 additions & 3 deletions .agents/product.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ Settings.
- The selected badge is drawn last, about 10% larger, over its neighbours, inside a white
focus ring with a black edge (a hardcore badge keeps its gold outline inside the ring).
- Under the grid, two lines for the selection: title | points, then description | unlock mode
(Hardcore, Casual, Pending sync or Locked) and type tag. Each part is shortened with "…"
(Hardcore, Casual, Pending sync or Locked) and type tag. Each part is shortened with "..."
when needed, right-hand parts first; the card (A) has the full text. The description follows
the card's spoiler setting ("Description hidden").
- A thin scrollbar on the right when the set is longer than a screen; PyUI's `selected/total`
Expand Down Expand Up @@ -161,7 +161,7 @@ The badges of RA's profile galleries (RAWeb `site-award.php`; how they are group
An event's image is the tier the player chose to show (`display_award_tier`); its text is the
tier actually earned. Before an event's details are fetched (offline first sync, or the v2
API refused), it shows RA's title and image with "Event award": nothing is invented.
Descriptions are shortened with "…" before the date is (`MenuItem.tail`).
Descriptions are shortened with "..." before the date is (`MenuItem.tail`).
- **Views** (Select cycles carousel → grid → list, and the bottom bar names the next one; also
"View" in the Y/Menu popup). All three show the same awards in the same order.
- **Carousel** (default): PyUI's animated carousel, the selection larger and centred, smaller
Expand Down Expand Up @@ -295,7 +295,9 @@ so the bridge shows "[START] Done [B] Delete [L1] Shift [R1] Caps" in the bottom
- **Text hygiene**:
- Emoji and pictographs (e.g. in rich presence) are stripped, because theme fonts can't draw
them. Accented letters a font lacks become the plain letter.
- Top-bar titles are truncated with "…" so they clear the clock and battery icons.
- Top-bar titles are truncated with "..." so they clear the clock and battery icons.
- A list title that doesn't fit ends with "..." before the value on its right; the selected
one scrolls after a second so it can be read (PyUI's behaviour, as in Spruce's own lists).

## Offline and error behaviour

Expand Down
32 changes: 17 additions & 15 deletions .agents/pyui.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,19 +180,19 @@ PyUI's views, without patching PyUI.
row's average background colour covers only the strip of bar inside the ring, ending at
the circle centre; the rim hides the strip's left edge. The top bar uses these same PNGs
at a smaller size, including mastery glow, with no bar to mask inside hollow rings.
- **Long text:** PyUI does not truncate list titles against `value_text`, or descriptions at
the screen edge. The bridge fits both (`ui/pyui/text.py`). A `MenuItem.tail` (an award's
date) stays whole after the description: `fit_with_tail` shortens the description first.
- **Long text:** since [SpruceOS #1900](https://github.com/spruceUI/spruceOS/pull/1900), PyUI
fits list titles to their room in pixels: a title that fits is left alone, a longer one ends
with "..." before the right-aligned value, and the selected one scrolls after a second.
The bridge passes titles whole (after `displayable()`). A selected title that scrolls keeps
the screen redrawing (about 66% of a core on a Mini+, against 7% idle) until the next press
or the screensaver. That's Spruce's own behaviour, accepted on 2026-10-22 so long titles can
be read. PyUI still doesn't fit descriptions: unselected ones run off the right edge, and
selected ones scroll past 70 characters. The bridge fits them (`text.fit_with_tail`), and a
`MenuItem.tail` (an award's date) stays whole: the description is shortened first.
`displayable()` strips a leading space, so the separator before the tail is added after it.
The room (`text.list_room`) takes the icon column from the selected-row background's width,
as PyUI does, not from the screen's. Only SPRUCE scales that background to the screen: the
The width (`text.description_room`) takes the icon column from the selected-row
background's width, as PyUI does. Only SPRUCE scales that background to the screen: the
other themes have a 640 px one at every resolution, and SPRUCE's is 640 px at 480×800.
Until 2026-10-21 the room used `screen // 8`. Titles came out 20 px too wide at 480×800 and
were cut earlier than needed on wide screens in other themes.
Since SpruceOS `4355fe1b`, PyUI also cuts every list title to 35 characters (46 at 1280×720,
`Theme.scale_with_width_multiplier(35)`) and scrolls the selected one after a second. A
fitted title longer than that loses its end without an ellipsis ("Achievement of the Week
2026 - Silv" at 640×480), and the screen doesn't go idle while it scrolls.

## Drawing gotchas
- **Idle frames:** since [SpruceOS #1890](https://github.com/spruceUI/spruceOS/pull/1890),
Expand All @@ -216,10 +216,12 @@ PyUI's views, without patching PyUI.
Error received on loading <text>"), which do happen when memory runs low. Don't put secrets
on screen without muting its log (`ask_text(..., secret=True)`).
- **Measurement cost:** measuring text with SDL_ttf for every row is very slow on a Mini
(1,000 rows took more than 15 s). Use cached glyph advances (`text.text_width`): one dict per
font, summed with `sum(map(...))`. A cached call per character (`lru_cache`, an Enum's
`.value`) cost several times more. `displayable()` returns plain ASCII at once and looks up
any other character once per font: every description has a "·".
(1,000 rows took more than 15 s; one `TTF_SizeUTF8` call is about 105 µs). Use PyUI's cached
glyph advances (`Display.get_text_width_from_glyphs` and `count_chars_that_fit`, through
`text.text_width` and `fit_text`): about 30 µs a title. They leave out kerning, so they come
out 0–4 px wider than SDL_ttf's measurement, never narrower. `displayable()` returns plain
ASCII at once and looks up any other character once per font, through PyUI's
`Display.requires_fallback_font`: every description has a "·".
- **Row height:** PyUI's own `_calculate_line_height` sizes every row's title and description
with SDL_ttf to find the tallest (0.66 s for 2,940 rows). `views._one_sample_row` has it
measure one sample row instead, holding every character the rows use; our rows share one
Expand Down
Binary file modified docs/images/achievement.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/awards-grid.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/awards-list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/awards.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/game-from-spruce.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/game-grid.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/game.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/games-1280x720.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/games-details.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/games.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/home.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/profile-more.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/profile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/recent.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/syncing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/theme-minimal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/theme-pico-8.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ build-backend = "uv_build"

[tool.cheevos]
# SpruceOS commit the PyUI bridge and the desktop runner were last verified against.
pyui-tested-commit = "339d692124177ffc11b426060424851eaabbe2c1"
pyui-tested-commit = "cb6210aa19c5cb61e404d4029444972448cac9f9"
# SpruceOS commit whose RAOfflineProxy bundle ROM identification was last verified against
# (RAOfflineProxy 2.2.0-alpha1 nightly 54). `make proxy-check` checks it; TESTING.md has the steps.
proxy-tested-commit = "f037b64bdfa4a6fd0f06e4221b97f1cf4751b933"
Expand Down
188 changes: 34 additions & 154 deletions src/cheevos/ui/pyui/text.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
"""Text handling for the PyUI bridge: glyph coverage, fast width estimates, fitting.
"""Text handling for the PyUI bridge: glyph coverage and fitting.

Theme fonts vary a lot (the Pico-8 theme's pixel font lacks "·" and "…"), and measuring every
row with SDL_ttf is too slow on a Miyoo Mini for big lists, so widths come from cached glyph
advances.
Theme fonts vary a lot (the Pico-8 theme's pixel font lacks "·" and "…"). Widths come from
PyUI's per-font glyph advances (``Display.get_text_width_from_glyphs``, SpruceOS #1900): measuring
every row with SDL_ttf is too slow on a Miyoo Mini for big lists.
"""

from __future__ import annotations

import ctypes
import functools
import logging
import re
Expand All @@ -31,9 +30,7 @@
"\u201c": '"',
"\u201d": '"',
}
_BMP_MAX = 0xFFFF # Legacy SDL_ttf glyph APIs only take 16-bit code points.
# Width estimates ignore kerning; keep a little slack so fitted text never touches neighbours.
_FIT_MARGIN = 0.97
_ELLIPSIS = "..." # as PyUI ends the list titles it shortens
# Share of the screen width the top-bar title may use if PyUI's top bar can't be measured.
_TITLE_WIDTH_SHARE = 0.44
_TOP_BAR_PAD = 10 # PyUI's spacing in the top bar (TopBar.render_top_bar_menu_not_skipped)
Expand Down Expand Up @@ -65,29 +62,21 @@ def font_purpose(role: Text) -> object:
return FontPurpose[role.value]


@functools.lru_cache(maxsize=256)
def _has_glyph(purpose: str, char: str) -> bool:
"""Whether the active theme's font for ``purpose`` can draw ``char``.
def _has_glyph(role: Text, char: str) -> bool:
"""Whether the active theme's font for ``role`` can draw ``char``.

Args:
purpose: ``FontPurpose`` member name.
role: Text role.
char: One character.

Returns:
``True`` if the font has a glyph (or the check is unavailable).
``True`` if the font has a glyph (or no fonts are loaded yet).
"""
from display.display import Display
from display.font_purpose import FontPurpose
from sdl2 import sdlttf

fonts = getattr(Display, "fonts", None)
loaded = fonts.get(FontPurpose[purpose]) if fonts else None
if loaded is None:
if not getattr(Display, "fonts", None):
return True
try:
return bool(sdlttf.TTF_GlyphIsProvided32(loaded.font, ord(char)))
except (AttributeError, RuntimeError): # PySDL2 uses RuntimeError stubs on SDL_ttf < 2.0.18.
return ord(char) > _BMP_MAX or bool(sdlttf.TTF_GlyphIsProvided(loaded.font, ord(char)))
return not Display.requires_fallback_font(char, font_purpose(role))


def displayable(value: str, role: Text = Text.BODY) -> str:
Expand Down Expand Up @@ -115,7 +104,7 @@ def displayable(value: str, role: Text = Text.BODY) -> str:
chars = set(text)
for char in chars - glyphs.checked: # each character is looked up once per font
glyphs.checked.add(char)
if not char.isascii() and not _has_glyph(role.value, char):
if not char.isascii() and not _has_glyph(role, char):
glyphs.swaps[ord(char)] = _ASCII_FALLBACKS.get(char) or _plain(char)
glyphs.missing.add(char)
if chars.isdisjoint(glyphs.missing):
Expand Down Expand Up @@ -156,77 +145,10 @@ def _plain(char: str) -> str:
return base or char


class _Advances(dict[str, int]):
"""How far each character advances the pen in one theme font, measured on first use.

Measuring long lists costs a dictionary lookup per character instead of an SDL_ttf call
per string (on the Mini, per-string measurement of 1,000 rows took longer than 15 s).

Args:
purpose: ``FontPurpose`` member name.
"""

def __init__(self, purpose: str) -> None:
super().__init__()
self._purpose = purpose

def __missing__(self, char: str) -> int:
"""Measure a character the first time it is looked up."""
width = self[char] = _advance(self._purpose, char)
return width


_advances: dict[Text, _Advances] = {}


def _font_advances(role: Text) -> _Advances:
"""Return the advance table of the theme font for ``role``.

Args:
role: Text role.

Returns:
Character to advance width.
"""
found = _advances.get(role)
if found is None:
found = _advances[role] = _Advances(role.value)
return found


def _advance(purpose: str, char: str) -> int:
"""Measure how far one character advances the pen in the theme font for ``purpose``.

Args:
purpose: ``FontPurpose`` member name.
char: One character.

Returns:
Advance width in pixels.
"""
from display.display import Display
from display.font_purpose import FontPurpose
from sdl2 import sdlttf

font = Display.fonts[FontPurpose[purpose]].font
codepoint = ord(char)
metrics = [ctypes.c_int() for _ in range(5)]
pointers = [ctypes.byref(m) for m in metrics]
try:
failed = sdlttf.TTF_GlyphMetrics32(font, codepoint, *pointers)
except (AttributeError, RuntimeError): # PySDL2 uses RuntimeError stubs on SDL_ttf < 2.0.18.
failed = (
sdlttf.TTF_GlyphMetrics(font, codepoint, *pointers) if codepoint <= _BMP_MAX else True
)
if not failed:
return metrics[4].value
return int(Display.get_text_dimensions(FontPurpose[purpose], char)[0])


def text_width(value: str, role: Text) -> int:
"""Estimate the rendered width of ``value`` from cached glyph advances.
"""Estimate the rendered width of ``value`` from PyUI's cached glyph advances.

Ignores kerning, which is within a few pixels for UI text; callers keep a small margin.
Kerning is left out, so the estimate comes out a few pixels wider than SDL_ttf's.

Args:
value: Text (already passed through :func:`displayable`).
Expand All @@ -235,20 +157,9 @@ def text_width(value: str, role: Text) -> int:
Returns:
Width in pixels.
"""
return sum(map(_font_advances(role).__getitem__, value))


@functools.lru_cache(maxsize=8)
def _ellipsis(role: Text) -> str:
"""Return "…" if the font for ``role`` has it, else "...".

Args:
role: Text role.
from display.display import Display

Returns:
The ellipsis to append when truncating.
"""
return displayable("…", role)
return int(Display.get_text_width_from_glyphs(font_purpose(role), value))


def fit_title(value: str) -> str:
Expand Down Expand Up @@ -338,18 +249,15 @@ def fit_text(value: str, role: Text, max_width: int) -> str:
Returns:
The text, truncated if needed.
"""
from display.display import Display

value = displayable(value, role)
budget = int(max_width * _FIT_MARGIN)
advances = _font_advances(role)
if sum(map(advances.__getitem__, value)) <= budget:
if text_width(value, role) <= max_width:
return value
ellipsis = _ellipsis(role)
remaining = budget - sum(map(advances.__getitem__, ellipsis))
for index, char in enumerate(value):
remaining -= advances[char]
if remaining < 0:
return value[:index].rstrip() + ellipsis
return value
room = max_width - text_width(_ELLIPSIS, role)
return (
value[: Display.count_chars_that_fit(font_purpose(role), value, room)].rstrip() + _ELLIPSIS
)


def fit_with_tail(value: str, tail: str, role: Text, max_width: int) -> str:
Expand All @@ -367,53 +275,26 @@ def fit_with_tail(value: str, tail: str, role: Text, max_width: int) -> str:
if not tail or not value:
return fit_text(value or tail, role, max_width)
joined = displayable(f"{value} · {tail}", role)
if text_width(joined, role) <= int(max_width * _FIT_MARGIN):
if text_width(joined, role) <= max_width:
return joined
end = " " + displayable(f"· {tail}", role) # displayable() strips a leading space
room = max_width - text_width(end, role)
if room < text_width(_ellipsis(role), role) * 4:
if room < text_width(_ELLIPSIS, role) * 4:
return fit_text(joined, role, max_width)
return fit_text(value, role, room) + end


@dataclass(frozen=True, slots=True)
class ListRoom:
"""Width a list row's text has, from the theme's layout (see :func:`list_room`).

Attributes:
full: Width without a right-aligned value (descriptions, titles without a value).
gap: Space between the title and the value.
least: Width a title keeps however wide the value is.
"""

full: int
gap: int
least: int

def title(self, value_text: str) -> int:
"""Return the width left for a title next to a right-aligned value.

Args:
value_text: The row's right-aligned value (may be empty).

Returns:
Maximum title width in pixels.
"""
if not value_text:
return self.full
return max(self.full - text_width(value_text, Text.TITLE) - self.gap, self.least)
def description_room() -> int:
"""Measure the width a list row's description has; measure once per list, not per row.


def list_room() -> ListRoom:
"""Measure the room for text in a list row; measure once per list, not once per row.

Mirrors PyUI's descriptive list layout: the icon column is 12.5% of the selected-row
background's width, the title starts a theme-defined offset after it, and the value is
right-aligned with that offset. The background isn't always as wide as the screen: most
themes have a 640 px one at every resolution, and SPRUCE's is 640 px at 480x800.
PyUI fits list titles itself, but not descriptions. Mirrors its descriptive list layout:
the icon column is 12.5% of the selected-row background's width, and the text starts a
theme-defined offset after it and ends that offset before the screen edge. The background
isn't always as wide as the screen: most themes have a 640 px one at every resolution, and
SPRUCE's is 640 px at 480x800.

Returns:
The room.
Width in pixels.
"""
from devices.device import Device
from display.display import Display
Expand All @@ -423,5 +304,4 @@ def list_room() -> ListRoom:
row = int(Display.get_image_dimensions(Theme.get_list_large_selected_bg())[0]) or screen
gap = int(Theme.get_descriptive_list_text_from_icon_offset())
used = int(Theme.get_descriptive_list_icon_offset_x()) + int(row * _ICON_SHARE) + gap * 2
least = screen // 4
return ListRoom(max(screen - used, least), gap, least)
return max(screen - used, screen // 4)
Loading
Loading