From e0f477bf0fd266d3d2c92df196ea93a7962009ba Mon Sep 17 00:00:00 2001 From: chuenchen309 <48723787+chuenchen309@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:14:51 +0800 Subject: [PATCH] fix(util): split_into_lines merges a word split across 2+ colored spans ESC_TEXT_REGEX's greedy posttext group makes pretext structurally empty for every match after the first, so the boundary-detection logic ("pretext empty => treat as space before") was only actually correct for the first match. A second colored span mid-word (e.g. two highlighted typo fixes within one word) got mistaken for the start of a new word, inserting a spurious space. Track the real boundary state across iterations instead. --- beets/util/layout.py | 21 +++++++++++++++++++-- docs/changelog.rst | 3 +++ test/util/test_layout.py | 12 ++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/beets/util/layout.py b/beets/util/layout.py index 4a92e12392..cea1d82cd7 100644 --- a/beets/util/layout.py +++ b/beets/util/layout.py @@ -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 @@ -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 @@ -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. diff --git a/docs/changelog.rst b/docs/changelog.rst index 096a35a6db..6949ed9ab9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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 diff --git a/test/util/test_layout.py b/test/util/test_layout.py index a8b9e755e7..47642b878b 100644 --- a/test/util/test_layout.py +++ b/test/util/test_layout.py @@ -1,5 +1,6 @@ from unittest import TestCase +from beets.util.color import uncolorize from beets.util.layout import split_into_lines @@ -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"