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 | |
| 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')
| -rw-r--r-- | src/app.c | 16 | ||||
| -rw-r--r-- | src/config.c | 27 | ||||
| -rw-r--r-- | src/config.h | 9 | ||||
| -rw-r--r-- | src/screen.c | 119 | ||||
| -rw-r--r-- | src/sl0ppty.h | 7 |
5 files changed, 148 insertions, 30 deletions
@@ -305,15 +305,13 @@ static tab_t *cur(app_t *a) { return &a->tabs[a->cur]; } * Always asked with the rect that is about to be registered as the hit, so a * thing that lights up and the thing that would be clicked cannot drift apart. * A pointer already carrying something is busy and lights nothing. */ -/* Display columns of a short chrome string. Counts codepoints, which is exact - * for what chrome draws: every glyph here is one column, and a mark wide - * enough not to be is the thing the config warns you about. */ -static uint16_t cells(const char *str) { - uint16_t n = 0; - for (const char *q = str; q && *q; q++) - if (((unsigned char)*q & 0xC0) != 0x80) n++; - return n; -} +/* Display columns of a chrome string, from the terminal's own width table. + * + * This counted codepoints until a two-column bell mark shifted a pane's title + * row: booking one column for a glyph drawn in two moves everything after it, + * and every length in this file -- title budgets, button positions, the hit + * rects that must agree with them -- is computed from here. */ +static uint16_t cells(const char *str) { return screen_cells(str); } static bool ptr_on(const app_t *a, uint16_t x, uint16_t y, uint16_t w, uint16_t h) { diff --git a/src/config.c b/src/config.c index 9473e34..d1b4eb6 100644 --- a/src/config.c +++ b/src/config.c @@ -268,6 +268,21 @@ static void bind_add(config_t *c, int key, uint16_t mods, action_t action, * `declared` is whether the config named `unfocused` itself, in which case it * has said what it wants -- including `states { unfocused { } }`, which says * "nothing", and must not be quietly refilled. */ +/* Copy a mark, cutting only where a codepoint ends. + * + * snprintf truncates at a byte, and a mark is a grapheme cluster: cutting one + * mid-codepoint puts bytes on the wire that are not UTF-8 at all, which shows + * up far from here as a garbled screen rather than a too-short mark. */ +static void set_mark(char *dst, size_t cap, const char *src) { + size_t n = strlen(src); + if (n >= cap) { + n = cap - 1; + while (n && ((unsigned char)src[n] & 0xC0) == 0x80) n--; + } + memcpy(dst, src, n); + dst[n] = 0; +} + static void apply_dim_unfocused(config_t *c, bool declared) { if (declared) return; if (!c->dim_unfocused) { @@ -923,15 +938,15 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) { c->pane_buttons = kdl_arg_bool(kdl_child(root, "pane_buttons"), 0, c->pane_buttons); const char *zm = kdl_arg(kdl_child(root, "zoom_mark"), 0, NULL); - if (zm) snprintf(c->zoom_mark, sizeof c->zoom_mark, "%s", zm); + if (zm) set_mark(c->zoom_mark, sizeof c->zoom_mark, zm); const char *zo = kdl_arg(kdl_child(root, "zoom_on_mark"), 0, NULL); - if (zo) snprintf(c->zoom_on_mark, sizeof c->zoom_on_mark, "%s", zo); + if (zo) set_mark(c->zoom_on_mark, sizeof c->zoom_on_mark, zo); const char *cm = kdl_arg(kdl_child(root, "close_mark"), 0, NULL); - if (cm) snprintf(c->close_mark, sizeof c->close_mark, "%s", cm); + if (cm) set_mark(c->close_mark, sizeof c->close_mark, cm); const char *mm = kdl_arg(kdl_child(root, "min_mark"), 0, NULL); - if (mm) snprintf(c->min_mark, sizeof c->min_mark, "%s", mm); + if (mm) set_mark(c->min_mark, sizeof c->min_mark, mm); const char *nt = kdl_arg(kdl_child(root, "newtab_mark"), 0, NULL); - if (nt) snprintf(c->newtab_mark, sizeof c->newtab_mark, "%s", nt); + if (nt) set_mark(c->newtab_mark, sizeof c->newtab_mark, nt); { /* `commands` (the default), `all`, or `none`. `true`/`false` are taken as * `all`/`none`, because that is what they used to mean here and a config @@ -954,7 +969,7 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) { c->bell_indicator = kdl_arg_bool(kdl_child(root, "bell_indicator"), 0, c->bell_indicator); const char *bm = kdl_arg(kdl_child(root, "bell_mark"), 0, NULL); - if (bm) snprintf(c->bell_mark, sizeof c->bell_mark, "%s", bm); + if (bm) set_mark(c->bell_mark, sizeof c->bell_mark, bm); const kdl_node_t *mins = kdl_child(root, "min_split"); if (mins) { diff --git a/src/config.h b/src/config.h index 8c01fdb..36e9915 100644 --- a/src/config.h +++ b/src/config.h @@ -110,16 +110,19 @@ typedef struct { * reads as even: a glyph that floats in a mostly-empty cell donates its own * whitespace to the gap beside it and looks further away than its * neighbours, which is what an en dash did here. */ - char zoom_mark[16], zoom_on_mark[16], close_mark[16], min_mark[16]; + /* 32 bytes because a mark is a grapheme cluster, not a character: a + family emoji is eighteen bytes on its own. Truncating one mid- + codepoint used to put invalid UTF-8 on the wire. */ + char zoom_mark[32], zoom_on_mark[32], close_mark[32], min_mark[32]; /* The new-tab button at the end of the strip. Drawn with a space each side, * so a one-character mark is still a three-cell target. */ - char newtab_mark[16]; + char newtab_mark[32]; bool bell_indicator; /* What to draw. A short string rather than a fixed glyph: the obvious * choice is an emoji bell, and emoji are drawn two columns wide by many * terminals while chrome here is booked as one — so the default is narrow * and anyone who knows their terminal can say otherwise. */ - char bell_mark[16]; + char bell_mark[32]; /* Which dead panes stay, showing what they printed and offering to run * again, and which just go. * 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) { diff --git a/src/sl0ppty.h b/src/sl0ppty.h index e937e2b..cb916c5 100644 --- a/src/sl0ppty.h +++ b/src/sl0ppty.h @@ -59,7 +59,12 @@ void screen_clear(screen_t *s); cell_t *screen_at(screen_t *s, uint16_t x, uint16_t y); 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); -/* Write a UTF-8 string one cell per codepoint; returns cells written. */ +/* Columns one grapheme cluster occupies, from lib-vt's own width table. */ +uint8_t screen_width_of(const char *txt, size_t len); +/* Columns a whole string occupies. What chrome must budget with. */ +uint16_t screen_cells(const char *txt); +/* Write a UTF-8 string; returns the *columns* written, which is not the number + * of characters when any of them is wide. */ 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); /* Diff cur against prev into s->out (the minimal byte stream for a terminal). */ |
