Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]()
4 changes: 4 additions & 0 deletions docs/source/text.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 98 additions & 25 deletions rich/containers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from itertools import zip_longest
import re
from typing import (
TYPE_CHECKING,
Iterable,
Expand All @@ -9,6 +9,7 @@
Union,
overload,
)
from itertools import zip_longest

if TYPE_CHECKING:
from .console import (
Expand All @@ -23,6 +24,7 @@

from .cells import cell_len
from .measure import Measurement
from .style import Style

T = TypeVar("T")

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
56 changes: 56 additions & 0 deletions tests/test_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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