|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import os |
| 4 | +import re |
| 5 | +from difflib import SequenceMatcher |
| 6 | +from functools import cache |
| 7 | +from itertools import chain |
| 8 | +from typing import Final, Literal |
| 9 | + |
| 10 | +from beets import config, util |
| 11 | +from beets.ui._common import UserError |
| 12 | + |
| 13 | +# Colorization. |
| 14 | + |
| 15 | +# ANSI terminal colorization code heavily inspired by pygments: |
| 16 | +# https://bitbucket.org/birkenfeld/pygments-main/src/default/pygments/console.py |
| 17 | +# (pygments is by Tim Hatch, Armin Ronacher, et al.) |
| 18 | + |
| 19 | +COLOR_ESCAPE: Final = "\x1b" |
| 20 | +LEGACY_COLORS: Final = { |
| 21 | + "black": ["black"], |
| 22 | + "darkred": ["red"], |
| 23 | + "darkgreen": ["green"], |
| 24 | + "brown": ["yellow"], |
| 25 | + "darkyellow": ["yellow"], |
| 26 | + "darkblue": ["blue"], |
| 27 | + "purple": ["magenta"], |
| 28 | + "darkmagenta": ["magenta"], |
| 29 | + "teal": ["cyan"], |
| 30 | + "darkcyan": ["cyan"], |
| 31 | + "lightgray": ["white"], |
| 32 | + "darkgray": ["bold", "black"], |
| 33 | + "red": ["bold", "red"], |
| 34 | + "green": ["bold", "green"], |
| 35 | + "yellow": ["bold", "yellow"], |
| 36 | + "blue": ["bold", "blue"], |
| 37 | + "fuchsia": ["bold", "magenta"], |
| 38 | + "magenta": ["bold", "magenta"], |
| 39 | + "turquoise": ["bold", "cyan"], |
| 40 | + "cyan": ["bold", "cyan"], |
| 41 | + "white": ["bold", "white"], |
| 42 | +} |
| 43 | +# All ANSI Colors. |
| 44 | +CODE_BY_COLOR: Final = { |
| 45 | + # Styles. |
| 46 | + "normal": 0, |
| 47 | + "bold": 1, |
| 48 | + "faint": 2, |
| 49 | + # "italic": 3, |
| 50 | + "underline": 4, |
| 51 | + # "blink_slow": 5, |
| 52 | + # "blink_rapid": 6, |
| 53 | + "inverse": 7, |
| 54 | + # "conceal": 8, |
| 55 | + # "crossed_out": 9 |
| 56 | + # Text colors. |
| 57 | + "black": 30, |
| 58 | + "red": 31, |
| 59 | + "green": 32, |
| 60 | + "yellow": 33, |
| 61 | + "blue": 34, |
| 62 | + "magenta": 35, |
| 63 | + "cyan": 36, |
| 64 | + "white": 37, |
| 65 | + # Background colors. |
| 66 | + "bg_black": 40, |
| 67 | + "bg_red": 41, |
| 68 | + "bg_green": 42, |
| 69 | + "bg_yellow": 43, |
| 70 | + "bg_blue": 44, |
| 71 | + "bg_magenta": 45, |
| 72 | + "bg_cyan": 46, |
| 73 | + "bg_white": 47, |
| 74 | +} |
| 75 | +RESET_COLOR: Final = f"{COLOR_ESCAPE}[39;49;00m" |
| 76 | +# Precompile common ANSI-escape regex patterns |
| 77 | +ANSI_CODE_REGEX: Final = re.compile(rf"({COLOR_ESCAPE}\[[;0-9]*m)") |
| 78 | +ESC_TEXT_REGEX: Final = re.compile( |
| 79 | + rf"""(?P<pretext>[^{COLOR_ESCAPE}]*) |
| 80 | + (?P<esc>(?:{ANSI_CODE_REGEX.pattern})+) |
| 81 | + (?P<text>[^{COLOR_ESCAPE}]+)(?P<reset>{re.escape(RESET_COLOR)}) |
| 82 | + (?P<posttext>[^{COLOR_ESCAPE}]*)""", |
| 83 | + re.VERBOSE, |
| 84 | +) |
| 85 | +ColorName = Literal[ |
| 86 | + "text_success", |
| 87 | + "text_warning", |
| 88 | + "text_error", |
| 89 | + "text_highlight", |
| 90 | + "text_highlight_minor", |
| 91 | + "action_default", |
| 92 | + "action", |
| 93 | + # New Colors |
| 94 | + "text_faint", |
| 95 | + "import_path", |
| 96 | + "import_path_items", |
| 97 | + "action_description", |
| 98 | + "changed", |
| 99 | + "text_diff_added", |
| 100 | + "text_diff_removed", |
| 101 | +] |
| 102 | + |
| 103 | + |
| 104 | +@cache |
| 105 | +def get_color_config() -> dict[ColorName, str]: |
| 106 | + """Parse and validate color configuration, converting names to ANSI codes. |
| 107 | +
|
| 108 | + Processes the UI color configuration, handling both new list format and |
| 109 | + legacy single-color format. Validates all color names against known codes |
| 110 | + and raises an error for any invalid entries. |
| 111 | + """ |
| 112 | + colors_by_color_name: dict[ColorName, list[str]] = { |
| 113 | + k: (v if isinstance(v, list) else LEGACY_COLORS.get(v, [v])) |
| 114 | + for k, v in config["ui"]["colors"].flatten().items() |
| 115 | + } |
| 116 | + |
| 117 | + invalid_colors: set[str] |
| 118 | + if invalid_colors := ( |
| 119 | + set(chain.from_iterable(colors_by_color_name.values())) |
| 120 | + - CODE_BY_COLOR.keys() |
| 121 | + ): |
| 122 | + raise UserError( |
| 123 | + f"Invalid color(s) in configuration: {', '.join(invalid_colors)}" |
| 124 | + ) |
| 125 | + |
| 126 | + return { |
| 127 | + n: ";".join(str(CODE_BY_COLOR[c]) for c in colors) |
| 128 | + for n, colors in colors_by_color_name.items() |
| 129 | + } |
| 130 | + |
| 131 | + |
| 132 | +def colorize(color_name: ColorName, text: str) -> str: |
| 133 | + """Apply ANSI color formatting to text based on configuration settings. |
| 134 | +
|
| 135 | + Returns colored text when color output is enabled and NO_COLOR environment |
| 136 | + variable is not set, otherwise returns plain text unchanged. |
| 137 | + """ |
| 138 | + if config["ui"]["color"] and "NO_COLOR" not in os.environ: |
| 139 | + color_code: str = get_color_config()[color_name] |
| 140 | + return f"{COLOR_ESCAPE}[{color_code}m{text}{RESET_COLOR}" |
| 141 | + |
| 142 | + return text |
| 143 | + |
| 144 | + |
| 145 | +def uncolorize(colored_text: str) -> str: |
| 146 | + """Remove colors from a string.""" |
| 147 | + # Define a regular expression to match ANSI codes. |
| 148 | + # See: http://stackoverflow.com/a/2187024/1382707 |
| 149 | + # Explanation of regular expression: |
| 150 | + # \x1b - matches ESC character |
| 151 | + # \[ - matches opening square bracket |
| 152 | + # [;\d]* - matches a sequence consisting of one or more digits or |
| 153 | + # semicola |
| 154 | + # [A-Za-z] - matches a letter |
| 155 | + return ANSI_CODE_REGEX.sub("", colored_text) |
| 156 | + |
| 157 | + |
| 158 | +def color_split(colored_text: str, index: int): |
| 159 | + length: int = 0 |
| 160 | + pre_split: str = "" |
| 161 | + post_split: str = "" |
| 162 | + found_color_code: str | None = None |
| 163 | + found_split: bool = False |
| 164 | + part: str |
| 165 | + for part in ANSI_CODE_REGEX.split(colored_text) or (): |
| 166 | + # Count how many real letters we have passed |
| 167 | + length += color_len(part) |
| 168 | + if found_split: |
| 169 | + post_split += part |
| 170 | + else: |
| 171 | + if ANSI_CODE_REGEX.match(part): |
| 172 | + # This is a color code |
| 173 | + if part == RESET_COLOR: |
| 174 | + found_color_code = None |
| 175 | + else: |
| 176 | + found_color_code = part |
| 177 | + pre_split += part |
| 178 | + else: |
| 179 | + if index < length: |
| 180 | + # Found part with our split in. |
| 181 | + split_index: int = index - (length - color_len(part)) |
| 182 | + found_split = True |
| 183 | + if found_color_code: |
| 184 | + pre_split += f"{part[:split_index]}{RESET_COLOR}" |
| 185 | + post_split += f"{found_color_code}{part[split_index:]}" |
| 186 | + else: |
| 187 | + pre_split += part[:split_index] |
| 188 | + post_split += part[split_index:] |
| 189 | + else: |
| 190 | + # Not found, add this part to the pre split |
| 191 | + pre_split += part |
| 192 | + return pre_split, post_split |
| 193 | + |
| 194 | + |
| 195 | +def color_len(colored_text: str) -> int: |
| 196 | + """Measure the length of a string while excluding ANSI codes from the |
| 197 | + measurement. The standard `len(my_string)` method also counts ANSI codes |
| 198 | + to the string length, which is counterproductive when layouting a |
| 199 | + Terminal interface. |
| 200 | + """ |
| 201 | + # Return the length of the uncolored string. |
| 202 | + return len(uncolorize(colored_text)) |
| 203 | + |
| 204 | + |
| 205 | +def _colordiff(a: object, b: object) -> tuple[str, str]: |
| 206 | + """Given two values, return the same pair of strings except with |
| 207 | + their differences highlighted in the specified color. Strings are |
| 208 | + highlighted intelligently to show differences; other values are |
| 209 | + stringified and highlighted in their entirety. |
| 210 | + """ |
| 211 | + # First, convert paths to readable format |
| 212 | + value: object |
| 213 | + for value in a, b: |
| 214 | + if isinstance(value, bytes): |
| 215 | + # A path field. |
| 216 | + value = util.displayable_path(value) |
| 217 | + |
| 218 | + if not isinstance(a, str) or not isinstance(b, str): |
| 219 | + # Non-strings: use ordinary equality. |
| 220 | + if a == b: |
| 221 | + return str(a), str(b) |
| 222 | + else: |
| 223 | + return ( |
| 224 | + colorize("text_diff_removed", str(a)), |
| 225 | + colorize("text_diff_added", str(b)), |
| 226 | + ) |
| 227 | + |
| 228 | + before: str = "" |
| 229 | + after: str = "" |
| 230 | + |
| 231 | + op: str |
| 232 | + a_start: int |
| 233 | + a_end: int |
| 234 | + b_start: int |
| 235 | + b_end: int |
| 236 | + matcher: SequenceMatcher[str] = SequenceMatcher(lambda x: False, a, b) |
| 237 | + for op, a_start, a_end, b_start, b_end in matcher.get_opcodes(): |
| 238 | + before_part: str |
| 239 | + after_part: str |
| 240 | + before_part, after_part = a[a_start:a_end], b[b_start:b_end] |
| 241 | + if op in {"delete", "replace"}: |
| 242 | + before_part = colorize("text_diff_removed", before_part) |
| 243 | + if op in {"insert", "replace"}: |
| 244 | + after_part = colorize("text_diff_added", after_part) |
| 245 | + |
| 246 | + before += before_part |
| 247 | + after += after_part |
| 248 | + |
| 249 | + return before, after |
| 250 | + |
| 251 | + |
| 252 | +def colordiff(a: object, b: object) -> tuple[str, str]: |
| 253 | + """Colorize differences between two values if color is enabled. |
| 254 | + (Like _colordiff but conditional.) |
| 255 | + """ |
| 256 | + if config["ui"]["color"]: |
| 257 | + return _colordiff(a, b) |
| 258 | + else: |
| 259 | + return str(a), str(b) |
0 commit comments