Merge branch 'main' into checkbox-switch

This commit is contained in:
Dave Pearson
2023-02-09 13:57:15 +00:00
15 changed files with 289 additions and 80 deletions

View File

@@ -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 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 <kbd>Space</kbd> https://github.com/Textualize/textual/issues/1433 - Added `Tree.action_toggle_node` to toggle a node without selecting, and bound it to <kbd>Space</kbd> https://github.com/Textualize/textual/issues/1433
- Added `Tree.reset` to fully reset a `Tree` https://github.com/Textualize/textual/issues/1437 - 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 ### Changed
@@ -43,6 +44,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Removed ### Removed
- Methods `MessagePump.emit` and `MessagePump.emit_no_wait` https://github.com/Textualize/textual/pull/1738 - 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 ## [0.10.1] - 2023-01-20

View File

@@ -5,7 +5,7 @@ from textual._easing import EASING
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.cli.previews.borders import TEXT from textual.cli.previews.borders import TEXT
from textual.containers import Container, Horizontal, Vertical 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.scrollbar import ScrollBarRender
from textual.widget import Widget from textual.widget import Widget
from textual.widgets import Button, Footer, Label, Input from textual.widgets import Button, Footer, Label, Input
@@ -23,8 +23,8 @@ class EasingButtons(Widget):
class Bar(Widget): class Bar(Widget):
position = Reactive.init(START_POSITION) position = reactive(START_POSITION)
animation_running = Reactive(False) animation_running = reactive(False)
DEFAULT_CSS = """ DEFAULT_CSS = """
@@ -53,8 +53,8 @@ class Bar(Widget):
class EasingApp(App): class EasingApp(App):
position = Reactive.init(START_POSITION) position = reactive(START_POSITION)
duration = Reactive.var(1.0) duration = var(1.0)
def on_load(self): def on_load(self):
self.bind( self.bind(

View File

@@ -15,7 +15,7 @@ from rich.text import Text
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.containers import Container, Horizontal from textual.containers import Container, Horizontal
from textual.reactive import reactive, watch from textual.reactive import reactive
from textual.widgets import ( from textual.widgets import (
Button, Button,
DataTable, DataTable,
@@ -203,7 +203,7 @@ class DarkSwitch(Horizontal):
yield Static("Dark mode toggle", classes="label") yield Static("Dark mode toggle", classes="label")
def on_mount(self) -> None: 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: def on_dark_change(self, dark: bool) -> None:
self.query_one(Switch).value = self.app.dark self.query_one(Switch).value = self.app.dark

View File

@@ -22,6 +22,7 @@ from rich.tree import Tree
from ._context import NoActiveAppError from ._context import NoActiveAppError
from ._node_list import NodeList from ._node_list import NodeList
from ._types import CallbackType
from .binding import Bindings, BindingType from .binding import Bindings, BindingType
from .color import BLACK, WHITE, Color from .color import BLACK, WHITE, Color
from .css._error_tools import friendly_list 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.styles import RenderStyles, Styles
from .css.tokenize import IDENTIFIER from .css.tokenize import IDENTIFIER
from .message_pump import MessagePump from .message_pump import MessagePump
from .reactive import Reactive from .reactive import Reactive, _watch
from .timer import Timer from .timer import Timer
from .walk import walk_breadth_first, walk_depth_first from .walk import walk_breadth_first, walk_depth_first
@@ -210,6 +211,10 @@ class DOMNode(MessagePump):
styles = self._component_styles[name] styles = self._component_styles[name]
return styles return styles
def _post_mount(self):
"""Called after the object has been mounted."""
Reactive._initialize_object(self)
@property @property
def _node_bases(self) -> Iterator[Type[DOMNode]]: def _node_bases(self) -> Iterator[Type[DOMNode]]:
"""Iterator[Type[DOMNode]]: The DOMNode bases classes (including self.__class__)""" """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] 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]: def get_pseudo_classes(self) -> Iterable[str]:
"""Get any pseudo classes applicable to this Node, e.g. hover, focus. """Get any pseudo classes applicable to this Node, e.g. hover, focus.

View File

@@ -123,6 +123,19 @@ class MessagePump(metaclass=MessagePumpMeta):
""" """
return self.app._logger 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: def _attach(self, parent: MessagePump) -> None:
"""Set the parent, and therefore attach this node to the tree. """Set the parent, and therefore attach this node to the tree.
@@ -358,7 +371,10 @@ class MessagePump(metaclass=MessagePumpMeta):
finally: finally:
# This is critical, mount may be waiting # This is critical, mount may be waiting
self._mounted_event.set() 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: async def _process_messages_loop(self) -> None:
"""Process messages until the queue is closed.""" """Process messages until the queue is closed."""

View File

@@ -53,6 +53,7 @@ class Pilot(Generic[ReturnType]):
async def wait_for_scheduled_animations(self) -> None: async def wait_for_scheduled_animations(self) -> None:
"""Wait for any current and scheduled animations to complete.""" """Wait for any current and scheduled animations to complete."""
await self._app.animator.wait_until_complete() await self._app.animator.wait_until_complete()
await wait_for_idle(0)
async def exit(self, result: ReturnType) -> None: async def exit(self, result: ReturnType) -> None:
"""Exit the app with the given result. """Exit the app with the given result.

View File

@@ -7,36 +7,26 @@ from typing import (
Any, Any,
Awaitable, Awaitable,
Callable, Callable,
ClassVar,
Generic, Generic,
Type, Type,
TypeVar, TypeVar,
Union,
) )
import rich.repr import rich.repr
from . import events from . import events
from ._callback import count_parameters, invoke from ._callback import count_parameters
from ._types import MessageTarget from ._types import MessageTarget, CallbackType
if TYPE_CHECKING: if TYPE_CHECKING:
from .app import App from .dom import DOMNode
from .widget import Widget
Reactable = Union[Widget, App] Reactable = DOMNode
ReactiveType = TypeVar("ReactiveType") ReactiveType = TypeVar("ReactiveType")
class _NotSet:
pass
_NOT_SET = _NotSet()
T = TypeVar("T")
@rich.repr.auto @rich.repr.auto
class Reactive(Generic[ReactiveType]): class Reactive(Generic[ReactiveType]):
"""Reactive descriptor. """Reactive descriptor.
@@ -50,7 +40,7 @@ class Reactive(Generic[ReactiveType]):
compute: Run compute methods when attribute is changed. Defaults to True. compute: Run compute methods when attribute is changed. Defaults to True.
""" """
_reactives: TypeVar[dict[str, object]] = {} _reactives: ClassVar[dict[str, object]] = {}
def __init__( def __init__(
self, self,
@@ -77,37 +67,6 @@ class Reactive(Generic[ReactiveType]):
yield "always_update", self._always_update yield "always_update", self._always_update
yield "compute", self._run_compute 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 @classmethod
def var( def var(
cls, cls,
@@ -254,7 +213,7 @@ class Reactive(Generic[ReactiveType]):
def invoke_watcher( def invoke_watcher(
watch_function: Callable, old_value: object, value: object watch_function: Callable, old_value: object, value: object
) -> bool: ) -> None:
"""Invoke a watch function. """Invoke a watch function.
Args: Args:
@@ -262,8 +221,6 @@ class Reactive(Generic[ReactiveType]):
old_value: The old value of the attribute. old_value: The old value of the attribute.
value: The new 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 _rich_traceback_omit = True
param_count = count_parameters(watch_function) param_count = count_parameters(watch_function)
@@ -280,17 +237,23 @@ class Reactive(Generic[ReactiveType]):
sender=obj, callback=partial(await_watcher, watch_result) sender=obj, callback=partial(await_watcher, watch_result)
) )
) )
return False
else:
return True
watch_function = getattr(obj, f"watch_{name}", None) watch_function = getattr(obj, f"watch_{name}", None)
if callable(watch_function): if callable(watch_function):
invoke_watcher(watch_function, old_value, value) invoke_watcher(watch_function, old_value, value)
watchers: list[Callable] = getattr(obj, "__watchers", {}).get(name, []) # Process "global" watchers
for watcher in watchers: watchers: list[tuple[Reactable, Callable]]
invoke_watcher(watcher, old_value, value) 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 @classmethod
def _compute(cls, obj: Reactable) -> None: def _compute(cls, obj: Reactable) -> None:
@@ -362,10 +325,12 @@ class var(Reactive[ReactiveType]):
) )
def watch( def _watch(
node: DOMNode,
obj: Reactable, obj: Reactable,
attribute_name: str, attribute_name: str,
callback: Callable[[Any], object], callback: CallbackType,
*,
init: bool = True, init: bool = True,
) -> None: ) -> None:
"""Watch a reactive variable on an object. """Watch a reactive variable on an object.
@@ -379,11 +344,11 @@ def watch(
if not hasattr(obj, "__watchers"): if not hasattr(obj, "__watchers"):
setattr(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, []) watcher_list = watchers.setdefault(attribute_name, [])
if callback in watcher_list: if callback in watcher_list:
return return
watcher_list.append(callback) watcher_list.append((node, callback))
if init: if init:
current_value = getattr(obj, attribute_name, None) current_value = getattr(obj, attribute_name, None)
Reactive._check_watchers(obj, attribute_name, current_value) Reactive._check_watchers(obj, attribute_name, current_value)

View File

@@ -14,5 +14,4 @@ from ._static import Static as Static
from ._switch import Switch as Switch from ._switch import Switch as Switch
from ._text_log import TextLog as TextLog from ._text_log import TextLog as TextLog
from ._tree import Tree as Tree from ._tree import Tree as Tree
from ._tree_node import TreeNode as TreeNode
from ._welcome import Welcome as Welcome from ._welcome import Welcome as Welcome

View File

@@ -11,7 +11,7 @@ from rich.text import Text, TextType
from .. import events from .. import events
from ..css._error_tools import friendly_list from ..css._error_tools import friendly_list
from ..message import Message from ..message import Message
from ..reactive import Reactive from ..reactive import reactive
from ..widgets import Static from ..widgets import Static
@@ -151,13 +151,13 @@ class Button(Static, can_focus=True):
ACTIVE_EFFECT_DURATION = 0.3 ACTIVE_EFFECT_DURATION = 0.3
"""When buttons are clicked they get the `-active` class for this duration (in seconds)""" """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.""" """The text label that appears within the button."""
variant = Reactive.init("default") variant = reactive("default")
"""The variant name for the button.""" """The variant name for the button."""
disabled = Reactive(False) disabled = reactive(False)
"""The disabled state of the button; `True` if disabled, `False` if not.""" """The disabled state of the button; `True` if disabled, `False` if not."""
class Pressed(Message, bubble=True): class Pressed(Message, bubble=True):

View File

@@ -8,7 +8,7 @@ from rich.console import RenderableType
from rich.text import Text from rich.text import Text
from .. import events from .. import events
from ..reactive import Reactive, watch from ..reactive import Reactive
from ..widget import Widget from ..widget import Widget
@@ -66,7 +66,7 @@ class Footer(Widget):
self.refresh() self.refresh()
def on_mount(self) -> None: 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: def _focus_changed(self, focused: Widget | None) -> None:
self._key_text = None self._key_text = None

View File

@@ -5,7 +5,7 @@ from datetime import datetime
from rich.text import Text from rich.text import Text
from ..widget import Widget from ..widget import Widget
from ..reactive import Reactive, watch from ..reactive import Reactive
class HeaderIcon(Widget): class HeaderIcon(Widget):
@@ -133,5 +133,5 @@ class Header(Widget):
def set_sub_title(sub_title: str) -> None: def set_sub_title(sub_title: str) -> None:
self.query_one(HeaderTitle).sub_text = sub_title self.query_one(HeaderTitle).sub_text = sub_title
watch(self.app, "title", set_title) self.watch(self.app, "title", set_title)
watch(self.app, "sub_title", set_sub_title) self.watch(self.app, "sub_title", set_sub_title)

View File

@@ -13964,6 +13964,164 @@
''' '''
# --- # ---
# name: test_screen_switch
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-1316892474-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-1316892474-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-1316892474-r1 { fill: #c5c8c6 }
.terminal-1316892474-r2 { fill: #e3e3e3 }
.terminal-1316892474-r3 { fill: #e1e1e1 }
.terminal-1316892474-r4 { fill: #dde8f3;font-weight: bold }
.terminal-1316892474-r5 { fill: #ddedf9 }
</style>
<defs>
<clipPath id="terminal-1316892474-clip-terminal">
<rect x="0" y="0" width="975.0" height="584.5999999999999" />
</clipPath>
<clipPath id="terminal-1316892474-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-9">
<rect x="0" y="221.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-10">
<rect x="0" y="245.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-11">
<rect x="0" y="269.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-12">
<rect x="0" y="294.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-13">
<rect x="0" y="318.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-14">
<rect x="0" y="343.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-15">
<rect x="0" y="367.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-16">
<rect x="0" y="391.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-17">
<rect x="0" y="416.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-18">
<rect x="0" y="440.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-19">
<rect x="0" y="465.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-20">
<rect x="0" y="489.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-21">
<rect x="0" y="513.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1316892474-line-22">
<rect x="0" y="538.3" width="976" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-1316892474-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">ModalApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-1316892474-clip-terminal)">
<rect fill="#282828" x="0" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="12.2" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="24.4" y="1.5" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="85.4" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="97.6" y="1.5" width="329.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="427" y="1.5" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="524.6" y="1.5" width="329.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="854" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="866.2" y="1.5" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="866.2" y="1.5" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="963.8" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="25.9" width="963.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="99.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="489.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="513.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="538.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#0053aa" x="0" y="562.7" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="36.6" y="562.7" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="219.6" y="562.7" width="756.4" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-1316892474-matrix">
<text class="terminal-1316892474-r2" x="12.2" y="20" textLength="12.2" clip-path="url(#terminal-1316892474-line-0)">⭘</text><text class="terminal-1316892474-r2" x="427" y="20" textLength="97.6" clip-path="url(#terminal-1316892474-line-0)">ModalApp</text><text class="terminal-1316892474-r1" x="976" y="20" textLength="12.2" clip-path="url(#terminal-1316892474-line-0)">
</text><text class="terminal-1316892474-r3" x="0" y="44.4" textLength="12.2" clip-path="url(#terminal-1316892474-line-1)">B</text><text class="terminal-1316892474-r1" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-1316892474-line-1)">
</text><text class="terminal-1316892474-r1" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-1316892474-line-2)">
</text><text class="terminal-1316892474-r1" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-1316892474-line-3)">
</text><text class="terminal-1316892474-r1" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-1316892474-line-4)">
</text><text class="terminal-1316892474-r1" x="976" y="142" textLength="12.2" clip-path="url(#terminal-1316892474-line-5)">
</text><text class="terminal-1316892474-r1" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-1316892474-line-6)">
</text><text class="terminal-1316892474-r1" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-1316892474-line-7)">
</text><text class="terminal-1316892474-r1" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-1316892474-line-8)">
</text><text class="terminal-1316892474-r1" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-1316892474-line-9)">
</text><text class="terminal-1316892474-r1" x="976" y="264" textLength="12.2" clip-path="url(#terminal-1316892474-line-10)">
</text><text class="terminal-1316892474-r1" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-1316892474-line-11)">
</text><text class="terminal-1316892474-r1" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-1316892474-line-12)">
</text><text class="terminal-1316892474-r1" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-1316892474-line-13)">
</text><text class="terminal-1316892474-r1" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-1316892474-line-14)">
</text><text class="terminal-1316892474-r1" x="976" y="386" textLength="12.2" clip-path="url(#terminal-1316892474-line-15)">
</text><text class="terminal-1316892474-r1" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-1316892474-line-16)">
</text><text class="terminal-1316892474-r1" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-1316892474-line-17)">
</text><text class="terminal-1316892474-r1" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-1316892474-line-18)">
</text><text class="terminal-1316892474-r1" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-1316892474-line-19)">
</text><text class="terminal-1316892474-r1" x="976" y="508" textLength="12.2" clip-path="url(#terminal-1316892474-line-20)">
</text><text class="terminal-1316892474-r1" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-1316892474-line-21)">
</text><text class="terminal-1316892474-r1" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-1316892474-line-22)">
</text><text class="terminal-1316892474-r4" x="0" y="581.2" textLength="36.6" clip-path="url(#terminal-1316892474-line-23)">&#160;A&#160;</text><text class="terminal-1316892474-r5" x="36.6" y="581.2" textLength="183" clip-path="url(#terminal-1316892474-line-23)">&#160;Push&#160;screen&#160;A&#160;</text>
</g>
</g>
</svg>
'''
# ---
# name: test_switches # name: test_switches
''' '''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg"> <svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">

View File

@@ -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()

View File

@@ -214,3 +214,7 @@ def test_auto_width_input(snap_compare):
assert snap_compare( assert snap_compare(
SNAPSHOT_APPS_DIR / "auto_width_input.py", press=["tab", *"Hello"] 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"])

View File

@@ -8,9 +8,13 @@ from textual.containers import Container
async def test_remove_single_widget(): async def test_remove_single_widget():
"""It should be possible to the only widget on a screen.""" """It should be possible to the only widget on a screen."""
async with App().run_test() as pilot: 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 assert len(pilot.app.screen.children) == 1
await pilot.app.query_one(Static).remove() await pilot.app.query_one(Static).remove()
assert not widget.is_attached
assert len(pilot.app.screen.children) == 0 assert len(pilot.app.screen.children) == 0