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

2

5

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

Nefen answered 19/7, 2023 at 14:43 Comment(0)
T
5

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()

Trifoliate answered 20/7, 2023 at 13:1 Comment(0)
L
3

Interesting you used the term "screen" because that's exactly what Textual calls them. See the docs on Screens.

Langouste answered 19/7, 2023 at 18:20 Comment(3)
Yes, I stumbled upon screen and mode, but I still didn't manage to implement something like "Element Clicked -> Event -> Change State -> Run compose() with different state". How does such a state transition look like? And how does the state handling look like? I didnt' find something like if state == "foo": yield Screen1(); else: yield Screen2() or even something like def on_selected(self, bla): sendEvent(blubb) Can you give an example (apart from the Screens-example, which I already read several times). Maybe I'm too biased by PyQt?Nefen
And btw - please don't get me wrong - I'm not sure I've seen a Python package with this level of perfection (even the simple dictionary example left me speechless with this slim async magic going on, resulting in the most beautiful exception report I've ever seen after I failed to provide the CSS referenced file). I'm pretty sure I just have to re-wrap my mind, but still I struggled a lot today :)Nefen
what I was missing was the self.push_screen(screen, callback=self.some_way_back) together with self.dismiss(<data>) part. With those I can implement some FSM <=> screen ping pong :)Nefen

© 2022 - 2024 — McMap. All rights reserved.