Repository: Textualize/textual

Issue:
Render opacity
Create a renderable which applies an opacity value between 0 and 1.

This value should blend the foreground color to the background color. So an opacity of 0 essentially makes text invisible. And an opacity of 0.5 makes a 50% blend from background to foreground.

Interface should be something like the following:

```python
class OpacityRender:

    def __init__(self, renderable, opacity):
        ...
```

In the `__rich_console__` method, it will call `render_lines` and apply the opacity to the segments.


Discussion:

Resolving patch:
diff --git a/src/textual/renderables/_blend_colors.py b/src/textual/renderables/_blend_colors.py
new file mode 100644
--- /dev/null
+++ b/src/textual/renderables/_blend_colors.py
@@ -0,0 +1,23 @@
+from rich.color import Color
+
+
+def blend_colors(color1: Color, color2: Color, ratio: float) -> Color:
+    """Given two RGB colors, return a color that sits some distance between
+    them in RGB color space.
+
+    Args:
+        color1 (Color): The first color.
+        color2 (Color): The second color.
+        ratio (float): The ratio of color1 to color2.
+
+    Returns:
+        Color: A Color representing the blending of the two supplied colors.
+    """
+    r1, g1, b1 = color1.triplet
+    r2, g2, b2 = color2.triplet
+    dr = r2 - r1
+    dg = g2 - g1
+    db = b2 - b1
+    return Color.from_rgb(
+        red=r1 + dr * ratio, green=g1 + dg * ratio, blue=b1 + db * ratio
+    )
diff --git a/src/textual/renderables/opacity.py b/src/textual/renderables/opacity.py
new file mode 100644
--- /dev/null
+++ b/src/textual/renderables/opacity.py
@@ -0,0 +1,91 @@
+import functools
+
+from rich.color import Color
+from rich.console import ConsoleOptions, Console, RenderResult, RenderableType
+from rich.segment import Segment
+from rich.style import Style
+
+from textual.renderables._blend_colors import blend_colors
+
+
+class Opacity:
+    """Wrap a renderable to blend foreground color into the background color.
+
+    Args:
+        renderable (RenderableType): The RenderableType to manipulate.
+        opacity (float): The opacity as a float. A value of 1.0 means text is fully visible.
+    """
+
+    def __init__(self, renderable: RenderableType, opacity: float = 1.0) -> None:
+        self.renderable = renderable
+        self.opacity = opacity
+
+    def __rich_console__(
+        self, console: Console, options: ConsoleOptions
+    ) -> RenderResult:
+        segments = console.render(self.renderable, options)
+        opacity = self.opacity
+        for segment in segments:
+            style = segment.style
+            if not style:
+                yield segment
+                continue
+            fg = style.color
+            bg = style.bgcolor
+            if fg and fg.triplet and bg and bg.triplet:
+                yield Segment(
+                    segment.text,
+                    _get_blended_style_cached(
+                        fg_color=fg, bg_color=bg, opacity=opacity
+                    ),
+                    segment.control,
+                )
+            else:
+                yield segment
+
+
+@functools.lru_cache(maxsize=1024)
+def _get_blended_style_cached(
+    fg_color: Color, bg_color: Color, opacity: float
+) -> Style:
+    return Style.from_color(
+        color=blend_colors(bg_color, fg_color, ratio=opacity),
+        bgcolor=bg_color,
+    )
+
+
+if __name__ == "__main__":
+    from rich.live import Live
+    from rich.panel import Panel
+    from rich.text import Text
+
+    from time import sleep
+
+    console = Console()
+
+    panel = Panel.fit(
+        Text("Steak: £30", style="#fcffde on #03761e"),
+        title="Menu",
+        style="#ffffff on #000000",
+    )
+    console.print(panel)
+
+    opacity_panel = Opacity(panel, opacity=0.5)
+    console.print(opacity_panel)
+
+    def frange(start, end, step):
+        current = start
+        while current < end:
+            yield current
+            current += step
+
+        while current >= 0:
+            yield current
+            current -= step
+
+    import itertools
+
+    with Live(opacity_panel, refresh_per_second=60) as live:
+        for value in itertools.cycle(frange(0, 1, 0.05)):
+            opacity_panel.value = value
+            sleep(0.05)
diff --git a/src/textual/renderables/sparkline.py b/src/textual/renderables/sparkline.py
--- a/src/textual/renderables/sparkline.py
+++ b/src/textual/renderables/sparkline.py
@@ -8,6 +8,8 @@
 from rich.segment import Segment
 from rich.style import Style
 
+from textual.renderables._blend_colors import blend_colors
+
 T = TypeVar("T", int, float)
 
 
@@ -62,10 +64,10 @@ def __rich_console__(
         width = self.width or options.max_width
         len_data = len(self.data)
         if len_data == 0:
-            yield Segment("▁" * width, style=self.min_color)
+            yield Segment("▁" * width, self.min_color)
             return
         if len_data == 1:
-            yield Segment("█" * width, style=self.max_color)
+            yield Segment("█" * width, self.max_color)
             return
 
         minimum, maximum = min(self.data), max(self.data)
@@ -83,32 +85,10 @@ def __rich_console__(
             partition_summary = summary_func(partition)
             height_ratio = (partition_summary - minimum) / extent
             bar_index = int(height_ratio * (len(self.BARS) - 1))
-            bar_color = _blend_colors(min_color, max_color, height_ratio)
+            bar_color = blend_colors(min_color, max_color, height_ratio)
             bars_rendered += 1
             bucket_index += step
-            yield Segment(text=self.BARS[bar_index], style=Style.from_color(bar_color))
-
-
-def _blend_colors(color1: Color, color2: Color, ratio: float) -> Color:
-    """Given two RGB colors, return a color that sits some distance between
-    them in RGB color space.
-
-    Args:
-        color1 (Color): The first color.
-        color2 (Color): The second color.
-        ratio (float): The ratio of color1 to color2.
-
-    Returns:
-        Color: A Color representing the blending of the two supplied colors.
-    """
-    r1, g1, b1 = color1.triplet
-    r2, g2, b2 = color2.triplet
-    dr = r2 - r1
-    dg = g2 - g1
-    db = b2 - b1
-    return Color.from_rgb(
-        red=r1 + dr * ratio, green=g1 + dg * ratio, blue=b1 + db * ratio
-    )
+            yield Segment(self.BARS[bar_index], Style.from_color(bar_color))
 
 
 if __name__ == "__main__":


Test patch:
diff --git a/tests/renderables/test_opacity.py b/tests/renderables/test_opacity.py
new file mode 100644
--- /dev/null
+++ b/tests/renderables/test_opacity.py
@@ -0,0 +1,50 @@
+import pytest
+from rich.text import Text
+
+from tests.utilities.render import render
+from textual.renderables.opacity import Opacity
+
+STOP = "\x1b[0m"
+
+
+@pytest.fixture
+def text():
+    return Text("Hello, world!", style="#ff0000 on #00ff00", end="")
+
+
+def test_simple_opacity(text):
+    blended_red_on_green = "\x1b[38;2;127;127;0;48;2;0;255;0m"
+    assert render(Opacity(text, opacity=.5)) == (
+        f"{blended_red_on_green}Hello, world!{STOP}"
+    )
+
+
+def test_value_zero_sets_foreground_color_to_background_color(text):
+    foreground = background = "0;255;0"
+    assert render(Opacity(text, opacity=0)) == (
+        f"\x1b[38;2;{foreground};48;2;{background}mHello, world!{STOP}"
+    )
+
+
+def test_opacity_value_of_one_noop(text):
+    assert render(Opacity(text, opacity=1)) == render(text)
+
+
+def test_ansi_colors_noop():
+    ansi_colored_text = Text("Hello, world!", style="red on green", end="")
+    assert render(Opacity(ansi_colored_text, opacity=.5)) == render(ansi_colored_text)
+
+
+def test_opacity_no_style_noop():
+    text_no_style = Text("Hello, world!", end="")
+    assert render(Opacity(text_no_style, opacity=.2)) == render(text_no_style)
+
+
+def test_opacity_only_fg_noop():
+    text_only_fg = Text("Hello, world!", style="#ff0000", end="")
+    assert render(Opacity(text_only_fg, opacity=.5)) == render(text_only_fg)
+
+
+def test_opacity_only_bg_noop():
+    text_only_bg = Text("Hello, world!", style="on #ff0000", end="")
+    assert render(Opacity(text_only_bg, opacity=.5)) == render(text_only_bg)
diff --git a/tests/utilities/render.py b/tests/utilities/render.py
--- a/tests/utilities/render.py
+++ b/tests/utilities/render.py
@@ -19,6 +19,7 @@ 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())
+    with console.capture() as capture:
+        console.print(renderable, no_wrap=no_wrap, end="")
+    output = replace_link_ids(capture.get())
     return output