Repository: fastapi/typer (python)

Issue:
[BUG] help line length miscalculated when using stylized text


### Discussed in https://github.com/fastapi/typer/discussions/1101

<div type='discussions-op-text'>

<sup>Originally posted by **zmeir** December 23, 2024</sup>
### First Check

- [X] I added a very descriptive title here.
- [X] I used the GitHub search to find a similar question and didn't find it.
- [X] I searched the Typer documentation, with the integrated search.
- [X] I already searched in Google "How to X in Typer" and didn't find any information.
- [X] I already read and followed all the tutorials in the docs and didn't find an answer.
- [X] I already checked if it is not related to Typer but to [Click](https://github.com/pallets/click).

### Commit to Help

- [X] I commit to help with one of those options 👆

### Example Code

```python
from typing import Annotated

import typer


app = typer.Typer()


@app.command()
def example(
    a: Annotated[str, typer.Option(help="This is A")],
    b: Annotated[str, typer.Option(help=f"This is {typer.style('B', underline=True)}")],
):
    pass


if __name__ == "__main__":
    app()
```


### Description

When using `typer.style` in the help text of a command option, the rightmost `|` in the "frame" of the Options output is misaligned. This seems to be due to a miscalculation of the help line length, caused by the invisible style characters.

Using the example script - run `python example.py --help` and you should get this output:
![image](https://github.com/user-attachments/assets/ed8dd11b-f32f-45da-8e7a-6542535b0a97)
(highlighted the misaligned part)

### Operating System

macOS

### Operating System Details

macOS version: 13.7.1 (22H221)

### Typer Version

0.15.1

### Python Version

3.10.13

### Additional Context

_No response_</div>


Patch:
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()


Test patch:
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}"
+    )