Merge pull request #720 from Textualize/opacity-changes

Changes to opacity
This commit is contained in:
Will McGugan
2022-09-01 16:33:38 +01:00
committed by GitHub
27 changed files with 361 additions and 165 deletions

View File

@@ -46,7 +46,7 @@ DirectoryTree {
DataTable { DataTable {
/*border:heavy red;*/ /*border:heavy red;*/
/* tint: 10% green; */ /* tint: 10% green; */
/* opacity: 50%; */ /* text-opacity: 50%; */
padding: 1; padding: 1;
margin: 1 2; margin: 1 2;
height: 24; height: 24;

View File

@@ -9,7 +9,7 @@ Stopwatch {
TimeDisplay { TimeDisplay {
content-align: center middle; content-align: center middle;
opacity: 60%; text-opacity: 60%;
height: 3; height: 3;
} }
@@ -37,7 +37,7 @@ Button {
} }
.started TimeDisplay { .started TimeDisplay {
opacity: 100%; text-opacity: 100%;
} }
.started #start { .started #start {

View File

@@ -8,7 +8,7 @@ Stopwatch {
TimeDisplay { TimeDisplay {
content-align: center middle; content-align: center middle;
opacity: 60%; text-opacity: 60%;
height: 3; height: 3;
} }

View File

@@ -9,7 +9,7 @@ Stopwatch {
TimeDisplay { TimeDisplay {
content-align: center middle; content-align: center middle;
opacity: 60%; text-opacity: 60%;
height: 3; height: 3;
} }
@@ -37,7 +37,7 @@ Button {
} }
.started TimeDisplay { .started TimeDisplay {
opacity: 100%; text-opacity: 100%;
} }
.started #start { .started #start {

View File

@@ -0,0 +1,31 @@
#zero-opacity {
opacity: 0%;
}
#quarter-opacity {
opacity: 25%;
}
#half-opacity {
opacity: 50%;
}
#three-quarter-opacity {
opacity: 75%;
}
#full-opacity {
opacity: 100%;
}
Screen {
background: antiquewhite;
}
Static {
height: 1fr;
border: outer dodgerblue;
background: lightseagreen;
content-align: center middle;
text-style: bold;
}

View File

@@ -0,0 +1,14 @@
from textual.app import App
from textual.widgets import Static
class OpacityApp(App):
def compose(self):
yield Static("opacity: 0%", id="zero-opacity")
yield Static("opacity: 25%", id="quarter-opacity")
yield Static("opacity: 50%", id="half-opacity")
yield Static("opacity: 75%", id="three-quarter-opacity")
yield Static("opacity: 100%", id="full-opacity")
app = OpacityApp(css_path="opacity.css")

View File

@@ -0,0 +1,25 @@
#zero-opacity {
text-opacity: 0%;
}
#quarter-opacity {
text-opacity: 25%;
}
#half-opacity {
text-opacity: 50%;
}
#three-quarter-opacity {
text-opacity: 75%;
}
#full-opacity {
text-opacity: 100%;
}
Static {
height: 1fr;
text-align: center;
text-style: bold;
}

View File

@@ -0,0 +1,14 @@
from textual.app import App
from textual.widgets import Static
class TextOpacityApp(App):
def compose(self):
yield Static("text-opacity: 0%", id="zero-opacity")
yield Static("text-opacity: 25%", id="quarter-opacity")
yield Static("text-opacity: 50%", id="half-opacity")
yield Static("text-opacity: 75%", id="three-quarter-opacity")
yield Static("text-opacity: 100%", id="full-opacity")
app = TextOpacityApp(css_path="text_opacity.css")

54
docs/styles/opacity.md Normal file
View File

@@ -0,0 +1,54 @@
# Opacity
The `opacity` property can be used to make a widget partially or fully transparent.
## Syntax
```
opacity: <FRACTIONAL>;
```
### Values
As a fractional property, `opacity` can be set to either a float (between 0 and 1),
or a percentage, e.g. `45%`.
Float values will be clamped between 0 and 1.
Percentage values will be clamped between 0% and 100%.
## Example
This example shows, from top to bottom, increasing opacity values.
=== "opacity.py"
```python
--8<-- "docs/examples/styles/opacity.py"
```
=== "opacity.css"
```scss
--8<-- "docs/examples/styles/opacity.css"
```
=== "Output"
```{.textual path="docs/examples/styles/opacity.py"}
```
## CSS
```sass
/* Fade the widget to 50% against its parent's background */
Widget {
opacity: 50%;
}
```
## Python
```python
# Fade the widget to 50% against its parent's background
widget.styles.opacity = "50%"
```

View File

@@ -0,0 +1,53 @@
# Text-opacity
The `text-opacity` blends the color of the content of a widget with the color of the background.
## Syntax
```
text-opacity: <FRACTIONAL>;
```
### Values
As a fractional property, `text-opacity` can be set to either a float (between 0 and 1),
or a percentage, e.g. `45%`.
Float values will be clamped between 0 and 1.
Percentage values will be clamped between 0% and 100%.
## Example
This example shows, from top to bottom, increasing text-opacity values.
=== "text_opacity.py"
```python
--8<-- "docs/examples/styles/text_opacity.py"
```
=== "text_opacity.css"
```css
--8<-- "docs/examples/styles/text_opacity.css"
```
=== "Output"
```{.textual path="docs/examples/styles/text_opacity.py"}
```
## CSS
```sass
/* Set the text to be "half-faded" against the background of the widget */
Widget {
text-opacity: 50%;
}
```
## Python
```python
# Set the text to be "half-faded" against the background of the widget
widget.styles.text_opacity = "50%"
```

View File

@@ -27,7 +27,7 @@ App > Screen {
DataTable { DataTable {
/*border:heavy red;*/ /*border:heavy red;*/
/* tint: 10% green; */ /* tint: 10% green; */
/* opacity: 50%; */ /* text-opacity: 50%; */
padding: 1; padding: 1;
margin: 1 2; margin: 1 2;
height: 12; height: 12;

View File

@@ -51,6 +51,7 @@ nav:
- "styles/min_height.md" - "styles/min_height.md"
- "styles/min_width.md" - "styles/min_width.md"
- "styles/offset.md" - "styles/offset.md"
- "styles/opacity.md"
- "styles/outline.md" - "styles/outline.md"
- "styles/overflow.md" - "styles/overflow.md"
- "styles/padding.md" - "styles/padding.md"
@@ -59,6 +60,7 @@ nav:
- "styles/scrollbar_size.md" - "styles/scrollbar_size.md"
- "styles/text_align.md" - "styles/text_align.md"
- "styles/text_style.md" - "styles/text_style.md"
- "styles/text_opacity.md"
- "styles/tint.md" - "styles/tint.md"
- "styles/visibility.md" - "styles/visibility.md"
- "styles/width.md" - "styles/width.md"

View File

@@ -1,4 +1,6 @@
Button { Button {
padding-left: 1; padding-left: 1;
padding-right: 1; padding-right: 1;
margin: 3;
text-opacity: 30%;
} }

View File

@@ -1,29 +1,8 @@
Screen { Screen {
background: lightcoral; background: darkslategrey;
}
#sidebar {
color: $text-panel;
background: $panel;
dock: left;
width: 30;
offset-x: -100%;
transition: offset 500ms in_out_cubic 2s;
layer: sidebar;
}
#sidebar.-active {
offset-x: 0;
} }
.box1 { .box1 {
background: orangered; background: darkmagenta;
height: 12; width: auto;
width: 30;
}
.box2 {
background: blueviolet;
height: 6;
width: 12;
} }

View File

@@ -1,46 +1,12 @@
from __future__ import annotations from __future__ import annotations
from rich.console import RenderableType
from textual import events
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.widget import Widget from textual.widgets import Static
class Box(Widget, can_focus=True):
CSS = "#box {background: blue;}"
def __init__(
self, id: str | None = None, classes: str | None = None, *children: Widget
):
super().__init__(*children, id=id, classes=classes)
def render(self) -> RenderableType:
return "Box"
class JustABox(App): class JustABox(App):
def on_load(self):
self.bind("s", "toggle_class('#sidebar', '-active')", description="Sidebar")
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
self.box = Box(classes="box1") yield Static("Hello, world!", classes="box1")
yield self.box
yield Box(classes="box2")
yield Widget(id="sidebar")
def key_a(self):
self.animator.animate(
self.box.styles,
"opacity",
value=0.0,
duration=2.0,
delay=2.0,
on_complete=self.box.remove,
)
async def on_key(self, event: events.Key) -> None:
await self.dispatch_key(event)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -54,7 +54,7 @@ Widget:hover {
} }
#footer { #footer {
opacity: 1; text-opacity: 1;
color: $text; color: $text;
background: $background; background: $background;
height: 3; height: 3;
@@ -62,5 +62,5 @@ Widget:hover {
} }
#footer.dim { #footer.dim {
opacity: 0.5; text-opacity: 0.5;
} }

View File

@@ -46,7 +46,7 @@ DirectoryTree {
DataTable { DataTable {
/*border:heavy red;*/ /*border:heavy red;*/
/* tint: 10% green; */ /* tint: 10% green; */
/* opacity: 50%; */ /* text-opacity: 50%; */
padding: 1; padding: 1;
margin: 1 2; margin: 1 2;
height: 24; height: 24;

45
src/textual/_opacity.py Normal file
View File

@@ -0,0 +1,45 @@
from typing import Iterable
from rich.segment import Segment
from rich.style import Style
from textual.color import Color
def _apply_opacity(
segments: Iterable[Segment],
base_background: Color,
opacity: float,
) -> Iterable[Segment]:
"""Takes an iterable of foreground Segments and blends them into the supplied
background color, yielding copies of the Segments with blended foreground and
background colors applied.
Args:
segments (Iterable[Segment]): The segments in the foreground.
base_background (Color): The background color to blend foreground into.
opacity (float): The blending factor. A value of 1.0 means output segments will
have identical foreground and background colors to input segments.
"""
_Segment = Segment
from_rich_color = Color.from_rich_color
from_color = Style.from_color
blend = base_background.blend
for segment in segments:
text, style, _ = segment
if not style:
yield segment
continue
blended_style = style
if style.color:
color = from_rich_color(style.color)
blended_foreground = blend(color, factor=opacity)
blended_style += from_color(color=blended_foreground.rich_color)
if style.bgcolor:
bgcolor = from_rich_color(style.bgcolor)
blended_background = blend(bgcolor, factor=opacity)
blended_style += from_color(bgcolor=blended_background.rich_color)
yield _Segment(text, blended_style)

View File

@@ -7,11 +7,12 @@ from rich.segment import Segment
from rich.style import Style from rich.style import Style
from ._border import get_box, render_row from ._border import get_box, render_row
from ._opacity import _apply_opacity
from ._segment_tools import line_crop, line_pad, line_trim from ._segment_tools import line_crop, line_pad, line_trim
from ._types import Lines from ._types import Lines
from .color import Color from .color import Color
from .geometry import Region, Size, Spacing from .geometry import Region, Size, Spacing
from .renderables.opacity import Opacity from .renderables.text_opacity import TextOpacity
from .renderables.tint import Tint from .renderables.tint import Tint
if sys.version_info >= (3, 10): if sys.version_info >= (3, 10):
@@ -237,10 +238,13 @@ class StylesCache:
Returns: Returns:
list[Segment]: New list of segments list[Segment]: New list of segments
""" """
if styles.opacity != 1.0: if styles.text_opacity != 1.0:
segments = Opacity.process_segments(segments, styles.opacity) segments = TextOpacity.process_segments(segments, styles.text_opacity)
if styles.tint.a: if styles.tint.a:
segments = Tint.process_segments(segments, styles.tint) segments = Tint.process_segments(segments, styles.tint)
if styles.opacity != 1.0:
segments = _apply_opacity(segments, base_background, styles.opacity)
segments = list(segments)
return segments if isinstance(segments, list) else list(segments) return segments if isinstance(segments, list) else list(segments)
line: Iterable[Segment] line: Iterable[Segment]

View File

@@ -332,7 +332,7 @@ class StylesBuilder:
"visibility", valid_values=list(VALID_VISIBILITY), context="css" "visibility", valid_values=list(VALID_VISIBILITY), context="css"
) )
def process_opacity(self, name: str, tokens: list[Token]) -> None: def _process_fractional(self, name: str, tokens: list[Token]) -> None:
if not tokens: if not tokens:
return return
token = tokens[0] token = tokens[0]
@@ -342,16 +342,17 @@ class StylesBuilder:
else: else:
token_name = token.name token_name = token.name
value = token.value value = token.value
rule_name = name.replace("-", "_")
if token_name == "scalar" and value.endswith("%"): if token_name == "scalar" and value.endswith("%"):
try: try:
opacity = percentage_string_to_float(value) text_opacity = percentage_string_to_float(value)
self.styles.set_rule(name, opacity) self.styles.set_rule(rule_name, text_opacity)
except ValueError: except ValueError:
error = True error = True
elif token_name == "number": elif token_name == "number":
try: try:
opacity = clamp(float(value), 0, 1) text_opacity = clamp(float(value), 0, 1)
self.styles.set_rule(name, opacity) self.styles.set_rule(rule_name, text_opacity)
except ValueError: except ValueError:
error = True error = True
else: else:
@@ -360,6 +361,9 @@ class StylesBuilder:
if error: if error:
self.error(name, token, fractional_property_help_text(name, context="css")) self.error(name, token, fractional_property_help_text(name, context="css"))
process_opacity = _process_fractional
process_text_opacity = _process_fractional
def _process_space(self, name: str, tokens: list[Token]) -> None: def _process_space(self, name: str, tokens: list[Token]) -> None:
space: list[int] = [] space: list[int] = []
append = space.append append = space.append

View File

@@ -88,6 +88,7 @@ class RulesMap(TypedDict, total=False):
text_style: Style text_style: Style
opacity: float opacity: float
text_opacity: float
padding: Spacing padding: Spacing
margin: Spacing margin: Spacing
@@ -185,6 +186,7 @@ class StylesBase(ABC):
"color", "color",
"background", "background",
"opacity", "opacity",
"text_opacity",
"tint", "tint",
"scrollbar_color", "scrollbar_color",
"scrollbar_color_hover", "scrollbar_color_hover",
@@ -205,6 +207,7 @@ class StylesBase(ABC):
text_style = StyleFlagsProperty() text_style = StyleFlagsProperty()
opacity = FractionalProperty() opacity = FractionalProperty()
text_opacity = FractionalProperty()
padding = SpacingProperty() padding = SpacingProperty()
margin = SpacingProperty() margin = SpacingProperty()

View File

@@ -31,7 +31,7 @@ def _get_blended_style_cached(
) )
class Opacity: class TextOpacity:
"""Blend foreground in to background.""" """Blend foreground in to background."""
def __init__(self, renderable: RenderableType, opacity: float = 1.0) -> None: def __init__(self, renderable: RenderableType, opacity: float = 1.0) -> None:
@@ -96,7 +96,7 @@ if __name__ == "__main__":
) )
console.print(panel) console.print(panel)
opacity_panel = Opacity(panel, opacity=0.5) opacity_panel = TextOpacity(panel, opacity=0.5)
console.print(opacity_panel) console.print(opacity_panel)
def frange(start, end, step): def frange(start, end, step):

View File

@@ -35,7 +35,7 @@ class HeaderClock(Widget):
padding: 0 1; padding: 0 1;
background: $secondary-background-lighten-1; background: $secondary-background-lighten-1;
color: $text-secondary-background; color: $text-secondary-background;
opacity: 85%; text-opacity: 85%;
content-align: center middle; content-align: center middle;
} }
""" """

View File

@@ -14,7 +14,7 @@ from textual import events
from textual._layout_resolve import layout_resolve, Edge from textual._layout_resolve import layout_resolve, Edge
from textual.keys import Keys from textual.keys import Keys
from textual.reactive import Reactive from textual.reactive import Reactive
from textual.renderables.opacity import Opacity from textual.renderables.text_opacity import TextOpacity
from textual.renderables.underline_bar import UnderlineBar from textual.renderables.underline_bar import UnderlineBar
from textual.widget import Widget from textual.widget import Widget
@@ -125,7 +125,7 @@ class TabsRenderable:
style=inactive_tab_style style=inactive_tab_style
+ Style.from_meta({"@click": f"range_clicked('{tab.name}')"}), + Style.from_meta({"@click": f"range_clicked('{tab.name}')"}),
) )
dimmed_tab_content = Opacity( dimmed_tab_content = TextOpacity(
tab_content, opacity=self.inactive_text_opacity tab_content, opacity=self.inactive_text_opacity
) )
segments = console.render(dimmed_tab_content) segments = console.render(dimmed_tab_content)

View File

@@ -1099,15 +1099,15 @@ class TestParseOpacity:
], ],
) )
def test_opacity_to_styles(self, css_value, styles_value): def test_opacity_to_styles(self, css_value, styles_value):
css = f"#some-widget {{ opacity: {css_value} }}" css = f"#some-widget {{ text-opacity: {css_value} }}"
stylesheet = Stylesheet() stylesheet = Stylesheet()
stylesheet.add_source(css) stylesheet.add_source(css)
assert stylesheet.rules[0].styles.opacity == styles_value assert stylesheet.rules[0].styles.text_opacity == styles_value
assert not stylesheet.rules[0].errors assert not stylesheet.rules[0].errors
def test_opacity_invalid_value(self): def test_opacity_invalid_value(self):
css = "#some-widget { opacity: 123x }" css = "#some-widget { text-opacity: 123x }"
stylesheet = Stylesheet() stylesheet = Stylesheet()
with pytest.raises(StylesheetParseError): with pytest.raises(StylesheetParseError):

View File

@@ -120,7 +120,7 @@ def test_render_styles_border():
def test_get_opacity_default(): def test_get_opacity_default():
styles = RenderStyles(DOMNode(), Styles(), Styles()) styles = RenderStyles(DOMNode(), Styles(), Styles())
assert styles.opacity == 1.0 assert styles.text_opacity == 1.0
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -136,14 +136,14 @@ def test_get_opacity_default():
) )
def test_opacity_set_then_get(set_value, expected): def test_opacity_set_then_get(set_value, expected):
styles = RenderStyles(DOMNode(), Styles(), Styles()) styles = RenderStyles(DOMNode(), Styles(), Styles())
styles.opacity = set_value styles.text_opacity = set_value
assert styles.opacity == expected assert styles.text_opacity == expected
def test_opacity_set_invalid_type_error(): def test_opacity_set_invalid_type_error():
styles = RenderStyles(DOMNode(), Styles(), Styles()) styles = RenderStyles(DOMNode(), Styles(), Styles())
with pytest.raises(StyleValueError): with pytest.raises(StyleValueError):
styles.opacity = "invalid value" styles.text_opacity = "invalid value"
@pytest.mark.parametrize( @pytest.mark.parametrize(

View File

@@ -2,7 +2,7 @@ import pytest
from rich.text import Text from rich.text import Text
from tests.utilities.render import render from tests.utilities.render import render
from textual.renderables.opacity import Opacity from textual.renderables.text_opacity import TextOpacity
STOP = "\x1b[0m" STOP = "\x1b[0m"
@@ -12,39 +12,39 @@ def text():
return Text("Hello, world!", style="#ff0000 on #00ff00", end="") return Text("Hello, world!", style="#ff0000 on #00ff00", end="")
def test_simple_opacity(text): def test_simple_text_opacity(text):
blended_red_on_green = "\x1b[38;2;127;127;0;48;2;0;255;0m" blended_red_on_green = "\x1b[38;2;127;127;0;48;2;0;255;0m"
assert render(Opacity(text, opacity=.5)) == ( assert render(TextOpacity(text, opacity=.5)) == (
f"{blended_red_on_green}Hello, world!{STOP}" f"{blended_red_on_green}Hello, world!{STOP}"
) )
def test_value_zero_sets_foreground_color_to_background_color(text): def test_value_zero_sets_foreground_color_to_background_color(text):
foreground = background = "0;255;0" foreground = background = "0;255;0"
assert render(Opacity(text, opacity=0)) == ( assert render(TextOpacity(text, opacity=0)) == (
f"\x1b[38;2;{foreground};48;2;{background}mHello, world!{STOP}" f"\x1b[38;2;{foreground};48;2;{background}mHello, world!{STOP}"
) )
def test_opacity_value_of_one_noop(text): def test_text_opacity_value_of_one_noop(text):
assert render(Opacity(text, opacity=1)) == render(text) assert render(TextOpacity(text, opacity=1)) == render(text)
def test_ansi_colors_noop(): def test_ansi_colors_noop():
ansi_colored_text = Text("Hello, world!", style="red on green", end="") ansi_colored_text = Text("Hello, world!", style="red on green", end="")
assert render(Opacity(ansi_colored_text, opacity=.5)) == render(ansi_colored_text) assert render(TextOpacity(ansi_colored_text, opacity=.5)) == render(ansi_colored_text)
def test_opacity_no_style_noop(): def test_text_opacity_no_style_noop():
text_no_style = Text("Hello, world!", end="") text_no_style = Text("Hello, world!", end="")
assert render(Opacity(text_no_style, opacity=.2)) == render(text_no_style) assert render(TextOpacity(text_no_style, opacity=.2)) == render(text_no_style)
def test_opacity_only_fg_noop(): def test_text_opacity_only_fg_noop():
text_only_fg = Text("Hello, world!", style="#ff0000", end="") text_only_fg = Text("Hello, world!", style="#ff0000", end="")
assert render(Opacity(text_only_fg, opacity=.5)) == render(text_only_fg) assert render(TextOpacity(text_only_fg, opacity=.5)) == render(text_only_fg)
def test_opacity_only_bg_noop(): def test_text_opacity_only_bg_noop():
text_only_bg = Text("Hello, world!", style="on #ff0000", end="") text_only_bg = Text("Hello, world!", style="on #ff0000", end="")
assert render(Opacity(text_only_bg, opacity=.5)) == render(text_only_bg) assert render(TextOpacity(text_only_bg, opacity=.5)) == render(text_only_bg)