diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 233f2fbf07..5f00f39f66 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -167,3 +167,9 @@ ae3a2e5729e3c0a5acbd8967ba2f11f4c53acd09 192217da5d70621089b06b06fff3dbcbeb4c0c4d # Add a changelog note b3f558584910ab4fc6aa185ec4a4c8554001ba24 +# Fix references to color utils +a6fcb7ba0f237530ff394a423a7cbe2ac4853c91 +# Fix diff references +1d54f2bf66506e7a45ce5e962106897e3c98f67a +# Fix layout references +ffb43290066c78cb72603b7e2a0a1c90056361dd diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index ad14fe1f8a..011314f5f3 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -28,9 +28,8 @@ import sys import textwrap import traceback -from difflib import SequenceMatcher from functools import cache -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any import confuse @@ -38,14 +37,14 @@ from beets.dbcore import db from beets.dbcore import query as db_query from beets.util import as_string +from beets.util.color import colorize from beets.util.deprecation import deprecate_for_maintainers +from beets.util.diff import get_model_changes from beets.util.functemplate import template if TYPE_CHECKING: from collections.abc import Callable, Iterable - from beets.dbcore.db import FormattedMapping - # On Windows platforms, use colorama to support "ANSI" terminal colors. if sys.platform == "win32": @@ -185,11 +184,6 @@ def should_move(move_opt=None): # Input prompts. -def indent(count): - """Returns a string with `count` many spaces.""" - return " " * count - - def input_(prompt=None): """Like `input`, but decodes the result to a Unicode string. Raises a UserError if stdin is not available. The prompt is sent to @@ -436,233 +430,6 @@ def input_select_objects(prompt, objs, rep, prompt_all=None): return [] -# Colorization. - -# 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" -LEGACY_COLORS = { - "black": ["black"], - "darkred": ["red"], - "darkgreen": ["green"], - "brown": ["yellow"], - "darkyellow": ["yellow"], - "darkblue": ["blue"], - "purple": ["magenta"], - "darkmagenta": ["magenta"], - "teal": ["cyan"], - "darkcyan": ["cyan"], - "lightgray": ["white"], - "darkgray": ["bold", "black"], - "red": ["bold", "red"], - "green": ["bold", "green"], - "yellow": ["bold", "yellow"], - "blue": ["bold", "blue"], - "fuchsia": ["bold", "magenta"], - "magenta": ["bold", "magenta"], - "turquoise": ["bold", "cyan"], - "cyan": ["bold", "cyan"], - "white": ["bold", "white"], -} -# All ANSI Colors. -CODE_BY_COLOR = { - # Styles. - "normal": 0, - "bold": 1, - "faint": 2, - "italic": 3, - "underline": 4, - "blink_slow": 5, - "blink_rapid": 6, - "inverse": 7, - "conceal": 8, - "crossed_out": 9, - # Text colors. - "black": 30, - "red": 31, - "green": 32, - "yellow": 33, - "blue": 34, - "magenta": 35, - "cyan": 36, - "white": 37, - "bright_black": 90, - "bright_red": 91, - "bright_green": 92, - "bright_yellow": 93, - "bright_blue": 94, - "bright_magenta": 95, - "bright_cyan": 96, - "bright_white": 97, - # Background colors. - "bg_black": 40, - "bg_red": 41, - "bg_green": 42, - "bg_yellow": 43, - "bg_blue": 44, - "bg_magenta": 45, - "bg_cyan": 46, - "bg_white": 47, - "bg_bright_black": 100, - "bg_bright_red": 101, - "bg_bright_green": 102, - "bg_bright_yellow": 103, - "bg_bright_blue": 104, - "bg_bright_magenta": 105, - "bg_bright_cyan": 106, - "bg_bright_white": 107, -} -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", - "text_error", - "text_highlight", - "text_highlight_minor", - "action_default", - "action", - # New Colors - "text_faint", - "import_path", - "import_path_items", - "action_description", - "changed", - "text_diff_added", - "text_diff_removed", -] - - -@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. - """ - template_dict: dict[ColorName, confuse.OneOf[str | list[str]]] = { - n: confuse.OneOf( - [ - confuse.Choice(sorted(LEGACY_COLORS)), - confuse.Sequence(confuse.Choice(sorted(CODE_BY_COLOR))), - ] - ) - for n in ColorName.__args__ # type: ignore[attr-defined] - } - template = confuse.MappingTemplate(template_dict) - colors_by_color_name = { - k: (v if isinstance(v, list) else LEGACY_COLORS.get(v, [v])) - for k, v in config["ui"]["colors"].get(template).items() - } - - 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: ColorName, text: str) -> str: - """Apply ANSI color formatting to text based on configuration settings.""" - color_code = get_color_config()[color_name] - return f"{COLOR_ESCAPE}[{color_code}m{text}{RESET_COLOR}" - - -def colorize(color_name: ColorName, text: str) -> str: - """Colorize text when color output is enabled.""" - if config["ui"]["color"] and "NO_COLOR" not in os.environ: - return _colorize(color_name, text) - - return text - - -def uncolorize(colored_text): - """Remove colors from a string.""" - # Define a regular expression to match ANSI codes. - # See: http://stackoverflow.com/a/2187024/1382707 - # Explanation of regular expression: - # \x1b - matches ESC character - # \[ - matches opening square bracket - # [;\d]* - matches a sequence consisting of one or more digits or - # semicola - # [A-Za-z] - matches a letter - return ANSI_CODE_REGEX.sub("", colored_text) - - -def color_split(colored_text, index): - length = 0 - pre_split = "" - post_split = "" - found_color_code = None - found_split = False - 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): - # This is a color code - if part == RESET_COLOR: - found_color_code = None - else: - found_color_code = part - pre_split += part - else: - if index < length: - # Found part with our split in. - split_index = index - (length - color_len(part)) - found_split = True - if found_color_code: - pre_split += f"{part[:split_index]}{RESET_COLOR}" - post_split += f"{found_color_code}{part[split_index:]}" - else: - pre_split += part[:split_index] - post_split += part[split_index:] - else: - # Not found, add this part to the pre split - pre_split += part - return pre_split, post_split - - -def color_len(colored_text): - """Measure the length of a string while excluding ANSI codes from the - measurement. The standard `len(my_string)` method also counts ANSI codes - to the string length, which is counterproductive when layouting a - Terminal interface. - """ - # Return the length of the uncolored string. - return len(uncolorize(colored_text)) - - -def colordiff(a: str, b: str) -> tuple[str, str]: - """Intelligently highlight the differences between two strings.""" - before = "" - after = "" - - matcher = SequenceMatcher(lambda _: False, a, b) - for op, a_start, a_end, b_start, b_end in matcher.get_opcodes(): - 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) - - before += before_part - after += after_part - - return before, after - - def get_path_formats(subview=None): """Get the configuration's path formats as a list of query/template pairs. @@ -696,379 +463,6 @@ def term_width() -> int: return columns if columns else config["ui"]["terminal_width"].get(int) -def split_into_lines(string, width_tuple): - """Splits string into a list of substrings at whitespace. - - `width_tuple` is a 3-tuple of `(first_width, last_width, middle_width)`. - The first substring has a length not longer than `first_width`, the last - substring has a length not longer than `last_width`, and all other - substrings have a length not longer than `middle_width`. - `string` may contain ANSI codes at word borders. - """ - first_width, middle_width, last_width = width_tuple - words = [] - - 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_REGEX.finditer(string): - # m contains four groups: - # pretext - any text before escape sequence - # esc - intitial escape sequence - # text - text, no escape sequence, may contain spaces - # reset - ASCII colour reset - space_before_text = False - if m.group("pretext") != "": - # Some pretext found, let's handle it - # Add any words in the pretext - words += m.group("pretext").split() - if m.group("pretext")[-1] == " ": - # Pretext ended on a space - space_before_text = True - else: - # Pretext ended mid-word, ensure next word - pass - else: - # pretext empty, treat as if there is a space before - space_before_text = True - if m.group("text")[0] == " ": - # First character of the text is a space - space_before_text = True - # Now, handle the words in the main text: - raw_words = m.group("text").split() - if space_before_text: - # Colorize each word with pre/post escapes - # Reconstruct colored words - words += [ - f"{m['esc']}{raw_word}{RESET_COLOR}" - for raw_word in raw_words - ] - elif raw_words: - # Pretext stops mid-word - if m.group("esc") != RESET_COLOR: - # Add the rest of the current word, with a reset after it - words[-1] += f"{m['esc']}{raw_words[0]}{RESET_COLOR}" - # Add the subsequent colored words: - words += [ - f"{m['esc']}{raw_word}{RESET_COLOR}" - for raw_word in raw_words[1:] - ] - else: - # Caught a mid-word escape sequence - words[-1] += raw_words[0] - words += raw_words[1:] - if ( - m.group("text")[-1] != " " - and m.group("posttext") != "" - and m.group("posttext")[0] != " " - ): - # reset falls mid-word - post_text = m.group("posttext").split() - words[-1] += post_text[0] - words += post_text[1:] - else: - # Add any words after escape sequence - words += m.group("posttext").split() - result = [] - next_substr = "" - # Iterate over all words. - previous_fit = False - for i in range(len(words)): - if i == 0: - pot_substr = words[i] - else: - # (optimistically) add the next word to check the fit - pot_substr = " ".join([next_substr, words[i]]) - # Find out if the pot(ential)_substr fits into the next substring. - fits_first = len(result) == 0 and color_len(pot_substr) <= first_width - fits_middle = len(result) != 0 and color_len(pot_substr) <= middle_width - if fits_first or fits_middle: - # Fitted(!) let's try and add another word before appending - next_substr = pot_substr - previous_fit = True - elif not fits_first and not fits_middle and previous_fit: - # Extra word didn't fit, append what we have - result.append(next_substr) - next_substr = words[i] - previous_fit = color_len(next_substr) <= middle_width - else: - # Didn't fit anywhere - if uncolorize(pot_substr) == pot_substr: - # Simple uncolored string, append a cropped word - if len(result) == 0: - # Crop word by the first_width for the first line - result.append(pot_substr[:first_width]) - # add rest of word to next line - next_substr = pot_substr[first_width:] - else: - result.append(pot_substr[:middle_width]) - next_substr = pot_substr[middle_width:] - else: - # Colored strings - if len(result) == 0: - this_line, next_line = color_split(pot_substr, first_width) - result.append(this_line) - next_substr = next_line - else: - this_line, next_line = color_split(pot_substr, middle_width) - result.append(this_line) - next_substr = next_line - previous_fit = color_len(next_substr) <= middle_width - - # We finished constructing the substrings, but the last substring - # has not yet been added to the result. - result.append(next_substr) - # Also, the length of the last substring was only checked against - # `middle_width`. Append an empty substring as the new last substring if - # the last substring is too long. - if not color_len(next_substr) <= last_width: - result.append("") - return result - - -def print_column_layout( - indent_str, left, right, separator=" -> ", max_width=term_width() -): - """Print left & right data, with separator inbetween - 'left' and 'right' have a structure of: - {'prefix':u'','contents':u'','suffix':u'','width':0} - In a column layout the printing will be: - {indent_str}{lhs0}{separator}{rhs0} - {lhs1 / padding }{rhs1} - ... - The first line of each column (i.e. {lhs0} or {rhs0}) is: - {prefix}{part of contents}{suffix} - With subsequent lines (i.e. {lhs1}, {rhs1} onwards) being the - rest of contents, wrapped if the width would be otherwise exceeded. - """ - if f"{right['prefix']}{right['contents']}{right['suffix']}" == "": - # No right hand information, so we don't need a separator. - separator = "" - first_line_no_wrap = ( - f"{indent_str}{left['prefix']}{left['contents']}{left['suffix']}" - f"{separator}{right['prefix']}{right['contents']}{right['suffix']}" - ) - if color_len(first_line_no_wrap) < max_width: - # Everything fits, print out line. - print_(first_line_no_wrap) - else: - # Wrap into columns - if "width" not in left or "width" not in right: - # If widths have not been defined, set to share space. - left["width"] = ( - max_width - len(indent_str) - color_len(separator) - ) // 2 - right["width"] = ( - max_width - len(indent_str) - color_len(separator) - ) // 2 - # On the first line, account for suffix as well as prefix - left_width_tuple = ( - left["width"] - - color_len(left["prefix"]) - - color_len(left["suffix"]), - left["width"] - color_len(left["prefix"]), - left["width"] - color_len(left["prefix"]), - ) - - left_split = split_into_lines(left["contents"], left_width_tuple) - right_width_tuple = ( - right["width"] - - color_len(right["prefix"]) - - color_len(right["suffix"]), - right["width"] - color_len(right["prefix"]), - right["width"] - color_len(right["prefix"]), - ) - - right_split = split_into_lines(right["contents"], right_width_tuple) - max_line_count = max(len(left_split), len(right_split)) - - out = "" - for i in range(max_line_count): - # indentation - out += indent_str - - # Prefix or indent_str for line - if i == 0: - out += left["prefix"] - else: - out += indent(color_len(left["prefix"])) - - # Line i of left hand side contents. - if i < len(left_split): - out += left_split[i] - left_part_len = color_len(left_split[i]) - else: - left_part_len = 0 - - # Padding until end of column. - # Note: differs from original - # column calcs in not -1 afterwards for space - # in track number as that is included in 'prefix' - padding = left["width"] - color_len(left["prefix"]) - left_part_len - - # Remove some padding on the first line to display - # length - if i == 0: - padding -= color_len(left["suffix"]) - - out += indent(padding) - - if i == 0: - out += left["suffix"] - - # Separator between columns. - if i == 0: - out += separator - else: - out += indent(color_len(separator)) - - # Right prefix, contents, padding, suffix - if i == 0: - out += right["prefix"] - else: - out += indent(color_len(right["prefix"])) - - # Line i of right hand side. - if i < len(right_split): - out += right_split[i] - right_part_len = color_len(right_split[i]) - else: - right_part_len = 0 - - # Padding until end of column - padding = ( - right["width"] - color_len(right["prefix"]) - right_part_len - ) - # Remove some padding on the first line to display - # length - if i == 0: - padding -= color_len(right["suffix"]) - out += indent(padding) - # Length in first line - if i == 0: - out += right["suffix"] - - # Linebreak, except in the last line. - if i < max_line_count - 1: - out += "\n" - - # Constructed all of the columns, now print - print_(out) - - -def print_newline_layout( - indent_str, left, right, separator=" -> ", max_width=term_width() -): - """Prints using a newline separator between left & right if - they go over their allocated widths. The datastructures are - shared with the column layout. In contrast to the column layout, - the prefix and suffix are printed at the beginning and end of - the contents. If no wrapping is required (i.e. everything fits) the - first line will look exactly the same as the column layout: - {indent}{lhs0}{separator}{rhs0} - However if this would go over the width given, the layout now becomes: - {indent}{lhs0} - {indent}{separator}{rhs0} - If {lhs0} would go over the maximum width, the subsequent lines are - indented a second time for ease of reading. - """ - if f"{right['prefix']}{right['contents']}{right['suffix']}" == "": - # No right hand information, so we don't need a separator. - separator = "" - first_line_no_wrap = ( - f"{indent_str}{left['prefix']}{left['contents']}{left['suffix']}" - f"{separator}{right['prefix']}{right['contents']}{right['suffix']}" - ) - if color_len(first_line_no_wrap) < max_width: - # Everything fits, print out line. - print_(first_line_no_wrap) - else: - # Newline separation, with wrapping - empty_space = max_width - len(indent_str) - # On lower lines we will double the indent for clarity - left_width_tuple = ( - empty_space, - empty_space - len(indent_str), - empty_space - len(indent_str), - ) - left_str = f"{left['prefix']}{left['contents']}{left['suffix']}" - left_split = split_into_lines(left_str, left_width_tuple) - # Repeat calculations for rhs, including separator on first line - right_width_tuple = ( - empty_space - color_len(separator), - empty_space - len(indent_str), - empty_space - len(indent_str), - ) - right_str = f"{right['prefix']}{right['contents']}{right['suffix']}" - right_split = split_into_lines(right_str, right_width_tuple) - for i, line in enumerate(left_split): - if i == 0: - print_(f"{indent_str}{line}") - elif line != "": - # Ignore empty lines - print_(f"{indent_str * 2}{line}") - for i, line in enumerate(right_split): - if i == 0: - print_(f"{indent_str}{separator}{line}") - elif line != "": - print_(f"{indent_str * 2}{line}") - - -FLOAT_EPSILON = 0.01 - - -def _multi_value_diff(field: str, oldset: set[str], newset: set[str]) -> str: - added = newset - oldset - removed = oldset - newset - - parts = [ - f"{field}:", - *(colorize("text_diff_removed", f" - {i}") for i in sorted(removed)), - *(colorize("text_diff_added", f" + {i}") for i in sorted(added)), - ] - return "\n".join(parts) - - -def _field_diff( - field: str, old: FormattedMapping, new: FormattedMapping -) -> str | None: - """Given two Model objects and their formatted views, format their values - for `field` and highlight changes among them. Return a human-readable - string. If the value has not changed, return None instead. - """ - # If no change, abort. - if (oldval := old.model.get(field)) == (newval := new.model.get(field)) or ( - isinstance(oldval, float) - and isinstance(newval, float) - and abs(oldval - newval) < FLOAT_EPSILON - ): - return None - - if isinstance(oldval, list): - if (oldset := set(oldval)) != (newset := set(newval)): - return _multi_value_diff(field, oldset, newset) - return None - - # Get formatted values for output. - oldstr, newstr = old.get(field, ""), new.get(field, "") - if field not in new: - return colorize("text_diff_removed", f"{field}: {oldstr}") - - if field not in old: - return colorize("text_diff_added", f"{field}: {newstr}") - - # For strings, highlight changes. For others, colorize the whole thing. - if isinstance(oldval, str): - oldstr, newstr = colordiff(oldstr, newstr) - else: - oldstr = colorize("text_diff_removed", oldstr) - newstr = colorize("text_diff_added", newstr) - - return f"{field}: {oldstr} -> {newstr}" - - def show_model_changes( new: library.LibModel, old: library.LibModel | None = None, @@ -1076,32 +470,14 @@ def show_model_changes( always: bool = False, print_obj: bool = True, ) -> bool: - """Given a Model object, print a list of changes from its pristine - version stored in the database. Return a boolean indicating whether - any changes were found. - - `old` may be the "original" object to avoid using the pristine - version from the database. `fields` may be a list of fields to - restrict the detection to. `always` indicates whether the object is - always identified, regardless of whether any changes are present. + """Print a diff of changes between two library model states. + + Optionally prints the original object label before listing field-level + changes when `print_obj` is enabled. When `always` is set, the object + label is printed even if no changes are detected. Returns whether any + changes were found. """ - old = old or new.get_fresh_from_db() - - # Keep the formatted views around instead of re-creating them in each - # iteration step - old_fmt = old.formatted() - new_fmt = new.formatted() - - # Build up lines showing changed fields. - diff_fields = (set(old) | set(new)) - {"mtime"} - if allowed_fields := set(fields or {}): - diff_fields &= allowed_fields - - changes = [ - d - for f in sorted(diff_fields) - if (d := _field_diff(f, old_fmt, new_fmt)) - ] + changes = get_model_changes(new, old, fields) # Print changes. if print_obj and (changes or always): diff --git a/beets/ui/commands/import_/display.py b/beets/ui/commands/import_/display.py index 764dafd39d..5a42ac6490 100644 --- a/beets/ui/commands/import_/display.py +++ b/beets/ui/commands/import_/display.py @@ -3,13 +3,14 @@ import os from dataclasses import dataclass from functools import cached_property -from typing import TYPE_CHECKING, TypedDict - -from typing_extensions import NotRequired +from typing import TYPE_CHECKING from beets import config, ui from beets.autotag import hooks from beets.util import displayable_path +from beets.util.color import colorize, dist_colorize, uncolorize +from beets.util.diff import colordiff +from beets.util.layout import get_column_layout, get_newline_layout, indent from beets.util.units import human_seconds_short if TYPE_CHECKING: @@ -20,18 +21,12 @@ from beets import autotag from beets.autotag.distance import Distance from beets.library.models import Item - from beets.ui import ColorName + from beets.util.color import ColorName + from beets.util.layout import Side VARIOUS_ARTISTS = "Various Artists" -class Side(TypedDict): - prefix: str - contents: str - suffix: str - width: NotRequired[int] - - @dataclass class ChangeRepresentation: """Keeps track of all information needed to generate a (colored) text @@ -46,7 +41,7 @@ class ChangeRepresentation: @cached_property def changed_prefix(self) -> str: - return ui.colorize("changed", "\u2260") + return colorize("changed", "\u2260") @cached_property def _indentation_config(self) -> confuse.Subview: @@ -54,17 +49,15 @@ def _indentation_config(self) -> confuse.Subview: @cached_property def indent_header(self) -> str: - return ui.indent(self._indentation_config["match_header"].as_number()) + return indent(self._indentation_config["match_header"].get(int)) @cached_property def indent_detail(self) -> str: - return ui.indent(self._indentation_config["match_details"].as_number()) + return indent(self._indentation_config["match_details"].get(int)) @cached_property def indent_tracklist(self) -> str: - return ui.indent( - self._indentation_config["match_tracklist"].as_number() - ) + return indent(self._indentation_config["match_tracklist"].get(int)) @cached_property def layout(self) -> int: @@ -83,10 +76,10 @@ def print_layout( if not max_width: # If no max_width provided, use terminal width max_width = ui.term_width() - if self.layout == 0: - ui.print_column_layout(indent, left, right, separator, max_width) - else: - ui.print_newline_layout(indent, left, right, separator, max_width) + + method = get_column_layout if self.layout == 0 else get_newline_layout + for line in method(indent, left, right, separator, max_width): + ui.print_(line) def show_match_header(self) -> None: """Print out a 'header' identifying the suggested match (album name, @@ -119,7 +112,7 @@ def show_match_header(self) -> None: # Data URL. if self.match.info.data_url: - url = ui.colorize("text_faint", f"{self.match.info.data_url}") + url = colorize("text_faint", f"{self.match.info.data_url}") ui.print_(f"{self.indent_header}{url}") def show_match_details(self) -> None: @@ -134,7 +127,7 @@ def show_match_details(self) -> None: left: Side right: Side if artist_l != artist_r: - artist_l, artist_r = ui.colordiff(artist_l, artist_r) + artist_l, artist_r = colordiff(artist_l, artist_r) left = { "prefix": f"{self.changed_prefix} Artist: ", "contents": artist_l, @@ -150,7 +143,7 @@ def show_match_details(self) -> None: type_ = self.match.type name_l, name_r = self.cur_name or "", self.match.info.name if self.cur_name != self.match.info.name != VARIOUS_ARTISTS: - name_l, name_r = ui.colordiff(name_l, name_r) + name_l, name_r = colordiff(name_l, name_r) left = { "prefix": f"{self.changed_prefix} {type_}: ", "contents": name_l, @@ -215,8 +208,8 @@ def make_track_numbers( else: highlight_color = "text_faint" - lhs_track = ui.colorize(highlight_color, f"(#{cur_track})") - rhs_track = ui.colorize(highlight_color, f"(#{new_track})") + lhs_track = colorize(highlight_color, f"(#{cur_track})") + rhs_track = colorize(highlight_color, f"(#{new_track})") return lhs_track, rhs_track, changed @staticmethod @@ -232,7 +225,7 @@ def make_track_titles( else: # If there is a title, highlight differences. cur_title = item.title.strip() - cur_col, new_col = ui.colordiff(cur_title, new_title) + cur_col, new_col = colordiff(cur_title, new_title) return cur_col, new_col, cur_title != new_title @staticmethod @@ -260,8 +253,8 @@ def make_track_lengths( cur_length = f"({human_seconds_short(cur_length0)})" new_length = f"({human_seconds_short(new_length0)})" # colorize - lhs_length = ui.colorize(highlight_color, cur_length) - rhs_length = ui.colorize(highlight_color, new_length) + lhs_length = colorize(highlight_color, cur_length) + rhs_length = colorize(highlight_color, new_length) return lhs_length, rhs_length, changed @@ -321,7 +314,7 @@ def get_width(side: Side) -> int: """Return the width of left or right in uncolorized characters.""" try: return len( - ui.uncolorize( + uncolorize( " ".join( [side["prefix"], side["contents"], side["suffix"]] ) @@ -410,14 +403,14 @@ def show_match_tracks(self) -> None: line = f" ! {track_info.title} (#{self.format_index(track_info)})" if track_info.length: line += f" ({human_seconds_short(track_info.length)})" - ui.print_(ui.colorize("text_warning", line)) + ui.print_(colorize("text_warning", line)) if self.match.extra_items: ui.print_(f"Unmatched tracks ({len(self.match.extra_items)}):") for item in self.match.extra_items: line = f" ! {item.title} (#{self.format_index(item)})" if item.length: line += f" ({human_seconds_short(item.length)})" - ui.print_(ui.colorize("text_warning", line)) + ui.print_(colorize("text_warning", line)) class TrackChange(ChangeRepresentation): @@ -522,19 +515,6 @@ def get_album_disambig_fields(info: hooks.AlbumInfo) -> Sequence[str]: return out -def dist_colorize(string: str, dist: Distance) -> str: - """Formats a string as a colorized similarity string according to - a distance. - """ - if dist <= config["match"]["strong_rec_thresh"].as_number(): - string = ui.colorize("text_success", string) - elif dist <= config["match"]["medium_rec_thresh"].as_number(): - string = ui.colorize("text_warning", string) - else: - string = ui.colorize("text_error", string) - return string - - def dist_string(dist: Distance) -> str: """Formats a distance (a float) as a colorized similarity percentage string. @@ -558,6 +538,6 @@ def penalty_string(distance: Distance, limit: int | None = None) -> str: penalties = [*penalties[:limit], "..."] # Prefix penalty string with U+2260: Not Equal To penalty_string = f"\u2260 {', '.join(penalties)}" - return ui.colorize("changed", penalty_string) + return colorize("changed", penalty_string) return "" diff --git a/beets/ui/commands/import_/session.py b/beets/ui/commands/import_/session.py index 1848e41925..9228ac4e26 100644 --- a/beets/ui/commands/import_/session.py +++ b/beets/ui/commands/import_/session.py @@ -4,11 +4,11 @@ from beets import autotag, config, importer, logging, plugins, ui from beets.autotag import Recommendation from beets.util import PromptChoice, displayable_path +from beets.util.color import colorize, dist_colorize from beets.util.units import human_bytes, human_seconds_short from .display import ( disambig_string, - dist_colorize, penalty_string, show_change, show_item_change, @@ -30,9 +30,9 @@ def choose_match(self, task): ui.print_() path_str0 = displayable_path(task.paths, "\n") - path_str = ui.colorize("import_path", path_str0) + path_str = colorize("import_path", path_str0) items_str0 = f"({len(task.items)} items)" - items_str = ui.colorize("import_path_items", items_str0) + items_str = colorize("import_path_items", items_str0) ui.print_(" ".join([path_str, items_str])) # Let plugins display info or prompt the user before we go through the @@ -447,7 +447,7 @@ def choose_candidate( if i == 0: metadata = dist_colorize(metadata, match.distance) else: - metadata = ui.colorize("text_highlight_minor", metadata) + metadata = colorize("text_highlight_minor", metadata) line1 = [index, distance, metadata] ui.print_(f" {' '.join(line1)}") diff --git a/beets/ui/commands/move.py b/beets/ui/commands/move.py index 206c24dcfc..7774bf43b2 100644 --- a/beets/ui/commands/move.py +++ b/beets/ui/commands/move.py @@ -7,6 +7,7 @@ from beets import logging, ui from beets.util import MoveOperation, displayable_path, normpath, syspath +from beets.util.diff import colordiff from .utils import do_query @@ -45,7 +46,7 @@ def show_path_changes(path_changes): if max_width > col_width: # Print every change over two lines for source, dest in zip(sources, destinations): - color_source, color_dest = ui.colordiff(source, dest) + color_source, color_dest = colordiff(source, dest) ui.print_(f"{color_source} \n -> {color_dest}") else: # Print every change on a single line, and add a header @@ -54,7 +55,7 @@ def show_path_changes(path_changes): ui.print_(f"Source {' ' * title_pad} Destination") for source, dest in zip(sources, destinations): pad = max_width - len(source) - color_source, color_dest = ui.colordiff(source, dest) + color_source, color_dest = colordiff(source, dest) ui.print_(f"{color_source} {' ' * pad} -> {color_dest}") diff --git a/beets/ui/commands/update.py b/beets/ui/commands/update.py index 9286bf12bc..ddf88c325a 100644 --- a/beets/ui/commands/update.py +++ b/beets/ui/commands/update.py @@ -4,6 +4,7 @@ from beets import library, logging, ui from beets.util import ancestry, syspath +from beets.util.color import colorize from .utils import do_query @@ -48,7 +49,7 @@ def update_items(lib, query, album, move, pretend, fields, exclude_fields=None): # Item deleted? if not item.path or not os.path.exists(syspath(item.path)): ui.print_(format(item)) - ui.print_(ui.colorize("text_error", " deleted")) + ui.print_(colorize("text_error", " deleted")) if not pretend: item.remove(True) affected_albums.add(item.album_id) diff --git a/beets/util/color.py b/beets/util/color.py new file mode 100644 index 0000000000..944f1cb96e --- /dev/null +++ b/beets/util/color.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import os +import re +from functools import cache +from typing import TYPE_CHECKING, Literal + +import confuse + +from beets import config + +if TYPE_CHECKING: + from beets.autotag.distance import Distance + +# 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" +LEGACY_COLORS = { + "black": ["black"], + "darkred": ["red"], + "darkgreen": ["green"], + "brown": ["yellow"], + "darkyellow": ["yellow"], + "darkblue": ["blue"], + "purple": ["magenta"], + "darkmagenta": ["magenta"], + "teal": ["cyan"], + "darkcyan": ["cyan"], + "lightgray": ["white"], + "darkgray": ["bold", "black"], + "red": ["bold", "red"], + "green": ["bold", "green"], + "yellow": ["bold", "yellow"], + "blue": ["bold", "blue"], + "fuchsia": ["bold", "magenta"], + "magenta": ["bold", "magenta"], + "turquoise": ["bold", "cyan"], + "cyan": ["bold", "cyan"], + "white": ["bold", "white"], +} +# All ANSI Colors. +CODE_BY_COLOR = { + # Styles. + "normal": 0, + "bold": 1, + "faint": 2, + "italic": 3, + "underline": 4, + "blink_slow": 5, + "blink_rapid": 6, + "inverse": 7, + "conceal": 8, + "crossed_out": 9, + # Text colors. + "black": 30, + "red": 31, + "green": 32, + "yellow": 33, + "blue": 34, + "magenta": 35, + "cyan": 36, + "white": 37, + "bright_black": 90, + "bright_red": 91, + "bright_green": 92, + "bright_yellow": 93, + "bright_blue": 94, + "bright_magenta": 95, + "bright_cyan": 96, + "bright_white": 97, + # Background colors. + "bg_black": 40, + "bg_red": 41, + "bg_green": 42, + "bg_yellow": 43, + "bg_blue": 44, + "bg_magenta": 45, + "bg_cyan": 46, + "bg_white": 47, + "bg_bright_black": 100, + "bg_bright_red": 101, + "bg_bright_green": 102, + "bg_bright_yellow": 103, + "bg_bright_blue": 104, + "bg_bright_magenta": 105, + "bg_bright_cyan": 106, + "bg_bright_white": 107, +} +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", + "text_error", + "text_highlight", + "text_highlight_minor", + "action_default", + "action", + # New Colors + "text_faint", + "import_path", + "import_path_items", + "action_description", + "changed", + "text_diff_added", + "text_diff_removed", +] + + +@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. + """ + template_dict: dict[ColorName, confuse.OneOf[str | list[str]]] = { + n: confuse.OneOf( + [ + confuse.Choice(sorted(LEGACY_COLORS)), + confuse.Sequence(confuse.Choice(sorted(CODE_BY_COLOR))), + ] + ) + for n in ColorName.__args__ # type: ignore[attr-defined] + } + template = confuse.MappingTemplate(template_dict) + colors_by_color_name = { + k: (v if isinstance(v, list) else LEGACY_COLORS.get(v, [v])) + for k, v in config["ui"]["colors"].get(template).items() + } + + 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: ColorName, text: str) -> str: + """Apply ANSI color formatting to text based on configuration settings.""" + color_code = get_color_config()[color_name] + return f"{COLOR_ESCAPE}[{color_code}m{text}{RESET_COLOR}" + + +def colorize(color_name: ColorName, text: str) -> str: + """Colorize text when color output is enabled.""" + if config["ui"]["color"] and "NO_COLOR" not in os.environ: + return _colorize(color_name, text) + + return text + + +def dist_colorize(string: str, dist: Distance) -> str: + """Formats a string as a colorized similarity string according to + a distance. + """ + if dist <= config["match"]["strong_rec_thresh"].as_number(): + string = colorize("text_success", string) + elif dist <= config["match"]["medium_rec_thresh"].as_number(): + string = colorize("text_warning", string) + else: + string = colorize("text_error", string) + return string + + +def uncolorize(colored_text: str) -> str: + """Remove colors from a string.""" + # Define a regular expression to match ANSI codes. + # See: http://stackoverflow.com/a/2187024/1382707 + # Explanation of regular expression: + # \x1b - matches ESC character + # \[ - matches opening square bracket + # [;\d]* - matches a sequence consisting of one or more digits or + # semicola + # [A-Za-z] - matches a letter + return ANSI_CODE_REGEX.sub("", colored_text) + + +def color_split(colored_text: str, index: int) -> tuple[str, str]: + length = 0 + pre_split = "" + post_split = "" + found_color_code = None + found_split = False + 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): + # This is a color code + if part == RESET_COLOR: + found_color_code = None + else: + found_color_code = part + pre_split += part + else: + if index < length: + # Found part with our split in. + split_index = index - (length - color_len(part)) + found_split = True + if found_color_code: + pre_split += f"{part[:split_index]}{RESET_COLOR}" + post_split += f"{found_color_code}{part[split_index:]}" + else: + pre_split += part[:split_index] + post_split += part[split_index:] + else: + # Not found, add this part to the pre split + pre_split += part + return pre_split, post_split + + +def color_len(colored_text: str) -> int: + """Measure the length of a string while excluding ANSI codes from the + measurement. The standard `len(my_string)` method also counts ANSI codes + to the string length, which is counterproductive when layouting a + Terminal interface. + """ + # Return the length of the uncolored string. + return len(uncolorize(colored_text)) diff --git a/beets/util/diff.py b/beets/util/diff.py new file mode 100644 index 0000000000..9feb658970 --- /dev/null +++ b/beets/util/diff.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from difflib import SequenceMatcher +from typing import TYPE_CHECKING + +from .color import colorize + +if TYPE_CHECKING: + from collections.abc import Iterable + + from beets.dbcore.db import FormattedMapping + from beets.library.models import LibModel + + +def colordiff(a: str, b: str) -> tuple[str, str]: + """Intelligently highlight the differences between two strings.""" + before = "" + after = "" + + matcher = SequenceMatcher(lambda _: False, a, b) + for op, a_start, a_end, b_start, b_end in matcher.get_opcodes(): + 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) + + before += before_part + after += after_part + + return before, after + + +FLOAT_EPSILON = 0.01 + + +def _multi_value_diff(field: str, oldset: set[str], newset: set[str]) -> str: + added = newset - oldset + removed = oldset - newset + + parts = [ + f"{field}:", + *(colorize("text_diff_removed", f" - {i}") for i in sorted(removed)), + *(colorize("text_diff_added", f" + {i}") for i in sorted(added)), + ] + return "\n".join(parts) + + +def _field_diff( + field: str, old: FormattedMapping, new: FormattedMapping +) -> str | None: + """Given two Model objects and their formatted views, format their values + for `field` and highlight changes among them. Return a human-readable + string. If the value has not changed, return None instead. + """ + # If no change, abort. + if (oldval := old.model.get(field)) == (newval := new.model.get(field)) or ( + isinstance(oldval, float) + and isinstance(newval, float) + and abs(oldval - newval) < FLOAT_EPSILON + ): + return None + + if isinstance(oldval, list): + if (oldset := set(oldval)) != (newset := set(newval)): + return _multi_value_diff(field, oldset, newset) + return None + + # Get formatted values for output. + oldstr, newstr = old.get(field, ""), new.get(field, "") + if field not in new: + return colorize("text_diff_removed", f"{field}: {oldstr}") + + if field not in old: + return colorize("text_diff_added", f"{field}: {newstr}") + + # For strings, highlight changes. For others, colorize the whole thing. + if isinstance(oldval, str): + oldstr, newstr = colordiff(oldstr, newstr) + else: + oldstr = colorize("text_diff_removed", oldstr) + newstr = colorize("text_diff_added", newstr) + + return f"{field}: {oldstr} -> {newstr}" + + +def get_model_changes( + new: LibModel, + old: LibModel | None, + fields: Iterable[str] | None, +) -> list[str]: + """Compute human-readable diff lines for changed fields between two models. + + Compares `new` against `old`, falling back to the database version of + `new` when `old` is not provided. When `fields` is given, only those + fields are considered. The `mtime` field is always excluded. + """ + old = old or new.get_fresh_from_db() + + # Keep the formatted views around instead of re-creating them in each + # iteration step + old_fmt = old.formatted() + new_fmt = new.formatted() + + # Build up lines showing changed fields. + diff_fields = (set(old) | set(new)) - {"mtime"} + if allowed_fields := set(fields or {}): + diff_fields &= allowed_fields + + return [ + d + for f in sorted(diff_fields) + if (d := _field_diff(f, old_fmt, new_fmt)) + ] diff --git a/beets/util/layout.py b/beets/util/layout.py new file mode 100644 index 0000000000..fbccb2843d --- /dev/null +++ b/beets/util/layout.py @@ -0,0 +1,358 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, TypedDict + +from typing_extensions import NotRequired + +from .color import ( + ESC_TEXT_REGEX, + RESET_COLOR, + color_len, + color_split, + uncolorize, +) + +if TYPE_CHECKING: + from collections.abc import Iterator + + +class Side(TypedDict): + prefix: str + contents: str + suffix: str + width: NotRequired[int] + + +def indent(count: int) -> str: + """Returns a string with `count` many spaces.""" + return " " * count + + +def split_into_lines( + string: str, width_tuple: tuple[int, int, int] +) -> list[str]: + """Splits string into a list of substrings at whitespace. + + `width_tuple` is a 3-tuple of `(first_width, last_width, middle_width)`. + The first substring has a length not longer than `first_width`, the last + substring has a length not longer than `last_width`, and all other + substrings have a length not longer than `middle_width`. + `string` may contain ANSI codes at word borders. + """ + first_width, middle_width, last_width = width_tuple + words = [] + + 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_REGEX.finditer(string): + # m contains four groups: + # pretext - any text before escape sequence + # esc - intitial escape sequence + # text - text, no escape sequence, may contain spaces + # reset - ASCII colour reset + space_before_text = False + if m.group("pretext") != "": + # Some pretext found, let's handle it + # Add any words in the pretext + words += m.group("pretext").split() + if m.group("pretext")[-1] == " ": + # Pretext ended on a space + space_before_text = True + else: + # Pretext ended mid-word, ensure next word + pass + else: + # pretext empty, treat as if there is a space before + space_before_text = True + if m.group("text")[0] == " ": + # First character of the text is a space + space_before_text = True + # Now, handle the words in the main text: + raw_words = m.group("text").split() + if space_before_text: + # Colorize each word with pre/post escapes + # Reconstruct colored words + words += [ + f"{m['esc']}{raw_word}{RESET_COLOR}" + for raw_word in raw_words + ] + elif raw_words: + # Pretext stops mid-word + if m.group("esc") != RESET_COLOR: + # Add the rest of the current word, with a reset after it + words[-1] += f"{m['esc']}{raw_words[0]}{RESET_COLOR}" + # Add the subsequent colored words: + words += [ + f"{m['esc']}{raw_word}{RESET_COLOR}" + for raw_word in raw_words[1:] + ] + else: + # Caught a mid-word escape sequence + words[-1] += raw_words[0] + words += raw_words[1:] + if ( + m.group("text")[-1] != " " + and m.group("posttext") != "" + and m.group("posttext")[0] != " " + ): + # reset falls mid-word + post_text = m.group("posttext").split() + words[-1] += post_text[0] + words += post_text[1:] + else: + # Add any words after escape sequence + words += m.group("posttext").split() + result: list[str] = [] + next_substr = "" + # Iterate over all words. + previous_fit = False + for i in range(len(words)): + if i == 0: + pot_substr = words[i] + else: + # (optimistically) add the next word to check the fit + pot_substr = " ".join([next_substr, words[i]]) + # Find out if the pot(ential)_substr fits into the next substring. + fits_first = len(result) == 0 and color_len(pot_substr) <= first_width + fits_middle = len(result) != 0 and color_len(pot_substr) <= middle_width + if fits_first or fits_middle: + # Fitted(!) let's try and add another word before appending + next_substr = pot_substr + previous_fit = True + elif not fits_first and not fits_middle and previous_fit: + # Extra word didn't fit, append what we have + result.append(next_substr) + next_substr = words[i] + previous_fit = color_len(next_substr) <= middle_width + else: + # Didn't fit anywhere + if uncolorize(pot_substr) == pot_substr: + # Simple uncolored string, append a cropped word + if len(result) == 0: + # Crop word by the first_width for the first line + result.append(pot_substr[:first_width]) + # add rest of word to next line + next_substr = pot_substr[first_width:] + else: + result.append(pot_substr[:middle_width]) + next_substr = pot_substr[middle_width:] + else: + # Colored strings + if len(result) == 0: + this_line, next_line = color_split(pot_substr, first_width) + result.append(this_line) + next_substr = next_line + else: + this_line, next_line = color_split(pot_substr, middle_width) + result.append(this_line) + next_substr = next_line + previous_fit = color_len(next_substr) <= middle_width + + # We finished constructing the substrings, but the last substring + # has not yet been added to the result. + result.append(next_substr) + # Also, the length of the last substring was only checked against + # `middle_width`. Append an empty substring as the new last substring if + # the last substring is too long. + if not color_len(next_substr) <= last_width: + result.append("") + return result + + +def get_column_layout( + indent_str: str, + left: Side, + right: Side, + separator: str, + max_width: int, +) -> Iterator[str]: + """Print left & right data, with separator inbetween + 'left' and 'right' have a structure of: + {'prefix':u'','contents':u'','suffix':u'','width':0} + In a column layout the printing will be: + {indent_str}{lhs0}{separator}{rhs0} + {lhs1 / padding }{rhs1} + ... + The first line of each column (i.e. {lhs0} or {rhs0}) is: + {prefix}{part of contents}{suffix} + With subsequent lines (i.e. {lhs1}, {rhs1} onwards) being the + rest of contents, wrapped if the width would be otherwise exceeded. + """ + if f"{right['prefix']}{right['contents']}{right['suffix']}" == "": + # No right hand information, so we don't need a separator. + separator = "" + first_line_no_wrap = ( + f"{indent_str}{left['prefix']}{left['contents']}{left['suffix']}" + f"{separator}{right['prefix']}{right['contents']}{right['suffix']}" + ) + if color_len(first_line_no_wrap) < max_width: + # Everything fits, print out line. + yield first_line_no_wrap + else: + # Wrap into columns + if "width" not in left or "width" not in right: + # If widths have not been defined, set to share space. + left["width"] = ( + max_width - len(indent_str) - color_len(separator) + ) // 2 + right["width"] = ( + max_width - len(indent_str) - color_len(separator) + ) // 2 + # On the first line, account for suffix as well as prefix + left_width_tuple = ( + left["width"] + - color_len(left["prefix"]) + - color_len(left["suffix"]), + left["width"] - color_len(left["prefix"]), + left["width"] - color_len(left["prefix"]), + ) + + left_split = split_into_lines(left["contents"], left_width_tuple) + right_width_tuple = ( + right["width"] + - color_len(right["prefix"]) + - color_len(right["suffix"]), + right["width"] - color_len(right["prefix"]), + right["width"] - color_len(right["prefix"]), + ) + + right_split = split_into_lines(right["contents"], right_width_tuple) + max_line_count = max(len(left_split), len(right_split)) + + out = "" + for i in range(max_line_count): + # indentation + out += indent_str + + # Prefix or indent_str for line + if i == 0: + out += left["prefix"] + else: + out += indent(color_len(left["prefix"])) + + # Line i of left hand side contents. + if i < len(left_split): + out += left_split[i] + left_part_len = color_len(left_split[i]) + else: + left_part_len = 0 + + # Padding until end of column. + # Note: differs from original + # column calcs in not -1 afterwards for space + # in track number as that is included in 'prefix' + padding = left["width"] - color_len(left["prefix"]) - left_part_len + + # Remove some padding on the first line to display + # length + if i == 0: + padding -= color_len(left["suffix"]) + + out += indent(padding) + + if i == 0: + out += left["suffix"] + + # Separator between columns. + if i == 0: + out += separator + else: + out += indent(color_len(separator)) + + # Right prefix, contents, padding, suffix + if i == 0: + out += right["prefix"] + else: + out += indent(color_len(right["prefix"])) + + # Line i of right hand side. + if i < len(right_split): + out += right_split[i] + right_part_len = color_len(right_split[i]) + else: + right_part_len = 0 + + # Padding until end of column + padding = ( + right["width"] - color_len(right["prefix"]) - right_part_len + ) + # Remove some padding on the first line to display + # length + if i == 0: + padding -= color_len(right["suffix"]) + out += indent(padding) + # Length in first line + if i == 0: + out += right["suffix"] + + # Linebreak, except in the last line. + if i < max_line_count - 1: + out += "\n" + + # Constructed all of the columns, now print + yield out + + +def get_newline_layout( + indent_str: str, + left: Side, + right: Side, + separator: str, + max_width: int, +) -> Iterator[str]: + """Prints using a newline separator between left & right if + they go over their allocated widths. The datastructures are + shared with the column layout. In contrast to the column layout, + the prefix and suffix are printed at the beginning and end of + the contents. If no wrapping is required (i.e. everything fits) the + first line will look exactly the same as the column layout: + {indent}{lhs0}{separator}{rhs0} + However if this would go over the width given, the layout now becomes: + {indent}{lhs0} + {indent}{separator}{rhs0} + If {lhs0} would go over the maximum width, the subsequent lines are + indented a second time for ease of reading. + """ + if f"{right['prefix']}{right['contents']}{right['suffix']}" == "": + # No right hand information, so we don't need a separator. + separator = "" + first_line_no_wrap = ( + f"{indent_str}{left['prefix']}{left['contents']}{left['suffix']}" + f"{separator}{right['prefix']}{right['contents']}{right['suffix']}" + ) + if color_len(first_line_no_wrap) < max_width: + # Everything fits, print out line. + yield first_line_no_wrap + else: + # Newline separation, with wrapping + empty_space = max_width - len(indent_str) + # On lower lines we will double the indent for clarity + left_width_tuple = ( + empty_space, + empty_space - len(indent_str), + empty_space - len(indent_str), + ) + left_str = f"{left['prefix']}{left['contents']}{left['suffix']}" + left_split = split_into_lines(left_str, left_width_tuple) + # Repeat calculations for rhs, including separator on first line + right_width_tuple = ( + empty_space - color_len(separator), + empty_space - len(indent_str), + empty_space - len(indent_str), + ) + right_str = f"{right['prefix']}{right['contents']}{right['suffix']}" + right_split = split_into_lines(right_str, right_width_tuple) + for i, line in enumerate(left_split): + if i == 0: + yield f"{indent_str}{line}" + elif line != "": + # Ignore empty lines + yield f"{indent_str * 2}{line}" + for i, line in enumerate(right_split): + if i == 0: + yield f"{indent_str}{separator}{line}" + elif line != "": + yield f"{indent_str * 2}{line}" diff --git a/beetsplug/badfiles.py b/beetsplug/badfiles.py index 70509ce4ff..f0ee5db7fd 100644 --- a/beetsplug/badfiles.py +++ b/beetsplug/badfiles.py @@ -26,6 +26,7 @@ from beets.plugins import BeetsPlugin from beets.ui import Subcommand from beets.util import displayable_path, par_map +from beets.util.color import colorize class CheckerCommandError(Exception): @@ -109,9 +110,7 @@ def check_item(self, item): dpath = displayable_path(item.path) self._log.debug("checking path: {}", dpath) if not os.path.exists(item.path): - ui.print_( - f"{ui.colorize('text_error', dpath)}: file does not exist" - ) + ui.print_(f"{colorize('text_error', dpath)}: file does not exist") # Run the checker against the file if one is found ext = os.path.splitext(item.path)[1][1:].decode("utf8", "ignore") @@ -138,21 +137,20 @@ def check_item(self, item): if status > 0: error_lines.append( - f"{ui.colorize('text_error', dpath)}: checker exited with" - f" status {status}" + f"{colorize('text_error', dpath)}: checker exited with status {status}" ) for line in output: error_lines.append(f" {line}") elif errors > 0: error_lines.append( - f"{ui.colorize('text_warning', dpath)}: checker found" + f"{colorize('text_warning', dpath)}: checker found" f" {errors} errors or warnings" ) for line in output: error_lines.append(f" {line}") elif self.verbose: - error_lines.append(f"{ui.colorize('text_success', dpath)}: ok") + error_lines.append(f"{colorize('text_success', dpath)}: ok") return error_lines @@ -173,8 +171,7 @@ def on_import_task_start(self, task, session): def on_import_task_before_choice(self, task, session): if hasattr(task, "_badfiles_checks_failed"): ui.print_( - f"{ui.colorize('text_warning', 'BAD')} one or more files failed" - " checks:" + f"{colorize('text_warning', 'BAD')} one or more files failed checks:" ) for error in task._badfiles_checks_failed: for error_line in error: diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 789182c33d..1819d45319 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -32,6 +32,7 @@ from beets import config, importer, plugins, ui, util from beets.util import bytestring_path, get_temp_filename, sorted_walk, syspath from beets.util.artresizer import ArtResizer +from beets.util.color import colorize from beets.util.config import sanitize_pairs if TYPE_CHECKING: @@ -1596,9 +1597,7 @@ def batch_fetch_art( and os.path.isfile(syspath(album.artpath)) ): if not quiet: - message = ui.colorize( - "text_highlight_minor", "has album art" - ) + message = colorize("text_highlight_minor", "has album art") ui.print_(f"{album}: {message}") else: # In ordinary invocations, look for images on the @@ -1609,7 +1608,7 @@ def batch_fetch_art( candidate = self.art_for_album(album, local_paths) if candidate: self._set_art(album, candidate) - message = ui.colorize("text_success", "found album art") + message = colorize("text_success", "found album art") else: - message = ui.colorize("text_error", "no art found") + message = colorize("text_error", "no art found") ui.print_(f"{album}: {message}") diff --git a/beetsplug/importsource.py b/beetsplug/importsource.py index e42be3f1f9..1787c6c256 100644 --- a/beetsplug/importsource.py +++ b/beetsplug/importsource.py @@ -10,8 +10,8 @@ from beets.dbcore.query import PathQuery from beets.plugins import BeetsPlugin -from beets.ui import colorize as colorize_text from beets.ui import input_options +from beets.util.color import colorize class ImportSourcePlugin(BeetsPlugin): @@ -94,8 +94,8 @@ def suggest_removal(self, item): # We ask the user whether they'd like to delete the item's source # directory - item_path = colorize_text("text_warning", item.filepath) - source_path = colorize_text("text_warning", srcpath) + item_path = colorize("text_warning", item.filepath) + source_path = colorize("text_warning", srcpath) print( f"The item:\n{item_path}\nis originated from:\n{source_path}\n" @@ -136,7 +136,7 @@ def suggest_removal(self, item): print("Doing so will delete the following items' sources as well:") for searched_item in item._db.items(source_dir_query): - print(colorize_text("text_warning", searched_item.filepath)) + print(colorize("text_warning", searched_item.filepath)) print("Would you like to continue?") continue_resp = input_options( diff --git a/beetsplug/play.py b/beetsplug/play.py index 0d96ee97f6..2474f3908a 100644 --- a/beetsplug/play.py +++ b/beetsplug/play.py @@ -22,6 +22,7 @@ from beets.plugins import BeetsPlugin from beets.ui import Subcommand from beets.util import PromptChoice, get_temp_filename +from beets.util.color import colorize # Indicate where arguments should be inserted into the command string. # If this is missing, they're placed at the end. @@ -132,7 +133,7 @@ def _play_command(self, lib, opts, args): paths = [relpath(path, relative_to) for path in paths] if not selection: - ui.print_(ui.colorize("text_warning", f"No {item_type} to play.")) + ui.print_(colorize("text_warning", f"No {item_type} to play.")) return open_args = self._playlist_or_paths(paths) @@ -197,7 +198,7 @@ def _exceeds_threshold( item_type += "s" ui.print_( - ui.colorize( + colorize( "text_warning", f"You are about to queue {len(selection)} {item_type}.", ) diff --git a/pyproject.toml b/pyproject.toml index 43775351a9..89abb6c7ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -329,7 +329,7 @@ ignore = [ "beets/**" = ["PT"] "test/plugins/test_ftintitle.py" = ["E501"] "test/test_util.py" = ["E501"] -"test/ui/test_field_diff.py" = ["E501"] +"test/util/test_diff.py" = ["E501"] "test/util/test_id_extractors.py" = ["E501"] "test/**" = ["RUF001"] # we use Unicode characters in tests diff --git a/test/ui/commands/test_import.py b/test/ui/commands/test_import.py index 6e96c3bf36..294111a4e8 100644 --- a/test/ui/commands/test_import.py +++ b/test/ui/commands/test_import.py @@ -142,50 +142,6 @@ def test_item_data_change_title_missing_with_unicode_filename(self): msg = re.sub(r" +", " ", self._show_change()) assert "caf\xe9.mp3" in msg or "caf.mp3" in msg - def test_colorize(self): - assert "test" == ui.uncolorize("test") - txt = ui.uncolorize("\x1b[31mtest\x1b[39;49;00m") - assert "test" == txt - txt = ui.uncolorize("\x1b[31mtest\x1b[39;49;00m test") - assert "test test" == txt - txt = ui.uncolorize("\x1b[31mtest\x1b[39;49;00mtest") - assert "testtest" == txt - txt = ui.uncolorize("test \x1b[31mtest\x1b[39;49;00m test") - assert "test test test" == txt - - def test_color_split(self): - exp = ("test", "") - res = ui.color_split("test", 5) - assert exp == res - exp = ("\x1b[31mtes\x1b[39;49;00m", "\x1b[31mt\x1b[39;49;00m") - res = ui.color_split("\x1b[31mtest\x1b[39;49;00m", 3) - assert exp == res - - def test_split_into_lines(self): - # Test uncolored text - txt = ui.split_into_lines("test test test", [5, 5, 5]) - assert txt == ["test", "test", "test"] - # Test multiple colored texts - colored_text = "\x1b[31mtest \x1b[39;49;00m" * 3 - split_txt = [ - "\x1b[31mtest\x1b[39;49;00m", - "\x1b[31mtest\x1b[39;49;00m", - "\x1b[31mtest\x1b[39;49;00m", - ] - txt = ui.split_into_lines(colored_text, [5, 5, 5]) - assert txt == split_txt - # Test single color, multi space text - colored_text = "\x1b[31m test test test \x1b[39;49;00m" - txt = ui.split_into_lines(colored_text, [5, 5, 5]) - assert txt == split_txt - # Test single color, different spacing - colored_text = "\x1b[31mtest\x1b[39;49;00mtest test test" - # ToDo: fix color_len to handle mid-text color escapes, and thus - # split colored texts over newlines (potentially with dashes?) - split_txt = ["\x1b[31mtest\x1b[39;49;00mt", "est", "test", "test"] - txt = ui.split_into_lines(colored_text, [5, 5, 5]) - assert txt == split_txt - def test_album_data_change_wrap_newline(self): # Patch ui.term_width to force wrapping with patch("beets.ui.term_width", return_value=30): diff --git a/test/ui/test_ui.py b/test/ui/test_ui.py index 577954a85c..dd5a2b4600 100644 --- a/test/ui/test_ui.py +++ b/test/ui/test_ui.py @@ -329,57 +329,6 @@ def test_beetsdir_config_paths_resolve_relative_to_beetsdir(self): assert config["statefile"].as_path() == self.beetsdir / "state" -class ShowModelChangeTest(IOMixin, unittest.TestCase): - def setUp(self): - super().setUp() - self.a = _common.item() - self.b = _common.item() - self.a.path = self.b.path - - def _show(self, **kwargs): - change = ui.show_model_changes(self.a, self.b, **kwargs) - out = self.io.getoutput() - return change, out - - def test_identical(self): - change, out = self._show() - assert not change - assert out == "" - - def test_string_fixed_field_change(self): - self.b.title = "x" - change, out = self._show() - assert change - assert "title" in out - - def test_int_fixed_field_change(self): - self.b.track = 9 - change, out = self._show() - assert change - assert "track" in out - - def test_floats_close_to_identical(self): - self.a.length = 1.00001 - self.b.length = 1.00005 - change, out = self._show() - assert not change - assert out == "" - - def test_floats_different(self): - self.a.length = 1.00001 - self.b.length = 2.00001 - change, out = self._show() - assert change - assert "length" in out - - def test_both_values_shown(self): - self.a.title = "foo" - self.b.title = "bar" - _, out = self._show() - assert "foo" in out - assert "bar" in out - - class PathFormatTest(unittest.TestCase): def test_custom_paths_prepend(self): default_formats = ui.get_path_formats() diff --git a/test/util/test_color.py b/test/util/test_color.py new file mode 100644 index 0000000000..98aa994c62 --- /dev/null +++ b/test/util/test_color.py @@ -0,0 +1,24 @@ +from unittest import TestCase + +from beets.util.color import color_split, uncolorize + + +class ColorTestCase(TestCase): + def test_uncolorize(self): + assert "test" == uncolorize("test") + txt = uncolorize("\x1b[31mtest\x1b[39;49;00m") + assert "test" == txt + txt = uncolorize("\x1b[31mtest\x1b[39;49;00m test") + assert "test test" == txt + txt = uncolorize("\x1b[31mtest\x1b[39;49;00mtest") + assert "testtest" == txt + txt = uncolorize("test \x1b[31mtest\x1b[39;49;00m test") + assert "test test test" == txt + + def test_color_split(self): + exp = ("test", "") + res = color_split("test", 5) + assert exp == res + exp = ("\x1b[31mtes\x1b[39;49;00m", "\x1b[31mt\x1b[39;49;00m") + res = color_split("\x1b[31mtest\x1b[39;49;00m", 3) + assert exp == res diff --git a/test/ui/test_field_diff.py b/test/util/test_diff.py similarity index 97% rename from test/ui/test_field_diff.py rename to test/util/test_diff.py index 24bac0123f..0109940138 100644 --- a/test/ui/test_field_diff.py +++ b/test/util/test_diff.py @@ -3,7 +3,7 @@ import pytest from beets.library import Item -from beets.ui import _field_diff +from beets.util.diff import _field_diff p = pytest.param @@ -17,7 +17,7 @@ def configure_color(self, config, color): def patch_colorize(self, monkeypatch): """Patch to return a deterministic string format instead of ANSI codes.""" monkeypatch.setattr( - "beets.ui._colorize", + "beets.util.color._colorize", lambda color_name, text: f"[{color_name}]{text}[/]", ) diff --git a/test/util/test_layout.py b/test/util/test_layout.py new file mode 100644 index 0000000000..302999e517 --- /dev/null +++ b/test/util/test_layout.py @@ -0,0 +1,30 @@ +from unittest import TestCase + +from beets.util.layout import split_into_lines + + +class LayoutTestCase(TestCase): + def test_split_into_lines(self): + # Test uncolored text + txt = split_into_lines("test test test", [5, 5, 5]) + assert txt == ["test", "test", "test"] + # Test multiple colored texts + colored_text = "\x1b[31mtest \x1b[39;49;00m" * 3 + split_txt = [ + "\x1b[31mtest\x1b[39;49;00m", + "\x1b[31mtest\x1b[39;49;00m", + "\x1b[31mtest\x1b[39;49;00m", + ] + txt = split_into_lines(colored_text, [5, 5, 5]) + assert txt == split_txt + # Test single color, multi space text + colored_text = "\x1b[31m test test test \x1b[39;49;00m" + txt = split_into_lines(colored_text, [5, 5, 5]) + assert txt == split_txt + # Test single color, different spacing + colored_text = "\x1b[31mtest\x1b[39;49;00mtest test test" + # ToDo: fix color_len to handle mid-text color escapes, and thus + # split colored texts over newlines (potentially with dashes?) + split_txt = ["\x1b[31mtest\x1b[39;49;00mt", "est", "test", "test"] + txt = split_into_lines(colored_text, [5, 5, 5]) + assert txt == split_txt