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 /src/screen.c | |
| 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 'src/screen.c')
| -rw-r--r-- | src/screen.c | 119 |
1 files changed, 108 insertions, 11 deletions
diff --git a/src/screen.c b/src/screen.c index b7175bb..aa0faa3 100644 --- a/src/screen.c +++ b/src/screen.c @@ -3,6 +3,8 @@ #define _GNU_SOURCE #include "sl0ppty.h" +#include <ghostty/vt.h> + #include <stdarg.h> #include <stdio.h> #include <stdlib.h> @@ -114,27 +116,121 @@ cell_t *screen_at(screen_t *s, uint16_t x, uint16_t y) { return &s->cur[(size_t)y * s->cols + x]; } +static size_t u8_len(unsigned char b) { + if (b < 0x80) return 1; + if ((b & 0xe0) == 0xc0) return 2; + if ((b & 0xf0) == 0xe0) return 3; + if ((b & 0xf8) == 0xf0) return 4; + return 1; +} + +/* Decode UTF-8 into codepoints, bounded. Returns how many were written and, + * via `used`, how many bytes they came from -- the caller needs both to walk a + * string cluster by cluster. */ +static size_t decode_utf8(const char *txt, size_t len, uint32_t *cps, + size_t max, size_t *used) { + size_t n = 0, i = 0; + while (i < len && n < max) { + size_t l = u8_len((unsigned char)txt[i]); + if (l == 0 || i + l > len) break; + uint32_t cp; + switch (l) { + case 1: cp = (unsigned char)txt[i]; break; + case 2: cp = ((uint32_t)((unsigned char)txt[i] & 0x1f) << 6) | + ((unsigned char)txt[i + 1] & 0x3f); break; + case 3: cp = ((uint32_t)((unsigned char)txt[i] & 0x0f) << 12) | + ((uint32_t)((unsigned char)txt[i + 1] & 0x3f) << 6) | + ((unsigned char)txt[i + 2] & 0x3f); break; + default: cp = ((uint32_t)((unsigned char)txt[i] & 0x07) << 18) | + ((uint32_t)((unsigned char)txt[i + 1] & 0x3f) << 12) | + ((uint32_t)((unsigned char)txt[i + 2] & 0x3f) << 6) | + ((unsigned char)txt[i + 3] & 0x3f); break; + } + cps[n++] = cp; + i += l; + } + if (used) *used = i; + return n; +} + +/* How many columns the first grapheme cluster takes, asked of the same table + * the terminal uses (lib-vt's). Guessing 1 for everything is what made a + * two-column bell mark shift the rest of a pane's title row: the cell was + * booked as one column and drawn as two, and every glyph after it landed a + * column early. */ +uint8_t screen_width_of(const char *txt, size_t len) { + uint32_t cps[16]; + size_t n = decode_utf8(txt, len, cps, sizeof cps / sizeof *cps, NULL); + if (!n) return 1; + uint8_t w = 1; + ghostty_unicode_grapheme_width(cps, n, &w); + /* A zero-width cluster still owns the cell it was written into: chrome is + * laid out in cells, and a mark that claims no space would be overwritten + * by whatever comes next. */ + return w ? w : 1; +} + +/* Columns a whole string occupies. + * + * Cluster by cluster, using the codepoint count lib-vt reports as consumed -- + * summing per-codepoint widths is a different and wrong answer, because an + * emoji with a variation selector is two codepoints and one cluster. */ +uint16_t screen_cells(const char *txt) { + if (!txt) return 0; + uint32_t cps[256]; + uint16_t total = 0; + size_t len = strlen(txt), at = 0; + while (at < len) { + size_t used = 0; + size_t n = decode_utf8(txt + at, len - at, cps, sizeof cps / sizeof *cps, + &used); + if (!n) break; + for (size_t i = 0; i < n;) { + uint8_t w = 1; + size_t eaten = ghostty_unicode_grapheme_width(cps + i, n - i, &w); + if (!eaten) break; + i += eaten; + total = (uint16_t)(total + w); + } + at += used; + } + return total; +} + void screen_put_utf8(screen_t *s, uint16_t x, uint16_t y, const char *txt, size_t len, color_t fg, color_t bg, uint16_t attrs) { cell_t *c = screen_at(s, x, y); if (!c) return; - if (len > sizeof c->text) len = sizeof c->text; + /* Clamped on a codepoint boundary. A grapheme cluster can be longer than + * this cell holds -- a family emoji is eighteen bytes -- and cutting one in + * the middle of a codepoint puts invalid UTF-8 on the wire, which is worse + * than losing the tail of a cluster nobody can see anyway. */ + if (len > sizeof c->text) { + len = sizeof c->text; + while (len && ((unsigned char)txt[len] & 0xC0) == 0x80) len--; + } memset(c->text, 0, sizeof c->text); memcpy(c->text, txt, len); c->len = (uint8_t)len; - c->width = 1; + c->width = screen_width_of(txt, len); + /* The second half of a wide cell is a tail: never painted, and skipped by + * the diff. Without claiming it, the cell to the right keeps whatever was + * there and the terminal draws both. */ + if (c->width == 2) { + cell_t *tail = screen_at(s, (uint16_t)(x + 1), y); + if (tail) { + memset(tail, 0, sizeof *tail); + tail->fg = fg; + tail->bg = bg; + } else { + c->width = 1; /* no room for the tail: do not claim what is not there */ + } + } c->fg = fg; c->bg = bg; c->attrs = attrs; } -static size_t u8_len(unsigned char b) { - if (b < 0x80) return 1; - if ((b & 0xe0) == 0xc0) return 2; - if ((b & 0xf0) == 0xe0) return 3; - if ((b & 0xf8) == 0xf0) return 4; - return 1; -} uint16_t screen_text(screen_t *s, uint16_t x, uint16_t y, const char *txt, color_t fg, color_t bg, uint16_t attrs) { @@ -143,10 +239,11 @@ uint16_t screen_text(screen_t *s, uint16_t x, uint16_t y, const char *txt, size_t len = u8_len((unsigned char)*p); if (x + n >= s->cols) break; screen_put_utf8(s, (uint16_t)(x + n), y, p, len, fg, bg, attrs); + cell_t *c = screen_at(s, (uint16_t)(x + n), y); + n = (uint16_t)(n + (c && c->width ? c->width : 1)); p += len; - n++; } - return n; /* cells written, so a caller can lay out what follows */ + return n; /* columns written, so a caller can lay out what follows */ } static bool color_eq(color_t a, color_t b) { |
