Repository: Textualize/textual
Pull request: Underline bar renderable
Naming is hard :) more than happy to hear suggestions for the params etc.

Closes #238 

Problem statement:
Implement a bar renderable for tabbed dialogs
Implement a renderable used to underline tabs in a tabbed dialog.

This renderable should use the same unicode characters as Rich progress bars. It should render a line with a portion in a different color extending from p1 to p2.

Also add the option to not render the highlight and just the background bar.

Suggested interface:

```python
class TabUnderline:
    def __init__(self, highlight: tuple[float, float] | None = None, color1: Color, color2: Color):
        ...
```

Hints from issue discussion:

Commit message: Ensure we clamp range properly, passing Styles directly

Patch to review:
diff --git a/src/textual/renderables/__init__.py b/src/textual/renderables/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/textual/renderables/underline_bar.py b/src/textual/renderables/underline_bar.py
new file mode 100644
index 0000000000..357b340d87
--- /dev/null
+++ b/src/textual/renderables/underline_bar.py
@@ -0,0 +1,127 @@
+from __future__ import annotations
+
+from rich.console import ConsoleOptions, Console, RenderResult
+from rich.segment import Segment
+from rich.style import Style, StyleType
+
+
+class UnderlineBar:
+    """Thin horizontal bar with a portion highlighted.
+
+    Args:
+        highlight_range (tuple[float, float]): The range to highlight. Defaults to ``(0, 0)`` (no highlight)
+        highlight_style (StyleType): The style of the highlighted range of the bar.
+        background_style (StyleType): The style of the non-highlighted range(s) of the bar.
+        width (int, optional): The width of the bar, or ``None`` to fill available width.
+    """
+
+    def __init__(
+        self,
+        highlight_range: tuple[float, float] = (0, 0),
+        highlight_style: StyleType = "magenta",
+        background_style: StyleType = "grey37",
+        width: int | None = None,
+    ) -> None:
+        self.highlight_range = highlight_range
+        if isinstance(highlight_style, str):
+            self.highlight_style = Style.parse(highlight_style)
+        else:
+            self.highlight_style = highlight_style
+        if isinstance(background_style, str):
+            self.background_style = Style.parse(background_style)
+        else:
+            self.background_style = background_style
+        self.width = width
+
+    def __rich_console__(
+        self, console: Console, options: ConsoleOptions
+    ) -> RenderResult:
+        half_bar_right = "╸"
+        half_bar_left = "╺"
+        bar = "━"
+
+        width = self.width or options.max_width
+        start, end = self.highlight_range
+
+        start = max(start, 0)
+        end = min(end, width)
+
+        if start == end == 0 or end < 0 or start > end:
+            yield Segment(bar * width, style=self.background_style)
+            return
+
+        # Round start and end to nearest half
+        start = round(start * 2) / 2
+        end = round(end * 2) / 2
+
+        # Check if we start/end on a number that rounds to a .5
+        half_start = start - int(start) > 0
+        half_end = end - int(end) > 0
+
+        # Initial non-highlighted portion of bar
+        yield Segment(bar * (int(start - 0.5)), style=self.background_style)
+        if not half_start and start > 0:
+            yield Segment(half_bar_right, style=self.background_style)
+
+        # The highlighted portion
+        bar_width = int(end) - int(start)
+        if half_start:
+            yield Segment(
+                half_bar_left + bar * (bar_width - 1), style=self.highlight_style
+            )
+        else:
+            yield Segment(bar * bar_width, style=self.highlight_style)
+        if half_end:
+            yield Segment(half_bar_right, style=self.highlight_style)
+
+        # The non-highlighted tail
+        if not half_end and end - width != 0:
+            yield Segment(half_bar_left, style=self.background_style)
+        yield Segment(bar * (int(width) - int(end) - 1), style=self.background_style)
+
+
+if __name__ == "__main__":
+    import random
+    from time import sleep
+    from rich.color import ANSI_COLOR_NAMES
+
+    console = Console()
+
+    def frange(start, end, step):
+        current = start
+        while current < end:
+            yield current
+            current += step
+
+        while current >= 0:
+            yield current
+            current -= step
+
+    step = 0.1
+    start_range = frange(0.5, 10.5, step)
+    end_range = frange(10, 20, step)
+    ranges = zip(start_range, end_range)
+
+    console.print(UnderlineBar(width=20), f"   (.0, .0)")
+
+    for range in ranges:
+        color = random.choice(list(ANSI_COLOR_NAMES.keys()))
+        console.print(
+            UnderlineBar(
+                range,
+                highlight_style=color,
+                width=20,
+            ),
+            f"   {range}",
+        )
+
+    from rich.live import Live
+
+    bar = UnderlineBar(width=80, highlight_range=(0, 4.5))
+    with Live(bar, refresh_per_second=60) as live:
+        while True:
+            bar.highlight_range = (
+                bar.highlight_range[0] + 0.1,
+                bar.highlight_range[1] + 0.1,
+            )
+            sleep(0.005)
diff --git a/tests/renderables/__init__.py b/tests/renderables/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/renderables/test_underline_bar.py b/tests/renderables/test_underline_bar.py
new file mode 100644
index 0000000000..511b84f2b8
--- /dev/null
+++ b/tests/renderables/test_underline_bar.py
@@ -0,0 +1,122 @@
+from rich.style import Style
+
+from tests.utilities.render import render
+from textual.renderables.underline_bar import UnderlineBar
+
+MAGENTA = "\x1b[35m"
+GREY = "\x1b[38;5;59m"
+STOP = "\x1b[0m"
+
+
+def test_no_highlight():
+    bar = UnderlineBar(width=6)
+    assert render(bar) == f"{GREY}━━━━━━{STOP}"
+
+
+def test_highlight_from_zero():
+    bar = UnderlineBar(highlight_range=(0, 2.5), width=6)
+    assert render(bar) == (
+        f"{MAGENTA}━━{STOP}{MAGENTA}╸{STOP}{GREY}━━━{STOP}"
+    )
+
+
+def test_highlight_from_zero_point_five():
+    bar = UnderlineBar(highlight_range=(0.5, 2), width=6)
+    assert render(bar) == (
+        f"{MAGENTA}╺━{STOP}{GREY}╺{STOP}{GREY}━━━{STOP}"
+    )
+
+
+def test_highlight_middle():
+    bar = UnderlineBar(highlight_range=(2, 4), width=6)
+    assert render(bar) == (
+        f"{GREY}━{STOP}"
+        f"{GREY}╸{STOP}"
+        f"{MAGENTA}━━{STOP}"
+        f"{GREY}╺{STOP}"
+        f"{GREY}━{STOP}"
+    )
+
+
+def test_highlight_half_start():
+    bar = UnderlineBar(highlight_range=(2.5, 4), width=6)
+    assert render(bar) == (
+        f"{GREY}━━{STOP}"
+        f"{MAGENTA}╺━{STOP}"
+        f"{GREY}╺{STOP}"
+        f"{GREY}━{STOP}"
+    )
+
+
+def test_highlight_half_end():
+    bar = UnderlineBar(highlight_range=(2, 4.5), width=6)
+    assert render(bar) == (
+        f"{GREY}━{STOP}"
+        f"{GREY}╸{STOP}"
+        f"{MAGENTA}━━{STOP}"
+        f"{MAGENTA}╸{STOP}"
+        f"{GREY}━{STOP}"
+    )
+
+
+def test_highlight_half_start_and_half_end():
+    bar = UnderlineBar(highlight_range=(2.5, 4.5), width=6)
+    assert render(bar) == (
+        f"{GREY}━━{STOP}"
+        f"{MAGENTA}╺━{STOP}"
+        f"{MAGENTA}╸{STOP}"
+        f"{GREY}━{STOP}"
+    )
+
+
+def test_highlight_to_near_end():
+    bar = UnderlineBar(highlight_range=(3, 5.5), width=6)
+    assert render(bar) == (
+        f"{GREY}━━{STOP}"
+        f"{GREY}╸{STOP}"
+        f"{MAGENTA}━━{STOP}"
+        f"{MAGENTA}╸{STOP}"
+    )
+
+
+def test_highlight_to_end():
+    bar = UnderlineBar(highlight_range=(3, 6), width=6)
+    assert render(bar) == (
+        f"{GREY}━━{STOP}{GREY}╸{STOP}{MAGENTA}━━━{STOP}"
+    )
+
+
+def test_highlight_out_of_bounds_start():
+    bar = UnderlineBar(highlight_range=(-2, 3), width=6)
+    assert render(bar) == (
+        f"{MAGENTA}━━━{STOP}{GREY}╺{STOP}{GREY}━━{STOP}"
+    )
+
+
+def test_highlight_out_of_bounds_end():
+    bar = UnderlineBar(highlight_range=(3, 9), width=6)
+    assert render(bar) == (
+        f"{GREY}━━{STOP}{GREY}╸{STOP}{MAGENTA}━━━{STOP}"
+    )
+
+
+def test_highlight_full_range_out_of_bounds_end():
+    bar = UnderlineBar(highlight_range=(9, 10), width=6)
+    assert render(bar) == f"{GREY}━━━━━━{STOP}"
+
+
+def test_highlight_full_range_out_of_bounds_start():
+    bar = UnderlineBar(highlight_range=(-5, -2), width=6)
+    assert render(bar) == f"{GREY}━━━━━━{STOP}"
+
+
+def test_init_with_str_style():
+    bar = UnderlineBar(background_style="green", highlight_style="yellow")
+    assert bar.background_style == Style(color="green")
+    assert bar.highlight_style == Style(color="yellow")
+
+
+def test_init_with_object_style():
+    bar = UnderlineBar(background_style=Style(color="green"), highlight_style=Style(color="yellow"))
+    assert bar.background_style == Style(color="green")
+    assert bar.highlight_style == Style(color="yellow")
diff --git a/tests/utilities/render.py b/tests/utilities/render.py
new file mode 100644
index 0000000000..a2435c542a
--- /dev/null
+++ b/tests/utilities/render.py
@@ -0,0 +1,24 @@
+import io
+import re
+
+from rich.console import Console, RenderableType
+
+
+re_link_ids = re.compile(r"id=[\d\.\-]*?;.*?\x1b")
+
+
+def replace_link_ids(render: str) -> str:
+    """Link IDs have a random ID and system path which is a problem for
+    reproducible tests.
+
+    """
+    return re_link_ids.sub("id=0;foo\x1b", render)
+
+
+def render(renderable: RenderableType, no_wrap: bool = False) -> str:
+    console = Console(
+        width=100, file=io.StringIO(), color_system="truecolor", legacy_windows=False
+    )
+    console.print(renderable, no_wrap=no_wrap)
+    output = replace_link_ids(console.file.getvalue())
+    return output


Code review comments:
diff_hunk: @@ -0,0 +1,127 @@
+from __future__ import annotations
+
+from rich.console import ConsoleOptions, Console, RenderResult
+from rich.segment import Segment
+from rich.style import Style, StyleType
+
+
+class UnderlineBar:
+    """Thin horizontal bar with a portion highlighted.
+
+    Args:
+        highlight_range (tuple[float, float]): The range to highlight. Defaults to ``(0, 0)`` (no highlight)
+        highlight_style (StyleType): The style of the highlighted range of the bar.
+        background_style (StyleType): The style of the non-highlighted range(s) of the bar.
+        width (int, optional): The width of the bar, or ``None`` to fill available width.
+    """
+
+    def __init__(
+        self,
+        highlight_range: tuple[float, float] = (0, 0),
+        highlight_style: StyleType = "magenta",
+        background_style: StyleType = "grey37",
+        width: int | None = None,
+    ) -> None:
+        self.highlight_range = highlight_range
+        if isinstance(highlight_style, str):
path: src/textual/renderables/underline_bar.py
text: @author:
`Style.parse` doesn't do this internally