Skip to content

Commit ad0e2ae

Browse files
authored
Merge pull request #886 from python-cmd2/truncate_with_style
truncate_line not handling style sequences
2 parents 8d9f97b + a00fd70 commit ad0e2ae

File tree

5 files changed

+139
-30
lines changed

5 files changed

+139
-30
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Bug Fixes
33
* Corrected issue where the actual new value was not always being printed in do_set. This occurred in cases where
44
the typed value differed from what the setter had converted it to.
5+
* Fixed bug where ANSI style sequences were not correctly handled in `utils.truncate_line()`.
56
* Enhancements
67
* Renamed set command's `-l/--long` flag to `-v/--verbose` for consistency with help and history commands.
78

cmd2/ansi.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ class bg(ColorBase):
118118
INTENSITY_BRIGHT = Style.BRIGHT
119119
INTENSITY_DIM = Style.DIM
120120
INTENSITY_NORMAL = Style.NORMAL
121+
121122
# ANSI style sequences not provided by colorama
122123
UNDERLINE_ENABLE = colorama.ansi.code_to_chars(4)
123124
UNDERLINE_DISABLE = colorama.ansi.code_to_chars(24)

cmd2/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515

1616
LINE_FEED = '\n'
1717

18+
# One character ellipsis
19+
HORIZONTAL_ELLIPSIS = '…'
20+
1821
DEFAULT_SHORTCUTS = {'?': 'help', '!': 'shell', '@': 'run_script', '@@': '_relative_run_script'}
1922

2023
# Used as the command name placeholder in disabled command messages.

cmd2/utils.py

Lines changed: 87 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import threading
1212
import unicodedata
1313
from 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

1616
from . 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,
845845
def 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

tests/test_utils.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import pytest
1111

1212
import cmd2.utils as cu
13+
from cmd2.constants import HORIZONTAL_ELLIPSIS
1314

1415
HELLO_WORLD = 'Hello, world!'
1516

@@ -297,7 +298,13 @@ def test_truncate_line():
297298
line = 'long'
298299
max_width = 3
299300
truncated = cu.truncate_line(line, max_width)
300-
assert truncated == 'lo\N{HORIZONTAL ELLIPSIS}'
301+
assert truncated == 'lo' + HORIZONTAL_ELLIPSIS
302+
303+
def test_truncate_line_already_fits():
304+
line = 'long'
305+
max_width = 4
306+
truncated = cu.truncate_line(line, max_width)
307+
assert truncated == line
301308

302309
def test_truncate_line_with_newline():
303310
line = 'fo\no'
@@ -315,20 +322,44 @@ def test_truncate_line_wide_text():
315322
line = '苹苹other'
316323
max_width = 6
317324
truncated = cu.truncate_line(line, max_width)
318-
assert truncated == '苹苹o\N{HORIZONTAL ELLIPSIS}'
325+
assert truncated == '苹苹o' + HORIZONTAL_ELLIPSIS
319326

320327
def test_truncate_line_split_wide_text():
321328
"""Test when truncation results in a string which is shorter than max_width"""
322329
line = '1苹2苹'
323330
max_width = 3
324331
truncated = cu.truncate_line(line, max_width)
325-
assert truncated == '1\N{HORIZONTAL ELLIPSIS}'
332+
assert truncated == '1' + HORIZONTAL_ELLIPSIS
326333

327334
def test_truncate_line_tabs():
328335
line = 'has\ttab'
329336
max_width = 9
330337
truncated = cu.truncate_line(line, max_width)
331-
assert truncated == 'has t\N{HORIZONTAL ELLIPSIS}'
338+
assert truncated == 'has t' + HORIZONTAL_ELLIPSIS
339+
340+
def test_truncate_with_style():
341+
from cmd2 import ansi
342+
343+
before_style = ansi.fg.blue + ansi.UNDERLINE_ENABLE
344+
after_style = ansi.fg.reset + ansi.UNDERLINE_DISABLE
345+
346+
# Style only before truncated text
347+
line = before_style + 'long'
348+
max_width = 3
349+
truncated = cu.truncate_line(line, max_width)
350+
assert truncated == before_style + 'lo' + HORIZONTAL_ELLIPSIS
351+
352+
# Style before and after truncated text
353+
line = before_style + 'long' + after_style
354+
max_width = 3
355+
truncated = cu.truncate_line(line, max_width)
356+
assert truncated == before_style + 'lo' + HORIZONTAL_ELLIPSIS + after_style
357+
358+
# Style only after truncated text
359+
line = 'long' + after_style
360+
max_width = 3
361+
truncated = cu.truncate_line(line, max_width)
362+
assert truncated == 'lo' + HORIZONTAL_ELLIPSIS + after_style
332363

333364
def test_align_text_fill_char_is_tab():
334365
text = 'foo'
@@ -337,6 +368,15 @@ def test_align_text_fill_char_is_tab():
337368
aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width)
338369
assert aligned == text + ' '
339370

371+
def test_align_text_fill_char_has_color():
372+
from cmd2 import ansi
373+
374+
text = 'foo'
375+
fill_char = ansi.fg.bright_yellow + '-' + ansi.fg.reset
376+
width = 5
377+
aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width)
378+
assert aligned == text + fill_char * 2
379+
340380
def test_align_text_width_is_too_small():
341381
text = 'foo'
342382
fill_char = '-'
@@ -351,7 +391,7 @@ def test_align_text_fill_char_is_too_long():
351391
with pytest.raises(TypeError):
352392
cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width)
353393

354-
def test_align_text_fill_char_is_unprintable():
394+
def test_align_text_fill_char_is_newline():
355395
text = 'foo'
356396
fill_char = '\n'
357397
width = 5
@@ -384,15 +424,15 @@ def test_align_text_wider_than_width_truncate():
384424
fill_char = '-'
385425
width = 8
386426
aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width, truncate=True)
387-
assert aligned == 'long te\N{HORIZONTAL ELLIPSIS}'
427+
assert aligned == 'long te' + HORIZONTAL_ELLIPSIS
388428

389429
def test_align_text_wider_than_width_truncate_add_fill():
390430
"""Test when truncation results in a string which is shorter than width and align_text adds filler"""
391431
text = '1苹2苹'
392432
fill_char = '-'
393433
width = 3
394434
aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width, truncate=True)
395-
assert aligned == '1\N{HORIZONTAL ELLIPSIS}-'
435+
assert aligned == '1' + HORIZONTAL_ELLIPSIS + fill_char
396436

397437
def test_align_text_has_unprintable():
398438
text = 'foo\x02'

0 commit comments

Comments
 (0)