Files
turnstone/examples/door-game/understone/engine/textwidth.py
Patrick Buckley b76b2a98d0 feat(examples): Understone v0.6 — UTF-8 graphics and the width discipline
The look of the next age — the modern equivalent of the ASCII->CP437 leap.
Full Unicode is available now, but the whole stack (text frames, golden
tests, the Watch's 1ch grid) assumes one glyph = one column, so the
enabling piece is a WIDTH RULE, not the glyphs themselves.

- textwidth.is_grid_safe: one code point, printable, East-Asian width not
  Wide/Fullwidth, no combining/format/control category. This is the
  one-glyph-one-column contract. Ambiguous-width glyphs are ACCEPTED on
  purpose — they ARE CP437 (the wall, the club-tree, the up-arrow forest)
  and render single-column on the Western-monospace metrics every surface
  uses; only genuinely double-width runes are barred. The loader enforces
  it on every map glyph; the player-name/free-text sanitizer enforces the
  same rule (the narrow ledger), so a wide name can't shear a frame.
- Re-skin: water ~ -> ≋, inn -> ⌂, healer -> ✚, dungeon mouth -> ∩, and
  the other adventurer -> ☻ (CP437's own player glyph). The colour field
  the renderer has carried unused since v0.1 now has a second consumer.
- Texture variants: grass and water vary by a deterministic per-coordinate
  hash, rendered identically in the Python frame builder and the Watch's
  JS. The two are kept in lockstep by shared hash constants + an agreement
  test that replays the JS arithmetic and asserts it equals the Python
  output for every variant over a grid — not a comment-coupled copy.
- Watch glow-up: a Noto Sans Mono font stack and a UTC-hour day/night tint
  (the Vale darkens at dusk on the lobby TV).
- The curated SAFE_PALETTE is enforced author-usable: a test asserts no
  palette glyph collides with the reserved player markers, so AUTHORING's
  generated appendix can't advertise a glyph the loader would reject.
- Resume is identity-preserving: an existing character resumes by exact
  stored name without re-validating the width rule (which governs creation
  only) — resume must never lock anyone out.

Tests 231 -> 283; width edges (CJK/emoji/combining/fullwidth), the
Python<->JS lockstep, the palette/reserved guard, and resume-vs-create all
pinned and revert-verified.
2026-06-13 04:40:40 -07:00

100 lines
3.5 KiB
Python

"""The one-glyph-one-column contract for everything drawn on the grid.
Every surface Understone paints — the bordered text frames, the golden frames
the screen tests pin, and the Watch's CSS ``1ch``-per-cell map — assumes each
map glyph occupies *exactly one* terminal column. A glyph that renders two
columns (a CJK ideograph, an emoji) shoves the row right and tears the
box-drawing border; a zero-width combining mark stacks onto its neighbour and
desynchronises the column count the other way. :func:`is_grid_safe` is the
single predicate that admits a character to the grid, and :data:`SAFE_PALETTE`
is the curated set of glyphs known to satisfy it with period CP437 flavour.
THE WESTERN-MONOSPACE ASSUMPTION. Width here is judged for the Western
monospace metrics every Understone surface actually uses — the pinned Watch
font stack and the monospace of a chat client's code block. Under those
metrics the East-Asian-Width *Ambiguous* class renders single-column, and
Ambiguous is the CP437 heartland: ``█ ♣ ↑ ∩ ≈ ★`` are all EAW=A. So the rule
bars only the genuinely double-width classes — Wide (``W``) and Fullwidth
(``F``) — and admits Ambiguous, Narrow, Neutral, and Halfwidth. The trade is
deliberate: on a CJK-width terminal an Ambiguous glyph would take two columns,
but Understone's surfaces are not those terminals.
"""
from __future__ import annotations
import unicodedata
# East-Asian-Width classes that render two columns under Western monospace and
# would therefore tear a frame; everything else (Na/N/H/A) renders one column.
_DOUBLE_WIDTH_EAW = frozenset({"W", "F"})
# Unicode general categories that carry no column of their own — combining
# marks (Mn/Mc/Me) stack onto a neighbour, format/control codes (Cf/Cc) are
# invisible — so a single such code point is not a paintable cell.
_ZERO_WIDTH_CATEGORIES = frozenset({"Mn", "Mc", "Me", "Cf", "Cc"})
def is_grid_safe(ch: str) -> bool:
"""Return whether *ch* may occupy a single grid cell.
A grid-safe character is exactly one code point, is printable, is not an
East-Asian Wide or Fullwidth glyph (the only classes that render two
columns under the Western monospace metrics our surfaces use — see the
module docstring), and is not a combining mark or format/control code (a
zero-width code point that would desynchronise the column count).
"""
if len(ch) != 1:
return False
if not ch.isprintable():
return False
if unicodedata.east_asian_width(ch) in _DOUBLE_WIDTH_EAW:
return False
return unicodedata.category(ch) not in _ZERO_WIDTH_CATEGORIES
# A curated set of single-column glyphs with BBS / CP437 character, grouped by
# the role an author is likely to want them for. Every entry is grid-safe AND
# free of the loader's reserved markers (two tests assert both), so a pack
# author can pull any of these for terrain, structures, or actors without
# risking a torn frame or colliding with the '@'/'☻' player markers. The black
# smiling face (☻) is the other-player marker and so is NOT here; its white
# twin (☺) is a free being glyph. The grouping is documentation; the set is
# what callers iterate.
SAFE_PALETTE: tuple[str, ...] = (
# terrain
"≋",
"≈",
"░",
"▒",
"▓",
"♣",
"↑",
"▲",
".",
",",
"'",
'"',
"=",
"~",
"§",
"ø",
"¤",
"Ω",
# structures
"⌂",
"✚",
"∩",
"†",
"‡",
"$",
"◊",
"☖",
# beings
"☺",
"¶",
# misc
"•",
"⁂",
"★",
)