Widen the cell for Thai/Lao vowel sign AM (U+0E33, U+0EB3)

These two code points have Grapheme_Cluster_Break=SpacingMark but a
width of one, the only such code points in Unicode. Grapheme
segmentation (GB9a) adds them to the previous cell, so "จำ" occupied a
single cell in kitty while wcwidth() based programs count two. As a
result the SARA AA glyph was clipped, only the nikhahit was visible and
the cursor drifted right by one cell per AM.

Now, when such a code point combines into a plain one cell wide cell,
the cell is widened to two cells, using the same mechanism as U+FE0F
does for narrow emoji. The C and Go wcswidth() implementations are
updated to match so that kitty's own width calculations agree with the
screen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Siriwat Uamganmsup 2026-09-15 11:05:44 +07:00
parent 1f20d43198
commit 0b29c33e89
8 changed files with 95 additions and 24 deletions

View file

@ -202,6 +202,8 @@ Detailed list of changes
0.49.0 [future]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Thai/Lao: Render the vowel sign AM (U+0E33/U+0EB3) correctly by widening the cell it combines into, matching wcwidth() based programs
- Support for :doc:`/custom-shaders` for adding various graphical effects (:iss:`10344`)
- Various throughput performance improvements for a 15-35% real world improvement depending on workload

View file

@ -412,7 +412,10 @@ For each decoded code point:
#. If there is no boundary, the current code point is added to the previous
cell and processing of the code point is finished. See the :ref:`var_select`
section below for handling of Unicode Variation selectors.
section below for handling of Unicode Variation selectors. If the code point has
``Grapheme_Cluster_Break=SpacingMark`` and a non-zero width and the previous
cell is one cell wide, the previous cell becomes two cells wide. This affects
only :code:`U+0E33 THAI CHARACTER SARA AM` and :code:`U+0EB3 LAO VOWEL SIGN AM`.
#. If there is a boundary, but the width of the current code point is zero,
it is added to the previous cell and processing is finished.

View file

@ -1172,6 +1172,32 @@ move_widened_char_past_multiline_chars(Screen *self, text_loop_state *s, CPUCell
else init_text_loop_line(self, s);
}
static void
widen_cell_to_two(Screen *self, text_loop_state *s, CPUCell *cp, GPUCell *gp, index_type xpos) {
CPUCell *cpu_cell = cp + xpos;
GPUCell *gpu_cell = gp + xpos;
cpu_cell->is_multicell = true;
cpu_cell->width = 2;
cpu_cell->natural_width = true;
if (!cpu_cell->scale) cpu_cell->scale = 1;
if (xpos + 1 < self->columns) {
CPUCell *second = cp + xpos + 1;
if (second->is_multicell) {
if (second->y) {
move_widened_char_past_multiline_chars(self, s, cpu_cell, gpu_cell, xpos, s->prev.y);
return;
}
nuke_multicell_char_at(self, xpos + 1, s->prev.y, false);
}
zero_cells(s, second, gp + xpos + 1);
self->cursor->x++;
*second = *cpu_cell;
second->x = 1;
} else {
move_widened_char_past_multiline_chars(self, s, cpu_cell, gpu_cell, xpos, s->prev.y);
}
}
static bool
is_emoji_presentation_base(char_type ch) {
return char_props_for(ch).is_emoji_presentation_base == 1;
@ -1187,29 +1213,8 @@ draw_combining_char(Screen *self, text_loop_state *s, char_type ch) {
if (!add_combining_char(self, ch, xpos, s->prev.y) || self->lc->count < 2) return;
unsigned base_pos = self->lc->count - 2;
if (ch == VS16) { // emoji presentation variation marker makes default text presentation emoji (narrow emoji) into wide emoji
CPUCell *cpu_cell = cp + xpos;
GPUCell *gpu_cell = gp + xpos;
if (self->lc->chars[base_pos + 1] == VS16 && !cpu_cell->is_multicell && is_emoji_presentation_base(self->lc->chars[base_pos])) {
cpu_cell->is_multicell = true;
cpu_cell->width = 2;
cpu_cell->natural_width = true;
if (!cpu_cell->scale) cpu_cell->scale = 1;
if (xpos + 1 < self->columns) {
CPUCell *second = cp + xpos + 1;
if (second->is_multicell) {
if (second->y) {
move_widened_char_past_multiline_chars(self, s, cpu_cell, gpu_cell, xpos, s->prev.y);
return;
}
nuke_multicell_char_at(self, xpos + 1, s->prev.y, false);
}
zero_cells(s, second, gp + xpos + 1);
self->cursor->x++;
*second = *cpu_cell;
second->x = 1;
} else {
move_widened_char_past_multiline_chars(self, s, cpu_cell, gpu_cell, xpos, s->prev.y);
}
if (self->lc->chars[base_pos + 1] == VS16 && !cp[xpos].is_multicell && is_emoji_presentation_base(self->lc->chars[base_pos])) {
widen_cell_to_two(self, s, cp, gp, xpos);
}
} else if (ch == VS15) {
const CPUCell *cpu_cell = cp + xpos;
@ -1220,6 +1225,10 @@ draw_combining_char(Screen *self, text_loop_state *s, char_type ch) {
init_segmentation_state(self, s);
}
}
} else {
// Thai/Lao SARA AM is a SpacingMark with non-zero width, it widens a narrow base cell
CharProps ch_props = char_props_for(ch);
if (ch_props.grapheme_break == GBP_SpacingMark && wcwidth_std(ch_props) > 0 && !cp[xpos].is_multicell) widen_cell_to_two(self, s, cp, gp, xpos);
}
}

View file

@ -49,6 +49,13 @@ wcswidth_step(WCSState *state, const char_type ch) {
state->prev_width = 1;
} else state->prev_width = 0;
break;
default:
// Thai/Lao SARA AM: a SpacingMark with non-zero width widens a narrow base cell
if (cp.grapheme_break == GBP_SpacingMark && wcwidth_std(cp) > 0 && state->prev_width == 1) {
ans = 1;
state->prev_width = 2;
}
break;
}
break;
}

View file

@ -578,6 +578,12 @@ class TestDataTypes(BaseTest):
self.ae(wcswidth('\U0001f610\ufe0e'), 1)
self.ae(wcswidth('\U0001f1e6a'), 3)
self.ae(wcswidth('\U0001f1e6a\U0001f1e8a'), 6)
# Thai/Lao SARA AM is a SpacingMark with width 1, it widens the cell it combines into
self.ae(wcswidth('จำ'), 2)
self.ae(wcswidth('ำ'), 1)
self.ae(wcswidth('กิ'), 1)
self.ae(wcswidth('จำำ'), 2)
self.ae(wcswidth('ກຳ'), 2)
self.ae(wcswidth('\U0001f1e6\U0001f1e8a'), 3)
self.ae(wcswidth('\U0001f1e6\U0001f1e8\U0001f1e6'), 4)
self.ae(wcswidth('a\u00adb'), 2)

View file

@ -1067,6 +1067,38 @@ class TestScreen(BaseTest):
s.reset()
s.draw('\N{HEAVY EXCLAMATION MARK SYMBOL}' + 4500 * '\N{VARIATION SELECTOR-16}')
def test_spacing_mark_widens_narrow_cell(self):
# Thai SARA AM and Lao AM are SpacingMarks with width 1 that widen the cell they combine into
s = self.create_screen(cols=5)
s.draw('จำ')
self.ae(s.cursor.x, 2)
self.assertTrue(str(s.line(0)).startswith('จำ'))
c = s.cpu_cells(0, 0)
self.assertTrue(c['mcd'])
self.ae(c['mcd']['width'], 2)
self.ae(c['x'], 0)
self.ae(s.cpu_cells(0, 1)['x'], 1)
s.reset()
s.draw('จ')
self.ae(s.cursor.x, 1)
s.draw('ำ')
self.ae(s.cursor.x, 2)
s.reset()
s.draw('จำำ')
self.ae(s.cursor.x, 2)
s.reset()
s.draw('ກຳ')
self.ae(s.cursor.x, 2)
s.reset()
s.draw('กิ')
self.ae(s.cursor.x, 1)
# widened char on the last column wraps onto the next line
s = self.create_screen(cols=2)
s.draw('aจำ')
self.ae((s.cursor.x, s.cursor.y), (2, 1))
self.ae(str(s.line(0)), 'a')
self.ae(str(s.line(1)), 'จำ')
def test_writing_with_cursor_on_trailer_of_wide_character(self):
s = self.create_screen()

View file

@ -84,6 +84,12 @@ func (self *WCWidthIterator) handle_rune(ch rune) error {
self.current_width -= 1
self.prev_width = 1
}
default:
// Thai/Lao SARA AM: a SpacingMark with non-zero width widens a narrow base cell
if cp.Grapheme_break() == uint8(GBP_SpacingMark) && cp.Width() > 0 && self.prev_width == 1 {
self.current_width += 1
self.prev_width = 2
}
}
} else {
width := cp.Width()

View file

@ -36,6 +36,12 @@ func TestWCSWidth(t *testing.T) {
wcswidth("\U0001F1E6\U0001F1E8a", 3)
wcswidth("\U0001F1E6\U0001F1E8\U0001F1E6", 4)
wcswidth("a\u00adb", 2)
// Thai/Lao SARA AM is a SpacingMark with width 1, it widens the cell it combines into
wcswidth("จำ", 2)
wcswidth("ำ", 1)
wcswidth("กิ", 1)
wcswidth("จำำ", 2)
wcswidth("ກຳ", 2)
wcswidth("a\x1b[22bcd", 25)
// Flags individually and together
wcwidth("\U0001f1ee\U0001f1f3", 2, 2)