@@ -737,6 +737,87 @@ def __init__(
737
737
self .saved_redirecting = saved_redirecting
738
738
739
739
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
+
740
821
class TextAlignment (Enum ):
741
822
"""Horizontal text alignment"""
742
823
@@ -801,7 +882,7 @@ def align_text(
801
882
raise (ValueError ("Fill character is an unprintable character" ))
802
883
803
884
# 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.
805
886
fill_char_style_begin , fill_char_style_end = fill_char .split (stripped_fill_char )
806
887
807
888
if text :
@@ -811,10 +892,10 @@ def align_text(
811
892
812
893
text_buf = io .StringIO ()
813
894
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 ] = []
818
899
819
900
for index , line in enumerate (lines ):
820
901
if index > 0 :
@@ -827,8 +908,8 @@ def align_text(
827
908
if line_width == - 1 :
828
909
raise (ValueError ("Text to align contains an unprintable character" ))
829
910
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 () )
832
913
833
914
# Calculate how wide each side of filling needs to be
834
915
if line_width >= width :
@@ -858,7 +939,7 @@ def align_text(
858
939
right_fill += ' ' * (right_fill_width - ansi .style_aware_wcswidth (right_fill ))
859
940
860
941
# 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 :
862
943
if left_fill :
863
944
left_fill = ansi .TextStyle .RESET_ALL + fill_char_style_begin + left_fill + fill_char_style_end
864
945
left_fill += ansi .TextStyle .RESET_ALL
@@ -867,11 +948,12 @@ def align_text(
867
948
right_fill = ansi .TextStyle .RESET_ALL + fill_char_style_begin + right_fill + fill_char_style_end
868
949
right_fill += ansi .TextStyle .RESET_ALL
869
950
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 )
872
953
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 )
875
957
876
958
return text_buf .getvalue ()
877
959
@@ -985,7 +1067,7 @@ def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str:
985
1067
return line
986
1068
987
1069
# Find all style sequences in the line
988
- styles = get_styles_in_text (line )
1070
+ styles_dict = get_styles_dict (line )
989
1071
990
1072
# Add characters one by one and preserve all style sequences
991
1073
done = False
@@ -995,10 +1077,10 @@ def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str:
995
1077
996
1078
while not done :
997
1079
# 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 )
1002
1084
index += style_len
1003
1085
continue
1004
1086
@@ -1015,13 +1097,16 @@ def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str:
1015
1097
truncated_buf .write (char )
1016
1098
index += 1
1017
1099
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 ))
1020
1105
1021
1106
return truncated_buf .getvalue ()
1022
1107
1023
1108
1024
- def get_styles_in_text (text : str ) -> Dict [int , str ]:
1109
+ def get_styles_dict (text : str ) -> Dict [int , str ]:
1025
1110
"""
1026
1111
Return an OrderedDict containing all ANSI style sequences found in a string
1027
1112
0 commit comments