diff --git a/CHANGELOG.md b/CHANGELOG.md index e7583d893..e73f39c8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed +- Fixed background color carrying over onto the following line when soft wrapping is enabled. https://github.com/Textualize/rich/issues/3838 + ## [14.1.0] - 2025-06-25 ### Changed diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4b04786b9..470d78d32 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -94,3 +94,4 @@ The following people have contributed to the development of Rich: - [Jonathan Helmus](https://github.com/jjhelmus) - [Brandon Capener](https://github.com/bcapener) - [Alex Zheng](https://github.com/alexzheng111) +- [Kevin Van Brunt](https://github.com/kmvanbrunt) diff --git a/rich/console.py b/rich/console.py index 994adfc06..bbf460904 100644 --- a/rich/console.py +++ b/rich/console.py @@ -1735,14 +1735,15 @@ def print( > 1 ): new_segments.insert(0, Segment.line()) + buffer_extend = self._buffer.extend if crop: - buffer_extend = self._buffer.extend for line in Segment.split_and_crop_lines( new_segments, self.width, pad=False ): buffer_extend(line) else: - self._buffer.extend(new_segments) + for line in Segment.split_lines(new_segments, include_new_lines=True): + buffer_extend(line) def print_json( self, diff --git a/rich/segment.py b/rich/segment.py index edcb52dd3..5caab389d 100644 --- a/rich/segment.py +++ b/rich/segment.py @@ -247,17 +247,23 @@ def filter_control( return filterfalse(attrgetter("control"), segments) @classmethod - def split_lines(cls, segments: Iterable["Segment"]) -> Iterable[List["Segment"]]: + def split_lines( + cls, + segments: Iterable["Segment"], + include_new_lines: bool = False, + ) -> Iterable[List["Segment"]]: """Split a sequence of segments in to a list of lines. Args: segments (Iterable[Segment]): Segments potentially containing line feeds. + include_new_lines (bool): Include newline segments in results. Defaults to False. Yields: Iterable[List[Segment]]: Iterable of segment lists, one per line. """ line: List[Segment] = [] append = line.append + new_line_segment = cls.line() for segment in segments: if "\n" in segment.text and not segment.control: @@ -267,6 +273,8 @@ def split_lines(cls, segments: Iterable["Segment"]) -> Iterable[List["Segment"]] if _text: append(cls(_text, style)) if new_line: + if include_new_lines: + line.append(new_line_segment) yield line line = [] append = line.append @@ -292,6 +300,7 @@ def split_and_crop_lines( length (int): Desired line length. style (Style, optional): Style to use for any padding. pad (bool): Enable padding of lines that are less than `length`. + include_new_lines (bool): Include newline segments in results. Defaults to True. Returns: Iterable[List[Segment]]: An iterable of lines of segments. diff --git a/tests/test_console.py b/tests/test_console.py index 499043b31..c4ccd6213 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -210,6 +210,65 @@ def test_print_text_multiple() -> None: assert console.file.getvalue() == "\x1b[1mfoo\x1b[0m bar baz\n" +def test_print_soft_wrap_no_styled_newlines() -> None: + """Test that style is not applied to newlines when soft wrapping.""" + + str1 = "line1\nline2\n" + str2 = "line5\nline6\n" + sep = "(sep1)\n(sep2)\n" + end = "(end1)\n(end2)\n" + style = "blue on white" + + # All newlines should appear outside of ANSI style sequences. + expected = ( + "\x1b[34;47mline1\x1b[0m\n" + "\x1b[34;47mline2\x1b[0m\n" + "\x1b[34;47m(sep1)\x1b[0m\n" + "\x1b[34;47m(sep2)\x1b[0m\n" + "\x1b[34;47mline5\x1b[0m\n" + "\x1b[34;47mline6\x1b[0m\n" + "\x1b[34;47m(end1)\x1b[0m\n" + "\x1b[34;47m(end2)\x1b[0m\n" + ) + + console = Console(color_system="truecolor") + with console.capture() as capture: + console.print(str1, str2, sep=sep, end=end, style=style, soft_wrap=True) + assert capture.get() == expected + + +def test_print_word_wrap_no_styled_newlines() -> None: + """ + Test that word wrapping does not insert styled newlines + or apply style to existing newlines. + """ + str1 = "this\nwill word wrap\n" + str2 = "and\nso will this\n" + sep = "(sep1)\n(sep2)\n" + end = "(end1)\n(end2)\n" + style = "blue on white" + + # All newlines should appear outside of ANSI style sequences. + expected = ( + "\x1b[34;47mthis\x1b[0m\n" + "\x1b[34;47mwill word \x1b[0m\n" + "\x1b[34;47mwrap\x1b[0m\n" + "\x1b[34;47m(sep1)\x1b[0m\n" + "\x1b[34;47m(sep2)\x1b[0m\n" + "\x1b[34;47mand\x1b[0m\n" + "\x1b[34;47mso will \x1b[0m\n" + "\x1b[34;47mthis\x1b[0m\n" + "\x1b[34;47m(end1)\x1b[0m\n" + "\x1b[34;47m(end2)\x1b[0m\n" + ) + + # Set a width which will cause word wrapping. + console = Console(color_system="truecolor", width=10) + with console.capture() as capture: + console.print(str1, str2, sep=sep, end=end, style=style, soft_wrap=False) + assert capture.get() == expected + + def test_print_json() -> None: console = Console(file=io.StringIO(), color_system="truecolor") console.print_json('[false, true, null, "foo"]', indent=4) diff --git a/tests/test_segment.py b/tests/test_segment.py index 2264dbe50..9d49f34aa 100644 --- a/tests/test_segment.py +++ b/tests/test_segment.py @@ -34,6 +34,14 @@ def test_split_lines(): assert list(Segment.split_lines(lines)) == [[Segment("Hello")], [Segment("World")]] +def test_split_lines_include_newlines(): + lines = [Segment("Hello\nWorld")] + assert list(Segment.split_lines(lines, include_new_lines=True)) == [ + [Segment("Hello"), Segment("\n", None)], + [Segment("World")], + ] + + def test_split_and_crop_lines(): assert list( Segment.split_and_crop_lines([Segment("Hello\nWorld!\n"), Segment("foo")], 4)