@@ -280,4 +280,153 @@ static public function getStringCache() {
280280 static public function clearStringCache () {
281281 self ::$ _string_cache = array ();
282282 }
283+
284+ /**
285+ * Get the active color code at the end of a colorized string.
286+ *
287+ * This function extracts the last active color state from a string that contains
288+ * color codes. It's used to maintain color continuity when wrapping text.
289+ *
290+ * @param string $string The string to analyze.
291+ * @param bool $pre_colorized Whether the string contains ANSI codes (true) or color tokens (false).
292+ * @return string The active color token/code or empty string if no color is active.
293+ */
294+ static public function getActiveColor ( $ string , $ pre_colorized = false ) {
295+ $ string = (string ) $ string ;
296+
297+ if ( $ pre_colorized ) {
298+ // For pre-colorized strings, we need to track ANSI escape codes
299+ // Pattern: \x1b[...m where ... can be numbers separated by semicolons
300+ $ pattern = '/\x1b\[([0-9;]+)m/ ' ;
301+ $ matches = array ();
302+ preg_match_all ( $ pattern , $ string , $ matches , PREG_OFFSET_CAPTURE );
303+
304+ if ( empty ( $ matches [0 ] ) ) {
305+ return '' ;
306+ }
307+
308+ // Get the last ANSI code
309+ $ last_code = end ( $ matches [0 ] )[0 ];
310+ $ last_params = end ( $ matches [1 ] )[0 ];
311+
312+ // If it's a reset code (0 or 0m), no color is active
313+ if ( $ last_params === '0 ' ) {
314+ return '' ;
315+ }
316+
317+ // Return the full ANSI code
318+ return $ last_code ;
319+ } else {
320+ // Track the last seen color token
321+ $ last_color = '' ;
322+
323+ // Get all color tokens
324+ $ colors = self ::getColors ();
325+
326+ // Find all color tokens in the string
327+ foreach ( $ colors as $ token => $ value ) {
328+ $ pos = 0 ;
329+ while ( ( $ pos = strpos ( $ string , $ token , $ pos ) ) !== false ) {
330+ // Make sure this isn't an escaped %%
331+ if ( $ pos === 0 || $ string [ $ pos - 1 ] !== '% ' ) {
332+ $ last_color = $ token ;
333+ }
334+ $ pos += strlen ( $ token );
335+ }
336+ }
337+
338+ // If the last color was a reset (%n or %N), return empty
339+ if ( $ last_color === '%n ' || $ last_color === '%N ' ) {
340+ return '' ;
341+ }
342+
343+ return $ last_color ;
344+ }
345+ }
346+
347+ /**
348+ * Get the ANSI reset code.
349+ *
350+ * @return string The ANSI reset code.
351+ */
352+ static public function getResetCode () {
353+ return "\x1b[0m " ;
354+ }
355+
356+ /**
357+ * Wrap a pre-colorized string at a specific width, preserving color codes.
358+ *
359+ * This function wraps text that contains ANSI color codes, ensuring that:
360+ * 1. Color codes are never split in the middle
361+ * 2. Active colors are properly terminated and restored across line breaks
362+ * 3. The wrapped segments maintain the correct display width
363+ *
364+ * @param string $string The string to wrap (with ANSI codes).
365+ * @param int $width The maximum display width per line.
366+ * @param string|bool $encoding Optional. The encoding of the string. Default false.
367+ * @return array Array of wrapped string segments.
368+ */
369+ static public function wrapPreColorized ( $ string , $ width , $ encoding = false ) {
370+ $ wrapped = array ();
371+ $ current_line = '' ;
372+ $ current_width = 0 ;
373+ $ active_color = '' ;
374+
375+ // Pattern to match ANSI escape sequences
376+ $ ansi_pattern = '/(\x1b\[[0-9;]*m)/ ' ;
377+
378+ // Split the string into parts: ANSI codes and text
379+ $ parts = preg_split ( $ ansi_pattern , $ string , -1 , PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY );
380+
381+ foreach ( $ parts as $ part ) {
382+ // Check if this part is an ANSI code
383+ if ( preg_match ( $ ansi_pattern , $ part ) ) {
384+ // It's an ANSI code, add it to current line without counting width
385+ $ current_line .= $ part ;
386+
387+ // Track the active color
388+ if ( preg_match ( '/\x1b\[0m/ ' , $ part ) ) {
389+ // Reset code
390+ $ active_color = '' ;
391+ } elseif ( preg_match ( '/\x1b\[([0-9;]+)m/ ' , $ part , $ matches ) && $ matches [1 ] !== '0 ' ) {
392+ // Non-reset color code
393+ $ active_color = $ part ;
394+ }
395+ } else {
396+ // It's text content, process it character by character
397+ $ text_length = \cli \safe_strlen ( $ part , $ encoding );
398+ $ offset = 0 ;
399+
400+ while ( $ offset < $ text_length ) {
401+ $ char = \cli \safe_substr ( $ part , $ offset , 1 , false , $ encoding );
402+ $ char_width = \cli \strwidth ( $ char , $ encoding );
403+
404+ // Check if adding this character would exceed the width
405+ if ( $ current_width + $ char_width > $ width && $ current_width > 0 ) {
406+ // Need to wrap - finish current line
407+ if ( $ active_color ) {
408+ $ current_line .= self ::getResetCode ();
409+ }
410+ $ wrapped [] = $ current_line ;
411+
412+ // Start new line
413+ $ current_line = $ active_color ? $ active_color : '' ;
414+ $ current_width = 0 ;
415+ }
416+
417+ // Add the character
418+ $ current_line .= $ char ;
419+ $ current_width += $ char_width ;
420+ $ offset ++;
421+ }
422+ }
423+ }
424+
425+ // Add the last line if there's any content
426+ if ( $ current_line !== '' && $ current_line !== $ active_color ) {
427+ $ wrapped [] = $ current_line ;
428+ }
429+
430+ return $ wrapped ;
431+ }
283432}
0 commit comments