From 9f3f2033b576f0e318d1564b98780586de6e0060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 10 May 2023 14:59:00 +0100 Subject: [PATCH 1/7] Add default mode. --- src/textual/app.py | 60 ++++++++++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 12f66cf04..b0e0b0811 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -159,6 +159,10 @@ class ScreenStackError(ScreenError): """Raised when attempting to pop the last screen from the stack.""" +class UnknownModeError(Exception): + """Raised when attempting to use a mode that is not known.""" + + class CssPathError(Exception): """Raised when supplied CSS path(s) are invalid.""" @@ -294,7 +298,10 @@ class App(Generic[ReturnType], DOMNode): self._workers = WorkerManager(self) self.error_console = Console(markup=False, stderr=True) self.driver_class = driver_class or self.get_driver_class() - self._screen_stack: list[Screen] = [] + self._screen_stacks: dict[str, list[Screen]] = {"_default": []} + """A stack of screens per mode.""" + self._current_mode: str = "_default" + """The current mode the app is in.""" self._sync_available = False self.mouse_over: Widget | None = None @@ -526,7 +533,7 @@ class App(Generic[ReturnType], DOMNode): Returns: A snapshot of the current state of the screen stack. """ - return self._screen_stack.copy() + return self._screen_stacks[self._current_mode].copy() def exit( self, result: ReturnType | None = None, message: RenderableType | None = None @@ -680,7 +687,9 @@ class App(Generic[ReturnType], DOMNode): ScreenStackError: If there are no screens on the stack. """ try: - return self._screen_stack[-1] + return self._screen_stacks[self._current_mode][-1] + except KeyError: + raise UnknownModeError(f"No known mode {self._current_mode!r}") from None except IndexError: raise ScreenStackError("No screens on stack") from None @@ -688,7 +697,7 @@ class App(Generic[ReturnType], DOMNode): def _background_screens(self) -> list[Screen]: """A list of screens that may be visible due to background opacity (top-most first, not including current screen).""" screens: list[Screen] = [] - for screen in reversed(self._screen_stack[:-1]): + for screen in reversed(self._screen_stacks[self._current_mode][:-1]): screens.append(screen) if screen.styles.background.a == 1: break @@ -1398,11 +1407,14 @@ class App(Generic[ReturnType], DOMNode): Returns: The screen that was replaced. """ - if self._screen_stack: + if self._screen_stacks[self._current_mode]: self.screen.refresh() screen.post_message(events.ScreenSuspend()) self.log.system(f"{screen} SUSPENDED") - if not self.is_screen_installed(screen) and screen not in self._screen_stack: + if ( + not self.is_screen_installed(screen) + and screen not in self._screen_stacks[self._current_mode] + ): screen.remove() self.log.system(f"{screen} REMOVED") return screen @@ -1426,14 +1438,14 @@ class App(Generic[ReturnType], DOMNode): f"push_screen requires a Screen instance or str; not {screen!r}" ) - if self._screen_stack: + if self._screen_stacks[self._current_mode]: self.screen.post_message(events.ScreenSuspend()) self.screen.refresh() next_screen, await_mount = self._get_screen(screen) next_screen._push_result_callback( - self.screen if self._screen_stack else None, callback + self.screen if self._screen_stacks[self._current_mode] else None, callback ) - self._screen_stack.append(next_screen) + self._screen_stacks[self._current_mode].append(next_screen) next_screen.post_message(events.ScreenResume()) self.log.system(f"{self.screen} is current (PUSHED)") return await_mount @@ -1449,10 +1461,12 @@ class App(Generic[ReturnType], DOMNode): f"switch_screen requires a Screen instance or str; not {screen!r}" ) if self.screen is not screen: - previous_screen = self._replace_screen(self._screen_stack.pop()) + previous_screen = self._replace_screen( + self._screen_stacks[self._current_mode].pop() + ) previous_screen._pop_result_callback() next_screen, await_mount = self._get_screen(screen) - self._screen_stack.append(next_screen) + self._screen_stacks[self._current_mode].append(next_screen) self.screen.post_message(events.ScreenResume()) self.log.system(f"{self.screen} is current (SWITCHED)") return await_mount @@ -1503,13 +1517,13 @@ class App(Generic[ReturnType], DOMNode): if screen not in self._installed_screens: return None uninstall_screen = self._installed_screens[screen] - if uninstall_screen in self._screen_stack: + if any(uninstall_screen in stack for stack in self._screen_stacks.values()): raise ScreenStackError("Can't uninstall screen in screen stack") del self._installed_screens[screen] self.log.system(f"{uninstall_screen} UNINSTALLED name={screen!r}") return screen else: - if screen in self._screen_stack: + if any(screen in stack for stack in self._screen_stacks.values()): raise ScreenStackError("Can't uninstall screen in screen stack") for name, installed_screen in self._installed_screens.items(): if installed_screen is screen: @@ -1524,7 +1538,7 @@ class App(Generic[ReturnType], DOMNode): Returns: The screen that was replaced. """ - screen_stack = self._screen_stack + screen_stack = self._screen_stacks[self._current_mode] if len(screen_stack) <= 1: raise ScreenStackError( "Can't pop screen; there must be at least one screen on the stack" @@ -1940,12 +1954,12 @@ class App(Generic[ReturnType], DOMNode): async def _close_all(self) -> None: """Close all message pumps.""" - # Close all screens on the stack. - for stack_screen in reversed(self._screen_stack): - if stack_screen._running: - await self._prune_node(stack_screen) - - self._screen_stack.clear() + # Close all screens on all stacks: + for stack in self._screen_stacks.values(): + for stack_screen in reversed(stack): + if stack_screen._running: + await self._prune_node(stack_screen) + stack.clear() # Close pre-defined screens. for screen in self.SCREENS.values(): @@ -1990,7 +2004,7 @@ class App(Generic[ReturnType], DOMNode): await self._message_queue.put(None) def refresh(self, *, repaint: bool = True, layout: bool = False) -> None: - if self._screen_stack: + if self._screen_stacks[self._current_mode]: self.screen.refresh(repaint=repaint, layout=layout) self.check_idle() @@ -2130,9 +2144,9 @@ class App(Generic[ReturnType], DOMNode): # Handle input events that haven't been forwarded # If the event has been forwarded it may have bubbled up back to the App if isinstance(event, events.Compose): - screen = Screen(id="_default") + screen = Screen(id=f"_default_{self._current_mode}") self._register(self, screen) - self._screen_stack.append(screen) + self._screen_stacks[self._current_mode].append(screen) await super().on_event(event) elif isinstance(event, events.InputEvent) and not event.is_forwarded: From 6e19772563d61caa5ef93ec156613e1a10b0e192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 10 May 2023 16:06:16 +0100 Subject: [PATCH 2/7] Add ability to switch between modes. --- src/textual/app.py | 71 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index b0e0b0811..0c83ee541 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -159,10 +159,18 @@ class ScreenStackError(ScreenError): """Raised when attempting to pop the last screen from the stack.""" -class UnknownModeError(Exception): +class ModeError(Exception): + """Base class for exceptions related to modes.""" + + +class UnknownModeError(ModeError): """Raised when attempting to use a mode that is not known.""" +class ActiveModeError(ModeError): + """Raised when attempting to remove the currently active mode.""" + + class CssPathError(Exception): """Raised when supplied CSS path(s) are invalid.""" @@ -216,6 +224,8 @@ class App(Generic[ReturnType], DOMNode): } """ + MODES: ClassVar[set[str]] = set() + """Modes associated with the app for the lifetime of the app.""" SCREENS: ClassVar[dict[str, Screen | Callable[[], Screen]]] = {} """Screens associated with the app for the lifetime of the app.""" _BASE_PATH: str | None = None @@ -1335,6 +1345,63 @@ class App(Generic[ReturnType], DOMNode): """ return self.mount(*widgets, before=before, after=after) + def _init_mode(self, mode: str) -> None: + """Do internal initialisation of a new screen stack mode.""" + + screen = Screen(id=f"_default_{mode}") + self._register(self, screen) + self._screen_stacks[mode] = [screen] + + def switch_mode(self, mode: str) -> None: + """Switch to a given mode. + + Args: + mode: The mode to switch to. + + Raises: + UnknownModeError: If trying to switch to an unknown mode. + """ + if mode not in self.MODES: + raise UnknownModeError(f"No known mode {mode!r}") + + self.screen.post_message(events.ScreenSuspend()) + self.screen.refresh() + + if mode not in self._screen_stacks: + self._init_mode(mode) + self._current_mode = mode + self.screen._screen_resized(self.size) + self.screen.post_message(events.ScreenResume()) + self.log.system(f"{self.screen} is active") + + def add_mode(self, mode: str) -> None: + """Adds a mode to the app. + + Args: + mode: The new mode. + """ + self.MODES.add(mode) + + def remove_mode(self, mode: str) -> None: + """Removes a mode from the app. + + Args: + mode: The mode to remove. It can't be the active mode. + + Raises: + ActiveModeError: If trying to remove the active mode. + UnknownModeError: If trying to remove an unknown mode. + """ + if mode == self._current_mode: + raise ActiveModeError(f"Can't remove active mode {mode!r}") + elif mode not in self.MODES: + raise UnknownModeError(f"Unknown mode {mode!r}") + + stack = self._screen_stacks.get(mode, []) + while stack: + self._replace_screen(stack.pop()) + self.MODES.remove(mode) + def is_screen_installed(self, screen: Screen | str) -> bool: """Check if a given screen has been installed. @@ -2144,7 +2211,7 @@ class App(Generic[ReturnType], DOMNode): # Handle input events that haven't been forwarded # If the event has been forwarded it may have bubbled up back to the App if isinstance(event, events.Compose): - screen = Screen(id=f"_default_{self._current_mode}") + screen = Screen(id=f"_default") self._register(self, screen) self._screen_stacks[self._current_mode].append(screen) await super().on_event(event) From 4d287837a25f09b0013933e92e4b1697279ac4e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 11 May 2023 16:42:49 +0100 Subject: [PATCH 3/7] Refactor screen stack modes. --- src/textual/app.py | 79 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 11 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 0c83ee541..8d162ef19 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -163,6 +163,10 @@ class ModeError(Exception): """Base class for exceptions related to modes.""" +class InvalidModeError(ModeError): + """Raised if there is an issue with a mode name.""" + + class UnknownModeError(ModeError): """Raised when attempting to use a mode that is not known.""" @@ -224,8 +228,35 @@ class App(Generic[ReturnType], DOMNode): } """ - MODES: ClassVar[set[str]] = set() - """Modes associated with the app for the lifetime of the app.""" + MODES: ClassVar[dict[str, str | Screen | Callable[[], Screen]]] = {} + """Modes associated with the app and their base screens. + + The base screen is the screen at the bottom of the mode stack. You can think of + it as the default screen for that stack. + The base screens can be names of screens listed in [SCREENS][textual.app.App.SCREENS], + [`Screen`][textual.screen.Screen] instances, or callables that return screens. + + Example: + ```py + class HelpScreen(Screen[None]): + ... + + class MainAppScreen(Screen[None]): + ... + + class MyApp(App[None]): + MODES = { + "default": "main", + "help": HelpScreen, + } + + SCREENS = { + "main": MainAppScreen, + } + + ... + ``` + """ SCREENS: ClassVar[dict[str, Screen | Callable[[], Screen]]] = {} """Screens associated with the app for the lifetime of the app.""" _BASE_PATH: str | None = None @@ -1348,8 +1379,14 @@ class App(Generic[ReturnType], DOMNode): def _init_mode(self, mode: str) -> None: """Do internal initialisation of a new screen stack mode.""" - screen = Screen(id=f"_default_{mode}") - self._register(self, screen) + stack = self._screen_stacks.get(mode, []) + if not stack: + _screen = self.MODES[mode] + if callable(_screen): + screen, _ = self._get_screen(_screen()) + else: + screen, _ = self._get_screen(self.MODES[mode]) + stack.append(screen) self._screen_stacks[mode] = [screen] def switch_mode(self, mode: str) -> None: @@ -1372,19 +1409,33 @@ class App(Generic[ReturnType], DOMNode): self._current_mode = mode self.screen._screen_resized(self.size) self.screen.post_message(events.ScreenResume()) + self.log.system(f"{self._current_mode!r} is the current mode") self.log.system(f"{self.screen} is active") - def add_mode(self, mode: str) -> None: - """Adds a mode to the app. + def add_mode( + self, mode: str, base_screen: str | Screen | Callable[[], Screen] + ) -> None: + """Adds a mode and its corresponding base screen to the app. Args: mode: The new mode. + base_screen: The base screen associated with the given mode. + + Raises: + InvalidModeError: If the name of the mode is not valid/duplicated. """ - self.MODES.add(mode) + if mode == "_default": + raise InvalidModeError("Cannot use '_default' as a custom mode.") + elif mode in self.MODES: + raise InvalidModeError(f"Duplicated mode name {mode!r}.") + + self.MODES[mode] = base_screen def remove_mode(self, mode: str) -> None: """Removes a mode from the app. + Screens that are running in the stack of that mode are scheduled for pruning. + Args: mode: The mode to remove. It can't be the active mode. @@ -1396,11 +1447,17 @@ class App(Generic[ReturnType], DOMNode): raise ActiveModeError(f"Can't remove active mode {mode!r}") elif mode not in self.MODES: raise UnknownModeError(f"Unknown mode {mode!r}") + else: + del self.MODES[mode] - stack = self._screen_stacks.get(mode, []) - while stack: - self._replace_screen(stack.pop()) - self.MODES.remove(mode) + if mode not in self._screen_stacks: + return + + stack = self._screen_stacks[mode] + for screen in reversed(stack): + if screen._running: + self.call_later(self._prune_node, screen) + del self._screen_stacks[mode] def is_screen_installed(self, screen: Screen | str) -> bool: """Check if a given screen has been installed. From 634789ae938d2eab4897832997b5e15d50724fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 16 May 2023 17:40:09 +0100 Subject: [PATCH 4/7] Add tests to screen modes. --- tests/test_screen_modes.py | 276 +++++++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 tests/test_screen_modes.py diff --git a/tests/test_screen_modes.py b/tests/test_screen_modes.py new file mode 100644 index 000000000..c345b2903 --- /dev/null +++ b/tests/test_screen_modes.py @@ -0,0 +1,276 @@ +from functools import partial +from itertools import cycle +from typing import Type + +import pytest + +from textual.app import ( + ActiveModeError, + App, + ComposeResult, + InvalidModeError, + UnknownModeError, +) +from textual.screen import ModalScreen, Screen +from textual.widgets import Footer, Header, Label, TextLog + +FRUITS = cycle("apple mango strawberry banana peach pear melon watermelon".split()) + + +class ScreenBindingsMixin(Screen[None]): + BINDINGS = [ + ("1", "one", "Mode 1"), + ("2", "two", "Mode 2"), + ("p", "push", "Push rnd scrn"), + ("o", "pop_screen", "Pop"), + ("r", "remove", "Remove mode 1"), + ] + + def action_one(self) -> None: + self.app.switch_mode("one") + + def action_two(self) -> None: + self.app.switch_mode("two") + + def action_fruits(self) -> None: + self.app.switch_mode("fruits") + + def action_push(self) -> None: + self.app.push_screen(FruitModal()) + + +class BaseScreen(ScreenBindingsMixin): + def __init__(self, label): + super().__init__() + self.label = label + + def compose(self) -> ComposeResult: + yield Header() + yield Label(self.label) + yield Footer() + + def action_remove(self) -> None: + self.app.remove_mode("one") + + +class FruitModal(ModalScreen[str], ScreenBindingsMixin): + BINDINGS = [("d", "dismiss_fruit", "Dismiss")] + + def compose(self) -> ComposeResult: + yield Label(next(FRUITS)) + + +class FruitsScreen(ScreenBindingsMixin): + def compose(self) -> ComposeResult: + yield TextLog() + + +@pytest.fixture +def ModesApp(): + class ModesApp(App[None]): + MODES = { + "one": lambda: BaseScreen("one"), + "two": "screen_two", + } + + SCREENS = { + "screen_two": lambda: BaseScreen("two"), + } + + def on_mount(self): + self.switch_mode("one") + + return ModesApp + + +async def test_mode_setup(ModesApp: Type[App]): + app = ModesApp() + async with app.run_test(): + assert isinstance(app.screen, BaseScreen) + assert str(app.screen.query_one(Label).renderable) == "one" + + +async def test_switch_mode(ModesApp: Type[App]): + app = ModesApp() + async with app.run_test() as pilot: + await pilot.press("2") + assert str(app.screen.query_one(Label).renderable) == "two" + await pilot.press("1") + assert str(app.screen.query_one(Label).renderable) == "one" + + +async def test_switch_same_mode(ModesApp: Type[App]): + app = ModesApp() + async with app.run_test() as pilot: + await pilot.press("1") + assert str(app.screen.query_one(Label).renderable) == "one" + await pilot.press("1") + assert str(app.screen.query_one(Label).renderable) == "one" + + +async def test_switch_unknown_mode(ModesApp: Type[App]): + app = ModesApp() + async with app.run_test(): + with pytest.raises(UnknownModeError): + app.switch_mode("unknown mode here") + + +async def test_remove_mode(ModesApp: Type[App]): + app = ModesApp() + async with app.run_test() as pilot: + app.switch_mode("two") + await pilot.pause() + assert str(app.screen.query_one(Label).renderable) == "two" + app.remove_mode("one") + assert "one" not in app.MODES + + +async def test_remove_active_mode(ModesApp: Type[App]): + app = ModesApp() + async with app.run_test(): + with pytest.raises(ActiveModeError): + app.remove_mode("one") + + +async def test_add_mode(ModesApp: Type[App]): + app = ModesApp() + async with app.run_test() as pilot: + app.add_mode("three", BaseScreen("three")) + app.switch_mode("three") + await pilot.pause() + assert str(app.screen.query_one(Label).renderable) == "three" + + +async def test_add_mode_duplicated(ModesApp: Type[App]): + app = ModesApp() + async with app.run_test(): + with pytest.raises(InvalidModeError): + app.add_mode("one", BaseScreen("one")) + + +async def test_screen_stack_preserved(ModesApp: Type[App]): + fruits = [] + N = 5 + + app = ModesApp() + async with app.run_test() as pilot: + # Build the stack up. + for _ in range(N): + await pilot.press("p") + fruits.append(str(app.query_one(Label).renderable)) + + assert len(app.screen_stack) == N + 1 + + # Switch out and back. + await pilot.press("2") + assert len(app.screen_stack) == 1 + await pilot.press("1") + + # Check the stack. + assert len(app.screen_stack) == N + 1 + for _ in range(N): + assert str(app.query_one(Label).renderable) == fruits.pop() + await pilot.press("o") + + +async def test_inactive_stack_is_alive(): + class FastCounter(Screen[None]): + def compose(self) -> ComposeResult: + self.lbl = Label("0") + yield self.lbl + + def on_mount(self) -> None: + self.set_interval(0.01, self.increment) + + def increment(self) -> None: + self.lbl.update(str(int(str(self.lbl.renderable)) + 1)) + + def key_s(self): + self.app.switch_mode("smile") + + class SmileScreen(Screen[None]): + def compose(self) -> ComposeResult: + yield Label(":)") + + def key_s(self): + self.app.switch_mode("fast") + + class ModesApp(App[None]): + MODES = { + "fast": FastCounter, + "smile": SmileScreen, + } + + def on_mount(self) -> None: + self.switch_mode("fast") + + app = ModesApp() + async with app.run_test() as pilot: + current = int(str(app.query_one(Label).renderable)) + await pilot.press("s") + assert str(app.query_one(Label).renderable) == ":)" + await pilot.press("s") + assert int(str(app.query_one(Label).renderable)) > current + + +async def test_multiple_mode_callbacks(): + written = [] + + class LogScreen(Screen[None]): + def __init__(self, value): + super().__init__() + self.value = value + + def key_p(self) -> None: + self.app.push_screen(ResultScreen(self.value), written.append) + + class ResultScreen(Screen[str]): + def __init__(self, value): + super().__init__() + self.value = value + + def key_p(self) -> None: + self.dismiss(self.value) + + def key_f(self) -> None: + self.app.switch_mode("first") + + def key_o(self) -> None: + self.app.switch_mode("other") + + class ModesApp(App[None]): + MODES = { + "first": lambda: LogScreen("first"), + "other": lambda: LogScreen("other"), + } + + def on_mount(self) -> None: + self.switch_mode("first") + + def key_f(self) -> None: + self.switch_mode("first") + + def key_o(self) -> None: + self.switch_mode("other") + + app = ModesApp() + async with app.run_test() as pilot: + # Push and dismiss ResultScreen("first") + await pilot.press("p") + await pilot.press("p") + assert written == ["first"] + + # Push ResultScreen("first") + await pilot.press("p") + # Switch to LogScreen("other") + await pilot.press("o") + # Push and dismiss ResultScreen("other") + await pilot.press("p") + await pilot.press("p") + assert written == ["first", "other"] + + # Go back to ResultScreen("first") + await pilot.press("f") + # Dismiss ResultScreen("first") + await pilot.press("p") + assert written == ["first", "other", "first"] From a058fe53eb76bb64ae16375b696828f53db11f01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 17 May 2023 11:15:56 +0100 Subject: [PATCH 5/7] Make test clearer. --- tests/test_screen_modes.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/test_screen_modes.py b/tests/test_screen_modes.py index c345b2903..6fd5c185d 100644 --- a/tests/test_screen_modes.py +++ b/tests/test_screen_modes.py @@ -174,16 +174,18 @@ async def test_screen_stack_preserved(ModesApp: Type[App]): async def test_inactive_stack_is_alive(): + """This tests that timers in screens outside the active stack keep going.""" + pings = [] + class FastCounter(Screen[None]): def compose(self) -> ComposeResult: - self.lbl = Label("0") - yield self.lbl + yield Label("fast") def on_mount(self) -> None: - self.set_interval(0.01, self.increment) + self.set_interval(0.01, self.ping) - def increment(self) -> None: - self.lbl.update(str(int(str(self.lbl.renderable)) + 1)) + def ping(self) -> None: + pings.append(str(self.app.query_one(Label).renderable)) def key_s(self): self.app.switch_mode("smile") @@ -206,11 +208,10 @@ async def test_inactive_stack_is_alive(): app = ModesApp() async with app.run_test() as pilot: - current = int(str(app.query_one(Label).renderable)) await pilot.press("s") assert str(app.query_one(Label).renderable) == ":)" await pilot.press("s") - assert int(str(app.query_one(Label).renderable)) > current + assert ":)" in pings async def test_multiple_mode_callbacks(): From d65daf81c04350712ad5fc737c1103bd6f849717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 18 May 2023 15:07:52 +0100 Subject: [PATCH 6/7] Address review comments. --- src/textual/app.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 8d162ef19..81ddee40b 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1454,10 +1454,9 @@ class App(Generic[ReturnType], DOMNode): return stack = self._screen_stacks[mode] - for screen in reversed(stack): - if screen._running: - self.call_later(self._prune_node, screen) del self._screen_stacks[mode] + for screen in reversed(stack): + self._replace_screen(screen) def is_screen_installed(self, screen: Screen | str) -> bool: """Check if a given screen has been installed. @@ -1535,9 +1534,8 @@ class App(Generic[ReturnType], DOMNode): self.screen.refresh() screen.post_message(events.ScreenSuspend()) self.log.system(f"{screen} SUSPENDED") - if ( - not self.is_screen_installed(screen) - and screen not in self._screen_stacks[self._current_mode] + if not self.is_screen_installed(screen) and all( + screen not in stack for stack in self._screen_stacks.values() ): screen.remove() self.log.system(f"{screen} REMOVED") From c64111bcb585b8c40eabf376be130fdeca984cdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Mon, 22 May 2023 14:21:11 +0100 Subject: [PATCH 7/7] Add property alias. Related comment: https://github.com/Textualize/textual/pull/2540\#discussion_r1196634789 --- src/textual/app.py | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 5ba8089ac..6a44941f9 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -592,6 +592,18 @@ class App(Generic[ReturnType], DOMNode): """ return self._screen_stacks[self._current_mode].copy() + @property + def _screen_stack(self) -> list[Screen]: + """A reference to the current screen stack. + + Note: + Consider using [`screen_stack`][textual.app.App.screen_stack] instead. + + Returns: + A reference to the current screen stack. + """ + return self._screen_stacks[self._current_mode] + def exit( self, result: ReturnType | None = None, message: RenderableType | None = None ) -> None: @@ -737,7 +749,7 @@ class App(Generic[ReturnType], DOMNode): ScreenStackError: If there are no screens on the stack. """ try: - return self._screen_stacks[self._current_mode][-1] + return self._screen_stack[-1] except KeyError: raise UnknownModeError(f"No known mode {self._current_mode!r}") from None except IndexError: @@ -747,7 +759,7 @@ class App(Generic[ReturnType], DOMNode): def _background_screens(self) -> list[Screen]: """A list of screens that may be visible due to background opacity (top-most first, not including current screen).""" screens: list[Screen] = [] - for screen in reversed(self._screen_stacks[self._current_mode][:-1]): + for screen in reversed(self._screen_stack[:-1]): screens.append(screen) if screen.styles.background.a == 1: break @@ -1539,7 +1551,7 @@ class App(Generic[ReturnType], DOMNode): Returns: The screen that was replaced. """ - if self._screen_stacks[self._current_mode]: + if self._screen_stack: self.screen.refresh() screen.post_message(events.ScreenSuspend()) self.log.system(f"{screen} SUSPENDED") @@ -1569,14 +1581,14 @@ class App(Generic[ReturnType], DOMNode): f"push_screen requires a Screen instance or str; not {screen!r}" ) - if self._screen_stacks[self._current_mode]: + if self._screen_stack: self.screen.post_message(events.ScreenSuspend()) self.screen.refresh() next_screen, await_mount = self._get_screen(screen) next_screen._push_result_callback( - self.screen if self._screen_stacks[self._current_mode] else None, callback + self.screen if self._screen_stack else None, callback ) - self._screen_stacks[self._current_mode].append(next_screen) + self._screen_stack.append(next_screen) next_screen.post_message(events.ScreenResume()) self.log.system(f"{self.screen} is current (PUSHED)") return await_mount @@ -1592,12 +1604,10 @@ class App(Generic[ReturnType], DOMNode): f"switch_screen requires a Screen instance or str; not {screen!r}" ) if self.screen is not screen: - previous_screen = self._replace_screen( - self._screen_stacks[self._current_mode].pop() - ) + previous_screen = self._replace_screen(self._screen_stack.pop()) previous_screen._pop_result_callback() next_screen, await_mount = self._get_screen(screen) - self._screen_stacks[self._current_mode].append(next_screen) + self._screen_stack.append(next_screen) self.screen.post_message(events.ScreenResume()) self.log.system(f"{self.screen} is current (SWITCHED)") return await_mount @@ -1669,7 +1679,7 @@ class App(Generic[ReturnType], DOMNode): Returns: The screen that was replaced. """ - screen_stack = self._screen_stacks[self._current_mode] + 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" @@ -2149,7 +2159,7 @@ class App(Generic[ReturnType], DOMNode): await self._message_queue.put(None) def refresh(self, *, repaint: bool = True, layout: bool = False) -> None: - if self._screen_stacks[self._current_mode]: + if self._screen_stack: self.screen.refresh(repaint=repaint, layout=layout) self.check_idle() @@ -2291,7 +2301,7 @@ class App(Generic[ReturnType], DOMNode): if isinstance(event, events.Compose): screen = Screen(id=f"_default") self._register(self, screen) - self._screen_stacks[self._current_mode].append(screen) + self._screen_stack.append(screen) screen.post_message(events.ScreenResume()) await super().on_event(event)