From e7c12988bca74367de4c16338425738dedab0985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Thu, 29 May 2025 10:56:28 +0100 Subject: [PATCH 1/7] Remove unused colors --- beets/config_default.yaml | 7 ------- beets/ui/__init__.py | 7 ------- docs/reference/config.rst | 7 ------- 3 files changed, 21 deletions(-) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index d1329f494..4367abb19 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -127,19 +127,12 @@ ui: action_default: ['bold', 'cyan'] action: ['bold', 'cyan'] # New Colors - text: ['normal'] text_faint: ['faint'] import_path: ['bold', 'blue'] import_path_items: ['bold', 'blue'] - added: ['green'] - removed: ['red'] changed: ['yellow'] - added_highlight: ['bold', 'green'] - removed_highlight: ['bold', 'red'] - changed_highlight: ['bold', 'yellow'] text_diff_added: ['bold', 'red'] text_diff_removed: ['bold', 'red'] - text_diff_changed: ['bold', 'red'] action_description: ['white'] import: indentation: diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 9f0ae82e1..79e5f1b20 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -507,20 +507,13 @@ COLOR_NAMES = [ "action_default", "action", # New Colors - "text", "text_faint", "import_path", "import_path_items", "action_description", - "added", - "removed", "changed", - "added_highlight", - "removed_highlight", - "changed_highlight", "text_diff_added", "text_diff_removed", - "text_diff_changed", ] COLORS: dict[str, list[str]] | None = None diff --git a/docs/reference/config.rst b/docs/reference/config.rst index d4f5b3674..4ed3bc9dd 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -465,19 +465,12 @@ your configuration file that looks like this: action_default: ['bold', 'cyan'] action: ['bold', 'cyan'] # New colors after UI overhaul - text: ['normal'] text_faint: ['faint'] import_path: ['bold', 'blue'] import_path_items: ['bold', 'blue'] - added: ['green'] - removed: ['red'] changed: ['yellow'] - added_highlight: ['bold', 'green'] - removed_highlight: ['bold', 'red'] - changed_highlight: ['bold', 'yellow'] text_diff_added: ['bold', 'red'] text_diff_removed: ['bold', 'red'] - text_diff_changed: ['bold', 'red'] action_description: ['white'] Available colors: black, darkred, darkgreen, brown (darkyellow), darkblue, From 0818505334e8aa9dbfdcc52bb40ec1c40e94ed70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Fri, 11 Jul 2025 20:36:31 +0100 Subject: [PATCH 2/7] Fix diff coloring for added and removed text in field diffs - Update default `text_diff_added` value: red -> green - Use `text_diff_removed` and `text_diff_added` instead of `text_error` in UI --- beets/config_default.yaml | 2 +- beets/ui/__init__.py | 4 ++-- docs/changelog.rst | 4 ++++ docs/reference/config.rst | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 4367abb19..0a80f77f2 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -131,7 +131,7 @@ ui: import_path: ['bold', 'blue'] import_path_items: ['bold', 'blue'] changed: ['yellow'] - text_diff_added: ['bold', 'red'] + text_diff_added: ['bold', 'green'] text_diff_removed: ['bold', 'red'] action_description: ['white'] import: diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 79e5f1b20..5735b0ba0 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -1103,8 +1103,8 @@ def _field_diff(field, old, old_fmt, new, new_fmt): if isinstance(oldval, str): oldstr, newstr = colordiff(oldval, newstr) else: - oldstr = colorize("text_error", oldstr) - newstr = colorize("text_error", newstr) + oldstr = colorize("text_diff_removed", oldstr) + newstr = colorize("text_diff_added", newstr) return f"{oldstr} -> {newstr}" diff --git a/docs/changelog.rst b/docs/changelog.rst index 794013806..b9ac55b01 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -135,6 +135,10 @@ Other changes: file. :bug:`5979` - :doc:`plugins/lastgenre`: Updated and streamlined the genre whitelist and canonicalization tree :bug:`5977` +- UI: Update default ``text_diff_added`` color from **bold red** to **bold + green.** +- UI: Use ``text_diff_added`` and ``text_diff_removed`` colors in **all** diff + comparisons. 2.3.1 (May 14, 2025) -------------------- diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 4ed3bc9dd..37ff2f8fa 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -469,7 +469,7 @@ your configuration file that looks like this: import_path: ['bold', 'blue'] import_path_items: ['bold', 'blue'] changed: ['yellow'] - text_diff_added: ['bold', 'red'] + text_diff_added: ['bold', 'green'] text_diff_removed: ['bold', 'red'] action_description: ['white'] From 04380676e1874cb822cba325708765aff9a5df3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Tue, 19 Aug 2025 21:42:47 +0100 Subject: [PATCH 3/7] Slightly simplify colors setup This replaces the funky color setup based on a global `COLORS` variable with a cached function `get_color_config`. --- beets/ui/__init__.py | 91 ++++++++++++++++++-------------------------- 1 file changed, 38 insertions(+), 53 deletions(-) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 5735b0ba0..293571ce2 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -30,7 +30,9 @@ import textwrap import traceback import warnings from difflib import SequenceMatcher -from typing import Any, Callable +from functools import cache +from itertools import chain +from typing import Any, Callable, Literal import confuse @@ -463,7 +465,7 @@ LEGACY_COLORS = { "white": ["bold", "white"], } # All ANSI Colors. -ANSI_CODES = { +CODE_BY_COLOR = { # Styles. "normal": 0, "bold": 1, @@ -496,9 +498,7 @@ ANSI_CODES = { } RESET_COLOR = f"{COLOR_ESCAPE}39;49;00m" -# These abstract COLOR_NAMES are lazily mapped on to the actual color in COLORS -# as they are defined in the configuration files, see function: colorize -COLOR_NAMES = [ +ColorName = Literal[ "text_success", "text_warning", "text_error", @@ -515,61 +515,46 @@ COLOR_NAMES = [ "text_diff_added", "text_diff_removed", ] -COLORS: dict[str, list[str]] | None = None -def _colorize(color, text): - """Returns a string that prints the given text in the given color - in a terminal that is ANSI color-aware. The color must be a list of strings - from ANSI_CODES. +@cache +def get_color_config() -> dict[ColorName, str]: + """Parse and validate color configuration, converting names to ANSI codes. + + Processes the UI color configuration, handling both new list format and + legacy single-color format. Validates all color names against known codes + and raises an error for any invalid entries. """ - # Construct escape sequence to be put before the text by iterating - # over all "ANSI codes" in `color`. - escape = "" - for code in color: - escape = f"{escape}{COLOR_ESCAPE}{ANSI_CODES[code]}m" - return f"{escape}{text}{RESET_COLOR}" + colors_by_color_name: dict[ColorName, list[str]] = { + k: (v if isinstance(v, list) else LEGACY_COLORS.get(v, [v])) + for k, v in config["ui"]["colors"].flatten().items() + } + + if invalid_colors := ( + set(chain.from_iterable(colors_by_color_name.values())) + - CODE_BY_COLOR.keys() + ): + raise UserError( + f"Invalid color(s) in configuration: {', '.join(invalid_colors)}" + ) + + return { + n: ";".join(str(CODE_BY_COLOR[c]) for c in colors) + for n, colors in colors_by_color_name.items() + } -def colorize(color_name, text): - """Colorize text if colored output is enabled. (Like _colorize but - conditional.) +def colorize(color_name: ColorName, text: str) -> str: + """Apply ANSI color formatting to text based on configuration settings. + + Returns colored text when color output is enabled and NO_COLOR environment + variable is not set, otherwise returns plain text unchanged. """ if config["ui"]["color"] and "NO_COLOR" not in os.environ: - global COLORS - if not COLORS: - # Read all color configurations and set global variable COLORS. - COLORS = dict() - for name in COLOR_NAMES: - # Convert legacy color definitions (strings) into the new - # list-based color definitions. Do this by trying to read the - # color definition from the configuration as unicode - if this - # is successful, the color definition is a legacy definition - # and has to be converted. - try: - color_def = config["ui"]["colors"][name].get(str) - except (confuse.ConfigTypeError, NameError): - # Normal color definition (type: list of unicode). - color_def = config["ui"]["colors"][name].get(list) - else: - # Legacy color definition (type: unicode). Convert. - if color_def in LEGACY_COLORS: - color_def = LEGACY_COLORS[color_def] - else: - raise UserError("no such color %s", color_def) - for code in color_def: - if code not in ANSI_CODES.keys(): - raise ValueError("no such ANSI code %s", code) - COLORS[name] = color_def - # In case a 3rd party plugin is still passing the actual color ('red') - # instead of the abstract color name ('text_error') - color = COLORS.get(color_name) - if not color: - log.debug("Invalid color_name: {}", color_name) - color = color_name - return _colorize(color, text) - else: - return text + color_code = get_color_config()[color_name] + return f"{COLOR_ESCAPE}{color_code}m{text}{RESET_COLOR}" + + return text def uncolorize(colored_text): From f8c2008f294ff0301daeb44313d70f89f4feea87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 20 Aug 2025 11:14:04 +0100 Subject: [PATCH 4/7] Dedupe "changed" colorize calls --- beets/ui/commands.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 911a5cfd3..a6fbb3500 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -21,6 +21,7 @@ import re import textwrap from collections import Counter from collections.abc import Sequence +from functools import cached_property from itertools import chain from platform import python_version from typing import Any, NamedTuple @@ -303,6 +304,10 @@ class ChangeRepresentation: TrackMatch object, accordingly. """ + @cached_property + def changed_prefix(self) -> str: + return ui.colorize("changed", "\u2260") + cur_artist = None # cur_album set if album, cur_title set if singleton cur_album = None @@ -394,7 +399,6 @@ class ChangeRepresentation: """Print out the details of the match, including changes in album name and artist name. """ - changed_prefix = ui.colorize("changed", "\u2260") # Artist. artist_l, artist_r = self.cur_artist or "", self.match.info.artist if artist_r == VARIOUS_ARTISTS: @@ -402,9 +406,8 @@ class ChangeRepresentation: artist_l, artist_r = "", "" if artist_l != artist_r: artist_l, artist_r = ui.colordiff(artist_l, artist_r) - # Prefix with U+2260: Not Equal To left = { - "prefix": f"{changed_prefix} Artist: ", + "prefix": f"{self.changed_prefix} Artist: ", "contents": artist_l, "suffix": "", } @@ -422,9 +425,8 @@ class ChangeRepresentation: and self.match.info.album != VARIOUS_ARTISTS ): album_l, album_r = ui.colordiff(album_l, album_r) - # Prefix with U+2260: Not Equal To left = { - "prefix": f"{changed_prefix} Album: ", + "prefix": f"{self.changed_prefix} Album: ", "contents": album_l, "suffix": "", } @@ -437,9 +439,8 @@ class ChangeRepresentation: title_l, title_r = self.cur_title or "", self.match.info.title if self.cur_title != self.match.info.title: title_l, title_r = ui.colordiff(title_l, title_r) - # Prefix with U+2260: Not Equal To left = { - "prefix": f"{changed_prefix} Title: ", + "prefix": f"{self.changed_prefix} Title: ", "contents": title_l, "suffix": "", } @@ -568,9 +569,8 @@ class ChangeRepresentation: # the case, thus the 'info' dictionary is unneeded. # penalties = penalty_string(self.match.distance.tracks[track_info]) - prefix = ui.colorize("changed", "\u2260 ") if changed else "* " lhs = { - "prefix": f"{prefix}{lhs_track} ", + "prefix": f"{self.changed_prefix if changed else '*'} {lhs_track} ", "contents": lhs_title, "suffix": f" {lhs_length}", } From f816f894d3790719a1013711329c6c8d07b3301a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 20 Aug 2025 11:28:39 +0100 Subject: [PATCH 5/7] Use default red/green for case differences --- beets/ui/__init__.py | 38 ++++++++++++-------------------------- docs/changelog.rst | 2 +- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 293571ce2..be8d29e87 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -620,7 +620,7 @@ def color_len(colored_text): return len(uncolorize(colored_text)) -def _colordiff(a, b): +def _colordiff(a: Any, b: Any) -> tuple[str, str]: """Given two values, return the same pair of strings except with their differences highlighted in the specified color. Strings are highlighted intelligently to show differences; other values are @@ -642,35 +642,21 @@ def _colordiff(a, b): colorize("text_diff_added", str(b)), ) - a_out = [] - b_out = [] + before = "" + after = "" matcher = SequenceMatcher(lambda x: False, a, b) for op, a_start, a_end, b_start, b_end in matcher.get_opcodes(): - if op == "equal": - # In both strings. - a_out.append(a[a_start:a_end]) - b_out.append(b[b_start:b_end]) - elif op == "insert": - # Right only. - b_out.append(colorize("text_diff_added", b[b_start:b_end])) - elif op == "delete": - # Left only. - a_out.append(colorize("text_diff_removed", a[a_start:a_end])) - elif op == "replace": - # Right and left differ. Colorise with second highlight if - # it's just a case change. - if a[a_start:a_end].lower() != b[b_start:b_end].lower(): - a_color = "text_diff_removed" - b_color = "text_diff_added" - else: - a_color = b_color = "text_highlight_minor" - a_out.append(colorize(a_color, a[a_start:a_end])) - b_out.append(colorize(b_color, b[b_start:b_end])) - else: - assert False + before_part, after_part = a[a_start:a_end], b[b_start:b_end] + if op in {"delete", "replace"}: + before_part = colorize("text_diff_removed", before_part) + if op in {"insert", "replace"}: + after_part = colorize("text_diff_added", after_part) - return "".join(a_out), "".join(b_out) + before += before_part + after += after_part + + return before, after def colordiff(a, b): diff --git a/docs/changelog.rst b/docs/changelog.rst index b9ac55b01..71fd657b7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -138,7 +138,7 @@ Other changes: - UI: Update default ``text_diff_added`` color from **bold red** to **bold green.** - UI: Use ``text_diff_added`` and ``text_diff_removed`` colors in **all** diff - comparisons. + comparisons, including case differences. 2.3.1 (May 14, 2025) -------------------- From 841c49d494b60c59c79635b1012e4f17458a6e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 20 Aug 2025 13:09:13 +0100 Subject: [PATCH 6/7] Update ui configuration docs --- docs/reference/config.rst | 53 ++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 37ff2f8fa..bc823ded4 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -429,20 +429,11 @@ UI Options The options that allow for customization of the visual appearance of the console interface. -These options are available in this section: - color ~~~~~ -Either ``yes`` or ``no``; whether to use color in console output (currently only -in the ``import`` command). Turn this off if your terminal doesn't support ANSI -colors. - -.. note:: - - The ``color`` option was previously a top-level configuration. This is still - respected, but a deprecation message will be shown until your top-level - ``color`` configuration has been nested under ``ui``. +Either ``yes`` or ``no``; whether to use color in console output. Turn this off +if your terminal doesn't support ANSI colors. .. _colors: @@ -450,10 +441,9 @@ colors ~~~~~~ The colors that are used throughout the user interface. These are only used if -the ``color`` option is set to ``yes``. For example, you might have a section in -your configuration file that looks like this: +the ``color`` option is set to ``yes``. See the default configuration: -:: +.. code-block:: yaml ui: colors: @@ -473,13 +463,18 @@ your configuration file that looks like this: text_diff_removed: ['bold', 'red'] action_description: ['white'] -Available colors: black, darkred, darkgreen, brown (darkyellow), darkblue, -purple (darkmagenta), teal (darkcyan), lightgray, darkgray, red, green, yellow, -blue, fuchsia (magenta), turquoise (cyan), white +Available attributes: -Legacy UI colors config directive used strings. If any colors value is still a -string instead of a list, it will be translated to list automatically. For -example ``blue`` will become ``['blue']``. +Foreground colors + ``black``, ``red``, ``green``, ``yellow``, ``blue``, ``magenta``, ``cyan``, + ``white`` + +Background colors + ``bg_black``, ``bg_red``, ``bg_green``, ``bg_yellow``, ``bg_blue``, + ``bg_magenta``, ``bg_cyan``, ``bg_white`` + +Text styles + ``normal``, ``bold``, ``faint``, ``underline``, ``reverse`` terminal_width ~~~~~~~~~~~~~~ @@ -488,7 +483,7 @@ Controls line wrapping on non-Unix systems. On Unix systems, the width of the terminal is detected automatically. If this fails, or on non-Unix systems, the specified value is used as a fallback. Defaults to ``80`` characters: -:: +.. code-block:: yaml ui: terminal_width: 80 @@ -504,7 +499,7 @@ different track lengths are colored with ``text_highlight_minor``. matching or distance score calculation (see :ref:`match-config`, ``distance_weights`` and :ref:`colors`): -:: +.. code-block:: yaml ui: length_diff_thresh: 10.0 @@ -516,18 +511,18 @@ When importing, beets will read several options to configure the visuals of the import dialogue. There are two layouts controlling how horizontal space and line wrapping is dealt with: ``column`` and ``newline``. The indentation of the respective elements of the import UI can also be configured. For example setting -``4`` for ``match_header`` will indent the very first block of a proposed match -by five characters in the terminal: +``2`` for ``match_header`` will indent the very first block of a proposed match +by two characters in the terminal: -:: +.. code-block:: yaml ui: import: indentation: - match_header: 4 - match_details: 4 - match_tracklist: 7 - layout: newline + match_header: 2 + match_details: 2 + match_tracklist: 5 + layout: column Importer Options ---------------- From 30093c517e11d2a352e1f26e6c5d4cc3be582920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Nejus?= Date: Wed, 20 Aug 2025 15:10:20 +0100 Subject: [PATCH 7/7] Define color regex patterns once --- beets/ui/__init__.py | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index be8d29e87..e0c1bb486 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -440,7 +440,7 @@ def input_select_objects(prompt, objs, rep, prompt_all=None): # ANSI terminal colorization code heavily inspired by pygments: # https://bitbucket.org/birkenfeld/pygments-main/src/default/pygments/console.py # (pygments is by Tim Hatch, Armin Ronacher, et al.) -COLOR_ESCAPE = "\x1b[" +COLOR_ESCAPE = "\x1b" LEGACY_COLORS = { "black": ["black"], "darkred": ["red"], @@ -496,8 +496,16 @@ CODE_BY_COLOR = { "bg_cyan": 46, "bg_white": 47, } -RESET_COLOR = f"{COLOR_ESCAPE}39;49;00m" - +RESET_COLOR = f"{COLOR_ESCAPE}[39;49;00m" +# Precompile common ANSI-escape regex patterns +ANSI_CODE_REGEX = re.compile(rf"({COLOR_ESCAPE}\[[;0-9]*m)") +ESC_TEXT_REGEX = re.compile( + rf"""(?P[^{COLOR_ESCAPE}]*) + (?P(?:{ANSI_CODE_REGEX.pattern})+) + (?P[^{COLOR_ESCAPE}]+)(?P{re.escape(RESET_COLOR)}) + (?P[^{COLOR_ESCAPE}]*)""", + re.VERBOSE, +) ColorName = Literal[ "text_success", "text_warning", @@ -552,7 +560,7 @@ def colorize(color_name: ColorName, text: str) -> str: """ if config["ui"]["color"] and "NO_COLOR" not in os.environ: color_code = get_color_config()[color_name] - return f"{COLOR_ESCAPE}{color_code}m{text}{RESET_COLOR}" + return f"{COLOR_ESCAPE}[{color_code}m{text}{RESET_COLOR}" return text @@ -567,26 +575,22 @@ def uncolorize(colored_text): # [;\d]* - matches a sequence consisting of one or more digits or # semicola # [A-Za-z] - matches a letter - ansi_code_regex = re.compile(r"\x1b\[[;\d]*[A-Za-z]", re.VERBOSE) - # Strip ANSI codes from `colored_text` using the regular expression. - text = ansi_code_regex.sub("", colored_text) - return text + return ANSI_CODE_REGEX.sub("", colored_text) def color_split(colored_text, index): - ansi_code_regex = re.compile(r"(\x1b\[[;\d]*[A-Za-z])", re.VERBOSE) length = 0 pre_split = "" post_split = "" found_color_code = None found_split = False - for part in ansi_code_regex.split(colored_text): + for part in ANSI_CODE_REGEX.split(colored_text): # Count how many real letters we have passed length += color_len(part) if found_split: post_split += part else: - if ansi_code_regex.match(part): + if ANSI_CODE_REGEX.match(part): # This is a color code if part == RESET_COLOR: found_color_code = None @@ -729,19 +733,13 @@ def split_into_lines(string, width_tuple): """ first_width, middle_width, last_width = width_tuple words = [] - esc_text = re.compile( - r"""(?P[^\x1b]*) - (?P(?:\x1b\[[;\d]*[A-Za-z])+) - (?P[^\x1b]+)(?P\x1b\[39;49;00m) - (?P[^\x1b]*)""", - re.VERBOSE, - ) + if uncolorize(string) == string: # No colors in string words = string.split() else: # Use a regex to find escapes and the text within them. - for m in esc_text.finditer(string): + for m in ESC_TEXT_REGEX.finditer(string): # m contains four groups: # pretext - any text before escape sequence # esc - intitial escape sequence