diff --git a/.agents/device.md b/.agents/device.md index 03135cf..17ab04a 100644 --- a/.agents/device.md +++ b/.agents/device.md @@ -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`). | diff --git a/.agents/product.md b/.agents/product.md index 4da4e56..123614f 100644 --- a/.agents/product.md +++ b/.agents/product.md @@ -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` @@ -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 @@ -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 diff --git a/.agents/pyui.md b/.agents/pyui.md index a7d1e29..36ec17f 100644 --- a/.agents/pyui.md +++ b/.agents/pyui.md @@ -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), @@ -216,10 +216,12 @@ PyUI's views, without patching PyUI. Error received on loading "), 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 diff --git a/docs/images/achievement.png b/docs/images/achievement.png index 03c7767..e66af91 100644 Binary files a/docs/images/achievement.png and b/docs/images/achievement.png differ diff --git a/docs/images/awards-grid.png b/docs/images/awards-grid.png index e5a69a6..b157591 100644 Binary files a/docs/images/awards-grid.png and b/docs/images/awards-grid.png differ diff --git a/docs/images/awards-list.png b/docs/images/awards-list.png index 1ac7156..cf52305 100644 Binary files a/docs/images/awards-list.png and b/docs/images/awards-list.png differ diff --git a/docs/images/awards.png b/docs/images/awards.png index 8510cb6..7ca35b0 100644 Binary files a/docs/images/awards.png and b/docs/images/awards.png differ diff --git a/docs/images/game-from-spruce.png b/docs/images/game-from-spruce.png index 1c81c15..868b75f 100644 Binary files a/docs/images/game-from-spruce.png and b/docs/images/game-from-spruce.png differ diff --git a/docs/images/game-grid.png b/docs/images/game-grid.png index 4ee0bc0..ed08354 100644 Binary files a/docs/images/game-grid.png and b/docs/images/game-grid.png differ diff --git a/docs/images/game.png b/docs/images/game.png index 755af52..cdffed2 100644 Binary files a/docs/images/game.png and b/docs/images/game.png differ diff --git a/docs/images/games-1280x720.png b/docs/images/games-1280x720.png index 0e0a881..e25052f 100644 Binary files a/docs/images/games-1280x720.png and b/docs/images/games-1280x720.png differ diff --git a/docs/images/games-details.png b/docs/images/games-details.png index 7224390..7c06a7c 100644 Binary files a/docs/images/games-details.png and b/docs/images/games-details.png differ diff --git a/docs/images/games.png b/docs/images/games.png index 807fac9..a1f16f1 100644 Binary files a/docs/images/games.png and b/docs/images/games.png differ diff --git a/docs/images/home.png b/docs/images/home.png index f34dde4..43f2fc5 100644 Binary files a/docs/images/home.png and b/docs/images/home.png differ diff --git a/docs/images/profile-more.png b/docs/images/profile-more.png index 9b8e1d1..6bf7be2 100644 Binary files a/docs/images/profile-more.png and b/docs/images/profile-more.png differ diff --git a/docs/images/profile.png b/docs/images/profile.png index eac2bda..ce86d7e 100644 Binary files a/docs/images/profile.png and b/docs/images/profile.png differ diff --git a/docs/images/recent.png b/docs/images/recent.png index 6429654..e9d27bc 100644 Binary files a/docs/images/recent.png and b/docs/images/recent.png differ diff --git a/docs/images/settings.png b/docs/images/settings.png index 9219de3..1fb4dc0 100644 Binary files a/docs/images/settings.png and b/docs/images/settings.png differ diff --git a/docs/images/syncing.png b/docs/images/syncing.png index 119062d..4202d54 100644 Binary files a/docs/images/syncing.png and b/docs/images/syncing.png differ diff --git a/docs/images/theme-minimal.png b/docs/images/theme-minimal.png index bdffefc..93e9660 100644 Binary files a/docs/images/theme-minimal.png and b/docs/images/theme-minimal.png differ diff --git a/docs/images/theme-pico-8.png b/docs/images/theme-pico-8.png index 8e3d03a..bebc4f4 100644 Binary files a/docs/images/theme-pico-8.png and b/docs/images/theme-pico-8.png differ diff --git a/pyproject.toml b/pyproject.toml index 5d49f6f..cdd4bb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/cheevos/ui/pyui/text.py b/src/cheevos/ui/pyui/text.py index bde57d8..7638f91 100644 --- a/src/cheevos/ui/pyui/text.py +++ b/src/cheevos/ui/pyui/text.py @@ -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 @@ -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) @@ -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: @@ -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): @@ -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`). @@ -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: @@ -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: @@ -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 @@ -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) diff --git a/src/cheevos/ui/pyui/views.py b/src/cheevos/ui/pyui/views.py index 337b933..1ffd5fe 100644 --- a/src/cheevos/ui/pyui/views.py +++ b/src/cheevos/ui/pyui/views.py @@ -17,13 +17,12 @@ Button, Text, displayable, - fit_text, fit_with_tail, screen_size, ) from cheevos.ui.pyui.row_bars import Progress from cheevos.ui.pyui.status_bar import Hint -from cheevos.ui.pyui.text import ListRoom, list_room +from cheevos.ui.pyui.text import description_room from cheevos.ui.pyui.title_bar import Title from cheevos.ui.pyui.visible_images import ImageDemand, VisibleImages, demanding @@ -119,13 +118,16 @@ def search(_value: object) -> str | None: return search -def _entry(item: MenuItem, *, room: ListRoom | None) -> Any: # noqa: ANN401 +def _entry(item: MenuItem, *, room: int | None) -> Any: # noqa: ANN401 """Convert one item into a PyUI ``GridOrListEntry``. + PyUI fits list titles to their room itself (SpruceOS #1900), and scrolls the selected one + when it doesn't fit, so titles are passed whole. + Args: item: Row or tile. - room: A list row's room: the title is shortened so it never runs into the - right-aligned value, and the description keeps its tail (lists only). + room: A list row's description width: the description is shortened to it and keeps + its tail (lists only). Returns: The PyUI entry, carrying the item as its value. @@ -135,15 +137,14 @@ def _entry(item: MenuItem, *, room: ListRoom | None) -> Any: # noqa: ANN401 icon = item.icon static = str(icon) if isinstance(icon, Path) else None searcher = None if icon is None or isinstance(icon, Path) else _lazy(icon) + title = displayable(item.title, Text.TITLE) if room is not None: - title = fit_text(item.title, Text.TITLE, room.title(item.value)) description = ( - fit_with_tail(item.description, item.tail, Text.BODY, room.full) + fit_with_tail(item.description, item.tail, Text.BODY, room) if item.description or item.tail else "" ) else: - title = displayable(item.title, Text.TITLE) description = displayable(item.description, Text.BODY) if item.progress is not None: description = "" # keeps the two-line row; the bar is drawn in its place @@ -165,12 +166,12 @@ def _entries(items: Sequence[MenuItem], layout: Layout = Layout.LIST) -> list[An Args: items: Rows or tiles. - layout: Target view; list titles are fitted next to their values. + layout: Target view; list descriptions are fitted to the row. Returns: PyUI entries in the same order. """ - room = list_room() if layout is Layout.LIST else None + room = description_room() if layout is Layout.LIST else None return [_entry(item, room=room) for item in items] diff --git a/tests/unit/test_ui_text.py b/tests/unit/test_ui_text.py index c14f80d..3f3002c 100644 --- a/tests/unit/test_ui_text.py +++ b/tests/unit/test_ui_text.py @@ -4,36 +4,40 @@ import pytest from cheevos.ui.pyui import text -from cheevos.ui.pyui.text import ( - ListRoom, - Text, - displayable, - fit_text, - fit_with_tail, - text_width, -) +from cheevos.ui.pyui.text import Text, displayable, fit_text, fit_with_tail, text_width LACKING = {"·", "…", "é"} # a pixel font like Pico-8's -@pytest.fixture(autouse=True) -def fake_font(monkeypatch): - """Every character is 10 px wide (a "W" 20 px); the font lacks the characters in LACKING.""" - measured = [] +def advance(char): + return 20 if char == "W" else 10 + + +def count_chars_that_fit(_purpose, value, max_width): + used = 0 + for index, char in enumerate(value): + used += advance(char) + if used > max_width: + return index + return len(value) - def advance(_purpose, char): - measured.append(char) - return 20 if char == "W" else 10 - monkeypatch.setattr(text, "_advance", advance) - monkeypatch.setattr(text, "_has_glyph", lambda _purpose, char: char not in LACKING) - text._advances.clear() +@pytest.fixture(autouse=True) +def fake_font(monkeypatch): + """PyUI's Display with a font where every character is 10 px wide (a "W" 20 px), lacking + the characters in LACKING.""" + display = SimpleNamespace( + fonts={"loaded": True}, + get_text_width_from_glyphs=lambda _purpose, value: sum(map(advance, value)), + count_chars_that_fit=count_chars_that_fit, + requires_fallback_font=lambda value, _purpose: any(char in LACKING for char in value), + ) + purposes = SimpleNamespace(FontPurpose={role.value: role.value for role in Text}) + monkeypatch.setitem(sys.modules, "display.display", SimpleNamespace(Display=display)) + monkeypatch.setitem(sys.modules, "display.font_purpose", purposes) text._glyphs.clear() - text._ellipsis.cache_clear() - yield measured - text._advances.clear() + yield display text._glyphs.clear() - text._ellipsis.cache_clear() def test_plain_ascii_is_returned_as_is(): @@ -50,18 +54,24 @@ def test_characters_the_font_lacks_get_stand_ins(): assert displayable("Ñandú", Text.BODY) == "Ñandú" # the font has these -def test_text_width_measures_each_character_once_per_font(fake_font): - assert text_width("WAA", Text.TITLE) == 40 - assert text_width("AWW", Text.TITLE) == 50 - assert sorted(fake_font) == ["A", "W"] - assert text_width("A", Text.BODY) == 10 - assert sorted(fake_font) == ["A", "A", "W"] +def test_characters_are_kept_until_the_theme_fonts_are_loaded(fake_font): + fake_font.fonts = {} + assert displayable("Pokémon · Red…", Text.BODY) == "Pokémon · Red…" + + +def test_widths_come_from_pyui_in_the_roles_font(fake_font, monkeypatch): + asked = [] + monkeypatch.setattr( + fake_font, "get_text_width_from_glyphs", lambda purpose, _value: asked.append(purpose) or 7 + ) + assert text_width("Mario", Text.HEADING) == 7 + assert asked == ["TOP_BAR_TEXT"] def test_fit_text_keeps_text_that_fits_and_shortens_the_rest(): - assert fit_text("Metroid", Text.TITLE, 100) == "Metroid" # 70 px of 97 - # 97 px less "..." (30 px) leaves 67 px: six letters, then the stand-in ellipsis - assert fit_text("Metroid Fusion", Text.TITLE, 100) == "Metroi..." + assert fit_text("Metroid", Text.TITLE, 100) == "Metroid" # 70 px of 100 + # 100 px less "..." (30 px) leaves 70 px: seven letters, then the ellipsis + assert fit_text("Metroid Fusion", Text.TITLE, 100) == "Metroid..." def test_fit_with_tail_keeps_the_date_whole(): @@ -72,12 +82,12 @@ def test_fit_with_tail_keeps_the_date_whole(): assert fitted.endswith("... - 1 May") assert text_width(fitted, Text.BODY) <= 200 # No room left for a description: everything is shortened together. - assert fit_with_tail("Awarded", "27 April 2026", Text.BODY, 120) == "Awarded..." + assert fit_with_tail("Awarded", "27 April 2026", Text.BODY, 110) == "Awarded..." assert fit_with_tail("", "1 May", Text.BODY, 200) == "1 May" assert fit_with_tail("Mastered", "", Text.BODY, 200) == "Mastered" -def test_list_room_takes_the_icon_column_from_the_row_background(monkeypatch): +def test_description_room_takes_the_icon_column_from_the_row_background(monkeypatch): # SPRUCE at 480x800: the row background is 640 px wide, so PyUI's icon column is 80 px. device = SimpleNamespace(screen_width=lambda: 480) theme = SimpleNamespace( @@ -93,11 +103,4 @@ def test_list_room_takes_the_icon_column_from_the_row_background(monkeypatch): ) monkeypatch.setitem(sys.modules, "themes.theme", SimpleNamespace(Theme=theme)) monkeypatch.setitem(sys.modules, "display.display", SimpleNamespace(Display=display)) - assert text.list_room() == ListRoom(full=480 - 7 - 80 - 7 * 2, gap=7, least=120) - - -def test_list_room_leaves_the_value_its_width_and_the_title_a_minimum(): - room = ListRoom(full=400, gap=16, least=160) - assert room.title("") == 400 - assert room.title("12/40") == 400 - 50 - 16 - assert room.title("W" * 20) == 160 + assert text.description_room() == 480 - 7 - 80 - 7 * 2 diff --git a/tests/unit/test_ui_ttf.py b/tests/unit/test_ui_ttf.py deleted file mode 100644 index 24af2dd..0000000 --- a/tests/unit/test_ui_ttf.py +++ /dev/null @@ -1,113 +0,0 @@ -import ctypes -import sys -from types import SimpleNamespace - -import pytest - -from cheevos.ui.pyui import text -from cheevos.ui.pyui.text import Text, displayable, fit_text, text_width - - -@pytest.fixture -def ttf(monkeypatch): - calls = [] - font = object() - - def metrics(_font, codepoint, *_metrics): - assert _font is font - calls.append(("metrics", codepoint)) - ctypes.cast(_metrics[-1], ctypes.POINTER(ctypes.c_int)).contents.value = 10 - return 0 - - def provided(_font, codepoint): - assert _font is font - calls.append(("provided", codepoint)) - return 0 if chr(codepoint) in {"é", "…"} else 1 - - def dimensions(_purpose, value): - calls.append(("dimensions", value)) - return (18, 24) - - api = SimpleNamespace( - TTF_GlyphMetrics=metrics, - TTF_GlyphMetrics32=metrics, - TTF_GlyphIsProvided=provided, - TTF_GlyphIsProvided32=provided, - ) - display = SimpleNamespace( - fonts={role.value: SimpleNamespace(font=font) for role in Text}, - get_text_dimensions=dimensions, - ) - for name, module in { - "sdl2": SimpleNamespace(sdlttf=api), - "display.display": SimpleNamespace(Display=display), - "display.font_purpose": SimpleNamespace( - FontPurpose={role.value: role.value for role in Text} - ), - }.items(): - monkeypatch.setitem(sys.modules, name, module) - text._advances.clear() - text._glyphs.clear() - text._has_glyph.cache_clear() - text._ellipsis.cache_clear() - yield SimpleNamespace(api=api, calls=calls, display=display) - text._advances.clear() - text._glyphs.clear() - text._has_glyph.cache_clear() - text._ellipsis.cache_clear() - - -def unavailable(*_args): - raise RuntimeError("requires SDL2_ttf 2.0.18, but the loaded version is 2.0.15") - - -@pytest.mark.parametrize("binding", ["runtime_error", "missing_attribute"]) -def test_older_ttf_fits_setup_title_and_keeps_glyph_advances_cached(ttf, binding): - if binding == "runtime_error": - ttf.api.TTF_GlyphMetrics32 = unavailable - else: - del ttf.api.TTF_GlyphMetrics32 - - assert fit_text("Setup", Text.HEADING, 200) == "Setup" - assert text_width("Setup", Text.HEADING) == 50 - assert ttf.calls == [("metrics", ord(char)) for char in "Setup"] - - -@pytest.mark.parametrize("binding", ["runtime_error", "missing_attribute"]) -def test_older_ttf_checks_glyph_coverage_and_uses_ascii_fallbacks(ttf, binding): - if binding == "runtime_error": - ttf.api.TTF_GlyphIsProvided32 = unavailable - else: - del ttf.api.TTF_GlyphIsProvided32 - - assert displayable("Pokémon… ·", Text.BODY) == "Pokemon... ·" - assert sorted(ttf.calls) == [("provided", ord(char)) for char in "·é…"] - - -def test_older_ttf_never_truncates_supplementary_codepoints_to_16_bits(ttf): - ttf.api.TTF_GlyphMetrics32 = unavailable - ttf.api.TTF_GlyphIsProvided32 = unavailable - char = "\U00020000" # Supplementary CJK character, outside the emoji filter. - assert displayable(char, Text.BODY) == char - assert text_width(char, Text.BODY) == 18 - assert ttf.calls == [("dimensions", char)] - - -def test_modern_ttf_measures_and_checks_supplementary_glyphs(ttf): - char = "\U00020000" - assert displayable(char, Text.BODY) == char - assert text_width(char, Text.BODY) == 10 - assert ttf.calls == [("provided", ord(char)), ("metrics", ord(char))] - - -def test_failed_glyph_metrics_use_text_dimensions(ttf): - ttf.api.TTF_GlyphMetrics32 = lambda *_args: -1 - assert text_width("A", Text.HEADING) == 18 - assert ttf.calls == [("dimensions", "A")] - - -def test_failed_legacy_glyph_metrics_use_text_dimensions(ttf): - ttf.api.TTF_GlyphMetrics32 = unavailable - ttf.api.TTF_GlyphMetrics = lambda *_args: -1 - assert text_width("A", Text.HEADING) == 18 - assert ttf.calls == [("dimensions", "A")]