diff --git a/lib/cli/Colors.php b/lib/cli/Colors.php index c8f5ab4..bba9f40 100644 --- a/lib/cli/Colors.php +++ b/lib/cli/Colors.php @@ -280,4 +280,96 @@ static public function getStringCache() { static public function clearStringCache() { self::$_string_cache = array(); } + + /** + * 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 + * + * 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. + * @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 - check for reset codes consistently + if ( preg_match( '/\x1b\[0m/', $part ) ) { + // Reset code (ESC[0m) + $active_color = ''; + } elseif ( preg_match( '/\x1b\[([0-9;]+)m/', $part, $matches ) ) { + // Non-reset color/formatting 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 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; + } + + 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..05d8b83 100644 --- a/tests/Test_Table_Ascii.php +++ b/tests/Test_Table_Ascii.php @@ -114,6 +114,45 @@ 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 + // The color codes are preserved across wrapped lines + $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 */