diff --git a/CHANGELOG.md b/CHANGELOG.md index 87edabf69..c26553d34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,11 +19,13 @@ 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 and DOMNode.is_attached methods https://github.com/Textualize/textual/pull/1750 ### Changed - 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 @@ -42,6 +44,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/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/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 ) ``` 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-nav.yml b/mkdocs-nav.yml index e453e9276..6b5c4508b 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -121,7 +121,6 @@ nav: - "styles/width.md" - Widgets: - "widgets/button.md" - - "widgets/checkbox.md" - "widgets/data_table.md" - "widgets/directory_tree.md" - "widgets/footer.md" @@ -133,6 +132,7 @@ nav: - "widgets/list_view.md" - "widgets/placeholder.md" - "widgets/static.md" + - "widgets/switch.md" - "widgets/text_log.md" - "widgets/tree.md" - API: @@ -140,7 +140,6 @@ nav: - "api/app.md" - "api/binding.md" - "api/button.md" - - "api/checkbox.md" - "api/color.md" - "api/containers.md" - "api/coordinate.md" @@ -165,6 +164,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/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/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..a470cf72b 100644 --- a/src/textual/demo.py +++ b/src/textual/demo.py @@ -15,15 +15,15 @@ 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, DataTable, Footer, Header, Input, Static, + Switch, TextLog, ) @@ -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) + 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 + 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/dom.py b/src/textual/dom.py index b6129daed..07f994958 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -22,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 @@ -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 @@ -210,6 +211,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__)""" @@ -643,6 +648,23 @@ class DOMNode(MessagePump): """ return [child for child in self.children if child.display] + def watch( + self, + obj: DOMNode, + attribute_name: str, + callback: CallbackType, + 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 4058139ce..d17bd8249 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -123,6 +123,19 @@ class MessagePump(metaclass=MessagePumpMeta): """ return self.app._logger + @property + def is_attached(self) -> bool: + """Is 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. @@ -358,7 +371,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/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. diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 93139501d..58f9a0837 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -7,36 +7,26 @@ from typing import ( Any, Awaitable, Callable, + ClassVar, Generic, Type, TypeVar, - Union, ) import rich.repr from . import events -from ._callback import count_parameters, invoke -from ._types import MessageTarget +from ._callback import count_parameters +from ._types import MessageTarget, CallbackType if TYPE_CHECKING: - from .app import App - from .widget import Widget + from .dom import DOMNode - Reactable = Union[Widget, App] + Reactable = DOMNode ReactiveType = TypeVar("ReactiveType") -class _NotSet: - pass - - -_NOT_SET = _NotSet() - -T = TypeVar("T") - - @rich.repr.auto class Reactive(Generic[ReactiveType]): """Reactive descriptor. @@ -50,7 +40,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 +67,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, @@ -254,7 +213,7 @@ class Reactive(Generic[ReactiveType]): def invoke_watcher( watch_function: Callable, old_value: object, value: object - ) -> bool: + ) -> None: """Invoke a watch function. Args: @@ -262,8 +221,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) @@ -280,17 +237,23 @@ 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): 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: @@ -362,10 +325,12 @@ class var(Reactive[ReactiveType]): ) -def watch( +def _watch( + node: DOMNode, obj: Reactable, attribute_name: str, - callback: Callable[[Any], object], + callback: CallbackType, + *, init: bool = True, ) -> None: """Watch a reactive variable on an object. @@ -379,11 +344,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((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/__init__.py b/src/textual/widgets/__init__.py index 98dd81f18..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 ._checkbox import Checkbox 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 @@ -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..7681c32f0 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -1,18 +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 ._checkbox import Checkbox as Checkbox 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 from ._welcome import Welcome as Welcome 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): 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"]) 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) 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..a9f31e2aa 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,324 @@ ''' # --- +# name: test_screen_switch + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ModalApp + + + + + + + + + + ModalApp + B + + + + + + + + + + + + + + + + + + + + + +  A  Push screen A  + + + + + ''' +# --- +# name: test_switches + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SwitchApp + + + + + + + + + + + + + + Example switches + + + ▔▔▔▔▔▔▔▔ + off:      + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + on:       + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + focused:  + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + custom:   + ▁▁▁▁▁▁▁▁ + + + + + + + + + + ''' +# --- # name: test_textlog_max_lines ''' 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() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 9ce48d171..c77025d4d 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): @@ -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"]) diff --git a/tests/test_focus.py b/tests/test_focus.py index e11b14a8d..b4d9ce8c5 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, Input, Switch 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" 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