| """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'") |
|
|