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..5772a317f8 100644 --- a/rich/containers.py +++ b/rich/containers.py @@ -1,4 +1,5 @@ from itertools import zip_longest +import re from typing import ( TYPE_CHECKING, Iterable, @@ -142,26 +143,63 @@ def justify( line.pad_left(width - cell_len(line.plain)) elif justify == "full": for line_index, line in enumerate(self._lines): + # Don't full-justify the last line if line_index == 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) + + # 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 == " ": + # Single-space gap: expand according to increments + add = increments[i] + if add: + # Determine style for the expanded gap based on adjacent word styles + 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: + # Whitespace run (>1) treated as indentation/alignment block, preserve as-is + tokens.append(piece) + else: + tokens.append(piece) + self[line_index] = Text("").join(tokens) diff --git a/rich/highlighter.py b/rich/highlighter.py index e4c462e2b6..722fdbeede 100644 --- a/rich/highlighter.py +++ b/rich/highlighter.py @@ -1,6 +1,6 @@ import re from abc import ABC, abstractmethod -from typing import List, Union +from typing import ClassVar, List, Tuple, Union from .text import Span, Text @@ -61,7 +61,7 @@ def highlight(self, text: Text) -> None: class RegexHighlighter(Highlighter): """Applies highlighting from a list of regular expressions.""" - highlights: List[str] = [] + highlights: ClassVar[Tuple[Union[str, "re.Pattern[str]"], ...]] = tuple() base_style: str = "" def highlight(self, text: Text) -> None: @@ -81,7 +81,7 @@ class ReprHighlighter(RegexHighlighter): """Highlights the text typically produced from ``__repr__`` methods.""" base_style = "repr." - highlights = [ + highlights: ClassVar[Tuple[Union[str, "re.Pattern[str]"], ...]] = ( r"(?P<)(?P[-\w.:|]*)(?P[\w\W]*)(?P>)", r'(?P[\w_]{1,50})=(?P"?[\w_]+"?)?', r"(?P[][{}()])", @@ -100,7 +100,7 @@ class ReprHighlighter(RegexHighlighter): r"(?b?'''.*?(?(file|https|http|ws|wss)://[-0-9a-zA-Z$_+!`(),.?/;:&=%#~@]*)", ), - ] + ) class JSONHighlighter(RegexHighlighter): @@ -111,14 +111,14 @@ class JSONHighlighter(RegexHighlighter): JSON_WHITESPACE = {" ", "\n", "\r", "\t"} base_style = "json." - highlights = [ + highlights: ClassVar[Tuple[Union[str, "re.Pattern[str]"], ...]] = ( _combine_regex( r"(?P[\{\[\(\)\]\}])", r"\b(?Ptrue)\b|\b(?Pfalse)\b|\b(?Pnull)\b", r"(?P(? None: super().highlight(text) @@ -146,7 +146,7 @@ class ISO8601Highlighter(RegexHighlighter): """ base_style = "iso8601." - highlights = [ + highlights: ClassVar[Tuple[Union[str, "re.Pattern[str]"], ...]] = ( # # Dates # @@ -195,7 +195,7 @@ class ISO8601Highlighter(RegexHighlighter): # Date and time, with optional fractional seconds and time zone (e.g., 2008-08-30T01:45:36 or 2008-08-30T01:45:36.123Z). # This is the XML Schema 'dateTime' type r"^(?P(?P-?(?:[1-9][0-9]*)?[0-9]{4})-(?P1[0-2]|0[1-9])-(?P3[01]|0[1-9]|[12][0-9]))T(?P