Compare commits

...

8 Commits

Author SHA1 Message Date
Nick Crews
e89931be56
Merge b5ef155de5 into 0752ff0472 2026-02-05 10:45:21 -08:00
Will McGugan
0752ff0472
Merge pull request #3953 from Textualize/zwj-fix
Fix ZWJ and edge cases
2026-02-01 16:19:20 +00:00
Will McGugan
54ae0cfbb8 simplify 2026-02-01 16:12:26 +00:00
Will McGugan
07edb85f7e refine 2026-02-01 15:59:59 +00:00
Will McGugan
31930ddc84 fix test 2026-02-01 15:24:39 +00:00
Will McGugan
454fcfc92c stupid comment 2026-02-01 15:08:09 +00:00
Will McGugan
13f87a4007 Fix ZWJ and edge cases 2026-02-01 15:00:41 +00:00
Nick Crews
b5ef155de5 Don't display() in jupyter when in a capture
Fixes #3274
2024-08-06 13:44:05 -08:00
6 changed files with 66 additions and 11 deletions

View File

@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [14.3.2] - 2026-02-01
### Fixed
- Fixed solo ZWJ crash https://github.com/Textualize/rich/pull/3953
- Fixed control codes reporting width of 1 https://github.com/Textualize/rich/pull/3953
## [14.3.1] - 2026-01-24 ## [14.3.1] - 2026-01-24
### Fixed ### Fixed

View File

@ -2,7 +2,7 @@
name = "rich" name = "rich"
homepage = "https://github.com/Textualize/rich" homepage = "https://github.com/Textualize/rich"
documentation = "https://rich.readthedocs.io/en/latest/" documentation = "https://rich.readthedocs.io/en/latest/"
version = "14.3.1" version = "14.3.2"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
authors = ["Will McGugan <willmcgugan@gmail.com>"] authors = ["Will McGugan <willmcgugan@gmail.com>"]
license = "MIT" license = "MIT"

View File

@ -55,23 +55,26 @@ def get_character_cell_size(character: str, unicode_version: str = "auto") -> in
int: Number of cells (0, 1 or 2) occupied by that character. int: Number of cells (0, 1 or 2) occupied by that character.
""" """
codepoint = ord(character) codepoint = ord(character)
if codepoint and codepoint < 32 or 0x07F <= codepoint < 0x0A0:
return 0
table = load_cell_table(unicode_version).widths table = load_cell_table(unicode_version).widths
if codepoint > table[-1][1]:
last_entry = table[-1]
if codepoint > last_entry[1]:
return 1 return 1
lower_bound = 0 lower_bound = 0
upper_bound = len(table) - 1 upper_bound = len(table) - 1
index = (lower_bound + upper_bound) // 2
while True: while lower_bound <= upper_bound:
index = (lower_bound + upper_bound) >> 1
start, end, width = table[index] start, end, width = table[index]
if codepoint < start: if codepoint < start:
upper_bound = index - 1 upper_bound = index - 1
elif codepoint > end: elif codepoint > end:
lower_bound = index + 1 lower_bound = index + 1
else: else:
return 0 if width == -1 else width return width
if upper_bound < lower_bound:
break
index = (lower_bound + upper_bound) // 2
return 1 return 1
@ -135,12 +138,14 @@ def _cell_len(text: str, unicode_version: str) -> int:
SPECIAL = {"\u200d", "\ufe0f"} SPECIAL = {"\u200d", "\ufe0f"}
iter_characters = iter(text) index = 0
character_count = len(text)
for character in iter_characters: while index < character_count:
character = text[index]
if character in SPECIAL: if character in SPECIAL:
if character == "\u200d": if character == "\u200d":
next(iter_characters) index += 1
elif last_measured_character: elif last_measured_character:
total_width += last_measured_character in cell_table.narrow_to_wide total_width += last_measured_character in cell_table.narrow_to_wide
last_measured_character = None last_measured_character = None
@ -148,6 +153,7 @@ def _cell_len(text: str, unicode_version: str) -> int:
if character_width := get_character_cell_size(character, unicode_version): if character_width := get_character_cell_size(character, unicode_version):
last_measured_character = character last_measured_character = character
total_width += character_width total_width += character_width
index += 1
return total_width return total_width

View File

@ -83,6 +83,12 @@ def _render_segments(segments: Iterable[Segment]) -> str:
def display(segments: Iterable[Segment], text: str) -> None: def display(segments: Iterable[Segment], text: str) -> None:
"""Render segments to Jupyter.""" """Render segments to Jupyter."""
segments = list(segments)
if not segments and not text:
# display() always prints a newline, so if there is no content then
# we don't want a single newline appearing.
# See https://github.com/Textualize/rich/issues/3274
return None
html = _render_segments(segments) html = _render_segments(segments)
jupyter_renderable = JupyterRenderable(html, text) jupyter_renderable = JupyterRenderable(html, text)
try: try:

View File

@ -187,3 +187,20 @@ def test_nerd_font():
"""Regression test for https://github.com/Textualize/rich/issues/3943""" """Regression test for https://github.com/Textualize/rich/issues/3943"""
# Not allocated by unicode, but used by nerd fonts # Not allocated by unicode, but used by nerd fonts
assert cell_len("\U000f024d") == 1 assert cell_len("\U000f024d") == 1
def test_zwj():
"""Test special case of zero width joiners"""
assert cell_len("") == 0
assert cell_len("\u200d") == 0
assert cell_len("1\u200d") == 1
# This sequence should really produce 2, but it aligns with with wcwidth
# What gets written to the terminal is anybody's guess, I've seen multiple variations
assert cell_len("1\u200d2") == 1
def test_non_printable():
"""Non printable characters should report a width of 0."""
for ordinal in range(31):
character = chr(ordinal)
assert cell_len(character) == 0

View File

@ -30,3 +30,22 @@ def test_jupyter_lines_env():
console = Console( console = Console(
width=40, _environ={"JUPYTER_LINES": "broken"}, force_jupyter=True width=40, _environ={"JUPYTER_LINES": "broken"}, force_jupyter=True
) )
def test_jupyter_capture(monkeypatch):
# If inside a capture, ipython's display shouldn't be called,
# or we would get spurious newlines.
# See https://github.com/Textualize/rich/issues/3274
called = False
def mock_display(*args, **kwargs):
nonlocal called
called = True
monkeypatch.setattr("IPython.display.display", mock_display)
console = Console(force_jupyter=True)
with console.capture():
console.print("foo")
assert not called
console.print("foo")
assert called