tui: preserve ansi styling when truncating output

This commit is contained in:
m1ngsama 2026-05-21 12:12:14 +08:00
parent 67d21ad0e9
commit 169ba1a150
5 changed files with 142 additions and 8 deletions

View file

@ -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

View file

@ -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);

View file

@ -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");

View file

@ -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);

View file

@ -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);