Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions beets/util/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ def split_into_lines(string: str, first_width: int, width: int) -> list[str]:
words = string.split()
else:
# Use a regex to find escapes and the text within them.
# `pretext` is empty for every match after the first, since the
# previous match's `posttext` (being a greedy [^ESC]* group) already
# consumed all plain text up to this escape sequence. Whether that
# represents a real word boundary depends on whether the text
# preceding this escape actually ended in whitespace, so we track it
# explicitly across iterations instead of assuming pretext-empty
# always means "boundary" (which is only true for the very first
# match, i.e. the start of the string).
prev_ends_with_space = True
for m in ESC_TEXT_REGEX.finditer(string):
# m contains four groups:
# pretext - any text before escape sequence
Expand All @@ -88,8 +97,7 @@ def split_into_lines(string: str, first_width: int, width: int) -> list[str]:
# Pretext ended mid-word, ensure next word
pass
else:
# pretext empty, treat as if there is a space before
space_before_text = True
space_before_text = prev_ends_with_space
if m.group("text")[0] == " ":
# First character of the text is a space
space_before_text = True
Expand Down Expand Up @@ -128,6 +136,15 @@ def split_into_lines(string: str, first_width: int, width: int) -> list[str]:
else:
# Add any words after escape sequence
words += m.group("posttext").split()
# Determine the boundary state for the next match's pretext-empty
# case: look at whatever content immediately precedes the next
# escape sequence -- posttext if there is any, otherwise text
# itself (posttext being empty means reset is followed directly
# by the next escape, with nothing in between).
if m.group("posttext"):
prev_ends_with_space = m.group("posttext")[-1] == " "
else:
prev_ends_with_space = m.group("text")[-1] == " "
result: list[str] = []
next_substr = ""
# Iterate over all words.
Expand Down
3 changes: 3 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ Bug fixes
valid date/time string" error instead of crashing with an uncaught
``KeyError``. A ``|`` was being accepted as a relative-date unit due to a
regular expression character-class typo.
- Fix word wrapping of colored diff output for a word containing two or more
separately-highlighted spans, which was incorrectly split into two words at
the second highlighted span.

..
For plugin developers
Expand Down
12 changes: 12 additions & 0 deletions test/util/test_layout.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from unittest import TestCase

from beets.util.color import uncolorize
from beets.util.layout import split_into_lines


Expand Down Expand Up @@ -28,3 +29,14 @@ def test_split_into_lines(self):
split_txt = ["\x1b[31mtest\x1b[39;49;00mt", "est", "test", "test"]
txt = split_into_lines(colored_text, 5, 5)
assert txt == split_txt

def test_split_into_lines_two_spans_in_one_word(self):
# A single word containing two separately-colored, non-adjacent
# spans (e.g. two highlighted typo fixes within one word) must stay
# one word, not get split at the second colored span.
red, reset = "\x1b[31m", "\x1b[39;49;00m"
colored_text = f"extra{red}A{reset}ordin{red}B{reset}ary"
txt = split_into_lines(colored_text, 100, 100)
assert len(txt) == 1
assert " " not in uncolorize(txt[0])
assert uncolorize(txt[0]) == "extraAordinBary"
Loading