From 8d905b753ddda6f10ecf4146e4c75c2d1a73c458 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 8 Feb 2023 13:39:24 +0000 Subject: [PATCH 01/17] Revert retrofitting of example code in an old blog post Makes sense to update all the docs to reflect the work done in #1738 but I feel it doesn't quite make sense to retrofit this into an old blog post -- especially if the code it is referring to was like that at the time and likely still will be for a wee while after this gets republished. --- .../on-dog-food-the-original-metaverse-and-not-being-bored.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/blog/posts/on-dog-food-the-original-metaverse-and-not-being-bored.md b/docs/blog/posts/on-dog-food-the-original-metaverse-and-not-being-bored.md index fab89158c..076cb96b5 100644 --- a/docs/blog/posts/on-dog-food-the-original-metaverse-and-not-being-bored.md +++ b/docs/blog/posts/on-dog-food-the-original-metaverse-and-not-being-bored.md @@ -288,7 +288,7 @@ So, thanks to this bit of code in my `Activity` widget... parent.move_child( self, before=parent.children.index( self ) - 1 ) - self.post_message_no_wait( self.Moved( self ) ) + self.emit_no_wait( self.Moved( self ) ) self.scroll_visible( top=True ) ``` From aae2a8882aff203c50851515306b36ae133dc466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 8 Feb 2023 13:58:21 +0000 Subject: [PATCH 02/17] Update _data_table.py --- src/textual/widgets/_data_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 79b8e4485..0a641d870 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -1042,7 +1042,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._set_hover_cursor(True) if self.show_cursor and self.cursor_type != "none": # Only post selection events if there is a visible row/col/cell cursor. - self._post_message_selected_message() + self._post_selected_message() meta = self.get_style_at(event.x, event.y).meta if meta: self.cursor_cell = Coordinate(meta["row"], meta["column"]) From c66c8b6ad6223b34b31c3be26afe7028122dd569 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 9 Feb 2023 09:35:01 +0000 Subject: [PATCH 03/17] Reactivity improvements --- src/textual/app.py | 4 +++ src/textual/cli/previews/easing.py | 10 +++---- src/textual/message_pump.py | 5 +++- src/textual/reactive.py | 45 ++---------------------------- src/textual/widget.py | 4 +++ src/textual/widgets/_button.py | 8 +++--- 6 files changed, 24 insertions(+), 52 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index d6a0d766d..c671cb216 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -414,6 +414,10 @@ class App(Generic[ReturnType], DOMNode): """ReturnType | None: The return type of the app.""" return self._return_value + def _post_mount(self): + """Called after the object has been mounted.""" + Reactive._initialize_object(self) + def animate( self, attribute: str, diff --git a/src/textual/cli/previews/easing.py b/src/textual/cli/previews/easing.py index b81290302..53b7c4475 100644 --- a/src/textual/cli/previews/easing.py +++ b/src/textual/cli/previews/easing.py @@ -5,7 +5,7 @@ from textual._easing import EASING from textual.app import App, ComposeResult from textual.cli.previews.borders import TEXT from textual.containers import Container, Horizontal, Vertical -from textual.reactive import Reactive +from textual.reactive import reactive, var from textual.scrollbar import ScrollBarRender from textual.widget import Widget from textual.widgets import Button, Footer, Label, Input @@ -23,8 +23,8 @@ class EasingButtons(Widget): class Bar(Widget): - position = Reactive.init(START_POSITION) - animation_running = Reactive(False) + position = reactive(START_POSITION) + animation_running = reactive(False) DEFAULT_CSS = """ @@ -53,8 +53,8 @@ class Bar(Widget): class EasingApp(App): - position = Reactive.init(START_POSITION) - duration = Reactive.var(1.0) + position = reactive(START_POSITION) + duration = var(1.0) def on_load(self): self.bind( diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 4058139ce..82fecdf5b 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -358,7 +358,10 @@ class MessagePump(metaclass=MessagePumpMeta): finally: # This is critical, mount may be waiting self._mounted_event.set() - Reactive._initialize_object(self) + self._post_mount() + + def _post_mount(self): + """Called after the object has been mounted.""" async def _process_messages_loop(self) -> None: """Process messages until the queue is closed.""" diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 93139501d..559111af4 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -7,6 +7,7 @@ from typing import ( Any, Awaitable, Callable, + ClassVar, Generic, Type, TypeVar, @@ -16,7 +17,7 @@ from typing import ( import rich.repr from . import events -from ._callback import count_parameters, invoke +from ._callback import count_parameters from ._types import MessageTarget if TYPE_CHECKING: @@ -28,15 +29,6 @@ if TYPE_CHECKING: ReactiveType = TypeVar("ReactiveType") -class _NotSet: - pass - - -_NOT_SET = _NotSet() - -T = TypeVar("T") - - @rich.repr.auto class Reactive(Generic[ReactiveType]): """Reactive descriptor. @@ -50,7 +42,7 @@ class Reactive(Generic[ReactiveType]): compute: Run compute methods when attribute is changed. Defaults to True. """ - _reactives: TypeVar[dict[str, object]] = {} + _reactives: ClassVar[dict[str, object]] = {} def __init__( self, @@ -77,37 +69,6 @@ class Reactive(Generic[ReactiveType]): yield "always_update", self._always_update yield "compute", self._run_compute - @classmethod - def init( - cls, - default: ReactiveType | Callable[[], ReactiveType], - *, - layout: bool = False, - repaint: bool = True, - always_update: bool = False, - compute: bool = True, - ) -> Reactive: - """A reactive variable that calls watchers and compute on initialize (post mount). - - Args: - default: A default value or callable that returns a default. - layout: Perform a layout on change. Defaults to False. - repaint: Perform a repaint on change. Defaults to True. - always_update: Call watchers even when the new value equals the old value. Defaults to False. - compute: Run compute methods when attribute is changed. Defaults to True. - - Returns: - A Reactive instance which calls watchers or initialize. - """ - return cls( - default, - layout=layout, - repaint=repaint, - init=True, - always_update=always_update, - compute=compute, - ) - @classmethod def var( cls, diff --git a/src/textual/widget.py b/src/textual/widget.py index 5bd204c9e..df297fcd8 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -360,6 +360,10 @@ class Widget(DOMNode): def offset(self, offset: Offset) -> None: self.styles.offset = ScalarOffset.from_offset(offset) + def _post_mount(self): + """Called after the object has been mounted.""" + Reactive._initialize_object(self) + ExpectType = TypeVar("ExpectType", bound="Widget") @overload diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index ade5a1fd8..eaa0b874b 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -11,7 +11,7 @@ from rich.text import Text, TextType from .. import events from ..css._error_tools import friendly_list from ..message import Message -from ..reactive import Reactive +from ..reactive import reactive from ..widgets import Static @@ -151,13 +151,13 @@ class Button(Static, can_focus=True): ACTIVE_EFFECT_DURATION = 0.3 """When buttons are clicked they get the `-active` class for this duration (in seconds)""" - label: Reactive[RenderableType] = Reactive("") + label: reactive[RenderableType] = reactive("") """The text label that appears within the button.""" - variant = Reactive.init("default") + variant = reactive("default") """The variant name for the button.""" - disabled = Reactive(False) + disabled = reactive(False) """The disabled state of the button; `True` if disabled, `False` if not.""" class Pressed(Message, bubble=True): From 507a2f82994910879b5aab39def46156799994e1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 9 Feb 2023 09:42:05 +0000 Subject: [PATCH 04/17] No need to return a bool here --- src/textual/reactive.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 559111af4..3d3e79de6 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -215,7 +215,7 @@ class Reactive(Generic[ReactiveType]): def invoke_watcher( watch_function: Callable, old_value: object, value: object - ) -> bool: + ) -> None: """Invoke a watch function. Args: @@ -223,8 +223,6 @@ class Reactive(Generic[ReactiveType]): old_value: The old value of the attribute. value: The new value of the attribute. - Returns: - True if the watcher was run, or False if it was posted. """ _rich_traceback_omit = True param_count = count_parameters(watch_function) @@ -241,9 +239,6 @@ class Reactive(Generic[ReactiveType]): sender=obj, callback=partial(await_watcher, watch_result) ) ) - return False - else: - return True watch_function = getattr(obj, f"watch_{name}", None) if callable(watch_function): From b0a9c743ea4ab374541db3047be663f8b7cd45d0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 9 Feb 2023 09:46:07 +0000 Subject: [PATCH 05/17] Change reactable type --- src/textual/app.py | 4 ---- src/textual/dom.py | 4 ++++ src/textual/reactive.py | 6 ++---- src/textual/widget.py | 4 ---- 4 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index c671cb216..d6a0d766d 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -414,10 +414,6 @@ class App(Generic[ReturnType], DOMNode): """ReturnType | None: The return type of the app.""" return self._return_value - def _post_mount(self): - """Called after the object has been mounted.""" - Reactive._initialize_object(self) - def animate( self, attribute: str, diff --git a/src/textual/dom.py b/src/textual/dom.py index b6129daed..c56c45b34 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -210,6 +210,10 @@ class DOMNode(MessagePump): styles = self._component_styles[name] return styles + def _post_mount(self): + """Called after the object has been mounted.""" + Reactive._initialize_object(self) + @property def _node_bases(self) -> Iterator[Type[DOMNode]]: """Iterator[Type[DOMNode]]: The DOMNode bases classes (including self.__class__)""" diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 3d3e79de6..c2863deab 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -11,7 +11,6 @@ from typing import ( Generic, Type, TypeVar, - Union, ) import rich.repr @@ -21,10 +20,9 @@ from ._callback import count_parameters from ._types import MessageTarget if TYPE_CHECKING: - from .app import App - from .widget import Widget + from .dom import DOMNode - Reactable = Union[Widget, App] + Reactable = DOMNode ReactiveType = TypeVar("ReactiveType") diff --git a/src/textual/widget.py b/src/textual/widget.py index df297fcd8..5bd204c9e 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -360,10 +360,6 @@ class Widget(DOMNode): def offset(self, offset: Offset) -> None: self.styles.offset = ScalarOffset.from_offset(offset) - def _post_mount(self): - """Called after the object has been mounted.""" - Reactive._initialize_object(self) - ExpectType = TypeVar("ExpectType", bound="Widget") @overload From 7f997023ce214d9a9b2797f2dae03f57692873e6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 9 Feb 2023 10:18:01 +0000 Subject: [PATCH 06/17] force wait for idle --- src/textual/pilot.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 2051c3098..b68f9409c 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -53,6 +53,7 @@ class Pilot(Generic[ReturnType]): async def wait_for_scheduled_animations(self) -> None: """Wait for any current and scheduled animations to complete.""" await self._app.animator.wait_until_complete() + await wait_for_idle(0) async def exit(self, result: ReturnType) -> None: """Exit the app with the given result. From 330e4db17c21d466d0b5246749d9459d9c79b7e2 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 9 Feb 2023 10:27:48 +0000 Subject: [PATCH 07/17] Remove the import/export of TreeNode from the widgets pyi file We've moved TreeNode out of the general widgets import, requiring the user to import from widgets.tree. When I made that change I missed this. --- src/textual/widgets/__init__.pyi | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index 82d25cd90..8fc4a8458 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -14,5 +14,4 @@ from ._static import Static as Static from ._input import Input as Input from ._text_log import TextLog as TextLog from ._tree import Tree as Tree -from ._tree_node import TreeNode as TreeNode from ._welcome import Welcome as Welcome From 6b91501ade5f6b06b09de1ff657d70ff580fc344 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 9 Feb 2023 11:04:37 +0000 Subject: [PATCH 08/17] exclude removed reactables --- src/textual/message_pump.py | 5 +++++ src/textual/reactive.py | 19 ++++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 82fecdf5b..648b0a4d7 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -123,6 +123,11 @@ class MessagePump(metaclass=MessagePumpMeta): """ return self.app._logger + @property + def is_attached(self) -> bool: + """Check the node is attached to the DOM""" + return self._parent is not None + def _attach(self, parent: MessagePump) -> None: """Set the parent, and therefore attach this node to the tree. diff --git a/src/textual/reactive.py b/src/textual/reactive.py index c2863deab..38d631a65 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -242,9 +242,18 @@ class Reactive(Generic[ReactiveType]): if callable(watch_function): invoke_watcher(watch_function, old_value, value) - watchers: list[Callable] = getattr(obj, "__watchers", {}).get(name, []) - for watcher in watchers: - invoke_watcher(watcher, old_value, value) + # Process "global" watchers + watchers: list[tuple[Reactable, Callable]] + watchers = getattr(obj, "__watchers", {}).get(name, []) + # Remove any watchers for reactables that have since closed + if watchers: + watchers[:] = [ + (reactable, callback) + for reactable, callback in watchers + if reactable.is_attached and not reactable._closing + ] + for _, callback in watchers: + invoke_watcher(callback, old_value, value) @classmethod def _compute(cls, obj: Reactable) -> None: @@ -333,11 +342,11 @@ def watch( if not hasattr(obj, "__watchers"): setattr(obj, "__watchers", {}) - watchers: dict[str, list[Callable]] = getattr(obj, "__watchers") + watchers: dict[str, list[tuple[Reactable, Callable]]] = getattr(obj, "__watchers") watcher_list = watchers.setdefault(attribute_name, []) if callback in watcher_list: return - watcher_list.append(callback) + watcher_list.append((obj, callback)) if init: current_value = getattr(obj, attribute_name, None) Reactive._check_watchers(obj, attribute_name, current_value) From decc1e2f3cd3cec6cfa8fd60878081413eabde3e Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 9 Feb 2023 11:10:30 +0000 Subject: [PATCH 09/17] Rename Checkbox to Switch A new form of Checkbox will be arriving in Textual soon, working in conjunction with a RadioButton. What was called Checkbox is perhaps a wee bit heavyweight in terms of visual design, but is a style of widget that should remain. With this in mind we're renaming the current Checkbox to Switch. In all other respects its workings remains the same, only the name has changed. Things for people to watch out for: - Imports will need to be updated. - Queries will need to be updated; special attention will need to be paid to any queries that are string-based. - CSS will need to be changed if any Checkbox styling is happening, or if any Checkbox component styles are being used. See #1725 as the initial motivation and #1746 as the issue for this particular change. --- CHANGELOG.md | 1 + docs/api/checkbox.md | 1 - docs/api/switch.md | 1 + .../widgets/{checkbox.css => switch.css} | 4 +- .../widgets/{checkbox.py => switch.py} | 20 +- docs/roadmap.md | 3 +- docs/widgets/checkbox.md | 63 ---- docs/widgets/switch.md | 63 ++++ mkdocs.yml | 4 +- src/textual/demo.css | 2 +- src/textual/demo.py | 10 +- src/textual/widgets/__init__.py | 4 +- src/textual/widgets/__init__.pyi | 2 +- .../widgets/{_checkbox.py => _switch.py} | 58 ++-- .../__snapshots__/test_snapshots.ambr | 320 +++++++++--------- tests/snapshot_tests/test_snapshots.py | 6 +- tests/test_focus.py | 12 +- 17 files changed, 288 insertions(+), 286 deletions(-) delete mode 100644 docs/api/checkbox.md create mode 100644 docs/api/switch.md rename docs/examples/widgets/{checkbox.css => switch.css} (86%) rename docs/examples/widgets/{checkbox.py => switch.py} (55%) delete mode 100644 docs/widgets/checkbox.md create mode 100644 docs/widgets/switch.md rename src/textual/widgets/{_checkbox.py => _switch.py} (65%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87edabf69..27c3fa5bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Breaking change: `TreeNode` can no longer be imported from `textual.widgets`; it is now available via `from textual.widgets.tree import TreeNode`. https://github.com/Textualize/textual/pull/1637 - `Tree` now shows a (subdued) cursor for a highlighted node when focus has moved elsewhere https://github.com/Textualize/textual/issues/1471 +- Breaking change: renamed `Checkbox` to `Switch`. ### Fixed diff --git a/docs/api/checkbox.md b/docs/api/checkbox.md deleted file mode 100644 index 6c9c434f2..000000000 --- a/docs/api/checkbox.md +++ /dev/null @@ -1 +0,0 @@ -::: textual.widgets.Checkbox diff --git a/docs/api/switch.md b/docs/api/switch.md new file mode 100644 index 000000000..711e817a0 --- /dev/null +++ b/docs/api/switch.md @@ -0,0 +1 @@ +::: textual.widgets.Switch diff --git a/docs/examples/widgets/checkbox.css b/docs/examples/widgets/switch.css similarity index 86% rename from docs/examples/widgets/checkbox.css rename to docs/examples/widgets/switch.css index 77c9fb368..fb6a0d220 100644 --- a/docs/examples/widgets/checkbox.css +++ b/docs/examples/widgets/switch.css @@ -7,7 +7,7 @@ Screen { width: auto; } -Checkbox { +Switch { height: auto; width: auto; } @@ -22,7 +22,7 @@ Checkbox { background: darkslategrey; } -#custom-design > .checkbox--switch { +#custom-design > .switch--switch { color: dodgerblue; background: darkslateblue; } diff --git a/docs/examples/widgets/checkbox.py b/docs/examples/widgets/switch.py similarity index 55% rename from docs/examples/widgets/checkbox.py rename to docs/examples/widgets/switch.py index 400f2ae25..54a59ad63 100644 --- a/docs/examples/widgets/checkbox.py +++ b/docs/examples/widgets/switch.py @@ -1,35 +1,35 @@ from textual.app import App, ComposeResult from textual.containers import Horizontal -from textual.widgets import Checkbox, Static +from textual.widgets import Switch, Static -class CheckboxApp(App): +class SwitchApp(App): def compose(self) -> ComposeResult: - yield Static("[b]Example checkboxes\n", classes="label") + yield Static("[b]Example switches\n", classes="label") yield Horizontal( Static("off: ", classes="label"), - Checkbox(animate=False), + Switch(animate=False), classes="container", ) yield Horizontal( Static("on: ", classes="label"), - Checkbox(value=True), + Switch(value=True), classes="container", ) - focused_checkbox = Checkbox() - focused_checkbox.focus() + focused_switch = Switch() + focused_switch.focus() yield Horizontal( - Static("focused: ", classes="label"), focused_checkbox, classes="container" + Static("focused: ", classes="label"), focused_switch, classes="container" ) yield Horizontal( Static("custom: ", classes="label"), - Checkbox(id="custom-design"), + Switch(id="custom-design"), classes="container", ) -app = CheckboxApp(css_path="checkbox.css") +app = SwitchApp(css_path="switch.css") if __name__ == "__main__": app.run() diff --git a/docs/roadmap.md b/docs/roadmap.md index 7589d5c33..f486260b8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -40,7 +40,7 @@ Widgets are key to making user-friendly interfaces. The builtin widgets should c - [x] Buttons * [x] Error / warning variants - [ ] Color picker -- [x] Checkbox +- [ ] Checkbox - [ ] Content switcher - [x] DataTable * [x] Cell select @@ -70,6 +70,7 @@ Widgets are key to making user-friendly interfaces. The builtin widgets should c * [ ] Style variants (solid, thin etc) - [ ] Radio boxes - [ ] Spark-lines +- [X] Switch - [ ] Tabs - [ ] TextArea (multi-line input) * [ ] Basic controls diff --git a/docs/widgets/checkbox.md b/docs/widgets/checkbox.md deleted file mode 100644 index a5a247ef9..000000000 --- a/docs/widgets/checkbox.md +++ /dev/null @@ -1,63 +0,0 @@ -# Checkbox - -A simple checkbox widget which stores a boolean value. - -- [x] Focusable -- [ ] Container - -## Example - -The example below shows checkboxes in various states. - -=== "Output" - - ```{.textual path="docs/examples/widgets/checkbox.py"} - ``` - -=== "checkbox.py" - - ```python - --8<-- "docs/examples/widgets/checkbox.py" - ``` - -=== "checkbox.css" - - ```sass - --8<-- "docs/examples/widgets/checkbox.css" - ``` - -## Reactive Attributes - -| Name | Type | Default | Description | -| ------- | ------ | ------- | ---------------------------------- | -| `value` | `bool` | `False` | The default value of the checkbox. | - -## Bindings - -The checkbox widget defines directly the following bindings: - -::: textual.widgets.Checkbox.BINDINGS - options: - show_root_heading: false - show_root_toc_entry: false - -## Component Classes - -The checkbox widget provides the following component classes: - -::: textual.widgets.Checkbox.COMPONENT_CLASSES - options: - show_root_heading: false - show_root_toc_entry: false - -## Messages - -### ::: textual.widgets.Checkbox.Changed - -## Additional Notes - -- To remove the spacing around a checkbox, set `border: none;` and `padding: 0;`. - -## See Also - -- [Checkbox](../api/checkbox.md) code reference diff --git a/docs/widgets/switch.md b/docs/widgets/switch.md new file mode 100644 index 000000000..1cb77be6e --- /dev/null +++ b/docs/widgets/switch.md @@ -0,0 +1,63 @@ +# Switch + +A simple switch widget which stores a boolean value. + +- [x] Focusable +- [ ] Container + +## Example + +The example below shows switches in various states. + +=== "Output" + + ```{.textual path="docs/examples/widgets/switch.py"} + ``` + +=== "switch.py" + + ```python + --8<-- "docs/examples/widgets/switch.py" + ``` + +=== "switch.css" + + ```sass + --8<-- "docs/examples/widgets/switch.css" + ``` + +## Reactive Attributes + +| Name | Type | Default | Description | +|---------|--------|---------|----------------------------------| +| `value` | `bool` | `False` | The default value of the switch. | + +## Bindings + +The switch widget defines directly the following bindings: + +::: textual.widgets.Switch.BINDINGS + options: + show_root_heading: false + show_root_toc_entry: false + +## Component Classes + +The switch widget provides the following component classes: + +::: textual.widgets.Switch.COMPONENT_CLASSES + options: + show_root_heading: false + show_root_toc_entry: false + +## Messages + +### ::: textual.widgets.Switch.Changed + +## Additional Notes + +- To remove the spacing around a `Switch`, set `border: none;` and `padding: 0;`. + +## See Also + +- [Switch](../api/switch.md) code reference diff --git a/mkdocs.yml b/mkdocs.yml index ad2d90d47..863f1288d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -126,7 +126,6 @@ nav: - "styles/width.md" - Widgets: - "widgets/button.md" - - "widgets/checkbox.md" - "widgets/data_table.md" - "widgets/directory_tree.md" - "widgets/footer.md" @@ -138,6 +137,7 @@ nav: - "widgets/list_view.md" - "widgets/placeholder.md" - "widgets/static.md" + - "widgets/switch.md" - "widgets/text_log.md" - "widgets/tree.md" - API: @@ -145,7 +145,6 @@ nav: - "api/app.md" - "api/binding.md" - "api/button.md" - - "api/checkbox.md" - "api/color.md" - "api/containers.md" - "api/coordinate.md" @@ -170,6 +169,7 @@ nav: - "api/scroll_view.md" - "api/static.md" - "api/strip.md" + - "api/switch.md" - "api/text_log.md" - "api/timer.md" - "api/tree.md" diff --git a/src/textual/demo.css b/src/textual/demo.css index c93224e9f..3fb8c7d71 100644 --- a/src/textual/demo.css +++ b/src/textual/demo.css @@ -119,7 +119,7 @@ DarkSwitch .label { color: $text-muted; } -DarkSwitch Checkbox { +DarkSwitch Switch { background: $boost; dock: left; } diff --git a/src/textual/demo.py b/src/textual/demo.py index 907d93307..9f523ab4c 100644 --- a/src/textual/demo.py +++ b/src/textual/demo.py @@ -18,7 +18,7 @@ from textual.containers import Container, Horizontal from textual.reactive import reactive, watch from textual.widgets import ( Button, - Checkbox, + Switch, DataTable, Footer, Header, @@ -138,7 +138,7 @@ Build your own or use the builtin widgets. - **Input** Text / Password input. - **Button** Clickable button with a number of styles. -- **Checkbox** A checkbox to toggle between states. +- **Switch** A switch to toggle between states. - **DataTable** A spreadsheet-like widget for navigating data. Cells may contain text or Rich renderables. - **Tree** An generic tree with expandable nodes. - **DirectoryTree** A tree of file and folders. @@ -199,16 +199,16 @@ class Title(Static): class DarkSwitch(Horizontal): def compose(self) -> ComposeResult: - yield Checkbox(value=self.app.dark) + yield Switch(value=self.app.dark) yield Static("Dark mode toggle", classes="label") def on_mount(self) -> None: watch(self.app, "dark", self.on_dark_change, init=False) def on_dark_change(self, dark: bool) -> None: - self.query_one(Checkbox).value = self.app.dark + self.query_one(Switch).value = self.app.dark - def on_checkbox_changed(self, event: Checkbox.Changed) -> None: + def on_switch_changed(self, event: Switch.Changed) -> None: self.app.dark = event.value diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index 98dd81f18..e26881468 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -9,7 +9,7 @@ from ..case import camel_to_snake # be able to "see" them. if typing.TYPE_CHECKING: from ._button import Button - from ._checkbox import Checkbox + from ._switch import Switch from ._data_table import DataTable from ._directory_tree import DirectoryTree from ._footer import Footer @@ -29,7 +29,7 @@ if typing.TYPE_CHECKING: __all__ = [ "Button", - "Checkbox", + "Switch", "DataTable", "DirectoryTree", "Footer", diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index 82d25cd90..ad3b73fbb 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -1,7 +1,7 @@ # This stub file must re-export every classes exposed in the __init__.py's `__all__` list: from ._button import Button as Button from ._data_table import DataTable as DataTable -from ._checkbox import Checkbox as Checkbox +from ._switch import Switch as Switch from ._directory_tree import DirectoryTree as DirectoryTree from ._footer import Footer as Footer from ._header import Header as Header diff --git a/src/textual/widgets/_checkbox.py b/src/textual/widgets/_switch.py similarity index 65% rename from src/textual/widgets/_checkbox.py rename to src/textual/widgets/_switch.py index 9b5b1b454..cdc9f21a6 100644 --- a/src/textual/widgets/_checkbox.py +++ b/src/textual/widgets/_switch.py @@ -12,12 +12,12 @@ from ..widget import Widget from ..scrollbar import ScrollBarRender -class Checkbox(Widget, can_focus=True): - """A checkbox widget that represents a boolean value. +class Switch(Widget, can_focus=True): + """A switch widget that represents a boolean value. - Can be toggled by clicking on it or through its [bindings][textual.widgets.Checkbox.BINDINGS]. + Can be toggled by clicking on it or through its [bindings][textual.widgets.Switch.BINDINGS]. - The checkbox widget also contains [component classes][textual.widgets.Checkbox.COMPONENT_CLASSES] + The switch widget also contains [component classes][textual.widgets.Switch.COMPONENT_CLASSES] that enable more customization. """ @@ -27,20 +27,20 @@ class Checkbox(Widget, can_focus=True): """ | Key(s) | Description | | :- | :- | - | enter,space | Toggle the checkbox status. | + | enter,space | Toggle the switch state. | """ COMPONENT_CLASSES: ClassVar[set[str]] = { - "checkbox--switch", + "switch--switch", } """ | Class | Description | | :- | :- | - | `checkbox--switch` | Targets the switch of the checkbox. | + | `switch--switch` | Targets the switch of the switch. | """ DEFAULT_CSS = """ - Checkbox { + Switch { border: tall transparent; background: $panel; height: auto; @@ -48,49 +48,49 @@ class Checkbox(Widget, can_focus=True): padding: 0 2; } - Checkbox > .checkbox--switch { + Switch > .switch--switch { background: $panel-darken-2; color: $panel-lighten-2; } - Checkbox:hover { + Switch:hover { border: tall $background; } - Checkbox:focus { + Switch:focus { border: tall $accent; } - Checkbox.-on { + Switch.-on { } - Checkbox.-on > .checkbox--switch { + Switch.-on > .switch--switch { color: $success; } """ value = reactive(False, init=False) - """The value of the checkbox; `True` for on and `False` for off.""" + """The value of the switch; `True` for on and `False` for off.""" slider_pos = reactive(0.0) """The position of the slider.""" class Changed(Message, bubble=True): - """Posted when the status of the checkbox changes. + """Posted when the status of the switch changes. - Can be handled using `on_checkbox_changed` in a subclass of `Checkbox` + Can be handled using `on_switch_changed` in a subclass of `Switch` or in a parent widget in the DOM. Attributes: - value: The value that the checkbox was changed to. - input: The `Checkbox` widget that was changed. + value: The value that the switch was changed to. + input: The `Switch` widget that was changed. """ - def __init__(self, sender: Checkbox, value: bool) -> None: + def __init__(self, sender: Switch, value: bool) -> None: super().__init__(sender) self.value: bool = value - self.input: Checkbox = sender + self.input: Switch = sender def __init__( self, @@ -101,14 +101,14 @@ class Checkbox(Widget, can_focus=True): id: str | None = None, classes: str | None = None, ): - """Initialise the checkbox. + """Initialise the switch. Args: - value: The initial value of the checkbox. Defaults to False. - animate: True if the checkbox should animate when toggled. Defaults to True. - name: The name of the checkbox. - id: The ID of the checkbox in the DOM. - classes: The CSS classes of the checkbox. + value: The initial value of the switch. Defaults to False. + animate: True if the switch should animate when toggled. Defaults to True. + name: The name of the switch. + id: The ID of the switch in the DOM. + classes: The CSS classes of the switch. """ super().__init__(name=name, id=id, classes=classes) if value: @@ -128,7 +128,7 @@ class Checkbox(Widget, can_focus=True): self.set_class(slider_pos == 1, "-on") def render(self) -> RenderableType: - style = self.get_component_rich_style("checkbox--switch") + style = self.get_component_rich_style("switch--switch") return ScrollBarRender( virtual_size=100, window_size=50, @@ -150,6 +150,6 @@ class Checkbox(Widget, can_focus=True): self.toggle() def toggle(self) -> None: - """Toggle the checkbox value. As a result of the value changing, - a Checkbox.Changed message will be posted.""" + """Toggle the switch value. As a result of the value changing, + a Switch.Changed message will be posted.""" self.value = not self.value diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 5a8c647bf..0064c4c32 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -344,166 +344,6 @@ ''' # --- -# name: test_checkboxes - ''' - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - CheckboxApp - - - - - - - - - - - - - - Example checkboxes - - - ▔▔▔▔▔▔▔▔ - off:      - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - on:       - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - focused:  - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - custom:   - ▁▁▁▁▁▁▁▁ - - - - - - - - - - ''' -# --- # name: test_columns_height ''' @@ -14124,6 +13964,166 @@ ''' # --- +# name: test_switches + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SwitchApp + + + + + + + + + + + + + + Example switches + + + ▔▔▔▔▔▔▔▔ + off:      + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + on:       + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + focused:  + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + custom:   + ▁▁▁▁▁▁▁▁ + + + + + + + + + + ''' +# --- # name: test_textlog_max_lines ''' diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 9ce48d171..cdc2d0552 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -52,8 +52,8 @@ def test_dock_layout_sidebar(snap_compare): # from these examples which test rendering and simple interactions with it. -def test_checkboxes(snap_compare): - """Tests checkboxes but also acts a regression test for using +def test_switches(snap_compare): + """Tests switches but also acts a regression test for using width: auto in a Horizontal layout context.""" press = [ "shift+tab", @@ -63,7 +63,7 @@ def test_checkboxes(snap_compare): "enter", # toggle on "wait:20", ] - assert snap_compare(WIDGET_EXAMPLES_DIR / "checkbox.py", press=press) + assert snap_compare(WIDGET_EXAMPLES_DIR / "switch.py", press=press) def test_input_and_focus(snap_compare): diff --git a/tests/test_focus.py b/tests/test_focus.py index e11b14a8d..cee50c6da 100644 --- a/tests/test_focus.py +++ b/tests/test_focus.py @@ -152,21 +152,21 @@ def test_focus_next_and_previous_with_type_selector_without_self(): screen = app.screen from textual.containers import Horizontal, Vertical - from textual.widgets import Button, Checkbox, Input + from textual.widgets import Button, Switch, Input screen._add_children( Vertical( Horizontal( Input(id="w3"), - Checkbox(id="w4"), + Switch(id="w4"), Input(id="w5"), Button(id="w6"), - Checkbox(id="w7"), + Switch(id="w7"), id="w2", ), Horizontal( Button(id="w9"), - Checkbox(id="w10"), + Switch(id="w10"), Button(id="w11"), Input(id="w12"), Input(id="w13"), @@ -180,11 +180,11 @@ def test_focus_next_and_previous_with_type_selector_without_self(): assert screen.focused.id == "w3" assert screen.focus_next(Button).id == "w6" - assert screen.focus_next(Checkbox).id == "w7" + assert screen.focus_next(Switch).id == "w7" assert screen.focus_next(Input).id == "w12" assert screen.focus_previous(Button).id == "w11" - assert screen.focus_previous(Checkbox).id == "w10" + assert screen.focus_previous(Switch).id == "w10" assert screen.focus_previous(Button).id == "w9" assert screen.focus_previous(Input).id == "w5" From 93acc27482158d4c8bd75b3004ca80b39ed28dbc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 9 Feb 2023 11:17:41 +0000 Subject: [PATCH 10/17] test for is_attached --- src/textual/message_pump.py | 13 +++++++++++-- tests/test_widget_removing.py | 6 +++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 648b0a4d7..ba7cc3485 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -125,8 +125,17 @@ class MessagePump(metaclass=MessagePumpMeta): @property def is_attached(self) -> bool: - """Check the node is attached to the DOM""" - return self._parent is not None + """Check the node is attached to the app via the DOM.""" + + from .app import App + + node = self + + while not isinstance(node, App): + if node._parent is None: + return False + node = node._parent + return True def _attach(self, parent: MessagePump) -> None: """Set the parent, and therefore attach this node to the tree. diff --git a/tests/test_widget_removing.py b/tests/test_widget_removing.py index e050bb09d..a33860c83 100644 --- a/tests/test_widget_removing.py +++ b/tests/test_widget_removing.py @@ -8,9 +8,13 @@ from textual.containers import Container async def test_remove_single_widget(): """It should be possible to the only widget on a screen.""" async with App().run_test() as pilot: - await pilot.app.mount(Static()) + widget = Static() + assert not widget.is_attached + await pilot.app.mount(widget) + assert widget.is_attached assert len(pilot.app.screen.children) == 1 await pilot.app.query_one(Static).remove() + assert not widget.is_attached assert len(pilot.app.screen.children) == 0 From 392b56e548567706e37e4ae1fb31be8693817028 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 9 Feb 2023 11:42:58 +0000 Subject: [PATCH 11/17] Added watch method --- CHANGELOG.md | 1 + src/textual/demo.py | 4 ++-- src/textual/dom.py | 16 +++++++++++++++- src/textual/message_pump.py | 1 - src/textual/reactive.py | 6 ++++-- src/textual/widgets/_footer.py | 4 ++-- src/textual/widgets/_header.py | 6 +++--- 7 files changed, 27 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87edabf69..96a1b1f62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added Shift+scroll wheel and ctrl+scroll wheel to scroll horizontally - Added `Tree.action_toggle_node` to toggle a node without selecting, and bound it to Space https://github.com/Textualize/textual/issues/1433 - Added `Tree.reset` to fully reset a `Tree` https://github.com/Textualize/textual/issues/1437 +- Added DOMNode.watch method https://github.com/Textualize/textual/pull/1750 ### Changed diff --git a/src/textual/demo.py b/src/textual/demo.py index 907d93307..58eb6aea0 100644 --- a/src/textual/demo.py +++ b/src/textual/demo.py @@ -15,7 +15,7 @@ from rich.text import Text from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Container, Horizontal -from textual.reactive import reactive, watch +from textual.reactive import reactive from textual.widgets import ( Button, Checkbox, @@ -203,7 +203,7 @@ class DarkSwitch(Horizontal): yield Static("Dark mode toggle", classes="label") def on_mount(self) -> None: - watch(self.app, "dark", self.on_dark_change, init=False) + self.watch(self.app, "dark", self.on_dark_change, init=False) def on_dark_change(self, dark: bool) -> None: self.query_one(Checkbox).value = self.app.dark diff --git a/src/textual/dom.py b/src/textual/dom.py index c56c45b34..97034a56f 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -4,6 +4,7 @@ import re from inspect import getfile from typing import ( TYPE_CHECKING, + Callable, ClassVar, Iterable, Iterator, @@ -31,7 +32,7 @@ from .css.parse import parse_declarations from .css.styles import RenderStyles, Styles from .css.tokenize import IDENTIFIER from .message_pump import MessagePump -from .reactive import Reactive +from .reactive import Reactive, _watch from .timer import Timer from .walk import walk_breadth_first, walk_depth_first @@ -647,6 +648,19 @@ class DOMNode(MessagePump): """ return [child for child in self.children if child.display] + def watch( + self, obj: DOMNode, attribute_name: str, callback: Callable, init: bool = True + ) -> None: + """Watches for modifications to reactive attributes on another object. + + Args: + obj: Object containing attribute to watch. + attribute_name: Attribute to watch. + callback: A callback to run when attribute changes. + init: Check watchers on first call. + """ + _watch(self, obj, attribute_name, callback, init=init) + def get_pseudo_classes(self) -> Iterable[str]: """Get any pseudo classes applicable to this Node, e.g. hover, focus. diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index ba7cc3485..cdcc4fdcf 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -126,7 +126,6 @@ class MessagePump(metaclass=MessagePumpMeta): @property def is_attached(self) -> bool: """Check the node is attached to the app via the DOM.""" - from .app import App node = self diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 38d631a65..cd891ffec 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -325,10 +325,12 @@ class var(Reactive[ReactiveType]): ) -def watch( +def _watch( + node: DOMNode, obj: Reactable, attribute_name: str, callback: Callable[[Any], object], + *, init: bool = True, ) -> None: """Watch a reactive variable on an object. @@ -346,7 +348,7 @@ def watch( watcher_list = watchers.setdefault(attribute_name, []) if callback in watcher_list: return - watcher_list.append((obj, callback)) + watcher_list.append((node, callback)) if init: current_value = getattr(obj, attribute_name, None) Reactive._check_watchers(obj, attribute_name, current_value) diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 0b1c9a808..00ccf9e1f 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -8,7 +8,7 @@ from rich.console import RenderableType from rich.text import Text from .. import events -from ..reactive import Reactive, watch +from ..reactive import Reactive from ..widget import Widget @@ -66,7 +66,7 @@ class Footer(Widget): self.refresh() def on_mount(self) -> None: - watch(self.screen, "focused", self._focus_changed) + self.watch(self.screen, "focused", self._focus_changed) def _focus_changed(self, focused: Widget | None) -> None: self._key_text = None diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py index b2df426cc..d09c4dad8 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -5,7 +5,7 @@ from datetime import datetime from rich.text import Text from ..widget import Widget -from ..reactive import Reactive, watch +from ..reactive import Reactive class HeaderIcon(Widget): @@ -133,5 +133,5 @@ class Header(Widget): def set_sub_title(sub_title: str) -> None: self.query_one(HeaderTitle).sub_text = sub_title - watch(self.app, "title", set_title) - watch(self.app, "sub_title", set_sub_title) + self.watch(self.app, "title", set_title) + self.watch(self.app, "sub_title", set_sub_title) From 85df8d703ea7d5f20b8c617d204b19b7a86dc197 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 9 Feb 2023 11:45:19 +0000 Subject: [PATCH 12/17] typing and changelog --- CHANGELOG.md | 3 ++- src/textual/dom.py | 8 ++++++-- src/textual/reactive.py | 4 ++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96a1b1f62..573189437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added Shift+scroll wheel and ctrl+scroll wheel to scroll horizontally - Added `Tree.action_toggle_node` to toggle a node without selecting, and bound it to Space https://github.com/Textualize/textual/issues/1433 - Added `Tree.reset` to fully reset a `Tree` https://github.com/Textualize/textual/issues/1437 -- Added DOMNode.watch method https://github.com/Textualize/textual/pull/1750 +- Added DOMNode.watch method https://github.com/Textualize/textual/pull/1750 ### Changed @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Removed - Methods `MessagePump.emit` and `MessagePump.emit_no_wait` https://github.com/Textualize/textual/pull/1738 +- Removed `reactive.watch` in favor of DOMNode.watch. ## [0.10.1] - 2023-01-20 diff --git a/src/textual/dom.py b/src/textual/dom.py index 97034a56f..07f994958 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -4,7 +4,6 @@ import re from inspect import getfile from typing import ( TYPE_CHECKING, - Callable, ClassVar, Iterable, Iterator, @@ -23,6 +22,7 @@ from rich.tree import Tree from ._context import NoActiveAppError from ._node_list import NodeList +from ._types import CallbackType from .binding import Bindings, BindingType from .color import BLACK, WHITE, Color from .css._error_tools import friendly_list @@ -649,7 +649,11 @@ class DOMNode(MessagePump): return [child for child in self.children if child.display] def watch( - self, obj: DOMNode, attribute_name: str, callback: Callable, init: bool = True + self, + obj: DOMNode, + attribute_name: str, + callback: CallbackType, + init: bool = True, ) -> None: """Watches for modifications to reactive attributes on another object. diff --git a/src/textual/reactive.py b/src/textual/reactive.py index cd891ffec..58f9a0837 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -17,7 +17,7 @@ import rich.repr from . import events from ._callback import count_parameters -from ._types import MessageTarget +from ._types import MessageTarget, CallbackType if TYPE_CHECKING: from .dom import DOMNode @@ -329,7 +329,7 @@ def _watch( node: DOMNode, obj: Reactable, attribute_name: str, - callback: Callable[[Any], object], + callback: CallbackType, *, init: bool = True, ) -> None: From 3a9c052d20e1a064370300e7bb2227638e46b4f7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 9 Feb 2023 11:49:11 +0000 Subject: [PATCH 13/17] Added snapshot --- .../__snapshots__/test_snapshots.ambr | 158 ++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 4 + 2 files changed, 162 insertions(+) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 5a8c647bf..c196ebfd9 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -14124,6 +14124,164 @@ ''' # --- +# name: test_screen_switch + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ModalApp + + + + + + + + + + ModalApp + B + + + + + + + + + + + + + + + + + + + + + +  A  Push screen A  + + + + + ''' +# --- # name: test_textlog_max_lines ''' diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 9ce48d171..b2eef0054 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -214,3 +214,7 @@ def test_auto_width_input(snap_compare): assert snap_compare( SNAPSHOT_APPS_DIR / "auto_width_input.py", press=["tab", *"Hello"] ) + + +def test_screen_switch(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "screen_switch.py", press=["a", "b"]) From 67e19d84e3c10d3bc94ad29a6996daeab3ef9ed8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 9 Feb 2023 11:50:06 +0000 Subject: [PATCH 14/17] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 573189437..651949865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added Shift+scroll wheel and ctrl+scroll wheel to scroll horizontally - Added `Tree.action_toggle_node` to toggle a node without selecting, and bound it to Space https://github.com/Textualize/textual/issues/1433 - Added `Tree.reset` to fully reset a `Tree` https://github.com/Textualize/textual/issues/1437 -- Added DOMNode.watch method https://github.com/Textualize/textual/pull/1750 +- Added DOMNode.watch and DOMNode.is_attached methods https://github.com/Textualize/textual/pull/1750 ### Changed From f450d98e3ee4f5091b13641ad82a03fa63c19a02 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 9 Feb 2023 11:55:36 +0000 Subject: [PATCH 15/17] snapshot --- .../snapshot_apps/screen_switch.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/snapshot_tests/snapshot_apps/screen_switch.py diff --git a/tests/snapshot_tests/snapshot_apps/screen_switch.py b/tests/snapshot_tests/snapshot_apps/screen_switch.py new file mode 100644 index 000000000..b58866eff --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/screen_switch.py @@ -0,0 +1,38 @@ +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widgets import Static, Header, Footer + + +class ScreenA(Screen): + BINDINGS = [("b", "switch_to_b", "Switch to screen B")] + + def compose(self) -> ComposeResult: + yield Header() + yield Static("A") + yield Footer() + + def action_switch_to_b(self): + self.app.switch_screen(ScreenB()) + + +class ScreenB(Screen): + def compose(self) -> ComposeResult: + yield Header() + yield Static("B") + yield Footer() + + +class ModalApp(App): + BINDINGS = [("a", "push_a", "Push screen A")] + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + + def action_push_a(self) -> None: + self.push_screen(ScreenA()) + + +if __name__ == "__main__": + app = ModalApp() + app.run() From 74fc85054c5c4fc0b11eebe722a9b14c1b8e0b31 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 9 Feb 2023 12:07:48 +0000 Subject: [PATCH 16/17] docstring [skip ci] --- src/textual/message_pump.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index cdcc4fdcf..d17bd8249 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -125,7 +125,7 @@ class MessagePump(metaclass=MessagePumpMeta): @property def is_attached(self) -> bool: - """Check the node is attached to the app via the DOM.""" + """Is the node is attached to the app via the DOM.""" from .app import App node = self From f092e9f46c3c10efd8e97cc1c9cfc65047038837 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 9 Feb 2023 13:50:38 +0000 Subject: [PATCH 17/17] Reorder some imports --- src/textual/demo.py | 2 +- src/textual/widgets/__init__.py | 2 +- src/textual/widgets/__init__.pyi | 4 ++-- tests/test_focus.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/textual/demo.py b/src/textual/demo.py index 9f523ab4c..362937c02 100644 --- a/src/textual/demo.py +++ b/src/textual/demo.py @@ -18,12 +18,12 @@ from textual.containers import Container, Horizontal from textual.reactive import reactive, watch from textual.widgets import ( Button, - Switch, DataTable, Footer, Header, Input, Static, + Switch, TextLog, ) diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index e26881468..355641bee 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -9,7 +9,6 @@ from ..case import camel_to_snake # be able to "see" them. if typing.TYPE_CHECKING: from ._button import Button - from ._switch import Switch from ._data_table import DataTable from ._directory_tree import DirectoryTree from ._footer import Footer @@ -21,6 +20,7 @@ if typing.TYPE_CHECKING: from ._placeholder import Placeholder from ._pretty import Pretty from ._static import Static + from ._switch import Switch from ._text_log import TextLog from ._tree import Tree from ._welcome import Welcome diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index ad3b73fbb..00d64cf50 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -1,17 +1,17 @@ # This stub file must re-export every classes exposed in the __init__.py's `__all__` list: from ._button import Button as Button from ._data_table import DataTable as DataTable -from ._switch import Switch as Switch from ._directory_tree import DirectoryTree as DirectoryTree from ._footer import Footer as Footer from ._header import Header as Header +from ._input import Input as Input from ._label import Label as Label from ._list_view import ListView as ListView from ._list_item import ListItem as ListItem from ._placeholder import Placeholder as Placeholder from ._pretty import Pretty as Pretty from ._static import Static as Static -from ._input import Input as Input +from ._switch import Switch as Switch from ._text_log import TextLog as TextLog from ._tree import Tree as Tree from ._tree_node import TreeNode as TreeNode diff --git a/tests/test_focus.py b/tests/test_focus.py index cee50c6da..b4d9ce8c5 100644 --- a/tests/test_focus.py +++ b/tests/test_focus.py @@ -152,7 +152,7 @@ def test_focus_next_and_previous_with_type_selector_without_self(): screen = app.screen from textual.containers import Horizontal, Vertical - from textual.widgets import Button, Switch, Input + from textual.widgets import Button, Input, Switch screen._add_children( Vertical(