From 169ba1a1503775521f526a577ee8b2db3c2a54e7 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Thu, 21 May 2026 12:12:14 +0800 Subject: [PATCH] tui: preserve ansi styling when truncating output --- docs/CHANGELOG.md | 2 + include/utf8.h | 7 +++ src/tui.c | 14 +++--- src/utf8.c | 109 +++++++++++++++++++++++++++++++++++++++++ tests/unit/test_utf8.c | 18 +++++++ 5 files changed, 142 insertions(+), 8 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7863e39..f9512ad 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -22,6 +22,8 @@ clients can discover common actions and troubleshooting paths in-product. - The GitHub workflow formerly named deploy now runs CI only; production deployment remains a manual operator action. +- Command output rendering now truncates ANSI-styled UTF-8 text without + counting escape sequences as visible width or cutting color codes. ## 2026-05-18 - Interactive input polish diff --git a/include/utf8.h b/include/utf8.h index b86cf74..d8c1be5 100644 --- a/include/utf8.h +++ b/include/utf8.h @@ -15,9 +15,16 @@ uint32_t utf8_decode(const char *str, int *bytes_read); /* Calculate display width of a UTF-8 string (considering CJK double-width) */ int utf8_string_width(const char *str); +/* Calculate display width while treating ANSI escape sequences as zero-width */ +int utf8_ansi_string_width(const char *str); + /* Truncate string to fit within max_width display characters */ void utf8_truncate(char *str, int max_width); +/* Truncate ANSI-styled UTF-8 text without cutting escape sequences */ +void utf8_ansi_truncate(const char *src, char *dst, size_t dst_size, + int max_width); + /* Count the number of UTF-8 characters in a string */ int utf8_strlen(const char *str); diff --git a/src/tui.c b/src/tui.c index 2a20461..77948bf 100644 --- a/src/tui.c +++ b/src/tui.c @@ -621,11 +621,14 @@ void tui_render_command_output(client_t *client) { /* Title */ const char *title = " COMMAND OUTPUT "; - int title_width = strlen(title); + char title_display[64]; + utf8_ansi_truncate(title, title_display, sizeof(title_display), rw); + int title_width = utf8_ansi_string_width(title_display); int padding = rw - title_width; if (padding < 0) padding = 0; - buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_REVERSE "%s", title); + buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_REVERSE "%s", + title_display); for (int i = 0; i < padding; i++) { buffer_append_bytes(buffer, sizeof(buffer), &pos, " ", 1); } @@ -642,12 +645,7 @@ void tui_render_command_output(client_t *client) { while (line && line_count < max_lines) { char truncated[1024]; - strncpy(truncated, line, sizeof(truncated) - 1); - truncated[sizeof(truncated) - 1] = '\0'; - - if (utf8_string_width(truncated) > rw) { - utf8_truncate(truncated, rw); - } + utf8_ansi_truncate(line, truncated, sizeof(truncated), rw); buffer_appendf(buffer, sizeof(buffer), &pos, "%s\r\n", truncated); line = strtok(NULL, "\n"); diff --git a/src/utf8.c b/src/utf8.c index 7d727ad..919e3f7 100644 --- a/src/utf8.c +++ b/src/utf8.c @@ -96,6 +96,49 @@ int utf8_string_width(const char *str) { return width; } +static const char *skip_ansi_sequence(const char *p) { + if (!p || *p != '\033') { + return p; + } + + if (p[1] == '[') { + const char *q = p + 2; + while (*q) { + unsigned char c = (unsigned char)*q; + if (c >= 0x40 && c <= 0x7E) { + return q + 1; + } + q++; + } + return p + 1; + } + + return p[1] ? p + 2 : p + 1; +} + +int utf8_ansi_string_width(const char *str) { + int width = 0; + int bytes_read; + const char *p = str; + + if (!str) { + return 0; + } + + while (*p != '\0') { + if (*p == '\033') { + p = skip_ansi_sequence(p); + continue; + } + + uint32_t codepoint = utf8_decode(p, &bytes_read); + width += utf8_char_width(codepoint); + p += bytes_read; + } + + return width; +} + /* Count the number of UTF-8 characters in a string */ int utf8_strlen(const char *str) { int count = 0; @@ -134,6 +177,72 @@ void utf8_truncate(char *str, int max_width) { *last_valid = '\0'; } +void utf8_ansi_truncate(const char *src, char *dst, size_t dst_size, + int max_width) { + int width = 0; + int bytes_read; + size_t pos = 0; + bool copied_ansi = false; + bool last_ansi_was_reset = false; + bool truncated = false; + const char *p = src; + + if (!dst || dst_size == 0) { + return; + } + + dst[0] = '\0'; + if (!src || max_width <= 0) { + return; + } + + while (*p != '\0') { + if (*p == '\033') { + const char *end = skip_ansi_sequence(p); + size_t len = (size_t)(end - p); + + if (pos + len >= dst_size) { + truncated = true; + break; + } + memcpy(dst + pos, p, len); + pos += len; + copied_ansi = true; + last_ansi_was_reset = + len == strlen(ANSI_RESET) && memcmp(p, ANSI_RESET, len) == 0; + p = end; + continue; + } + + uint32_t codepoint = utf8_decode(p, &bytes_read); + int char_width = utf8_char_width(codepoint); + + if (width + char_width > max_width) { + truncated = true; + break; + } + if (pos + (size_t)bytes_read >= dst_size) { + truncated = true; + break; + } + + memcpy(dst + pos, p, (size_t)bytes_read); + pos += (size_t)bytes_read; + width += char_width; + p += bytes_read; + } + + if (truncated && copied_ansi && !last_ansi_was_reset) { + size_t reset_len = strlen(ANSI_RESET); + if (pos + reset_len < dst_size) { + memcpy(dst + pos, ANSI_RESET, reset_len); + pos += reset_len; + } + } + + dst[pos] = '\0'; +} + /* Remove last UTF-8 character from string */ void utf8_remove_last_char(char *str) { int len = strlen(str); diff --git a/tests/unit/test_utf8.c b/tests/unit/test_utf8.c index 7f42f9e..d5a2b63 100644 --- a/tests/unit/test_utf8.c +++ b/tests/unit/test_utf8.c @@ -114,6 +114,22 @@ TEST(utf8_string_width_cjk_only) { assert(utf8_string_width("中文字符") == 8); } +TEST(utf8_ansi_string_width_ignores_escape_sequences) { + assert(utf8_ansi_string_width("\033[1;36mHello\033[0m") == 5); + assert(utf8_ansi_string_width("\033[31m支持\033[0m") == 4); + assert(utf8_ansi_string_width("A\033[7;33m中\033[0mB") == 4); +} + +TEST(utf8_ansi_truncate_preserves_escape_sequences) { + char out[64]; + + utf8_ansi_truncate("\033[31mHello世界\033[0m", out, sizeof(out), 7); + assert(strcmp(out, "\033[31mHello世\033[0m") == 0); + + utf8_ansi_truncate("A\033[7;33m中文\033[0mB", out, sizeof(out), 5); + assert(strcmp(out, "A\033[7;33m中文\033[0m") == 0); +} + /* Test backspace handling */ TEST(utf8_remove_last_char) { char buffer[256]; @@ -228,6 +244,8 @@ int main(void) { RUN_TEST(utf8_string_width_ascii); RUN_TEST(utf8_string_width_mixed); RUN_TEST(utf8_string_width_cjk_only); + RUN_TEST(utf8_ansi_string_width_ignores_escape_sequences); + RUN_TEST(utf8_ansi_truncate_preserves_escape_sequences); RUN_TEST(utf8_remove_last_char); RUN_TEST(utf8_remove_last_char_multibyte); RUN_TEST(utf8_remove_last_word);