Textual: The Definitive Guide - Part 2.

wiseai

Mahmoud Harmouch

Posted on April 15, 2022

Textual: The Definitive Guide - Part 2.

Greetings, everyone. In this article, we will pick up where we left off and continue our series on demystifying every aspect of Textual. In Part 1, we interacted with various Textual components such as views and widgets. In addition, we were able to customize the way these components are laid out on the terminal while retaining a very high level of abstraction working with fully-featured widgets. We also introduced the concepts of widget event handlers, watchers, and reactive attributes/properties, but we didn't elaborate much. And that's what we are going to focus on in this article.

To ensure that we are all on the same page, this tutorial assumes that you are already familiar with object-oriented programming. If not, consider reading one of the most comprehensive tutorials out there about this topic.

Spoilers ahead: By the end of this article, you will learn how to create a custom login screen in Textual, as shown in the figure below.


A Login Screen.

πŸ‘‰ Table Of Content (TOC).

Events and Event Handlers

πŸ” Go To TOC.

An event is a signal that something interesting has happened. It can be triggered by different actions such as clicking on a button, typing on the keyboard, or making a mouse movement. Events are used to control and respond to what the user does with their Input/Output devices.

In Textual, events are being fired all the time. What makes Textual interesting is the fact that it is asynchronous, which means it provides an event loop that your program executes when you call the run method on the main app. This method is constantly cycling through events such as keyboard entry and mouse motion, among many others. When something interesting happens, it does the necessary processing to ensure that your code knows the event has happened and has a chance to respond.

So if an event happens, an event handler responds to what happened. In Textual, event handlers are just functions that usually take one argument: the event that occurred. An event handler can react to changes in the state of an object.

Add Event Handlers To Custom Widgets

πŸ” Go To TOC.

As with most Textual events, you can hook up event handlers in your program by implementing an on_<event_name> method. Let's start by creating an app.py file. Then add the following code definition:



from textual.app import App
from textual.widget import Widget


class InputText(Widget):
    ...

class MainApp(App):
    ...

if __name__ == '__main__':
    MainApp.run()


Enter fullscreen mode Exit fullscreen mode

The second line of code imports the Widget class to create a custom widget by extending it. Inheritance is used to create a new subclass InputText from Widget with no extra logic.

If you now run python app.py or poetry run python app.py, it will behave the same way as the Basic Textual App Example of the previous article. Now, we are going to add custom logic to these classes.

The event handler is accessed as a property on the widget object with the on_ prefix. There are specific types of events for different widgets; for a button like a widget, the click event is kicked off by a mouse press.

Now let's add this event handler to our InputText widget.



import sys

from textual.app import App
from textual.widget import Widget


class InputText(Widget):
    def on_click(self) -> None:
        sys.exit(0)

class MainApp(App):
    async def on_mount(self) -> None:
        await self.view.dock(InputText())

if __name__ == '__main__':
    MainApp.run()


Enter fullscreen mode Exit fullscreen mode

As you can tell, we added an on_click and an on_mount event handlers to our classes. The latter was used to attach/dock the InputText widget to the terminal and make it visible. This event handler is kicked off when you first run your Textual app. Think of it as a pre-processing step to make the widgets ready for rendering. It is an async function since Textual is an asynchronous framework.

If you run this example, your terminal will look like the following:


A raw rich panel.

As you can see, our InputText looks like a raw placeholder because, by default, widgets are rendered as rich panels. However, we will add some custom rendering capabilities by overriding this method.

Now, if you click anywhere on the widget, it will fire a click event which will trigger the on_click event handler. Then it executes the contents of that method sys.exit(0), which will cause our program to exit.

Everything behaves as expected. Now we need to change the rendering of this widget to simulate the concept of a text box where a user can type in the text. To do so, we will add the following render function:



import sys

from rich.align import Align
from rich.box import DOUBLE
from rich.console import RenderableType
from rich.panel import Panel
from rich.style import Style
from rich.text import Text
from textual.app import App
from textual.widget import Widget


class InputText(Widget):
    def on_click(self) -> None:
        sys.exit(0)

    def render(self) -> RenderableType:
        renderable = Align.left(Text("", style="bold"))
        return Panel(
            renderable,
            title="input_text",
            title_align="center",
            height=3,
            style="bold white on rgb(50,57,50)",
            border_style=Style(color="green"),
            box=DOUBLE,
        )


class MainApp(App):
    async def on_mount(self) -> None:
        await self.view.dock(InputText())


if __name__ == "__main__":
    MainApp.run()


Enter fullscreen mode Exit fullscreen mode


Custom input field.

Now, we need to allow the user to enter text into this widget which can be done by adding a Reactive attribute in the InputText class to store the value of the pressed keys on the keyboard and then render the value of that attribute on the widget. This is a good occasion to delve into the Textual concept of Reactive attributes/properties.

Reactive Attributes

πŸ” Go To TOC.

Textual Reactive attributes/properties are somewhat magical. At their core, Reactive attributes are implemented using the concept of python descriptors. In Textual, properties have type-validating features using the validate_<attribute_name> notation. For instance, you can always be sure that a string is being stored for a given attribute that does not contain an integer value. Another example, you can ensure that a number is within a specific range. To illustrate this, let's take the following snippet of code:



import sys

from rich.align import Align
from rich.box import DOUBLE
from rich.console import RenderableType
from rich.panel import Panel
from rich.style import Style
from rich.text import Text
from textual.app import App
from textual.reactive import Reactive
from textual.widget import Widget


class InputText(Widget):

    # The property is created at the class level as an instance of the Reactive class.
    title: Reactive[RenderableType] = Reactive("")

    def __init__(self, title: str):
        super().__init__(title)
        self.title = title

    def validate_title(self, value) -> None:
        try:
          return value.lower()
        except (AttributeError, TypeError):
          raise AssertionError('title attribute should be a string.')

    def on_click(self) -> None:
        sys.exit(0)

    def render(self) -> RenderableType:
        renderable = Align.left(Text("", style="bold"))
        return Panel(
            renderable,
            title=self.title,
            title_align="center",
            height=3,
            style="bold white on rgb(50,57,50)",
            border_style=Style(color="green"),
            box=DOUBLE,
        )


class MainApp(App):
    async def on_mount(self) -> None:
        await self.view.dock(InputText(4096))


if __name__ == "__main__":
    MainApp.run()


Enter fullscreen mode Exit fullscreen mode

If you run the above program, it will fail and throw the following exception:



AssertionError: title attribute should be a string.


Enter fullscreen mode Exit fullscreen mode

And that's because of the line await self.view.dock(InputText(4096)) that tries to instantiate the InputText class with an integer and assign that value to title at this line of code self.title = title. This line will trigger the validate_title function to run its inner code, making the program raise an AttributeError because, as you may know, an integer doesn't have a lower method. This way, you ensure that your attribute values are being validated before assignment.

Interestingly enough, Textual Reactive Attributes can fire events when their values change. This can be extremely useful, as you will see in subsequent articles. The fired event can be handled by implementing a watch_<attribute_name> method.

Let's introduce another attribute called content to store the keys being entered by a user using a keyboard. We need to figure out how to update the content field when the user presses a key. But first, let's add a handler responsible for that event called on_key. Textual takes care of all sorts of keys available on your keyboard.



import sys

from rich.align import Align
from rich.box import DOUBLE
from rich.console import RenderableType
from rich.panel import Panel
from rich.style import Style
from rich.text import Text
from textual import events
from textual.app import App
from textual.reactive import Reactive
from textual.widget import Widget


class InputText(Widget):

    title: Reactive[RenderableType] = Reactive("")
    content: Reactive[RenderableType] = Reactive("")

    def __init__(self, title: str):
        super().__init__(title)
        self.title = title

    def on_key(self, event: events.Key) -> None:
        self.content += event.key

    def validate_title(self, value) -> None:
        try:
          return value.lower()
        except (AttributeError, TypeError):
          raise AssertionError('title attribute should be a string.')

    def on_click(self) -> None:
        sys.exit(0)

    def render(self) -> RenderableType:
        renderable = Align.left(Text("", style="bold"))
        return Panel(
            renderable,
            title=self.title,
            title_align="center",
            height=3,
            style="bold white on rgb(50,57,50)",
            border_style=Style(color="green"),
            box=DOUBLE,
        )


class MainApp(App):
    async def on_mount(self) -> None:
        await self.view.dock(InputText("input field"))


if __name__ == "__main__":
    MainApp.run()


Enter fullscreen mode Exit fullscreen mode

Whenever you press a key, it will be appended to the content string variable. Now all you need to do is figure out how to reflect the value of this variable on the widget. As you may guess, this can be done by tweaking the render function as follows:



import sys

from rich.align import Align
from rich.box import DOUBLE
from rich.console import RenderableType
from rich.panel import Panel
from rich.style import Style
from rich.text import Text
from textual import events
from textual.app import App
from textual.reactive import Reactive
from textual.widget import Widget


class InputText(Widget):

    title: Reactive[RenderableType] = Reactive("")
    content: Reactive[RenderableType] = Reactive("")

    def __init__(self, title: str):
        super().__init__(title)
        self.title = title

    def on_key(self, event: events.Key) -> None:
        self.content += event.key

    def validate_title(self, value) -> None:
        try:
          return value.lower()
        except (AttributeError, TypeError):
          raise AssertionError('title attribute should be a string.')

    def render(self) -> RenderableType:
        renderable = Align.left(Text(self.content, style="bold"))
        return Panel(
            renderable,
            title=self.title,
            title_align="center",
            height=3,
            style="bold white on rgb(50,57,50)",
            border_style=Style(color="green"),
            box=DOUBLE,
        )


class MainApp(App):
    async def on_mount(self) -> None:
        await self.view.dock(InputText("input field"))


if __name__ == "__main__":
    MainApp.run()


Enter fullscreen mode Exit fullscreen mode

If you run the above code and start typing on your keyboard, you will notice that it is not rendered yet. And that's because your terminal needs a focus by clicking on it. Now, the text will be reflected on the widget as shown below.


Custom input field.

You can add the ability to remove a letter by checking if the key being pressed is ctrl+h which is the backspace on your keyboard.



    def on_key(self, event: events.Key) -> None:
        if event.key == "ctrl+h":
           self.content = self.content[:-1]
        else:
           self.content += event.key


Enter fullscreen mode Exit fullscreen mode

Putting It All Together

πŸ” Go To TOC.

Now, it is time to create a custom widget that has the following properties:

  • If the widget is a text field like username, print out the letter on the widget.
  • If the widget is a password field, hideout the letters being entered and display the character * instead.
  • You can type out letters on the widget only if your mouse is hovering over it. This can be done using the on_enter and on_leave handlers.

Our final program is defined as follows:



from rich.align import Align
from rich.box import DOUBLE
from rich.console import RenderableType
from rich.panel import Panel
from rich.style import Style
from rich.text import Text
from textual import events
from textual.app import App
from textual.reactive import Reactive
from textual.widget import Widget


class InputText(Widget):

    title: Reactive[RenderableType] = Reactive("")
    content: Reactive[RenderableType] = Reactive("")
    mouse_over: Reactive[RenderableType] = Reactive(False)

    def __init__(self, title: str):
        super().__init__(title)
        self.title = title

    def on_enter(self) -> None:
        self.mouse_over = True

    def on_leave(self) -> None:
        self.mouse_over = False

    def on_key(self, event: events.Key) -> None:
        if self.mouse_over == True:
            if event.key == "ctrl+h":
                self.content = self.content[:-1]
            else:
                self.content += event.key

    def validate_title(self, value) -> None:
        try:
            return value.lower()
        except (AttributeError, TypeError):
            raise AssertionError("title attribute should be a string.")

    def render(self) -> RenderableType:
        renderable = None
        if self.title.lower() == "password":
            renderable = "".join(map(lambda char: "*", self.content))
        else:
            renderable = Align.left(Text(self.content, style="bold"))
        return Panel(
            renderable,
            title=self.title,
            title_align="center",
            height=3,
            style="bold white on rgb(50,57,50)",
            border_style=Style(color="green"),
            box=DOUBLE,
        )


class MainApp(App):
    async def on_load(self) -> None:
        await self.bind("ctrl+c", "quit", "Quit")

    async def on_mount(self) -> None:
        await self.view.dock(InputText("user_name"), edge="left", size=50)
        await self.view.dock(InputText("password"), edge="left", size=50)


if __name__ == "__main__":
    MainApp.run()


Enter fullscreen mode Exit fullscreen mode

If you run the above snippet of code, it will generate the following output. You can switch between widgets by clicking on them.


Custom input fields.

Adding A Submit Button

πŸ” Go To TOC.

Thanks to @darkstarinternet suggestion, we can extend the capabilities of our app to handle a submit button that will store the values of the username and password for future processing. It can be accomplished with the help of the handle_button_pressed method, which will capture the pressed button event as the name suggests. Our final application will look like the following:



from rich.align import Align
from rich.box import DOUBLE
from rich.console import RenderableType
from rich.panel import Panel
from rich.style import Style
from rich.text import Text
from textual import events
from textual.app import App
from textual.reactive import Reactive
from textual.widget import Widget
from textual.widgets import Button
from textual.widgets import Button, ButtonPressed


class Submit(Button):

    clicked: Reactive[RenderableType] = Reactive(False)

    def on_click(self) -> None:
        self.clicked = True


class InputText(Widget):

    title: Reactive[RenderableType] = Reactive("")
    content: Reactive[RenderableType] = Reactive("")
    mouse_over: Reactive[RenderableType] = Reactive(False)

    def __init__(self, title: str):
        super().__init__(title)
        self.title = title

    def on_enter(self) -> None:
        self.mouse_over = True

    def on_leave(self) -> None:
        self.mouse_over = False

    def on_key(self, event: events.Key) -> None:
        if self.mouse_over == True:
            if event.key == "ctrl+h":
                self.content = self.content[:-1]
            else:
                self.content += event.key

    def validate_title(self, value) -> None:
        try:
            return value.lower()
        except (AttributeError, TypeError):
            raise AssertionError("title attribute should be a string.")

    def render(self) -> RenderableType:
        renderable = None
        if self.title.lower() == "password":
            renderable = "".join(map(lambda char: "*", self.content))
        else:
            renderable = Align.left(Text(self.content, style="bold"))
        return Panel(
            renderable,
            title=self.title,
            title_align="center",
            height=3,
            style="bold white on rgb(50,57,50)",
            border_style=Style(color="green"),
            box=DOUBLE,
        )


class MainApp(App):
    submit: Reactive[RenderableType] = Reactive(False)
    username: Reactive[RenderableType] = Reactive("")
    password: Reactive[RenderableType] = Reactive("")

    def handle_button_pressed(self, message: ButtonPressed) -> None:
        """A message sent by the submit button"""
        assert isinstance(message.sender, Button)
        button_name = message.sender.name
        self.submit = message.sender.clicked
        if button_name == "submit" and self.submit:
            self.submit_button.clicked = False
            self.username = self.username_field.content
            self.password = self.password_field.content
            self.log(f"username = {self.username}")

    async def on_mount(self) -> None:
        self.submit_button = Submit(
            label="Submit", name="submit", style="black on white"
        )
        self.submit = self.submit_button.clicked
        self.username_field = InputText("username")
        self.password_field = InputText("password")
        await self.view.dock(self.submit_button, edge="bottom", size=3)
        await self.view.dock(self.username_field, edge="left", size=50)
        await self.view.dock(self.password_field, edge="left", size=50)


if __name__ == "__main__":
    MainApp.run(log="textual.log")


Enter fullscreen mode Exit fullscreen mode


A Login Screen.

Wrapping Up

πŸ” Go To TOC.

Textual is an exciting TUI toolkit that you can use to create whatever type of terminal user interface. This article highlighted a few common practices for developing custom Textual widgets, such as an input field, hooking up event handlers, reactive attributes, handling keyboard and mouse events, etc.

There are many components and concepts about Textual that we haven't covered yet. So if you are interested, make sure to check out the Textual repo for examples and tutorials, and don't forget to stay tuned to this blog. I recently found that the creator of Textual has a blog where he documents all the major changes, releases about Textual. I also discovered a css branch for Textual where you can use CSS to customize your widgets. That's a pretty impressive feature, which will be the topic of future articles. Imagine creating a web browser within your terminal using Textual; that would be extremely sick!

As always, this article is a gift to you, and you can share it with whomever you like or use it in any way that would be beneficial to your personal and professional development. By supporting this blog, you keep me motivated to publish high-quality content related to python in general and textual specifically. Thank you in advance for your ultimate support!

You are free to use the code in this article, which is licensed under the MIT licence, as a starting point for various needs. Don’t forget to look at the readme file and use your imagination to make more complex apps meaningful to your use case.

Happy Coding, folks; see you in the next one.

πŸ’– πŸ’ͺ πŸ™… 🚩
wiseai
Mahmoud Harmouch

Posted on April 15, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related