38 lines
1.2 KiB
Python
38 lines
1.2 KiB
Python
"""Terminal display width calculation for unicode and ANSI-colored text."""
|
|
|
|
import re
|
|
import unicodedata
|
|
|
|
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;:]*[A-Za-z]")
|
|
|
|
|
|
def _is_wide(char: str) -> bool:
|
|
"""Return True for characters rendered as two terminal columns."""
|
|
if unicodedata.east_asian_width(char) in {"F", "W"}:
|
|
return True
|
|
cp = ord(char)
|
|
return unicodedata.category(char) == "So" and (
|
|
0x2600 <= cp <= 0x27BF or 0x1F300 <= cp <= 0x1F9FF or 0x1FA00 <= cp <= 0x1FAFF
|
|
)
|
|
|
|
|
|
def display_width(text: str) -> int:
|
|
"""Calculate the display width of a string in terminal columns.
|
|
|
|
ANSI escape codes are ignored. Wide characters (East Asian F/W and
|
|
emoji) count as two columns, combining marks and format characters
|
|
(e.g. emoji variation selectors) as zero.
|
|
"""
|
|
plain = ANSI_ESCAPE_RE.sub("", text)
|
|
width = 0
|
|
for char in plain:
|
|
if unicodedata.category(char) in {"Mn", "Mc", "Me", "Cf"}:
|
|
continue
|
|
width += 2 if _is_wide(char) else 1
|
|
return width
|
|
|
|
|
|
def pad_display(text: str, width: int) -> str:
|
|
"""Pad text with trailing spaces to the given display width (no truncation)."""
|
|
return text + " " * max(width - display_width(text), 0)
|