-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Description
This issue is related to #5008 (comment) - problem is still valid - awaiting pop_screen freezes application. Given workaround solution by Will is not satisfying - it can cause other issues like race conditions. I am running out of solutions for this. Of course executing the same code by bindings works without any problems. Popping screen should work not matter what caused execution of code - reaction for binding in action method or button handle in @on(Button.Pressed).
Here is MRE which shows the problem:
from __future__ import annotations
from textual import on
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.screen import Screen
from textual.widgets import Button, Footer, Static
class InitialScreen(Screen):
def compose(self) -> ComposeResult:
yield Static("Initial screen")
yield Button("Push next screen")
@on(Button.Pressed)
def push_next_screen(self) -> None:
self.app.push_screen(NextScreen())
class NextScreen(Screen):
BINDINGS = [
Binding("d", "do_something", "Do something"),
]
def compose(self) -> ComposeResult:
yield Static("Next screen")
yield Button("Do something")
yield Footer()
async def action_do_something(self) -> None:
"""It's fine to call this from key bindings."""
await self._do_something()
@on(Button.Pressed)
async def do_something_from_button(self) -> None:
"""Will deadlock the app."""
await self._do_something()
# workaround solution which can cause another issues like race conditions
# self.app.run_worker(self._do_something())
async def _do_something(self) -> None:
await self.app.pop_screen()
self.notify("Something that requires the screen to be already popped aka. await screen replacement.")
class MyApp(App):
def on_mount(self) -> None:
self.push_screen(InitialScreen())
MyApp().run()A bit more complicated, but real example:
from __future__ import annotations
from textual import on
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.screen import ModalScreen, Screen
from textual.widgets import Button, Footer, Static
class ConfigScreen(Screen):
BINDINGS = [
Binding("escape", "app.pop_screen", "Go back"),
]
def __init__(self, action: str) -> None:
super().__init__()
self.action = action
def compose(self) -> ComposeResult:
yield Static(f"Config screen of {self.action}")
yield Footer()
class SelectConfigDialog(ModalScreen):
DEFAULT_CSS = """
SelectConfigDialog {
align: center middle;
background: $background 85%;
Vertical {
width: 50%;
height: 50%;
border: $primary outer;
background: $panel;
}
}
"""
BINDINGS = [
Binding("escape", "dismiss", "Go back"),
Binding("1", "push_config_screen('change-email')", "Change email"),
Binding("2", "push_config_screen('change-password')", "Change password"),
]
def compose(self) -> ComposeResult:
with Vertical():
yield Static("What do you want to do?")
yield Button("Change email (1)", id="change-email")
yield Button("Change password (2)", id="change-password")
yield Footer()
@on(Button.Pressed)
async def push_config_screen_from_button(self, event: Button.Pressed) -> None:
"""Should dismiss the dialog and push the appropriate config screen."""
# await self.dismiss() # Will cause ScreenError("Can't await screen.dismiss() from the screen's message handler; try removing the await keyword.")
# self.dismiss() # AFAIK calling without await is dangerous as it may dismiss ConfigScreen instead of SelectConfigDialog by looking at the `await_pop = self.app.pop_screen()` in dismiss method
await self.app.pop_screen() # Will cause the deadlock
self.app.push_screen(ConfigScreen(event.button.id))
async def _action_push_config_screen(self, action: str) -> None:
"""Should dismiss the dialog and push the appropriate config screen."""
await self.dismiss() # works just fine
self.app.push_screen(ConfigScreen(action))
class InitialScreen(Screen):
BINDINGS = [
Binding("c", "open_configuration_dialog", "Open configuration dialog"),
]
def compose(self) -> ComposeResult:
yield Static("Initial screen")
yield Static("Press 'c' to open the dialog.")
yield Footer()
def action_open_configuration_dialog(self) -> None:
self.app.push_screen(SelectConfigDialog())
class MyApp(App):
def on_mount(self) -> None:
self.push_screen(InitialScreen())
MyApp().run()In my application also faced similar problem while switching modes. I have dashboard (main) mode and config mode. Also got some global buttons in header which change modes if necessary - dashboard, config and cart (which is in dashboard mode, additionally pushes next screen). This time application freezes probably while resetting old mode (need to remove and add it to drop all screens from screen stack). I think this is connected with pop_screen problem. Both are screen management.
from __future__ import annotations
from typing import Literal
from textual import on
from textual.app import App, ComposeResult, ScreenError
from textual.await_complete import AwaitComplete
from textual.binding import Binding
from textual.containers import Horizontal
from textual.screen import Screen, ScreenResultType
from textual.widgets import Button, Footer, Static
class GlobalButtons(Horizontal):
"""In real scenario, this can be e.g in the header or hamburger menu.."""
DEFAULT_CSS = """
GlobalButtons {
align: right bottom;
margin-bottom: 1;
height: auto;
}
"""
def compose(self) -> ComposeResult:
yield Button("Dashboard", id="main")
yield Button("Config", id="config")
yield Button("Cart", id="cart")
yield Button("Account", id="account")
@on(Button.Pressed)
async def handle_navigation(self, event: Button.Pressed) -> None:
#await self.app.handle_navigation(event.button.id) # Will cause deadlock
self.app.run_worker(self.app.handle_navigation(event.button.id)) # This workaround can cause race when pressing buttons too fast e.g. ActiveModeError: Can't remove active mode 'main'
class CartScreen(Screen):
"""Cart should be pushed only in the main mode."""
BINDINGS = [
Binding("escape", "app.pop_screen", "Go back"),
]
def compose(self) -> ComposeResult:
yield GlobalButtons()
yield Static("Cart")
yield Footer()
class AccountScreen(Screen):
"""Account should be pushed only in the main mode."""
BINDINGS = [
Binding("escape", "app.pop_screen", "Go back"),
]
def compose(self) -> ComposeResult:
yield GlobalButtons()
yield Static("Account")
yield Footer()
class DashboardScreen(Screen):
def compose(self) -> ComposeResult:
yield GlobalButtons()
yield Static("Dashboard")
yield Footer()
class ConfigScreen(Screen):
def compose(self) -> ComposeResult:
yield GlobalButtons()
yield Static("Config screen")
yield Footer()
class MyApp(App):
BINDINGS = [
Binding("d", "handle_navigation('main')", "Dashboard"),
Binding("c", "handle_navigation('config')", "Config"),
Binding("t", "handle_navigation('cart')", "Cart"),
Binding("u", "handle_navigation('account')", "Account"),
]
"""Global bindings works just fine with no deadlock."""
MODES = {
"config": ConfigScreen,
"main": DashboardScreen,
}
def on_mount(self) -> None:
self.switch_mode("main")
async def action_handle_navigation(self, requested: Literal["cart", "account", "config", "main"]) -> None:
await self.handle_navigation(requested)
async def handle_navigation(self, requested: Literal["cart", "account", "config", "main"]) -> None:
main_push_transitions = {
("main", "cart"): CartScreen,
("main", "account"): AccountScreen,
}
config_switch_to_main_and_push_transitions = {
("config", "cart"): CartScreen,
("config", "account"): AccountScreen,
}
if isinstance(self.screen, CartScreen) and requested == "cart" or isinstance(self.screen, AccountScreen) and requested == "account":
# nothing to do
return
if self.current_mode == "main" and requested == "main":
self.get_screen_from_current_stack(DashboardScreen).pop_until_active()
return
if (key := (self.current_mode, requested)) in main_push_transitions:
await self.push_screen(main_push_transitions[key]())
return
if (key := (self.current_mode, requested)) in config_switch_to_main_and_push_transitions:
# Transitioning from config mode to main mode before pushing the screen
with self.app.batch_update():
await self.switch_mode_with_reset("main")
await self.push_screen(config_switch_to_main_and_push_transitions[key]())
return
if requested in {"main", "config"}:
await self.switch_mode_with_reset(requested)
return
raise AssertionError(f"Unexpected navigation request: {requested}")
def switch_mode_with_reset(self, new_mode: str) -> AwaitComplete:
"""
Switch mode and reset all other active modes.
The `App.switch_mode` method from Textual keeps the previous mode in the stack.
This method allows to switch to a new mode and have only a new mode in the stack without keeping
any screen stacks of other modes. The next switch mode will be like fresh switch mode.
Args:
----
new_mode: The mode to switch to.
"""
async def impl() -> None:
await self.switch_mode(new_mode)
modes_to_keep = (new_mode, "_default")
modes_to_reset = [mode for mode in self._screen_stacks if mode not in modes_to_keep]
assert all(mode in self.MODES for mode in modes_to_reset), "Unexpected mode in modes_to_reset"
await self.reset_mode(*modes_to_reset)
return AwaitComplete(impl()).call_next(self)
def reset_mode(self, *modes: str) -> AwaitComplete:
async def impl() -> None:
for mode in modes:
await self.remove_mode(mode)
self.add_mode(mode, self.MODES[mode])
return AwaitComplete(impl()).call_next(self)
def get_screen_from_current_stack(self, screen: type[Screen[ScreenResultType]]) -> Screen[ScreenResultType]:
for current_screen in self.screen_stack:
if isinstance(current_screen, screen):
return current_screen
raise ScreenError(f"Screen {screen} not found in stack")
MyApp().run()