@@ -737,6 +737,87 @@ def __init__(
737737 self .saved_redirecting = saved_redirecting
738738
739739
740+ def _remove_overridden_styles (styles_to_parse : List [str ]) -> List [str ]:
741+ """
742+ Utility function for align_text() / truncate_line() which filters a style list down
743+ to only those which would still be in effect if all were processed in order.
744+
745+ This is mainly used to reduce how many style strings are stored in memory when
746+ building large multiline strings with ANSI styles. We only need to carry over
747+ styles from previous lines that are still in effect.
748+
749+ :param styles_to_parse: list of styles to evaluate.
750+ :return: list of styles that are still in effect.
751+ """
752+ from . import (
753+ ansi ,
754+ )
755+
756+ class StyleState :
757+ """Keeps track of what text styles are enabled"""
758+
759+ def __init__ (self ) -> None :
760+ # Contains styles still in effect, keyed by their index in styles_to_parse
761+ self .style_dict : Dict [int , str ] = dict ()
762+
763+ # Indexes into style_dict
764+ self .reset_all : Optional [int ] = None
765+ self .fg : Optional [int ] = None
766+ self .bg : Optional [int ] = None
767+ self .intensity : Optional [int ] = None
768+ self .italic : Optional [int ] = None
769+ self .overline : Optional [int ] = None
770+ self .strikethrough : Optional [int ] = None
771+ self .underline : Optional [int ] = None
772+
773+ # Read the previous styles in order and keep track of their states
774+ style_state = StyleState ()
775+
776+ for index , style in enumerate (styles_to_parse ):
777+ # For styles types that we recognize, only keep their latest value from styles_to_parse.
778+ # All unrecognized style types will be retained and their order preserved.
779+ if style in (str (ansi .TextStyle .RESET_ALL ), str (ansi .TextStyle .ALT_RESET_ALL )):
780+ style_state = StyleState ()
781+ style_state .reset_all = index
782+ elif ansi .STD_FG_RE .match (style ) or ansi .EIGHT_BIT_FG_RE .match (style ) or ansi .RGB_FG_RE .match (style ):
783+ if style_state .fg is not None :
784+ style_state .style_dict .pop (style_state .fg )
785+ style_state .fg = index
786+ elif ansi .STD_BG_RE .match (style ) or ansi .EIGHT_BIT_BG_RE .match (style ) or ansi .RGB_BG_RE .match (style ):
787+ if style_state .bg is not None :
788+ style_state .style_dict .pop (style_state .bg )
789+ style_state .bg = index
790+ elif style in (
791+ str (ansi .TextStyle .INTENSITY_BOLD ),
792+ str (ansi .TextStyle .INTENSITY_DIM ),
793+ str (ansi .TextStyle .INTENSITY_NORMAL ),
794+ ):
795+ if style_state .intensity is not None :
796+ style_state .style_dict .pop (style_state .intensity )
797+ style_state .intensity = index
798+ elif style in (str (ansi .TextStyle .ITALIC_ENABLE ), str (ansi .TextStyle .ITALIC_DISABLE )):
799+ if style_state .italic is not None :
800+ style_state .style_dict .pop (style_state .italic )
801+ style_state .italic = index
802+ elif style in (str (ansi .TextStyle .OVERLINE_ENABLE ), str (ansi .TextStyle .OVERLINE_DISABLE )):
803+ if style_state .overline is not None :
804+ style_state .style_dict .pop (style_state .overline )
805+ style_state .overline = index
806+ elif style in (str (ansi .TextStyle .STRIKETHROUGH_ENABLE ), str (ansi .TextStyle .STRIKETHROUGH_DISABLE )):
807+ if style_state .strikethrough is not None :
808+ style_state .style_dict .pop (style_state .strikethrough )
809+ style_state .strikethrough = index
810+ elif style in (str (ansi .TextStyle .UNDERLINE_ENABLE ), str (ansi .TextStyle .UNDERLINE_DISABLE )):
811+ if style_state .underline is not None :
812+ style_state .style_dict .pop (style_state .underline )
813+ style_state .underline = index
814+
815+ # Store this style and its location in the dictionary
816+ style_state .style_dict [index ] = style
817+
818+ return list (style_state .style_dict .values ())
819+
820+
740821class TextAlignment (Enum ):
741822 """Horizontal text alignment"""
742823
@@ -801,7 +882,7 @@ def align_text(
801882 raise (ValueError ("Fill character is an unprintable character" ))
802883
803884 # Isolate the style chars before and after the fill character. We will use them when building sequences of
804- # of fill characters. Instead of repeating the style characters for each fill character, we'll wrap each sequence.
885+ # fill characters. Instead of repeating the style characters for each fill character, we'll wrap each sequence.
805886 fill_char_style_begin , fill_char_style_end = fill_char .split (stripped_fill_char )
806887
807888 if text :
@@ -811,10 +892,10 @@ def align_text(
811892
812893 text_buf = io .StringIO ()
813894
814- # ANSI style sequences that may affect future lines will be cancelled by the fill_char's style.
815- # To avoid this, we save the state of a line's style so we can restore it when beginning the next line.
816- # This also allows the lines to be used independently and still have their style. TableCreator does this.
817- aggregate_styles = ''
895+ # ANSI style sequences that may affect subsequent lines will be cancelled by the fill_char's style.
896+ # To avoid this, we save styles which are still in effect so we can restore them when beginning the next line.
897+ # This also allows lines to be used independently and still have their style. TableCreator does this.
898+ previous_styles : List [ str ] = []
818899
819900 for index , line in enumerate (lines ):
820901 if index > 0 :
@@ -827,8 +908,8 @@ def align_text(
827908 if line_width == - 1 :
828909 raise (ValueError ("Text to align contains an unprintable character" ))
829910
830- # Get the styles in this line
831- line_styles = get_styles_in_text ( line )
911+ # Get list of styles in this line
912+ line_styles = list ( get_styles_dict ( line ). values () )
832913
833914 # Calculate how wide each side of filling needs to be
834915 if line_width >= width :
@@ -858,7 +939,7 @@ def align_text(
858939 right_fill += ' ' * (right_fill_width - ansi .style_aware_wcswidth (right_fill ))
859940
860941 # Don't allow styles in fill characters and text to affect one another
861- if fill_char_style_begin or fill_char_style_end or aggregate_styles or line_styles :
942+ if fill_char_style_begin or fill_char_style_end or previous_styles or line_styles :
862943 if left_fill :
863944 left_fill = ansi .TextStyle .RESET_ALL + fill_char_style_begin + left_fill + fill_char_style_end
864945 left_fill += ansi .TextStyle .RESET_ALL
@@ -867,11 +948,12 @@ def align_text(
867948 right_fill = ansi .TextStyle .RESET_ALL + fill_char_style_begin + right_fill + fill_char_style_end
868949 right_fill += ansi .TextStyle .RESET_ALL
869950
870- # Write the line and restore any styles from previous lines
871- text_buf .write (left_fill + aggregate_styles + line + right_fill )
951+ # Write the line and restore styles from previous lines which are still in effect
952+ text_buf .write (left_fill + '' . join ( previous_styles ) + line + right_fill )
872953
873- # Update the aggregate with styles in this line
874- aggregate_styles += '' .join (line_styles .values ())
954+ # Update list of styles that are still in effect for the next line
955+ previous_styles .extend (line_styles )
956+ previous_styles = _remove_overridden_styles (previous_styles )
875957
876958 return text_buf .getvalue ()
877959
@@ -985,7 +1067,7 @@ def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str:
9851067 return line
9861068
9871069 # Find all style sequences in the line
988- styles = get_styles_in_text (line )
1070+ styles_dict = get_styles_dict (line )
9891071
9901072 # Add characters one by one and preserve all style sequences
9911073 done = False
@@ -995,10 +1077,10 @@ def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str:
9951077
9961078 while not done :
9971079 # Check if a style sequence is at this index. These don't count toward display width.
998- if index in styles :
999- truncated_buf .write (styles [index ])
1000- style_len = len (styles [index ])
1001- styles .pop (index )
1080+ if index in styles_dict :
1081+ truncated_buf .write (styles_dict [index ])
1082+ style_len = len (styles_dict [index ])
1083+ styles_dict .pop (index )
10021084 index += style_len
10031085 continue
10041086
@@ -1015,13 +1097,16 @@ def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str:
10151097 truncated_buf .write (char )
10161098 index += 1
10171099
1018- # Append remaining style sequences from original string
1019- truncated_buf .write ('' .join (styles .values ()))
1100+ # Filter out overridden styles from the remaining ones
1101+ remaining_styles = _remove_overridden_styles (list (styles_dict .values ()))
1102+
1103+ # Append the remaining styles to the truncated text
1104+ truncated_buf .write ('' .join (remaining_styles ))
10201105
10211106 return truncated_buf .getvalue ()
10221107
10231108
1024- def get_styles_in_text (text : str ) -> Dict [int , str ]:
1109+ def get_styles_dict (text : str ) -> Dict [int , str ]:
10251110 """
10261111 Return an OrderedDict containing all ANSI style sequences found in a string
10271112
0 commit comments