|
1 | 1 | """Provides common utilities to support Rich in cmd2-based applications."""
|
2 | 2 |
|
3 | 3 | import re
|
4 |
| -from collections.abc import Mapping |
| 4 | +from collections.abc import ( |
| 5 | + Iterable, |
| 6 | + Mapping, |
| 7 | +) |
5 | 8 | from enum import Enum
|
6 | 9 | from typing import (
|
7 | 10 | IO,
|
|
18 | 21 | )
|
19 | 22 | from rich.padding import Padding
|
20 | 23 | from rich.protocol import rich_cast
|
| 24 | +from rich.segment import Segment |
21 | 25 | from rich.style import StyleType
|
22 | 26 | from rich.table import (
|
23 | 27 | Column,
|
@@ -258,47 +262,6 @@ def rich_text_to_string(text: Text) -> str:
|
258 | 262 | return capture.get()
|
259 | 263 |
|
260 | 264 |
|
261 |
| -# If True, Rich still has the bug addressed in string_to_rich_text(). |
262 |
| -_from_ansi_has_newline_bug = Text.from_ansi("\n").plain == "" |
263 |
| - |
264 |
| - |
265 |
| -def string_to_rich_text(text: str) -> Text: |
266 |
| - r"""Create a Rich Text object from a string which can contain ANSI style sequences. |
267 |
| -
|
268 |
| - This wraps rich.Text.from_ansi() to handle an issue where it removes the |
269 |
| - trailing line break from a string (e.g. "Hello\n" becomes "Hello"). |
270 |
| -
|
271 |
| - There is currently a pull request to fix this. |
272 |
| - https://github.com/Textualize/rich/pull/3793 |
273 |
| -
|
274 |
| - :param text: a string to convert to a Text object. |
275 |
| - :return: the converted string |
276 |
| - """ |
277 |
| - result = Text.from_ansi(text) |
278 |
| - |
279 |
| - if _from_ansi_has_newline_bug: |
280 |
| - # If the original string ends with a recognized line break character, |
281 |
| - # then restore the missing newline. We use "\n" because Text.from_ansi() |
282 |
| - # converts all line breaks into newlines. |
283 |
| - # Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines |
284 |
| - line_break_chars = { |
285 |
| - "\n", # Line Feed |
286 |
| - "\r", # Carriage Return |
287 |
| - "\v", # Vertical Tab |
288 |
| - "\f", # Form Feed |
289 |
| - "\x1c", # File Separator |
290 |
| - "\x1d", # Group Separator |
291 |
| - "\x1e", # Record Separator |
292 |
| - "\x85", # Next Line (NEL) |
293 |
| - "\u2028", # Line Separator |
294 |
| - "\u2029", # Paragraph Separator |
295 |
| - } |
296 |
| - if text and text[-1] in line_break_chars: |
297 |
| - result.append("\n") |
298 |
| - |
299 |
| - return result |
300 |
| - |
301 |
| - |
302 | 265 | def indent(renderable: RenderableType, level: int) -> Padding:
|
303 | 266 | """Indent a Rich renderable.
|
304 | 267 |
|
@@ -350,6 +313,133 @@ def prepare_objects_for_rendering(*objects: Any) -> tuple[Any, ...]:
|
350 | 313 |
|
351 | 314 | # Check for any ANSI style sequences in the string.
|
352 | 315 | if ANSI_STYLE_SEQUENCE_RE.search(renderable_as_str):
|
353 |
| - object_list[i] = string_to_rich_text(renderable_as_str) |
| 316 | + object_list[i] = Text.from_ansi(renderable_as_str) |
354 | 317 |
|
355 | 318 | return tuple(object_list)
|
| 319 | + |
| 320 | + |
| 321 | +################################################################################### |
| 322 | +# Rich Library Monkey Patches |
| 323 | +# |
| 324 | +# These patches fix specific bugs in the Rich library. They are conditional and |
| 325 | +# will only be applied if the bug is detected. When the bugs are fixed in a |
| 326 | +# future Rich release, these patches and their corresponding tests should be |
| 327 | +# removed. |
| 328 | +################################################################################### |
| 329 | + |
| 330 | +################################################################################### |
| 331 | +# Text.from_ansi() monkey patch |
| 332 | +################################################################################### |
| 333 | + |
| 334 | +# Save original Text.from_ansi() so we can call it in our wrapper |
| 335 | +_orig_text_from_ansi = Text.from_ansi |
| 336 | + |
| 337 | + |
| 338 | +@classmethod # type: ignore[misc] |
| 339 | +def _from_ansi_wrapper(cls: type[Text], text: str, *args: Any, **kwargs: Any) -> Text: # noqa: ARG001 |
| 340 | + r"""Wrap Text.from_ansi() to fix its trailing newline bug. |
| 341 | +
|
| 342 | + This wrapper handles an issue where Text.from_ansi() removes the |
| 343 | + trailing line break from a string (e.g. "Hello\n" becomes "Hello"). |
| 344 | +
|
| 345 | + There is currently a pull request to fix this. |
| 346 | + https://github.com/Textualize/rich/pull/3793 |
| 347 | + """ |
| 348 | + result = _orig_text_from_ansi(text, *args, **kwargs) |
| 349 | + |
| 350 | + # If the original string ends with a recognized line break character, |
| 351 | + # then restore the missing newline. We use "\n" because Text.from_ansi() |
| 352 | + # converts all line breaks into newlines. |
| 353 | + # Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines |
| 354 | + line_break_chars = { |
| 355 | + "\n", # Line Feed |
| 356 | + "\r", # Carriage Return |
| 357 | + "\v", # Vertical Tab |
| 358 | + "\f", # Form Feed |
| 359 | + "\x1c", # File Separator |
| 360 | + "\x1d", # Group Separator |
| 361 | + "\x1e", # Record Separator |
| 362 | + "\x85", # Next Line (NEL) |
| 363 | + "\u2028", # Line Separator |
| 364 | + "\u2029", # Paragraph Separator |
| 365 | + } |
| 366 | + if text and text[-1] in line_break_chars: |
| 367 | + result.append("\n") |
| 368 | + |
| 369 | + return result |
| 370 | + |
| 371 | + |
| 372 | +def _from_ansi_has_newline_bug() -> bool: |
| 373 | + """Check if Test.from_ansi() strips the trailing line break from a string.""" |
| 374 | + return Text.from_ansi("\n") == Text.from_ansi("") |
| 375 | + |
| 376 | + |
| 377 | +# Only apply the monkey patch if the bug is present |
| 378 | +if _from_ansi_has_newline_bug(): |
| 379 | + Text.from_ansi = _from_ansi_wrapper # type: ignore[assignment] |
| 380 | + |
| 381 | + |
| 382 | +################################################################################### |
| 383 | +# Segment.apply_style() monkey patch |
| 384 | +################################################################################### |
| 385 | + |
| 386 | +# Save original Segment.apply_style() so we can call it in our wrapper |
| 387 | +_orig_segment_apply_style = Segment.apply_style |
| 388 | + |
| 389 | + |
| 390 | +@classmethod # type: ignore[misc] |
| 391 | +def _apply_style_wrapper(cls: type[Segment], *args: Any, **kwargs: Any) -> Iterable["Segment"]: |
| 392 | + r"""Wrap Segment.apply_style() to fix bug with styling newlines. |
| 393 | +
|
| 394 | + This wrapper handles an issue where Segment.apply_style() includes newlines |
| 395 | + within styled Segments. As a result, when printing text using a background color |
| 396 | + and soft wrapping, the background color incorrectly carries over onto the following line. |
| 397 | +
|
| 398 | + You can reproduce this behavior by calling console.print() using a background color |
| 399 | + and soft wrapping. |
| 400 | +
|
| 401 | + For example: |
| 402 | + console.print("line_1", style="blue on white", soft_wrap=True) |
| 403 | +
|
| 404 | + When soft wrapping is disabled, console.print() splits Segments into their individual |
| 405 | + lines, which separates the newlines from the styled text. Therefore, the background color |
| 406 | + issue does not occur in that mode. |
| 407 | +
|
| 408 | + This function copies that behavior to fix this the issue even when soft wrapping is enabled. |
| 409 | + """ |
| 410 | + styled_segments = list(_orig_segment_apply_style(*args, **kwargs)) |
| 411 | + newline_segment = cls.line() |
| 412 | + |
| 413 | + # If the final segment is a newline, it will be stripped by Segment.split_lines(). |
| 414 | + # Save an unstyled newline to restore later. |
| 415 | + end_segment = newline_segment if styled_segments and styled_segments[-1].text == "\n" else None |
| 416 | + |
| 417 | + # Use Segment.split_lines() to separate the styled text from the newlines. |
| 418 | + # This way the ANSI reset code will appear before any newline. |
| 419 | + sanitized_segments: list[Segment] = [] |
| 420 | + |
| 421 | + lines = list(Segment.split_lines(styled_segments)) |
| 422 | + for index, line in enumerate(lines): |
| 423 | + sanitized_segments.extend(line) |
| 424 | + if index < len(lines) - 1: |
| 425 | + sanitized_segments.append(newline_segment) |
| 426 | + |
| 427 | + if end_segment is not None: |
| 428 | + sanitized_segments.append(end_segment) |
| 429 | + |
| 430 | + return sanitized_segments |
| 431 | + |
| 432 | + |
| 433 | +def _rich_has_styled_newline_bug() -> bool: |
| 434 | + """Check if newlines are styled when soft wrapping.""" |
| 435 | + console = Console(force_terminal=True) |
| 436 | + with console.capture() as capture: |
| 437 | + console.print("line_1", style="blue on white", soft_wrap=True) |
| 438 | + |
| 439 | + # Check if we see a styled newline in the output |
| 440 | + return "\x1b[34;47m\n\x1b[0m" in capture.get() |
| 441 | + |
| 442 | + |
| 443 | +# Only apply the monkey patch if the bug is present |
| 444 | +if _rich_has_styled_newline_bug(): |
| 445 | + Segment.apply_style = _apply_style_wrapper # type: ignore[assignment] |
0 commit comments