Skip to content

Commit bc99c90

Browse files
authored
Merge pull request #831 from python-cmd2/align_text
Added text alignment functions
2 parents 0aac6ce + a4427a3 commit bc99c90

File tree

6 files changed

+352
-46
lines changed

6 files changed

+352
-46
lines changed

CHANGELOG.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
## 0.9.22 (TBD, 2019)
1+
## 0.9.22 (December 9, 2019)
22
* Bug Fixes
33
* Fixed bug where a redefined `ansi.style_error` was not being used in all `cmd2` files
44
* Enhancements
55
* Enabled line buffering when redirecting output to a file
6-
6+
* Added `align_left()`, `align_center()`, and `align_right()` to utils.py. All 3 of these functions support
7+
ANSI escape sequences and characters with display widths greater than 1. They wrap `align_text()` which
8+
is also in utils.py.
9+
710
## 0.9.21 (November 26, 2019)
811
* Bug Fixes
912
* Fixed bug where pipe processes were not being stopped by Ctrl-C

cmd2/cmd2.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2718,6 +2718,7 @@ def _print_topics(self, header: str, cmds: List[str], verbose: bool) -> None:
27182718
self.stdout.write("\n")
27192719

27202720
shortcuts_parser = DEFAULT_ARGUMENT_PARSER(description="List available shortcuts")
2721+
27212722
@with_argparser(shortcuts_parser)
27222723
def do_shortcuts(self, _: argparse.Namespace) -> None:
27232724
"""List available shortcuts"""
@@ -2727,13 +2728,15 @@ def do_shortcuts(self, _: argparse.Namespace) -> None:
27272728
self.poutput("Shortcuts for other commands:\n{}".format(result))
27282729

27292730
eof_parser = DEFAULT_ARGUMENT_PARSER(description="Called when <Ctrl>-D is pressed", epilog=INTERNAL_COMMAND_EPILOG)
2731+
27302732
@with_argparser(eof_parser)
27312733
def do_eof(self, _: argparse.Namespace) -> bool:
27322734
"""Called when <Ctrl>-D is pressed"""
27332735
# Return True to stop the command loop
27342736
return True
27352737

27362738
quit_parser = DEFAULT_ARGUMENT_PARSER(description="Exit this application")
2739+
27372740
@with_argparser(quit_parser)
27382741
def do_quit(self, _: argparse.Namespace) -> bool:
27392742
"""Exit this application"""
@@ -3215,6 +3218,7 @@ def do_run_pyscript(self, args: argparse.Namespace) -> Optional[bool]:
32153218
# Only include the do_ipy() method if IPython is available on the system
32163219
if ipython_available: # pragma: no cover
32173220
ipython_parser = DEFAULT_ARGUMENT_PARSER(description="Enter an interactive IPython shell")
3221+
32183222
@with_argparser(ipython_parser)
32193223
def do_ipy(self, _: argparse.Namespace) -> None:
32203224
"""Enter an interactive IPython shell"""
@@ -3223,6 +3227,7 @@ def do_ipy(self, _: argparse.Namespace) -> None:
32233227
'Run Python code from external files with: run filename.py\n')
32243228
exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0])
32253229

3230+
# noinspection PyUnusedLocal
32263231
def load_ipy(cmd2_app: Cmd, py_bridge: PyBridge):
32273232
"""
32283233
Embed an IPython shell in an environment that is restricted to only the variables in this function
@@ -3715,7 +3720,7 @@ class TestMyAppCase(Cmd2TestCase):
37153720
verinfo = ".".join(map(str, sys.version_info[:3]))
37163721
num_transcripts = len(transcripts_expanded)
37173722
plural = '' if len(transcripts_expanded) == 1 else 's'
3718-
self.poutput(ansi.style(utils.center_text('cmd2 transcript test', pad='='), bold=True))
3723+
self.poutput(ansi.style(utils.align_center(' cmd2 transcript test ', fill_char='='), bold=True))
37193724
self.poutput('platform {} -- Python {}, cmd2-{}, readline-{}'.format(sys.platform, verinfo, cmd2.__version__,
37203725
rl_type))
37213726
self.poutput('cwd: {}'.format(os.getcwd()))
@@ -3733,8 +3738,8 @@ class TestMyAppCase(Cmd2TestCase):
37333738
execution_time = time.time() - start_time
37343739
if test_results.wasSuccessful():
37353740
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='='))
3741+
finish_msg = ' {0} transcript{1} passed in {2:.3f} seconds '.format(num_transcripts, plural, execution_time)
3742+
finish_msg = ansi.style_success(utils.align_center(finish_msg, fill_char='='))
37383743
self.poutput(finish_msg)
37393744
else:
37403745
# 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: 149 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,151 @@ 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, alignment: TextAlignment, *, fill_char: str = ' ',
641+
width: Optional[int] = None, tab_width: int = 4) -> 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. If text has line breaks, then each line is aligned independently.
646+
647+
There are convenience wrappers around this function: align_left(), align_center(), and align_right()
648+
649+
:param text: text to align (can contain multiple lines)
650+
:param alignment: how to align the text
651+
:param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
652+
:param width: display width of the aligned text. Defaults to width of the terminal.
653+
:param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
654+
be converted to a space.
655+
:return: aligned text
656+
:raises: TypeError if fill_char is more than one character
657+
ValueError if text or fill_char contains an unprintable character
658+
"""
659+
import io
660+
import shutil
661+
662+
from . import ansi
663+
664+
# Handle tabs
665+
text = text.replace('\t', ' ' * tab_width)
666+
if fill_char == '\t':
667+
fill_char = ' '
668+
669+
if len(fill_char) != 1:
670+
raise TypeError("Fill character must be exactly one character long")
671+
672+
fill_char_width = ansi.ansi_safe_wcswidth(fill_char)
673+
if fill_char_width == -1:
674+
raise (ValueError("Fill character is an unprintable character"))
675+
676+
if text:
677+
lines = text.splitlines()
678+
else:
679+
lines = ['']
680+
681+
if width is None:
682+
width = shutil.get_terminal_size().columns
683+
684+
text_buf = io.StringIO()
685+
686+
for index, line in enumerate(lines):
687+
if index > 0:
688+
text_buf.write('\n')
689+
690+
# Use ansi_safe_wcswidth to support characters with display widths
691+
# greater than 1 as well as ANSI escape sequences
692+
line_width = ansi.ansi_safe_wcswidth(line)
693+
if line_width == -1:
694+
raise(ValueError("Text to align contains an unprintable character"))
695+
696+
# Check if line is wider than the desired final width
697+
if width <= line_width:
698+
text_buf.write(line)
699+
continue
700+
701+
# Calculate how wide each side of filling needs to be
702+
total_fill_width = width - line_width
703+
704+
if alignment == TextAlignment.LEFT:
705+
left_fill_width = 0
706+
right_fill_width = total_fill_width
707+
elif alignment == TextAlignment.CENTER:
708+
left_fill_width = total_fill_width // 2
709+
right_fill_width = total_fill_width - left_fill_width
710+
else:
711+
left_fill_width = total_fill_width
712+
right_fill_width = 0
713+
714+
# Determine how many fill characters are needed to cover the width
715+
left_fill = (left_fill_width // fill_char_width) * fill_char
716+
right_fill = (right_fill_width // fill_char_width) * fill_char
717+
718+
# In cases where the fill character display width didn't divide evenly into
719+
# the gaps being filled, pad the remainder with spaces.
720+
left_fill += ' ' * (left_fill_width - ansi.ansi_safe_wcswidth(left_fill))
721+
right_fill += ' ' * (right_fill_width - ansi.ansi_safe_wcswidth(right_fill))
722+
723+
text_buf.write(left_fill + line + right_fill)
724+
725+
return text_buf.getvalue()
726+
727+
728+
def align_left(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> str:
729+
"""
730+
Left align text for display within a given width. Supports characters with display widths greater than 1.
731+
ANSI escape sequences are safely ignored and do not count toward the display width. This means colored text is
732+
supported. If text has line breaks, then each line is aligned independently.
733+
734+
:param text: text to left align (can contain multiple lines)
735+
:param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
736+
:param width: display width of the aligned text. Defaults to width of the terminal.
737+
:param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
738+
be converted to a space.
739+
:return: left-aligned text
740+
:raises: TypeError if fill_char is more than one character
741+
ValueError if text or fill_char contains an unprintable character
742+
"""
743+
return align_text(text, TextAlignment.LEFT, fill_char=fill_char, width=width, tab_width=tab_width)
744+
745+
746+
def align_center(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> str:
747+
"""
748+
Center text for display within a given width. Supports characters with display widths greater than 1.
749+
ANSI escape sequences are safely ignored and do not count toward the display width. This means colored text is
750+
supported. If text has line breaks, then each line is aligned independently.
751+
752+
:param text: text to center (can contain multiple lines)
753+
:param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
754+
:param width: display width of the aligned text. Defaults to width of the terminal.
755+
:param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
756+
be converted to a space.
757+
:return: centered text
758+
:raises: TypeError if fill_char is more than one character
759+
ValueError if text or fill_char contains an unprintable character
760+
"""
761+
return align_text(text, TextAlignment.CENTER, fill_char=fill_char, width=width, tab_width=tab_width)
762+
763+
764+
def align_right(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> str:
765+
"""
766+
Right align 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. If text has line breaks, then each line is aligned independently.
769+
770+
:param text: text to right align (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-aligned text
776+
:raises: TypeError if fill_char is more than one character
777+
ValueError if text or fill_char contains an unprintable character
778+
"""
779+
return align_text(text, TextAlignment.RIGHT, fill_char=fill_char, width=width, tab_width=tab_width)

docs/api/utility_functions.rst

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ Utility Functions
99

1010
.. autofunction:: cmd2.decorators.categorize
1111

12-
.. autofunction:: cmd2.utils.center_text
12+
.. autofunction:: cmd2.utils.align_text
13+
14+
.. autofunction:: cmd2.utils.align_left
15+
16+
.. autofunction:: cmd2.utils.align_center
17+
18+
.. autofunction:: cmd2.utils.align_right
1319

1420
.. autofunction:: cmd2.utils.strip_quotes
1521

0 commit comments

Comments
 (0)