Home Build an amazing TUI app with Textual
Post
Cancel

Build an amazing TUI app with Textual

  1. Introduction
  2. Install Textual
  3. Our first app
  4. Custom Widgets
  5. Conclusion

1. Introduction

You’ve probably heard of GUI. However, the term TUI (Terminal User Interface) might sound not that familar. When we think of terminal apps, the first thing that comes to our mind is text-heavy and boring.

However, it’s definitely possible to build an amazing-looking app on our terminal! Check out the project with a beautiful TUI I’ve worked on to see what you can do.

In this article, we’ll go over how to make a simple TUI counter app with Textual. Textual is an awesome tool to build beautiful TUI apps, integrating the familiar “DOM” concepts.

2. Install Textual

Let’s create a project root directory and create a virtual environment.

1
2
virtualenv .venv
source .venv/bin/activate

Install Textual by

1
pip install "textual[dev]"

3. Our first app

Create a file counter.py and paste the following.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from textual.app import App
from textual.widgets import Header, Footer

class CounterApp(App):
    css_path = "counter.css"
    BINDINGS = [
        ("q", "quit", "Quit App")
    ]

    def compose(self):
        yield Header()
        yield Footer()

    def action_quit(self):
        self.exit()


if __name__ == '__main__':
    app = CounterApp()
    app.run()

This is the simplest baseline that shows the header and the footer.

To run the app, enter python counter.py

Now let’s go one by one and see what each line does.

  • class CounterApp(App)
    • This is the entry point class of our app, extending App class.
  • CSS_PATH
    • If you have a custom css file, provide the path to this variable
  • BINDINGS
    • It’s a list of 3-tuples defining keyboard bindings.
    • The first element q is the key stroke to trigger an event
    • The second element quit is used in “naming” the action handler for quitting the app.
    • The last element Quit App is a description of the key binding which appears in the footer
  • def compose(self)
    • This method returns a single or a list of widgets. However, it’s easier to yield them (making the method a generator). In this example, it yields two pre-defined widgets(or instance of widget to be more specific) Header() and Footer().
  • def action_quit(self)
    • This is where an interesting part comes in. In Textual, you prepend the word action_ in front of the key bindings you have defined in BINDINGS.
    • Since we named the action when pressing q as “quit”, we must name the method as action_quit. Textual will automatically match the action handler.

4. Custom Widgets

We want to make a counter app which contains a text field to display counts and a button which triggers a “plus one” action.

It’s super simple to create custom widgets in Textual. Just like python classes, you can easily create your own widgets.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
from textual.app import App
from textual.containers import Container, Horizontal
from textual.widgets import Header, Footer, Static, Button
from textual.messages import Message
from textual.reactive import reactive

class CounterText(Static):
    """display counter value"""
    count = reactive(0) # reactive attribute

    def watch_count(self, count): # called when count is updated
        self.update(str(count)) # display updated value


class CounterButton(Button):
    """A button widget"""
    def compose(self):
        yield Static("Click me!")

    # Custom message -> propagate to all upper layrs
    class CounterButtonPressed(Message):
        def __init__(self):
            super().__init__()

    # Button pressed event handler (Textual built-in handler) when button is pressed
    def on_button_pressed(self, event: Button.Pressed):
        self.post_message(self.CounterButtonPressed())


class Counter(Horizontal):
    def compose(self):
        yield CounterText("0", id="counter-text")
        yield CounterButton(id="counter-button", variant="success")

    # Message handler (message from the child widget "CounterButton")
    def on_counter_button_counter_button_pressed(self, event):
        counter_text = self.query_one(CounterText)
        counter_text.count += 1


class CounterApp(App):
    CSS_PATH = "counter.css"
    BINDINGS = [
        ("q", "quit", "Quit App")
    ]

    def compose(self):
        yield Header()
        yield Footer()
        yield Counter()

    def action_quit(self):
        self.exit()


if __name__ == '__main__':
    app = CounterApp()
    app.run()

We have four components in our app

  • CounterApp(App) - entry point
    • Counter(Horizontal) - container holding button and text
      • CounterButton(Button) - button widget
      • CounterText(Static) - text widget

First, the entry widget CounterApp yields Counter horizontal container widget which aligns its children widgets horizontally.

Counter widget again yields two children, CounterButton and CounterText.

CounterText widget

1
2
3
4
5
6
class CounterText(Static):
    """display counter value"""
    count = reactive(0) # reactive attribute

    def watch_count(self, count): # called when count is updated
        self.update(str(count)) # display updated value

CounterText widget extends Static which is used for displaying text data. It has an attribute called count whose value is a reactive attribute.

Instead of refreshing a component manually, you can declare a reactive widget which provides a more convenient and efficient way of handling changes.

Then, declare a method def watch_count(self, count) to handle that particular reactive attribute. The name of the method actually matters. It must be prepended with watch_ followed by the name of the reactive attribute count.

Whenever count variable gets updated, Textual will automatically call this method which takes one argument which is the updated value of the variable.

To actually apply the change, use self.update().

CounterButton widget

This widget is a button that you can click on to trigger events. Textual already provides you with on_button_pressed event handler which is automatically called when you press the button.

A cool feature of Textual is that you can pass custom messages(or events) to all its parent widgets.

To declare a custom message, create a class with a “Pascal case” name, extending Message class.

Then, propagate the message to all its parent widgets by self.post_message(self.CounterButtonPressed().

Counter widget

Since Counter is the parent of CounterButton widget, it can receive the custom CounterButtonPressed message we just created.

on_counter_button_counter_button_pressed is our message handler method for CounterButtonPressed. The method name is no coincidence.

The message handler method’s name consists of three parts

  1. on
  2. Widget class name that’s sending the message: CounterButton
  3. Message name: CounterButtonPressed

Then, convert all the pascal cases into snake cases and connect them with underscore _. Hence, the resulted name would be on_counter_buton_counter_button_pressed.

This method takes a parameter event which contains detailed information of the particular message.

Now, we need to somehow change the count variable in CounterText widget. To get the CounterText widget object, we can use self.query_one(). You can pass the class itself or query by ID as self.query_one(#some-id).

After getting the object, you can change the count variable. Then, the watch_count method in CounterText will be triggered, modifying the display.

One reminder is that the messages are only propagated to parents but not to its children.

Finally, you can see that our count value changes when clicking the button!

5. Conclusion

We’ve gone over a simple counter app demonstration. Now it’s time to create more advanced and powerful CLI app!

Explore Textual Documentation to see more about its built-in widgets and other cool features.

This post is licensed under CC BY 4.0 by the author.
Trending Tags