Skip to content

Commit cda57dc

Browse files
committed
Updated center_text to support ansi escape sequences and characters with display widths greater than 1.
Also added left and right justification functions.
1 parent 0aac6ce commit cda57dc

File tree

4 files changed

+167
-37
lines changed

4 files changed

+167
-37
lines changed

cmd2/cmd2.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3715,7 +3715,7 @@ class TestMyAppCase(Cmd2TestCase):
37153715
verinfo = ".".join(map(str, sys.version_info[:3]))
37163716
num_transcripts = len(transcripts_expanded)
37173717
plural = '' if len(transcripts_expanded) == 1 else 's'
3718-
self.poutput(ansi.style(utils.center_text('cmd2 transcript test', pad='='), bold=True))
3718+
self.poutput(ansi.style(utils.center_text(' cmd2 transcript test ', fill_char='='), bold=True))
37193719
self.poutput('platform {} -- Python {}, cmd2-{}, readline-{}'.format(sys.platform, verinfo, cmd2.__version__,
37203720
rl_type))
37213721
self.poutput('cwd: {}'.format(os.getcwd()))
@@ -3733,8 +3733,8 @@ class TestMyAppCase(Cmd2TestCase):
37333733
execution_time = time.time() - start_time
37343734
if test_results.wasSuccessful():
37353735
ansi.ansi_aware_write(sys.stderr, stream.read())
3736-
finish_msg = '{0} transcript{1} passed in {2:.3f} seconds'.format(num_transcripts, plural, execution_time)
3737-
finish_msg = ansi.style_success(utils.center_text(finish_msg, pad='='))
3736+
finish_msg = ' {0} transcript{1} passed in {2:.3f} seconds '.format(num_transcripts, plural, execution_time)
3737+
finish_msg = ansi.style_success(utils.center_text(finish_msg, fill_char='='))
37383738
self.poutput(finish_msg)
37393739
else:
37403740
# Strip off the initial traceback which isn't particularly useful for end users

cmd2/parsing.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313

1414

1515
def shlex_split(str_to_split: str) -> List[str]:
16-
"""A wrapper around shlex.split() that uses cmd2's preferred arguments.
16+
"""
17+
A wrapper around shlex.split() that uses cmd2's preferred arguments.
18+
This allows other classes to easily call split() the same way StatementParser does.
1719
18-
This allows other classes to easily call split() the same way StatementParser does
1920
:param str_to_split: the string being split
2021
:return: A list of tokens
2122
"""
@@ -26,8 +27,8 @@ def shlex_split(str_to_split: str) -> List[str]:
2627
class MacroArg:
2728
"""
2829
Information used to replace or unescape arguments in a macro value when the macro is resolved
29-
Normal argument syntax : {5}
30-
Escaped argument syntax: {{5}}
30+
Normal argument syntax: {5}
31+
Escaped argument syntax: {{5}}
3132
"""
3233
# The starting index of this argument in the macro value
3334
start_index = attr.ib(validator=attr.validators.instance_of(int))

cmd2/utils.py

Lines changed: 148 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55
import glob
66
import os
77
import re
8-
import shutil
98
import subprocess
109
import sys
1110
import threading
1211
import unicodedata
12+
from enum import Enum
1313
from typing import Any, Iterable, List, Optional, TextIO, Union
1414

1515
from . import constants
@@ -363,21 +363,6 @@ def get_exes_in_path(starts_with: str) -> List[str]:
363363
return list(exes_set)
364364

365365

366-
def center_text(msg: str, *, pad: str = ' ') -> str:
367-
"""Centers text horizontally for display within the current terminal, optionally padding both sides.
368-
369-
:param msg: message to display in the center
370-
:param pad: if provided, the first character will be used to pad both sides of the message
371-
:return: centered message, optionally padded on both sides with pad_char
372-
"""
373-
term_width = shutil.get_terminal_size().columns
374-
surrounded_msg = ' {} '.format(msg)
375-
if not pad:
376-
pad = ' '
377-
fill_char = pad[:1]
378-
return surrounded_msg.center(term_width, fill_char)
379-
380-
381366
class StdSim(object):
382367
"""
383368
Class to simulate behavior of sys.stdout or sys.stderr.
@@ -644,3 +629,150 @@ def basic_complete(text: str, line: str, begidx: int, endidx: int, match_against
644629
:return: a list of possible tab completions
645630
"""
646631
return [cur_match for cur_match in match_against if cur_match.startswith(text)]
632+
633+
634+
class TextAlignment(Enum):
635+
LEFT = 1
636+
CENTER = 2
637+
RIGHT = 3
638+
639+
640+
def align_text(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4,
641+
alignment: TextAlignment) -> str:
642+
"""
643+
Align text for display within a given width. Supports characters with display widths greater than 1.
644+
ANSI escape sequences are safely ignored and do not count toward the display width. This means colored text is
645+
supported. Each line in text will be aligned independently.
646+
647+
There are convenience wrappers around this function: ljustify_text(), center_text(), and rjustify_text()
648+
649+
:param text: text to align (Can contain multiple lines)
650+
:param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
651+
:param width: display width of the aligned text. Defaults to width of the terminal.
652+
:param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
653+
be converted to a space.
654+
:param alignment: how to align the text
655+
:return: aligned text
656+
:raises: ValueError if text or fill_char contains an unprintable character
657+
TypeError if fill_char is more than one character
658+
659+
"""
660+
import io
661+
import shutil
662+
663+
from . import ansi
664+
665+
# Handle tabs
666+
text.replace('\t', ' ' * tab_width)
667+
if fill_char == '\t':
668+
fill_char = ' '
669+
670+
if len(fill_char) != 1:
671+
raise ValueError("Fill character must be exactly one character long")
672+
673+
fill_char_width = ansi.ansi_safe_wcswidth(fill_char)
674+
if fill_char_width == -1:
675+
raise (ValueError("Fill character is an unprintable character"))
676+
677+
if text:
678+
lines = text.splitlines()
679+
else:
680+
lines = ['']
681+
682+
text_buf = io.StringIO()
683+
684+
for index, line in enumerate(lines):
685+
if index > 0:
686+
text_buf.write('\n')
687+
688+
# Use ansi_safe_wcswidth to support characters with display widths greater than 1
689+
# as well as ANSI escape sequences
690+
line_width = ansi.ansi_safe_wcswidth(line)
691+
if line_width == -1:
692+
# This can happen if text contains characters like newlines or tabs
693+
raise(ValueError("Text to align contains an unprintable character"))
694+
695+
if width is None:
696+
width = shutil.get_terminal_size().columns
697+
698+
# Check if line is wider than the desired final width
699+
if width <= line_width:
700+
text_buf.write(line)
701+
continue
702+
703+
# Calculate how wide each side of filling needs to be
704+
total_fill_width = width - line_width
705+
706+
if alignment == TextAlignment.LEFT:
707+
left_fill_width = 0
708+
right_fill_width = total_fill_width
709+
elif alignment == TextAlignment.CENTER:
710+
left_fill_width = total_fill_width // 2
711+
right_fill_width = total_fill_width - left_fill_width
712+
else:
713+
left_fill_width = total_fill_width
714+
right_fill_width = 0
715+
716+
# Determine how many fill characters are needed to cover the width
717+
left_fill = (left_fill_width // fill_char_width) * fill_char
718+
right_fill = (right_fill_width // fill_char_width) * fill_char
719+
720+
# In cases where the fill character display width didn't divide evenly into
721+
# the gaps being filled, pad the remainder with spaces.
722+
left_fill += ' ' * (left_fill_width - ansi.ansi_safe_wcswidth(left_fill))
723+
right_fill += ' ' * (right_fill_width - ansi.ansi_safe_wcswidth(right_fill))
724+
725+
text_buf.write(left_fill + line + right_fill)
726+
727+
return text_buf.getvalue()
728+
729+
730+
def ljustify_text(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> str:
731+
"""
732+
Left justify text for display within a given width. Supports characters with display widths greater than 1.
733+
ANSI escape sequences are safely ignored and do not count toward the display width. This means colored text is
734+
supported. Each line in text will be aligned independently.
735+
736+
:param text: text to left justify (Can contain multiple lines)
737+
:param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
738+
:param width: display width of the aligned text. Defaults to width of the terminal.
739+
:param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
740+
be converted to a space.
741+
:return: left-justified text
742+
"""
743+
return align_text(text, fill_char=fill_char, width=width,
744+
tab_width=tab_width, alignment=TextAlignment.LEFT)
745+
746+
747+
def center_text(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> str:
748+
"""
749+
Center text for display within a given width. Supports characters with display widths greater than 1.
750+
ANSI escape sequences are safely ignored and do not count toward the display width. This means colored text is
751+
supported. Each line in text will be aligned independently.
752+
753+
:param text: text to center (Can contain multiple lines)
754+
:param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
755+
:param width: display width of the aligned text. Defaults to width of the terminal.
756+
:param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
757+
be converted to a space.
758+
:return: centered text
759+
"""
760+
return align_text(text, fill_char=fill_char, width=width,
761+
tab_width=tab_width, alignment=TextAlignment.CENTER)
762+
763+
764+
def rjustify_text(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> str:
765+
"""
766+
Right justify text for display within a given width. Supports characters with display widths greater than 1.
767+
ANSI escape sequences are safely ignored and do not count toward the display width. This means colored text is
768+
supported. Each line in text will be aligned independently.
769+
770+
:param text: text to right justify (Can contain multiple lines)
771+
:param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
772+
:param width: display width of the aligned text. Defaults to width of the terminal.
773+
:param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
774+
be converted to a space.
775+
:return: right-justified text
776+
"""
777+
return align_text(text, fill_char=fill_char, width=width,
778+
tab_width=tab_width, alignment=TextAlignment.RIGHT)

tests/test_utils.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -293,24 +293,21 @@ def test_context_flag_exit_err(context_flag):
293293
context_flag.__exit__()
294294

295295

296-
def test_center_text_pad_none():
296+
def test_center_text_pad_equals():
297297
msg = 'foo'
298-
centered = cu.center_text(msg, pad=None)
299-
expected_center = ' ' + msg + ' '
300-
assert expected_center in centered
298+
fill_char = '='
299+
centered = cu.center_text(msg, fill_char=fill_char)
300+
assert msg in centered
301+
assert centered.startswith(fill_char)
302+
assert centered.endswith(fill_char)
301303
letters_in_centered = set(centered)
302304
letters_in_msg = set(msg)
303305
assert len(letters_in_centered) == len(letters_in_msg) + 1
304306

305-
def test_center_text_pad_equals():
307+
308+
def test_center_text_pad_blank():
306309
msg = 'foo'
307-
pad = '='
308-
centered = cu.center_text(msg, pad=pad)
309-
expected_center = ' ' + msg + ' '
310-
assert expected_center in centered
311-
assert centered.startswith(pad)
312-
assert centered.endswith(pad)
313-
letters_in_centered = set(centered)
314-
letters_in_msg = set(msg)
315-
assert len(letters_in_centered) == len(letters_in_msg) + 2
310+
fill_char = ''
316311

312+
with pytest.raises(ValueError):
313+
cu.center_text(msg, fill_char=fill_char)

0 commit comments

Comments
 (0)