From 92aca16b4c7ee496a915ab6f786ba5fbac29d61c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 11 Jul 2024 19:27:50 +0100 Subject: [PATCH 01/14] mutte --- src/textual/dom.py | 13 +++++++++++++ src/textual/reactive.py | 9 +++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 0bb74876c2..42a8015438 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -237,6 +237,19 @@ def set_reactive( ) setattr(self, f"_reactive_{reactive.name}", value) + def mutate_reactive(self, reactive: Reactive[ReactiveType]) -> None: + """Force an update to a mutable reactive. + + This will invoke any associated watchers. + + Args: + reactive (_type_): _description_ + """ + + internal_name = f"_reactive_{reactive.name}" + value = getattr(self, internal_name) + reactive._set(self, value) + def data_bind( self, *reactives: Reactive[Any], diff --git a/src/textual/reactive.py b/src/textual/reactive.py index fb0224dad0..8bbbc979e6 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -262,7 +262,7 @@ def __get__( else: return getattr(obj, internal_name) - def __set__(self, obj: Reactable, value: ReactiveType) -> None: + def _set(self, obj: Reactable, value: ReactiveType, always: bool = False) -> None: _rich_traceback_omit = True if not hasattr(obj, "_id"): @@ -287,7 +287,7 @@ def __set__(self, obj: Reactable, value: ReactiveType) -> None: if callable(public_validate_function): value = public_validate_function(value) # If the value has changed, or this is the first time setting the value - if current_value != value or self._always_update: + if always or current_value != value or self._always_update: # Store the internal value setattr(obj, self.internal_name, value) @@ -308,6 +308,11 @@ def __set__(self, obj: Reactable, value: ReactiveType) -> None: recompose=self._recompose, ) + def __set__(self, obj: Reactable, value: ReactiveType) -> None: + _rich_traceback_omit = True + + self._set(obj, value) + @classmethod def _check_watchers(cls, obj: Reactable, name: str, old_value: Any) -> None: """Check watchers, and call watch methods / computes From 94426f7abc4203e329b62a6f4abdd75f3c347e16 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 11 Jul 2024 19:30:29 +0100 Subject: [PATCH 02/14] words --- src/textual/dom.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 42a8015438..5d94a7f1b3 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -221,7 +221,7 @@ def set_reactive( ``` Args: - name: Name of reactive attribute. + reactive: A reactive property (use the class scope syntax, i.e. `MyClass.my_reactive`). value: New value of reactive. Raises: @@ -240,10 +240,20 @@ def set_reactive( def mutate_reactive(self, reactive: Reactive[ReactiveType]) -> None: """Force an update to a mutable reactive. - This will invoke any associated watchers. + Example: + ```python + self.reactive_name_list.append("Jessica") + self.mutate_reactive(MyClass.reactive_name_list) + ``` + + Textual will automatically detect when a reactive is set to a new value, but it is unable + to detect if a value is _mutated_ (such as updating a list, dict, or attribute of an). + If you do wish to use a collection or other mutable object in a reactive, then you can call + this method after your reactive is updated. This will ensure that all the reactive _superpowers_ + work. Args: - reactive (_type_): _description_ + reactive: A reactive property (use the class scope syntax, i.e. `MyClass.my_reactive`). """ internal_name = f"_reactive_{reactive.name}" From d02b15bcb2e906946fdfef3282ae15d09f7de701 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 11 Jul 2024 19:32:31 +0100 Subject: [PATCH 03/14] words --- src/textual/dom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 5d94a7f1b3..6802ea0e6f 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -247,7 +247,7 @@ def mutate_reactive(self, reactive: Reactive[ReactiveType]) -> None: ``` Textual will automatically detect when a reactive is set to a new value, but it is unable - to detect if a value is _mutated_ (such as updating a list, dict, or attribute of an). + to detect if a value is _mutated_ (such as updating a list, dict, or attribute of an object). If you do wish to use a collection or other mutable object in a reactive, then you can call this method after your reactive is updated. This will ensure that all the reactive _superpowers_ work. From 71f7277885acb886fd8e92364247ace52f51cf9e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 11 Jul 2024 19:33:12 +0100 Subject: [PATCH 04/14] lways --- src/textual/dom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 6802ea0e6f..bc75cadd86 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -258,7 +258,7 @@ def mutate_reactive(self, reactive: Reactive[ReactiveType]) -> None: internal_name = f"_reactive_{reactive.name}" value = getattr(self, internal_name) - reactive._set(self, value) + reactive._set(self, value, always=True) def data_bind( self, From 08c4aad92dc65ea9a4b6e0ab3bd5e726600520a3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 12 Jul 2024 15:23:29 +0100 Subject: [PATCH 05/14] added test --- CHANGELOG.md | 1 + src/textual/reactive.py | 2 +- tests/test_reactive.py | 41 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed46c3a9f2..37c99a1851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - `TextArea.line_number_start` reactive attribute https://github.com/Textualize/textual/pull/4471 +- Added `DOMNode.mutate_reactive` ### Fixed diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 8bbbc979e6..2507b14ab5 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -287,7 +287,7 @@ def _set(self, obj: Reactable, value: ReactiveType, always: bool = False) -> Non if callable(public_validate_function): value = public_validate_function(value) # If the value has changed, or this is the first time setting the value - if always or current_value != value or self._always_update: + if always or self._always_update or current_value != value: # Store the internal value setattr(obj, self.internal_name, value) diff --git a/tests/test_reactive.py b/tests/test_reactive.py index 8a22d70a13..ad2da5724d 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -735,7 +735,6 @@ def on_test_widget_test_message(self, event: TestWidget.TestMessage) -> None: message_senders.append(event._sender) class TestApp(App[None]): - def compose(self) -> ComposeResult: yield TestContainer() @@ -744,3 +743,43 @@ def compose(self) -> ComposeResult: pilot.app.query_one(TestWidget).make_reaction() await pilot.pause() assert message_senders == [pilot.app.query_one(TestWidget)] + + +async def test_mutate_reactive() -> None: + """Test explicitly mutating reactives""" + + watched_names: list[list[str]] = [] + + class TestWidget(Widget): + names: reactive[list[str]] = reactive(list) + + def watch_names(self, names: list[str]) -> None: + watched_names.append(names.copy()) + + class TestApp(App): + def compose(self) -> ComposeResult: + yield TestWidget() + + app = TestApp() + async with app.run_test(): + widget = app.query_one(TestWidget) + # watch method called on startup + assert watched_names == [[]] + + # Mutate the list + widget.names.append("Paul") + # No changes expected + assert watched_names == [[]] + # Explicitly mutate the reactive + widget.mutate_reactive(TestWidget.names) + # Watcher will be invoked + assert watched_names == [[], ["Paul"]] + # Make further modifications + widget.names.append("Jessica") + widget.names.remove("Paul") + # No change expected + assert watched_names == [[], ["Paul"]] + # Explicit mutation + widget.mutate_reactive(TestWidget.names) + # Watcher should be invoked + assert watched_names == [[], ["Paul"], ["Jessica"]] From 12aa4ed7229903279c7fa576a6741477e8304b06 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 12 Jul 2024 15:51:01 +0100 Subject: [PATCH 06/14] micro oprimizations --- src/textual/message_pump.py | 3 +-- src/textual/widget.py | 26 +++++++++++++++----------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index f5b312b0f6..42bbe7df3f 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -298,8 +298,7 @@ def check_message_enabled(self, message: Message) -> bool: `True` if the message will be sent, or `False` if it is disabled. """ - enabled = type(message) not in self._disabled_messages - return enabled + return type(message) not in self._disabled_messages def disable_messages(self, *messages: type[Message]) -> None: """Disable message types from being processed.""" diff --git a/src/textual/widget.py b/src/textual/widget.py index fcdbc8aa60..5c00c8099a 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -107,6 +107,8 @@ _NULL_STYLE = Style() +_MOUSE_EVENTS_DISALLOW_IF_DISABLED = (events.MouseEvent, events.Enter, events.Leave) +_MOUSE_EVENTS_ALLOW_IF_DISABLED = (events.MouseScrollDown, events.MouseScrollUp) class AwaitMount: @@ -1782,11 +1784,13 @@ def virtual_region_with_margin(self) -> Region: @property def _self_or_ancestors_disabled(self) -> bool: """Is this widget or any of its ancestors disabled?""" - return any( - node.disabled - for node in self.ancestors_with_self - if isinstance(node, Widget) - ) + + node: Widget | None = self + while isinstance(node, Widget) and not node.is_dom_root: + if node.disabled: + return True + node = node._parent # type:ignore[assignment] + return False @property def focusable(self) -> bool: @@ -3714,20 +3718,20 @@ def check_message_enabled(self, message: Message) -> bool: `True` if the message will be sent, or `False` if it is disabled. """ # Do the normal checking and get out if that fails. - if not super().check_message_enabled(message): - return False - message_type = type(message) - if self._is_prevented(message_type): + if not super().check_message_enabled(message) or self._is_prevented( + type(message) + ): return False + # Mouse scroll events should always go through, this allows mouse # wheel scrolling to pass through disabled widgets. - if isinstance(message, (events.MouseScrollDown, events.MouseScrollUp)): + if isinstance(message, _MOUSE_EVENTS_ALLOW_IF_DISABLED): return True # Otherwise, if this is any other mouse event, the widget receiving # the event must not be disabled at this moment. return ( not self._self_or_ancestors_disabled - if isinstance(message, (events.MouseEvent, events.Enter, events.Leave)) + if isinstance(message, _MOUSE_EVENTS_DISALLOW_IF_DISABLED) else True ) From 78b2a6670a704b4151b87a4dbaf1c1708dd28c4a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 12 Jul 2024 16:09:05 +0100 Subject: [PATCH 07/14] docs --- CHANGELOG.md | 2 +- docs/guide/reactivity.md | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37c99a1851..19387fcba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - `TextArea.line_number_start` reactive attribute https://github.com/Textualize/textual/pull/4471 -- Added `DOMNode.mutate_reactive` +- Added `DOMNode.mutate_reactive` https://github.com/Textualize/textual/pull/4731 ### Fixed diff --git a/docs/guide/reactivity.md b/docs/guide/reactivity.md index 0d191ad832..74a9be3e68 100644 --- a/docs/guide/reactivity.md +++ b/docs/guide/reactivity.md @@ -383,6 +383,13 @@ The following app contains a fix for this issue: The line `self.set_reactive(Greeter.greeting, greeting)` sets the `greeting` attribute but doesn't immediately invoke the watcher. +## Mutable reactives + +Textual can detect when you set a reactive to a new value, but it can't detect when you _mutate_ a value. +In practice, this means that Textual can detect changes to basic types (int, float, str, etc.), but not if you update a collection, such as a list or dict. + +You can still use collections and other mutable objects in reactives, but you will need to call [`mutate_reactive`][textual.dom.DOMNode.mutate_reactive] after making changes for the reactive superpowers to work. + ## Data binding Reactive attributes from one widget may be *bound* (connected) to another widget, so that changes to a single reactive will automatically update another widget (potentially more than one). From 4c11d09315f6c7c6494103d6717474a8ff8408e9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 12 Jul 2024 16:23:18 +0100 Subject: [PATCH 08/14] docs --- docs/guide/reactivity.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/guide/reactivity.md b/docs/guide/reactivity.md index 74a9be3e68..ff7a1cebdf 100644 --- a/docs/guide/reactivity.md +++ b/docs/guide/reactivity.md @@ -390,6 +390,24 @@ In practice, this means that Textual can detect changes to basic types (int, flo You can still use collections and other mutable objects in reactives, but you will need to call [`mutate_reactive`][textual.dom.DOMNode.mutate_reactive] after making changes for the reactive superpowers to work. +Here's an example, that uses a reactive list: + +=== "set_reactive03.py" + + ```python hl_lines="16" + --8<-- "docs/examples/guide/reactivity/set_reactive03.py" + ``` + + 1. Creates a reactive list of strings. + 2. Explicitly mutate the reactive list. + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/set_reactive03.py" press="W,i,l,l,enter"} + ``` + +Note the call to `mutate_reactive`. Without it, the display would not update when a new name is appended to the list. + ## Data binding Reactive attributes from one widget may be *bound* (connected) to another widget, so that changes to a single reactive will automatically update another widget (potentially more than one). From ac860e93d42f6e647850e3e7055a0196670c8af3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 12 Jul 2024 16:23:46 +0100 Subject: [PATCH 09/14] example --- .../guide/reactivity/set_reactive03.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 docs/examples/guide/reactivity/set_reactive03.py diff --git a/docs/examples/guide/reactivity/set_reactive03.py b/docs/examples/guide/reactivity/set_reactive03.py new file mode 100644 index 0000000000..80d3a45bd9 --- /dev/null +++ b/docs/examples/guide/reactivity/set_reactive03.py @@ -0,0 +1,21 @@ +from textual.app import App, ComposeResult +from textual.reactive import reactive +from textual.widgets import Input, Label + + +class MultiGreet(App): + names: reactive[list[str]] = reactive(list, recompose=True) # (1)! + + def compose(self) -> ComposeResult: + yield Input(placeholder="Give me a name") + for name in self.names: + yield Label(f"Hello, {name}") + + def on_input_submitted(self, event: Input.Changed) -> None: + self.names.append(event.value) + self.mutate_reactive(MultiGreet.names) # (2)! + + +if __name__ == "__main__": + app = MultiGreet() + app.run() From 33ac0bbc076de088da2f23d3d1451e88292408ff Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 15 Jul 2024 14:35:03 +0100 Subject: [PATCH 10/14] fix text opacity --- src/textual/widget.py | 10 ++++++ .../snapshot_apps/component_text_opacity.py | 33 +++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 5 +++ 3 files changed, 48 insertions(+) create mode 100644 tests/snapshot_tests/snapshot_apps/component_text_opacity.py diff --git a/src/textual/widget.py b/src/textual/widget.py index dfac89349c..06d0205f3d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -41,6 +41,8 @@ from rich.text import Text from typing_extensions import Self +from textual.color import Color + if TYPE_CHECKING: from .app import RenderResult @@ -803,6 +805,14 @@ def get_component_rich_style(self, *names: str, partial: bool = False) -> Style: if names not in self._rich_style_cache: component_styles = self.get_component_styles(*names) style = component_styles.rich_style + text_opacity = component_styles.text_opacity + if text_opacity < 1 and style.bgcolor is not None: + style += Style.from_color( + ( + Color.from_rich_color(style.bgcolor) + + component_styles.color.multiply_alpha(text_opacity) + ).rich_color + ) partial_style = component_styles.partial_rich_style self._rich_style_cache[names] = (style, partial_style) diff --git a/tests/snapshot_tests/snapshot_apps/component_text_opacity.py b/tests/snapshot_tests/snapshot_apps/component_text_opacity.py new file mode 100644 index 0000000000..647bffcab5 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/component_text_opacity.py @@ -0,0 +1,33 @@ +from textual.app import App, ComposeResult +from textual.scroll_view import ScrollView +from textual.strip import Strip +from rich.segment import Segment + + +class LineWidget(ScrollView): + COMPONENT_CLASSES = {"faded"} + + DEFAULT_CSS = """ + LineWidget { + background:blue; + color:white; + &>.faded{ + text-opacity:0.5; + + } + } + """ + + def render_line(self, y: int) -> Strip: + style = self.get_component_rich_style("faded") + return Strip([Segment("W" * self.size.width, style)]) + + +class TestApp(App): + def compose(self) -> ComposeResult: + yield LineWidget() + + +if __name__ == "__main__": + app = TestApp() + app.run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 5cc5bf4d5a..d63959c698 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -1328,3 +1328,8 @@ async def run_before(pilot: Pilot) -> None: assert snap_compare( SNAPSHOT_APPS_DIR / "programmatic_disable_button.py", run_before=run_before ) + + +def test_component_text_opacity(snap_compare): + """Regression test for https://github.com/Textualize/textual/issues/3413""" + assert snap_compare(SNAPSHOT_APPS_DIR / "component_text_opacity.py") From ee58bb7dfcdc276f4afa218045b8b16976911c5b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 15 Jul 2024 14:40:21 +0100 Subject: [PATCH 11/14] import --- src/textual/widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 06d0205f3d..6c1ca931a9 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -41,7 +41,7 @@ from rich.text import Text from typing_extensions import Self -from textual.color import Color +from .color import Color if TYPE_CHECKING: from .app import RenderResult From a38a9a4d95001013c09adfc8876e951df8413680 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 15 Jul 2024 14:40:59 +0100 Subject: [PATCH 12/14] import --- src/textual/widget.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 6c1ca931a9..7a736f6bf0 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -41,8 +41,6 @@ from rich.text import Text from typing_extensions import Self -from .color import Color - if TYPE_CHECKING: from .app import RenderResult @@ -61,6 +59,7 @@ from .await_remove import AwaitRemove from .box_model import BoxModel from .cache import FIFOCache +from .color import Color from .css.match import match from .css.parse import parse_selectors from .css.query import NoMatches, WrongType From 72de6c0f76a8e0ab1a54d2c492490e0dfcc4b02a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 15 Jul 2024 14:48:07 +0100 Subject: [PATCH 13/14] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbb4b8a577..ef4a845921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed scroll_visible with margin https://github.com/Textualize/textual/pull/4719 - Fixed programmatically disabling button stuck in hover state https://github.com/Textualize/textual/pull/4724 - Fixed `Tree` and `DirectoryTree` horizontal scrolling off-by-2 https://github.com/Textualize/textual/pull/4744 +- Fixed text-opacity in component styles https://github.com/Textualize/textual/pull/4747 ### Changed From 10256cc0256ec92ce41d11cfa8e556e19ccb65cb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 15 Jul 2024 15:07:32 +0100 Subject: [PATCH 14/14] snapshot --- .../__snapshots__/test_snapshots.ambr | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index dba02e40e7..1ecfe0e40d 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -4606,6 +4606,161 @@ ''' # --- +# name: test_component_text_opacity + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TestApp + + + + + + + + + + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + + + + + ''' +# --- # name: test_content_switcher_example_initial '''