Search code examples
pythonrichpython-textual

With Python-textual (package) how do I linearly switch between different 'screens'?


With textual I'd like to build a simple program which presents me with different options I can choose from using OptionList, but one by one, e.g.

First "screen":

what do you want to buy (Car/Bike)?
+---------+
|   Car   |
| > Bike  |
+---------+

bike

And after I pressed/clicked on "Bike" I'd like to see the second 'screen' (with potentially different widgets):

electric (yes/no)?
+---------+
|   Yes   |
| > No    |
+---------+

No

The following code shows me the first list of options but I have no idea how to proceed:

from textual.app import App, ComposeResult
from textual.widgets import Footer, Header, OptionList, Static
from textual import events, on

class SelectType(Static):
    def compose(self) -> ComposeResult:
        yield OptionList(
            "Car",
            "Bike",
        )

    @on(OptionList.OptionSelected)
    def selected(self, *args):
        return None # What to do here?

class MainProgram(App[None]):
    def compose(self) -> ComposeResult:
        yield Header()
        yield Footer()
        yield SelectType()

MainProgram().run()

What to do now? I crawled the tutorial, guides, examples but it looks like they all show me how to build one set of widgets but I didn't find a way to make a transition between one input screen and another one..


Solution

  • Depending on the scope and purpose of the application you're wanting to build, there's a few different approaches you could take here. Some options are:

    • If it's a limited number of main choices with a handful of widgets making up the sub-questions, perhaps TabbedContent would be what you're looking for.
    • If you want a wee bit more control, you could wire things up on a single screen using ContentSwitcher.
    • You could also build a "create the DOM as you go" approach by creating your initial question(s) with compose and then using a combination of mount and remove.

    As suggested by Will in his answer, one very likely approach would be to use a Screen or two. With a little bit of thought you could probably turn it into quite a flexible and comprehensive application for asking questions.

    What follows is a very simplistic illustration of some of the approaches you could take. In it you'll find I've only put together a "bike" screen (with some placeholder questions), and only put in a placeholder screen for a car. Hopefully though it will illustrate some of the key ideas.

    What's important here is that it uses ModalScreen and the screen callback facility to query the user and then get the data back to the main entry point.

    There are, of course, a lot of details "left for the reader"; do feel free to ask more about this if anything isn't clear in the example.

    from typing import Any
    
    from textual import on
    from textual.app import App, ComposeResult
    from textual.widgets import Button, OptionList, Label, Checkbox, Pretty
    from textual.widgets.option_list import Option
    from textual.screen import ModalScreen
    
    class SubScreen(ModalScreen):
    
        DEFAULT_CSS = """
        SubScreen {
            background: $panel;
            border: solid $boost;
        }
        """
    
    class CarScreen(SubScreen):
    
        def compose(self) -> ComposeResult:
            yield Label("Buy a car!")
            yield Label("Lots of car-oriented widgets here I guess!")
            yield Button("Buy!", id="buy")
            yield Button("Cancel", id="cancel")
    
        @on(Button.Pressed, "#buy")
        def buy_it(self) -> None:
            self.dismiss({
                "options": "everything -- really we'd ask"
            })
    
        @on(Button.Pressed, "#cancel")
        def cancel_purchase(self) -> None:
            self.dismiss({})
    
    class BikeScreen(SubScreen):
    
        def compose(self) -> ComposeResult:
            # Here we compose up the question screen for a bike.
            yield Label("Buy a bike!")
            yield Checkbox("Electric", id="electric")
            yield Checkbox("Mudguard", id="mudguard")
            yield Checkbox("Bell", id="bell")
            yield Checkbox("Wheels, I guess?", id="wheels")
            yield Button("Buy!", id="buy")
            yield Button("Cancel", id="cancel")
    
        @on(Button.Pressed, "#buy")
        def buy_it(self) -> None:
            # The user has pressed the buy button, so we make a structure that
            # has a key/value mapping of the answers for all the questions. Here
            # I'm just using the Checkbox; in a full application you'd want to
            # take more types of widgets into account.
            self.dismiss({
                **{"type": "bike"},
                **{
                    question.id: question.value for question in self.query(Checkbox)
                }
            })
    
        @on(Button.Pressed, "#cancel")
        def cancel_purchase(self) -> None:
            # Cancel was pressed. So here we'll return no-data.
            self.dismiss({})
    
    class VehiclePurchaseApp(App[None]):
    
        # Here you could create a structure of all of the types of vehicle, with
        # their names and the screen that asks the questions.
        VEHCILES: dict[str, tuple[str, type[ModalScreen]]] = {
            "car": ("Car", CarScreen),
            "bike": ("Bike", BikeScreen)
        }
    
        def compose(self) -> ComposeResult:
            # This builds the initial option list from the vehicles listed above.
            yield OptionList(
                *[Option(name, identifier) for identifier, (name, _) in self.VEHCILES.items()]
            )
            # The `Pretty` is just somewhere to show the result. See
            # selection_made below.
            yield Pretty("")
    
        def selection_made(self, selection: dict[str, Any]) -> None:
            # This is the method that receives the selection after the user has
            # asked to buy the vehicle. For now I'm just dumping the selection
            # into a `Pretty` widget to show it.
            self.query_one(Pretty).update(selection)
    
        @on(OptionList.OptionSelected)
        def next_screen(self, event: OptionList.OptionSelected) -> None:
            # If the ID of the option that was selected is known to us...
            if event.option_id in self.VEHCILES:
                # ...create an instance of the screen associated with it, push
                # it and set up the callback.
                self.push_screen(self.VEHCILES[event.option_id][1](), callback=self.selection_made)
    
    if __name__ == "__main__":
        VehiclePurchaseApp().run()