Repository: Textualize/textual
Pull request: Render opacity
Closes #271 

A Panel, and the same Panel passed through the opacity renderable:
![image](https://user-images.githubusercontent.com/5740731/153044463-4cc591e5-bb02-4d6f-ad6a-9dc965983eeb.png)

An example with `Live` because why not? :)

https://user-images.githubusercontent.com/5740731/153045908-f71efb5f-01ee-4c46-87f2-ec35239a9c24.MOV

Problem statement:
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.

Hints from issue discussion:

Commit message: Use render instead of render_lines in Opacity, add tests

Patch to review:
diff --git a/src/textual/renderables/opacity.py b/src/textual/renderables/opacity.py
new file mode 100644
index 0000000000..f6cd42986d
--- /dev/null
+++ b/src/textual/renderables/opacity.py
@@ -0,0 +1,90 @@
+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.utilities import blend_colors
+
+
+class Opacity:
+    """Wrap a renderable to blend foreground color into the background color.
+
+    Args:
+        renderable (RenderableType): The RenderableType to manipulate.
+        value (float): The opacity as a float. A value of 1.0 means text is fully visible.
+    """
+
+    def __init__(self, renderable: RenderableType, value: float = 1.0) -> None:
+        self.renderable = renderable
+        self.value = value
+
+    def __rich_console__(
+        self, console: Console, options: ConsoleOptions
+    ) -> RenderResult:
+        segments = console.render(self.renderable, options)
+        opacity = self.value
+        for segment in segments:
+            style = segment.style
+            if not style:
+                yield segment
+                continue
+            fg, bg = style.color, style.bgcolor
+            if fg and fg.triplet and bg and bg.triplet:
+                yield Segment(
+                    text=segment.text,
+                    style=_get_blended_style_cached(
+                        fg_color=fg, bg_color=bg, opacity=opacity
+                    ),
+                    control=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, value=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
index a246305809..175952941b 100644
--- 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.utilities import blend_colors
+
 T = TypeVar("T", int, float)
 
 
@@ -83,34 +85,12 @@ 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
-    )
-
-
 if __name__ == "__main__":
     console = Console()
 
diff --git a/src/textual/renderables/utilities.py b/src/textual/renderables/utilities.py
new file mode 100644
index 0000000000..a65fb0f8ce
--- /dev/null
+++ b/src/textual/renderables/utilities.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/tests/renderables/test_opacity.py b/tests/renderables/test_opacity.py
new file mode 100644
index 0000000000..8dbd694087
--- /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, value=.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, value=0)) == (
+        f"\x1b[38;2;{foreground};48;2;{background}mHello, world!{STOP}"
+    )
+
+
+def test_opacity_value_of_one_noop(text):
+    assert render(Opacity(text, value=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, value=.5)) == render(ansi_colored_text)
+
+
+def test_opacity_no_style_noop():
+    text_no_style = Text("Hello, world!", end="")
+    assert render(Opacity(text_no_style, value=.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, value=.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, value=.5)) == render(text_only_bg)
diff --git a/tests/utilities/render.py b/tests/utilities/render.py
index a2435c542a..2a951f1f00 100644
--- 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


Code review comments:
diff_hunk: @@ -0,0 +1,90 @@
+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.utilities import blend_colors
+
+
+class Opacity:
+    """Wrap a renderable to blend foreground color into the background color.
+
+    Args:
+        renderable (RenderableType): The RenderableType to manipulate.
+        value (float): The opacity as a float. A value of 1.0 means text is fully visible.
+    """
+
+    def __init__(self, renderable: RenderableType, value: float = 1.0) -> None:
path: src/textual/renderables/opacity.py
text: @user1:
Could we rename `value` to `opacity`? Seems a bit vague otherwise...

diff_hunk: @@ -0,0 +1,90 @@
+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.utilities import blend_colors
+
+
+class Opacity:
+    """Wrap a renderable to blend foreground color into the background color.
+
+    Args:
+        renderable (RenderableType): The RenderableType to manipulate.
+        value (float): The opacity as a float. A value of 1.0 means text is fully visible.
+    """
+
+    def __init__(self, renderable: RenderableType, value: float = 1.0) -> None:
+        self.renderable = renderable
+        self.value = value
+
+    def __rich_console__(
+        self, console: Console, options: ConsoleOptions
+    ) -> RenderResult:
+        segments = console.render(self.renderable, options)
+        opacity = self.value
+        for segment in segments:
+            style = segment.style
+            if not style:
+                yield segment
+                continue
+            fg, bg = style.color, style.bgcolor
path: src/textual/renderables/opacity.py
text: @user1:
I suspect this is slower that doing the assignment on two lines, since you would need to construct a tuple.

Although, if you ran a `timeit` and this proved not the case, I would happily withdraw my objection!