Skip to content

Commit e4bc87d

Browse files
authored
Merge pull request #850 from python-cmd2/truncate_string
Truncate line
2 parents 2ffdefb + 2f52a84 commit e4bc87d

File tree

6 files changed

+164
-40
lines changed

6 files changed

+164
-40
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
* Enhancements
33
* Flushing stderr when setting the window title and printing alerts for better responsiveness in cases where
44
stderr is not unbuffered.
5+
* Added function to truncate a single line to fit within a given display width. `cmd2.utils.truncate_line`
6+
supports characters with display widths greater than 1 and ANSI style sequences.
7+
* Added line truncation support to `cmd2.utils` text alignment functions.
58

69
## 0.9.23 (January 9, 2020)
710
* Bug Fixes

cmd2/utils.py

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -638,7 +638,7 @@ class TextAlignment(Enum):
638638

639639

640640
def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ',
641-
width: Optional[int] = None, tab_width: int = 4) -> str:
641+
width: Optional[int] = None, tab_width: int = 4, truncate: bool = False) -> str:
642642
"""
643643
Align text for display within a given width. Supports characters with display widths greater than 1.
644644
ANSI style sequences are safely ignored and do not count toward the display width. This means colored text is
@@ -652,15 +652,24 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ',
652652
:param width: display width of the aligned text. Defaults to width of the terminal.
653653
:param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
654654
be converted to a space.
655+
:param truncate: if True, then each line will be shortened to fit within the display width. The truncated
656+
portions are replaced by a '…' character. Defaults to False.
655657
:return: aligned text
656658
:raises: TypeError if fill_char is more than one character
657659
ValueError if text or fill_char contains an unprintable character
660+
ValueError if width is less than 1
658661
"""
659662
import io
660663
import shutil
661664

662665
from . import ansi
663666

667+
if width is None:
668+
width = shutil.get_terminal_size().columns
669+
670+
if width < 1:
671+
raise ValueError("width must be at least 1")
672+
664673
# Handle tabs
665674
text = text.replace('\t', ' ' * tab_width)
666675
if fill_char == '\t':
@@ -678,23 +687,21 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ',
678687
else:
679688
lines = ['']
680689

681-
if width is None:
682-
width = shutil.get_terminal_size().columns
683-
684690
text_buf = io.StringIO()
685691

686692
for index, line in enumerate(lines):
687693
if index > 0:
688694
text_buf.write('\n')
689695

690-
# Use style_aware_wcswidth to support characters with display widths
691-
# greater than 1 as well as ANSI style sequences
696+
if truncate:
697+
line = truncate_line(line, width)
698+
692699
line_width = ansi.style_aware_wcswidth(line)
693700
if line_width == -1:
694701
raise(ValueError("Text to align contains an unprintable character"))
695702

696-
# Check if line is wider than the desired final width
697-
if width <= line_width:
703+
elif line_width >= width:
704+
# No need to add fill characters
698705
text_buf.write(line)
699706
continue
700707

@@ -725,7 +732,8 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ',
725732
return text_buf.getvalue()
726733

727734

728-
def align_left(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> str:
735+
def align_left(text: str, *, fill_char: str = ' ', width: Optional[int] = None,
736+
tab_width: int = 4, truncate: bool = False) -> str:
729737
"""
730738
Left align text for display within a given width. Supports characters with display widths greater than 1.
731739
ANSI style sequences are safely ignored and do not count toward the display width. This means colored text is
@@ -736,14 +744,19 @@ def align_left(text: str, *, fill_char: str = ' ', width: Optional[int] = None,
736744
:param width: display width of the aligned text. Defaults to width of the terminal.
737745
:param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
738746
be converted to a space.
747+
:param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is
748+
replaced by a '…' character. Defaults to False.
739749
:return: left-aligned text
740750
:raises: TypeError if fill_char is more than one character
741751
ValueError if text or fill_char contains an unprintable character
752+
ValueError if width is less than 1
742753
"""
743-
return align_text(text, TextAlignment.LEFT, fill_char=fill_char, width=width, tab_width=tab_width)
754+
return align_text(text, TextAlignment.LEFT, fill_char=fill_char, width=width,
755+
tab_width=tab_width, truncate=truncate)
744756

745757

746-
def align_center(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> str:
758+
def align_center(text: str, *, fill_char: str = ' ', width: Optional[int] = None,
759+
tab_width: int = 4, truncate: bool = False) -> str:
747760
"""
748761
Center text for display within a given width. Supports characters with display widths greater than 1.
749762
ANSI style sequences are safely ignored and do not count toward the display width. This means colored text is
@@ -754,14 +767,19 @@ def align_center(text: str, *, fill_char: str = ' ', width: Optional[int] = None
754767
:param width: display width of the aligned text. Defaults to width of the terminal.
755768
:param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
756769
be converted to a space.
770+
:param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is
771+
replaced by a '…' character. Defaults to False.
757772
:return: centered text
758773
:raises: TypeError if fill_char is more than one character
759774
ValueError if text or fill_char contains an unprintable character
775+
ValueError if width is less than 1
760776
"""
761-
return align_text(text, TextAlignment.CENTER, fill_char=fill_char, width=width, tab_width=tab_width)
777+
return align_text(text, TextAlignment.CENTER, fill_char=fill_char, width=width,
778+
tab_width=tab_width, truncate=truncate)
762779

763780

764-
def align_right(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> str:
781+
def align_right(text: str, *, fill_char: str = ' ', width: Optional[int] = None,
782+
tab_width: int = 4, truncate: bool = False) -> str:
765783
"""
766784
Right align text for display within a given width. Supports characters with display widths greater than 1.
767785
ANSI style sequences are safely ignored and do not count toward the display width. This means colored text is
@@ -772,8 +790,47 @@ def align_right(text: str, *, fill_char: str = ' ', width: Optional[int] = None,
772790
:param width: display width of the aligned text. Defaults to width of the terminal.
773791
:param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
774792
be converted to a space.
793+
:param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is
794+
replaced by a '…' character. Defaults to False.
775795
:return: right-aligned text
776796
:raises: TypeError if fill_char is more than one character
777797
ValueError if text or fill_char contains an unprintable character
798+
ValueError if width is less than 1
799+
"""
800+
return align_text(text, TextAlignment.RIGHT, fill_char=fill_char, width=width,
801+
tab_width=tab_width, truncate=truncate)
802+
803+
804+
def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str:
778805
"""
779-
return align_text(text, TextAlignment.RIGHT, fill_char=fill_char, width=width, tab_width=tab_width)
806+
Truncate a single line to fit within a given display width. Any portion of the string that is truncated
807+
is replaced by a '…' character. Supports characters with display widths greater than 1. ANSI style sequences are
808+
safely ignored and do not count toward the display width. This means colored text is supported.
809+
810+
:param line: text to truncate
811+
:param max_width: the maximum display width the resulting string is allowed to have
812+
:param tab_width: any tabs in the text will be replaced with this many spaces
813+
:return: line that has a display width less than or equal to width
814+
:raises: ValueError if text contains an unprintable character like a new line
815+
ValueError if max_width is less than 1
816+
"""
817+
from . import ansi
818+
819+
# Handle tabs
820+
line = line.replace('\t', ' ' * tab_width)
821+
822+
if ansi.style_aware_wcswidth(line) == -1:
823+
raise (ValueError("text contains an unprintable character"))
824+
825+
if max_width < 1:
826+
raise ValueError("max_width must be at least 1")
827+
828+
if ansi.style_aware_wcswidth(line) > max_width:
829+
# Remove characters until we fit. Leave room for the ellipsis.
830+
line = line[:max_width - 1]
831+
while ansi.style_aware_wcswidth(line) > max_width - 1:
832+
line = line[:-1]
833+
834+
line += "\N{HORIZONTAL ELLIPSIS}"
835+
836+
return line

docs/api/utility_functions.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ Utility Functions
1717

1818
.. autofunction:: cmd2.utils.align_right
1919

20+
.. autofunction:: cmd2.utils.truncate_line
21+
2022
.. autofunction:: cmd2.utils.strip_quotes
2123

2224
.. autofunction:: cmd2.utils.namedtuple_with_defaults

docs/features/generating_output.rst

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -140,17 +140,19 @@ the terminal or not.
140140
Aligning Text
141141
--------------
142142

143-
If you would like to generate output which is left, center, or right aligned within a
144-
specified width or the terminal width, the following functions can help:
143+
If you would like to generate output which is left, center, or right aligned
144+
within a specified width or the terminal width, the following functions can
145+
help:
145146

146147
- :meth:`cmd2.utils.align_left`
147148
- :meth:`cmd2.utils.align_center`
148149
- :meth:`cmd2.utils.align_right`
149150

150-
These functions differ from Python's string justifying functions in that they support
151-
characters with display widths greater than 1. Additionally, ANSI style sequences are safely
152-
ignored and do not count toward the display width. This means colored text is supported. If
153-
text has line breaks, then each line is aligned independently.
151+
These functions differ from Python's string justifying functions in that they
152+
support characters with display widths greater than 1. Additionally, ANSI style
153+
sequences are safely ignored and do not count toward the display width. This
154+
means colored text is supported. If text has line breaks, then each line is
155+
aligned independently.
154156

155157

156158

@@ -165,5 +167,5 @@ in the output to generate colors on the terminal.
165167

166168
The :meth:`cmd2.ansi.style_aware_wcswidth` function solves both of these
167169
problems. Pass it a string, and regardless of which Unicode characters and ANSI
168-
text style escape sequences it contains, it will tell you how many characters on the
169-
screen that string will consume when printed.
170+
text style escape sequences it contains, it will tell you how many characters
171+
on the screen that string will consume when printed.

docs/features/settings.rst

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ echo
7070
~~~~
7171

7272
If ``True``, each command the user issues will be repeated to the screen before
73-
it is executed. This is particularly useful when running scripts. This behavior
74-
does not occur when a running command at the prompt.
73+
it is executed. This is particularly useful when running scripts. This
74+
behavior does not occur when a running command at the prompt.
7575

7676

7777
editor
@@ -105,13 +105,14 @@ Allow access to your application in one of the
105105
max_completion_items
106106
~~~~~~~~~~~~~~~~~~~~
107107

108-
Maximum number of CompletionItems to display during tab completion. A CompletionItem
109-
is a special kind of tab-completion hint which displays both a value and description
110-
and uses one line for each hint. Tab complete the ``set`` command for an example.
108+
Maximum number of CompletionItems to display during tab completion. A
109+
CompletionItem is a special kind of tab-completion hint which displays both a
110+
value and description and uses one line for each hint. Tab complete the ``set``
111+
command for an example.
111112

112-
If the number of tab-completion hints exceeds ``max_completion_items``, then they will
113-
be displayed in the typical columnized format and will not include the description text
114-
of the CompletionItem.
113+
If the number of tab-completion hints exceeds ``max_completion_items``, then
114+
they will be displayed in the typical columnized format and will not include
115+
the description text of the CompletionItem.
115116

116117

117118
prompt

tests/test_utils.py

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -293,54 +293,113 @@ def test_context_flag_exit_err(context_flag):
293293
context_flag.__exit__()
294294

295295

296+
def test_truncate_line():
297+
line = 'long'
298+
max_width = 3
299+
truncated = cu.truncate_line(line, max_width)
300+
assert truncated == 'lo\N{HORIZONTAL ELLIPSIS}'
301+
302+
def test_truncate_line_with_newline():
303+
line = 'fo\no'
304+
max_width = 2
305+
with pytest.raises(ValueError):
306+
cu.truncate_line(line, max_width)
307+
308+
def test_truncate_line_width_is_too_small():
309+
line = 'foo'
310+
max_width = 0
311+
with pytest.raises(ValueError):
312+
cu.truncate_line(line, max_width)
313+
314+
def test_truncate_line_wide_text():
315+
line = '苹苹other'
316+
max_width = 6
317+
truncated = cu.truncate_line(line, max_width)
318+
assert truncated == '苹苹o\N{HORIZONTAL ELLIPSIS}'
319+
320+
def test_truncate_line_split_wide_text():
321+
"""Test when truncation results in a string which is shorter than max_width"""
322+
line = '1苹2苹'
323+
max_width = 3
324+
truncated = cu.truncate_line(line, max_width)
325+
assert truncated == '1\N{HORIZONTAL ELLIPSIS}'
326+
327+
def test_truncate_line_tabs():
328+
line = 'has\ttab'
329+
max_width = 9
330+
truncated = cu.truncate_line(line, max_width)
331+
assert truncated == 'has t\N{HORIZONTAL ELLIPSIS}'
332+
296333
def test_align_text_fill_char_is_tab():
297334
text = 'foo'
298335
fill_char = '\t'
299336
width = 5
300-
aligned = cu.align_text(text, fill_char=fill_char, width=width, alignment=cu.TextAlignment.LEFT)
337+
aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width)
301338
assert aligned == text + ' '
302339

340+
def test_align_text_width_is_too_small():
341+
text = 'foo'
342+
fill_char = '-'
343+
width = 0
344+
with pytest.raises(ValueError):
345+
cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width)
346+
303347
def test_align_text_fill_char_is_too_long():
304348
text = 'foo'
305349
fill_char = 'fill'
306350
width = 5
307351
with pytest.raises(TypeError):
308-
cu.align_text(text, fill_char=fill_char, width=width, alignment=cu.TextAlignment.LEFT)
352+
cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width)
309353

310354
def test_align_text_fill_char_is_unprintable():
311355
text = 'foo'
312356
fill_char = '\n'
313357
width = 5
314358
with pytest.raises(ValueError):
315-
cu.align_text(text, fill_char=fill_char, width=width, alignment=cu.TextAlignment.LEFT)
359+
cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width)
316360

317361
def test_align_text_has_tabs():
318362
text = '\t\tfoo'
319363
fill_char = '-'
320364
width = 10
321-
aligned = cu.align_text(text, fill_char=fill_char, width=width, alignment=cu.TextAlignment.LEFT, tab_width=2)
365+
aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width, tab_width=2)
322366
assert aligned == ' ' + 'foo' + '---'
323367

324368
def test_align_text_blank():
325369
text = ''
326370
fill_char = '-'
327371
width = 5
328-
aligned = cu.align_text(text, fill_char=fill_char, width=width, alignment=cu.TextAlignment.LEFT)
372+
aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width)
329373
assert aligned == fill_char * width
330374

331375
def test_align_text_wider_than_width():
332-
text = 'long'
376+
text = 'long text field'
333377
fill_char = '-'
334-
width = 3
335-
aligned = cu.align_text(text, fill_char=fill_char, width=width, alignment=cu.TextAlignment.LEFT)
378+
width = 8
379+
aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width)
336380
assert aligned == text
337381

382+
def test_align_text_wider_than_width_truncate():
383+
text = 'long text field'
384+
fill_char = '-'
385+
width = 8
386+
aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width, truncate=True)
387+
assert aligned == 'long te\N{HORIZONTAL ELLIPSIS}'
388+
389+
def test_align_text_wider_than_width_truncate_add_fill():
390+
"""Test when truncation results in a string which is shorter than width and align_text adds filler"""
391+
text = '1苹2苹'
392+
fill_char = '-'
393+
width = 3
394+
aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width, truncate=True)
395+
assert aligned == '1\N{HORIZONTAL ELLIPSIS}-'
396+
338397
def test_align_text_has_unprintable():
339398
text = 'foo\x02'
340399
fill_char = '-'
341400
width = 5
342401
with pytest.raises(ValueError):
343-
cu.align_text(text, fill_char=fill_char, width=width, alignment=cu.TextAlignment.LEFT)
402+
cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width)
344403

345404
def test_align_text_term_width():
346405
import shutil
@@ -351,7 +410,7 @@ def test_align_text_term_width():
351410
term_width = shutil.get_terminal_size().columns
352411
expected_fill = (term_width - ansi.style_aware_wcswidth(text)) * fill_char
353412

354-
aligned = cu.align_text(text, fill_char=fill_char, alignment=cu.TextAlignment.LEFT)
413+
aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char)
355414
assert aligned == text + expected_fill
356415

357416
def test_align_left():

0 commit comments

Comments
 (0)