aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-15 18:44:07 +0200
committeruser <user@clank>2026-08-15 18:44:07 +0200
commit93ce0b645babd0851edb34701bbfffa5565daf65 (patch)
treeb402f934e5624a731fef4988d5d4e640766817fa /tests
parentsl0ppty: watch the config directory even when it is not there yet (diff)
downloadslosh-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.py114
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())