diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index d31dbafb5c..5901a2f569 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -99,3 +99,92 @@ def main(bar: str): result = runner.invoke(app, ["--help"]) assert "Usage" in result.stdout assert "BAR" in result.stdout + + +def test_make_rich_text_with_ansi_escape_sequences(): + from typer.rich_utils import Text, _make_rich_text + + ansi_text = "This is \x1b[4munderlined\x1b[0m text" + result = _make_rich_text(text=ansi_text, markup_mode="rich") + + assert isinstance(result, Text) + assert "\x1b[" not in result.plain + assert "underlined" in result.plain + + mixed_text = "Start \x1b[31mred\x1b[0m middle \x1b[32mgreen\x1b[0m end" + result = _make_rich_text(text=mixed_text, markup_mode="rich") + assert isinstance(result, Text) + assert "\x1b[" not in result.plain + assert "red" in result.plain + assert "green" in result.plain + + fake_ansi = "This contains \x1b[ but not a complete sequence" + result = _make_rich_text(text=fake_ansi, markup_mode="rich") + assert isinstance(result, Text) + assert "\x1b[" not in result.plain + assert "This contains " in result.plain + + +def test_make_rich_text_with_typer_style_in_help(): + app = typer.Typer() + + @app.command() + def example( + a: str = typer.Option(help="This is A"), + b: str = typer.Option(help=f"This is {typer.style('B', underline=True)}"), + ): + """Example command with styled help text.""" + pass # pragma: no cover + + result = runner.invoke(app, ["--help"]) + + assert result.exit_code == 0 + assert "This is A" in result.stdout + assert "This is B" in result.stdout + assert "\x1b[" not in result.stdout + + +def test_help_table_alignment_with_styled_text(): + app = typer.Typer() + + @app.command() + def example( + a: str = typer.Option(help="This is A"), + b: str = typer.Option(help=f"This is {typer.style('B', underline=True)}"), + c: str = typer.Option(help="This is C"), + ): + """Example command with styled help text.""" + pass # pragma: no cover + + result = runner.invoke(app, ["--help"]) + + assert result.exit_code == 0 + + lines = result.stdout.split("\n") + + option_a_line = None + option_b_line = None + option_c_line = None + + for line in lines: + if "--a" in line and "This is A" in line: + option_a_line = line + elif "--b" in line and "This is B" in line: + option_b_line = line + elif "--c" in line and "This is C" in line: + option_c_line = line + + assert option_a_line is not None, "Option A line not found" + assert option_b_line is not None, "Option B line not found" + assert option_c_line is not None, "Option C line not found" + + def find_right_boundary_pos(line): + return line.rfind("|") + + pos_a = find_right_boundary_pos(option_a_line) + pos_b = find_right_boundary_pos(option_b_line) + pos_c = find_right_boundary_pos(option_c_line) + + assert pos_a == pos_b == pos_c, ( + f"Right boundaries not aligned: A={pos_a}, B={pos_b}, C={pos_c}" + ) diff --git a/typer/rich_utils.py b/typer/rich_utils.py index ad110cb8d6..e0d4debf73 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -97,6 +97,7 @@ MARKUP_MODE_MARKDOWN = "markdown" MARKUP_MODE_RICH = "rich" _RICH_HELP_PANEL_NAME = "rich_help_panel" +ANSI_PREFIX = "\033[" MarkupModeStrict = Literal["markdown", "rich"] @@ -124,6 +125,10 @@ class NegativeOptionHighlighter(RegexHighlighter): negative_highlighter = NegativeOptionHighlighter() +def _has_ansi_character(text: str) -> bool: + return ANSI_PREFIX in text + + def _get_rich_console(stderr: bool = False) -> Console: return Console( theme=Theme( @@ -160,7 +165,10 @@ def _make_rich_text( return Markdown(text, style=style) else: assert markup_mode == MARKUP_MODE_RICH - return highlighter(Text.from_markup(text, style=style)) + if _has_ansi_character(text): + return highlighter(Text.from_ansi(text, style=style)) + else: + return highlighter(Text.from_markup(text, style=style)) @group()