Merge pull request #1750 from Textualize/fix-screen-switch

exclude removed reactables
This commit is contained in:
Will McGugan 2023-02-09 12:08:24 +00:00 committed by GitHub
commit 67c2127e46
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 265 additions and 17 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 `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 DOMNode.watch and DOMNode.is_attached methods https://github.com/Textualize/textual/pull/1750
### Changed
@ -42,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

View file

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

View file

@ -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
@ -647,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.

View file

@ -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.

View file

@ -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
@ -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:
@ -316,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.
@ -333,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)

View file

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

View file

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

View file

@ -14124,6 +14124,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_textlog_max_lines
'''
<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(
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():
"""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