From 39b23e1f512b4fb50edb2e96947ccaff4b6747a2 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 1 Nov 2022 17:01:56 +0000 Subject: [PATCH] WIP: Starting work on adding before/after mount directions This is still a work-in-progress, but this feels like a good point to commit for safe keeping. This is a non-working WIP. --- src/textual/app.py | 29 +++++++++++++++++++---- src/textual/dom.py | 54 ++++++++++++++++++++++++++++++++++++++++++ src/textual/widget.py | 21 ++++++++++++---- tests/test_dom_spot.py | 22 +++++++++++++++++ 4 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 tests/test_dom_spot.py diff --git a/src/textual/app.py b/src/textual/app.py index 7ea329ed4..32ba05d52 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -822,16 +822,29 @@ class App(Generic[ReturnType], DOMNode): self._require_stylesheet_update.add(self.screen if node is None else node) self.check_idle() - def mount(self, *widgets: Widget) -> AwaitMount: - """Mount the given widgets. + def mount( + self, *widgets: Widget, before: MountSpot = None, after: MountSpot = None + ) -> AwaitMount: + """Mount the given widgets relative to the app's screen. Args: *widgets (Widget): The widget(s) to mount. + before (MountSpot, optional): Optional location to mount before. + after (MountSpot, optional): Optional location to mount after. Returns: AwaitMount: An awaitable object that waits for widgets to be mounted. + + Raises: + MountError: If there is a problem with the mount request. + + Note: + Only one of ``before`` or ``after`` can be provided. If both are + provided a ``MountError`` will be raised. """ - return AwaitMount(self._register(self.screen, *widgets)) + return AwaitMount( + self._register(self.screen, *widgets, before=before, after=after) + ) def mount_all(self, widgets: Iterable[Widget]) -> AwaitMount: """Mount widgets from an iterable. @@ -1300,12 +1313,20 @@ class App(Generic[ReturnType], DOMNode): return True return False - def _register(self, parent: DOMNode, *widgets: Widget) -> list[Widget]: + def _register( + self, + parent: DOMNode, + *widgets: Widget, + before: MountSpot = None, + after: MountSpot = None, + ) -> list[Widget]: """Register widget(s) so they may receive events. Args: parent (DOMNode): Parent node. *widgets: The widget(s) to register. + before (MountSpot, optional): Optional location to mount before. + after (MountSpot, optional): Optional location to mount after. Returns: list[Widget]: List of modified widgets. diff --git a/src/textual/dom.py b/src/textual/dom.py index aa8f9a1b4..369879f5c 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -13,6 +13,7 @@ from typing import ( TypeVar, cast, overload, + Union, ) import rich.repr @@ -894,3 +895,56 @@ class DOMNode(MessagePump): def refresh(self, *, repaint: bool = True, layout: bool = False) -> None: pass + + DOMSpot = Union[int, str, "DOMQuery[Widget]", "Widget", None] + """The type of a relative location of a node in the DOM.""" + + def _find_spot(self, spot: DOMSpot) -> tuple["DOMNode", int]: + """Collapse a number of DOM location identifiers into a parent/child-index pair. + + Args: + spot (DOMSpot): The spot to find. + + Returns: + tuple[DOMNode, int]: The parent and the location in its child list. + + Raises: + ValueError: If a given node can't be located amongst children. + + The rules of this method are: + + - Given ``None``, parent is ``self`` and location is ``-1``. + - Given an integer, parent is ``self`` and location is the integer value. + - Given a DOMNode, parent is the node's parent and location is + where the widget is found in the parent's ``children``. If it + can't be found a ``ValueError`` will be raised. + - Given a query result, the ``first`` node is used. The code then + falls to acting as if a DOMNode were given. + - Given a string, it is used to perform a query and then the result + is used as if a query result were given. + """ + + from .widget import Widget + + # None pretty much means "at the end of our child list." + if spot is None: + return cast(Widget, self), -1 + + # A numeric location means at that point in our child list. + if isinstance(spot, int): + return cast(Widget, self), spot + + # We've got a widget that has a parent; let's look for that in our children. + if isinstance(spot, DOMNode): + try: + return cast(DOMNode, spot.parent), spot.parent.children._index(spot) + except ValueError: + raise DOMError(f"{spot!r} is not a child of {self!r}") from None + + # Do we have a string? If we do, cast that into a query. + if isinstance(spot, str): + spot = self.query(spot) + + # At this point, we should have a query of some description. So + # let's now descend into it and see. + return spot.first(DOMNode).parent._find_spot(spot.first(DOMNode)) diff --git a/src/textual/widget.py b/src/textual/widget.py index 9d1f50bb2..d617c7b60 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -51,7 +51,7 @@ from .reactive import Reactive from .render import measure if TYPE_CHECKING: - from .app import App, ComposeResult + from .app import App, ComposeResult, MountSpot from .scrollbar import ( ScrollBar, ScrollBarCorner, @@ -375,16 +375,29 @@ class Widget(DOMNode): if self._scrollbar_corner is not None: yield self._scrollbar_corner - def mount(self, *widgets: Widget) -> AwaitMount: - """Mount child widgets (making this widget a container). + def mount( + self, *widgets: Widget, before: MountSpot = None, after: MountSpot = None + ) -> AwaitMount: + """Mount widgets below this widget (making this widget a container). Args: *widgets (Widget): The widget(s) to mount. + before (MountSpot, optional): Optional location to mount before. + after (MountSpot, optional): Optional location to mount after. Returns: AwaitMount: An awaitable object that waits for widgets to be mounted. + + Raises: + MountError: If there is a problem with the mount request. + + Note: + Only one of ``before`` or ``after`` can be provided. If both are + provided a ``MountError`` will be raised. """ - return AwaitMount(self.app._register(self, *widgets)) + return AwaitMount( + self.app._register(self, *widgets, before=before, after=after) + ) def compose(self) -> ComposeResult: """Called by Textual to create child widgets. diff --git a/tests/test_dom_spot.py b/tests/test_dom_spot.py new file mode 100644 index 000000000..620c9718d --- /dev/null +++ b/tests/test_dom_spot.py @@ -0,0 +1,22 @@ +from textual.widget import Widget + +class Content(Widget): + pass + +class Body(Widget): + pass + +def test_find_dom_spot(): + screen = Widget(name="Screen") + header = Widget(name="Header", id="header") + body = Body(id="body") + content = [ Content(id=f"item{n}") for n in range(1000)] + body._add_children(*content) + footer = Widget(name="Footer", id="footer") + screen._add_children(header, body, footer) + assert list(screen.children) == [header,body,footer] + assert screen._find_spot(None) == (screen,-1) + assert screen._find_spot(1) == (screen, 1) + assert screen._find_spot(body) == screen._find_spot(1) + assert screen._find_spot("Body") == screen._find_spot(body) + assert screen._find_spot("#body") == screen._find_spot(1)