From 794b7622e0fd8d1f7eef9f936004ec35114fb19b Mon Sep 17 00:00:00 2001 From: anticipate218 <3481402509@qq.com> Date: Thu, 12 Nov 2026 03:22:54 +0800 Subject: [PATCH 1/9] Preserve SGR parameter order when mixing colon colors and resets Apply accumulated integer SGR parameters before each following colon color. Preserve valid reset/color ordering through the C-backed propagation and clipping APIs, with native and Python regression coverage. Assisted-by: OpenAI Codex --- libwcwidth/src/sgr.c | 146 +++++++++++++++++++----------------- libwcwidth/tests/test_sgr.c | 28 +++++++ tests/test_sgr_state.py | 22 ++++++ 3 files changed, 127 insertions(+), 69 deletions(-) diff --git a/libwcwidth/src/sgr.c b/libwcwidth/src/sgr.c index e2fa3c90..b84a1a37 100644 --- a/libwcwidth/src/sgr.c +++ b/libwcwidth/src/sgr.c @@ -108,78 +108,11 @@ set_color(int *dst, int *dst_len, const int *src, int src_len) } } -void -wcwidth_sgr_update(wcwidth_sgr_state_t *state, const char *sgr_params, size_t sgr_params_len) +static void +apply_integer_params(wcwidth_sgr_state_t *state, const int *params, int nparams) { - int stack_params[WCWIDTH_SGR_MAX_PARAMS]; - int *params = stack_params; - size_t params_cap = WCWIDTH_SGR_MAX_PARAMS; - int nparams = 0; - const char *p = sgr_params; - const char *end = sgr_params + sgr_params_len; int i; - /* Empty params is equivalent to "0" (reset): \x1b[m. */ - if (sgr_params_len == 0) { - *state = WCWIDTH_SGR_STATE_DEFAULT; - return; - } - - /* Phase 1: parse all semicolon-separated segments. - * Colon-separated tuples (ITU T.416 extended colors) are applied - * immediately. Plain integers are collected for phase 2. */ - while (p < end) { - const char *seg_end = p; - - while (seg_end < end && *seg_end != ';') { - seg_end++; - } - { - size_t seg_len = (size_t) (seg_end - p); - - if (seg_len == 0) { - if (!sgr_params_reserve(¶ms, stack_params, ¶ms_cap, nparams)) { - break; - } - params[nparams++] = 0; - } - else { - int has_colon = 0; - size_t j; - - for (j = 0; j < seg_len; j++) { - if (p[j] == ':') { - has_colon = 1; - break; - } - } - - if (has_colon) { - int tuple[WCWIDTH_SGR_COLOR_MAX]; - int n = parse_colon_tuple(p, seg_len, tuple, WCWIDTH_SGR_COLOR_MAX); - if (n >= 2 && tuple[0] == 38) { - set_color(state->fg, &state->fg_len, tuple, n); - } - else if (n >= 2 && tuple[0] == 48) { - set_color(state->bg, &state->bg_len, tuple, n); - } - } - else { - if (!sgr_params_reserve(¶ms, stack_params, ¶ms_cap, nparams)) { - break; - } - params[nparams++] = parse_int(p, seg_len); - } - } - } - - p = seg_end; - if (p < end && *p == ';') { - p++; - } - } - - /* Phase 2: apply integer parameters in order. */ for (i = 0; i < nparams; i++) { int val = params[i]; @@ -305,6 +238,81 @@ wcwidth_sgr_update(wcwidth_sgr_state_t *state, const char *sgr_params, size_t sg } } } +} + +void +wcwidth_sgr_update(wcwidth_sgr_state_t *state, const char *sgr_params, size_t sgr_params_len) +{ + int stack_params[WCWIDTH_SGR_MAX_PARAMS]; + int *params = stack_params; + size_t params_cap = WCWIDTH_SGR_MAX_PARAMS; + int nparams = 0; + const char *p = sgr_params; + const char *end = sgr_params + sgr_params_len; + + /* Empty params is equivalent to "0" (reset): \x1b[m. */ + if (sgr_params_len == 0) { + *state = WCWIDTH_SGR_STATE_DEFAULT; + return; + } + + /* Collect runs of integer parameters, applying each run before the next + * colon-separated color so resets and colors retain their original order. */ + while (p < end) { + const char *seg_end = p; + + while (seg_end < end && *seg_end != ';') { + seg_end++; + } + { + size_t seg_len = (size_t) (seg_end - p); + + if (seg_len == 0) { + if (!sgr_params_reserve(¶ms, stack_params, ¶ms_cap, nparams)) { + break; + } + params[nparams++] = 0; + } + else { + int has_colon = 0; + size_t j; + + for (j = 0; j < seg_len; j++) { + if (p[j] == ':') { + has_colon = 1; + break; + } + } + + if (has_colon) { + int tuple[WCWIDTH_SGR_COLOR_MAX]; + int n = parse_colon_tuple(p, seg_len, tuple, WCWIDTH_SGR_COLOR_MAX); + + apply_integer_params(state, params, nparams); + nparams = 0; + if (n >= 2 && tuple[0] == 38) { + set_color(state->fg, &state->fg_len, tuple, n); + } + else if (n >= 2 && tuple[0] == 48) { + set_color(state->bg, &state->bg_len, tuple, n); + } + } + else { + if (!sgr_params_reserve(¶ms, stack_params, ¶ms_cap, nparams)) { + break; + } + params[nparams++] = parse_int(p, seg_len); + } + } + } + + p = seg_end; + if (p < end && *p == ';') { + p++; + } + } + + apply_integer_params(state, params, nparams); if (params != stack_params) { free(params); diff --git a/libwcwidth/tests/test_sgr.c b/libwcwidth/tests/test_sgr.c index ee60a2a7..902f3303 100644 --- a/libwcwidth/tests/test_sgr.c +++ b/libwcwidth/tests/test_sgr.c @@ -211,6 +211,33 @@ TEST(attribute_and_color_codes) } } +TEST(mixed_color_parameter_order) +{ + static const char *const inputs[] = { + "0;38:2::255:0:0", "0;48:2::0:0:255", "31;38:5:196", "39;38:5:196", + "38;5;196;38:5:46", "38:5:196;38;5;46", "38:5:196;0", + }; + static const char *const expected[] = { + "\x1b[38:2:0:255:0:0m", + "\x1b[48:2:0:0:0:255m", + "\x1b[38;5;196m", + "\x1b[38;5;196m", + "\x1b[38;5;46m", + "\x1b[38;5;46m", + "", + }; + size_t i; + + for (i = 0; i < sizeof(inputs) / sizeof(inputs[0]); i++) { + wcwidth_sgr_state_t s = WCWIDTH_SGR_STATE_DEFAULT; + char buf[64]; + + wcwidth_sgr_update(&s, inputs[i], strlen(inputs[i])); + wcwidth_sgr_to_escape(&s, buf, sizeof(buf)); + ASSERT_STREQ(expected[i], buf); + } +} + int main(void) { @@ -221,5 +248,6 @@ main(void) RUN_TEST(to_escape_colon_form); RUN_TEST(propagate_basic); RUN_TEST(attribute_and_color_codes); + RUN_TEST(mixed_color_parameter_order); return test_summary(); } diff --git a/tests/test_sgr_state.py b/tests/test_sgr_state.py index 21be647e..bde377a9 100644 --- a/tests/test_sgr_state.py +++ b/tests/test_sgr_state.py @@ -9,6 +9,7 @@ import pytest # local +import wcwidth from wcwidth import clip, wrap from wcwidth.sgr_state import (_SGR_STATE_DEFAULT, _SGRState, @@ -171,6 +172,27 @@ def test_propagate_sgr_colon_format_color_is_restored(): (38, 2, 0, 255, 0, 0)) +@pytest.mark.parametrize('params,expected', [ + ('0;38:2::255:0:0', '\x1b[38:2:0:255:0:0m'), + ('0;48:2::0:0:255', '\x1b[48:2:0:0:0:255m'), + ('31;38:5:196', '\x1b[38;5;196m'), + ('39;38:5:196', '\x1b[38;5;196m'), + ('38;5;196;38:5:46', '\x1b[38;5;46m'), + ('38:5:196;38;5;46', '\x1b[38;5;46m'), + ('38:5:196;0', ''), +]) +@pytest.mark.parametrize('operation', ['propagate_sgr', 'clip']) +def test_mixed_sgr_parameters_keep_order(params, expected, operation): + """Apply resets and colors in order when semicolon and colon forms mix.""" + opening = f'\x1b[{params}m' + reset = '\x1b[0m' if expected else '' + if operation == 'propagate_sgr': + result = wcwidth.propagate_sgr([opening + 'red', 'text'])[1] + else: + result = clip(opening + 'red text', 4, 8) + assert result == expected + 'text' + reset + + def test_sgr_state_color_override(): """Newer color replaces older regardless of format.""" state = _sgr_state_update(_SGR_STATE_DEFAULT, '\x1b[38;5;208m') From fbb7275974e667939334d52e267356bcdcd1de4f Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Fri, 13 Nov 2026 09:59:36 -0500 Subject: [PATCH 2/9] support for xterm/kitty "hybrid" SGR tested --- libwcwidth/src/sgr.c | 434 ++++++++++++++++++------------------ libwcwidth/tests/test_sgr.c | 110 +++++++-- tests/test_sgr_state.py | 27 ++- wcwidth/sgr_state.py | 28 ++- 4 files changed, 351 insertions(+), 248 deletions(-) diff --git a/libwcwidth/src/sgr.c b/libwcwidth/src/sgr.c index b84a1a37..51ac7724 100644 --- a/libwcwidth/src/sgr.c +++ b/libwcwidth/src/sgr.c @@ -4,50 +4,23 @@ #include "wcwidth/sgr.h" #include -#include -#include #include #include #include -/* Stack capacity for collected SGR parameters; longer runs grow onto the - * heap. */ -#define WCWIDTH_SGR_MAX_PARAMS 64 - -/* - * Ensure *params* has room for one more entry, growing from the caller's - * stack array onto the heap on first overflow. Returns false on allocation - * failure, in which case the caller applies what it has already collected. - */ -static bool -sgr_params_reserve(int **params, int *stack_params, size_t *cap, int count) +/* One semicolon-delimited parameter: an integer or a colon-separated tuple. */ +typedef struct { - size_t new_cap; - int *grown; + int values[WCWIDTH_SGR_COLOR_MAX]; + int count; + bool tuple; +} sgr_token_t; - if ((size_t) count < *cap) { - return true; - } - if (*cap > SIZE_MAX / (2 * sizeof(int))) { - return false; - } - new_cap = *cap * 2; - if (*params == stack_params) { - grown = (int *) malloc(new_cap * sizeof(int)); - if (grown != NULL) { - memcpy(grown, stack_params, *cap * sizeof(int)); - } - } - else { - grown = (int *) realloc(*params, new_cap * sizeof(int)); - } - if (grown == NULL) { - return false; - } - *params = grown; - *cap = new_cap; - return true; -} +typedef struct +{ + const char *p; + const char *end; +} sgr_reader_t; const wcwidth_sgr_state_t WCWIDTH_SGR_STATE_DEFAULT = {0}; @@ -108,215 +81,248 @@ set_color(int *dst, int *dst_len, const int *src, int src_len) } } +static bool sgr_reader_next(sgr_reader_t *reader, sgr_token_t *token); +static bool sgr_read_extended_color(sgr_reader_t *reader, int base, int *color, int *color_len); + static void -apply_integer_params(wcwidth_sgr_state_t *state, const int *params, int nparams) +apply_integer_param(wcwidth_sgr_state_t *state, int val) { - int i; + if (val == 0) { + *state = WCWIDTH_SGR_STATE_DEFAULT; + } + else if (val == 1) { + state->bold = true; + } + else if (val == 2) { + state->dim = true; + } + else if (val == 3) { + state->italic = true; + } + else if (val == 4) { + state->underline = true; + } + else if (val == 5) { + state->blink = true; + } + else if (val == 6) { + state->rapid_blink = true; + } + else if (val == 7) { + state->inverse = true; + } + else if (val == 8) { + state->hidden = true; + } + else if (val == 9) { + state->strikethrough = true; + } + else if (val == 21) { + state->double_underline = true; + } + else if (val == 22) { + state->bold = false; + state->dim = false; + } + else if (val == 23) { + state->italic = false; + } + else if (val == 24) { + state->underline = false; + state->double_underline = false; + } + else if (val == 25) { + state->blink = false; + state->rapid_blink = false; + } + else if (val == 27) { + state->inverse = false; + } + else if (val == 28) { + state->hidden = false; + } + else if (val == 29) { + state->strikethrough = false; + } + else if ((val >= 30 && val <= 37) || (val >= 90 && val <= 97)) { + state->fg[0] = val; + state->fg_len = 1; + } + else if ((val >= 40 && val <= 47) || (val >= 100 && val <= 107)) { + state->bg[0] = val; + state->bg_len = 1; + } + else if (val == 39) { + state->fg_len = 0; + } + else if (val == 49) { + state->bg_len = 0; + } +} - for (i = 0; i < nparams; i++) { - int val = params[i]; +void +wcwidth_sgr_update(wcwidth_sgr_state_t *state, const char *sgr_params, size_t sgr_params_len) +{ + sgr_reader_t reader; + sgr_token_t token; - if (val == 0) { - *state = WCWIDTH_SGR_STATE_DEFAULT; - continue; - } + /* Empty params is equivalent to "0" (reset): \x1b[m. */ + if (sgr_params_len == 0) { + *state = WCWIDTH_SGR_STATE_DEFAULT; + return; + } - if (val == 1) { - state->bold = true; - } - else if (val == 2) { - state->dim = true; - } - else if (val == 3) { - state->italic = true; - } - else if (val == 4) { - state->underline = true; - } - else if (val == 5) { - state->blink = true; - } - else if (val == 6) { - state->rapid_blink = true; - } - else if (val == 7) { - state->inverse = true; - } - else if (val == 8) { - state->hidden = true; - } - else if (val == 9) { - state->strikethrough = true; - } - else if (val == 21) { - state->double_underline = true; - } - else if (val == 22) { - state->bold = false; - state->dim = false; - } - else if (val == 23) { - state->italic = false; - } - else if (val == 24) { - state->underline = false; - state->double_underline = false; - } - else if (val == 25) { - state->blink = false; - state->rapid_blink = false; - } - else if (val == 27) { - state->inverse = false; - } - else if (val == 28) { - state->hidden = false; - } - else if (val == 29) { - state->strikethrough = false; - } - else if ((val >= 30 && val <= 37) || (val >= 90 && val <= 97)) { - state->fg[0] = val; - state->fg_len = 1; - } - else if ((val >= 40 && val <= 47) || (val >= 100 && val <= 107)) { - state->bg[0] = val; - state->bg_len = 1; - } - else if (val == 39) { - state->fg_len = 0; - } - else if (val == 49) { - state->bg_len = 0; - } - else if (val == 38 && i + 2 < nparams) { - int mode = params[++i]; + reader.p = sgr_params; + reader.end = sgr_params + sgr_params_len; + + /* Apply parameters as they are read */ + while (sgr_reader_next(&reader, &token)) { + if (!token.tuple) { + int val = token.values[0]; - if (mode == 5 && i + 1 < nparams) { - int n = params[++i]; + if (val == 38 || val == 48) { + int color[WCWIDTH_SGR_COLOR_MAX]; + int color_len = 0; - state->fg[0] = 38; - state->fg[1] = 5; - state->fg[2] = n; - state->fg_len = 3; + if (sgr_read_extended_color(&reader, val, color, &color_len)) { + if (val == 38) { + set_color(state->fg, &state->fg_len, color, color_len); + } + else { + set_color(state->bg, &state->bg_len, color, color_len); + } + } } - else if (mode == 2 && i + 3 < nparams) { - int r = params[++i]; - int g = params[++i]; - int b = params[++i]; - - state->fg[0] = 38; - state->fg[1] = 2; - state->fg[2] = r; - state->fg[3] = g; - state->fg[4] = b; - state->fg_len = 5; + else { + apply_integer_param(state, val); } } - else if (val == 48 && i + 2 < nparams) { - int mode = params[++i]; - - if (mode == 5 && i + 1 < nparams) { - int n = params[++i]; - - state->bg[0] = 48; - state->bg[1] = 5; - state->bg[2] = n; - state->bg_len = 3; + else if (token.count >= 2 && (token.values[0] == 38 || token.values[0] == 48)) { + if (token.values[0] == 38) { + set_color(state->fg, &state->fg_len, token.values, token.count); } - else if (mode == 2 && i + 3 < nparams) { - int r = params[++i]; - int g = params[++i]; - int b = params[++i]; - - state->bg[0] = 48; - state->bg[1] = 2; - state->bg[2] = r; - state->bg[3] = g; - state->bg[4] = b; - state->bg_len = 5; + else { + set_color(state->bg, &state->bg_len, token.values, token.count); } } } } -void -wcwidth_sgr_update(wcwidth_sgr_state_t *state, const char *sgr_params, size_t sgr_params_len) +/* + * Read the next semicolon-delimited parameter. Empty parameters are zero. + * Returns false at the end of the string. + */ +static bool +sgr_reader_next(sgr_reader_t *reader, sgr_token_t *token) { - int stack_params[WCWIDTH_SGR_MAX_PARAMS]; - int *params = stack_params; - size_t params_cap = WCWIDTH_SGR_MAX_PARAMS; - int nparams = 0; - const char *p = sgr_params; - const char *end = sgr_params + sgr_params_len; + const char *seg_end; + size_t seg_len; + size_t i; + bool has_colon = false; - /* Empty params is equivalent to "0" (reset): \x1b[m. */ - if (sgr_params_len == 0) { - *state = WCWIDTH_SGR_STATE_DEFAULT; - return; + if (reader->p >= reader->end) { + return false; + } + + seg_end = reader->p; + while (seg_end < reader->end && *seg_end != ';') { + seg_end++; } + seg_len = (size_t) (seg_end - reader->p); - /* Collect runs of integer parameters, applying each run before the next - * colon-separated color so resets and colors retain their original order. */ - while (p < end) { - const char *seg_end = p; + token->count = 1; + token->tuple = false; + token->values[0] = 0; - while (seg_end < end && *seg_end != ';') { - seg_end++; + for (i = 0; i < seg_len; i++) { + if (reader->p[i] == ':') { + has_colon = true; + break; } - { - size_t seg_len = (size_t) (seg_end - p); + } + if (has_colon) { + token->tuple = true; + token->count = parse_colon_tuple(reader->p, seg_len, token->values, WCWIDTH_SGR_COLOR_MAX); + } + else if (seg_len > 0) { + token->values[0] = parse_int(reader->p, seg_len); + } - if (seg_len == 0) { - if (!sgr_params_reserve(¶ms, stack_params, ¶ms_cap, nparams)) { - break; - } - params[nparams++] = 0; - } - else { - int has_colon = 0; - size_t j; + reader->p = seg_end; + if (reader->p < reader->end && *reader->p == ';') { + reader->p++; + } + return true; +} - for (j = 0; j < seg_len; j++) { - if (p[j] == ':') { - has_colon = 1; - break; - } - } +/* + * Read the parameters of an extended color after its 38/48 code. + * + * xterm and kitty both accept the base as a separate parameter with a colon group for the mode and + * components, for example 38;5:196 and 38;2:1:2:3. See parse_extended_colors() in xterm's + * charproc.c and _parse_sgr() in kitty's vt-parser.c. + * + * Returns true when a complete color was read. On failure the parameters read are consumed. + */ +static bool +sgr_read_extended_color(sgr_reader_t *reader, int base, int *color, int *color_len) +{ + sgr_token_t token; + int mode; + + if (!sgr_reader_next(reader, &token)) { + return false; + } - if (has_colon) { - int tuple[WCWIDTH_SGR_COLOR_MAX]; - int n = parse_colon_tuple(p, seg_len, tuple, WCWIDTH_SGR_COLOR_MAX); + if (token.tuple) { + mode = token.values[0]; + if (mode == 5 && token.count == 2) { + color[0] = base; + color[1] = 5; + color[2] = token.values[1]; + *color_len = 3; + return true; + } + if (mode == 2 && (token.count == 4 || token.count == 5)) { + int i; - apply_integer_params(state, params, nparams); - nparams = 0; - if (n >= 2 && tuple[0] == 38) { - set_color(state->fg, &state->fg_len, tuple, n); - } - else if (n >= 2 && tuple[0] == 48) { - set_color(state->bg, &state->bg_len, tuple, n); - } - } - else { - if (!sgr_params_reserve(¶ms, stack_params, ¶ms_cap, nparams)) { - break; - } - params[nparams++] = parse_int(p, seg_len); - } + color[0] = base; + color[1] = 2; + for (i = 1; i < token.count; i++) { + color[i + 1] = token.values[i]; } + *color_len = token.count + 1; + return true; } + return false; + } - p = seg_end; - if (p < end && *p == ';') { - p++; + mode = token.values[0]; + if (mode == 5) { + if (!sgr_reader_next(reader, &token) || token.tuple) { + return false; } + color[0] = base; + color[1] = 5; + color[2] = token.values[0]; + *color_len = 3; + return true; } + if (mode == 2) { + int i; - apply_integer_params(state, params, nparams); - - if (params != stack_params) { - free(params); + color[0] = base; + color[1] = 2; + for (i = 0; i < 3; i++) { + if (!sgr_reader_next(reader, &token) || token.tuple) { + return false; + } + color[2 + i] = token.values[0]; + } + *color_len = 5; + return true; } + return false; } bool diff --git a/libwcwidth/tests/test_sgr.c b/libwcwidth/tests/test_sgr.c index 902f3303..a6c949fb 100644 --- a/libwcwidth/tests/test_sgr.c +++ b/libwcwidth/tests/test_sgr.c @@ -19,6 +19,64 @@ make_state(const char *sgr_esc) return s; } +/* + * Replace ESC with its visible spelling so assertion failures stay readable + * instead of being consumed by the terminal. + */ +static const char * +visible_escapes(const char *src, char *dst, size_t cap) +{ + size_t out = 0; + + if (cap == 0) { + return dst; + } + + while (*src != '\0' && out + 1 < cap) { + if (*src == '\x1b') { + if (out + 4 >= cap) { + break; + } + memcpy(dst + out, "\\x1b", 4); + out += 4; + } + else { + dst[out++] = *src; + } + src++; + } + dst[out] = '\0'; + return dst; +} + +typedef struct +{ + const char *input; + const char *expected; +} sgr_escape_case_t; + +static void +check_escapes(const sgr_escape_case_t *cases, size_t count) +{ + size_t i; + + for (i = 0; i < count; i++) { + wcwidth_sgr_state_t s = WCWIDTH_SGR_STATE_DEFAULT; + char buf[64]; + + wcwidth_sgr_update(&s, cases[i].input, strlen(cases[i].input)); + wcwidth_sgr_to_escape(&s, buf, sizeof(buf)); + if (strcmp(cases[i].expected, buf) != 0) { + char want[512]; + char got[512]; + + FAIL("input \"%s\": expected \"%s\", got \"%s\"", cases[i].input, + visible_escapes(cases[i].expected, want, sizeof(want)), + visible_escapes(buf, got, sizeof(got))); + } + } +} + TEST(update_basic) { wcwidth_sgr_state_t s = make_state("\x1b[1;31m"); @@ -211,31 +269,37 @@ TEST(attribute_and_color_codes) } } -TEST(mixed_color_parameter_order) +TEST(sgr_color_parameters) { - static const char *const inputs[] = { - "0;38:2::255:0:0", "0;48:2::0:0:255", "31;38:5:196", "39;38:5:196", - "38;5;196;38:5:46", "38:5:196;38;5;46", "38:5:196;0", - }; - static const char *const expected[] = { - "\x1b[38:2:0:255:0:0m", - "\x1b[48:2:0:0:0:255m", - "\x1b[38;5;196m", - "\x1b[38;5;196m", - "\x1b[38;5;46m", - "\x1b[38;5;46m", - "", + static const sgr_escape_case_t cases[] = { + {"0;38:2::255:0:0", "\x1b[38:2:0:255:0:0m"}, + {"0;48:2::0:0:255", "\x1b[48:2:0:0:0:255m"}, + {"31;38;5:196", "\x1b[38;5;196m"}, + {"39;38;5:196", "\x1b[38;5;196m"}, + {"38;5;196;38:5;46", "\x1b[38;5;46m"}, + {"38:5:196;38;5;46", "\x1b[38;5;46m"}, + {"38:5:196;0", ""}, + {"0;38;5:196", "\x1b[38;5;196m"}, + {"0;38;2:1:2:3", "\x1b[38;2;1;2;3m"}, + {"0;38;2:255:0:0", "\x1b[38;2;255;0;0m"}, + {"0;38;2:0:255:0:0", "\x1b[38:2:0:255:0:0m"}, + {"0;48;5:22", "\x1b[48;5;22m"}, + {"0;48;2:0:0:0:255", "\x1b[48:2:0:0:0:255m"}, + {"0;38;5:196:197", ""}, + {"0;38;2:1:2", ""}, + {"0;38;2;1:2:3", ""}, + {"0;38;5;196:197", ""}, + {"0;38;3:1:2:3", ""}, + {"38", ""}, + {"38;5", ""}, + {"38;2", ""}, + {"38;2;1", ""}, + {"38;2;1;2", ""}, + {"", ""}, + {"0;38;3;1", "\x1b[1m"}, }; - size_t i; - - for (i = 0; i < sizeof(inputs) / sizeof(inputs[0]); i++) { - wcwidth_sgr_state_t s = WCWIDTH_SGR_STATE_DEFAULT; - char buf[64]; - wcwidth_sgr_update(&s, inputs[i], strlen(inputs[i])); - wcwidth_sgr_to_escape(&s, buf, sizeof(buf)); - ASSERT_STREQ(expected[i], buf); - } + check_escapes(cases, sizeof(cases) / sizeof(cases[0])); } int @@ -248,6 +312,6 @@ main(void) RUN_TEST(to_escape_colon_form); RUN_TEST(propagate_basic); RUN_TEST(attribute_and_color_codes); - RUN_TEST(mixed_color_parameter_order); + RUN_TEST(sgr_color_parameters); return test_summary(); } diff --git a/tests/test_sgr_state.py b/tests/test_sgr_state.py index bde377a9..b31cbc62 100644 --- a/tests/test_sgr_state.py +++ b/tests/test_sgr_state.py @@ -176,21 +176,28 @@ def test_propagate_sgr_colon_format_color_is_restored(): ('0;38:2::255:0:0', '\x1b[38:2:0:255:0:0m'), ('0;48:2::0:0:255', '\x1b[48:2:0:0:0:255m'), ('31;38:5:196', '\x1b[38;5;196m'), - ('39;38:5:196', '\x1b[38;5;196m'), - ('38;5;196;38:5:46', '\x1b[38;5;46m'), + ('39;38;5:196', '\x1b[38;5;196m'), + ('38;5;196;38:5;46', '\x1b[38;5;46m'), ('38:5:196;38;5;46', '\x1b[38;5;46m'), ('38:5:196;0', ''), + ('0;38;5:196', '\x1b[38;5;196m'), + ('0;38;2:1:2:3', '\x1b[38;2;1;2;3m'), + ('0;38;2:255:0:0', '\x1b[38;2;255;0;0m'), + ('0;38;2:0:255:0:0', '\x1b[38:2:0:255:0:0m'), + ('0;48;5:22', '\x1b[48;5;22m'), + ('0;48;2:0:0:0:255', '\x1b[48:2:0:0:0:255m'), + ('0;38;5:196:197', ''), + ('0;38;2:1:2', ''), + ('0;38;2;1:2:3', ''), + ('0;38;5;196:197', ''), + ('0;38;3:1:2:3', ''), ]) -@pytest.mark.parametrize('operation', ['propagate_sgr', 'clip']) -def test_mixed_sgr_parameters_keep_order(params, expected, operation): - """Apply resets and colors in order when semicolon and colon forms mix.""" +def test_sgr_color_parameters(params, expected): + """Apply resets, colon colors, and xterm hybrids in stream order.""" opening = f'\x1b[{params}m' reset = '\x1b[0m' if expected else '' - if operation == 'propagate_sgr': - result = wcwidth.propagate_sgr([opening + 'red', 'text'])[1] - else: - result = clip(opening + 'red text', 4, 8) - assert result == expected + 'text' + reset + assert wcwidth.propagate_sgr([opening + 'red', 'text'])[1] == expected + 'text' + reset + assert clip(opening + 'red text', 4, 8) == expected + 'text' + reset def test_sgr_state_color_override(): diff --git a/wcwidth/sgr_state.py b/wcwidth/sgr_state.py index 447db038..026abdb7 100644 --- a/wcwidth/sgr_state.py +++ b/wcwidth/sgr_state.py @@ -167,6 +167,30 @@ def _sgr_state_to_sequence(state: _SGRState) -> str: return f'\x1b[{";".join(params)}m' +def _merge_hybrid_colors(params: list[int | tuple[int, ...]]) -> None: + """ + Merge xterm's base-plus-colon form into a single color tuple. + + xterm and kitty accept ``38;5:196`` and ``38;2:1:2:3``, where the base + code is a separate parameter and the mode and components form a colon + tuple. Only that documented shape is merged; all other forms are left + for the ordinary parsers. + + :param params: Parsed SGR parameters, modified in place. + """ + for index in range(len(params) - 1): + base, mode_item = params[index], params[index + 1] + if not isinstance(base, int) or base not in (_SGR.FG_EXTENDED, _SGR.BG_EXTENDED): + continue + if not isinstance(mode_item, tuple): + continue + mode, *components = mode_item + if mode == 5 and len(components) == 1: + params[index:index + 2] = [(base, 5, components[0])] + elif mode == 2 and len(components) in (3, 4): + params[index:index + 2] = [(base, 2, *components)] + + def _parse_sgr_params(sequence: str) -> list[int | tuple[int, ...]]: r""" Parse SGR sequence and return list of parameter values. @@ -174,7 +198,8 @@ def _parse_sgr_params(sequence: str) -> list[int | tuple[int, ...]]: Handles compound sequences like ``\x1b[1;31;4m`` -> [1, 31, 4]. Empty params (e.g., ``\x1b[m``) are treated as [0] (reset). Colon-separated extended colors like ``\x1b[38:2::255:0:0m`` are returned - as tuples: [(38, 2, 255, 0, 0)]. + as tuples: [(38, 2, 0, 255, 0, 0)]. xterm's base-plus-colon forms, + ``38;5:196`` and ``38;2:1:2:3``, are merged into the same shape. :param sequence: SGR escape sequence string. :returns: List of integer parameters or tuples for colon-separated colors. @@ -194,6 +219,7 @@ def _parse_sgr_params(sequence: str) -> list[int | tuple[int, ...]]: result.append(tuple(parts)) else: result.append(int(param) if param else 0) + _merge_hybrid_colors(result) return result From e8f2a7d0695b45767bad3ddd8f9e97dc65bf810a Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Fri, 13 Nov 2026 11:09:05 -0500 Subject: [PATCH 3/9] Remove the unreachable SGR propagate fallback in the C shim --- wcwidth/_wcwidth_c.c | 41 +++++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/wcwidth/_wcwidth_c.c b/wcwidth/_wcwidth_c.c index 8b047f67..c0e4a6cc 100644 --- a/wcwidth/_wcwidth_c.c +++ b/wcwidth/_wcwidth_c.c @@ -1167,28 +1167,25 @@ propagate_sgr_impl(PyObject *self, PyObject *const *args, Py_ssize_t nargs, PyOb c_line_lens[i] = (size_t)utf8_len; } - /* The C library can still fail for an SGR state beyond its reserved - * prefix space; fall back to the Python implementation in that case. */ - if (wcwidth_sgr_propagate(c_lines, c_line_lens, c_out_lens, (size_t)nlines) < 0) { - PyErr_Clear(); - result = call_python_function("wcwidth.sgr_state", "propagate_sgr", &st); - } else { - result = PyList_New(nlines); - if (result != NULL) { - for (i = 0; i < nlines; i++) { - PyObject *line = PyUnicode_FromStringAndSize(c_lines[i], - (Py_ssize_t)c_out_lens[i]); - if (line == NULL) { - Py_DECREF(result); - result = NULL; - break; - } - if (PyList_SetItem(result, i, line) < 0) { - /* SetItem() steals the reference whether or not it fails */ - Py_DECREF(result); - result = NULL; - break; - } + /* Cannot fail: sgr.c asserts at compile time that the longest restore + * prefix fits WCWIDTH_SGR_PROPAGATE_SPARE. */ + (void)wcwidth_sgr_propagate(c_lines, c_line_lens, c_out_lens, (size_t)nlines); + + result = PyList_New(nlines); + if (result != NULL) { + for (i = 0; i < nlines; i++) { + PyObject *line = + PyUnicode_FromStringAndSize(c_lines[i], (Py_ssize_t)c_out_lens[i]); + if (line == NULL) { + Py_DECREF(result); + result = NULL; + break; + } + if (PyList_SetItem(result, i, line) < 0) { + /* SetItem() steals the reference whether or not it fails */ + Py_DECREF(result); + result = NULL; + break; } } } From 91d5271b1f216715b222d8a7fa8a205db4a89aa3 Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Fri, 13 Nov 2026 11:11:21 -0500 Subject: [PATCH 4/9] Document the SGR parameter order fix in history --- docs/history.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/history.rst b/docs/history.rst index f4a828a5..e7bc6274 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -15,6 +15,9 @@ History * **Bugfix** `wrap()`_ of whitespace-only line should not discard run-on SGR attrs, `PR #310`_. * **Bugfix** `clip()`_ control character processing after an errant OSC 8 close, `PR #308`_. * **Bugfix** libwcwidth `wrap()`_ no longer crashes when a memory allocation fails, `PR #317`_. + * **Bugfix** SGR colon colors applied out of order in libwcwidth, so a leading ``0`` wiped the + color that followed, and xterm's hybrid ``38;5:N`` and ``38;2:R:G:B`` forms were not accepted, + `PR #321`_. * **Improved** `wrap()`_ better supports unlikely OSC 8 hyperlink metadata, `PR #325`_. * **Improved** performance of flag emojis by ~5.6x, `PR #304`_. * **Improved** performance of `clip()`_, `wrap()`_, and `propagate_sgr()`_ by caching SGR state @@ -361,6 +364,7 @@ https://www.cl.cam.ac.uk/~mgk25/ucs/wcwidth.c:: .. _`PR #310`: https://github.com/jquast/wcwidth/pull/310 .. _`PR #315`: https://github.com/jquast/wcwidth/pull/315 .. _`PR #317`: https://github.com/jquast/wcwidth/pull/317 +.. _`PR #321`: https://github.com/jquast/wcwidth/pull/321 .. _`PR #325`: https://github.com/jquast/wcwidth/pull/325 .. _`Issue #101`: https://github.com/jquast/wcwidth/issues/101 .. _`Issue #155`: https://github.com/jquast/wcwidth/issues/155 From 3689e1a5463d8ae6504ed527ae2d06e07ee99c5c Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Fri, 13 Nov 2026 11:12:47 -0500 Subject: [PATCH 5/9] document --- docs/history.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/history.rst b/docs/history.rst index e7bc6274..54f10678 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -16,8 +16,7 @@ History * **Bugfix** `clip()`_ control character processing after an errant OSC 8 close, `PR #308`_. * **Bugfix** libwcwidth `wrap()`_ no longer crashes when a memory allocation fails, `PR #317`_. * **Bugfix** SGR colon colors applied out of order in libwcwidth, so a leading ``0`` wiped the - color that followed, and xterm's hybrid ``38;5:N`` and ``38;2:R:G:B`` forms were not accepted, - `PR #321`_. + color that followed, and parse xterm/kitty's hybrid colon/semi-colon forms `PR #321`_. * **Improved** `wrap()`_ better supports unlikely OSC 8 hyperlink metadata, `PR #325`_. * **Improved** performance of flag emojis by ~5.6x, `PR #304`_. * **Improved** performance of `clip()`_, `wrap()`_, and `propagate_sgr()`_ by caching SGR state From d70fe9338f330be41618506eedeea69a877c6147 Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Fri, 13 Nov 2026 11:17:02 -0500 Subject: [PATCH 6/9] fix import lint --- tests/test_sgr_state.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_sgr_state.py b/tests/test_sgr_state.py index b31cbc62..a3b09ab9 100644 --- a/tests/test_sgr_state.py +++ b/tests/test_sgr_state.py @@ -9,7 +9,6 @@ import pytest # local -import wcwidth from wcwidth import clip, wrap from wcwidth.sgr_state import (_SGR_STATE_DEFAULT, _SGRState, @@ -196,7 +195,7 @@ def test_sgr_color_parameters(params, expected): """Apply resets, colon colors, and xterm hybrids in stream order.""" opening = f'\x1b[{params}m' reset = '\x1b[0m' if expected else '' - assert wcwidth.propagate_sgr([opening + 'red', 'text'])[1] == expected + 'text' + reset + assert propagate_sgr([opening + 'red', 'text'])[1] == expected + 'text' + reset assert clip(opening + 'red text', 4, 8) == expected + 'text' + reset From e086b8dcd3d06216def1ee41234d023f30c3f756 Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Fri, 13 Nov 2026 11:22:33 -0500 Subject: [PATCH 7/9] wrap() parity: Keep escapes that follow dropped whitespace in libwcwidth wrap() --- libwcwidth/src/textwrap.c | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/libwcwidth/src/textwrap.c b/libwcwidth/src/textwrap.c index 7a58bceb..1e273cc2 100644 --- a/libwcwidth/src/textwrap.c +++ b/libwcwidth/src/textwrap.c @@ -394,6 +394,29 @@ cl_prepend_last(chunklist_t *cl, const char *s, size_t n) return true; } +/* + * Append *s* to the last chunk of *cl*. Used when the chunk that held these sequences is dropped + * and no following chunk exists to carry them. + */ +static bool +cl_append_last(chunklist_t *cl, const char *s, size_t n) +{ + chunk_t *c; + char *nd; + WCWIDTH_ASSERT(cl->count > 0); + c = &cl->chunks[cl->count - 1]; + nd = malloc(c->len + n + 1); + if (nd == NULL) + return false; + memcpy(nd, c->data, c->len); + memcpy(nd + c->len, s, n); + nd[c->len + n] = '\0'; + free(c->data); + c->data = nd; + c->len += n; + return true; +} + static void cl_pop(chunklist_t *cl) { @@ -930,8 +953,12 @@ _wrap_chunks(chunklist_t *chunks, const wcwidth_wrap_opts_t *opts, lines_t *line dropped_whitespace = true; cur_w -= chunk_width(c->data, c->len, opts); cl_pop(&cur); - if (sq.len > 0 && chunks->count > 0) - cl_prepend_last(chunks, sq.data, sq.len); + if (sq.len > 0) { + if (cur.count > 0) + cl_append_last(&cur, sq.data, sq.len); + else if (chunks->count > 0) + cl_prepend_last(chunks, sq.data, sq.len); + } } sbuf_free(&st); sbuf_free(&sq); From c16cc9a119cdfe011752e5bafd7695f43f3af07b Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Fri, 13 Nov 2026 11:59:33 -0500 Subject: [PATCH 8/9] Name the failing test in C test output --- libwcwidth/tests/test_common.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libwcwidth/tests/test_common.h b/libwcwidth/tests/test_common.h index b7d7cae1..1199a86e 100644 --- a/libwcwidth/tests/test_common.h +++ b/libwcwidth/tests/test_common.h @@ -23,7 +23,8 @@ static const char *_current_test = NULL; #define FAIL(fmt, ...) \ do { \ - fprintf(stderr, "FAIL: %s:%d: " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__); \ + fprintf(stderr, "FAIL: %s: %s:%d: " fmt "\n", _current_test, __FILE__, __LINE__, \ + ##__VA_ARGS__); \ _tests_failed++; \ return; \ } while (0) From 8929a3d9cefa30e60e593cd3571f278ac89c5b66 Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Fri, 13 Nov 2026 11:59:33 -0500 Subject: [PATCH 9/9] Cover the trailing-escape drop arms in libwcwidth wrap() --- libwcwidth/tests/test_textwrap.c | 55 ++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/libwcwidth/tests/test_textwrap.c b/libwcwidth/tests/test_textwrap.c index c242ea98..20bb81e7 100644 --- a/libwcwidth/tests/test_textwrap.c +++ b/libwcwidth/tests/test_textwrap.c @@ -285,6 +285,57 @@ TEST(wrap_trailing_escape_after_dropped_space) free(out); } +TEST(wrap_escape_appended_to_last_chunk) +{ + char *out; + size_t len; + wcwidth_wrap_opts_t o = WCWIDTH_WRAP_OPTS_DEFAULT; + const char *exp[] = {"a b\x1b[0m"}; + + o.width = 5; + ASSERT_EQ(0, wcwidth_wrap_u8("a b \x1b[0m", 9, &o, &out, &len)); + check_lines(out, len, exp, 1); + free(out); +} + +TEST(wrap_escape_only_tail_dropped) +{ + char *out; + size_t len; + wcwidth_wrap_opts_t o = WCWIDTH_WRAP_OPTS_DEFAULT; + const char *exp[] = {"\x1b[31ma"}; + + o.width = 2; + ASSERT_EQ(0, wcwidth_wrap_u8("\x1b[31ma \x1b[0m", 12, &o, &out, &len)); + check_lines(out, len, exp, 1); + free(out); +} + +TEST(wrap_whitespace_only_with_escapes) +{ + char *out; + size_t len; + wcwidth_wrap_opts_t o = WCWIDTH_WRAP_OPTS_DEFAULT; + + o.width = 4; + ASSERT_EQ(0, wcwidth_wrap_u8(" \x1b[31m \x1b[0m", 15, &o, &out, &len)); + ASSERT_EQ((int64_t) 0, (int64_t) len); + free(out); +} + +TEST(wrap_escape_moves_to_next_line) +{ + char *out; + size_t len; + wcwidth_wrap_opts_t o = WCWIDTH_WRAP_OPTS_DEFAULT; + const char *exp[] = {"a", "\x1b[0mb"}; + + o.width = 3; + ASSERT_EQ(0, wcwidth_wrap_u8("a \x1b[0m b", 9, &o, &out, &len)); + check_lines(out, len, exp, 2); + free(out); +} + TEST(wrap_sgr_long_word) { char *out; @@ -851,6 +902,10 @@ main(void) RUN_TEST(wrap_unknown_escape); RUN_TEST(wrap_sequence_only_whitespace_line); RUN_TEST(wrap_trailing_escape_after_dropped_space); + RUN_TEST(wrap_escape_appended_to_last_chunk); + RUN_TEST(wrap_escape_only_tail_dropped); + RUN_TEST(wrap_whitespace_only_with_escapes); + RUN_TEST(wrap_escape_moves_to_next_line); RUN_TEST(wrap_max_lines_placeholder); RUN_TEST(wrap_drops_leading_whitespace_only_line); RUN_TEST(wrap_sgr_long_word);