From 210214260d6272ed8af52608bbbd1de4cff91f12 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 31 Aug 2022 13:39:34 +0100 Subject: [PATCH 01/11] Renaming opacity to text-opacity in code --- docs/examples/basic.css | 54 +++++++++++----------- docs/examples/introduction/stopwatch.css | 8 ++-- docs/examples/introduction/stopwatch03.css | 4 +- docs/examples/introduction/stopwatch04.css | 8 ++-- e2e_tests/test_apps/basic.css | 54 +++++++++++----------- sandbox/darren/buttons.css | 2 + sandbox/darren/just_a_box.py | 16 +++---- sandbox/dev_sandbox.scss | 4 +- sandbox/will/basic.css | 52 ++++++++++----------- src/textual/_styles_cache.py | 4 +- src/textual/css/_styles_builder.py | 11 +++-- src/textual/css/styles.py | 6 +-- src/textual/widgets/_header.py | 12 ++--- tests/css/test_parse.py | 6 +-- tests/css/test_styles.py | 8 ++-- 15 files changed, 126 insertions(+), 123 deletions(-) diff --git a/docs/examples/basic.css b/docs/examples/basic.css index e46e9b62a..17e551f60 100644 --- a/docs/examples/basic.css +++ b/docs/examples/basic.css @@ -4,18 +4,18 @@ * { transition: color 300ms linear, background 300ms linear; -} +} *:hover { - /* tint: 30% red; + /* tint: 30% red; /* outline: heavy red; */ } App > Screen { - + background: $surface; - color: $text-surface; + color: $text-surface; layers: base sidebar; color: $text-background; @@ -23,12 +23,12 @@ App > Screen { layout: vertical; overflow: hidden; - + } #tree-container { overflow-y: auto; - height: 20; + height: 20; margin: 1 3; background: $panel; padding: 1 2; @@ -37,7 +37,7 @@ App > Screen { DirectoryTree { padding: 0 1; height: auto; - + } @@ -46,10 +46,10 @@ DirectoryTree { DataTable { /*border:heavy red;*/ /* tint: 10% green; */ - /* opacity: 50%; */ + /* text-opacity: 50%; */ padding: 1; - margin: 1 2; - height: 24; + margin: 1 2; + height: 24; } #sidebar { @@ -59,7 +59,7 @@ DataTable { width: 30; margin-bottom: 1; offset-x: -100%; - + transition: offset 500ms in_out_cubic; layer: sidebar; } @@ -97,8 +97,8 @@ DataTable { Tweet { height:12; width: 100%; - margin: 0 2; - + margin: 0 2; + background: $panel; color: $text-panel; layout: vertical; @@ -121,9 +121,9 @@ Tweet { layout: vertical; } -.code { +.code { height: auto; - + } @@ -133,12 +133,12 @@ TweetHeader { color: $text-accent } -TweetBody { +TweetBody { width: 100%; background: $panel; color: $text-panel; - height: auto; - padding: 0 1 0 0; + height: auto; + padding: 0 1 0 0; } Tweet.scroll-horizontal TweetBody { @@ -158,7 +158,7 @@ Tweet.scroll-horizontal TweetBody { /* padding: 1 0 0 0 ; */ transition: background 400ms in_out_cubic, color 400ms in_out_cubic; - + } .button:hover { @@ -178,7 +178,7 @@ Tweet.scroll-horizontal TweetBody { color: $text-accent; background: $accent; height: 1; - + content-align: center middle; dock:bottom; } @@ -213,7 +213,7 @@ Error { color: $text-error; border-top: tall $error-darken-2; border-bottom: tall $error-darken-2; - + padding: 0; text-style: bold; align-horizontal: center; @@ -226,21 +226,21 @@ Warning { color: $text-warning-fade-1; border-top: tall $warning-darken-2; border-bottom: tall $warning-darken-2; - + text-style: bold; align-horizontal: center; } Success { width: 100%; - - height:auto; + + height:auto; box-sizing: border-box; background: $success; - color: $text-success-fade-1; - + color: $text-success-fade-1; + border-top: hkey $success-darken-2; - border-bottom: hkey $success-darken-2; + border-bottom: hkey $success-darken-2; text-style: bold ; diff --git a/docs/examples/introduction/stopwatch.css b/docs/examples/introduction/stopwatch.css index 93678369c..716d7957b 100644 --- a/docs/examples/introduction/stopwatch.css +++ b/docs/examples/introduction/stopwatch.css @@ -9,12 +9,12 @@ Stopwatch { TimeDisplay { content-align: center middle; - opacity: 60%; + text-opacity: 60%; height: 3; } Button { - width: 16; + width: 16; } #start { @@ -30,14 +30,14 @@ Button { dock: right; } -.started { +.started { text-style: bold; background: $success; color: $text-success; } .started TimeDisplay { - opacity: 100%; + text-opacity: 100%; } .started #start { diff --git a/docs/examples/introduction/stopwatch03.css b/docs/examples/introduction/stopwatch03.css index 3c1d2325d..fe7b15521 100644 --- a/docs/examples/introduction/stopwatch03.css +++ b/docs/examples/introduction/stopwatch03.css @@ -8,12 +8,12 @@ Stopwatch { TimeDisplay { content-align: center middle; - opacity: 60%; + text-opacity: 60%; height: 3; } Button { - width: 16; + width: 16; } #start { diff --git a/docs/examples/introduction/stopwatch04.css b/docs/examples/introduction/stopwatch04.css index 93678369c..716d7957b 100644 --- a/docs/examples/introduction/stopwatch04.css +++ b/docs/examples/introduction/stopwatch04.css @@ -9,12 +9,12 @@ Stopwatch { TimeDisplay { content-align: center middle; - opacity: 60%; + text-opacity: 60%; height: 3; } Button { - width: 16; + width: 16; } #start { @@ -30,14 +30,14 @@ Button { dock: right; } -.started { +.started { text-style: bold; background: $success; color: $text-success; } .started TimeDisplay { - opacity: 100%; + text-opacity: 100%; } .started #start { diff --git a/e2e_tests/test_apps/basic.css b/e2e_tests/test_apps/basic.css index 3c9043b6b..b7eaaed81 100644 --- a/e2e_tests/test_apps/basic.css +++ b/e2e_tests/test_apps/basic.css @@ -4,33 +4,33 @@ * { transition: color 300ms linear, background 300ms linear; -} +} *:hover { - /* tint: 30% red; + /* tint: 30% red; /* outline: heavy red; */ } App > Screen { - + background: $surface; - color: $text-surface; + color: $text-surface; layers: sidebar; color: $text-background; background: $background; layout: vertical; - + } DataTable { /*border:heavy red;*/ /* tint: 10% green; */ - /* opacity: 50%; */ + /* text-opacity: 50%; */ padding: 1; - margin: 1 2; - height: 12; + margin: 1 2; + height: 12; } #sidebar { @@ -39,7 +39,7 @@ DataTable { dock: left; width: 30; offset-x: -100%; - + transition: offset 500ms in_out_cubic; layer: sidebar; } @@ -76,7 +76,7 @@ DataTable { background: $secondary-background; height: 1; content-align: center middle; - + dock: top; } @@ -84,8 +84,8 @@ DataTable { Tweet { height:12; width: 100%; - - + + background: $panel; color: $text-panel; layout: vertical; @@ -100,7 +100,7 @@ Tweet { .scrollable { - + overflow-y: scroll; margin: 1 2; height: 20; @@ -108,9 +108,9 @@ Tweet { layout: vertical; } -.code { +.code { height: auto; - + } @@ -120,12 +120,12 @@ TweetHeader { color: $text-accent } -TweetBody { +TweetBody { width: 100%; background: $panel; color: $text-panel; - height: auto; - padding: 0 1 0 0; + height: auto; + padding: 0 1 0 0; } Tweet.scroll-horizontal TweetBody { @@ -145,7 +145,7 @@ Tweet.scroll-horizontal TweetBody { /* padding: 1 0 0 0 ; */ transition: background 400ms in_out_cubic, color 400ms in_out_cubic; - + } .button:hover { @@ -165,7 +165,7 @@ Tweet.scroll-horizontal TweetBody { color: $text-accent; background: $accent; height: 1; - + content-align: center middle; dock:bottom; } @@ -200,7 +200,7 @@ Error { color: $text-error; border-top: tall $error-darken-2; border-bottom: tall $error-darken-2; - + padding: 0; text-style: bold; align-horizontal: center; @@ -213,21 +213,21 @@ Warning { color: $text-warning-fade-1; border-top: tall $warning-darken-2; border-bottom: tall $warning-darken-2; - + text-style: bold; align-horizontal: center; } Success { width: 100%; - - height:auto; + + height:auto; box-sizing: border-box; background: $success; - color: $text-success-fade-1; - + color: $text-success-fade-1; + border-top: hkey $success-darken-2; - border-bottom: hkey $success-darken-2; + border-bottom: hkey $success-darken-2; text-style: bold ; diff --git a/sandbox/darren/buttons.css b/sandbox/darren/buttons.css index cedf20ded..695e85016 100644 --- a/sandbox/darren/buttons.css +++ b/sandbox/darren/buttons.css @@ -1,4 +1,6 @@ Button { padding-left: 1; padding-right: 1; + margin: 3; + text-opacity: 30%; } diff --git a/sandbox/darren/just_a_box.py b/sandbox/darren/just_a_box.py index 24d2715f4..b40132db9 100644 --- a/sandbox/darren/just_a_box.py +++ b/sandbox/darren/just_a_box.py @@ -26,15 +26,15 @@ class JustABox(App): yield Box(classes="box2") def key_a(self): - self.box.styles.display = "none" + # self.box.styles.display = "none" # self.box.styles.visibility = "hidden" - # self.animator.animate( - # self.box.styles, - # "opacity", - # value=0.0, - # duration=2.0, - # on_complete=self.box.remove, - # ) + self.animator.animate( + self.box.styles, + "text_opacity", + value=0.0, + duration=2.0, + on_complete=self.box.remove, + ) async def on_key(self, event: events.Key) -> None: await self.dispatch_key(event) diff --git a/sandbox/dev_sandbox.scss b/sandbox/dev_sandbox.scss index 132b4affd..d2232312a 100644 --- a/sandbox/dev_sandbox.scss +++ b/sandbox/dev_sandbox.scss @@ -54,7 +54,7 @@ Widget:hover { } #footer { - opacity: 1; + text-opacity: 1; color: $text; background: $background; height: 3; @@ -62,5 +62,5 @@ Widget:hover { } #footer.dim { - opacity: 0.5; + text-opacity: 0.5; } diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index efbf1b61c..c57b43f9c 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -4,18 +4,18 @@ * { transition: color 300ms linear, background 300ms linear; -} +} *:hover { - /* tint: 30% red; + /* tint: 30% red; /* outline: heavy red; */ } App > Screen { - + background: $surface; - color: $text-surface; + color: $text-surface; layers: base sidebar; color: $text-background; @@ -23,12 +23,12 @@ App > Screen { layout: vertical; overflow: hidden; - + } #tree-container { overflow-y: auto; - height: 20; + height: 20; margin: 1 2; background: $panel; padding: 1 2; @@ -37,7 +37,7 @@ App > Screen { DirectoryTree { padding: 0 1; height: auto; - + } @@ -46,10 +46,10 @@ DirectoryTree { DataTable { /*border:heavy red;*/ /* tint: 10% green; */ - /* opacity: 50%; */ + /* text-opacity: 50%; */ padding: 1; - margin: 1 2; - height: 24; + margin: 1 2; + height: 24; } #sidebar { @@ -59,7 +59,7 @@ DataTable { width: 30; margin-bottom: 1; offset-x: -100%; - + transition: offset 500ms in_out_cubic; layer: sidebar; } @@ -98,7 +98,7 @@ Tweet { height:12; width: 100%; margin: 0 2; - + margin:0 2; background: $panel; color: $text-panel; @@ -123,9 +123,9 @@ Tweet { layout: vertical; } -.code { +.code { height: auto; - + } @@ -135,12 +135,12 @@ TweetHeader { color: $text-accent } -TweetBody { +TweetBody { width: 100%; background: $panel; color: $text-panel; - height: auto; - padding: 0 1 0 0; + height: auto; + padding: 0 1 0 0; } Tweet.scroll-horizontal TweetBody { @@ -160,7 +160,7 @@ Tweet.scroll-horizontal TweetBody { /* padding: 1 0 0 0 ; */ transition: background 400ms in_out_cubic, color 400ms in_out_cubic; - + } .button:hover { @@ -180,7 +180,7 @@ Tweet.scroll-horizontal TweetBody { color: $text-accent; background: $accent; height: 1; - + content-align: center middle; dock:bottom; } @@ -215,7 +215,7 @@ Error { color: $text-error; border-top: tall $error-darken-2; border-bottom: tall $error-darken-2; - + padding: 0; text-style: bold; align-horizontal: center; @@ -228,21 +228,21 @@ Warning { color: $text-warning-fade-1; border-top: tall $warning-darken-2; border-bottom: tall $warning-darken-2; - + text-style: bold; align-horizontal: center; } Success { width: 100%; - - height:auto; + + height:auto; box-sizing: border-box; background: $success; - color: $text-success-fade-1; - + color: $text-success-fade-1; + border-top: hkey $success-darken-2; - border-bottom: hkey $success-darken-2; + border-bottom: hkey $success-darken-2; text-style: bold ; diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index fcbe23b1a..6b4896050 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -237,8 +237,8 @@ class StylesCache: Returns: list[Segment]: New list of segments """ - if styles.opacity != 1.0: - segments = Opacity.process_segments(segments, styles.opacity) + if styles.text_opacity != 1.0: + segments = Opacity.process_segments(segments, styles.text_opacity) if styles.tint.a: segments = Tint.process_segments(segments, styles.tint) return segments if isinstance(segments, list) else list(segments) diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 2af067c5a..e380e7bc4 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -332,7 +332,7 @@ class StylesBuilder: "visibility", valid_values=list(VALID_VISIBILITY), context="css" ) - def process_opacity(self, name: str, tokens: list[Token]) -> None: + def process_text_opacity(self, name: str, tokens: list[Token]) -> None: if not tokens: return token = tokens[0] @@ -342,16 +342,17 @@ class StylesBuilder: else: token_name = token.name value = token.value + rule_name = name.replace("-", "_") if token_name == "scalar" and value.endswith("%"): try: - opacity = percentage_string_to_float(value) - self.styles.set_rule(name, opacity) + text_opacity = percentage_string_to_float(value) + self.styles.set_rule(rule_name, text_opacity) except ValueError: error = True elif token_name == "number": try: - opacity = clamp(float(value), 0, 1) - self.styles.set_rule(name, opacity) + text_opacity = clamp(float(value), 0, 1) + self.styles.set_rule(rule_name, text_opacity) except ValueError: error = True else: diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 2fe837e9e..1f302c929 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -87,7 +87,7 @@ class RulesMap(TypedDict, total=False): background: Color text_style: Style - opacity: float + text_opacity: float padding: Spacing margin: Spacing @@ -184,7 +184,7 @@ class StylesBase(ABC): "max_height", "color", "background", - "opacity", + "text_opacity", "tint", "scrollbar_color", "scrollbar_color_hover", @@ -204,7 +204,7 @@ class StylesBase(ABC): background = ColorProperty(Color(0, 0, 0, 0), background=True) text_style = StyleFlagsProperty() - opacity = FractionalProperty() + text_opacity = FractionalProperty() padding = SpacingProperty() margin = SpacingProperty() diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py index 6b0f58aa0..e54c6ce6d 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -29,14 +29,14 @@ class HeaderClock(Widget): """Display a clock on the right of the header.""" CSS = """ - HeaderClock { + HeaderClock { dock: right; width: auto; padding: 0 1; background: $secondary-background-lighten-1; color: $text-secondary-background; - opacity: 85%; - content-align: center middle; + text-opacity: 85%; + content-align: center middle; } """ @@ -51,9 +51,9 @@ class HeaderTitle(Widget): """Display the title / subtitle in the header.""" CSS = """ - HeaderTitle { + HeaderTitle { content-align: center middle; - width: 100%; + width: 100%; } """ @@ -79,7 +79,7 @@ class Header(Widget): height: 1; } Header.tall { - height: 3; + height: 3; } """ diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py index d22c8fe46..7be18b4c7 100644 --- a/tests/css/test_parse.py +++ b/tests/css/test_parse.py @@ -1098,15 +1098,15 @@ class TestParseOpacity: ], ) 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.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 def test_opacity_invalid_value(self): - css = "#some-widget { opacity: 123x }" + css = "#some-widget { text-opacity: 123x }" stylesheet = Stylesheet() with pytest.raises(StylesheetParseError): diff --git a/tests/css/test_styles.py b/tests/css/test_styles.py index 1ca614448..84a5610db 100644 --- a/tests/css/test_styles.py +++ b/tests/css/test_styles.py @@ -120,7 +120,7 @@ def test_render_styles_border(): def test_get_opacity_default(): styles = RenderStyles(DOMNode(), Styles(), Styles()) - assert styles.opacity == 1.0 + assert styles.text_opacity == 1.0 @pytest.mark.parametrize( @@ -136,14 +136,14 @@ def test_get_opacity_default(): ) def test_opacity_set_then_get(set_value, expected): styles = RenderStyles(DOMNode(), Styles(), Styles()) - styles.opacity = set_value - assert styles.opacity == expected + styles.text_opacity = set_value + assert styles.text_opacity == expected def test_opacity_set_invalid_type_error(): styles = RenderStyles(DOMNode(), Styles(), Styles()) with pytest.raises(StyleValueError): - styles.opacity = "invalid value" + styles.text_opacity = "invalid value" @pytest.mark.parametrize( From 56bc83c30e50904df4aa5e1807b8a7271cc2ef83 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 31 Aug 2022 14:14:22 +0100 Subject: [PATCH 02/11] Reference doc for text-opacity --- docs/examples/styles/text_opacity.css | 25 +++++++++++++++++++++++++ docs/examples/styles/text_opacity.py | 14 ++++++++++++++ docs/styles/text_opacity.md | 0 3 files changed, 39 insertions(+) create mode 100644 docs/examples/styles/text_opacity.css create mode 100644 docs/examples/styles/text_opacity.py create mode 100644 docs/styles/text_opacity.md diff --git a/docs/examples/styles/text_opacity.css b/docs/examples/styles/text_opacity.css new file mode 100644 index 000000000..882cc8acb --- /dev/null +++ b/docs/examples/styles/text_opacity.css @@ -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; +} diff --git a/docs/examples/styles/text_opacity.py b/docs/examples/styles/text_opacity.py new file mode 100644 index 000000000..a2e9dcff2 --- /dev/null +++ b/docs/examples/styles/text_opacity.py @@ -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") diff --git a/docs/styles/text_opacity.md b/docs/styles/text_opacity.md new file mode 100644 index 000000000..e69de29bb From 9f3ca0829f4c030ee19e2a6a956bf91d0756c889 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 31 Aug 2022 14:21:12 +0100 Subject: [PATCH 03/11] Extract common processing for opacity/text-opacity in style builder --- src/textual/css/_styles_builder.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index e380e7bc4..2037897f7 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -332,7 +332,7 @@ class StylesBuilder: "visibility", valid_values=list(VALID_VISIBILITY), context="css" ) - def process_text_opacity(self, name: str, tokens: list[Token]) -> None: + def _process_fractional(self, name: str, tokens: list[Token]) -> None: if not tokens: return token = tokens[0] @@ -361,6 +361,9 @@ class StylesBuilder: if error: 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: space: list[int] = [] append = space.append From 367b3287bfe8081f20688e90597b1509204ca839 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 31 Aug 2022 15:01:50 +0100 Subject: [PATCH 04/11] Add opacity support --- src/textual/_opacity.py | 30 +++++++++++++++++++ src/textual/_styles_cache.py | 10 +++++-- src/textual/css/styles.py | 3 ++ .../{opacity.py => text_opacity.py} | 4 +-- src/textual/widget.py | 1 + src/textual/widgets/tabs.py | 4 +-- .../{test_opacity.py => test_text_opacity.py} | 26 ++++++++-------- 7 files changed, 59 insertions(+), 19 deletions(-) create mode 100644 src/textual/_opacity.py rename src/textual/renderables/{opacity.py => text_opacity.py} (97%) rename tests/renderables/{test_opacity.py => test_text_opacity.py} (51%) diff --git a/src/textual/_opacity.py b/src/textual/_opacity.py new file mode 100644 index 000000000..03b28a0dc --- /dev/null +++ b/src/textual/_opacity.py @@ -0,0 +1,30 @@ +from typing import Iterable + +from rich.segment import Segment +from rich.style import Style + +from textual.color import Color +from textual.renderables._blend_colors import blend_colors + + +def _apply_widget_opacity( + segments: Iterable[Segment], + base_background: Color, + opacity: float, +) -> Iterable[Segment]: + _Segment = Segment + for segment in segments: + text, style, _ = segment + if not style: + yield segment + continue + + color = style.color + bgcolor = style.bgcolor + if color and color.triplet and bgcolor and bgcolor.triplet: + blended_foreground = blend_colors(color, base_background, ratio=opacity) + blended_background = blend_colors(bgcolor, base_background, ratio=opacity) + blended_style = Style(color=blended_foreground, bgcolor=blended_background) + yield _Segment(text, style + blended_style) + else: + yield segment diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index 6b4896050..84aa63faa 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -7,11 +7,12 @@ from rich.segment import Segment from rich.style import Style from ._border import get_box, render_row +from ._opacity import _apply_widget_opacity from ._segment_tools import line_crop, line_pad, line_trim from ._types import Lines from .color import Color from .geometry import Region, Size, Spacing -from .renderables.opacity import Opacity +from .renderables.text_opacity import TextOpacity from .renderables.tint import Tint if sys.version_info >= (3, 10): @@ -238,9 +239,14 @@ class StylesCache: list[Segment]: New list of segments """ if styles.text_opacity != 1.0: - segments = Opacity.process_segments(segments, styles.text_opacity) + segments = TextOpacity.process_segments(segments, styles.text_opacity) if styles.tint.a: segments = Tint.process_segments(segments, styles.tint) + if styles.opacity != 1.0: + segments = _apply_widget_opacity( + segments, base_background, styles.opacity + ) + return segments if isinstance(segments, list) else list(segments) line: Iterable[Segment] diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 1f302c929..8dec85133 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -87,6 +87,7 @@ class RulesMap(TypedDict, total=False): background: Color text_style: Style + opacity: float text_opacity: float padding: Spacing @@ -184,6 +185,7 @@ class StylesBase(ABC): "max_height", "color", "background", + "opacity", "text_opacity", "tint", "scrollbar_color", @@ -204,6 +206,7 @@ class StylesBase(ABC): background = ColorProperty(Color(0, 0, 0, 0), background=True) text_style = StyleFlagsProperty() + opacity = FractionalProperty() text_opacity = FractionalProperty() padding = SpacingProperty() diff --git a/src/textual/renderables/opacity.py b/src/textual/renderables/text_opacity.py similarity index 97% rename from src/textual/renderables/opacity.py rename to src/textual/renderables/text_opacity.py index 22c8b5513..d05c944b4 100644 --- a/src/textual/renderables/opacity.py +++ b/src/textual/renderables/text_opacity.py @@ -31,7 +31,7 @@ def _get_blended_style_cached( ) -class Opacity: +class TextOpacity: """Blend foreground in to background.""" def __init__(self, renderable: RenderableType, opacity: float = 1.0) -> None: @@ -96,7 +96,7 @@ if __name__ == "__main__": ) console.print(panel) - opacity_panel = Opacity(panel, opacity=0.5) + opacity_panel = TextOpacity(panel, opacity=0.5) console.print(opacity_panel) def frange(start, end, step): diff --git a/src/textual/widget.py b/src/textual/widget.py index 3f0e2f2bb..4c79dbc39 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1266,6 +1266,7 @@ class Widget(DOMNode): width, height = self.size renderable = self.render() renderable = self.post_render(renderable) + renderable = self.apply_opacity(renderable) options = self._console.options.update_dimensions(width, height).update( highlight=False ) diff --git a/src/textual/widgets/tabs.py b/src/textual/widgets/tabs.py index 12e810a01..48381586a 100644 --- a/src/textual/widgets/tabs.py +++ b/src/textual/widgets/tabs.py @@ -14,7 +14,7 @@ from textual import events from textual._layout_resolve import layout_resolve, Edge from textual.keys import Keys 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.widget import Widget @@ -125,7 +125,7 @@ class TabsRenderable: style=inactive_tab_style + Style.from_meta({"@click": f"range_clicked('{tab.name}')"}), ) - dimmed_tab_content = Opacity( + dimmed_tab_content = TextOpacity( tab_content, opacity=self.inactive_text_opacity ) segments = console.render(dimmed_tab_content) diff --git a/tests/renderables/test_opacity.py b/tests/renderables/test_text_opacity.py similarity index 51% rename from tests/renderables/test_opacity.py rename to tests/renderables/test_text_opacity.py index 05cd3539e..04f8aaca6 100644 --- a/tests/renderables/test_opacity.py +++ b/tests/renderables/test_text_opacity.py @@ -2,7 +2,7 @@ import pytest from rich.text import Text from tests.utilities.render import render -from textual.renderables.opacity import Opacity +from textual.renderables.text_opacity import TextOpacity STOP = "\x1b[0m" @@ -12,39 +12,39 @@ def text(): 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" - assert render(Opacity(text, opacity=.5)) == ( + assert render(TextOpacity(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)) == ( + assert render(TextOpacity(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_text_opacity_value_of_one_noop(text): + assert render(TextOpacity(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) + 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="") - 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="") - 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="") - assert render(Opacity(text_only_bg, opacity=.5)) == render(text_only_bg) + assert render(TextOpacity(text_only_bg, opacity=.5)) == render(text_only_bg) From 09acbfedfd652188a16d5d6c850f9a6a9f747634 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 31 Aug 2022 15:57:37 +0100 Subject: [PATCH 05/11] Implement widget opacity --- sandbox/darren/just_a_box.py | 14 ++++++++------ src/textual/_opacity.py | 26 ++++++++++++++++---------- src/textual/_styles_cache.py | 2 +- src/textual/widget.py | 1 - 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/sandbox/darren/just_a_box.py b/sandbox/darren/just_a_box.py index ad10b1b75..f5849d8b6 100644 --- a/sandbox/darren/just_a_box.py +++ b/sandbox/darren/just_a_box.py @@ -25,18 +25,20 @@ class JustABox(App): def compose(self) -> ComposeResult: self.box = Box(classes="box1") + self.box.styles.opacity = "50%" yield self.box yield Box(classes="box2") yield Widget(id="sidebar") def key_a(self): + self._animate_out() + + def _animate_out(self): + def p(): + print("done") + self.animator.animate( - self.box.styles, - "text_opacity", - value=0.0, - duration=2.0, - delay=2.0, - on_complete=self.box.remove, + self.box.styles, "opacity", value=0.0, duration=2.0, on_complete=p ) async def on_key(self, event: events.Key) -> None: diff --git a/src/textual/_opacity.py b/src/textual/_opacity.py index 03b28a0dc..05fcc629c 100644 --- a/src/textual/_opacity.py +++ b/src/textual/_opacity.py @@ -4,7 +4,6 @@ from rich.segment import Segment from rich.style import Style from textual.color import Color -from textual.renderables._blend_colors import blend_colors def _apply_widget_opacity( @@ -19,12 +18,19 @@ def _apply_widget_opacity( yield segment continue - color = style.color - bgcolor = style.bgcolor - if color and color.triplet and bgcolor and bgcolor.triplet: - blended_foreground = blend_colors(color, base_background, ratio=opacity) - blended_background = blend_colors(bgcolor, base_background, ratio=opacity) - blended_style = Style(color=blended_foreground, bgcolor=blended_background) - yield _Segment(text, style + blended_style) - else: - yield segment + blended_style = style + if style.color: + color = Color.from_rich_color(style.color) + blended_foreground = base_background.blend(color, factor=opacity) + blended_style = style + Style.from_color( + color=blended_foreground.rich_color + ) + + if style.bgcolor: + bgcolor = Color.from_rich_color(style.bgcolor) + blended_background = base_background.blend(bgcolor, factor=opacity) + blended_style = blended_style + Style.from_color( + bgcolor=blended_background.rich_color + ) + + yield _Segment(text, blended_style) diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index 84aa63faa..f2f2cf97b 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -246,7 +246,7 @@ class StylesCache: segments = _apply_widget_opacity( segments, base_background, styles.opacity ) - + segments = list(segments) return segments if isinstance(segments, list) else list(segments) line: Iterable[Segment] diff --git a/src/textual/widget.py b/src/textual/widget.py index 4c79dbc39..3f0e2f2bb 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1266,7 +1266,6 @@ class Widget(DOMNode): width, height = self.size renderable = self.render() renderable = self.post_render(renderable) - renderable = self.apply_opacity(renderable) options = self._console.options.update_dimensions(width, height).update( highlight=False ) From 2b7584c7ffc34ec7a10185213368fca917cc8e3f Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 31 Aug 2022 17:08:21 +0100 Subject: [PATCH 06/11] Add opacity docs --- docs/examples/styles/opacity.css | 30 ++++++++++++++++++ docs/examples/styles/opacity.py | 14 +++++++++ docs/styles/opacity.md | 54 ++++++++++++++++++++++++++++++++ docs/styles/text_opacity.md | 2 +- mkdocs.yml | 2 ++ sandbox/darren/just_a_box.css | 27 ++-------------- sandbox/darren/just_a_box.py | 40 ++--------------------- src/textual/_opacity.py | 2 +- src/textual/_styles_cache.py | 6 ++-- 9 files changed, 109 insertions(+), 68 deletions(-) create mode 100644 docs/examples/styles/opacity.css create mode 100644 docs/examples/styles/opacity.py create mode 100644 docs/styles/opacity.md diff --git a/docs/examples/styles/opacity.css b/docs/examples/styles/opacity.css new file mode 100644 index 000000000..86d3845bd --- /dev/null +++ b/docs/examples/styles/opacity.css @@ -0,0 +1,30 @@ +#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; + background: lightseagreen; + content-align: center middle; + text-style: bold; +} diff --git a/docs/examples/styles/opacity.py b/docs/examples/styles/opacity.py new file mode 100644 index 000000000..d723b1d84 --- /dev/null +++ b/docs/examples/styles/opacity.py @@ -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") diff --git a/docs/styles/opacity.md b/docs/styles/opacity.md new file mode 100644 index 000000000..368dac2c7 --- /dev/null +++ b/docs/styles/opacity.md @@ -0,0 +1,54 @@ +# Opacity + +The `opacity` property can be used to make a widget partially or fully transparent. + + +## Syntax + +``` +opacity: ; +``` + +### 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, increase 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 +/* Set the text to be "half-faded" against the background of the widget */ +Widget { + opacity: 50%; +} +``` + +## Python + +```python +# Set the text to be "half-faded" against the background of the widget +widget.styles.opacity = "50%" +``` diff --git a/docs/styles/text_opacity.md b/docs/styles/text_opacity.md index 6604b0a24..92ed91169 100644 --- a/docs/styles/text_opacity.md +++ b/docs/styles/text_opacity.md @@ -1,6 +1,6 @@ # Text-opacity -The `text-opacity` blends the color of the text in a widget with the color of the background. +The `text-opacity` blends the color of the content of a widget with the color of the background. ## Syntax diff --git a/mkdocs.yml b/mkdocs.yml index 3f9af74bb..1e84692a9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -51,6 +51,7 @@ nav: - "styles/min_height.md" - "styles/min_width.md" - "styles/offset.md" + - "styles/opacity.md" - "styles/outline.md" - "styles/overflow.md" - "styles/padding.md" @@ -59,6 +60,7 @@ nav: - "styles/scrollbar_size.md" - "styles/text_align.md" - "styles/text_style.md" + - "styles/text_opacity.md" - "styles/tint.md" - "styles/visibility.md" - "styles/width.md" diff --git a/sandbox/darren/just_a_box.css b/sandbox/darren/just_a_box.css index 49502bd64..c5317c9ef 100644 --- a/sandbox/darren/just_a_box.css +++ b/sandbox/darren/just_a_box.css @@ -1,29 +1,8 @@ Screen { - background: lightcoral; -} - -#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; + background: darkslategrey; } .box1 { - background: orangered; - height: 12; - width: 30; -} - -.box2 { - background: blueviolet; - height: 6; - width: 12; + background: darkmagenta; + width: auto; } diff --git a/sandbox/darren/just_a_box.py b/sandbox/darren/just_a_box.py index f5849d8b6..eee099d25 100644 --- a/sandbox/darren/just_a_box.py +++ b/sandbox/darren/just_a_box.py @@ -1,48 +1,12 @@ from __future__ import annotations -from rich.console import RenderableType - -from textual import events from textual.app import App, ComposeResult -from textual.widget import Widget - - -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" +from textual.widgets import Static class JustABox(App): - def on_load(self): - self.bind("s", "toggle_class('#sidebar', '-active')", description="Sidebar") - def compose(self) -> ComposeResult: - self.box = Box(classes="box1") - self.box.styles.opacity = "50%" - yield self.box - yield Box(classes="box2") - yield Widget(id="sidebar") - - def key_a(self): - self._animate_out() - - def _animate_out(self): - def p(): - print("done") - - self.animator.animate( - self.box.styles, "opacity", value=0.0, duration=2.0, on_complete=p - ) - - async def on_key(self, event: events.Key) -> None: - await self.dispatch_key(event) + yield Static("Hello, world!", classes="box1") if __name__ == "__main__": diff --git a/src/textual/_opacity.py b/src/textual/_opacity.py index 05fcc629c..4bd313406 100644 --- a/src/textual/_opacity.py +++ b/src/textual/_opacity.py @@ -6,7 +6,7 @@ from rich.style import Style from textual.color import Color -def _apply_widget_opacity( +def _apply_opacity( segments: Iterable[Segment], base_background: Color, opacity: float, diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index f2f2cf97b..e5bf36cd9 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -7,7 +7,7 @@ from rich.segment import Segment from rich.style import Style from ._border import get_box, render_row -from ._opacity import _apply_widget_opacity +from ._opacity import _apply_opacity from ._segment_tools import line_crop, line_pad, line_trim from ._types import Lines from .color import Color @@ -243,9 +243,7 @@ class StylesCache: if styles.tint.a: segments = Tint.process_segments(segments, styles.tint) if styles.opacity != 1.0: - segments = _apply_widget_opacity( - segments, base_background, styles.opacity - ) + segments = _apply_opacity(segments, base_background, styles.opacity) segments = list(segments) return segments if isinstance(segments, list) else list(segments) From 5cfe4dbdcc0d39de5bce02b223470a6fe1263802 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 31 Aug 2022 17:12:07 +0100 Subject: [PATCH 07/11] Fixing some typos --- docs/styles/opacity.md | 2 +- docs/styles/text_opacity.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/styles/opacity.md b/docs/styles/opacity.md index 368dac2c7..c954f1332 100644 --- a/docs/styles/opacity.md +++ b/docs/styles/opacity.md @@ -18,7 +18,7 @@ Percentage values will be clamped between 0% and 100%. ## Example -This example shows, from top to bottom, increase opacity values. +This example shows, from top to bottom, increasing opacity values. === "opacity.py" diff --git a/docs/styles/text_opacity.md b/docs/styles/text_opacity.md index 92ed91169..5a0e1c6c5 100644 --- a/docs/styles/text_opacity.md +++ b/docs/styles/text_opacity.md @@ -17,7 +17,7 @@ Percentage values will be clamped between 0% and 100%. ## Example -This example shows, from top to bottom, increase opacity values. +This example shows, from top to bottom, increasing text-opacity values. === "text_opacity.py" From 1c83604357d77f2c6779f1b27a198c9ac39ed904 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 31 Aug 2022 17:36:59 +0100 Subject: [PATCH 08/11] Fix some docs, add a border to opacity output example --- docs/examples/styles/opacity.css | 1 + docs/styles/opacity.md | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/examples/styles/opacity.css b/docs/examples/styles/opacity.css index 86d3845bd..15d059480 100644 --- a/docs/examples/styles/opacity.css +++ b/docs/examples/styles/opacity.css @@ -24,6 +24,7 @@ Screen { Static { height: 1fr; + border: outer dodgerblue; background: lightseagreen; content-align: center middle; text-style: bold; diff --git a/docs/styles/opacity.md b/docs/styles/opacity.md index c954f1332..1ccff5458 100644 --- a/docs/styles/opacity.md +++ b/docs/styles/opacity.md @@ -40,7 +40,7 @@ This example shows, from top to bottom, increasing opacity values. ## CSS ```sass -/* Set the text to be "half-faded" against the background of the widget */ +/* Fade the widget to 50% against its parent's background */ Widget { opacity: 50%; } @@ -49,6 +49,6 @@ Widget { ## Python ```python -# Set the text to be "half-faded" against the background of the widget +# Fade the widget to 50% against its parent's background widget.styles.opacity = "50%" ``` From b2f9d57eac919d0b40b646756a88060cdb785a98 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 31 Aug 2022 17:40:38 +0100 Subject: [PATCH 09/11] Add a docstring for _apply_opacity --- src/textual/_opacity.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/textual/_opacity.py b/src/textual/_opacity.py index 4bd313406..685cbf881 100644 --- a/src/textual/_opacity.py +++ b/src/textual/_opacity.py @@ -11,6 +11,16 @@ def _apply_opacity( 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 for segment in segments: text, style, _ = segment From 17eae1849b3be9df220b5deb2959bab41d8515d0 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 31 Aug 2022 17:44:59 +0100 Subject: [PATCH 10/11] Small simplification to styling in opacity code --- src/textual/_opacity.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/textual/_opacity.py b/src/textual/_opacity.py index 685cbf881..949e66195 100644 --- a/src/textual/_opacity.py +++ b/src/textual/_opacity.py @@ -32,15 +32,11 @@ def _apply_opacity( if style.color: color = Color.from_rich_color(style.color) blended_foreground = base_background.blend(color, factor=opacity) - blended_style = style + Style.from_color( - color=blended_foreground.rich_color - ) + blended_style += Style.from_color(color=blended_foreground.rich_color) if style.bgcolor: bgcolor = Color.from_rich_color(style.bgcolor) blended_background = base_background.blend(bgcolor, factor=opacity) - blended_style = blended_style + Style.from_color( - bgcolor=blended_background.rich_color - ) + blended_style += Style.from_color(bgcolor=blended_background.rich_color) yield _Segment(text, blended_style) From 4aa1401b6fb03d53f545b06d5089ea03e3a7a83b Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 31 Aug 2022 17:47:01 +0100 Subject: [PATCH 11/11] Hoisting methods out of loop for performance --- src/textual/_opacity.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/textual/_opacity.py b/src/textual/_opacity.py index 949e66195..026da6d84 100644 --- a/src/textual/_opacity.py +++ b/src/textual/_opacity.py @@ -22,6 +22,9 @@ def _apply_opacity( 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: @@ -30,13 +33,13 @@ def _apply_opacity( blended_style = style if style.color: - color = Color.from_rich_color(style.color) - blended_foreground = base_background.blend(color, factor=opacity) - blended_style += Style.from_color(color=blended_foreground.rich_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 = Color.from_rich_color(style.bgcolor) - blended_background = base_background.blend(bgcolor, factor=opacity) - blended_style += Style.from_color(bgcolor=blended_background.rich_color) + 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)