Merge pull request #1778 from Textualize/screens-redux

implements screens view
This commit is contained in:
Will McGugan
2023-02-14 09:55:07 +00:00
committed by GitHub
17 changed files with 149 additions and 102 deletions

View File

@@ -19,13 +19,20 @@ 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 - Added `DOMNode.watch` and `DOMNode.is_attached` methods https://github.com/Textualize/textual/pull/1750
- Added `DOMNode.css_tree` which is a renderable that shows the DOM and CSS https://github.com/Textualize/textual/pull/1778
- Added `DOMNode.children_view` which is a view on to a nodes children list, use for querying https://github.com/Textualize/textual/pull/1778
### Changed ### 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 - 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 - `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` https://github.com/Textualize/textual/issues/1746 - Breaking change: renamed `Checkbox` to `Switch` https://github.com/Textualize/textual/issues/1746
- `App.install_screen` name is no longer optional https://github.com/Textualize/textual/pull/1778
- `App.query` now only includes the current screen https://github.com/Textualize/textual/pull/1778
- `DOMNode.tree` now displays simple DOM structure only https://github.com/Textualize/textual/pull/1778
- `App.install_screen` now returns None rather than AwaitMount https://github.com/Textualize/textual/pull/1778
- `DOMNode.children` is now a simple sequence, the NodesList is exposed as `DOMNode._nodes` https://github.com/Textualize/textual/pull/1778
### Fixed ### Fixed

View File

@@ -21,7 +21,7 @@ The example below creates a simple tree.
--8<-- "docs/examples/widgets/tree.py" --8<-- "docs/examples/widgets/tree.py"
``` ```
Tree widgets have a "root" attribute which is an instance of a [TreeNode][textual.widgets.tree.TreeNode]. Call [add()][textual.widgets.tree.TreeNode.add] or [add_leaf()][textual.widgets.tree,TreeNode.add_leaf] to add new nodes underneath the root. Both these methods return a TreeNode for the child which you can use to add additional levels. Tree widgets have a "root" attribute which is an instance of a [TreeNode][textual.widgets.tree.TreeNode]. Call [add()][textual.widgets.tree.TreeNode.add] or [add_leaf()][textual.widgets.tree.TreeNode.add_leaf] to add new nodes underneath the root. Both these methods return a TreeNode for the child which you can use to add additional levels.
## Reactive Attributes ## Reactive Attributes

View File

@@ -47,7 +47,6 @@ typing-extensions = "^4.0.0"
aiohttp = { version = ">=3.8.1", optional = true } aiohttp = { version = ">=3.8.1", optional = true }
click = {version = ">=8.1.2", optional = true} click = {version = ">=8.1.2", optional = true}
msgpack = { version = ">=1.0.3", optional = true } msgpack = { version = ">=1.0.3", optional = true }
nanoid = ">=2.0.0"
mkdocs-exclude = "^1.0.2" mkdocs-exclude = "^1.0.2"
[tool.poetry.extras] [tool.poetry.extras]

View File

@@ -57,7 +57,7 @@ class Layout(ABC):
Returns: Returns:
Width of the content. Width of the content.
""" """
if not widget.children: if not widget._nodes:
width = 0 width = 0
else: else:
# Use a size of 0, 0 to ignore relative sizes, since those are flexible anyway # Use a size of 0, 0 to ignore relative sizes, since those are flexible anyway
@@ -85,7 +85,7 @@ class Layout(ABC):
Returns: Returns:
Content height (in lines). Content height (in lines).
""" """
if not widget.children: if not widget._nodes:
height = 0 height = 0
else: else:
# Use a height of zero to ignore relative heights # Use a height of zero to ignore relative heights

View File

@@ -25,15 +25,15 @@ from typing import (
Generic, Generic,
Iterable, Iterable,
List, List,
Sequence,
Type, Type,
TypeVar, TypeVar,
Union, Union,
cast, cast,
overload, overload,
) )
from weakref import WeakSet, WeakValueDictionary from weakref import WeakSet
import nanoid
import rich import rich
import rich.repr import rich.repr
from rich.console import Console, RenderableType from rich.console import Console, RenderableType
@@ -385,9 +385,7 @@ class App(Generic[ReturnType], DOMNode):
self.scroll_sensitivity_y: float = 2.0 self.scroll_sensitivity_y: float = 2.0
"""Number of lines to scroll in the Y direction with wheel or trackpad.""" """Number of lines to scroll in the Y direction with wheel or trackpad."""
self._installed_screens: WeakValueDictionary[ self._installed_screens: dict[str, Screen | Callable[[], Screen]] = {}
str, Screen | Callable[[], Screen]
] = WeakValueDictionary()
self._installed_screens.update(**self.SCREENS) self._installed_screens.update(**self.SCREENS)
self.devtools: DevtoolsClient | None = None self.devtools: DevtoolsClient | None = None
@@ -420,6 +418,14 @@ class App(Generic[ReturnType], DOMNode):
"""ReturnType | None: The return type of the app.""" """ReturnType | None: The return type of the app."""
return self._return_value return self._return_value
@property
def children(self) -> Sequence["Widget"]:
"""A view on to the children which contains just the screen."""
try:
return (self.screen,)
except ScreenError:
return ()
def animate( def animate(
self, self,
attribute: str, attribute: str,
@@ -1265,15 +1271,16 @@ class App(Generic[ReturnType], DOMNode):
return await_mount return await_mount
return AwaitMount(self.screen, []) return AwaitMount(self.screen, [])
def install_screen(self, screen: Screen, name: str | None = None) -> AwaitMount: def install_screen(self, screen: Screen, name: str) -> None:
"""Install a screen. """Install a screen.
Installing a screen prevents Textual from destroying it when it is no longer on the screen stack. Installing a screen prevents Textual from destroying it when it is no longer on the screen stack.
Note that you don't need to install a screen to use it. See [push_screen][textual.app.App.push_screen]
or [switch_screen][textual.app.App.switch_screen] to make a new screen current.
Args: Args:
screen: Screen to install. screen: Screen to install.
name: Unique name of screen or None to auto-generate. name: Unique name to identify the screen.
Defaults to None.
Raises: Raises:
ScreenError: If the screen can't be installed. ScreenError: If the screen can't be installed.
@@ -1281,8 +1288,6 @@ class App(Generic[ReturnType], DOMNode):
Returns: Returns:
An awaitable that awaits the mounting of the screen and its children. An awaitable that awaits the mounting of the screen and its children.
""" """
if name is None:
name = nanoid.generate()
if name in self._installed_screens: if name in self._installed_screens:
raise ScreenError(f"Can't install screen; {name!r} is already installed") raise ScreenError(f"Can't install screen; {name!r} is already installed")
if screen in self._installed_screens.values(): if screen in self._installed_screens.values():
@@ -1290,13 +1295,17 @@ class App(Generic[ReturnType], DOMNode):
"Can't install screen; {screen!r} has already been installed" "Can't install screen; {screen!r} has already been installed"
) )
self._installed_screens[name] = screen self._installed_screens[name] = screen
_screen, await_mount = self._get_screen(name) # Ensures screen is running
self.log.system(f"{screen} INSTALLED name={name!r}") self.log.system(f"{screen} INSTALLED name={name!r}")
return await_mount
def uninstall_screen(self, screen: Screen | str) -> str | None: def uninstall_screen(self, screen: Screen | str) -> str | None:
"""Uninstall a screen. If the screen was not previously installed then this """Uninstall a screen.
method is a null-op.
If the screen was not previously installed then this method is a null-op.
Uninstalling a screen allows Textual to delete it when it is popped or switched.
Note that uninstalling a screen is only required if you have previously installed it
with [install_screen][textual.app.App.install_screen].
Textual will also uninstall screens automatically on exit.
Args: Args:
screen: The screen to uninstall or the name of a installed screen. screen: The screen to uninstall or the name of a installed screen.
@@ -1641,19 +1650,19 @@ class App(Generic[ReturnType], DOMNode):
# Now to figure out where to place it. If we've got a `before`... # Now to figure out where to place it. If we've got a `before`...
if before is not None: if before is not None:
# ...it's safe to NodeList._insert before that location. # ...it's safe to NodeList._insert before that location.
parent.children._insert(before, child) parent._nodes._insert(before, child)
elif after is not None and after != -1: elif after is not None and after != -1:
# In this case we've got an after. -1 holds the special # In this case we've got an after. -1 holds the special
# position (for now) of meaning "okay really what I mean is # position (for now) of meaning "okay really what I mean is
# do an append, like if I'd asked to add with no before or # do an append, like if I'd asked to add with no before or
# after". So... we insert before the next item in the node # after". So... we insert before the next item in the node
# list, iff after isn't -1. # list, iff after isn't -1.
parent.children._insert(after + 1, child) parent._nodes._insert(after + 1, child)
else: else:
# At this point we appear to not be adding before or after, # At this point we appear to not be adding before or after,
# or we've got a before/after value that really means # or we've got a before/after value that really means
# "please append". So... # "please append". So...
parent.children._append(child) parent._nodes._append(child)
# Now that the widget is in the NodeList of its parent, sort out # Now that the widget is in the NodeList of its parent, sort out
# the rest of the admin. # the rest of the admin.
@@ -1698,8 +1707,8 @@ class App(Generic[ReturnType], DOMNode):
raise AppError(f"Can't register {widget!r}; expected a Widget instance") raise AppError(f"Can't register {widget!r}; expected a Widget instance")
if widget not in self._registry: if widget not in self._registry:
self._register_child(parent, widget, before, after) self._register_child(parent, widget, before, after)
if widget.children: if widget._nodes:
self._register(widget, *widget.children) self._register(widget, *widget._nodes)
apply_stylesheet(widget) apply_stylesheet(widget)
if not self._running: if not self._running:
@@ -1716,7 +1725,7 @@ class App(Generic[ReturnType], DOMNode):
""" """
widget.reset_focus() widget.reset_focus()
if isinstance(widget._parent, Widget): if isinstance(widget._parent, Widget):
widget._parent.children._remove(widget) widget._parent._nodes._remove(widget)
widget._detach() widget._detach()
self._registry.discard(widget) self._registry.discard(widget)
@@ -1894,6 +1903,7 @@ class App(Generic[ReturnType], DOMNode):
# Handle input events that haven't been forwarded # Handle input events that haven't been forwarded
# If the event has been forwarded it may have bubbled up back to the App # If the event has been forwarded it may have bubbled up back to the App
if isinstance(event, events.Compose): if isinstance(event, events.Compose):
self.log(event)
screen = Screen(id="_default") screen = Screen(id="_default")
self._register(self, screen) self._register(self, screen)
self._screen_stack.append(screen) self._screen_stack.append(screen)
@@ -2098,7 +2108,7 @@ class App(Generic[ReturnType], DOMNode):
# snipping each affected branch from the DOM. # snipping each affected branch from the DOM.
for widget in pruned_remove: for widget in pruned_remove:
if widget.parent is not None: if widget.parent is not None:
widget.parent.children._remove(widget) widget.parent._nodes._remove(widget)
# Return the list of widgets that should end up being sent off in a # Return the list of widgets that should end up being sent off in a
# prune event. # prune event.
@@ -2117,10 +2127,10 @@ class App(Generic[ReturnType], DOMNode):
while stack: while stack:
widget = pop() widget = pop()
children = [*widget.children, *widget._get_virtual_dom()] children = [*widget._nodes, *widget._get_virtual_dom()]
if children: if children:
yield children yield children
for child in widget.children: for child in widget._nodes:
push(child) push(child)
def _remove_nodes(self, widgets: list[Widget]) -> AwaitRemove: def _remove_nodes(self, widgets: list[Widget]) -> AwaitRemove:

View File

@@ -7,6 +7,7 @@ from typing import (
ClassVar, ClassVar,
Iterable, Iterable,
Iterator, Iterator,
Sequence,
Type, Type,
TypeVar, TypeVar,
cast, cast,
@@ -132,7 +133,7 @@ class DOMNode(MessagePump):
check_identifiers("class name", *_classes) check_identifiers("class name", *_classes)
self._classes.update(_classes) self._classes.update(_classes)
self.children: NodeList = NodeList() self._nodes: NodeList = NodeList()
self._css_styles: Styles = Styles(self) self._css_styles: Styles = Styles(self)
self._inline_styles: Styles = Styles(self) self._inline_styles: Styles = Styles(self)
self.styles: RenderStyles = RenderStyles( self.styles: RenderStyles = RenderStyles(
@@ -150,6 +151,11 @@ class DOMNode(MessagePump):
super().__init__() super().__init__()
@property
def children(self) -> Sequence["Widget"]:
"""A view on to the children."""
return self._nodes
@property @property
def auto_refresh(self) -> float | None: def auto_refresh(self) -> float | None:
return self._auto_refresh return self._auto_refresh
@@ -484,7 +490,7 @@ class DOMNode(MessagePump):
return self.styles.visibility != "hidden" return self.styles.visibility != "hidden"
@visible.setter @visible.setter
def visible(self, new_value: bool) -> None: def visible(self, new_value: bool | str) -> None:
if isinstance(new_value, bool): if isinstance(new_value, bool):
self.styles.visibility = "visible" if new_value else "hidden" self.styles.visibility = "visible" if new_value else "hidden"
elif new_value in VALID_VISIBILITY: elif new_value in VALID_VISIBILITY:
@@ -497,10 +503,27 @@ class DOMNode(MessagePump):
@property @property
def tree(self) -> Tree: def tree(self) -> Tree:
"""Get a Rich tree object which will recursively render the structure of the node tree. """Get a Rich tree object which will recursively render the structure of the node tree."""
Returns: def render_info(node: DOMNode) -> Pretty:
A Rich object which may be printed. return Pretty(node)
tree = Tree(render_info(self))
def add_children(tree, node):
for child in node.children:
info = render_info(child)
branch = tree.add(info)
if tree.children:
add_children(branch, child)
add_children(tree, self)
return tree
@property
def css_tree(self) -> Tree:
"""Get a Rich tree object which will recursively render the structure of the node tree,
which also displays CSS and size information.
""" """
from rich.columns import Columns from rich.columns import Columns
from rich.console import Group from rich.console import Group
@@ -648,7 +671,7 @@ class DOMNode(MessagePump):
Children of this widget which will be displayed. Children of this widget which will be displayed.
""" """
return [child for child in self.children if child.display] return [child for child in self._nodes if child.display]
def watch( def watch(
self, self,
@@ -691,7 +714,7 @@ class DOMNode(MessagePump):
Args: Args:
node: A DOM node. node: A DOM node.
""" """
self.children._append(node) self._nodes._append(node)
node._attach(self) node._attach(self)
def _add_children(self, *nodes: Widget) -> None: def _add_children(self, *nodes: Widget) -> None:
@@ -700,7 +723,7 @@ class DOMNode(MessagePump):
Args: Args:
*nodes: Positional args should be new DOM nodes. *nodes: Positional args should be new DOM nodes.
""" """
_append = self.children._append _append = self._nodes._append
for node in nodes: for node in nodes:
node._attach(self) node._attach(self)
_append(node) _append(node)

View File

@@ -236,5 +236,4 @@ class LinuxDriver(Driver):
except Exception as error: except Exception as error:
log(error) log(error)
finally: finally:
with timer("selector.close"): selector.close()
selector.close()

View File

@@ -53,7 +53,7 @@ def walk_depth_first(
""" """
from textual.dom import DOMNode from textual.dom import DOMNode
stack: list[Iterator[DOMNode]] = [iter(root.children)] stack: list[Iterator[DOMNode]] = [iter(root._nodes)]
pop = stack.pop pop = stack.pop
push = stack.append push = stack.append
check_type = filter_type or DOMNode check_type = filter_type or DOMNode

View File

@@ -299,7 +299,7 @@ class Widget(DOMNode):
""" """
parent = self.parent parent = self.parent
if parent is not None: if parent is not None:
siblings = list(parent.children) siblings = list(parent._nodes)
siblings.remove(self) siblings.remove(self)
return siblings return siblings
else: else:
@@ -390,7 +390,7 @@ class Widget(DOMNode):
NoMatches: if no children could be found for this ID NoMatches: if no children could be found for this ID
WrongType: if the wrong type was found. WrongType: if the wrong type was found.
""" """
child = self.children._get_by_id(id) child = self._nodes._get_by_id(id)
if child is None: if child is None:
raise NoMatches(f"No child found with id={id!r}") raise NoMatches(f"No child found with id={id!r}")
if expect_type is None: if expect_type is None:
@@ -472,7 +472,7 @@ class Widget(DOMNode):
""" """
assert self.is_container assert self.is_container
cache_key = (size, self.children._updates) cache_key = (size, self._nodes._updates)
if ( if (
self._arrangement_cache_key == cache_key self._arrangement_cache_key == cache_key
and self._cached_arrangement is not None and self._cached_arrangement is not None
@@ -481,7 +481,7 @@ class Widget(DOMNode):
self._arrangement_cache_key = cache_key self._arrangement_cache_key = cache_key
arrangement = self._cached_arrangement = arrange( arrangement = self._cached_arrangement = arrange(
self, self.children, size, self.screen.size self, self._nodes, size, self.screen.size
) )
return arrangement return arrangement
@@ -549,7 +549,7 @@ class Widget(DOMNode):
# children. We should be able to go looking for the widget's # children. We should be able to go looking for the widget's
# location amongst its parent's children. # location amongst its parent's children.
try: try:
return cast("Widget", spot.parent), spot.parent.children.index(spot) return cast("Widget", spot.parent), spot.parent._nodes.index(spot)
except ValueError: except ValueError:
raise MountError(f"{spot!r} is not a child of {self!r}") from None raise MountError(f"{spot!r} is not a child of {self!r}") from None
@@ -687,7 +687,7 @@ class Widget(DOMNode):
"""Ensure a given child reference is a Widget.""" """Ensure a given child reference is a Widget."""
if isinstance(child, int): if isinstance(child, int):
try: try:
child = self.children[child] child = self._nodes[child]
except IndexError: except IndexError:
raise WidgetError( raise WidgetError(
f"An index of {child} for the child to {called} is out of bounds" f"An index of {child} for the child to {called} is out of bounds"
@@ -696,7 +696,7 @@ class Widget(DOMNode):
# We got an actual widget, so let's be sure it really is one of # We got an actual widget, so let's be sure it really is one of
# our children. # our children.
try: try:
_ = self.children.index(child) _ = self._nodes.index(child)
except ValueError: except ValueError:
raise WidgetError(f"{child!r} is not a child of {self!r}") from None raise WidgetError(f"{child!r} is not a child of {self!r}") from None
return child return child
@@ -709,11 +709,11 @@ class Widget(DOMNode):
# child; where we're moving it to, which should be within the child # child; where we're moving it to, which should be within the child
# list; and how we're supposed to move it. All that's left is doing # list; and how we're supposed to move it. All that's left is doing
# the right thing. # the right thing.
self.children._remove(child) self._nodes._remove(child)
if before is not None: if before is not None:
self.children._insert(self.children.index(target), child) self._nodes._insert(self._nodes.index(target), child)
else: else:
self.children._insert(self.children.index(target) + 1, child) self._nodes._insert(self._nodes.index(target) + 1, child)
# Request a refresh. # Request a refresh.
self.refresh(layout=True) self.refresh(layout=True)
@@ -1181,9 +1181,7 @@ class Widget(DOMNode):
List of widgets that can receive focus. List of widgets that can receive focus.
""" """
focusable = [ focusable = [child for child in self._nodes if child.display and child.visible]
child for child in self.children if child.display and child.visible
]
return sorted(focusable, key=attrgetter("_focus_sort_key")) return sorted(focusable, key=attrgetter("_focus_sort_key"))
@property @property
@@ -1277,7 +1275,7 @@ class Widget(DOMNode):
Returns: Returns:
True if this widget is a container. True if this widget is a container.
""" """
return self.styles.layout is not None or bool(self.children) return self.styles.layout is not None or bool(self._nodes)
@property @property
def is_scrollable(self) -> bool: def is_scrollable(self) -> bool:
@@ -1286,7 +1284,7 @@ class Widget(DOMNode):
Returns: Returns:
True if this widget may be scrolled. True if this widget may be scrolled.
""" """
return self.styles.layout is not None or bool(self.children) return self.styles.layout is not None or bool(self._nodes)
@property @property
def layer(self) -> str: def layer(self) -> str:

View File

@@ -94,33 +94,33 @@ class ListView(Vertical, can_focus=True, can_focus_children=False):
""" """
if self.index is None: if self.index is None:
return None return None
elif 0 <= self.index < len(self.children): elif 0 <= self.index < len(self._nodes):
return self.children[self.index] return self._nodes[self.index]
def validate_index(self, index: int | None) -> int | None: def validate_index(self, index: int | None) -> int | None:
"""Clamp the index to the valid range, or set to None if there's nothing to highlight.""" """Clamp the index to the valid range, or set to None if there's nothing to highlight."""
if not self.children or index is None: if not self._nodes or index is None:
return None return None
return self._clamp_index(index) return self._clamp_index(index)
def _clamp_index(self, index: int) -> int: def _clamp_index(self, index: int) -> int:
"""Clamp the index to a valid value given the current list of children""" """Clamp the index to a valid value given the current list of children"""
last_index = max(len(self.children) - 1, 0) last_index = max(len(self._nodes) - 1, 0)
return clamp(index, 0, last_index) return clamp(index, 0, last_index)
def _is_valid_index(self, index: int | None) -> bool: def _is_valid_index(self, index: int | None) -> bool:
"""Return True if the current index is valid given the current list of children""" """Return True if the current index is valid given the current list of children"""
if index is None: if index is None:
return False return False
return 0 <= index < len(self.children) return 0 <= index < len(self._nodes)
def watch_index(self, old_index: int, new_index: int) -> None: def watch_index(self, old_index: int, new_index: int) -> None:
"""Updates the highlighting when the index changes.""" """Updates the highlighting when the index changes."""
if self._is_valid_index(old_index): if self._is_valid_index(old_index):
old_child = self.children[old_index] old_child = self._nodes[old_index]
old_child.highlighted = False old_child.highlighted = False
if self._is_valid_index(new_index): if self._is_valid_index(new_index):
new_child = self.children[new_index] new_child = self._nodes[new_index]
new_child.highlighted = True new_child.highlighted = True
else: else:
new_child = None new_child = None
@@ -166,7 +166,7 @@ class ListView(Vertical, can_focus=True, can_focus_children=False):
def on_list_item__child_clicked(self, event: ListItem._ChildClicked) -> None: def on_list_item__child_clicked(self, event: ListItem._ChildClicked) -> None:
self.focus() self.focus()
self.index = self.children.index(event.sender) self.index = self._nodes.index(event.sender)
self.post_message_no_wait(self.Selected(self, event.sender)) self.post_message_no_wait(self.Selected(self, event.sender))
def _scroll_highlighted_region(self) -> None: def _scroll_highlighted_region(self) -> None:
@@ -175,4 +175,4 @@ class ListView(Vertical, can_focus=True, can_focus_children=False):
self.scroll_to_widget(self.highlighted_child, animate=False) self.scroll_to_widget(self.highlighted_child, animate=False)
def __len__(self): def __len__(self):
return len(self.children) return len(self._nodes)

View File

@@ -473,7 +473,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
text_label = self.process_label(label) text_label = self.process_label(label)
self._updates = 0 self._updates = 0
self._nodes: dict[NodeID, TreeNode[TreeDataType]] = {} self._tree_nodes: dict[NodeID, TreeNode[TreeDataType]] = {}
self._current_id = 0 self._current_id = 0
self.root = self._add_node(None, text_label, data) self.root = self._add_node(None, text_label, data)
@@ -515,7 +515,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
expand: bool = False, expand: bool = False,
) -> TreeNode[TreeDataType]: ) -> TreeNode[TreeDataType]:
node = TreeNode(self, parent, self._new_id(), label, data, expanded=expand) node = TreeNode(self, parent, self._new_id(), label, data, expanded=expand)
self._nodes[node._id] = node self._tree_nodes[node._id] = node
self._updates += 1 self._updates += 1
return node return node
@@ -630,7 +630,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
Tree.UnknownID: Raised if the `TreeNode` ID is unknown. Tree.UnknownID: Raised if the `TreeNode` ID is unknown.
""" """
try: try:
return self._nodes[node_id] return self._tree_nodes[node_id]
except KeyError: except KeyError:
raise self.UnknownNodeID(f"Unknown NodeID ({node_id}) in tree") from None raise self.UnknownNodeID(f"Unknown NodeID ({node_id}) in tree") from None

View File

@@ -42,9 +42,11 @@ async def test_installed_screens():
pilot.app.pop_screen() pilot.app.pop_screen()
@skip_py310
async def test_screens(): async def test_screens():
app = App() app = App()
# There should be nothing in the children since the app hasn't run yet
assert not app._nodes
assert not app.children
app._set_active() app._set_active()
with pytest.raises(ScreenStackError): with pytest.raises(ScreenStackError):
@@ -60,6 +62,10 @@ async def test_screens():
app.install_screen(screen1, "screen1") app.install_screen(screen1, "screen1")
app.install_screen(screen2, "screen2") app.install_screen(screen2, "screen2")
# Installing a screen does not add it to the DOM
assert not app._nodes
assert not app.children
# Check they are installed # Check they are installed
assert app.is_screen_installed("screen1") assert app.is_screen_installed("screen1")
assert app.is_screen_installed("screen2") assert app.is_screen_installed("screen2")
@@ -84,17 +90,22 @@ async def test_screens():
assert app.screen_stack == [screen1] assert app.screen_stack == [screen1]
# Check it is current # Check it is current
assert app.screen is screen1 assert app.screen is screen1
# There should be one item in the children view
assert app.children == (screen1,)
# Switch to another screen # Switch to another screen
app.switch_screen("screen2") app.switch_screen("screen2")
# Check it has changed the stack and that it is current # Check it has changed the stack and that it is current
assert app.screen_stack == [screen2] assert app.screen_stack == [screen2]
assert app.screen is screen2 assert app.screen is screen2
assert app.children == (screen2,)
# Push another screen # Push another screen
app.push_screen("screen3") app.push_screen("screen3")
assert app.screen_stack == [screen2, screen3] assert app.screen_stack == [screen2, screen3]
assert app.screen is screen3 assert app.screen is screen3
# Only the current screen is in children
assert app.children == (screen3,)
# Pop a screen # Pop a screen
assert app.pop_screen() is screen3 assert app.pop_screen() is screen3

View File

@@ -13,7 +13,7 @@ async def test_unmount():
class UnmountWidget(Container): class UnmountWidget(Container):
def on_unmount(self, event: events.Unmount): def on_unmount(self, event: events.Unmount):
unmount_ids.append( unmount_ids.append(
f"{self.__class__.__name__}#{self.id}-{self.parent is not None}-{len(self.children)}" f"{self.__class__.__name__}#{self.id}-{self.parent is not None}-{len(self._nodes)}"
) )
class MyScreen(Screen): class MyScreen(Screen):

View File

@@ -59,9 +59,9 @@ async def test_widget_move_child() -> None:
container = Widget(*widgets) container = Widget(*widgets)
await pilot.app.mount(container) await pilot.app.mount(container)
container.move_child(child, before=target) container.move_child(child, before=target)
assert container.children[0].id == "widget-1" assert container._nodes[0].id == "widget-1"
assert container.children[1].id == "widget-0" assert container._nodes[1].id == "widget-0"
assert container.children[2].id == "widget-2" assert container._nodes[2].id == "widget-2"
# Test the different permutations of moving one widget after another. # Test the different permutations of moving one widget after another.
perms = ((0, 1), (widgets[0], 1), (0, widgets[1]), (widgets[0], widgets[1])) perms = ((0, 1), (widgets[0], 1), (0, widgets[1]), (widgets[0], widgets[1]))
@@ -70,22 +70,22 @@ async def test_widget_move_child() -> None:
container = Widget(*widgets) container = Widget(*widgets)
await pilot.app.mount(container) await pilot.app.mount(container)
container.move_child(child, after=target) container.move_child(child, after=target)
assert container.children[0].id == "widget-1" assert container._nodes[0].id == "widget-1"
assert container.children[1].id == "widget-0" assert container._nodes[1].id == "widget-0"
assert container.children[2].id == "widget-2" assert container._nodes[2].id == "widget-2"
# Test moving after a child after the last child. # Test moving after a child after the last child.
async with App().run_test() as pilot: async with App().run_test() as pilot:
container = Widget(*widgets) container = Widget(*widgets)
await pilot.app.mount(container) await pilot.app.mount(container)
container.move_child(widgets[0], after=widgets[-1]) container.move_child(widgets[0], after=widgets[-1])
assert container.children[0].id == "widget-1" assert container._nodes[0].id == "widget-1"
assert container.children[-1].id == "widget-0" assert container._nodes[-1].id == "widget-0"
# Test moving after a child after the last child's numeric position. # Test moving after a child after the last child's numeric position.
async with App().run_test() as pilot: async with App().run_test() as pilot:
container = Widget(*widgets) container = Widget(*widgets)
await pilot.app.mount(container) await pilot.app.mount(container)
container.move_child(widgets[0], after=widgets[9]) container.move_child(widgets[0], after=widgets[9])
assert container.children[0].id == "widget-1" assert container._nodes[0].id == "widget-1"
assert container.children[-1].id == "widget-0" assert container._nodes[-1].id == "widget-0"

View File

@@ -23,7 +23,7 @@ def test_find_dom_spot():
# Just as a quick double-check, make sure the main components are in # Just as a quick double-check, make sure the main components are in
# their intended place. # their intended place.
assert list(screen.children) == [header, body, footer] assert list(screen._nodes) == [header, body, footer]
# Now check that we find what we're looking for in the places we expect # Now check that we find what we're looking for in the places we expect
# to find them. # to find them.

View File

@@ -26,72 +26,72 @@ async def test_mount_via_app() -> None:
async with App().run_test() as pilot: async with App().run_test() as pilot:
# Mount the first one and make sure it's there. # Mount the first one and make sure it's there.
await pilot.app.mount(widgets[0]) await pilot.app.mount(widgets[0])
assert len(pilot.app.screen.children) == 1 assert len(pilot.app.screen._nodes) == 1
assert pilot.app.screen.children[0] == widgets[0] assert pilot.app.screen._nodes[0] == widgets[0]
# Mount the next 2 widgets via mount. # Mount the next 2 widgets via mount.
await pilot.app.mount(*widgets[1:3]) await pilot.app.mount(*widgets[1:3])
assert list(pilot.app.screen.children) == widgets[0:3] assert list(pilot.app.screen._nodes) == widgets[0:3]
# Finally mount the rest of the widgets via mount_all. # Finally mount the rest of the widgets via mount_all.
await pilot.app.mount_all(widgets[3:]) await pilot.app.mount_all(widgets[3:])
assert list(pilot.app.screen.children) == widgets assert list(pilot.app.screen._nodes) == widgets
async with App().run_test() as pilot: async with App().run_test() as pilot:
# Mount a widget before -1, which is "before the end". # Mount a widget before -1, which is "before the end".
penultimate = Static(id="penultimate") penultimate = Static(id="penultimate")
await pilot.app.mount_all(widgets) await pilot.app.mount_all(widgets)
await pilot.app.mount(penultimate, before=-1) await pilot.app.mount(penultimate, before=-1)
assert pilot.app.screen.children[-2] == penultimate assert pilot.app.screen._nodes[-2] == penultimate
async with App().run_test() as pilot: async with App().run_test() as pilot:
# Mount a widget after -1, which is "at the end". # Mount a widget after -1, which is "at the end".
ultimate = Static(id="ultimate") ultimate = Static(id="ultimate")
await pilot.app.mount_all(widgets) await pilot.app.mount_all(widgets)
await pilot.app.mount(ultimate, after=-1) await pilot.app.mount(ultimate, after=-1)
assert pilot.app.screen.children[-1] == ultimate assert pilot.app.screen._nodes[-1] == ultimate
async with App().run_test() as pilot: async with App().run_test() as pilot:
# Mount a widget before -2, which is "before the penultimate". # Mount a widget before -2, which is "before the penultimate".
penpenultimate = Static(id="penpenultimate") penpenultimate = Static(id="penpenultimate")
await pilot.app.mount_all(widgets) await pilot.app.mount_all(widgets)
await pilot.app.mount(penpenultimate, before=-2) await pilot.app.mount(penpenultimate, before=-2)
assert pilot.app.screen.children[-3] == penpenultimate assert pilot.app.screen._nodes[-3] == penpenultimate
async with App().run_test() as pilot: async with App().run_test() as pilot:
# Mount a widget after -2, which is "before the end". # Mount a widget after -2, which is "before the end".
penultimate = Static(id="penultimate") penultimate = Static(id="penultimate")
await pilot.app.mount_all(widgets) await pilot.app.mount_all(widgets)
await pilot.app.mount(penultimate, after=-2) await pilot.app.mount(penultimate, after=-2)
assert pilot.app.screen.children[-2] == penultimate assert pilot.app.screen._nodes[-2] == penultimate
async with App().run_test() as pilot: async with App().run_test() as pilot:
# Mount a widget before 0, which is "at the start". # Mount a widget before 0, which is "at the start".
start = Static(id="start") start = Static(id="start")
await pilot.app.mount_all(widgets) await pilot.app.mount_all(widgets)
await pilot.app.mount(start, before=0) await pilot.app.mount(start, before=0)
assert pilot.app.screen.children[0] == start assert pilot.app.screen._nodes[0] == start
async with App().run_test() as pilot: async with App().run_test() as pilot:
# Mount a widget after 0. You get the idea... # Mount a widget after 0. You get the idea...
second = Static(id="second") second = Static(id="second")
await pilot.app.mount_all(widgets) await pilot.app.mount_all(widgets)
await pilot.app.mount(second, after=0) await pilot.app.mount(second, after=0)
assert pilot.app.screen.children[1] == second assert pilot.app.screen._nodes[1] == second
async with App().run_test() as pilot: async with App().run_test() as pilot:
# Mount a widget relative to another via query. # Mount a widget relative to another via query.
queue_jumper = Static(id="queue-jumper") queue_jumper = Static(id="queue-jumper")
await pilot.app.mount_all(widgets) await pilot.app.mount_all(widgets)
await pilot.app.mount(queue_jumper, after="#starter-5") await pilot.app.mount(queue_jumper, after="#starter-5")
assert pilot.app.screen.children[6] == queue_jumper assert pilot.app.screen._nodes[6] == queue_jumper
async with App().run_test() as pilot: async with App().run_test() as pilot:
# Mount a widget relative to another via query. # Mount a widget relative to another via query.
queue_jumper = Static(id="queue-jumper") queue_jumper = Static(id="queue-jumper")
await pilot.app.mount_all(widgets) await pilot.app.mount_all(widgets)
await pilot.app.mount(queue_jumper, after=widgets[5]) await pilot.app.mount(queue_jumper, after=widgets[5])
assert pilot.app.screen.children[6] == queue_jumper assert pilot.app.screen._nodes[6] == queue_jumper
async with App().run_test() as pilot: async with App().run_test() as pilot:
# Make sure we get told off for trying to before and after. # Make sure we get told off for trying to before and after.

View File

@@ -13,28 +13,28 @@ async def test_remove_single_widget():
assert not widget.is_attached assert not widget.is_attached
await pilot.app.mount(widget) await pilot.app.mount(widget)
assert widget.is_attached assert widget.is_attached
assert len(pilot.app.screen.children) == 1 assert len(pilot.app.screen._nodes) == 1
await pilot.app.query_one(Static).remove() await pilot.app.query_one(Static).remove()
assert not widget.is_attached assert not widget.is_attached
assert len(pilot.app.screen.children) == 0 assert len(pilot.app.screen._nodes) == 0
async def test_many_remove_all_widgets(): async def test_many_remove_all_widgets():
"""It should be possible to remove all widgets on a multi-widget screen.""" """It should be possible to remove all widgets on a multi-widget screen."""
async with App().run_test() as pilot: async with App().run_test() as pilot:
await pilot.app.mount(*[Static() for _ in range(10)]) await pilot.app.mount(*[Static() for _ in range(10)])
assert len(pilot.app.screen.children) == 10 assert len(pilot.app.screen._nodes) == 10
await pilot.app.query(Static).remove() await pilot.app.query(Static).remove()
assert len(pilot.app.screen.children) == 0 assert len(pilot.app.screen._nodes) == 0
async def test_many_remove_some_widgets(): async def test_many_remove_some_widgets():
"""It should be possible to remove some widgets on a multi-widget screen.""" """It should be possible to remove some widgets on a multi-widget screen."""
async with App().run_test() as pilot: async with App().run_test() as pilot:
await pilot.app.mount(*[Static(classes=f"is-{n%2}") for n in range(10)]) await pilot.app.mount(*[Static(classes=f"is-{n%2}") for n in range(10)])
assert len(pilot.app.screen.children) == 10 assert len(pilot.app.screen._nodes) == 10
await pilot.app.query(".is-0").remove() await pilot.app.query(".is-0").remove()
assert len(pilot.app.screen.children) == 5 assert len(pilot.app.screen._nodes) == 5
async def test_remove_branch(): async def test_remove_branch():
@@ -46,7 +46,7 @@ async def test_remove_branch():
Container(Container(Container(Container(Container(Static()))))), Container(Container(Container(Container(Container(Static()))))),
) )
assert len(pilot.app.screen.walk_children(with_self=False)) == 13 assert len(pilot.app.screen.walk_children(with_self=False)) == 13
await pilot.app.screen.children[0].remove() await pilot.app.screen._nodes[0].remove()
assert len(pilot.app.screen.walk_children(with_self=False)) == 7 assert len(pilot.app.screen.walk_children(with_self=False)) == 7
@@ -68,14 +68,14 @@ async def test_remove_move_focus():
async with App().run_test() as pilot: async with App().run_test() as pilot:
buttons = [Button(str(n)) for n in range(10)] buttons = [Button(str(n)) for n in range(10)]
await pilot.app.mount(Container(*buttons[:5]), Container(*buttons[5:])) await pilot.app.mount(Container(*buttons[:5]), Container(*buttons[5:]))
assert len(pilot.app.screen.children) == 2 assert len(pilot.app.screen._nodes) == 2
assert len(pilot.app.screen.walk_children(with_self=False)) == 12 assert len(pilot.app.screen.walk_children(with_self=False)) == 12
assert pilot.app.focused is None assert pilot.app.focused is None
await pilot.press("tab") await pilot.press("tab")
assert pilot.app.focused is not None assert pilot.app.focused is not None
assert pilot.app.focused == buttons[0] assert pilot.app.focused == buttons[0]
await pilot.app.screen.children[0].remove() await pilot.app.screen._nodes[0].remove()
assert len(pilot.app.screen.children) == 1 assert len(pilot.app.screen._nodes) == 1
assert len(pilot.app.screen.walk_children(with_self=False)) == 6 assert len(pilot.app.screen.walk_children(with_self=False)) == 6
assert pilot.app.focused is not None assert pilot.app.focused is not None
assert pilot.app.focused == buttons[9] assert pilot.app.focused == buttons[9]
@@ -95,7 +95,7 @@ async def test_widget_remove_order():
Removable(Removable(Removable(id="grandchild"), id="child"), id="parent") Removable(Removable(Removable(id="grandchild"), id="child"), id="parent")
) )
assert len(pilot.app.screen.walk_children(with_self=False)) == 3 assert len(pilot.app.screen.walk_children(with_self=False)) == 3
await pilot.app.screen.children[0].remove() await pilot.app.screen._nodes[0].remove()
assert len(pilot.app.screen.walk_children(with_self=False)) == 0 assert len(pilot.app.screen.walk_children(with_self=False)) == 0
assert removals == ["grandchild", "child", "parent"] assert removals == ["grandchild", "child", "parent"]