aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_wide_marks.py
blob: 11ea5f6da7e8385883c72fad3cda4cb1381cbfc0 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
#!/usr/bin/env python3
"""Chrome is laid out in columns, and some glyphs are two of them.

The bug: a bell mark like 🔔 is one character and two columns. Every length in
app.c came from counting characters, so the mark was booked as one column and
drawn as two, and everything after it on that row -- the title, the buttons,
the frame's right corner -- landed a column early.

Assertions here are on *columns*, taken from style runs rather than the text
dump: the dump collapses a wide cell into one character, so measuring a python
string is measuring the wrong thing and would have passed before the fix.
"""

import json
import os
import subprocess
import sys
import tempfile

from harness import BIN, check, report

LAYOUT = """layout {
  tab {
    pane focus=true
    pane command="printf '\\\\033]2;ringer\\\\007'; printf '\\\\a'; sleep 30"
  }
}
"""

NARROW = "\u2022"  # one column
WIDE = "\U0001f514"  # bell emoji, two columns
CLUSTER = "\U0001f468\u200d\U0001f469\u200d\U0001f467"  # ZWJ family: 18 bytes


def run(mark, cols=100, rows=14):
    lay = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
    lay.write(LAYOUT)
    lay.close()
    cfg = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
    # An unmistakable bell colour: the assertion is "how many columns did the
    # mark claim", which means finding those columns without guessing.
    # ...and without the default bell flash: it paints the whole frame in
    # that same colour at full strength, which would make "the first column
    # that is not the bell colour" the far corner of the pane.
    cfg.write('bell_mark "%s"\ntheme { bell "#00ff00" }\nstates { bell { } }\n' % mark)
    cfg.close()
    p = subprocess.run(
        [
            BIN,
            "--script",
            "--cols",
            str(cols),
            "--rows",
            str(rows),
            "--layout",
            lay.name,
            "--",
            "/bin/sh",
            "-c",
            "read x",
        ],
        input=b"settle 250\npanes\nsnapshot json\n",
        capture_output=True,
        env={**os.environ, "SLOSH_CONFIG": cfg.name},
    )
    os.unlink(lay.name)
    os.unlink(cfg.name)
    return p.stdout


def columns_claimed_by_the_mark(mark):
    """How many columns of the title row the mark actually owns.

    Measured from where it is drawn (one column in from the pane's left edge)
    to the first column that is not the bell colour. This is the number that
    was wrong: the glyph was drawn in two columns and booked as one, so the
    dashes, title and buttons after it all moved a column left."""
    lines = run(mark).decode().strip().split("\n")
    panes, snap = json.loads(lines[0]), json.loads(lines[1])
    rang = [q for q in panes if not q["focused"]][0]
    y, start = rang["y"], rang["x"] + 1
    runs = {r["x"]: r for r in snap["styles"] if r["y"] == y}
    n, x = 0, start
    while x in runs and runs[x].get("fg") == "#00ff00":
        n += runs[x]["w"]
        x += runs[x]["w"]
    return n


def test_a_mark_claims_exactly_the_columns_it_draws_in():
    for label, mark, want in (
        ("narrow", NARROW, 1),
        ("wide", WIDE, 2),
        ("two characters", "!!", 2),
    ):
        got = columns_claimed_by_the_mark(mark)
        check(
            "%s mark claims %d column(s)" % (label, want),
            got == want,
            "claimed %d" % got,
        )


def test_the_mark_is_actually_drawn():
    out = run(WIDE).decode()
    check("a wide mark reaches the screen", WIDE in out, "no bell mark on screen")


def test_no_mark_can_put_invalid_utf8_on_the_wire():
    """A mark is a grapheme cluster, not a character. The config stored it in a
    fixed buffer with snprintf, which truncates at a byte: an 18-byte family
    emoji came out as 15 bytes ending mid-codepoint, and every consumer of the
    snapshot then had a decoding error instead of a screen."""
    for label, mark in (("wide", WIDE), ("18-byte cluster", CLUSTER)):
        raw = run(mark)
        try:
            raw.decode("utf-8")
            ok = True
        except UnicodeDecodeError as e:
            ok = False
            detail = repr(raw[max(0, e.start - 20) : e.start + 12])
        check("%s: output is valid utf-8" % label, ok, detail if not ok else "")


def test_widths_come_from_the_terminals_own_table():
    """Not a table of our own: the same one lib-vt uses for pane content, so
    chrome and content cannot disagree about how wide something is."""
    src = open(os.path.join(os.path.dirname(__file__), "..", "src", "screen.c")).read()
    check(
        "width comes from lib-vt",
        "ghostty_unicode_grapheme_width" in src,
        "screen.c is measuring text some other way",
    )


for name, fn in sorted(list(globals().items())):
    if name.startswith("test_"):
        fn()
sys.exit(report())