Implement proper UTF-8 grapheme cluster support for modern terminals

This commit addresses critical UTF-8 display and input issues, particularly
with emoji variations and complex Unicode sequences that caused rendering
problems in modern terminals like Ghostty.

Key improvements:
- Enhanced utf8.c with grapheme cluster detection using utf8proc
- Fixed input field handling to preserve emoji variation selectors
- Improved text measurement and cursor positioning for complex Unicode
- Added smart paste processing for multi-codepoint characters
- Bypassed problematic TRANSLIT when not needed for UTF-8 content
- Enhanced GUI entry and readline to handle grapheme clusters properly

The implementation maintains full backward compatibility while providing
proper support for modern Unicode standards, fixing display corruption
that occurred with emoji sequences containing variation selectors.

Tested with emoji sequences like: 💕💋😘🥰💞❣️💓♥️♥️💓❣️🥰🥰😘💋💋
This commit is contained in:
kofany 2025-09-10 17:44:46 +02:00
commit 28a793d659
9 changed files with 564 additions and 81 deletions

View file

@ -27,9 +27,70 @@
/* Provide is_utf8(): */
#include <irssi/src/core/recode.h>
#include <irssi/src/core/signals.h>
#include <irssi/src/core/settings.h>
#ifdef HAVE_LIBUTF8PROC
#include <utf8proc.h>
/* Advance the str pointer one grapheme cluster further when utf8proc is available,
* or fall back to single character advancement. Returns display width. */
static int string_advance_with_grapheme_support(char const **str, int policy)
{
utf8proc_int32_t codepoint, prev_codepoint = 0;
utf8proc_int32_t state = 0;
const char *start = *str;
const char *pos = *str;
int cluster_width = 0;
utf8proc_ssize_t bytes;
if (policy != TREAT_STRING_AS_UTF8) {
/* Fall back to byte-based processing */
*str += 1;
return 1;
}
if (*pos == '\0') {
return 0;
}
/* Process codepoints until we find a grapheme boundary */
while (*pos != '\0') {
bytes = utf8proc_iterate((const utf8proc_uint8_t *)pos, -1, &codepoint);
if (bytes < 0) {
/* Invalid UTF-8, skip one byte */
*str = pos + 1;
return 1;
}
/* Check if this is a grapheme boundary */
if (pos != start && utf8proc_grapheme_break_stateful(prev_codepoint, codepoint, &state)) {
/* We found the end of the current cluster */
break;
}
/* Add this codepoint's width to the cluster */
if (unichar_isprint(codepoint)) {
int char_width = i_wcwidth(codepoint);
if (char_width > cluster_width) {
cluster_width = char_width;
}
}
prev_codepoint = codepoint;
pos += bytes;
}
*str = pos;
return cluster_width > 0 ? cluster_width : 1;
}
#endif
int string_advance(char const **str, int policy)
{
#ifdef HAVE_LIBUTF8PROC
return string_advance_with_grapheme_support(str, policy);
#else
if (policy == TREAT_STRING_AS_UTF8) {
gunichar c;
@ -43,6 +104,7 @@ int string_advance(char const **str, int policy)
return 1;
}
#endif
}
int string_policy(const char *str)
@ -133,3 +195,204 @@ int string_chars_for_width(const char *str, int policy, unsigned int n, unsigned
}
return char_count;
}
int unichar_width(unichar chr)
{
int width;
/* For individual codepoints, fall back to standard wcwidth.
* This is not grapheme-cluster aware, but better than nothing
* for GUI code that works with unichar arrays instead of UTF-8 strings.
*
* Note: For proper emoji support, use string_advance() on UTF-8 strings
* instead of processing individual codepoints.
*/
if (!unichar_isprint(chr))
return 1;
width = i_wcwidth(chr);
return width < 0 ? 1 : width;
}
int unichar_array_advance_cluster(const unichar *text, int text_len, int *pos)
{
#ifdef HAVE_LIBUTF8PROC
utf8proc_int32_t state = 0;
int cluster_width = 0;
utf8proc_int32_t first_codepoint, codepoint;
int char_width;
int has_variation_selector = 0;
if (*pos >= text_len) {
return 0;
}
/* Process first codepoint */
first_codepoint = text[*pos];
if (unichar_isprint(first_codepoint)) {
char_width = i_wcwidth(first_codepoint);
if (char_width > cluster_width) {
cluster_width = char_width;
}
}
(*pos)++;
/* Process additional codepoints until we find a grapheme boundary */
while (*pos < text_len) {
codepoint = text[*pos];
/* Check if this is a grapheme boundary */
if (utf8proc_grapheme_break_stateful(text[*pos - 1], codepoint, &state)) {
/* We found the end of the current cluster */
break;
}
/* Check for variation selector */
if (codepoint == 0xFE0F) {
has_variation_selector = 1;
}
/* Add this codepoint's width to the cluster (usually 0 for combining chars) */
if (unichar_isprint(codepoint)) {
char_width = i_wcwidth(codepoint);
if (char_width > cluster_width) {
cluster_width = char_width;
}
}
(*pos)++;
}
/* Special handling for emoji with variation selector */
if (has_variation_selector && cluster_width == 1) {
/* Base emoji (like ❣ U+2763, ♥ U+2665) + variation selector should have width 2 */
cluster_width = 2;
}
return cluster_width > 0 ? cluster_width : 1;
#else
/* Fall back to single character processing when utf8proc unavailable */
unichar chr;
int width;
if (*pos >= text_len) {
return 0;
}
chr = text[*pos];
(*pos)++;
/* Bounds check after increment */
if (*pos > text_len) {
*pos = text_len;
}
if (!unichar_isprint(chr))
return 1;
width = i_wcwidth(chr);
return width < 0 ? 1 : width;
#endif
}
int unichar_array_move_cluster_backward(const unichar *text, int text_len, int *pos)
{
#ifdef HAVE_LIBUTF8PROC
utf8proc_int32_t state = 0;
int cluster_start;
int cluster_width = 0;
int temp_pos;
if (*pos <= 0) {
return 0;
}
/* Move back one codepoint first */
(*pos)--;
/* Find the beginning of the current grapheme cluster by going backwards */
cluster_start = *pos;
/* Go back to find cluster boundary */
while (cluster_start > 0) {
/* Check if there's a grapheme boundary between previous char and current */
if (utf8proc_grapheme_break_stateful(text[cluster_start - 1], text[cluster_start], &state)) {
/* Found boundary, cluster starts here */
break;
}
cluster_start--;
}
/* Calculate width of this cluster */
temp_pos = cluster_start;
while (temp_pos < text_len && temp_pos < *pos + 1) {
unichar codepoint = text[temp_pos];
if (unichar_isprint(codepoint)) {
int char_width = i_wcwidth(codepoint);
if (char_width > cluster_width) {
cluster_width = char_width;
}
}
temp_pos++;
}
*pos = cluster_start;
return cluster_width > 0 ? cluster_width : 1;
#else
/* Fall back to single character processing when utf8proc unavailable */
unichar chr;
int width;
if (*pos <= 0) {
return 0;
}
(*pos)--;
chr = text[*pos];
if (!unichar_isprint(chr))
return 1;
width = i_wcwidth(chr);
return width < 0 ? 1 : width;
#endif
}
int unichar_array_find_cluster_start(const unichar *text, int text_len, int pos)
{
#ifdef HAVE_LIBUTF8PROC
utf8proc_int32_t state = 0;
int cluster_start = pos;
if (pos <= 0 || pos >= text_len) {
return pos;
}
/* Go back to find cluster boundary */
while (cluster_start > 0) {
/* Check if there's a grapheme boundary between previous char and current */
if (utf8proc_grapheme_break_stateful(text[cluster_start - 1], text[cluster_start], &state)) {
/* Found boundary, cluster starts here */
break;
}
cluster_start--;
}
return cluster_start;
#else
/* Fall back to single character processing when utf8proc unavailable */
return pos;
#endif
}
void utf8_init(void)
{
/* no-op */
}
void utf8_deinit(void)
{
/* Nothing to clean up currently */
}