#!/usr/bin/env python3
# unimatrix.py — a paged, self-labelling text-rendering matrix for a terminal emulator.
#
# WHY IT LOOKS LIKE THIS. It is meant to be photographed, not read by a program: every page fills one
# phone screen, every row is labelled with what it is testing, and anything whose correct rendering is
# non-obvious carries the expected result inline (column counts, "one glyph", NFC-vs-NFD pairs). That
# way a screenshot alone is enough to judge a pass, with no cross-referencing.
#
# The hard cases are deliberately adjacent to their controls: ❤ next to ❤️, NFC café next to NFD café,
# 👍 next to 👍🏽. A renderer that gets one of a pair right and the other wrong is the common failure,
# and it is invisible unless the two sit side by side.
#
#   unimatrix.py            all pages, 2.5s apart (for a capture run)
#   unimatrix.py 4          just page 4
#   unimatrix.py all 0      all pages, no delay
import sys, os, time, unicodedata

CJK_TOP = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'cjk_top.txt')
CLS, DIM, OFF, INV = '\033[H\033[2J', '\033[2m', '\033[0m', '\033[7m'


def cols():
    try:
        return min(os.get_terminal_size().columns, 120)
    except OSError:
        return 48


def head(n, total, title, note):
    w = cols()
    print(CLS + INV + f' {n}/{total}  {title} '.ljust(w)[:w] + OFF)
    print(DIM + note[:w] + OFF)


def row(label, s):
    print(f'{DIM}{label:<11}{OFF}{s}')


def grid(chars, per):
    """Space-separated so a glyph that overflows its cell is visible as a collision."""
    out = []
    for i in range(0, len(chars), per):
        out.append(' '.join(chars[i:i + per]))
    return out


def cjk_top(n):
    try:
        return open(CJK_TOP, encoding='utf-8').read().strip()[:n]
    except OSError:
        return '的一是不了人我在有他这为之大来以个中上们'[:n]


# ── the pages ────────────────────────────────────────────────────────────────
def p_ascii():
    head(1, 9, 'ASCII + Latin', 'the baseline: if these shift, everything after is suspect')
    for a in range(0x20, 0x80, 0x20):
        row(f'U+{a:04X}', ''.join(chr(c) for c in range(a, a + 0x20)))
    print()
    row('latin-1', ''.join(chr(c) for c in range(0xC0, 0xFF)))
    print()
    row('NFC café', 'café   ' + DIM + '4 cells, one é' + OFF)
    row('NFD café', 'café   ' + DIM + '4 cells, e + U+0301 combined into one' + OFF)
    row('stacked', 'á̂̃  ë̄  o̊́  ' + DIM + '1 cell each' + OFF)
    row('bidi', 'abc العربية 123   ' + DIM + 'Arabic, then 123' + OFF)
    row('hebrew', 'עברית')
    row('zwsp', 'a​b  ' + DIM + 'renders as ab' + OFF)


def p_symbols():
    head(2, 9, 'Greek · Cyrillic · symbols', 'the alphabets and marks a TUI leans on')
    row('greek', ''.join(chr(c) for c in range(0x391, 0x3CA)))
    row('cyrillic', ''.join(chr(c) for c in range(0x410, 0x450)))
    print()
    row('math', '∀∂∃∅∇∈∉∋∏∑−∗√∝∞∧∨∩∪∫≈≠≡≤≥⊂⊃⊆⊇⊕⊗')
    row('arrows', '←↑→↓↔↕⇐⇑⇒⇓⇔⇕↩↪⇧⇨➜➤')
    row('marks', '✓✔✗✘✕✖ ●○◆◇■□▲▼ ★☆ ⚠⚙⚡ ⏺⎿ ✻✽✳ • · … ™©®')
    row('ambiguous', '± × ÷ ° µ ¶ § † ‡ ‰   ' + DIM + 'EAW=A: 1 cell under a Latin locale' + OFF)
    print()
    row('claude', '⏺ ⎿ ✻ ✽   ' + DIM + 'Claude Code: bullet, result branch, thinking' + OFF)
    row('codex', '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏   ' + DIM + 'Codex: the braille spinner' + OFF)


def p_boxes():
    head(3, 9, 'Box drawing · blocks · braille', 'Term synthesises U+2500-259F and E0B0-E0B3 itself')
    for a in range(0x2500, 0x2580, 0x20):
        row(f'U+{a:04X}', ''.join(chr(c) for c in range(a, a + 0x20)))
    row('blocks', ''.join(chr(c) for c in range(0x2580, 0x25A0)))
    print()
    row('joins', '┌───┬───┐  ╭───┬───╮   ' + DIM + 'corners must MEET, no gaps' + OFF)
    row('', '│   │   │  │   │   │')
    row('', '├───┼───┤  ├───┼───┤')
    row('', '└───┴───┘  ╰───┴───╯')
    print()
    row('shades', '░░░░▒▒▒▒▓▓▓▓████   ' + DIM + 'four even steps' + OFF)
    row('braille', ''.join(chr(c) for c in range(0x2800, 0x2840)))
    row('powerline', '  ')


def p_cjk1():
    head(4, 9, 'CJK — the 240 most used', "ranked by frequency in Term's own zh copy")
    s = cjk_top(240)
    for line in grid(list(s), 16):
        print(line)
    print()
    row('width', '中文abc中文   ' + DIM + 'each han = 2 cells; abc must not drift' + OFF)


def p_cjk2():
    head(5, 9, 'CJK — blocks, kana, hangul', 'a stratified sweep, not just the common ones')
    # 16 wide, not 32: a han char is 2 cells, so 16 + an 11-cell label is 43 — inside a 48-column
    # phone terminal. Strides are chosen to stay INSIDE each block: 0x9FFF ends unified han, ext-A ends
    # 0x4DBF, and hangul syllables end 0xD7A3 — one stride too far there lands in the surrogate range,
    # which is not encodable and killed the whole run the first time.
    row('U+4E00', ''.join(chr(0x4E00 + i * 401) for i in range(16)))
    row('U+7000', ''.join(chr(0x7000 + i * 401) for i in range(16)))
    row('U+9000', ''.join(chr(0x9000 + i * 199) for i in range(16)))
    row('ext-A', ''.join(chr(0x3400 + i * 397) for i in range(16)))
    print()
    row('hiragana', ''.join(chr(c) for c in range(0x3041, 0x3051)))
    row('katakana', ''.join(chr(c) for c in range(0x30A1, 0x30B1)))
    row('hangul', ''.join(chr(0xAC00 + i * 701) for i in range(16)))
    print()
    row('punct', '、。〃〈〉《》「」『』【】〔〕・ー～')
    row('fullwidth', '！＂＃＄％＆＇（）＊＋，－．／０１２３ＡＢＣａｂｃ')
    row('astral', '𠀀𠀁𠀂 𡃁 𢫬   ' + DIM + 'CJK ext-B, beyond the BMP' + OFF)


def p_emoji1():
    head(6, 9, 'Emoji — presentation', 'the VS16 pair is the test: text glyph vs colour glyph')
    row('smileys', '😀😃😄😁😆😅🤣😂🙂🙃😉😊')
    row('objects', '🚀🛰️💻🖥️⌨️🖱️💾📱🔋🔌')
    row('nature', '🌍🌏🌙⭐🌈🔥💧🌱🍀🌸')
    print()
    row('text VS15', '❤︎ ☘︎ ⚠︎ ☂︎ ✈︎   ' + DIM + 'monochrome, 1 cell' + OFF)
    row('emoji VS16', '❤️ ☘️ ⚠️ ☂️ ✈️   ' + DIM + 'colour — ink must stay inside the cell' + OFF)
    row('bare', '❤ ☘ ⚠ ☂ ✈   ' + DIM + 'default presentation' + OFF)
    print()
    # Two groups with their OWN control, because the emoji on this page are not all the same width:
    # ❤️ ☘️ ⚠️ sit on narrow bases (1 cell), 👍 🚀 are 2. One shared control row could not line up with
    # both, and an earlier version's apparent misalignment was that mistake rather than a render fault.
    row('1-cell', '|\u2764\ufe0f|\u2618\ufe0f|\u26a0\ufe0f|   ' + DIM + 'against the row below' + OFF)
    row('control', '|a|b|c|   ' + DIM + 'bars must sit at the SAME x as the row above' + OFF)
    print()
    row('2-cell', '|\U0001f44d|\U0001f680|\U0001f30d|   ' + DIM + 'against the row below' + OFF)
    row('control', '|\u4e2d|\u4e2d|\u4e2d|   ' + DIM + 'same again, for double-width' + OFF)
    print()
    row('VS probe', '|\u2764|\u2764\ufe0e|\u2764\ufe0f|   ' + DIM + 'bare | VS15 text | VS16 colour' + OFF)
    row('', '|\u2618|\u2618\ufe0e|\u2618\ufe0f|   ' + DIM + 'the selector itself must be invisible' + OFF)


def p_emoji2():
    head(7, 9, 'Emoji — clusters', 'one grapheme = one glyph = 2 cells, or the shaper failed')
    row('skin tone', '👍 👍🏻 👍🏼 👍🏽 👍🏾 👍🏿   ' + DIM + 'six glyphs, 2 cells each' + OFF)
    row('', '👩 👩🏽 🧑🏿 ✋🏼 👌🏾')
    print()
    row('ZWJ family', '👨‍👩‍👧‍👦 👨‍👩‍👦 👩‍👩‍👧 👪   ' + DIM + 'ONE figure each, not 4 side by side' + OFF)
    row('ZWJ role', '👩‍🚀 👨‍💻 👩🏽‍🚀 🧑‍🔬 👨‍🍳   ' + DIM + 'person + object fused' + OFF)
    row('ZWJ other', '🏳️‍🌈 🏴‍☠️ 👁️‍🗨️ ❤️‍🔥')
    print()
    row('flags', '🇯🇵 🇨🇳 🇺🇸 🇬🇧 🇰🇷 🇩🇪   ' + DIM + 'regional-indicator pairs' + OFF)
    row('keycaps', '1️⃣ 2️⃣ 3️⃣ #️⃣ *️⃣')
    row('control', '👨👩👧👦   ' + DIM + 'no ZWJ: four separate figures (correct)' + OFF)


def p_stress():
    head(8, 9, 'Scroll stress', 'glyph-diverse scrollback is what makes an atlas reset mid-frame')
    s = cjk_top(1000)
    n = 0
    for r in range(18):
        line = ''.join(s[(r * 40 + i) % len(s)] for i in range(18))
        print(f'{DIM}{r:3}{OFF} {line} \033[1m{line[:6]}\033[0m \033[2m{line[:6]}\033[0m')
        n += 1
    print()
    print(DIM + 'each row: plain / bold / dim of the same glyphs — three distinct tiles per char.' + OFF)
    print(DIM + 'fling this and every row must stay self-consistent; ghosting shows as a wrong glyph.' + OFF)


def p_edge():
    head(9, 9, 'Edge cases', 'the things that used to be wrong')
    row('tab', 'a\tb\tc   ' + DIM + 'tab stops every 8' + OFF)
    row('soft hyph', 'a­b')
    row('nbsp', 'a b')
    row('RTL num', 'العربية 2026')
    row('combining', 'x' + '́' * 6 + '   ' + DIM + 'six marks on one x, still 1 cell' + OFF)
    print()
    row('wide+mark', '中́ 文̈   ' + DIM + 'mark on a wide base, still 2 cells' + OFF)
    row('mixed', 'a中b文c👍d❤️e   ' + DIM + '1,2,1,2,1,2,1,2,1 cells' + OFF)
    row('ruler', '|....|....|....|....|....|....|....|....')
    print()
    row('unassigned', '\U000E0001 � ￿   ' + DIM + 'replacement + noncharacter' + OFF)
    row('done', 'end of matrix')


PAGES = [p_ascii, p_symbols, p_boxes, p_cjk1, p_cjk2, p_emoji1, p_emoji2, p_stress, p_edge]

if __name__ == '__main__':
    a = sys.argv[1] if len(sys.argv) > 1 else 'all'
    delay = float(sys.argv[2]) if len(sys.argv) > 2 else 2.5
    if a.isdigit():
        PAGES[int(a) - 1]()
    else:
        for i, fn in enumerate(PAGES):
            # one page must not be able to end the run — a bad codepoint on page 5 cost a whole
            # 30-second capture the first time round
            try:
                fn()
            except Exception as e:
                print(CLS + f'page {i + 1} failed: {e}')
            if i < len(PAGES) - 1:
                time.sleep(delay)
