Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implements screen modes #2540

Merged
merged 8 commits into from
May 22, 2023
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 175 additions & 23 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,38 @@ class ScreenStackError(ScreenError):
"""Raised when trying to manipulate the screen stack incorrectly."""


class ModeError(Exception):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just noticed while looking in app.py at something else (a week or so after this PR was merged), all of the ModeError classes seem to have been duplicated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #2715.
What a weird thing, though...

"""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 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."""

Expand Down Expand Up @@ -212,6 +244,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
Expand Down Expand Up @@ -294,7 +355,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
Expand Down Expand Up @@ -526,7 +590,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
Expand Down Expand Up @@ -673,15 +737,17 @@ 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]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you have a property for self._screen_stacks[self._current_mode][-1] ? Seems to pop up in a few places. It could be name _screen_stack ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you mean to ask for a property for self._screen_stacks[self._current_mode]?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is already a screen_stack property that returns a copy of the current stack but in some places I want the actual stack.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about a _screen_stack property then?

except KeyError:
raise UnknownModeError(f"No known mode {self._current_mode!r}") from None
except IndexError:
raise ScreenStackError("No screens on stack") from None

@property
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
Expand Down Expand Up @@ -1319,6 +1385,88 @@ 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])
rodrigogiraoserrao marked this conversation as resolved.
Show resolved Hide resolved
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]
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.

Expand Down Expand Up @@ -1391,11 +1539,13 @@ 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 all(
screen not in stack for stack in self._screen_stacks.values()
):
screen.remove()
self.log.system(f"{screen} REMOVED")
return screen
Expand All @@ -1419,14 +1569,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
Expand All @@ -1442,10 +1592,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
Expand Down Expand Up @@ -1496,13 +1648,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:
Expand All @@ -1517,7 +1669,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"
Expand Down Expand Up @@ -1947,12 +2099,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():
Expand Down Expand Up @@ -1997,7 +2149,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()

Expand Down Expand Up @@ -2137,9 +2289,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)
screen.post_message(events.ScreenResume())
await super().on_event(event)

Expand Down
Loading