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.
π Table Of Content (TOC).
- Events and Event Handlers
- Add Event Handlers To Custom Widgets
- Reactive Attributes
- Putting It All Together
- Adding A Submit Button
- Wrapping Up
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()
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()
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:
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()
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()
If you run the above program, it will fail and throw the following exception:
AssertionError: title attribute should be a string.
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()
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()
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.
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
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
andon_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()
If you run the above snippet of code, it will generate the following output. You can switch between widgets by clicking on them.
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")
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.
Top comments (4)
I've got it running and can enter the username and password, but how do I get the contents of the fields? Could you add a submit button that returns the two values when pressed? I'm hoping to use Textual for my project but can't proceed without working input fields.
Thanks! I'm looking forward to a Textual css tutorial!
Thanks for suggesting the submit button. I have updated the post. Take a look at the Adding a submit button section.
Welcome! Yup, i will give the css branch a try and create a tutorial about it. Stay tuned!
Is there any way to change focus from one field to another using TAB keyword?
Hey there. yeah, it is totally doable by calling the
focus
method. For more info, please refer to this example. Don't forget to slap that star button an odd number of times. Cheers.