Skip to content

Commit

Permalink
Merge pull request #831 from python-cmd2/align_text
Browse files Browse the repository at this point in the history
Added text alignment functions
  • Loading branch information
kmvanbrunt authored Dec 10, 2019
2 parents 0aac6ce + a4427a3 commit bc99c90
Show file tree
Hide file tree
Showing 6 changed files with 352 additions and 46 deletions.
7 changes: 5 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
## 0.9.22 (TBD, 2019)
## 0.9.22 (December 9, 2019)
* Bug Fixes
* Fixed bug where a redefined `ansi.style_error` was not being used in all `cmd2` files
* Enhancements
* Enabled line buffering when redirecting output to a file

* Added `align_left()`, `align_center()`, and `align_right()` to utils.py. All 3 of these functions support
ANSI escape sequences and characters with display widths greater than 1. They wrap `align_text()` which
is also in utils.py.

## 0.9.21 (November 26, 2019)
* Bug Fixes
* Fixed bug where pipe processes were not being stopped by Ctrl-C
Expand Down
11 changes: 8 additions & 3 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -2718,6 +2718,7 @@ def _print_topics(self, header: str, cmds: List[str], verbose: bool) -> None:
self.stdout.write("\n")

shortcuts_parser = DEFAULT_ARGUMENT_PARSER(description="List available shortcuts")

@with_argparser(shortcuts_parser)
def do_shortcuts(self, _: argparse.Namespace) -> None:
"""List available shortcuts"""
Expand All @@ -2727,13 +2728,15 @@ def do_shortcuts(self, _: argparse.Namespace) -> None:
self.poutput("Shortcuts for other commands:\n{}".format(result))

eof_parser = DEFAULT_ARGUMENT_PARSER(description="Called when <Ctrl>-D is pressed", epilog=INTERNAL_COMMAND_EPILOG)

@with_argparser(eof_parser)
def do_eof(self, _: argparse.Namespace) -> bool:
"""Called when <Ctrl>-D is pressed"""
# Return True to stop the command loop
return True

quit_parser = DEFAULT_ARGUMENT_PARSER(description="Exit this application")

@with_argparser(quit_parser)
def do_quit(self, _: argparse.Namespace) -> bool:
"""Exit this application"""
Expand Down Expand Up @@ -3215,6 +3218,7 @@ def do_run_pyscript(self, args: argparse.Namespace) -> Optional[bool]:
# Only include the do_ipy() method if IPython is available on the system
if ipython_available: # pragma: no cover
ipython_parser = DEFAULT_ARGUMENT_PARSER(description="Enter an interactive IPython shell")

@with_argparser(ipython_parser)
def do_ipy(self, _: argparse.Namespace) -> None:
"""Enter an interactive IPython shell"""
Expand All @@ -3223,6 +3227,7 @@ def do_ipy(self, _: argparse.Namespace) -> None:
'Run Python code from external files with: run filename.py\n')
exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0])

# noinspection PyUnusedLocal
def load_ipy(cmd2_app: Cmd, py_bridge: PyBridge):
"""
Embed an IPython shell in an environment that is restricted to only the variables in this function
Expand Down Expand Up @@ -3715,7 +3720,7 @@ class TestMyAppCase(Cmd2TestCase):
verinfo = ".".join(map(str, sys.version_info[:3]))
num_transcripts = len(transcripts_expanded)
plural = '' if len(transcripts_expanded) == 1 else 's'
self.poutput(ansi.style(utils.center_text('cmd2 transcript test', pad='='), bold=True))
self.poutput(ansi.style(utils.align_center(' cmd2 transcript test ', fill_char='='), bold=True))
self.poutput('platform {} -- Python {}, cmd2-{}, readline-{}'.format(sys.platform, verinfo, cmd2.__version__,
rl_type))
self.poutput('cwd: {}'.format(os.getcwd()))
Expand All @@ -3733,8 +3738,8 @@ class TestMyAppCase(Cmd2TestCase):
execution_time = time.time() - start_time
if test_results.wasSuccessful():
ansi.ansi_aware_write(sys.stderr, stream.read())
finish_msg = '{0} transcript{1} passed in {2:.3f} seconds'.format(num_transcripts, plural, execution_time)
finish_msg = ansi.style_success(utils.center_text(finish_msg, pad='='))
finish_msg = ' {0} transcript{1} passed in {2:.3f} seconds '.format(num_transcripts, plural, execution_time)
finish_msg = ansi.style_success(utils.align_center(finish_msg, fill_char='='))
self.poutput(finish_msg)
else:
# Strip off the initial traceback which isn't particularly useful for end users
Expand Down
9 changes: 5 additions & 4 deletions cmd2/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@


def shlex_split(str_to_split: str) -> List[str]:
"""A wrapper around shlex.split() that uses cmd2's preferred arguments.
"""
A wrapper around shlex.split() that uses cmd2's preferred arguments.
This allows other classes to easily call split() the same way StatementParser does.
This allows other classes to easily call split() the same way StatementParser does
:param str_to_split: the string being split
:return: A list of tokens
"""
Expand All @@ -26,8 +27,8 @@ def shlex_split(str_to_split: str) -> List[str]:
class MacroArg:
"""
Information used to replace or unescape arguments in a macro value when the macro is resolved
Normal argument syntax : {5}
Escaped argument syntax: {{5}}
Normal argument syntax: {5}
Escaped argument syntax: {{5}}
"""
# The starting index of this argument in the macro value
start_index = attr.ib(validator=attr.validators.instance_of(int))
Expand Down
165 changes: 149 additions & 16 deletions cmd2/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
import glob
import os
import re
import shutil
import subprocess
import sys
import threading
import unicodedata
from enum import Enum
from typing import Any, Iterable, List, Optional, TextIO, Union

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


def center_text(msg: str, *, pad: str = ' ') -> str:
"""Centers text horizontally for display within the current terminal, optionally padding both sides.
:param msg: message to display in the center
:param pad: if provided, the first character will be used to pad both sides of the message
:return: centered message, optionally padded on both sides with pad_char
"""
term_width = shutil.get_terminal_size().columns
surrounded_msg = ' {} '.format(msg)
if not pad:
pad = ' '
fill_char = pad[:1]
return surrounded_msg.center(term_width, fill_char)


class StdSim(object):
"""
Class to simulate behavior of sys.stdout or sys.stderr.
Expand Down Expand Up @@ -644,3 +629,151 @@ def basic_complete(text: str, line: str, begidx: int, endidx: int, match_against
:return: a list of possible tab completions
"""
return [cur_match for cur_match in match_against if cur_match.startswith(text)]


class TextAlignment(Enum):
LEFT = 1
CENTER = 2
RIGHT = 3


def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ',
width: Optional[int] = None, tab_width: int = 4) -> str:
"""
Align text for display within a given width. Supports characters with display widths greater than 1.
ANSI escape sequences are safely ignored and do not count toward the display width. This means colored text is
supported. If text has line breaks, then each line is aligned independently.
There are convenience wrappers around this function: align_left(), align_center(), and align_right()
:param text: text to align (can contain multiple lines)
:param alignment: how to align the text
:param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
:param width: display width of the aligned text. Defaults to width of the terminal.
:param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
be converted to a space.
:return: aligned text
:raises: TypeError if fill_char is more than one character
ValueError if text or fill_char contains an unprintable character
"""
import io
import shutil

from . import ansi

# Handle tabs
text = text.replace('\t', ' ' * tab_width)
if fill_char == '\t':
fill_char = ' '

if len(fill_char) != 1:
raise TypeError("Fill character must be exactly one character long")

fill_char_width = ansi.ansi_safe_wcswidth(fill_char)
if fill_char_width == -1:
raise (ValueError("Fill character is an unprintable character"))

if text:
lines = text.splitlines()
else:
lines = ['']

if width is None:
width = shutil.get_terminal_size().columns

text_buf = io.StringIO()

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

# Use ansi_safe_wcswidth to support characters with display widths
# greater than 1 as well as ANSI escape sequences
line_width = ansi.ansi_safe_wcswidth(line)
if line_width == -1:
raise(ValueError("Text to align contains an unprintable character"))

# Check if line is wider than the desired final width
if width <= line_width:
text_buf.write(line)
continue

# Calculate how wide each side of filling needs to be
total_fill_width = width - line_width

if alignment == TextAlignment.LEFT:
left_fill_width = 0
right_fill_width = total_fill_width
elif alignment == TextAlignment.CENTER:
left_fill_width = total_fill_width // 2
right_fill_width = total_fill_width - left_fill_width
else:
left_fill_width = total_fill_width
right_fill_width = 0

# Determine how many fill characters are needed to cover the width
left_fill = (left_fill_width // fill_char_width) * fill_char
right_fill = (right_fill_width // fill_char_width) * fill_char

# In cases where the fill character display width didn't divide evenly into
# the gaps being filled, pad the remainder with spaces.
left_fill += ' ' * (left_fill_width - ansi.ansi_safe_wcswidth(left_fill))
right_fill += ' ' * (right_fill_width - ansi.ansi_safe_wcswidth(right_fill))

text_buf.write(left_fill + line + right_fill)

return text_buf.getvalue()


def align_left(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> str:
"""
Left align text for display within a given width. Supports characters with display widths greater than 1.
ANSI escape sequences are safely ignored and do not count toward the display width. This means colored text is
supported. If text has line breaks, then each line is aligned independently.
:param text: text to left align (can contain multiple lines)
:param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
:param width: display width of the aligned text. Defaults to width of the terminal.
:param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
be converted to a space.
:return: left-aligned text
:raises: TypeError if fill_char is more than one character
ValueError if text or fill_char contains an unprintable character
"""
return align_text(text, TextAlignment.LEFT, fill_char=fill_char, width=width, tab_width=tab_width)


def align_center(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> str:
"""
Center text for display within a given width. Supports characters with display widths greater than 1.
ANSI escape sequences are safely ignored and do not count toward the display width. This means colored text is
supported. If text has line breaks, then each line is aligned independently.
:param text: text to center (can contain multiple lines)
:param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
:param width: display width of the aligned text. Defaults to width of the terminal.
:param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
be converted to a space.
:return: centered text
:raises: TypeError if fill_char is more than one character
ValueError if text or fill_char contains an unprintable character
"""
return align_text(text, TextAlignment.CENTER, fill_char=fill_char, width=width, tab_width=tab_width)


def align_right(text: str, *, fill_char: str = ' ', width: Optional[int] = None, tab_width: int = 4) -> str:
"""
Right align text for display within a given width. Supports characters with display widths greater than 1.
ANSI escape sequences are safely ignored and do not count toward the display width. This means colored text is
supported. If text has line breaks, then each line is aligned independently.
:param text: text to right align (can contain multiple lines)
:param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character)
:param width: display width of the aligned text. Defaults to width of the terminal.
:param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will
be converted to a space.
:return: right-aligned text
:raises: TypeError if fill_char is more than one character
ValueError if text or fill_char contains an unprintable character
"""
return align_text(text, TextAlignment.RIGHT, fill_char=fill_char, width=width, tab_width=tab_width)
8 changes: 7 additions & 1 deletion docs/api/utility_functions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ Utility Functions

.. autofunction:: cmd2.decorators.categorize

.. autofunction:: cmd2.utils.center_text
.. autofunction:: cmd2.utils.align_text

.. autofunction:: cmd2.utils.align_left

.. autofunction:: cmd2.utils.align_center

.. autofunction:: cmd2.utils.align_right

.. autofunction:: cmd2.utils.strip_quotes

Expand Down
Loading

0 comments on commit bc99c90

Please sign in to comment.