diff --git a/beets/config_default.yaml b/beets/config_default.yaml index f708702a81..41c089b618 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -53,13 +53,35 @@ 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 + albumdiff: + layout: column format_item: $artist - $album - $title format_album: $albumartist - $album diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 768eb76c78..ccdffabd6a 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -39,6 +39,7 @@ from beets.util.functemplate import Template from beets import config from beets.util import confit +from beets.util.confit import ConfigTypeError from beets.autotag import mb from beets.dbcore import query as db_query @@ -137,6 +138,18 @@ def print_(*strings, **kwargs): sys.stdout.write(txt) +def indent(count): + """Returns a string with `count` many spaces. + """ + return u' ' * count + + +def indent_str(count, string): + """Returns `string`, indented with `count` many spaces. + """ + return indent(count) + string + + def input_(prompt=None): """Like `raw_input`, but decodes the result to a Unicode string. Raises a UserError if stdin is not available. The prompt is sent to @@ -220,8 +233,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()) @@ -254,15 +270,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. @@ -321,8 +338,11 @@ 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. """ + # Start prompt with U+279C: Heavy Round-Tipped Rightwards Arrow + yesno = colorize('action', '\u279C ') + \ + colorize('action_description', 'Enter Y or N:') sel = input_options( - ('y', 'n'), require, prompt, 'Enter Y or N:' + ('y', 'n'), require, prompt, yesno ) return sel == 'y' @@ -378,51 +398,90 @@ def human_seconds_short(interval): # http://dev.pocoo.org/hg/pygments-main/file/b2deea5b5030/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'] + '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 + out of 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: + if code in ANSI_CODES.keys(): + escape = escape + COLOR_ESCAPE + "%im" % ANSI_CODES[code] + else: + raise ValueError('no such ANSI code %s', code) return escape + text + RESET_COLOR @@ -433,8 +492,27 @@ def colorize(color_name, text): if config['ui']['color']: global COLORS if not COLORS: - COLORS = dict((name, config['ui']['colors'][name].get(unicode)) - for name in COLOR_NAMES) + # 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 ConfigTypeError: + # 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 ValueError('no such color %s', color) + + 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) @@ -446,13 +524,44 @@ def colorize(color_name, text): return text -def _colordiff(a, b, highlight='text_highlight', - minor_highlight='text_highlight_minor'): +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(u'', colored_text) + return text + + +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. """ + # Set highlight colors. + highlight_added = 'text_diff_added' + highlight_removed = 'text_diff_removed' + minor_highlight = 'text_highlight_minor' + if not isinstance(a, basestring) or not isinstance(b, basestring): # Non-strings: use ordinary equality. a = unicode(a) @@ -460,7 +569,7 @@ def _colordiff(a, b, highlight='text_highlight', if a == b: return a, b else: - return colorize(highlight, a), colorize(highlight, b) + return colorize(highlight_removed, a), colorize(highlight_added, b) if isinstance(a, bytes) or isinstance(b, bytes): # A path field. @@ -470,6 +579,10 @@ def _colordiff(a, b, highlight='text_highlight', a_out = [] b_out = [] + add_mapper = lambda w: w if re.match('(\s)', w) else colorize(highlight_added, w) + remove_mapper = lambda w: w if re.match('(\s)', w) else colorize(highlight_removed, w) + minor_mapper = lambda w: w if re.match('(\s)', w) else colorize(minor_highlight, w) + matcher = SequenceMatcher(lambda x: False, a, b) for op, a_start, a_end, b_start, b_end in matcher.get_opcodes(): if op == 'equal': @@ -478,31 +591,39 @@ def _colordiff(a, b, highlight='text_highlight', b_out.append(b[b_start:b_end]) elif op == 'insert': # Right only. - b_out.append(colorize(highlight, b[b_start:b_end])) + words = re.split('(\s)', b[b_start:b_end]) + words_colorized = map(add_mapper, words) + b_out.append(''.join(words_colorized)) elif op == 'delete': # Left only. - a_out.append(colorize(highlight, a[a_start:a_end])) + words = re.split('(\s)', a[a_start:a_end]) + words_colorized = map(remove_mapper, words) + a_out.append(''.join(words_colorized)) elif op == 'replace': # Right and left differ. Colorise with second highlight if # it's just a case change. + words_a = re.split('(\s)', a[a_start:a_end]) + words_b = re.split('(\s)', b[b_start:b_end]) if a[a_start:a_end].lower() != b[b_start:b_end].lower(): - color = highlight + words_a_colorized = map(remove_mapper, words_a) + words_b_colorized = map(add_mapper, words_b) else: - color = minor_highlight - a_out.append(colorize(color, a[a_start:a_end])) - b_out.append(colorize(color, b[b_start:b_end])) + words_a_colorized = map(minor_mapper, words_a) + words_b_colorized = map(minor_mapper, words_b) + a_out.append(''.join(words_a_colorized)) + b_out.append(''.join(words_b_colorized)) else: assert(False) return u''.join(a_out), u''.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 unicode(a), unicode(b) @@ -559,6 +680,65 @@ def term_width(): return width +def split_into_lines(string, raw_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`. + + `raw_string` and `string` are two strings that contain the same words, + but `string` may contain ANSI codes at word borders. Use `raw_string` + to find substrings, but return the words in `string`. + """ + first_width, middle_width, last_width = width_tuple + + words_raw = raw_string.split() + words = string.split() + assert len(words_raw) == len(words) + result = { 'col': [], 'raw': [] } + next_substr_raw = u'' + next_substr = u'' + + # Iterate over all words. + for i in range(len(words_raw)): + if i == 0: + pot_substr_raw = words_raw[i] + pot_substr = words[i] + else: + pot_substr_raw = ' '.join([next_substr_raw, words_raw[i]]) + pot_substr = ' '.join([next_substr, words[i]]) + + # Find out if the pot(ential)_substr fits into the next substring. + fits_first = \ + (len(result['raw']) == 0 and len(pot_substr_raw) <= first_width) + fits_middle = \ + (len(result['raw']) != 0 and len(pot_substr_raw) <= middle_width) + if fits_first or fits_middle: + next_substr_raw = pot_substr_raw + next_substr = pot_substr + else: + result['raw'].append(next_substr_raw) + result['col'].append(next_substr) + next_substr_raw = words_raw[i] + next_substr = words[i] + + # We finished constructing the substrings, but the last substring + # has not yet been added to the result. + result['raw'].append(next_substr_raw) + result['col'].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 len(next_substr_raw) <= last_width: + result['raw'].append(u'') + result['col'].append(u'') + + return result + + FLOAT_EPSILON = 0.01 diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 348d12c887..80a8e84973 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -152,21 +152,28 @@ def disambig_string(info): disambig.append(info.albumdisambig) if disambig: - return u', '.join(disambig) + return ui.colorize('text_highlight_minor', u' | '.join(disambig)) -def dist_string(dist): - """Formats a distance (a float) as a colorized similarity percentage - string. +def dist_colorize(string, dist): + """Formats a string as a colorized similarity string according to + a distance. """ - out = '%.1f%%' % ((1 - dist) * 100) if dist <= config['match']['strong_rec_thresh'].as_number(): - out = ui.colorize('text_success', out) + string = ui.colorize('text_success', string) elif dist <= config['match']['medium_rec_thresh'].as_number(): - out = ui.colorize('text_warning', out) + string = ui.colorize('text_warning', string) else: - out = ui.colorize('text_error', out) - return out + string = ui.colorize('text_error', string) + return string + + +def dist_string(dist): + """Formats a distance (a float) as a colorized similarity percentage + string. + """ + string = '%.1f%%' % ((1 - dist) * 100) + return dist_colorize(string, dist) def penalty_string(distance, limit=None): @@ -182,185 +189,587 @@ 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 = u'\u2260 %s' % ', '.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's tags are + changed according to `match`, which must be an AlbumMatch object. """ - def show_album(artist, album): - if artist: - album_description = u' %s - %s' % (artist, album) - elif album: - album_description = u' %s' % album - else: - album_description = u' (unknown album)' - print_(album_description) - def format_index(track_info): - """Return a string representing the track index of the given - TrackInfo or Item object. + cur_artist = None + cur_album = None + match = None + + indent_header = u'' + indent_detail = u'' + + def __init__(self, cur_artist, cur_album, match): + self.cur_artist = cur_artist + self.cur_album = cur_album + self.match = match + + # 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) + + 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. """ - if isinstance(track_info, hooks.TrackInfo): - index = track_info.index - medium_index = track_info.medium_index - medium = track_info.medium - mediums = match.info.mediums - else: - index = medium_index = track_info.track - medium = track_info.disc - mediums = track_info.disctotal - if config['per_disc_numbering']: - if mediums > 1: - return u'{0}-{1}'.format(medium, medium_index) - else: - return unicode(medium_index) - else: - return unicode(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 = u'', u'' + # Print newline at beginning of change block. + print_(u'') - artist_l, artist_r = ui.colordiff(artist_l, artist_r) - album_l, album_r = ui.colordiff(album_l, album_r) + # 'Match' line and similarity. + print_(self.indent_header + u'Match (%s):' % dist_string(self.match.distance)) - print_("Correcting tags from:") - show_album(artist_l, album_l) - print_("To:") - show_album(artist_r, album_r) - else: - print_(u"Tagging:\n {0.artist} - {0.album}".format(match.info)) + # Artist name and album title. + artist_album_str = u'{0.artist} - {0.album}'.format(self.match.info) + print_(self.indent_header + dist_colorize(artist_album_str, self.match.distance)) - # Data URL. - if match.info.data_url: - print_('URL:\n %s' % match.info.data_url) + # Penalties. + penalties = penalty_string(self.match.distance) + if penalties: + print_(self.indent_header + penalties) - # 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)) + # Disambiguation. + disambig = disambig_string(self.match.info) + if disambig: + print_(self.indent_header + disambig) - # Tracks. - pairs = match.mapping.items() - pairs.sort(key=lambda (_, track_info): track_info.index) + # Data URL. + if self.match.info.data_url: + url = ui.colorize('text_highlight_minor', '%s' % self.match.info.data_url) + print_(self.indent_header + url) - # 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: + 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 u'', self.match.info.artist + if artist_r == VARIOUS_ARTISTS: + # Hide artists for VA releases. + artist_l, artist_r = u'', u'' + if artist_l != artist_r: + artist_l, artist_r = ui.colordiff(artist_l, artist_r) + # Prefix with U+2260: Not Equal To + print_(self.indent_detail + ui.colorize('changed', u'\u2260'), + u'Artist:', artist_l, u'->', artist_r) + else: + print_(self.indent_detail + '*', 'Artist:', artist_r) + + # 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 + print_(self.indent_detail + ui.colorize('changed', u'\u2260'), + u'Album:', album_l, u'->', album_r) + else: + print_(self.indent_detail + '*', 'Album:', album_r) + + +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. + """ + def get_match_details_indentation(): + """Reads match detail indentation width from config. + """ + match_detail_indent_width = \ + config['ui']['import']['indentation']['match_details'].as_number() + return ui.indent(match_detail_indent_width) - # Medium number and title. - if medium != track_info.medium or disctitle != track_info.disctitle: + def show_match_tracks(): + """Print out the tracks of the match, summarizing changes the match + suggests for them. + """ + def make_medium_info_line(): + """Construct a line with the current medium's info.""" media = match.info.media or 'Media' + # Build output string. if match.info.mediums > 1 and track_info.disctitle: - lhs = '%s %s: %s' % (media, track_info.medium, + out = '* %s %s: %s' % (media, track_info.medium, track_info.disctitle) - elif match.info.mediums > 1: - lhs = '%s %s' % (media, track_info.medium) elif track_info.disctitle: - lhs = '%s: %s' % (media, track_info.disctitle) + out = '* %s: %s' % (media, track_info.disctitle) else: - lhs = None - if lhs: - lines.append((lhs, '', 0)) - medium, disctitle = track_info.medium, track_info.disctitle - - # Titles. - new_title = track_info.title - if not item.title.strip(): - # If there's no title, we use the filename. - cur_title = displayable_path(os.path.basename(item.path)) - lhs, rhs = cur_title, new_title - else: - cur_title = item.title.strip() - lhs, rhs = ui.colordiff(cur_title, new_title) - lhs_width = len(cur_title) - - # 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' + out = '* %s %s' % (media, track_info.medium) + return out + + def make_line(item, track_info): + """docstring for make_track_line""" + def make_track_titles(item, track_info): + """docstring for fname + """ + new_title = track_info.title + if not item.title.strip(): + # If there's no title, we use the filename. Don't colordiff. + cur_title = displayable_path(os.path.basename(item.path)) + return cur_title, new_title + else: + # If there is a title, highlight differences. + cur_title = item.title.strip() + return ui.colordiff(cur_title, new_title) + + def make_track_numbers(item, track_info): + """docstring for fname + """ + cur_track = format_index(item) + new_track = format_index(track_info) + if cur_track != new_track: + if item.track in (track_info.index, track_info.medium_index): + cur_track_templ = u'(#{})' + new_track_templ = u'(#{})' + cur_track_color = 'text_highlight_minor' + new_track_color = 'text_highlight_minor' + else: + cur_track_templ = u'(#{})' + new_track_templ = u'(#{})' + cur_track_color = 'text_highlight' + new_track_color = 'text_highlight' + else: + cur_track_templ = u'' + new_track_templ = u'' + cur_track_color = 'text_faint' + new_track_color = 'text_faint' + cur_track = cur_track_templ.format(cur_track) + new_track = new_track_templ.format(new_track) + lhs_track = ui.colorize(cur_track_color, cur_track) + rhs_track = ui.colorize(new_track_color, new_track) + return lhs_track, rhs_track + + def make_track_lengths(item, track_info): + """ + """ + if item.length and track_info.length and \ + abs(item.length - track_info.length) > \ + config['ui']['length_diff_thresh'].as_number(): + cur_length_templ = u'({})' + new_length_templ = u'({})' + cur_length_color = 'text_highlight' + new_length_color = 'text_highlight' + else: + cur_length_templ = u'({})' + new_length_templ = u'({})' + cur_length_color = 'text_highlight_minor' + new_length_color = 'text_highlight_minor' + cur_length0 = ui.human_seconds_short(item.length) + new_length0 = ui.human_seconds_short(track_info.length) + cur_length = cur_length_templ.format(cur_length0) + new_length = new_length_templ.format(new_length0) + lhs_length = ui.colorize(cur_length_color, cur_length) + rhs_length = ui.colorize(new_length_color, new_length) + return lhs_length, rhs_length + + # Track titles. + lhs_title, rhs_title = make_track_titles(item, track_info) + # Track number change. + lhs_track, rhs_track = make_track_numbers(item, track_info) + # Length change. + lhs_length, rhs_length = make_track_lengths(item, track_info) + # Penalties. + penalties = penalty_string(match.distance.tracks[track_info]) + + # Construct comparison strings to check for differences and update + # line length. + lhs_comp = ui.uncolorize(' '.join([lhs_track, lhs_title, lhs_length])) + rhs_comp = ui.uncolorize(' '.join([rhs_track, rhs_title, rhs_length])) + lhs_width = len(lhs_comp) + rhs_width = len(rhs_comp) + + # Construct indentation. + indent_width = \ + config['ui']['import']['indentation']['match_tracklist'].as_number() + indent = ui.indent(indent_width) + + # Construct lhs and rhs dicts. + info = { + 'prefix': u'', + 'indent': indent, + 'changed': False, + 'penalties': penalty_string(match.distance.tracks[track_info]), + } + lhs = { + 'title': lhs_title, + 'track': lhs_track, + 'length': lhs_length, + 'width': lhs_width, + } + rhs = { + 'title': rhs_title, + 'track': rhs_track, + 'length': rhs_length, + 'width': rhs_width, + } + + # Check whether track info will change should the user apply + # the match. + # TODO: Is there a better way to determine if a track has changed? + if lhs_comp != rhs_comp: + # Prefix changed tracks with U+2260: Not Equal To + info['changed'] = True + info['prefix'] = ui.colorize('changed', '\u2260 ') + return (info, lhs, rhs) + elif config['import']['detail']: + # Prefix unchanged tracks with * + info['changed'] = False + info['prefix'] = '* ' + return (info, lhs, {}) + + def calc_column_width(col_width, max_width_l, max_width_r): + """Calculate column widths for a two-column layout. + `col_width` is the naive width for each column (the total width + divided by 2). + `max_width_l` and `max_width_r` are the maximum width of the + content of each column. + Returns a 2-tuple of the left and right column width. + """ + if (max_width_l <= col_width) and (max_width_r <= col_width): + col_width_l = max_width_l + col_width_r = max_width_r + elif ((max_width_l > col_width) or (max_width_r > col_width)) \ + and ((max_width_l + max_width_r) <= col_width * 2): + # Either left or right column larger than allowed, but the other is + # smaller than allowed - in total the content fits. + col_width_l = max_width_l + col_width_r = max_width_r + else: + col_width_l = col_width + col_width_r = col_width + return col_width_l, col_width_r + + def format_index(track_info): + """Return a string representing the track index of the given + TrackInfo or Item object. + """ + if isinstance(track_info, hooks.TrackInfo): + index = track_info.index + medium_index = track_info.medium_index + medium = track_info.medium + mediums = match.info.mediums + else: + index = medium_index = track_info.track + medium = track_info.disc + mediums = track_info.disctotal + if config['per_disc_numbering']: + if mediums > 1: + return u'{0}-{1}'.format(medium, medium_index) + else: + return unicode(medium_index) else: - color = 'text_highlight' - templ = ui.colorize(color, u' (#{0})') - lhs += templ.format(cur_track) - rhs += templ.format(new_track) - lhs_width += len(cur_track) + 4 - - # 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', u' ({0})') - lhs += templ.format(cur_length) - rhs += templ.format(new_length) - lhs_width += len(cur_length) + 3 + return unicode(index) + + def format_track(info, lhs_width, rhs_width, col_width_l, col_width_r, lhs, rhs): + """docstring for format_track""" + # Print track. + pad_l = u' ' * (col_width_l - lhs_width) + pad_r = u' ' * (col_width_r - rhs_width) + xhs_template = u'{title} {title} {padding}{length}' + lhs_str = xhs_template.format( + track = lhs['track'], + title = lhs['title'], + padding = pad_l, + length = lhs['length'] + ) + rhs_str = xhs_template.format( + track = rhs['track'], + title = rhs['title'], + padding = pad_r, + length = rhs['length'] + ) + line_template = u'{indent}{prefix}{lhs} ->\n{indent}{padding}{rhs}' + out = line_template.format( + indent = info['indent'], + prefix = info['prefix'], + padding = ui.indent(len('* ')), + lhs = lhs_str, + rhs = rhs_str, + ) + print_(out) + + def format_track_as_columns(info, col_width_l, col_width_r, lhs, rhs): + """docstring for format_track_as_columns""" + # TODO: Think about how to beautify calc_available_columns_per_line + # and ui.split_into_lines, especially with regard to the + # available cols tuple (first, middle, last). + def calc_available_columns_per_line(col_width, track_num_len, track_duration_len): + """Calculate the available space in columns for the track title + for the first, all middle, and the last line.""" + # Account for space between title and number/duration. + if track_num_len > 0: track_num_len += 1 + if track_duration_len > 0: track_duration_len += 1 + # Calculate the columns already in use for track number and + # track duration. + used_first = track_num_len + track_duration_len + used_middle = track_num_len + used_last = track_num_len + # Calculate the available columns for the track title. + col_width_first = col_width - used_first + col_width_middle = col_width - used_middle + col_width_last = col_width - used_last + return col_width_first, col_width_middle, col_width_last + + def calc_word_wrapping(col_width, xhs): + """docstring for calc_word_wrapping""" + # Calculate available space for word wrapping. + available_cols = calc_available_columns_per_line( + col_width, + xhs['len']['track'], + xhs['len']['length'] + ) + # Calculate word wrapping. + xhs_lines = ui.split_into_lines( + xhs['title'], + xhs['uncolored']['title'], + available_cols + ) + return xhs_lines + + # Uncolorize and measure colored strings. + # TODO: Get rid of this. + lhs['len'] = {} + lhs['len']['track'] = ui.color_len(lhs['track']) + lhs['len']['length'] = ui.color_len(lhs['length']) + lhs['uncolored'] = {} + lhs['uncolored']['title'] = ui.uncolorize(lhs['title']) + rhs['len'] = {} + rhs['len']['track'] = ui.color_len(rhs['track']) + rhs['len']['length'] = ui.color_len(rhs['length']) + rhs['uncolored'] = {} + rhs['uncolored']['title'] = ui.uncolorize(rhs['title']) + + # Get indent and prefix. + indent = info['indent'] + prefix = info['prefix'] + + # Calculate word wrapping. + lhs_lines = calc_word_wrapping(col_width_l, lhs) + rhs_lines = calc_word_wrapping(col_width_r, rhs) + + # Construct string for all lines of both columns. + max_line_count = max(len(lhs_lines['col']), len(rhs_lines['col'])) + align_length_l = lhs['len']['length'] + align_length_r = rhs['len']['length'] + out = u'' + for i in range(max_line_count): + # Indentation + out += indent + + # Prefix. + if i == 0: + out += prefix + else: + out += ui.indent(len('* ')) - # Penalties. - penalties = penalty_string(match.distance.tracks[track_info]) - if penalties: - rhs += ' %s' % penalties - - if lhs != rhs: - lines.append((' * %s' % lhs, rhs, lhs_width)) - elif config['import']['detail']: - lines.append((' * %s' % lhs, '', lhs_width)) - - # 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_(u'%s ->\n %s' % (lhs, rhs)) + # Track number or alignment + if i == 0 and lhs['len']['track'] > 0: + out += lhs['track'] + ' ' + else: + out += ' ' * lhs['len']['track'] + + # Line i of lhs track title. + if i in range(len(lhs_lines['col'])): + out += lhs_lines['col'][i] + + # Alignment up to the end of the left column. + if i in range(len(lhs_lines['raw'])): + align_title = len(lhs_lines['raw'][i]) + else: + align_title = 0 + align_used = lhs['len']['track'] + align_title + if i == 0: + align_used += align_length_l + padding = col_width_l - align_used + out += ' ' * padding + + # Length in first line. + if i == 0: + out += lhs['length'] + + # Arrow between columns. + if i == 0: + out += u' -> ' + else: + out += u' ' # u' .. ' + + # Track number or alignment. + if i == 0 and rhs['len']['track'] > 0: + out += rhs['track'] + ' ' + else: + out += ' ' * rhs['len']['track'] + + # Line i of rhs track title. + if i in range(len(rhs_lines['col'])): + out += rhs_lines['col'][i] + + # Alignment up to the end of the right column. + if i in range(len(rhs_lines['raw'])): + align_title = len(rhs_lines['raw'][i]) + else: + align_title = 0 + align_used = lhs['len']['track'] + align_title + if i == 0: + align_used += align_length_r + padding = col_width_r - align_used + out += ' ' * padding + + # Length in first line. + if i == 0: + out += rhs['length'] + + # Linebreak, except in the last line. + if i < max_line_count-1: + out += u'\n' + # Print complete line. + print_(out) + + def print_line(info, lhs, rhs): + """ + """ + if 'disk' in info: + # Print disk info. + print_(info['disk']) + elif not info['changed']: + # Print unchanged track. + l_pre = info['indent'] + info['prefix'] + pad_l = ' ' * (max_width_l - lhs['width']) + lhs_str = "{0} {1} {2}{3}".format( + lhs['track'], lhs['title'], pad_l, lhs['length']) + print_(l_pre + lhs_str) else: - pad = max_width - lhs_width - print_(u'%s%s -> %s' % (lhs, ' ' * pad, rhs)) - - # Missing and unmatched tracks. - if match.extra_tracks: - print_('Missing tracks ({0}/{1} - {2:.1%}):'.format( - len(match.extra_tracks), - len(match.info.tracks), - len(match.extra_tracks) / len(match.info.tracks) - )) - for track_info in match.extra_tracks: - line = ' ! %s (#%s)' % (track_info.title, format_index(track_info)) - 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 ({0}):'.format(len(match.extra_items))) - for item in match.extra_items: - line = ' ! %s (#%s)' % (item.title, format_index(item)) - if item.length: - line += ' (%s)' % ui.human_seconds_short(item.length) - print_(ui.colorize('text_warning', line)) + # Print changed track. + if (lhs['width'] > col_width_l) or (rhs['width'] > col_width_r): + layout = \ + config['ui']['import']['albumdiff']['layout'].as_choice({ + 'column': 0, + 'newline': 1, + }) + if layout == 0: + # Word wrapping inside columns. + format_track_as_columns(info, + col_width_l, col_width_r, lhs, rhs) + elif layout == 1: + # Wrap overlong track changes at column border. + format_track(info, lhs['width'], rhs['width'], + max_width_l, max_width_r, lhs, rhs) + else: + l_pre = info['indent'] + info['prefix'] + pad_l = ' ' * (col_width_l - lhs['width']) + pad_r = ' ' * (col_width_r - rhs['width']) + template = "{0} {1} {2}{3}" + lhs_str = template.format( + lhs['track'], lhs['title'], pad_l, lhs['length']) + rhs_str = template.format( + rhs['track'], rhs['title'], pad_r, rhs['length']) + print_(l_pre + u'%s -> %s' % (lhs_str, rhs_str)) + + # Read match detail indentation width from config. + detail_indent = get_match_details_indentation() + + # Tracks. + pairs = match.mapping.items() + pairs.sort(key=lambda (_, track_info): track_info.index) + + ### ----------------------------------------------------------------- + ### Build lines array + ### ----------------------------------------------------------------- + + # Build up LHS and RHS for track difference display. The `lines` list + # contains `(info, lhs, rhs)` tuples. + lines = [] + medium = disctitle = None + max_width_l = max_width_r = 0 + + 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: + out = make_medium_info_line() + info = { + 'prefix': u'', + 'disk': detail_indent + out, + 'penalties': None, + } + lhs = {} + rhs = {} + lines.append((info, lhs, rhs)) + medium, disctitle = track_info.medium, track_info.disctitle + + # Construct the line tuple for the track. + info, lhs, rhs = make_line(item, track_info) + lines.append((info, lhs, rhs)) + + # Update lhs and rhs maximum line widths. + if max_width_l < lhs['width']: + max_width_l = lhs['width'] + if max_width_r < rhs['width']: + max_width_r = rhs['width'] + + ### ----------------------------------------------------------------- + ### Print lines + ### ----------------------------------------------------------------- + + terminal_width = ui.term_width() + joiner_width = len(''.join(['* ', ' -> '])) + indent_width = config['ui']['import']['indentation']['match_tracklist'].as_number() + col_width = (terminal_width - indent_width - joiner_width) // 2 + + if lines: + # Calculate width of left and right column. + col_width_l, col_width_r = \ + calc_column_width(col_width, max_width_l, max_width_r) + # Print lines. + for info, lhs, rhs in lines: + print_line(info, lhs, rhs) + + ### ----------------------------------------------------------------- + ### Missing and unmatched tracks + ### ----------------------------------------------------------------- + + # Missing and unmatched tracks. + if match.extra_tracks: + print_('Missing tracks ({0}/{1} - {2:.1%}):'.format( + len(match.extra_tracks), + len(match.info.tracks), + len(match.extra_tracks) / len(match.info.tracks) + )) + for track_info in match.extra_tracks: + line = ' ! %s (#%s)' % (track_info.title, format_index(track_info)) + 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 ({0}):'.format(len(match.extra_items))) + for item in match.extra_items: + line = ' ! %s (#%s)' % (item.title, format_index(item)) + if item.length: + line += ' (%s)' % ui.human_seconds_short(item.length) + print_(ui.colorize('text_warning', line)) + + change = ChangeRepresentation(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. + show_match_tracks() def show_item_change(item, match): @@ -397,7 +806,7 @@ def show_item_change(item, match): # Disambiguation. disambig = disambig_string(match.info) if disambig: - info.append(ui.colorize('text_highlight_minor', '(%s)' % disambig)) + info.append('(%s)' % disambig) print_(' '.join(info)) @@ -532,36 +941,42 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None, if not bypass_candidates: # Display list of candidates. + print_(u'') print_(u'Finding tags for {0} "{1} - {2}".'.format( u'track' if singleton else u'album', item.artist if singleton else cur_artist, item.title if singleton else cur_album, )) - print_(u'Candidates:') + print_(ui.indent(2) + u'Candidates:') for i, match in enumerate(candidates): # Index, metadata, and distance. - line = [ - u'{0}.'.format(i + 1), - u'{0} - {1}'.format( - match.info.artist, - match.info.title if singleton else match.info.album, - ), - u'({0})'.format(dist_string(match.distance)), + index0 = u'{0}.'.format(i + 1) + index = dist_colorize(index0, match.distance) + dist = '(%.1f%%)' % ((1 - match.distance) * 100) + distance = dist_colorize(dist, match.distance) + metadata = u'{0} - {1}'.format( + match.info.artist, + match.info.title if singleton else match.info.album, + ) + if i == 0: + metadata = dist_colorize(metadata, match.distance) + 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. if singleton: @@ -668,8 +1083,11 @@ def choose_match(self, task): """ # Show what we're tagging. print_() - print_(displayable_path(task.paths, u'\n') + - u' ({0} items)'.format(len(task.items))) + path_str0 = displayable_path(task.paths, u'\n') + path_str = ui.colorize('import_path', path_str0) + items_str0 = u'({0} items)'.format(len(task.items)) + items_str = ui.colorize('import_path_items', items_str0) + print_(' '.join([path_str, items_str])) # Take immediate action if appropriate. action = _summary_judment(task.rec)