Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions specs/renderer-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,15 @@ NOT override the background already present in each glyph cell; element
backgrounds established by `open({ bg })` remain in effect, and the terminal
default remains in effect where no element background applies.

**Grapheme cluster preservation.** When `content` contains grapheme clusters — a
base codepoint followed by one or more combining marks (Unicode codepoints with
`wcwidth` ≤ 0) — the renderer MUST preserve and emit the full cluster. Combining
marks MUST NOT be silently dropped. Combining marks do not advance the cursor
position; they are attached to the preceding base codepoint's cell and emitted
immediately after the base codepoint's bytes in the output stream. A cell
occupied by a grapheme cluster with combining marks MUST be treated as changed
(and thus emitted) when any mark in the cluster changes between frames.

The set of styling properties accepted by `props` is part of the current
implementation surface and may be extended.

Expand Down Expand Up @@ -997,11 +1006,23 @@ elements; clip regions; and scroll containers.

**Text measurement.** Text width measurement uses `wcwidth`-based character
width computation, supporting ASCII, CJK wide characters, and other Unicode
codepoints.

**Cell representation.** Each cell in the buffer stores a Unicode codepoint, a
foreground color (packed ARGB with attribute flags in the high byte), and a
background color.
codepoints. Combining marks (codepoints with `wcwidth` ≤ 0) contribute zero to
measured width; they attach to the preceding base codepoint's cell and are not
counted as separate cells in layout. Measurement and rendering MUST agree: if
the measurer ignores a combining mark for width purposes, the renderer MUST
still attach and emit it.

**Cell representation.** Each cell in the buffer stores a grapheme cluster — a
base Unicode codepoint plus up to 8 combining-mark codepoints — together with a
foreground color (packed ARGB with attribute flags in the high byte) and a
background color. The combining-mark slots are zero-terminated; a cell with no
combining marks stores zero in every slot. When a text string produces more than
8 combining marks for a single base codepoint, the excess marks are silently
truncated from the end (marks 1–8 are kept, marks 9+ are discarded), ensuring
that the first and most semantically significant marks always survive. Cell
comparison for diffing considers combining marks: a cell is considered changed
when any combining mark differs from the front buffer, not only when the base
codepoint or color attributes differ.

**Border junction resolution.** When bordered elements share edges, the renderer
accumulates per-cell direction bitmasks and resolves them to correct box-drawing
Expand Down
17 changes: 11 additions & 6 deletions src/cell.c
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@

void cells_fill(Cell *buf, int w, int h, uint32_t ch, uint32_t fg,
uint32_t bg) {
for (int i = 0; i < w * h; i++) {
buf[i].ch = ch;
buf[i].fg = fg;
buf[i].bg = bg;
}
/* Designated init zeros unspecified fields (including combining[]). */
Cell tmpl = {.ch = ch, .fg = fg, .bg = bg};
for (int i = 0; i < w * h; i++)
buf[i] = tmpl;
}

int cell_cmp(Cell *a, Cell *b) {
return a->ch != b->ch || a->fg != b->fg || a->bg != b->bg;
if (a->ch != b->ch || a->fg != b->fg || a->bg != b->bg)
return 1;
for (int i = 0; i < CELL_MAX_COMBINING; i++) {
if (a->combining[i] != b->combining[i])
return 1;
}
return 0;
}
6 changes: 6 additions & 0 deletions src/cell.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@

#include <stdint.h>

/* Maximum combining marks stored per cell. Marks beyond this limit are
* silently truncated from the end; the first CELL_MAX_COMBINING are kept. */
#define CELL_MAX_COMBINING 8
Comment on lines +8 to +10

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this gives us plenty of headroom for the common case and is unlikely to be a problem in the short-term, but I have a slight concern that it will be a long-term issue if any terminal protocols introduce complex metadata via ZWJ that requires >8 codepoints

an alternative design would be bumping this cieling and keeping a dynamic map of combining size per cell rather than reserving a flat 8 per cell.


typedef struct {
uint32_t ch;
uint32_t fg; /* 0xAARRGGBB — upper byte: attribute flags */
uint32_t bg; /* 0xAARRGGBB — upper byte: attribute flags */
uint32_t combining[CELL_MAX_COMBINING]; /* zero-terminated combining-mark
codepoints */
} Cell;

/* Attribute flags (packed into high byte of fg) */
Expand Down
30 changes: 29 additions & 1 deletion src/clayterm.c
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,9 @@ struct Clayterm {
* Output buffer is sized at 64 bytes per cell — enough for worst-case
* full-screen redraws with truecolor SGR sequences on every cell.
*/
#define OUT_BYTES_PER_CELL 64
/* 128 bytes per cell: ~84 bytes worst-case for CUP + SGR sequences, plus up
* to 4 (base SMP char) + 8×4 (combining marks) = 36 bytes of cluster text. */
#define OUT_BYTES_PER_CELL 128

/* ── Cell buffer ops ──────────────────────────────────────────────── */

Expand All @@ -133,6 +135,23 @@ static void setcell(struct Clayterm *ct, int x, int y, uint32_t ch, uint32_t fg,
if (!(bg & ATTR_DEFAULT)) {
c->bg = bg;
}
for (int i = 0; i < CELL_MAX_COMBINING; i++)
c->combining[i] = 0;
}

/* Append a combining-mark codepoint to the cell at (x, y) in the back buffer.
* Marks beyond CELL_MAX_COMBINING are silently dropped (truncation from end).
*/
static void append_combining(struct Clayterm *ct, int x, int y, uint32_t cp) {
if (x < 0 || x >= ct->w || y < 0 || y >= ct->h)
return;
Cell *c = cell_at(ct, ct->back, x, y);
for (int i = 0; i < CELL_MAX_COMBINING; i++) {
if (c->combining[i] == 0) {
c->combining[i] = cp;
return;
}
}
}

/* ── Escape sequence generation ───────────────────────────────────── */
Expand Down Expand Up @@ -238,6 +257,8 @@ static void present_cups(struct Clayterm *ct, int row) {
emit_ch(ct, i, y, row, ' ');
} else {
emit_ch(ct, x, y, row, back->ch);
for (int ci = 0; ci < CELL_MAX_COMBINING && back->combining[ci]; ci++)
buf_char(&ct->out, back->combining[ci]);
/* mark trailing cells of wide char as invalid in front
* so they'll diff when overwritten by narrow chars */
for (int i = 1; i < w; i++) {
Expand Down Expand Up @@ -298,6 +319,8 @@ static void present_lines(struct Clayterm *ct) {
if (!iswprint(ch))
ch = 0xfffd;
buf_char(&ct->out, ch);
for (int ci = 0; ci < CELL_MAX_COMBINING && back->combining[ci]; ci++)
buf_char(&ct->out, back->combining[ci]);
for (int i = 1; i < w; i++) {
Cell *fw = cell_at(ct, ct->front, x + i, y);
fw->ch = 0xffffffff;
Expand Down Expand Up @@ -391,6 +414,7 @@ static void render_text(struct Clayterm *ct, int x0, int y0,
const char *p = slice;
int rem = slice_len;
int x = x0;
int last_x = -1; /* column of the most-recently written base cell */

while (rem > 0) {
/* Check at the top of each iteration: if the pointer we are about to
Expand All @@ -415,7 +439,11 @@ static void render_text(struct Clayterm *ct, int x0, int y0,
cw = 1;
if (cw > 0) {
setcell(ct, x, y0, cp, fg, bg);
last_x = x;
x += cw;
} else if (last_x >= 0) {
/* combining mark: attach to the preceding base cell */
append_combining(ct, last_x, y0, cp);
}
p += n;
rem -= n;
Expand Down
108 changes: 108 additions & 0 deletions test/term.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,114 @@ describe("term", () => {
});
});

describe("grapheme clusters", () => {
/* Helper: count occurrences of a substring in a string */
function countOf(haystack: string, needle: string): number {
let n = 0;
let pos = 0;
while ((pos = haystack.indexOf(needle, pos)) !== -1) {
n++;
pos += needle.length;
}
return n;
}

it("preserves kitty-graphics placeholder cluster (base + 2 combining marks)", async () => {
/* U+10EEEE = kitty placeholder; U+0305 = row-index diacritic;
* U+030D = col-index diacritic. Both marks must appear in the emitted
* bytes immediately after the base codepoint. */
let term2 = await createTerm({ width: 40, height: 4 });
let out = decode(
term2
.render(
[
open("root", {
layout: { width: grow(), height: grow(), direction: "ttb" },
}),
text("\u{10EEEE}\u{0305}\u{030D}"),
close(),
],
{ mode: "line" },
)
.output,
);

expect(out).toContain("\u{10EEEE}\u{0305}\u{030D}");
});

it("preserves combining accent (e + U+0301)", async () => {
let term2 = await createTerm({ width: 40, height: 4 });
let out = decode(
term2
.render(
[
open("root", {
layout: { width: grow(), height: grow(), direction: "ttb" },
}),
text("e\u{0301}"),
close(),
],
{ mode: "line" },
)
.output,
);

/* The combining mark must follow the base immediately in the output. */
expect(out).toContain("e\u{0301}");
});

it("preserves ZWJ in output (ZWJ is per-cell combining; following emoji start new cells)", async () => {
/* 👨‍👩‍👧‍👦 = 👨 ZWJ 👩 ZWJ 👧 ZWJ 👦.
* ZWJ (U+200D, wcwidth 0) attaches to the preceding base emoji cell.
* The following emoji have positive wcwidth and start new cells, so the
* family sequence is split across cells in the cell-based model. The
* invariant tested here: ZWJ bytes MUST NOT be dropped from the output. */
let term2 = await createTerm({ width: 40, height: 4 });
let out = decode(
term2
.render(
[
open("root", {
layout: { width: grow(), height: grow(), direction: "ttb" },
}),
text("👨\u{200D}👩\u{200D}👧\u{200D}👦"),
close(),
],
{ mode: "line" },
)
.output,
);

expect(countOf(out, "\u{200D}")).toBe(3);
expect(out).toContain("👨");
expect(out).toContain("👩");
});

it("truncates excess combining marks from the end (first 8 survive)", async () => {
/* A base char followed by 9 combining graves (U+0300).
* The 9th mark exceeds CELL_MAX_COMBINING=8 and is silently dropped;
* the first 8 must appear in the output. */
let term2 = await createTerm({ width: 40, height: 4 });
let grave = "\u{0300}";
let out = decode(
term2
.render(
[
open("root", {
layout: { width: grow(), height: grow(), direction: "ttb" },
}),
text("a" + grave.repeat(9)),
close(),
],
{ mode: "line" },
)
.output,
);

expect(countOf(out, grave)).toBe(8);
});
});

describe("caret placement", () => {
// These tests use `print()`, which marks the terminal's final
// cursor position by appending U+0332 COMBINING LOW LINE to that
Expand Down
Loading