diff options
| author | user <user@clank> | 2026-08-15 18:44:07 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-15 18:44:07 +0200 |
| commit | 93ce0b645babd0851edb34701bbfffa5565daf65 (patch) | |
| tree | b402f934e5624a731fef4988d5d4e640766817fa /tests | |
| parent | sl0ppty: watch the config directory even when it is not there yet (diff) | |
| download | slosh-93ce0b645babd0851edb34701bbfffa5565daf65.tar.gz slosh-93ce0b645babd0851edb34701bbfffa5565daf65.tar.xz slosh-93ce0b645babd0851edb34701bbfffa5565daf65.zip | |
sl0ppty: measure chrome in columns, not characters
Good catch: a two-column bell mark shifted the pane title row. Everything in
app.c that positions chrome -- title budgets, button placement, the hit rects
that have to agree with them -- came from a `cells()` that counted codepoints,
and a glyph drawn in two columns was booked as one, so every glyph after it
landed a column early.
Widths now come from lib-vt's own table, via ghostty_unicode_grapheme_width.
That is deliberately the same table the terminal uses for pane content, so
chrome and content cannot disagree about how wide something is, and it is
cluster-aware: an emoji with a variation selector is two codepoints and one
glyph. A wide cell claims its right half as a tail (width 0), which the
renderer already knew how to skip.
HANDOVER said this was a known trap and to dump the actual cells before
touching arithmetic, which is exactly how this went: the first assertion I
wrote passed against a deliberately broken width function, because it measured
the frame's right corner -- drawn at an absolute position, so it does not move
-- rather than the columns the mark claims. The test now reads the mark's own
colour runs and demands 1 column for a narrow mark and 2 for a wide one, and it
fails when the width function is stubbed back to 1.
Two things fell out on the way:
ghostty_unicode_grapheme_width measures only the *first* cluster and returns
the codepoints it consumed. My first loop ignored that and asked "did the width
change when I added the next codepoint?", which concludes that an entire ASCII
string is a single cluster -- the cheatsheet modal collapsed to ten columns
wide with the text clipped. Use the return value.
A cluster can also be longer than a cell holds: a ZWJ family emoji is 18 bytes
against 16. Both the cell writer and the config's mark fields (now 32 bytes)
clamp on a codepoint boundary, because snprintf cuts at a byte and half a
codepoint is invalid UTF-8 -- which showed up as a decoder error in a test far
from the cause, not as a short mark.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_wide_marks.py | 114 |
1 files changed, 114 insertions, 0 deletions
diff --git a/tests/test_wide_marks.py b/tests/test_wide_marks.py new file mode 100644 index 0000000..c53df57 --- /dev/null +++ b/tests/test_wide_marks.py @@ -0,0 +1,114 @@ +#!/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. + cfg.write('bell_mark "%s"\ntheme { bell "#00ff00" }\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, + "SL0PPTY_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()) |
