From 97a9619d5904503e9f522717da096556f0a785ef Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 17 Aug 2022 08:46:06 +0100 Subject: [PATCH] revised screens api --- poetry.lock | 11 ++- pyproject.toml | 1 + sandbox/will/screens.py | 16 ++--- src/textual/app.py | 119 ++++++++++++++++++++++++--------- src/textual/screen.py | 1 + src/textual/widgets/_pretty.py | 2 +- 6 files changed, 107 insertions(+), 43 deletions(-) diff --git a/poetry.lock b/poetry.lock index bb51b8028..0e2803c6b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -444,6 +444,14 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "nanoid" +version = "2.0.0" +description = "A tiny, secure, URL-friendly, unique string ID generator for Python" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "nodeenv" version = "1.7.0" @@ -780,7 +788,7 @@ dev = ["aiohttp", "click", "msgpack"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "2d0f99d7fb563eb0b34cda9542ecf87c35cf5944a67510625969ec7b046b6d03" +content-hash = "61db56567f708cd9ca1c27f0e4a4b4aa3dd808fc8411f80967a90995d7fdd8c8" [metadata.files] aiohttp = [ @@ -1275,6 +1283,7 @@ mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] +nanoid = [] nodeenv = [ {file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"}, {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"}, diff --git a/pyproject.toml b/pyproject.toml index df35c59e5..71eb976e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ typing-extensions = { version = "^4.0.0", python = "<3.8" } aiohttp = { version = "^3.8.1", optional = true } click = {version = "8.1.2", optional = true} msgpack = { version = "^1.0.3", optional = true } +nanoid = "^2.0.0" [tool.poetry.extras] dev = ["aiohttp", "click", "msgpack"] diff --git a/sandbox/will/screens.py b/sandbox/will/screens.py index 750035abb..c3f655fee 100644 --- a/sandbox/will/screens.py +++ b/sandbox/will/screens.py @@ -17,7 +17,7 @@ class NewScreen(Screen): yield Footer() def on_screen_resume(self): - self.query("*").refresh() + self.query_one(Pretty).update(self.app.screen_stack) class ScreenApp(App): @@ -41,17 +41,16 @@ class ScreenApp(App): } """ - SCREENS = { - "1": NewScreen("screen 1"), - "2": NewScreen("screen 2"), - "3": NewScreen("screen 3"), - } - def compose(self) -> ComposeResult: yield Static("On Screen 1") yield Footer() def on_mount(self) -> None: + + self.install_screen(NewScreen("Screen1"), name="1") + self.install_screen(NewScreen("Screen2"), name="2") + self.install_screen(NewScreen("Screen3"), name="3") + self.bind("1", "switch_screen('1')", description="Screen 1") self.bind("2", "switch_screen('2')", description="Screen 2") self.bind("3", "switch_screen('3')", description="Screen 3") @@ -63,4 +62,5 @@ class ScreenApp(App): app = ScreenApp() -app.run() +if __name__ == "__main__": + app.run() diff --git a/src/textual/app.py b/src/textual/app.py index d5ef2331d..19a5bf7dd 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -21,7 +21,7 @@ from typing import ( TypeVar, TYPE_CHECKING, ) -from weakref import WeakSet +from weakref import WeakSet, WeakValueDictionary from ._ansi_sequences import SYNC_START, SYNC_END @@ -30,6 +30,7 @@ if sys.version_info >= (3, 8): else: from typing_extensions import Literal # pragma: no cover +import nanoid import rich import rich.repr from rich.console import Console, RenderableType @@ -113,7 +114,11 @@ class ActionError(Exception): pass -class ScreenStackError(Exception): +class ScreenError(Exception): + pass + + +class ScreenStackError(ScreenError): pass @@ -216,6 +221,10 @@ class App(Generic[ReturnType], DOMNode): self._registry: WeakSet[DOMNode] = WeakSet() + self._installed_screens: WeakValueDictionary[ + str, Screen + ] = WeakValueDictionary() + self.devtools = DevtoolsClient() self._return_value: ReturnType | None = None @@ -614,7 +623,14 @@ class App(Generic[ReturnType], DOMNode): for widget in widgets: self._register(self.screen, widget) - def _get_screen(self, screen: Screen | str) -> Screen: + def is_screen_installed(self, screen: Screen | str) -> bool: + """Check if a given screen has been installed.""" + if isinstance(screen, str): + return screen in self._installed_screens + else: + return screen in self._installed_screens.values() + + def get_screen(self, screen: Screen | str) -> Screen: """Get a screen and ensure it is registered. Args: @@ -628,41 +644,30 @@ class App(Generic[ReturnType], DOMNode): """ if isinstance(screen, str): try: - next_screen = self.SCREENS[screen] + next_screen = self._installed_screens[screen] except KeyError: - raise KeyError( - "No screen called {screen!r} found in {self.__class__}.SCREENS" - ) from None + raise KeyError("No screen called {screen!r} installed") from None else: next_screen = screen if not next_screen.is_running: self._register(self, next_screen) return next_screen - def _replace_screen(self, screen: Screen, remove: bool | None = None) -> Screen: + def _replace_screen(self, screen: Screen) -> Screen: """Handle the replaced screen. Args: screen (Screen): A screen object. - remove (bool | None): Remove replaced screen if True. Don't remove if False. - If None, remove screens not in self.SCREENS. Returns: - Screen: The replaced screen + Screen: The screen that was replaced. + """ screen.post_message_no_wait(events.ScreenSuspend(self)) - if remove is None: - if screen not in self.SCREENS.values(): - screen.remove() - else: - screen.detach() - else: - if remove: - if screen in self.SCREENS.values(): - raise ScreenStackError("Can't remove screen set in App.SCREENS") - screen.remove() - else: - screen.detach() + self.log(f"{screen} SUSPENDED") + if not self.is_screen_installed(screen) and screen not in self._screen_stack: + screen.remove() + self.log(f"{screen} REMOVED") return screen def push_screen(self, screen: Screen | str) -> None: @@ -672,43 +677,91 @@ class App(Generic[ReturnType], DOMNode): screen (Screen | str): A Screen instance or an id. """ - next_screen = self._get_screen(screen) + next_screen = self.get_screen(screen) self._screen_stack.append(next_screen) self.screen.post_message_no_wait(events.ScreenResume(self)) + self.log(f"{self.screen} is current (PUSHED)") - def switch_screen(self, screen: Screen | str, remove: bool | None) -> Screen: + def switch_screen(self, screen: Screen | str) -> Screen: """Switch to a another screen. Args: screen (Screen | str): A screen instance or a named of a screen. - remove (bool | None): Remove replaced screen if True. Don't remove if False. - If None, remove screens not in self.SCREENS. Returns: Screen: The previous screen object. """ - next_screen = self._get_screen(screen) - previous_screen = self._replace_screen(self._screen_stack.pop(), remove=remove) + previous_screen = self._replace_screen(self._screen_stack.pop()) + next_screen = self.get_screen(screen) self._screen_stack.append(next_screen) self.screen.post_message_no_wait(events.ScreenResume(self)) + self.log(f"{self.screen} is current (SWITCHED)") return previous_screen - def pop_screen(self, remove: bool | None = None) -> Screen: + def install_screen(self, screen: Screen, name: str | None = None) -> str: + """Install a screen. + + Args: + screen (Screen): Screen to install. + name (str | None, optional): Unique name of screen or None to auto-generate. + Defaults to None. + + Raises: + ScreenError: If the screen can't be installed. + + Returns: + str: The name of the screen + """ + if name is None: + name = nanoid.generate() + if name in self._installed_screens: + raise ScreenError(f"Can't install screen; {name!r} is already registered") + if screen in self._installed_screens.values(): + raise ScreenError( + "Can't install screen; {screen!r} has already been installed" + ) + self._installed_screens[name] = screen + self.get_screen(name) # Ensures screen is running + self.log(f"{screen} INSTALLED name={name!r}") + return name + + def uninstall_screen(self, screen: Screen | str) -> str | None: + """Uninstall a screen. If the screen was not previously installed then this + method is a null-op. + + Args: + screen (Screen | str): The screen to uninstall or the name of a installed screen. + + Returns: + str | None: The name of the screen that was uninstalled, or None if no screen was uninstalled. + """ + if isinstance(screen, str): + uninstalled_screen = self._installed_screens.pop(screen) + self.log(f"{uninstalled_screen} UNINSTALLED name={screen!r}") + return screen + else: + for name, installed_screen in self._installed_screens.items(): + if installed_screen is screen: + self._installed_screens.pop(name) + self.log(f"{screen} UNINSTALLED name={name!r}") + return name + return None + + def pop_screen(self) -> Screen: """Pop the current screen from the stack, and switch to the previous screen. Returns: Screen: The screen that was replaced. - remove (bool | None): Remove replaced screen if True. Don't remove if False. - If None, remove screens not in self.SCREENS. """ screen_stack = self._screen_stack if len(screen_stack) <= 1: raise ScreenStackError( "Can't pop screen; there must be at least one screen on the stack" ) - previous_screen = self._replace_screen(screen_stack.pop(), remove) + previous_screen = self._replace_screen(screen_stack.pop()) self.screen._screen_resized(self.size) self.screen.post_message_no_wait(events.ScreenResume(self)) + self.log(f"{self.screen} is active") return previous_screen def set_focus(self, widget: Widget | None) -> None: diff --git a/src/textual/screen.py b/src/textual/screen.py index b427b9e89..d81297ca9 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -232,6 +232,7 @@ class Screen(Widget): def _on_screen_resume(self) -> None: """Called by the App""" + size = self.app.size self._refresh_layout(size, full=True) diff --git a/src/textual/widgets/_pretty.py b/src/textual/widgets/_pretty.py index 44b64ff0b..3d4369e52 100644 --- a/src/textual/widgets/_pretty.py +++ b/src/textual/widgets/_pretty.py @@ -33,4 +33,4 @@ class Pretty(Widget): def update(self, object: Any) -> None: self._renderable = PrettyRenderable(object) - self.refresh() + self.refresh(layout=True)