|
|
"""Terminal display utilities for proper text alignment. |
|
|
|
|
|
This module provides utilities for calculating visible width of text in terminals, |
|
|
handling ANSI escape codes, emoji, and East Asian characters correctly. |
|
|
""" |
|
|
|
|
|
import re |
|
|
import unicodedata |
|
|
|
|
|
|
|
|
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m") |
|
|
|
|
|
|
|
|
EMOJI_START = 0x1F300 |
|
|
EMOJI_END = 0x1FAFF |
|
|
|
|
|
|
|
|
def calculate_display_width(text: str) -> int: |
|
|
"""Calculate the visible width of text in terminal columns. |
|
|
|
|
|
This function correctly handles: |
|
|
- ANSI escape codes (removed from width calculation) |
|
|
- Emoji characters (counted as 2 columns) |
|
|
- East Asian Wide/Fullwidth characters (counted as 2 columns) |
|
|
- Combining characters (counted as 0 columns) |
|
|
- Regular ASCII characters (counted as 1 column) |
|
|
|
|
|
Args: |
|
|
text: Input text that may contain ANSI codes, emoji, or unicode characters |
|
|
|
|
|
Returns: |
|
|
Number of terminal columns the text will occupy when displayed |
|
|
|
|
|
Examples: |
|
|
>>> calculate_display_width("Hello") |
|
|
5 |
|
|
>>> calculate_display_width("你好") |
|
|
4 |
|
|
>>> calculate_display_width("🤖") |
|
|
2 |
|
|
>>> calculate_display_width("\033[31mRed\033[0m") |
|
|
3 |
|
|
""" |
|
|
|
|
|
clean_text = ANSI_ESCAPE_RE.sub("", text) |
|
|
|
|
|
width = 0 |
|
|
for char in clean_text: |
|
|
|
|
|
if unicodedata.combining(char): |
|
|
continue |
|
|
|
|
|
code_point = ord(char) |
|
|
|
|
|
|
|
|
if EMOJI_START <= code_point <= EMOJI_END: |
|
|
width += 2 |
|
|
continue |
|
|
|
|
|
|
|
|
|
|
|
eaw = unicodedata.east_asian_width(char) |
|
|
if eaw in ("W", "F"): |
|
|
width += 2 |
|
|
else: |
|
|
width += 1 |
|
|
|
|
|
return width |
|
|
|
|
|
|
|
|
def truncate_with_ellipsis(text: str, max_width: int, ellipsis: str = "…") -> str: |
|
|
"""Truncate text to fit within max_width, adding ellipsis if needed. |
|
|
|
|
|
Args: |
|
|
text: Text to truncate (ANSI codes are preserved but not counted) |
|
|
max_width: Maximum visible width in terminal columns |
|
|
ellipsis: Ellipsis character to use (default: "…") |
|
|
|
|
|
Returns: |
|
|
Truncated text with ellipsis if needed |
|
|
|
|
|
Examples: |
|
|
>>> truncate_with_ellipsis("Hello World", 8) |
|
|
'Hello W…' |
|
|
>>> truncate_with_ellipsis("你好世界", 5) |
|
|
'你好…' |
|
|
""" |
|
|
if max_width <= 0: |
|
|
return "" |
|
|
|
|
|
current_width = calculate_display_width(text) |
|
|
|
|
|
|
|
|
if current_width <= max_width: |
|
|
return text |
|
|
|
|
|
|
|
|
plain_text = ANSI_ESCAPE_RE.sub("", text) |
|
|
|
|
|
|
|
|
ellipsis_width = calculate_display_width(ellipsis) |
|
|
if max_width <= ellipsis_width: |
|
|
return plain_text[:max_width] |
|
|
|
|
|
|
|
|
available_width = max_width - ellipsis_width |
|
|
truncated = "" |
|
|
current_width = 0 |
|
|
|
|
|
for char in plain_text: |
|
|
char_width = calculate_display_width(char) |
|
|
if current_width + char_width > available_width: |
|
|
break |
|
|
truncated += char |
|
|
current_width += char_width |
|
|
|
|
|
return truncated + ellipsis |
|
|
|
|
|
|
|
|
def pad_to_width(text: str, target_width: int, align: str = "left", fill_char: str = " ") -> str: |
|
|
"""Pad text to reach target width with proper alignment. |
|
|
|
|
|
Args: |
|
|
text: Text to pad (may contain ANSI codes) |
|
|
target_width: Target width in terminal columns |
|
|
align: Alignment mode - "left", "right", or "center" |
|
|
fill_char: Character to use for padding (default: space) |
|
|
|
|
|
Returns: |
|
|
Padded text |
|
|
|
|
|
Examples: |
|
|
>>> pad_to_width("Hello", 10) |
|
|
'Hello ' |
|
|
>>> pad_to_width("你好", 10) |
|
|
'你好 ' |
|
|
>>> pad_to_width("Test", 10, align="center") |
|
|
' Test ' |
|
|
""" |
|
|
current_width = calculate_display_width(text) |
|
|
|
|
|
if current_width >= target_width: |
|
|
return text |
|
|
|
|
|
padding_needed = target_width - current_width |
|
|
|
|
|
if align == "left": |
|
|
return text + (fill_char * padding_needed) |
|
|
elif align == "right": |
|
|
return (fill_char * padding_needed) + text |
|
|
elif align == "center": |
|
|
left_padding = padding_needed // 2 |
|
|
right_padding = padding_needed - left_padding |
|
|
return (fill_char * left_padding) + text + (fill_char * right_padding) |
|
|
else: |
|
|
raise ValueError(f"Invalid align value: {align}. Must be 'left', 'right', or 'center'") |
|
|
|