Textualize / textual

The lean application framework for Python. Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal and a web browser.
https://textual.textualize.io/
MIT License
25.58k stars 787 forks source link

InvalidStateError when dismissing Screen #4884

Closed gsportelli closed 2 months ago

gsportelli commented 2 months ago

Please give a brief but clear explanation of the issue. If you can, include a complete working example that demonstrates the bug. Check it can run without modifications.

I'm creating a dialog box as follows:

class InfoScreen(Screen[bool]):
    """Screen with a parameter."""
    BINDINGS = [("escape", "app.pop_screen", "Pop screen")]

    def __init__(self, question: str) -> None:
        self.question = question
        super().__init__()

    def compose(self) -> ComposeResult:
        yield Vertical(
            Label(self.question, id="info-label"),
            Button("Ok", variant="primary", id="ok"),
            id="info-vertical"
        )

    @on(Button.Pressed, "#ok")
    def handle_ok(self) -> None:
        self.dismiss('ok')

Then I call the dialog as follows:

    @work(exclusive=True)
    async def action_info(self) -> None:
        await self.push_screen_wait(InfoScreen(f"This is an info screen"))

When I do so, the dialog opens and I can click on the button to dismiss it. However, onece in a while it exits abruptly with an IvalidStateError

╭──────────────────────────────────────────────────────────────────────────────────── Traceback (most recent call last) ─────────────────────────────────────────────────────────────────────────────────────╮
│ /home/pi/work/app-textual-ui/trui.py:100 in handle_ok                                                                                                                                             │
│                                                                                                                                                                                                            │
│    97 │                                                                                        ╭────── locals ───────╮                                                                                     │
│    98 │   @on(Button.Pressed, "#ok")                                                           │ self = InfoScreen() │                                                                                     │
│    99 │   def handle_ok(self) -> None:                                                         ╰─────────────────────╯                                                                                     │
│ ❱ 100 │   │   self.dismiss('ok')                                                                                                                                                                           │
│   101                                                                                                                                                                                                      │
│   102 class YesNoScreen(Screen[bool]):                                                                                                                                                                     │
│   103 │   """Screen with a parameter."""                                                                                                                                                                   │
│                                                                                                                                                                                                            │
│ /home/pi/.pyenv/py3/lib/python3.9/site-packages/textual/screen.py:110 in __call__                                                                                                                          │
│                                                                                                                                                                                                            │
│    107 │   │   │   If the requested or the callback are `None` this will be a no-op.                                                                                                                       │
│    108 │   │   """                                                                                                                                                                                         ││    109 │   │   if self.future is not None:                                                                                                                                                                 │
│ ❱  110 │   │   │   self.future.set_result(result)                                                                                                                                                          │
│    111 │   │   if self.requester is not None and self.callback is not None:                                                                                                                                │
│    112 │   │   │   self.requester.call_next(self.callback, result)                                                                                                                                         │
│    113 │   │   self.callback = None                                                                                                                                                                        │
│                                                                                                                                                                                                            │
│ ╭────────────────────────────────────────────────────────────────── locals ───────────────────────────────────────────────────────────────────╮                                                            │
│ │ result = 'ok'                                                                                                                               │                                                            │
│ │   self = ResultCallback(AppRemoteUI(title='AppRemoteUI', classes={'app', '-dark-mode'}), None, future=<Future cancelled>) │                                                            │
│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯                                                            │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
InvalidStateError: invalid state

It will be helpful if you run the following command and paste the results:

textual diagnose

Textual dignose does not work. Here are the Python and textual versions:

Python 3.9.2 (default, Mar 12 2021, 04:06:34)
[GCC 10.2.1 20210110] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import textual
>>> textual.__version__
'0.76.0'

Feel free to add screenshots and / or videos. These can be very helpful!

github-actions[bot] commented 2 months ago

Thank you for your issue. Give us a little time to review it.

PS. You might want to check the FAQ if you haven't done so already.

This is an automated reply, generated by FAQtory

TomJGooding commented 2 months ago

However, onece in a while it exits abruptly with an IvalidStateError

I wonder perhaps this only occurs when you trigger action_info when you're already on the InfoScreen?

gsportelli commented 2 months ago

I don't think so, but using ModalScreen instead of Screen seems to solve the problem. I couldn't test it thoroughly though. EDIT: To make it happen, I open and close the dialog quickly several times, so maybe when the screen is not modal it might happen that action_info is called when the previous screen is not fully dismissed as you say.

TomJGooding commented 2 months ago

Here's a quick MRE which I think demonstrates the issue:

  1. Press i to push the screen.
  2. Press i again.
  3. Click the button to dismiss both screens.
  4. Crashes with InvalidStateError
from textual import on, work
from textual.app import App, ComposeResult
from textual.containers import Vertical
from textual.screen import Screen
from textual.widgets import Button, Footer, Label

class InfoScreen(Screen[bool]):
    def __init__(self, question: str) -> None:
        self.question = question
        super().__init__()

    def compose(self) -> ComposeResult:
        yield Vertical(
            Label(self.question, id="info-label"),
            Button("Ok", variant="primary", id="ok"),
            id="info-vertical",
        )
        yield Footer()

    @on(Button.Pressed, "#ok")
    def handle_ok(self) -> None:
        self.dismiss(True)  # Changed the `dismiss` result to compatible type

class ExampleApp(App):
    BINDINGS = [("i", "info", "Info")]

    screen_count = 0

    def compose(self) -> ComposeResult:
        yield Label("This is the default screen")
        yield Footer()

    @work(exclusive=True)
    async def action_info(self) -> None:
        self.screen_count += 1
        await self.push_screen_wait(
            InfoScreen(f"This is info screen #{self.screen_count}")
        )

if __name__ == "__main__":
    app = ExampleApp()
    app.run()
TomJGooding commented 2 months ago

using ModalScreen instead of Screen seems to solve the problem.

That's because the ModalScreen will temporarily disable the main interface, so pressing i in my example above wouldn't have any effect.

github-actions[bot] commented 2 months ago

Don't forget to star the repository!

Follow @textualizeio for Textual updates.