Improve default pretty diff colors (#5949)

- Switch default `text_diff_added` color from bold **red** to bold
**green**; unify all diff/case comparisons to use `text_diff_added` and
`text_diff_removed` colors consistently.
- Simplify color handling and setup: introduce cached color config
(validation + legacy normalization), consolidate regexes
- Remove unused colors names
- Update `ui` configuration docs.

### `beet write -p year:2000..2005`

#### Before
<img width="1261" height="460" alt="image"
src="https://github.com/user-attachments/assets/70207350-6d9a-48d8-a314-457ac07c5ad1"
/>

#### After
<img width="1257" height="536" alt="image"
src="https://github.com/user-attachments/assets/efcf3453-016a-490f-84ab-dedfb2aca97b"
/>

### `beet move -p albumtype:broadcast`

#### Before
<img width="1103" height="505" alt="image"
src="https://github.com/user-attachments/assets/9e76c5d6-d878-4535-9da3-a949e6c0830c"
/>

#### After
<img width="1134" height="508" alt="image"
src="https://github.com/user-attachments/assets/3dc03988-8011-4072-8840-6f0a0d12350f"
/>

## Summary by Sourcery

Improve default diff colors from red to green and streamline color
handling by refactoring ANSI code management, removing legacy logic, and
unifying diff highlighting. Also extract a cached changed_prefix
property and update UI config documentation.

Enhancements:
- Introduce cached get_color_config and consolidate ANSI escape regex
patterns to simplify color configuration parsing
- Refactor diff highlighting to consistently use text_diff_added and
text_diff_removed and simplify _colordiff implementation
- Add ChangeRepresentation.changed_prefix cached property for consistent
‘not equal’ prefix formatting

Documentation:
- Update UI configuration documentation to reflect new default colors
and removed settings

Chores:
- Remove unused color names and legacy normalization code
This commit is contained in:
Šarūnas Nejus 2025-09-13 12:57:13 +01:00 committed by GitHub
commit 2b4758d440
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 107 additions and 160 deletions

View file

@ -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_added: ['bold', 'green']
text_diff_removed: ['bold', 'red']
text_diff_changed: ['bold', 'red']
action_description: ['white']
import:
indentation:

View file

@ -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
@ -438,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"],
@ -463,7 +465,7 @@ LEGACY_COLORS = {
"white": ["bold", "white"],
}
# All ANSI Colors.
ANSI_CODES = {
CODE_BY_COLOR = {
# Styles.
"normal": 0,
"bold": 1,
@ -494,11 +496,17 @@ ANSI_CODES = {
"bg_cyan": 46,
"bg_white": 47,
}
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 = [
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<pretext>[^{COLOR_ESCAPE}]*)
(?P<esc>(?:{ANSI_CODE_REGEX.pattern})+)
(?P<text>[^{COLOR_ESCAPE}]+)(?P<reset>{re.escape(RESET_COLOR)})
(?P<posttext>[^{COLOR_ESCAPE}]*)""",
re.VERBOSE,
)
ColorName = Literal[
"text_success",
"text_warning",
"text_error",
@ -507,76 +515,54 @@ 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
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):
@ -589,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
@ -642,7 +624,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
@ -664,35 +646,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):
@ -765,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<pretext>[^\x1b]*)
(?P<esc>(?:\x1b\[[;\d]*[A-Za-z])+)
(?P<text>[^\x1b]+)(?P<reset>\x1b\[39;49;00m)
(?P<posttext>[^\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
@ -1110,8 +1072,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}"

View file

@ -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}",
}

View file

@ -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, including case differences.
2.3.1 (May 14, 2025)
--------------------

View file

@ -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:
@ -465,28 +455,26 @@ 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_added: ['bold', 'green']
text_diff_removed: ['bold', 'red']
text_diff_changed: ['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
~~~~~~~~~~~~~~
@ -495,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
@ -511,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
@ -523,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
----------------