1111import threading
1212import unicodedata
1313from enum import Enum
14- from typing import Any , Callable , Iterable , List , Optional , TextIO , Union
14+ from typing import Any , Callable , Dict , Iterable , List , Optional , TextIO , Union
1515
1616from . import constants
1717
@@ -682,8 +682,8 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ',
682682 width : Optional [int ] = None , tab_width : int = 4 , truncate : bool = False ) -> str :
683683 """
684684 Align text for display within a given width. Supports characters with display widths greater than 1.
685- ANSI style sequences are safely ignored and do not count toward the display width. This means colored text is
686- supported. If text has line breaks, then each line is aligned independently.
685+ ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned
686+ independently.
687687
688688 There are convenience wrappers around this function: align_left(), align_center(), and align_right()
689689
@@ -696,7 +696,7 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ',
696696 :param truncate: if True, then each line will be shortened to fit within the display width. The truncated
697697 portions are replaced by a '…' character. Defaults to False.
698698 :return: aligned text
699- :raises: TypeError if fill_char is more than one character
699+ :raises: TypeError if fill_char is more than one character (not including ANSI style sequences)
700700 ValueError if text or fill_char contains an unprintable character
701701 ValueError if width is less than 1
702702 """
@@ -716,7 +716,7 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ',
716716 if fill_char == '\t ' :
717717 fill_char = ' '
718718
719- if len (fill_char ) != 1 :
719+ if len (ansi . strip_style ( fill_char ) ) != 1 :
720720 raise TypeError ("Fill character must be exactly one character long" )
721721
722722 fill_char_width = ansi .style_aware_wcswidth (fill_char )
@@ -777,8 +777,8 @@ def align_left(text: str, *, fill_char: str = ' ', width: Optional[int] = None,
777777 tab_width : int = 4 , truncate : bool = False ) -> str :
778778 """
779779 Left align text for display within a given width. Supports characters with display widths greater than 1.
780- ANSI style sequences are safely ignored and do not count toward the display width. This means colored text is
781- supported. If text has line breaks, then each line is aligned independently.
780+ ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned
781+ independently.
782782
783783 :param text: text to left align (can contain multiple lines)
784784 :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
@@ -788,7 +788,7 @@ def align_left(text: str, *, fill_char: str = ' ', width: Optional[int] = None,
788788 :param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is
789789 replaced by a '…' character. Defaults to False.
790790 :return: left-aligned text
791- :raises: TypeError if fill_char is more than one character
791+ :raises: TypeError if fill_char is more than one character (not including ANSI style sequences)
792792 ValueError if text or fill_char contains an unprintable character
793793 ValueError if width is less than 1
794794 """
@@ -800,8 +800,8 @@ def align_center(text: str, *, fill_char: str = ' ', width: Optional[int] = None
800800 tab_width : int = 4 , truncate : bool = False ) -> str :
801801 """
802802 Center text for display within a given width. Supports characters with display widths greater than 1.
803- ANSI style sequences are safely ignored and do not count toward the display width. This means colored text is
804- supported. If text has line breaks, then each line is aligned independently.
803+ ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned
804+ independently.
805805
806806 :param text: text to center (can contain multiple lines)
807807 :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
@@ -811,7 +811,7 @@ def align_center(text: str, *, fill_char: str = ' ', width: Optional[int] = None
811811 :param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is
812812 replaced by a '…' character. Defaults to False.
813813 :return: centered text
814- :raises: TypeError if fill_char is more than one character
814+ :raises: TypeError if fill_char is more than one character (not including ANSI style sequences)
815815 ValueError if text or fill_char contains an unprintable character
816816 ValueError if width is less than 1
817817 """
@@ -823,8 +823,8 @@ def align_right(text: str, *, fill_char: str = ' ', width: Optional[int] = None,
823823 tab_width : int = 4 , truncate : bool = False ) -> str :
824824 """
825825 Right align text for display within a given width. Supports characters with display widths greater than 1.
826- ANSI style sequences are safely ignored and do not count toward the display width. This means colored text is
827- supported. If text has line breaks, then each line is aligned independently.
826+ ANSI style sequences do not count toward the display width. If text has line breaks, then each line is aligned
827+ independently.
828828
829829 :param text: text to right align (can contain multiple lines)
830830 :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
@@ -834,7 +834,7 @@ def align_right(text: str, *, fill_char: str = ' ', width: Optional[int] = None,
834834 :param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is
835835 replaced by a '…' character. Defaults to False.
836836 :return: right-aligned text
837- :raises: TypeError if fill_char is more than one character
837+ :raises: TypeError if fill_char is more than one character (not including ANSI style sequences)
838838 ValueError if text or fill_char contains an unprintable character
839839 ValueError if width is less than 1
840840 """
@@ -845,8 +845,15 @@ def align_right(text: str, *, fill_char: str = ' ', width: Optional[int] = None,
845845def truncate_line (line : str , max_width : int , * , tab_width : int = 4 ) -> str :
846846 """
847847 Truncate a single line to fit within a given display width. Any portion of the string that is truncated
848- is replaced by a '…' character. Supports characters with display widths greater than 1. ANSI style sequences are
849- safely ignored and do not count toward the display width. This means colored text is supported.
848+ is replaced by a '…' character. Supports characters with display widths greater than 1. ANSI style sequences
849+ do not count toward the display width.
850+
851+ If there are ANSI style sequences in the string after where truncation occurs, this function will append them
852+ to the returned string.
853+
854+ This is done to prevent issues caused in cases like: truncate_string(fg.blue + hello + fg.reset, 3)
855+ In this case, "hello" would be truncated before fg.reset resets the color from blue. Appending the remaining style
856+ sequences makes sure the style is in the same state had the entire string been printed.
850857
851858 :param line: text to truncate
852859 :param max_width: the maximum display width the resulting string is allowed to have
@@ -855,6 +862,7 @@ def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str:
855862 :raises: ValueError if text contains an unprintable character like a new line
856863 ValueError if max_width is less than 1
857864 """
865+ import io
858866 from . import ansi
859867
860868 # Handle tabs
@@ -866,12 +874,68 @@ def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str:
866874 if max_width < 1 :
867875 raise ValueError ("max_width must be at least 1" )
868876
869- if ansi .style_aware_wcswidth (line ) > max_width :
870- # Remove characters until we fit. Leave room for the ellipsis.
871- line = line [:max_width - 1 ]
872- while ansi .style_aware_wcswidth (line ) > max_width - 1 :
873- line = line [:- 1 ]
877+ if ansi .style_aware_wcswidth (line ) <= max_width :
878+ return line
879+
880+ # Find all style sequences in the line
881+ styles = get_styles_in_text (line )
882+
883+ # Add characters one by one and preserve all style sequences
884+ done = False
885+ index = 0
886+ total_width = 0
887+ truncated_buf = io .StringIO ()
888+
889+ while not done :
890+ # Check if a style sequence is at this index. These don't count toward display width.
891+ if index in styles :
892+ truncated_buf .write (styles [index ])
893+ style_len = len (styles [index ])
894+ styles .pop (index )
895+ index += style_len
896+ continue
897+
898+ char = line [index ]
899+ char_width = ansi .style_aware_wcswidth (char )
900+
901+ # This char will make the text too wide, add the ellipsis instead
902+ if char_width + total_width >= max_width :
903+ char = constants .HORIZONTAL_ELLIPSIS
904+ char_width = ansi .style_aware_wcswidth (char )
905+ done = True
906+
907+ total_width += char_width
908+ truncated_buf .write (char )
909+ index += 1
910+
911+ # Append remaining style sequences from original string
912+ truncated_buf .write ('' .join (styles .values ()))
913+
914+ return truncated_buf .getvalue ()
915+
916+
917+ def get_styles_in_text (text : str ) -> Dict [int , str ]:
918+ """
919+ Return an OrderedDict containing all ANSI style sequences found in a string
920+
921+ The structure of the dictionary is:
922+ key: index where sequences begins
923+ value: ANSI style sequence found at index in text
924+
925+ Keys are in ascending order
926+
927+ :param text: text to search for style sequences
928+ """
929+ from . import ansi
930+
931+ start = 0
932+ styles = collections .OrderedDict ()
874933
875- line += "\N{HORIZONTAL ELLIPSIS} "
934+ while True :
935+ match = ansi .ANSI_STYLE_RE .search (text , start )
936+ if match is None :
937+ break
938+ styles [match .start ()] = match .group ()
939+ start += len (match .group ())
876940
877- return line
941+ return styles
0 commit comments