From 94ae4400be131c904517e6551edf2d41d41c645e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 13:10:35 +0000 Subject: [PATCH 1/6] Initial plan From 59deb21afb47c2044686dad50a613185fa679fde Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 13:56:01 +0000 Subject: [PATCH 2/6] Changes before error encountered Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- lib/cli/Colors.php | 149 +++++++++++++++++++++++++++++++++++++ lib/cli/table/Ascii.php | 23 ++++-- tests/Test_Table_Ascii.php | 38 ++++++++++ 3 files changed, 202 insertions(+), 8 deletions(-) diff --git a/lib/cli/Colors.php b/lib/cli/Colors.php index c8f5ab4..3ac6e89 100644 --- a/lib/cli/Colors.php +++ b/lib/cli/Colors.php @@ -280,4 +280,153 @@ static public function getStringCache() { static public function clearStringCache() { self::$_string_cache = array(); } + + /** + * Get the active color code at the end of a colorized string. + * + * This function extracts the last active color state from a string that contains + * color codes. It's used to maintain color continuity when wrapping text. + * + * @param string $string The string to analyze. + * @param bool $pre_colorized Whether the string contains ANSI codes (true) or color tokens (false). + * @return string The active color token/code or empty string if no color is active. + */ + static public function getActiveColor( $string, $pre_colorized = false ) { + $string = (string) $string; + + if ( $pre_colorized ) { + // For pre-colorized strings, we need to track ANSI escape codes + // Pattern: \x1b[...m where ... can be numbers separated by semicolons + $pattern = '/\x1b\[([0-9;]+)m/'; + $matches = array(); + preg_match_all( $pattern, $string, $matches, PREG_OFFSET_CAPTURE ); + + if ( empty( $matches[0] ) ) { + return ''; + } + + // Get the last ANSI code + $last_code = end( $matches[0] )[0]; + $last_params = end( $matches[1] )[0]; + + // If it's a reset code (0 or 0m), no color is active + if ( $last_params === '0' ) { + return ''; + } + + // Return the full ANSI code + return $last_code; + } else { + // Track the last seen color token + $last_color = ''; + + // Get all color tokens + $colors = self::getColors(); + + // Find all color tokens in the string + foreach ( $colors as $token => $value ) { + $pos = 0; + while ( ( $pos = strpos( $string, $token, $pos ) ) !== false ) { + // Make sure this isn't an escaped %% + if ( $pos === 0 || $string[ $pos - 1 ] !== '%' ) { + $last_color = $token; + } + $pos += strlen( $token ); + } + } + + // If the last color was a reset (%n or %N), return empty + if ( $last_color === '%n' || $last_color === '%N' ) { + return ''; + } + + return $last_color; + } + } + + /** + * Get the ANSI reset code. + * + * @return string The ANSI reset code. + */ + static public function getResetCode() { + return "\x1b[0m"; + } + + /** + * Wrap a pre-colorized string at a specific width, preserving color codes. + * + * This function wraps text that contains ANSI color codes, ensuring that: + * 1. Color codes are never split in the middle + * 2. Active colors are properly terminated and restored across line breaks + * 3. The wrapped segments maintain the correct display width + * + * @param string $string The string to wrap (with ANSI codes). + * @param int $width The maximum display width per line. + * @param string|bool $encoding Optional. The encoding of the string. Default false. + * @return array Array of wrapped string segments. + */ + static public function wrapPreColorized( $string, $width, $encoding = false ) { + $wrapped = array(); + $current_line = ''; + $current_width = 0; + $active_color = ''; + + // Pattern to match ANSI escape sequences + $ansi_pattern = '/(\x1b\[[0-9;]*m)/'; + + // Split the string into parts: ANSI codes and text + $parts = preg_split( $ansi_pattern, $string, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY ); + + foreach ( $parts as $part ) { + // Check if this part is an ANSI code + if ( preg_match( $ansi_pattern, $part ) ) { + // It's an ANSI code, add it to current line without counting width + $current_line .= $part; + + // Track the active color + if ( preg_match( '/\x1b\[0m/', $part ) ) { + // Reset code + $active_color = ''; + } elseif ( preg_match( '/\x1b\[([0-9;]+)m/', $part, $matches ) && $matches[1] !== '0' ) { + // Non-reset color code + $active_color = $part; + } + } else { + // It's text content, process it character by character + $text_length = \cli\safe_strlen( $part, $encoding ); + $offset = 0; + + while ( $offset < $text_length ) { + $char = \cli\safe_substr( $part, $offset, 1, false, $encoding ); + $char_width = \cli\strwidth( $char, $encoding ); + + // Check if adding this character would exceed the width + if ( $current_width + $char_width > $width && $current_width > 0 ) { + // Need to wrap - finish current line + if ( $active_color ) { + $current_line .= self::getResetCode(); + } + $wrapped[] = $current_line; + + // Start new line + $current_line = $active_color ? $active_color : ''; + $current_width = 0; + } + + // Add the character + $current_line .= $char; + $current_width += $char_width; + $offset++; + } + } + } + + // Add the last line if there's any content + if ( $current_line !== '' && $current_line !== $active_color ) { + $wrapped[] = $current_line; + } + + return $wrapped; + } } diff --git a/lib/cli/table/Ascii.php b/lib/cli/table/Ascii.php index 113c092..02222c4 100644 --- a/lib/cli/table/Ascii.php +++ b/lib/cli/table/Ascii.php @@ -146,14 +146,21 @@ public function row( array $row ) { $wrapped_lines = []; foreach ( $split_lines as $line ) { - do { - $wrapped_value = \cli\safe_substr( $line, 0, $col_width, true /*is_width*/, $encoding ); - $val_width = Colors::width( $wrapped_value, self::isPreColorized( $col ), $encoding ); - if ( $val_width ) { - $wrapped_lines[] = $wrapped_value; - $line = \cli\safe_substr( $line, \cli\safe_strlen( $wrapped_value, $encoding ), null /*length*/, false /*is_width*/, $encoding ); - } - } while ( $line ); + // Use the new color-aware wrapping for pre-colorized content + if ( self::isPreColorized( $col ) && Colors::width( $line, true, $encoding ) > $col_width ) { + $line_wrapped = Colors::wrapPreColorized( $line, $col_width, $encoding ); + $wrapped_lines = array_merge( $wrapped_lines, $line_wrapped ); + } else { + // For non-colorized content, use the original logic + do { + $wrapped_value = \cli\safe_substr( $line, 0, $col_width, true /*is_width*/, $encoding ); + $val_width = Colors::width( $wrapped_value, self::isPreColorized( $col ), $encoding ); + if ( $val_width ) { + $wrapped_lines[] = $wrapped_value; + $line = \cli\safe_substr( $line, \cli\safe_strlen( $wrapped_value, $encoding ), null /*length*/, false /*is_width*/, $encoding ); + } + } while ( $line ); + } } $row[ $col ] = array_shift( $wrapped_lines ); diff --git a/tests/Test_Table_Ascii.php b/tests/Test_Table_Ascii.php index 6eac675..b5df3d9 100644 --- a/tests/Test_Table_Ascii.php +++ b/tests/Test_Table_Ascii.php @@ -114,6 +114,44 @@ public function testDrawOneColumnColorDisabledTable() { $this->assertInOutEquals(array($headers, $rows), $output); } + /** + * Test that colorized text wraps correctly while maintaining color codes. + */ + public function testWrappedColorizedText() { + Colors::enable( true ); + $headers = array('Column 1', 'Column 2'); + $green_code = "\x1b\x5b\x33\x32\x3b\x31\x6d"; // Green + bright + $reset_code = "\x1b\x5b\x30\x6d"; // Reset + + // Create a long colorized string that will wrap + $long_text = Colors::colorize('%GThis is a long green text%n', true); + + $rows = array( + array('Short', $long_text), + ); + + // Expected output with wrapped text maintaining colors + $output = <<_instance->setHeaders($headers); + $this->_instance->setRows($rows); + $renderer = new Ascii([10, 12]); + $renderer->setConstraintWidth(30); + $this->_instance->setRenderer($renderer); + $this->_instance->setAsciiPreColorized(true); + $this->_instance->display(); + $this->assertOutFileEqualsWith($output); + } + /** * Checks that spacing and borders are handled correctly in table */ From 5dc813f487d96353069efa5dc2a217e6b9c15ca3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 14:28:45 +0000 Subject: [PATCH 3/6] Fix test for wrapped colorized text - update expected output Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- tests/Test_Table_Ascii.php | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/Test_Table_Ascii.php b/tests/Test_Table_Ascii.php index b5df3d9..05d8b83 100644 --- a/tests/Test_Table_Ascii.php +++ b/tests/Test_Table_Ascii.php @@ -131,14 +131,15 @@ public function testWrappedColorizedText() { ); // Expected output with wrapped text maintaining colors + // The color codes are preserved across wrapped lines $output = << Date: Mon, 19 Jan 2026 15:41:32 +0000 Subject: [PATCH 4/6] Remove unused getActiveColor function Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- lib/cli/Colors.php | 63 ---------------------------------------------- 1 file changed, 63 deletions(-) diff --git a/lib/cli/Colors.php b/lib/cli/Colors.php index 3ac6e89..2542796 100644 --- a/lib/cli/Colors.php +++ b/lib/cli/Colors.php @@ -281,69 +281,6 @@ static public function clearStringCache() { self::$_string_cache = array(); } - /** - * Get the active color code at the end of a colorized string. - * - * This function extracts the last active color state from a string that contains - * color codes. It's used to maintain color continuity when wrapping text. - * - * @param string $string The string to analyze. - * @param bool $pre_colorized Whether the string contains ANSI codes (true) or color tokens (false). - * @return string The active color token/code or empty string if no color is active. - */ - static public function getActiveColor( $string, $pre_colorized = false ) { - $string = (string) $string; - - if ( $pre_colorized ) { - // For pre-colorized strings, we need to track ANSI escape codes - // Pattern: \x1b[...m where ... can be numbers separated by semicolons - $pattern = '/\x1b\[([0-9;]+)m/'; - $matches = array(); - preg_match_all( $pattern, $string, $matches, PREG_OFFSET_CAPTURE ); - - if ( empty( $matches[0] ) ) { - return ''; - } - - // Get the last ANSI code - $last_code = end( $matches[0] )[0]; - $last_params = end( $matches[1] )[0]; - - // If it's a reset code (0 or 0m), no color is active - if ( $last_params === '0' ) { - return ''; - } - - // Return the full ANSI code - return $last_code; - } else { - // Track the last seen color token - $last_color = ''; - - // Get all color tokens - $colors = self::getColors(); - - // Find all color tokens in the string - foreach ( $colors as $token => $value ) { - $pos = 0; - while ( ( $pos = strpos( $string, $token, $pos ) ) !== false ) { - // Make sure this isn't an escaped %% - if ( $pos === 0 || $string[ $pos - 1 ] !== '%' ) { - $last_color = $token; - } - $pos += strlen( $token ); - } - } - - // If the last color was a reset (%n or %N), return empty - if ( $last_color === '%n' || $last_color === '%N' ) { - return ''; - } - - return $last_color; - } - } - /** * Get the ANSI reset code. * From 1f87a4bae6fb18c76e30b537be8df626a9a3e570 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 19 Jan 2026 17:19:19 +0100 Subject: [PATCH 5/6] Update lib/cli/Colors.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/cli/Colors.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/cli/Colors.php b/lib/cli/Colors.php index 2542796..1f80f03 100644 --- a/lib/cli/Colors.php +++ b/lib/cli/Colors.php @@ -359,8 +359,10 @@ static public function wrapPreColorized( $string, $width, $encoding = false ) { } } - // Add the last line if there's any content - if ( $current_line !== '' && $current_line !== $active_color ) { + // Add the last line if there's any displayable content + $visible_content = preg_replace( $ansi_pattern, '', $current_line ); + $visible_width = $visible_content !== null ? \cli\strwidth( $visible_content, $encoding ) : 0; + if ( $visible_width > 0 ) { $wrapped[] = $current_line; } From f0e1716dacd10503e0272659bcdf2097faa97076 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 17:23:15 +0000 Subject: [PATCH 6/6] Fix inconsistent reset code checking in wrapPreColorized - Remove strict string comparison ($matches[1] !== '0') in favor of consistent regex check - Add documentation about limitation: only tracks most recent ANSI code, not layered formatting - Simplify logic by treating all ANSI codes (except ESC[0m) as potential active colors Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- lib/cli/Colors.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/cli/Colors.php b/lib/cli/Colors.php index 1f80f03..bba9f40 100644 --- a/lib/cli/Colors.php +++ b/lib/cli/Colors.php @@ -298,6 +298,10 @@ static public function getResetCode() { * 2. Active colors are properly terminated and restored across line breaks * 3. The wrapped segments maintain the correct display width * + * Note: This implementation tracks only the most recent ANSI code and does not + * support layered formatting (e.g., bold + color). When multiple formatting + * codes are applied, only the last one will be preserved across line breaks. + * * @param string $string The string to wrap (with ANSI codes). * @param int $width The maximum display width per line. * @param string|bool $encoding Optional. The encoding of the string. Default false. @@ -321,12 +325,12 @@ static public function wrapPreColorized( $string, $width, $encoding = false ) { // It's an ANSI code, add it to current line without counting width $current_line .= $part; - // Track the active color + // Track the active color - check for reset codes consistently if ( preg_match( '/\x1b\[0m/', $part ) ) { - // Reset code + // Reset code (ESC[0m) $active_color = ''; - } elseif ( preg_match( '/\x1b\[([0-9;]+)m/', $part, $matches ) && $matches[1] !== '0' ) { - // Non-reset color code + } elseif ( preg_match( '/\x1b\[([0-9;]+)m/', $part, $matches ) ) { + // Non-reset color/formatting code $active_color = $part; } } else {