-
Notifications
You must be signed in to change notification settings - Fork 780
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
Implements screen modes #2540
Changes from 7 commits
9f3f203
6e19772
4d28783
634789a
a058fe5
d65daf8
ad986b1
c64111b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -159,6 +159,38 @@ class ScreenStackError(ScreenError): | |
"""Raised when trying to manipulate the screen stack incorrectly.""" | ||
|
||
|
||
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 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 +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 | ||
|
@@ -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 | ||
|
@@ -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 | ||
|
@@ -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] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you have a property for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Did you mean to ask for a property for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is already a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How about a |
||
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 | ||
|
@@ -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. | ||
|
||
|
@@ -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 | ||
|
@@ -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 | ||
|
@@ -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 | ||
|
@@ -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: | ||
|
@@ -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" | ||
|
@@ -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(): | ||
|
@@ -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() | ||
|
||
|
@@ -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) | ||
|
||
|
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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...