|
| 1 | +""" |
| 2 | +ANSI utilities for FZF preview scripts. |
| 3 | +
|
| 4 | +Lightweight stdlib-only utilities to replace Rich dependency in preview scripts. |
| 5 | +Provides RGB color formatting, table rendering, and markdown stripping. |
| 6 | +""" |
| 7 | + |
| 8 | +import os |
| 9 | +import re |
| 10 | +import shutil |
| 11 | +import textwrap |
| 12 | +import unicodedata |
| 13 | + |
| 14 | + |
| 15 | +def get_terminal_width() -> int: |
| 16 | + """ |
| 17 | + Get terminal width, prioritizing FZF preview environment variables. |
| 18 | +
|
| 19 | + Returns: |
| 20 | + Terminal width in columns |
| 21 | + """ |
| 22 | + fzf_cols = os.environ.get("FZF_PREVIEW_COLUMNS") |
| 23 | + if fzf_cols: |
| 24 | + return int(fzf_cols) |
| 25 | + return shutil.get_terminal_size((80, 24)).columns |
| 26 | + |
| 27 | + |
| 28 | +def display_width(text: str) -> int: |
| 29 | + """ |
| 30 | + Calculate the actual display width of text, accounting for wide characters. |
| 31 | +
|
| 32 | + Args: |
| 33 | + text: Text to measure |
| 34 | +
|
| 35 | + Returns: |
| 36 | + Display width in terminal columns |
| 37 | + """ |
| 38 | + width = 0 |
| 39 | + for char in text: |
| 40 | + # East Asian Width property: 'F' (Fullwidth) and 'W' (Wide) take 2 columns |
| 41 | + if unicodedata.east_asian_width(char) in ("F", "W"): |
| 42 | + width += 2 |
| 43 | + else: |
| 44 | + width += 1 |
| 45 | + return width |
| 46 | + |
| 47 | + |
| 48 | +def rgb_color(r: int, g: int, b: int, text: str, bold: bool = False) -> str: |
| 49 | + """ |
| 50 | + Format text with RGB color using ANSI escape codes. |
| 51 | +
|
| 52 | + Args: |
| 53 | + r: Red component (0-255) |
| 54 | + g: Green component (0-255) |
| 55 | + b: Blue component (0-255) |
| 56 | + text: Text to colorize |
| 57 | + bold: Whether to make text bold |
| 58 | +
|
| 59 | + Returns: |
| 60 | + ANSI-escaped colored text |
| 61 | + """ |
| 62 | + color_code = f"\x1b[38;2;{r};{g};{b}m" |
| 63 | + bold_code = "\x1b[1m" if bold else "" |
| 64 | + reset = "\x1b[0m" |
| 65 | + return f"{color_code}{bold_code}{text}{reset}" |
| 66 | + |
| 67 | + |
| 68 | +def parse_color(color_csv: str) -> tuple[int, int, int]: |
| 69 | + """ |
| 70 | + Parse RGB color from comma-separated string. |
| 71 | +
|
| 72 | + Args: |
| 73 | + color_csv: Color as 'R,G,B' string |
| 74 | +
|
| 75 | + Returns: |
| 76 | + Tuple of (r, g, b) integers |
| 77 | + """ |
| 78 | + parts = color_csv.split(",") |
| 79 | + return int(parts[0]), int(parts[1]), int(parts[2]) |
| 80 | + |
| 81 | + |
| 82 | +def print_rule(sep_color: str) -> None: |
| 83 | + """ |
| 84 | + Print a horizontal rule line. |
| 85 | +
|
| 86 | + Args: |
| 87 | + sep_color: Color as 'R,G,B' string |
| 88 | + """ |
| 89 | + width = get_terminal_width() |
| 90 | + r, g, b = parse_color(sep_color) |
| 91 | + print(rgb_color(r, g, b, "─" * width)) |
| 92 | + |
| 93 | + |
| 94 | +def print_table_row( |
| 95 | + key: str, value: str, header_color: str, key_width: int, value_width: int |
| 96 | +) -> None: |
| 97 | + """ |
| 98 | + Print a two-column table row with left-aligned key and right-aligned value. |
| 99 | +
|
| 100 | + Args: |
| 101 | + key: Left column text (header/key) |
| 102 | + value: Right column text (value) |
| 103 | + header_color: Color for key as 'R,G,B' string |
| 104 | + key_width: Width for key column |
| 105 | + value_width: Width for value column |
| 106 | + """ |
| 107 | + r, g, b = parse_color(header_color) |
| 108 | + key_styled = rgb_color(r, g, b, key, bold=True) |
| 109 | + |
| 110 | + # Get actual terminal width |
| 111 | + term_width = get_terminal_width() |
| 112 | + |
| 113 | + # Calculate display widths accounting for wide characters |
| 114 | + key_display_width = display_width(key) |
| 115 | + |
| 116 | + # Calculate actual value width based on terminal and key display width |
| 117 | + actual_value_width = max(20, term_width - key_display_width - 2) |
| 118 | + |
| 119 | + # Wrap value if it's too long (use character count, not display width for wrapping) |
| 120 | + value_lines = textwrap.wrap(str(value), width=actual_value_width) if value else [""] |
| 121 | + |
| 122 | + if not value_lines: |
| 123 | + value_lines = [""] |
| 124 | + |
| 125 | + # Print first line with properly aligned value |
| 126 | + first_line = value_lines[0] |
| 127 | + first_line_display_width = display_width(first_line) |
| 128 | + |
| 129 | + # Use manual spacing to right-align based on display width |
| 130 | + spacing = term_width - key_display_width - first_line_display_width - 2 |
| 131 | + if spacing > 0: |
| 132 | + print(f"{key_styled} {' ' * spacing}{first_line}") |
| 133 | + else: |
| 134 | + print(f"{key_styled} {first_line}") |
| 135 | + |
| 136 | + # Print remaining wrapped lines (left-aligned, indented) |
| 137 | + for line in value_lines[1:]: |
| 138 | + print(f"{' ' * (key_display_width + 2)}{line}") |
| 139 | + |
| 140 | + |
| 141 | +def strip_markdown(text: str) -> str: |
| 142 | + """ |
| 143 | + Strip markdown formatting from text. |
| 144 | +
|
| 145 | + Removes: |
| 146 | + - Headers (# ## ###) |
| 147 | + - Bold (**text** or __text__) |
| 148 | + - Italic (*text* or _text_) |
| 149 | + - Links ([text](url)) |
| 150 | + - Code blocks (```code```) |
| 151 | + - Inline code (`code`) |
| 152 | +
|
| 153 | + Args: |
| 154 | + text: Markdown-formatted text |
| 155 | +
|
| 156 | + Returns: |
| 157 | + Plain text with markdown removed |
| 158 | + """ |
| 159 | + if not text: |
| 160 | + return "" |
| 161 | + |
| 162 | + # Remove code blocks first |
| 163 | + text = re.sub(r"```[\s\S]*?```", "", text) |
| 164 | + |
| 165 | + # Remove inline code |
| 166 | + text = re.sub(r"`([^`]+)`", r"\1", text) |
| 167 | + |
| 168 | + # Remove headers |
| 169 | + text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE) |
| 170 | + |
| 171 | + # Remove bold (** or __) |
| 172 | + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) |
| 173 | + text = re.sub(r"__(.+?)__", r"\1", text) |
| 174 | + |
| 175 | + # Remove italic (* or _) |
| 176 | + text = re.sub(r"\*(.+?)\*", r"\1", text) |
| 177 | + text = re.sub(r"_(.+?)_", r"\1", text) |
| 178 | + |
| 179 | + # Remove links, keep text |
| 180 | + text = re.sub(r"\[(.+?)\]\(.+?\)", r"\1", text) |
| 181 | + |
| 182 | + # Remove images |
| 183 | + text = re.sub(r"!\[.*?\]\(.+?\)", "", text) |
| 184 | + |
| 185 | + return text.strip() |
| 186 | + |
| 187 | + |
| 188 | +def wrap_text(text: str, width: int | None = None) -> str: |
| 189 | + """ |
| 190 | + Wrap text to terminal width. |
| 191 | +
|
| 192 | + Args: |
| 193 | + text: Text to wrap |
| 194 | + width: Width to wrap to (defaults to terminal width) |
| 195 | +
|
| 196 | + Returns: |
| 197 | + Wrapped text |
| 198 | + """ |
| 199 | + if width is None: |
| 200 | + width = get_terminal_width() |
| 201 | + |
| 202 | + return textwrap.fill(text, width=width) |
0 commit comments