Repository: Textualize/textual
Pull request: Implements screen modes
As it stands, the modes "work" but the API feels a bit rough.
Discussion in the related issue: #2327


The app below lets you play with modes.
Press <kbd>1</kbd> and <kbd>2</kbd> to switch between modes 1 and 2.
Press <kbd>P</kbd> to push random fruit modals into the screen.
Press <kbd>O</kbd> to pop a screen from the stack.

<details>
<summary>Demo code.</summary>

```py
from random import choice

from textual.app import App, ComposeResult
from textual.screen import Screen, ModalScreen
from textual.widgets import Footer, Header, Label


FRUITS = "apple mango strawberry banana peach pear melon watermelon".split()


class BaseScreen(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 __init__(self, label):
        super().__init__()
        self.label = label

    def compose(self) -> ComposeResult:
        yield Header()
        yield Label(self.label)
        yield Footer()

    def action_one(self) -> None:
        self.app.switch_mode("one")

    def action_two(self) -> None:
        self.app.switch_mode("two")

    def action_push(self) -> None:
        self.app.push_screen(FruitModal())

    def action_remove(self) -> None:
        self.app.remove_mode("one")


class FruitModal(ModalScreen[None]):
    BINDINGS = [
        ("1", "one", "Mode 1"),
        ("2", "two", "Mode 2"),
        ("p", "push", "Push rnd scrn"),
        ("o", "pop_screen", "Pop"),
    ]

    def compose(self) -> ComposeResult:
        yield Label(choice(FRUITS))

    def action_one(self) -> None:
        self.app.switch_mode("one")

    def action_two(self) -> None:
        self.app.switch_mode("two")

    def action_push(self) -> None:
        self.app.push_screen(FruitModal())


class ModesApp(App[None]):
    MODES = ["one", "two"]

    def on_mount(self) -> None:
        self.switch_mode("two")
        self.push_screen(BaseScreen("two"))
        self.switch_mode("one")
        self.push_screen(BaseScreen("one"))


app = ModesApp()
if __name__ == "__main__":
    app.run()
```

</details>

This will close #2327 

Problem statement:
Implement mode concept
The `switch_screen` method doesn't fit with the stack of screens concept.

It was introduced to provide switchable modes in an app, but doesn't quite capture that. I think what we need is a concept of "modes" where each mode has it's own independant stack of screens. The app can switch between these modes as required.

Suggest we have a `MODES` class var similar to screens, which should be a sequence of modes.

Something like this:

```python
MODES = ["calculator", "help", "error", "sign-up"]
```

Each of those modes has an associated stack of screens.

App would have a `switch_mode` method which changes to the given mode. Modes could also be created dynamically, with `add_mode` and `remove_mode`.

I'm hoping this could be implemented without much impact on the rest of the code. But there may be edge cases.

If you tackle this, please check with @willmcgugan

Hints from issue discussion:
#2540 adds a possible implementation that follows your instructions to the letter.
However, I suspect you might be thinking of things that aren't clear from the description alone.

 - For example, is the point of `add_mode` _just_ to add a new mode to the set of modes available?
 - Does each custom mode get a default screen at the base of the stack?
 - Should the user be able to set up a mode with a _custom_ default screen? As it stands, when you switch to a mode for the first time, it shows a default empty screen.

The app below lets you play with modes.
Press <kbd>1</kbd> and <kbd>2</kbd> to switch between modes 1 and 2.
Press <kbd>P</kbd> to push random fruit modals into the screen.
Press the letter <kbd>O</kbd> to pop a screen from the stack.

<details>
<summary>Demo code.</summary>

```py
from random import choice

from textual.app import App, ComposeResult
from textual.screen import Screen, ModalScreen
from textual.widgets import Footer, Header, Label


FRUITS = "apple mango strawberry banana peach pear melon watermelon".split()


class BaseScreen(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 __init__(self, label):
        super().__init__()
        self.label = label

    def compose(self) -> ComposeResult:
        yield Header()
        yield Label(self.label)
        yield Footer()

    def action_one(self) -> None:
        self.app.switch_mode("one")

    def action_two(self) -> None:
        self.app.switch_mode("two")

    def action_push(self) -> None:
        self.app.push_screen(FruitModal())

    def action_remove(self) -> None:
        self.app.remove_mode("one")


class FruitModal(ModalScreen[None]):
    BINDINGS = [
        ("1", "one", "Mode 1"),
        ("2", "two", "Mode 2"),
        ("p", "push", "Push rnd scrn"),
        ("o", "pop_screen", "Pop"),
    ]

    def compose(self) -> ComposeResult:
        yield Label(choice(FRUITS))

    def action_one(self) -> None:
        self.app.switch_mode("one")

    def action_two(self) -> None:
        self.app.switch_mode("two")

    def action_push(self) -> None:
        self.app.push_screen(FruitModal())


class ModesApp(App[None]):
    MODES = ["one", "two"]

    def on_mount(self) -> None:
        self.switch_mode("two")
        self.push_screen(BaseScreen("two"))
        self.switch_mode("one")
        self.push_screen(BaseScreen("one"))


app = ModesApp()
if __name__ == "__main__":
    app.run()
```

</details>
Don't think of this as interpreting what Will wants. I'd like you to design the API.

You've identified problems with the proposed API, which is great. But I'd like you to propose and implement solutions in the spirit of the rest of Textual. So simple, declarative, with good error handling. 

It will likely be an iterative process. It will take a few back and forth attempts before it is finalized.
@willmcgugan understood.

I like the new version of the API better:

 1. user specifies modes and base screens for each mode (the screen at the base of the stack);
 2. `add_mode` takes a mode name and a base screen for that mode;
 3. `remove_mode` and `switch_mode` stay the same.

```python
class HelpScreen(Screen[None]):
    ...

class MainAppScreen(Screen[None]):
    ...

class MyApp(App[None]):
    MODES = {
        "default": "main",
        "help": HelpScreen,
    }

    SCREENS = {
        "main": MainAppScreen,
    }

    ...
```

<details>
<summary>Previous app rewritten with this API.</summary>

```py
from random import choice

from textual.app import App, ComposeResult
from textual.screen import Screen, ModalScreen
from textual.widgets import Footer, Header, Label


FRUITS = "apple mango strawberry banana peach pear melon watermelon".split()


class BaseScreen(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 __init__(self, label):
        super().__init__()
        self.label = label

    def compose(self) -> ComposeResult:
        yield Header()
        yield Label(self.label)
        yield Footer()

    def action_one(self) -> None:
        self.app.switch_mode("one")

    def action_two(self) -> None:
        self.app.switch_mode("two")

    def action_push(self) -> None:
        self.app.push_screen(FruitModal())

    def action_remove(self) -> None:
        self.app.remove_mode("one")


class FruitModal(ModalScreen[None]):
    BINDINGS = [
        ("1", "one", "Mode 1"),
        ("2", "two", "Mode 2"),
        ("p", "push", "Push rnd scrn"),
        ("o", "pop_screen", "Pop"),
    ]

    def compose(self) -> ComposeResult:
        yield Label(choice(FRUITS))

    def action_one(self) -> None:
        self.app.switch_mode("one")

    def action_two(self) -> None:
        self.app.switch_mode("two")

    def action_push(self) -> None:
        self.app.push_screen(FruitModal())


class ModesApp(App[None]):
    MODES = {
        "one": lambda: BaseScreen("one"),
        "two": lambda: BaseScreen("two"),
    }

    def on_mount(self) -> None:
        self.switch_mode("one")


app = ModesApp()
if __name__ == "__main__":
    app.run()
```

</details>
Liking the look of this!  Will dig in to it when I’m back.
@rodrigogiraoserrao  Yes, I do like this API. Make it so.

Commit message: Add tests to screen modes.

Patch to review:
diff --git a/src/textual/app.py b/src/textual/app.py
index 12f66cf04f..8d162ef19e 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -159,6 +159,22 @@ class ScreenStackError(ScreenError):
     """Raised when attempting to pop the last screen from the stack."""
 
 
+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."""
+
+
+class ActiveModeError(ModeError):
+    """Raised when attempting to remove the currently active mode."""
+
+
 class CssPathError(Exception):
     """Raised when supplied CSS path(s) are invalid."""
 
@@ -212,6 +228,35 @@ class App(Generic[ReturnType], DOMNode):
     }
     """
 
+    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
@@ -294,7 +339,10 @@ def __init__(
         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 +574,7 @@ def screen_stack(self) -> Sequence[Screen]:
         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 +728,9 @@ def screen(self) -> Screen:
             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 +738,7 @@ def screen(self) -> Screen:
     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
@@ -1326,6 +1376,89 @@ def mount_all(
         """
         return self.mount(*widgets, before=before, after=after)
 
+    def _init_mode(self, mode: str) -> None:
+        """Do internal initialisation of a new screen stack mode."""
+
+        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:
+        """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._current_mode!r} is the current mode")
+        self.log.system(f"{self.screen} is active")
+
+    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.
+        """
+        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.
+
+        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}")
+        else:
+            del self.MODES[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.
 
@@ -1398,11 +1531,14 @@ def _replace_screen(self, screen: Screen) -> Screen:
         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 +1562,14 @@ def push_screen(
                 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 +1585,12 @@ def switch_screen(self, screen: Screen | str) -> AwaitMount:
                 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 +1641,13 @@ def uninstall_screen(self, screen: Screen | str) -> str | None:
             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 +1662,7 @@ def pop_screen(self) -> Screen:
         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 +2078,12 @@ def is_mounted(self, widget: Widget) -> bool:
     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 +2128,7 @@ async def _on_exit_app(self) -> None:
         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 +2268,9 @@ async def on_event(self, event: events.Event) -> None:
         # 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._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:
diff --git a/tests/test_screen_modes.py b/tests/test_screen_modes.py
new file mode 100644
index 0000000000..c345b29032
--- /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"]


Code review comments:
diff_hunk: @@ -1398,11 +1531,14 @@ def _replace_screen(self, screen: Screen) -> Screen:
         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]
path: src/textual/app.py
text: @author:
Should I check if the screen is in any of the stacks?
I'm not sure how this "installation" state works.

@user1:
I think you should check that it is not in any stack. You don't want to remove it, if it is in use.

@author:
Right, that makes sense.