Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ See the `dependencies` list under the `[project]` heading in [pyproject.toml](..
| ---------------------------------------------------------- | --------------- | ------------------------------------------------------ |
| [python](https://www.python.org/downloads/) | `3.10` | Python programming language |
| [pyperclip](https://github.com/asweigart/pyperclip) | `1.8` | Cross-platform clipboard functions |
| [rich](https://github.com/Textualize/rich) | `14.1.0` | Add rich text and beautiful formatting in the terminal |
| [rich](https://github.com/Textualize/rich) | `14.3.0` | Add rich text and beautiful formatting in the terminal |
| [rich-argparse](https://github.com/hamdanal/rich-argparse) | `1.7.1` | A rich-enabled help formatter for argparse |

> `macOS` and `Windows` each have an extra dependency to ensure they have a viable alternative to
Expand Down
75 changes: 1 addition & 74 deletions cmd2/rich_utils.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
"""Provides common utilities to support Rich in cmd2-based applications."""

import re
from collections.abc import (
Iterable,
Mapping,
)
from collections.abc import Mapping
from enum import Enum
from typing import (
IO,
Expand All @@ -22,7 +19,6 @@
from rich.padding import Padding
from rich.pretty import is_expandable
from rich.protocol import rich_cast
from rich.segment import Segment
from rich.style import StyleType
from rich.table import (
Column,
Expand Down Expand Up @@ -380,72 +376,3 @@ def _from_ansi_has_newline_bug() -> bool:
# Only apply the monkey patch if the bug is present
if _from_ansi_has_newline_bug():
Text.from_ansi = _from_ansi_wrapper # type: ignore[assignment]


###################################################################################
# Segment.apply_style() monkey patch
###################################################################################

# Save original Segment.apply_style() so we can call it in our wrapper
_orig_segment_apply_style = Segment.apply_style


@classmethod # type: ignore[misc]
def _apply_style_wrapper(cls: type[Segment], *args: Any, **kwargs: Any) -> Iterable["Segment"]:
r"""Wrap Segment.apply_style() to fix bug with styling newlines.

This wrapper handles an issue where Segment.apply_style() includes newlines
within styled Segments. As a result, when printing text using a background color
and soft wrapping, the background color incorrectly carries over onto the following line.

You can reproduce this behavior by calling console.print() using a background color
and soft wrapping.

For example:
console.print("line_1", style="blue on white", soft_wrap=True)

When soft wrapping is disabled, console.print() splits Segments into their individual
lines, which separates the newlines from the styled text. Therefore, the background color
issue does not occur in that mode.

This function copies that behavior to fix this the issue even when soft wrapping is enabled.

There is currently a pull request on Rich to fix this.
https://github.com/Textualize/rich/pull/3839
"""
styled_segments = list(_orig_segment_apply_style(*args, **kwargs))
newline_segment = cls.line()

# If the final segment ends in a newline, that newline will be stripped by Segment.split_lines().
# Save an unstyled newline to restore later.
end_segment = newline_segment if styled_segments and styled_segments[-1].text.endswith("\n") else None

# Use Segment.split_lines() to separate the styled text from the newlines.
# This way the ANSI reset code will appear before any newline.
sanitized_segments: list[Segment] = []

lines = list(Segment.split_lines(styled_segments))
for index, line in enumerate(lines):
sanitized_segments.extend(line)
if index < len(lines) - 1:
sanitized_segments.append(newline_segment)

if end_segment is not None:
sanitized_segments.append(end_segment)

return sanitized_segments


def _rich_has_styled_newline_bug() -> bool:
"""Check if newlines are styled when soft wrapping."""
console = Console(force_terminal=True)
with console.capture() as capture:
console.print("line_1", style="blue on white", soft_wrap=True)

# Check if we see a styled newline in the output
return "\x1b[34;47m\n\x1b[0m" in capture.get()


# Only apply the monkey patch if the bug is present
if _rich_has_styled_newline_bug():
Segment.apply_style = _apply_style_wrapper # type: ignore[assignment]
2 changes: 0 additions & 2 deletions cmd2/styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ class Cmd2Style(StrEnum):

COMMAND_LINE = "cmd2.example" # Command line examples in help text
ERROR = "cmd2.error" # Error text (used by perror())
EXCEPTION_TYPE = "cmd2.exception.type" # Used by pexcept to mark an exception type
HELP_HEADER = "cmd2.help.header" # Help table header text
HELP_LEADER = "cmd2.help.leader" # Text right before the help tables are listed
SUCCESS = "cmd2.success" # Success text (used by psuccess())
Expand All @@ -63,7 +62,6 @@ class Cmd2Style(StrEnum):
DEFAULT_CMD2_STYLES: dict[str, StyleType] = {
Cmd2Style.COMMAND_LINE: Style(color=Color.CYAN, bold=True),
Cmd2Style.ERROR: Style(color=Color.BRIGHT_RED),
Cmd2Style.EXCEPTION_TYPE: Style(color=Color.DARK_ORANGE, bold=True),
Cmd2Style.HELP_HEADER: Style(color=Color.BRIGHT_GREEN, bold=True),
Cmd2Style.HELP_LEADER: Style(color=Color.CYAN, bold=True),
Cmd2Style.SUCCESS: Style(color=Color.GREEN),
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ dependencies = [
"gnureadline>=8; platform_system == 'Darwin'",
"pyperclip>=1.8.2",
"pyreadline3>=3.4; platform_system == 'Windows'",
"rich>=14.1.0",
"rich>=14.3.0",
"rich-argparse>=1.7.1",
]

Expand Down
187 changes: 0 additions & 187 deletions tests/test_rich_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import pytest
import rich.box
from rich.console import Console
from rich.segment import Segment
from rich.style import Style
from rich.table import Table
from rich.text import Text
Expand Down Expand Up @@ -143,189 +142,3 @@ def test_from_ansi_wrapper() -> None:
# Test empty string
input_string = ""
assert Text.from_ansi(input_string).plain == input_string


@pytest.mark.parametrize(
# Print with style and verify that everything but newline characters have style.
('objects', 'sep', 'end', 'expected'),
[
# Print nothing
((), " ", "\n", "\n"),
# Empty string
(("",), " ", "\n", "\n"),
# Multple empty strings
(("", ""), " ", "\n", "\x1b[34;47m \x1b[0m\n"),
# Basic string
(
("str_1",),
" ",
"\n",
"\x1b[34;47mstr_1\x1b[0m\n",
),
# String which ends with newline
(
("str_1\n",),
" ",
"\n",
"\x1b[34;47mstr_1\x1b[0m\n\n",
),
# String which ends with multiple newlines
(
("str_1\n\n",),
" ",
"\n",
"\x1b[34;47mstr_1\x1b[0m\n\n\n",
),
# Mutiple lines
(
("str_1\nstr_2",),
" ",
"\n",
"\x1b[34;47mstr_1\x1b[0m\n\x1b[34;47mstr_2\x1b[0m\n",
),
# Multiple strings
(
("str_1", "str_2"),
" ",
"\n",
"\x1b[34;47mstr_1 str_2\x1b[0m\n",
),
# Multiple strings with newline between them.
(
("str_1\n", "str_2"),
" ",
"\n",
"\x1b[34;47mstr_1\x1b[0m\n\x1b[34;47m str_2\x1b[0m\n",
),
# Multiple strings and non-space value for sep
(
("str_1", "str_2"),
"(sep)",
"\n",
"\x1b[34;47mstr_1(sep)str_2\x1b[0m\n",
),
# Multiple strings and sep is a newline
(
("str_1", "str_2"),
"\n",
"\n",
"\x1b[34;47mstr_1\x1b[0m\n\x1b[34;47mstr_2\x1b[0m\n",
),
# Multiple strings and sep has newlines
(
("str_1", "str_2"),
"(sep1)\n(sep2)\n",
"\n",
("\x1b[34;47mstr_1(sep1)\x1b[0m\n\x1b[34;47m(sep2)\x1b[0m\n\x1b[34;47mstr_2\x1b[0m\n"),
),
# Non-newline value for end.
(
("str_1", "str_2"),
"(sep1)\n(sep2)",
"(end)",
"\x1b[34;47mstr_1(sep1)\x1b[0m\n\x1b[34;47m(sep2)str_2\x1b[0m\x1b[34;47m(end)\x1b[0m",
),
# end has newlines.
(
("str_1", "str_2"),
"(sep1)\n(sep2)\n",
"(end1)\n(end2)\n",
(
"\x1b[34;47mstr_1(sep1)\x1b[0m\n"
"\x1b[34;47m(sep2)\x1b[0m\n"
"\x1b[34;47mstr_2\x1b[0m\x1b[34;47m(end1)\x1b[0m\n"
"\x1b[34;47m(end2)\x1b[0m\n"
),
),
# Empty sep and end values
(
("str_1", "str_2"),
"",
"",
"\x1b[34;47mstr_1str_2\x1b[0m",
),
],
)
def test_apply_style_wrapper_soft_wrap(objects: tuple[str], sep: str, end: str, expected: str) -> None:
# Check if we are still patching Segment.apply_style(). If this check fails, then Rich
# has fixed the bug. Therefore, we can remove this test function and ru._apply_style_wrapper.
assert Segment.apply_style.__func__ is ru._apply_style_wrapper.__func__ # type: ignore[attr-defined]

console = Console(force_terminal=True)

try:
# Since our patch was meant to fix behavior seen when soft wrapping,
# we will first test in that condition.
with console.capture() as capture:
console.print(*objects, sep=sep, end=end, style="blue on white", soft_wrap=True)
result = capture.get()
assert result == expected

# Now print with soft wrapping disabled. Since none of our input strings are long enough
# to auto wrap, the results should be the same as our soft-wrapping output.
with console.capture() as capture:
console.print(*objects, sep=sep, end=end, style="blue on white", soft_wrap=False)
result = capture.get()
assert result == expected

# Now remove our patch and disable soft wrapping. This will prove that our patch produces
# the same result as unpatched Rich
Segment.apply_style = ru._orig_segment_apply_style # type: ignore[assignment]

with console.capture() as capture:
console.print(*objects, sep=sep, end=end, style="blue on white", soft_wrap=False)
result = capture.get()
assert result == expected

finally:
# Restore the patch
Segment.apply_style = ru._apply_style_wrapper # type: ignore[assignment]


def test_apply_style_wrapper_word_wrap() -> None:
"""
Test that our patch didn't mess up word wrapping.
Make sure it does not insert styled newlines or apply style to existing newlines.
"""
# Check if we are still patching Segment.apply_style(). If this check fails, then Rich
# has fixed the bug. Therefore, we can remove this test function and ru._apply_style_wrapper.
assert Segment.apply_style.__func__ is ru._apply_style_wrapper.__func__ # type: ignore[attr-defined]

str1 = "this\nwill word wrap\n"
str2 = "and\nso will this\n"
sep = "(sep1)\n(sep2)\n"
end = "(end1)\n(end2)\n"
style = "blue on white"

# All newlines should appear outside of ANSI style sequences.
expected = (
"\x1b[34;47mthis\x1b[0m\n"
"\x1b[34;47mwill word \x1b[0m\n"
"\x1b[34;47mwrap\x1b[0m\n"
"\x1b[34;47m(sep1)\x1b[0m\n"
"\x1b[34;47m(sep2)\x1b[0m\n"
"\x1b[34;47mand\x1b[0m\n"
"\x1b[34;47mso will \x1b[0m\n"
"\x1b[34;47mthis\x1b[0m\n"
"\x1b[34;47m(end1)\x1b[0m\n"
"\x1b[34;47m(end2)\x1b[0m\n"
)

# Set a width which will cause word wrapping.
console = Console(force_terminal=True, width=10)

try:
with console.capture() as capture:
console.print(str1, str2, sep=sep, end=end, style=style, soft_wrap=False)
assert capture.get() == expected

# Now remove our patch and make sure it produced the same result as unpatched Rich.
Segment.apply_style = ru._orig_segment_apply_style # type: ignore[assignment]

with console.capture() as capture:
console.print(str1, str2, sep=sep, end=end, style=style, soft_wrap=False)
assert capture.get() == expected

finally:
# Restore the patch
Segment.apply_style = ru._apply_style_wrapper # type: ignore[assignment]
Loading