This commit is contained in:
hendrixxxh 2025-12-03 09:57:00 +08:00 committed by GitHub
commit 110290cf2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 130 additions and 27 deletions

View file

@ -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

View file

@ -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]()

View file

@ -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

View file

@ -1,4 +1,5 @@
from itertools import zip_longest
import re
from typing import (
TYPE_CHECKING,
Iterable,
@ -142,26 +143,63 @@ class Lines:
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)

View file

@ -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 @@ class NullHighlighter(Highlighter):
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<tag_start><)(?P<tag_name>[-\w.:|]*)(?P<tag_contents>[\w\W]*)(?P<tag_end>>)",
r'(?P<attrib_name>[\w_]{1,50})=(?P<attrib_value>"?[\w_]+"?)?',
r"(?P<brace>[][{}()])",
@ -100,7 +100,7 @@ class ReprHighlighter(RegexHighlighter):
r"(?<![\\\w])(?P<str>b?'''.*?(?<!\\)'''|b?'.*?(?<!\\)'|b?\"\"\".*?(?<!\\)\"\"\"|b?\".*?(?<!\\)\")",
r"(?P<url>(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<brace>[\{\[\(\)\]\}])",
r"\b(?P<bool_true>true)\b|\b(?P<bool_false>false)\b|\b(?P<null>null)\b",
r"(?P<number>(?<!\w)\-?[0-9]+\.?[0-9]*(e[\-\+]?\d+?)?\b|0x[0-9a-fA-F]*)",
JSON_STR,
),
]
)
def highlight(self, text: Text) -> 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<date>(?P<year>-?(?:[1-9][0-9]*)?[0-9]{4})-(?P<month>1[0-2]|0[1-9])-(?P<day>3[01]|0[1-9]|[12][0-9]))T(?P<time>(?P<hour>2[0-3]|[01][0-9]):(?P<minute>[0-5][0-9]):(?P<second>[0-5][0-9])(?P<ms>\.[0-9]+)?)(?P<timezone>Z|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9])?$",
]
)
if __name__ == "__main__": # pragma: no cover

View file

@ -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=16)
text = Text(" foo bar baz", justify="full")
lines = text.wrap(console, 16)
# 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
assert len(lines[0].plain) == 16
# 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