From be290e54441987fdb80437f7982f4d5ad01bac59 Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Sat, 14 Oct 2023 10:42:48 +0200 Subject: [PATCH] A large code-overhaul of the beets ui: - Allow user to change UI colors in config file. - "Change Representation" class allows Albums and Track matches to reuse similar formatting code - Functions to split text into lines for printing - Tests for the new UI to check wrapping functions --- beets/config_default.yaml | 35 +- beets/ui/__init__.py | 527 +++++++++++++++++++++++++----- beets/ui/commands.py | 669 +++++++++++++++++++++++++------------- test/test_ui.py | 87 ++++- 4 files changed, 1000 insertions(+), 318 deletions(-) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 358614d00..114e9b6e8 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -97,13 +97,34 @@ ui: length_diff_thresh: 10.0 color: yes colors: - text_success: green - text_warning: yellow - text_error: red - text_highlight: red - text_highlight_minor: lightgray - action_default: turquoise - action: blue + text_success: ['bold', 'green'] + text_warning: ['bold', 'yellow'] + text_error: ['bold', 'red'] + text_highlight: ['bold', 'red'] + text_highlight_minor: ['white'] + action_default: ['bold', 'cyan'] + action: ['bold', 'blue'] + # New Colors + text: ['normal'] + text_faint: ['faint'] + import_path: ['bold', 'inverse', '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: ['blue'] + import: + indentation: + match_header: 2 + match_details: 2 + match_tracklist: 5 + layout: column format_item: $artist - $album - $title format_album: $albumartist - $album diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index cd9f4989e..37131b203 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -184,6 +184,13 @@ 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 @@ -267,8 +274,11 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None, show_letter) # Insert the highlighted letter back into the word. + descr_color = "action_default" if is_default else "action_description" capitalized.append( - option[:index] + show_letter + option[index + 1:] + colorize(descr_color, option[:index]) + + show_letter + + colorize(descr_color, option[index + 1:]) ) display_letters.append(found_letter.upper()) @@ -301,15 +311,16 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None, prompt_part_lengths += [len(s) for s in options] # Wrap the query text. - prompt = '' + # Start prompt with U+279C: Heavy Round-Tipped Rightwards Arrow + prompt = colorize("action", "\u279C ") line_length = 0 for i, (part, length) in enumerate(zip(prompt_parts, prompt_part_lengths)): # Add punctuation. if i == len(prompt_parts) - 1: - part += '?' + part += colorize("action_description", "?") else: - part += ',' + part += colorize("action_description", ",") length += 1 # Choose either the current line or the beginning of the next. @@ -368,10 +379,12 @@ def input_yn(prompt, require=False): """Prompts the user for a "yes" or "no" response. The default is "yes" unless `require` is `True`, in which case there is no default. """ - sel = input_options( - ('y', 'n'), require, prompt, 'Enter Y or N:' + # Start prompt with U+279C: Heavy Round-Tipped Rightwards Arrow + yesno = colorize("action", "\u279C ") + colorize( + "action_description", "Enter Y or N:" ) - return sel == 'y' + sel = input_options(("y", "n"), require, prompt, yesno) + return sel == "y" def input_select_objects(prompt, objs, rep, prompt_all=None): @@ -465,97 +478,200 @@ def human_seconds_short(interval): # https://bitbucket.org/birkenfeld/pygments-main/src/default/pygments/console.py # (pygments is by Tim Hatch, Armin Ronacher, et al.) COLOR_ESCAPE = "\x1b[" -DARK_COLORS = { - "black": 0, - "darkred": 1, - "darkgreen": 2, - "brown": 3, - "darkyellow": 3, - "darkblue": 4, - "purple": 5, - "darkmagenta": 5, - "teal": 6, - "darkcyan": 6, - "lightgray": 7 +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"], } -LIGHT_COLORS = { - "darkgray": 0, - "red": 1, - "green": 2, - "yellow": 3, - "blue": 4, - "fuchsia": 5, - "magenta": 5, - "turquoise": 6, - "cyan": 6, - "white": 7 +# All ANSI Colors. +ANSI_CODES = { + # 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, + # 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, } RESET_COLOR = 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 = ['text_success', 'text_warning', 'text_error', 'text_highlight', - 'text_highlight_minor', 'action_default', 'action'] +COLOR_NAMES = [ + "text_success", + "text_warning", + "text_error", + "text_highlight", + "text_highlight_minor", + "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 = 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 something - in DARK_COLORS or LIGHT_COLORS. + in a terminal that is ANSI color-aware. The color must be a list of strings + from ANSI_CODES. """ - if color in DARK_COLORS: - escape = COLOR_ESCAPE + "%im" % (DARK_COLORS[color] + 30) - elif color in LIGHT_COLORS: - escape = COLOR_ESCAPE + "%i;01m" % (LIGHT_COLORS[color] + 30) - else: - raise ValueError('no such color %s', color) + # Construct escape sequence to be put before the text by iterating + # over all "ANSI codes" in `color`. + escape = "" + for code in color: + escape = escape + COLOR_ESCAPE + "%im" % ANSI_CODES[code] return escape + text + RESET_COLOR -def colorize(color_name, text): +def colorize(color_name, text, whitespace=True): """Colorize text if colored output is enabled. (Like _colorize but conditional.) """ - if not config['ui']['color'] or 'NO_COLOR' in os.environ.keys(): + if config["ui"]["color"]: + 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(unicode) + 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: {0}", color_name) + color = color_name + if whitespace: + # Colorize including whitespaces + return _colorize(color, text) + else: + # Split into words, then colorize individually + return " ".join(_colorize(color, word) + for word in text.split()) + else: return text - global COLORS - if not COLORS: - COLORS = {name: - config['ui']['colors'][name].as_str() - for name in COLOR_NAMES} - # 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: {0}', color_name) - color = color_name - return _colorize(color, 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 + 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 -def _colordiff(a, b, highlight='text_highlight', - minor_highlight='text_highlight_minor'): +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, b): """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 stringified and highlighted in their entirety. """ - if not isinstance(a, str) \ - or not isinstance(b, str): - # Non-strings: use ordinary equality. - a = str(a) - b = str(b) - if a == b: - return a, b - else: - return colorize(highlight, a), colorize(highlight, b) - + # First, convert paths to readable format if isinstance(a, bytes) or isinstance(b, bytes): # A path field. a = util.displayable_path(a) b = util.displayable_path(b) + if not isinstance(a, str) or not isinstance(b, str): + # Non-strings: use ordinary equality. + if a == b: + return str(a), str(b) + else: + return ( + colorize("text_diff_removed", str(a)), + colorize("text_diff_added", str(b)) + ) + a_out = [] b_out = [] @@ -566,32 +682,41 @@ def _colordiff(a, b, highlight='text_highlight', 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(highlight, b[b_start:b_end])) + # Right only. Colorize whitespace if added. + b_out.append(colorize("text_diff_added", + b[b_start:b_end], + whitespace=True)) elif op == 'delete': # Left only. - a_out.append(colorize(highlight, a[a_start:a_end])) + a_out.append(colorize("text_diff_removed", + a[a_start:a_end], + whitespace=False)) 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(): - color = highlight + a_color = "text_diff_removed" + b_color = "text_diff_added" else: - color = minor_highlight - a_out.append(colorize(color, a[a_start:a_end])) - b_out.append(colorize(color, b[b_start:b_end])) + a_color = b_color = "text_highlight_minor" + a_out.append(colorize(a_color, + a[a_start:a_end], + whitespace=False)) + b_out.append(colorize(b_color, + b[b_start:b_end], + whitespace=False)) else: assert False return ''.join(a_out), ''.join(b_out) -def colordiff(a, b, highlight='text_highlight'): +def colordiff(a, b): """Colorize differences between two values if color is enabled. (Like _colordiff but conditional.) """ if config['ui']['color']: - return _colordiff(a, b, highlight) + return _colordiff(a, b) else: return str(a), str(b) @@ -648,6 +773,258 @@ def term_width(): return width +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. + esc_text = re.compile(r"(?P\x1b\[[;\d]*[A-Za-z])" + r"(?P[^\x1b]+)", re.VERBOSE) + for m in esc_text.finditer(string): + # m contains two groups: + # esc - intitial escape sequence + # text - text, no escape sequence, may contain spaces + raw_words = m.group("text").split() + # Reconstruct colored words, without spaces. + words += [m.group("esc") + raw_word + + RESET_COLOR for raw_word in raw_words] + result = [] + next_substr = "" + # Iterate over all words. + for i in range(len(words)): + if i == 0: + pot_substr = words[i] + else: + 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: + next_substr = pot_substr + else: + result.append(next_substr) + next_substr = words[i] + # 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 right["prefix"] + right["contents"] + right["suffix"] == '': + # No right hand information, so we don't need a separator. + separator = "" + first_line_no_wrap = ( + indent_str + + left["prefix"] + + left["contents"] + + left["suffix"] + + 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 right["prefix"] + right["contents"] + right["suffix"] == '': + # No right hand information, so we don't need a separator. + separator = "" + first_line_no_wrap = ( + indent_str + + left["prefix"] + + left["contents"] + + left["suffix"] + + 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 = 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 = 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_(indent_str + line) + elif line != "": + # Ignore empty lines + print_(indent_str * 2 + line) + for i, line in enumerate(right_split): + if i == 0: + print_(indent_str + separator + line) + elif line != "": + print_(indent_str * 2 + line) + + FLOAT_EPSILON = 0.01 diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 8f68d319b..093787135 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -26,7 +26,8 @@ from typing import Sequence import beets from beets import ui -from beets.ui import print_, input_, decargs, show_path_changes +from beets.ui import print_, input_, decargs, show_path_changes, \ + print_newline_layout, print_column_layout from beets import autotag from beets.autotag import Recommendation from beets.autotag import hooks @@ -160,7 +161,6 @@ default_commands.append(fields_cmd) # help: Print help text for commands class HelpCommand(ui.Subcommand): - def __init__(self): super().__init__( 'help', aliases=('?',), @@ -243,18 +243,25 @@ def get_album_disambig_fields(info: hooks.AlbumInfo) -> Sequence[str]: return out +def dist_colorize(string, dist): + """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): """Formats a distance (a float) as a colorized similarity percentage string. """ - out = '%.1f%%' % ((1 - dist) * 100) - if dist <= config['match']['strong_rec_thresh'].as_number(): - out = ui.colorize('text_success', out) - elif dist <= config['match']['medium_rec_thresh'].as_number(): - out = ui.colorize('text_warning', out) - else: - out = ui.colorize('text_error', out) - return out + string = "{:.1f}%".format(((1 - dist) * 100)) + return dist_colorize(string, dist) def penalty_string(distance, limit=None): @@ -270,24 +277,172 @@ def penalty_string(distance, limit=None): if penalties: if limit and len(penalties) > limit: penalties = penalties[:limit] + ['...'] - return ui.colorize('text_warning', '(%s)' % ', '.join(penalties)) + # Prefix penalty string with U+2260: Not Equal To + penalty_string = "\u2260 {}".format(", ".join(penalties)) + return ui.colorize("changed", penalty_string) -def show_change(cur_artist, cur_album, match): - """Print out a representation of the changes that will be made if an - album's tags are changed according to `match`, which must be an AlbumMatch - object. +class ChangeRepresentation(object): + """Keeps track of all information needed to generate a (colored) text + representation of the changes that will be made if an album or singleton's + tags are changed according to `match`, which must be an AlbumMatch or + TrackMatch object, accordingly. """ - def show_album(artist, album): - if artist: - album_description = f' {artist} - {album}' - elif album: - album_description = ' %s' % album - else: - album_description = ' (unknown album)' - print_(album_description) - def format_index(track_info): + cur_artist = None + # cur_album set if album, cur_title set if singleton + cur_album = None + cur_title = None + match = None + indent_header = "" + indent_detail = "" + + def __init__(self): + # Read match header indentation width from config. + match_header_indent_width = config["ui"]["import"]["indentation"][ + "match_header" + ].as_number() + self.indent_header = ui.indent(match_header_indent_width) + + # Read match detail indentation width from config. + match_detail_indent_width = config["ui"]["import"]["indentation"][ + "match_details" + ].as_number() + self.indent_detail = ui.indent(match_detail_indent_width) + + # Read match tracklist indentation width from config + match_tracklist_indent_width = config["ui"]["import"]["indentation"][ + "match_tracklist" + ].as_number() + self.indent_tracklist = ui.indent(match_tracklist_indent_width) + self.layout = config["ui"]["import"]["layout"].as_choice( + { + "column": 0, + "newline": 1, + } + ) + + def print_layout(self, indent, left, right, separator=" -> ", + max_width=None): + if not max_width: + # If no max_width provided, use terminal width + max_width = ui.term_width() + if self.layout == 0: + print_column_layout(indent, left, right, separator, max_width) + else: + print_newline_layout(indent, left, right, separator, max_width) + + def show_match_header(self): + """Print out a 'header' identifying the suggested match (album name, + artist name,...) and summarizing the changes that would be made should + the user accept the match. + """ + # Print newline at beginning of change block. + print_("") + + # 'Match' line and similarity. + print_(self.indent_header + + f"Match ({dist_string(self.match.distance)}):") + + if self.match.info.get("album"): + # Matching an album - print that + artist_album_str = f"{self.match.info.artist}" + \ + f" - {self.match.info.album}" + else: + # Matching a single track + artist_album_str = f"{self.match.info.artist}" + \ + f" - {self.match.info.title}" + print_( + self.indent_header + + dist_colorize(artist_album_str, self.match.distance) + ) + + # Penalties. + penalties = penalty_string(self.match.distance) + if penalties: + print_(self.indent_header + penalties) + + # Disambiguation. + disambig = disambig_string(self.match.info) + if disambig: + print_(self.indent_header + disambig) + + # Data URL. + if self.match.info.data_url: + url = ui.colorize("text_faint", f"{self.match.info.data_url}") + print_(self.indent_header + url) + + def show_match_details(self): + """Print out the details of the match, including changes in album name + and artist name. + """ + # Artist. + artist_l, artist_r = self.cur_artist or "", self.match.info.artist + if artist_r == VARIOUS_ARTISTS: + # Hide artists for VA releases. + 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": ui.colorize("changed", "\u2260") + " Artist: ", + "contents": artist_l, + "suffix": "", + } + right = {"prefix": "", "contents": artist_r, "suffix": ""} + self.print_layout(self.indent_detail, left, right) + + else: + print_(self.indent_detail + "*", "Artist:", artist_r) + + if self.cur_album: + # Album + album_l, album_r = self.cur_album or "", self.match.info.album + if ( + self.cur_album != self.match.info.album + 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": ui.colorize("changed", "\u2260") + " Album: ", + "contents": album_l, + "suffix": "", + } + right = {"prefix": "", "contents": album_r, "suffix": ""} + self.print_layout(self.indent_detail, left, right) + else: + print_(self.indent_detail + "*", "Album:", album_r) + elif self.cur_title: + # Title - for singletons + 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": ui.colorize("changed", "\u2260") + " Title: ", + "contents": title_l, + "suffix": "", + } + right = {"prefix": "", "contents": title_r, "suffix": ""} + self.print_layout(self.indent_detail, left, right) + else: + print_(self.indent_detail + "*", "Title:", title_r) + + def make_medium_info_line(self, track_info): + """Construct a line with the current medium's info.""" + media = self.match.info.media or "Media" + # Build output string. + if self.match.info.mediums > 1 and track_info.disctitle: + return f"* {media} {track_info.medium}: {track_info.disctitle}" + elif self.match.info.mediums > 1: + return f"* {media} {track_info.medium}" + elif track_info.disctitle: + return f"* {media}: {track_info.disctitle}" + else: + return "" + + def format_index(self, track_info): """Return a string representing the track index of the given TrackInfo or Item object. """ @@ -295,7 +450,7 @@ def show_change(cur_artist, cur_album, match): index = track_info.index medium_index = track_info.medium_index medium = track_info.medium - mediums = match.info.mediums + mediums = self.match.info.mediums else: index = medium_index = track_info.track medium = track_info.disc @@ -309,205 +464,271 @@ def show_change(cur_artist, cur_album, match): else: return str(index) - # Identify the album in question. - if cur_artist != match.info.artist or \ - (cur_album != match.info.album and - match.info.album != VARIOUS_ARTISTS): - artist_l, artist_r = cur_artist or '', match.info.artist - album_l, album_r = cur_album or '', match.info.album - if artist_r == VARIOUS_ARTISTS: - # Hide artists for VA releases. - artist_l, artist_r = '', '' - - if config['artist_credit']: - artist_r = match.info.artist_credit - - artist_l, artist_r = ui.colordiff(artist_l, artist_r) - album_l, album_r = ui.colordiff(album_l, album_r) - - print_("Correcting tags from:") - show_album(artist_l, album_l) - print_("To:") - show_album(artist_r, album_r) - else: - print_("Tagging:\n {0.artist} - {0.album}".format(match.info)) - - # Data URL. - if match.info.data_url: - print_('URL:\n %s' % match.info.data_url) - - # Info line. - info = [] - # Similarity. - info.append('(Similarity: %s)' % dist_string(match.distance)) - # Penalties. - penalties = penalty_string(match.distance) - if penalties: - info.append(penalties) - # Disambiguation. - disambig = disambig_string(match.info) - if disambig: - info.append(ui.colorize('text_highlight_minor', '(%s)' % disambig)) - print_(' '.join(info)) - - # Tracks. - pairs = list(match.mapping.items()) - pairs.sort(key=lambda item_and_track_info: item_and_track_info[1].index) - - # Build up LHS and RHS for track difference display. The `lines` list - # contains ``(lhs, rhs, width)`` tuples where `width` is the length (in - # characters) of the uncolorized LHS. - lines = [] - medium = disctitle = None - for item, track_info in pairs: - - # Medium number and title. - if medium != track_info.medium or disctitle != track_info.disctitle: - media = match.info.media or 'Media' - if match.info.mediums > 1 and track_info.disctitle: - lhs = '{} {}: {}'.format(media, track_info.medium, - track_info.disctitle) - elif match.info.mediums > 1: - lhs = f'{media} {track_info.medium}' - elif track_info.disctitle: - lhs = f'{media}: {track_info.disctitle}' + def make_track_numbers(self, item, track_info): + """Format colored track indices.""" + cur_track = self.format_index(item) + new_track = self.format_index(track_info) + templ = "(#{})" + changed = False + # Choose color based on change. + if cur_track != new_track: + changed = True + if item.track in (track_info.index, track_info.medium_index): + highlight_color = "text_highlight_minor" else: - lhs = None - if lhs: - lines.append((lhs, '', 0)) - medium, disctitle = track_info.medium, track_info.disctitle + highlight_color = "text_highlight" + else: + highlight_color = "text_faint" - # Titles. + cur_track = templ.format(cur_track) + new_track = templ.format(new_track) + lhs_track = ui.colorize(highlight_color, cur_track) + rhs_track = ui.colorize(highlight_color, new_track) + return lhs_track, rhs_track, changed + + @staticmethod + def make_track_titles(item, track_info): + """Format colored track titles.""" new_title = track_info.title if not item.title.strip(): - # If there's no title, we use the filename. + # If there's no title, we use the filename. Don't colordiff. cur_title = displayable_path(os.path.basename(item.path)) - lhs, rhs = cur_title, new_title + return cur_title, new_title, True else: + # If there is a title, highlight differences. cur_title = item.title.strip() - lhs, rhs = ui.colordiff(cur_title, new_title) - lhs_width = len(cur_title) + cur_col, new_col = ui.colordiff(cur_title, new_title) + return cur_col, new_col, cur_title != new_title + @staticmethod + def make_track_lengths(item, track_info): + """Format colored track lengths.""" + changed = False + if ( + item.length + and track_info.length + and abs(item.length - track_info.length) + > config["ui"]["length_diff_thresh"].as_number() + ): + highlight_color = "text_highlight" + changed = True + else: + highlight_color = "text_highlight_minor" + + # Handle nonetype lengths by setting to 0 + cur_length0 = item.length if item.length else 0 + new_length0 = track_info.length if track_info.length else 0 + # format into string + cur_length = f"({ui.human_seconds_short(cur_length0)})" + new_length = f"({ui.human_seconds_short(new_length0)})" + # colorize + lhs_length = ui.colorize(highlight_color, cur_length) + rhs_length = ui.colorize(highlight_color, new_length) + + return lhs_length, rhs_length, changed + + def make_line(self, item, track_info): + """Extract changes from item -> new TrackInfo object, and colorize + appropriately. Returns (lhs, rhs) for column printing. + """ + # Track titles. + lhs_title, rhs_title, diff_title = \ + self.make_track_titles(item, track_info) # Track number change. - cur_track, new_track = format_index(item), format_index(track_info) - if cur_track != new_track: - if item.track in (track_info.index, track_info.medium_index): - color = 'text_highlight_minor' - else: - color = 'text_highlight' - templ = ui.colorize(color, ' (#{0})') - lhs += templ.format(cur_track) - rhs += templ.format(new_track) - lhs_width += len(cur_track) + 4 - + lhs_track, rhs_track, diff_track = \ + self.make_track_numbers(item, track_info) # Length change. - if item.length and track_info.length and \ - abs(item.length - track_info.length) > \ - config['ui']['length_diff_thresh'].as_number(): - cur_length = ui.human_seconds_short(item.length) - new_length = ui.human_seconds_short(track_info.length) - templ = ui.colorize('text_highlight', ' ({0})') - lhs += templ.format(cur_length) - rhs += templ.format(new_length) - lhs_width += len(cur_length) + 3 + lhs_length, rhs_length, diff_length = \ + self.make_track_lengths(item, track_info) - # Penalties. - penalties = penalty_string(match.distance.tracks[track_info]) - if penalties: - rhs += ' %s' % penalties + changed = diff_title or diff_track or diff_length - if lhs != rhs: - lines.append((' * %s' % lhs, rhs, lhs_width)) - elif config['import']['detail']: - lines.append((' * %s' % lhs, '', lhs_width)) + # Construct lhs and rhs dicts. + # Previously, we printed the penalties, however this is no longer + # the case, thus the 'info' dictionary is unneeded. + # penalties = penalty_string(self.match.distance.tracks[track_info]) - # Print each track in two columns, or across two lines. - col_width = (ui.term_width() - len(''.join([' * ', ' -> ']))) // 2 - if lines: - max_width = max(w for _, _, w in lines) - for lhs, rhs, lhs_width in lines: - if not rhs: - print_(lhs) - elif max_width > col_width: - print_(f'{lhs} ->\n {rhs}') - else: - pad = max_width - lhs_width - print_('{}{} -> {}'.format(lhs, ' ' * pad, rhs)) + prefix = ui.colorize("changed", "\u2260 ") if changed else "* " + lhs = { + "prefix": prefix + lhs_track + " ", + "contents": lhs_title, + "suffix": " " + lhs_length, + } + rhs = {"prefix": "", "contents": "", "suffix": ""} + if not changed: + # Only return the left side, as nothing changed. + return (lhs, rhs) + else: + # Construct a dictionary for the "changed to" side + rhs = { + "prefix": rhs_track + " ", + "contents": rhs_title, + "suffix": " " + rhs_length, + } + return (lhs, rhs) - # Missing and unmatched tracks. - if match.extra_tracks: - print_('Missing tracks ({}/{} - {:.1%}):'.format( - len(match.extra_tracks), - len(match.info.tracks), - len(match.extra_tracks) / len(match.info.tracks) - )) - pad_width = max(len(track_info.title) for track_info in - match.extra_tracks) - for track_info in match.extra_tracks: - line = ' ! {0: <{width}} (#{1: >2})'.format(track_info.title, - format_index(track_info), - width=pad_width) - if track_info.length: - line += ' (%s)' % ui.human_seconds_short(track_info.length) - print_(ui.colorize('text_warning', line)) - if match.extra_items: - print_('Unmatched tracks ({}):'.format(len(match.extra_items))) - pad_width = max(len(item.title) for item in match.extra_items) - for item in match.extra_items: - line = ' ! {0: <{width}} (#{1: >2})'.format(item.title, - format_index(item), - width=pad_width) - if item.length: - line += ' (%s)' % ui.human_seconds_short(item.length) - print_(ui.colorize('text_warning', line)) + def print_tracklist(self, lines): + """Calculates column widths for tracks stored as line tuples: + (left, right). Then prints each line of tracklist. + """ + if len(lines) == 0: + # If no lines provided, e.g. details not required, do nothing. + return + + def get_width(side): + """Return the width of left or right in uncolorized characters.""" + try: + return len( + ui.uncolorize( + " ".join([side["prefix"], + side["contents"], + side["suffix"]]) + ) + ) + except KeyError: + # An empty dictionary -> Nothing to report + return 0 + + # Check how to fit content into terminal window + indent_width = len(self.indent_tracklist) + terminal_width = ui.term_width() + joiner_width = len("".join(["* ", " -> "])) + col_width = (terminal_width - indent_width - joiner_width) // 2 + max_width_l = max(get_width(line_tuple[0]) for line_tuple in lines) + max_width_r = max(get_width(line_tuple[1]) for line_tuple in lines) + + if ( + (max_width_l <= col_width) + and (max_width_r <= col_width) + or ( + ((max_width_l > col_width) or (max_width_r > col_width)) + and ((max_width_l + max_width_r) <= col_width * 2) + ) + ): + # All content fits. Either both maximum widths are below column + # widths, or one of the columns is larger than allowed but the + # other is smaller than allowed. + # In this case we can afford to shrink the columns to fit their + # largest string + col_width_l = max_width_l + col_width_r = max_width_r + else: + # Not all content fits - stick with original half/half split + col_width_l = col_width + col_width_r = col_width + + # Print out each line, using the calculated width from above. + for left, right in lines: + left["width"] = col_width_l + right["width"] = col_width_r + self.print_layout(self.indent_tracklist, left, right) + + +class AlbumChange(ChangeRepresentation): + """Album change representation, setting cur_album""" + + def __init__(self, cur_artist, cur_album, match): + super(AlbumChange, self).__init__() + self.cur_artist = cur_artist + self.cur_album = cur_album + self.match = match + + def show_match_tracks(self): + """Print out the tracks of the match, summarizing changes the match + suggests for them. + """ + # Tracks. + # match is an AlbumMatch named tuple, mapping is a dict + # Sort the pairs by the track_info index (at index 1 of the namedtuple) + pairs = list(self.match.mapping.items()) + pairs.sort( + key=lambda item_and_track_info: item_and_track_info[1].index) + # Build up LHS and RHS for track difference display. The `lines` list + # contains `(left, right)` tuples. + lines = [] + medium = disctitle = None + for item, track_info in pairs: + # If the track is the first on a new medium, show medium + # number and title. + if medium != track_info.medium or \ + disctitle != track_info.disctitle: + # Create header for new medium + header = self.make_medium_info_line(track_info) + if header != "": + # Print tracks from previous medium + self.print_tracklist(lines) + lines = [] + print_(self.indent_detail + header) + # Save new medium details for future comparison. + medium, disctitle = track_info.medium, track_info.disctitle + + if config["import"]["detail"]: + # Construct the line tuple for the track. + left, right = self.make_line(item, track_info) + lines.append((left, right)) + self.print_tracklist(lines) + + # Missing and unmatched tracks. + if self.match.extra_tracks: + print_( + "Missing tracks ({0}/{1} - {2:.1%}):".format( + len(self.match.extra_tracks), + len(self.match.info.tracks), + len(self.match.extra_tracks) / len(self.match.info.tracks), + ) + ) + for track_info in self.match.extra_tracks: + line = f" ! {track_info.title} (#{self.format_index(track_info)})" + if track_info.length: + line += f" ({ui.human_seconds_short(track_info.length)})" + print_(ui.colorize("text_warning", line)) + if self.match.extra_items: + print_(f"Unmatched tracks ({len(self.match.extra_items)}):") + for item in self.match.extra_items: + line = " ! {} (#{})".format(item.title, self.format_index(item)) + if item.length: + line += " ({})".format(ui.human_seconds_short(item.length)) + print_(ui.colorize("text_warning", line)) + + +class TrackChange(ChangeRepresentation): + """Track change representation, comparing item with match.""" + + def __init__(self, cur_artist, cur_title, match): + super(TrackChange, self).__init__() + self.cur_artist = cur_artist + self.cur_title = cur_title + self.match = match + + +def show_change(cur_artist, cur_album, match): + """Print out a representation of the changes that will be made if an + album's tags are changed according to `match`, which must be an AlbumMatch + object. + """ + change = AlbumChange(cur_artist=cur_artist, cur_album=cur_album, + match=match) + + # Print the match header. + change.show_match_header() + + # Print the match details. + change.show_match_details() + + # Print the match tracks. + change.show_match_tracks() def show_item_change(item, match): """Print out the change that would occur by tagging `item` with the metadata from `match`, a TrackMatch object. """ - cur_artist, new_artist = item.artist, match.info.artist - cur_title, new_title = item.title, match.info.title - cur_album = item.album if item.album else "" - new_album = match.info.album if match.info.album else "" - - if (cur_artist != new_artist or cur_title != new_title - or cur_album != new_album): - cur_artist, new_artist = ui.colordiff(cur_artist, new_artist) - cur_title, new_title = ui.colordiff(cur_title, new_title) - cur_album, new_album = ui.colordiff(cur_album, new_album) - - print_("Correcting track tags from:") - print_(f" {cur_artist} - {cur_title}") - if cur_album: - print_(f" Album: {cur_album}") - print_("To:") - print_(f" {new_artist} - {new_title}") - if new_album: - print_(f" Album: {new_album}") - - else: - print_(f"Tagging track: {cur_artist} - {cur_title}") - if cur_album: - print_(f" Album: {new_album}") - - # Data URL. - if match.info.data_url: - print_('URL:\n %s' % match.info.data_url) - - # Info line. - info = [] - # Similarity. - info.append('(Similarity: %s)' % dist_string(match.distance)) - # Penalties. - penalties = penalty_string(match.distance) - if penalties: - info.append(penalties) - # Disambiguation. - disambig = disambig_string(match.info) - if disambig: - info.append(ui.colorize('text_highlight_minor', '(%s)' % disambig)) - print_(' '.join(info)) + change = TrackChange(cur_artist=item.artist, cur_title=item.title, + match=match) + # Print the match header. + change.show_match_header() + # Print the match details. + change.show_match_details() def summarize_items(items, singleton): @@ -640,36 +861,40 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None, if not bypass_candidates: # Display list of candidates. + print_("") print_('Finding tags for {} "{} - {}".'.format( 'track' if singleton else 'album', item.artist if singleton else cur_artist, item.title if singleton else cur_album, )) - print_('Candidates:') + print_(ui.indent(2) + 'Candidates:') for i, match in enumerate(candidates): # Index, metadata, and distance. - line = [ - '{}.'.format(i + 1), - '{} - {}'.format( - match.info.artist, - match.info.title if singleton else match.info.album, - ), - '({})'.format(dist_string(match.distance)), - ] + index0 = "{0}.".format(i + 1) + index = dist_colorize(index0, match.distance) + dist = "({:.1f}%)".format((1 - match.distance) * 100) + distance = dist_colorize(dist, match.distance) + metadata = "{0} - {1}".format( + match.info.artist, + match.info.title if singleton else match.info.album, + ) + if i == 0: + metadata = dist_colorize(metadata, match.distance) + else: + metadata = ui.colorize("text_highlight_minor", metadata) + line1 = [index, distance, metadata] + print_(ui.indent(2) + " ".join(line1)) # Penalties. penalties = penalty_string(match.distance, 3) if penalties: - line.append(penalties) + print_(ui.indent(13) + penalties) # Disambiguation disambig = disambig_string(match.info) if disambig: - line.append(ui.colorize('text_highlight_minor', - '(%s)' % disambig)) - - print_(' '.join(line)) + print_(ui.indent(13) + disambig) # Ask the user for a choice. sel = ui.input_options(choice_opts, @@ -769,8 +994,12 @@ class TerminalImportSession(importer.ImportSession): """ # Show what we're tagging. print_() - print_(displayable_path(task.paths, '\n') + - ' ({} items)'.format(len(task.items))) + + path_str0 = displayable_path(task.paths, '\n') + path_str = ui.colorize('import_path', path_str0) + items_str0 = '({} items)'.format(len(task.items)) + items_str = ui.colorize('import_path_items', items_str0) + print_(' '.join([path_str, items_str])) # Let plugins display info or prompt the user before we go through the # process of selecting candidate. diff --git a/test/test_ui.py b/test/test_ui.py index 27d36c493..e4b134f4b 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -1184,59 +1184,114 @@ class ShowChangeTest(_common.TestCase): ] ) - def _show_change(self, items=None, info=None, + def _show_change(self, items=None, info=None, color=False, cur_artist='the artist', cur_album='the album', dist=0.1): """Return an unicode string representing the changes""" items = items or self.items info = info or self.info mapping = dict(zip(items, info.tracks)) - config['ui']['color'] = False - album_dist = distance(items, info, mapping) - album_dist._penalties = {'album': [dist]} + config['ui']['color'] = color + config['import']['detail'] = True + change_dist = distance(items, info, mapping) + change_dist._penalties = {'album': [dist], 'artist': [dist]} commands.show_change( cur_artist, cur_album, - autotag.AlbumMatch(album_dist, info, mapping, set(), set()), + autotag.AlbumMatch(change_dist, info, mapping, set(), set()), ) return self.io.getoutput().lower() def test_null_change(self): msg = self._show_change() - self.assertTrue('similarity: 90' in msg) - self.assertTrue('tagging:' in msg) + self.assertTrue('match (90.0%)' in msg) + self.assertTrue('album, artist' in msg) def test_album_data_change(self): msg = self._show_change(cur_artist='another artist', cur_album='another album') - self.assertTrue('correcting tags from:' in msg) + self.assertTrue('another artist -> the artist' in msg) + self.assertTrue('another album -> the album' in msg) def test_item_data_change(self): self.items[0].title = 'different' msg = self._show_change() - self.assertTrue('different -> the title' in msg) + self.assertTrue('different' in msg and 'the title' in msg) def test_item_data_change_with_unicode(self): self.items[0].title = 'caf\xe9' msg = self._show_change() - self.assertTrue('caf\xe9 -> the title' in msg) + self.assertTrue(u'caf\xe9' in msg and 'the title' in msg) def test_album_data_change_with_unicode(self): - msg = self._show_change(cur_artist='caf\xe9', - cur_album='another album') - self.assertTrue('correcting tags from:' in msg) + msg = self._show_change(cur_artist=u'caf\xe9', + cur_album=u'another album') + self.assertTrue(u'caf\xe9' in msg and 'the artist' in msg) def test_item_data_change_title_missing(self): self.items[0].title = '' msg = re.sub(r' +', ' ', self._show_change()) - self.assertTrue('file.mp3 -> the title' in msg) + self.assertTrue(u'file.mp3' in msg and 'the title' in msg) def test_item_data_change_title_missing_with_unicode_filename(self): self.items[0].title = '' self.items[0].path = '/path/to/caf\xe9.mp3'.encode() msg = re.sub(r' +', ' ', self._show_change()) - self.assertTrue('caf\xe9.mp3 -> the title' in msg or - 'caf.mp3 ->' in msg) + self.assertTrue(u'caf\xe9.mp3' in msg or + u'caf.mp3' in msg) + + def test_split_into_lines(self): + # Test uncolored text + txt = ui.split_into_lines("test test test", [5, 5, 5]) + self.assertEqual(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]) + self.assertEqual(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]) + self.assertEqual(txt, split_txt) + + def test_album_data_change_wrap_newline(self): + # Patch ui.term_width to force wrapping + with patch('beets.ui.commands.ui.term_width', return_value=30): + # Test newline layout + config['ui']['import']['layout'] = u'newline' + long_name = u'another artist with a' + (u' very' * 10) + \ + u' long name' + msg = self._show_change(cur_artist=long_name, + cur_album='another album') + # _common.log.info("Message:{}".format(msg)) + self.assertTrue('artist: another artist' in msg) + self.assertTrue(' -> the artist' in msg) + self.assertFalse('another album -> the album' in msg) + + def test_item_data_change_wrap_column(self): + # Patch ui.term_width to force wrapping + with patch('beets.ui.commands.ui.term_width', return_value=54): + # Test Column layout + config['ui']['import']['layout'] = u'column' + long_title = u'a track with a' + (u' very' * 10) + \ + u' long name' + self.items[0].title = long_title + msg = self._show_change() + self.assertTrue('(#1) a track (1:00) -> (#1) the title (0:00)' + in msg) + + def test_item_data_change_wrap_newline(self): + # Patch ui.term_width to force wrapping + with patch('beets.ui.commands.ui.term_width', return_value=30): + config['ui']['import']['layout'] = u'newline' + long_title = u'a track with a' + (u' very' * 10) + \ + u' long name' + self.items[0].title = long_title + msg = self._show_change() + self.assertTrue('(#1) a track with' in msg) + self.assertTrue(' -> (#1) the title (0:00)' in msg) @patch('beets.library.Item.try_filesize', Mock(return_value=987))