mirror of
https://github.com/Textualize/rich.git
synced 2025-12-23 07:08:35 +00:00
Merge 5a9da7b2c4 into f82a399d58
This commit is contained in:
commit
110290cf2c
6 changed files with 130 additions and 27 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue