diff --git a/CHANGELOG.md b/CHANGELOG.md index e53205666f..5397dfb097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Python3.14 compatibility https://github.com/Textualize/rich/pull/3861 +### Fixed + +- Fixed full justification to preserve indentation blocks and multi-space runs; only single-space gaps between words are expanded. This prevents code-like text and intentional spacing from being altered when using `justify="full"`. + ## [14.1.0] - 2025-06-25 ### Changed diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4b04786b9c..89220f4ba0 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) +- [Your Name]() diff --git a/docs/source/text.rst b/docs/source/text.rst index c5a1add82b..6bb84fc9ed 100644 --- a/docs/source/text.rst +++ b/docs/source/text.rst @@ -49,6 +49,10 @@ The Text class has a number of parameters you can set on the constructor to modi - ``no_wrap`` prevents wrapping if the text is longer then the available width. - ``tab_size`` Sets the number of characters in a tab. +.. note:: + + When using ``justify="full"``, Rich preserves indentation blocks and whitespace runs greater than a single space. Only single-space gaps between words are expanded to achieve full justification. This ensures leading indentation, code blocks, and intentional spacing remain intact while aligning text to both left and right edges. + A Text instance may be used in place of a plain string virtually everywhere in the Rich API, which gives you a lot of control in how text renders within other Rich renderables. For instance, the following example right aligns text within a :class:`~rich.panel.Panel`:: from rich import print diff --git a/rich/containers.py b/rich/containers.py index 901ff8ba6e..77ab11980b 100644 --- a/rich/containers.py +++ b/rich/containers.py @@ -1,4 +1,4 @@ -from itertools import zip_longest +import re from typing import ( TYPE_CHECKING, Iterable, @@ -9,6 +9,7 @@ Union, overload, ) +from itertools import zip_longest if TYPE_CHECKING: from .console import ( @@ -23,6 +24,7 @@ from .cells import cell_len from .measure import Measurement +from .style import Style T = TypeVar("T") @@ -76,12 +78,10 @@ def __iter__(self) -> Iterator["Text"]: return iter(self._lines) @overload - def __getitem__(self, index: int) -> "Text": - ... + def __getitem__(self, index: int) -> "Text": ... @overload - def __getitem__(self, index: slice) -> List["Text"]: - ... + def __getitem__(self, index: slice) -> List["Text"]: ... def __getitem__(self, index: Union[slice, int]) -> Union["Text", List["Text"]]: return self._lines[index] @@ -142,26 +142,99 @@ def justify( line.pad_left(width - cell_len(line.plain)) elif justify == "full": for line_index, line in enumerate(self._lines): - if line_index == len(self._lines) - 1: + # Don't full-justify the last line (unless it's the only line) + if line_index == len(self._lines) - 1 and len(self._lines) > 1: break - words = line.split(" ") - words_size = sum(cell_len(word.plain) for word in words) - num_spaces = len(words) - 1 - spaces = [1 for _ in range(num_spaces)] - index = 0 - if spaces: - while words_size + num_spaces < width: - spaces[len(spaces) - index - 1] += 1 - num_spaces += 1 - index = (index + 1) % len(spaces) + + # Legacy path: if there are no multi-space runs, keep original behavior to match golden outputs + if not re.search(r"\s{2,}", line.plain): + words = line.split(" ") + words_size = sum(cell_len(word.plain) for word in words) + num_spaces = len(words) - 1 + spaces = [1 for _ in range(num_spaces)] + index = 0 + if spaces: + while words_size + num_spaces < width: + spaces[len(spaces) - index - 1] += 1 + num_spaces += 1 + index = (index + 1) % len(spaces) + + tokens: List[Text] = [] + for idx, (word, next_word) in enumerate( + zip_longest(words, words[1:]) + ): + tokens.append(word) + if idx < len(spaces): + style = word.get_style_at_offset(console, -1) + next_style = ( + next_word.get_style_at_offset(console, 0) + if next_word + else line.style + ) + space_style = style if style == next_style else line.style + tokens.append(Text(" " * spaces[idx], style=space_style)) + self[line_index] = Text("").join(tokens) + continue + + # Divide line into tokens of words and whitespace runs + def _flatten_whitespace_spans() -> Iterable[int]: + for match in re.finditer(r"\s+", line.plain): + start, end = match.span() + yield start + yield end + + pieces: List[Text] = [ + p for p in line.divide(_flatten_whitespace_spans()) if p.plain != "" + ] + + # Identify indices of expandable single-space gaps (between words only) + expandable_indices: List[int] = [] + for i, piece in enumerate(pieces): + if piece.plain == " ": + if 0 < i < len(pieces) - 1: + prev_is_word = not pieces[i - 1].plain.isspace() + next_is_word = not pieces[i + 1].plain.isspace() + if prev_is_word and next_is_word: + expandable_indices.append(i) + + # Compute extra spaces required to reach target width + current_width = cell_len(line.plain) + extra = max(0, width - current_width) + + # Distribute extra spaces from rightmost gap to left in round-robin + increments: List[int] = [0] * len(pieces) + if expandable_indices and extra: + rev_gaps = list(reversed(expandable_indices)) + gi = 0 + while extra > 0: + idx = rev_gaps[gi] + increments[idx] += 1 + extra -= 1 + gi = (gi + 1) % len(rev_gaps) + + # Rebuild tokens, preserving indentation blocks (whitespace runs > 1) tokens: List[Text] = [] - for index, (word, next_word) in enumerate( - zip_longest(words, words[1:]) - ): - tokens.append(word) - if index < len(spaces): - style = word.get_style_at_offset(console, -1) - next_style = next_word.get_style_at_offset(console, 0) - space_style = style if style == next_style else line.style - tokens.append(Text(" " * spaces[index], style=space_style)) + for i, piece in enumerate(pieces): + if piece.plain.isspace(): + if piece.plain == " ": + add = increments[i] + left_style = ( + pieces[i - 1].get_style_at_offset(console, -1) + if i > 0 + else line.style + ) + right_style = ( + pieces[i + 1].get_style_at_offset(console, 0) + if i + 1 < len(pieces) + else line.style + ) + space_style = ( + left_style if left_style == right_style else line.style + ) + tokens.append(Text(" " * (1 + add), style=space_style)) + else: + tokens.append(piece) + else: + tokens.append(piece) + self[line_index] = Text("").join(tokens) diff --git a/tests/test_text.py b/tests/test_text.py index 925803343c..030134218d 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -1070,3 +1070,59 @@ def test_append_loop_regression() -> None: b = Text("two", "blue") b.append_text(b) assert b.plain == "twotwo" + + +def test_full_justify_preserves_indentation_blocks() -> None: + console = Console(width=20) + text = Text(" foo bar baz", justify="full") + lines = text.wrap(console, 20) + # Only one line, full-justified; leading 4-space indentation must be preserved + assert len(lines) == 1 + assert lines[0].plain.startswith(" ") + # Total width should match console width (20 chars) + assert len(lines[0].plain) == 20 + # The gaps expanded should be single-space gaps between words; indentation remains 4 spaces + # Split to verify only the inter-word spaces grew + after = lines[0].plain + # Indentation is 4 spaces followed by words + assert after[:4] == " " + # There should be no sequences of spaces > 4 at the start + assert re.match(r"^\s{4}\S", after) is not None + + +def test_full_justify_does_not_expand_multi_space_gaps() -> None: + console = Console(width=20) + text = Text("foo bar baz", justify="full") + lines = text.wrap(console, 20) + assert len(lines) == 1 + result = lines[0].plain + # Confirm original multi-space runs remain present (at least 2 and 3 spaces respectively) + assert "foo" in result and "bar" in result and "baz" in result + # Verify the run between foo and bar is >=2 spaces and between bar and baz is >=3 spaces + between_foo_bar = result[result.index("foo") + 3 : result.index("bar")] + between_bar_baz = result[result.index("bar") + 3 : result.index("baz")] + assert len(between_foo_bar.strip(" ")) == 0 and len(between_foo_bar) >= 2 + assert len(between_bar_baz.strip(" ")) == 0 and len(between_bar_baz) >= 3 + + +def test_full_justify_respects_space_style_from_neighbors() -> None: + console = Console(width=18) + # Style words differently; expanded spaces should inherit a consistent style + text = Text("foo bar baz", justify="full") + text.stylize("red", 0, 3) # foo + text.stylize("blue", 4, 7) # bar + text.stylize("green", 8, 11) # baz + lines = text.wrap(console, 18) + assert len(lines) == 1 + justified = lines[0] + # Get styles at positions of the first expanded gap (after foo) + # Find first space index after 'foo' + first_space = justified.plain.find(" ", 3) + # Collect styles of contiguous spaces after first_space + space_styles = { + justified.get_style_at_offset(console, i).color + for i in range(first_space, len(justified.plain)) + if justified.plain[i] == " " + } + # Expect either unified neighbor style or base line style; at minimum ensure no None unexpected + assert space_styles