这是用户在 2025-7-28 13:46 为 https://mathspp.com/blog/textual-for-beginners 保存的双语快照页面,由 沉浸式翻译 提供双语支持。了解如何保存?
Two screenshots of a TODO app built with Textual, which is the end result you get if you follow along this Textual tutorial for beginners.
A TODO app in Textual.

Introduction

This tutorial will teach you the basics that you need to use Textual to build terminal user interfaces (TUIs).

This is an unofficial written version of a tutorial I gave at EuroPython 2023 in Prague.

In case you don't know what Textual is, go ahead and read the description below. Otherwise, let's get building!

What is Textual?

Textual is a Python framework that lets users build applications that run directly in the terminal. Quoting the Textual documentation, some of the advantages of using Textual to build applications include:

  • Rapid development: you can use your existing Python skills to build a beautiful user interface. You don't need to learn new languages to write user interfaces.
  • Low requirements: you can run Textual on a single board computer if you want to.
  • Cross-platform: Textual runs just about everywhere you can run Python.
  • Remote: Textual apps can run over SSH.
  • CLI integration: Textual apps can be launched and run from the command prompt.
  • Open source: Textual is licensed under MIT.

Setup

To get ready for this tutorial, create a new folder, create a new virtual environment, and install Textual!

~:$ mkdir textual-tutorial ~:$ cd textual-tutorial ~/textual-tutorial:$ python -m venv .venv ~/textual-tutorial:$ . .venv/bin/activate ~/textual-tutorial:$ python -m pip install textual

To verify that the installation was successful, run the command

~/textual-tutorial:$ python -m textual

That should open the Textual demo app. It should look something like this:

The Textual demo terminal application that you can run with the command `python -m textual` to verify Textual is working correctly.
Textual demo app.

To quit the app, press Ctrl + C.

This tutorial was written for Textual 0.29.0. Some information in this tutorial may become outdated. Check the Textual docs for updated information and join the Textual Discord to talk to us.

Your first Textual app

Textual lets you build TUIs – terminal user interfaces. These are programs that provide an interface for the user to interact with, but they run inside the terminal.

In Textual, the program that shows things to the user and with which the user interacts is called an app. Let us see how to build your first, most basic app.

An application, in Textual, is a class that inherits from textual.app.App. Then, to run your app, you need to instantiate it and call its method run:

from textual.app import App class MyApp(App): pass MyApp().run()

Run your app with the command:

~:$ python your_first_app.py

It should show a black screen, which means your app is running! It does nothing, but it is running! To exit your app, press Ctrl + C.

Showing widgets on the screen

For your app to do something useful, you will want to add widgets to it. Widgets are the interface elements that the user interacts with and that you can use to convey information to the user.

Textual has an extensive widget library that includes:

  • app headers and footers;
  • labels;
  • buttons;
  • inputs;
  • radio buttons and checkboxes;
  • switches;
  • tabs;
  • etc.

Composing widgets

Now I will show you how to use widgets in your apps. We will start by adding a header and a button to our app. To add the widgets to the app we need to import them:

from textual.widgets import Button, Header

Next, we need to know how to tell Textual to display the widgets inside our app. We do this via the App.compose method, which must return an iterable of widgets. For ease of use, it is recommended that you yield your widgets one at a time:

from textual.app import App from textual.widgets import Button, Header class MyApp(App): def compose(self): yield Header() yield Button() MyApp().run()

If you run this app, you should see an app with a header with the title “MyApp” and a button with the text “Button”, like the image below shows:

A Textual app showcasing how to use Textual widgets with the method compose. The app shows a header and a button.
An app with two widgets.

Customising widgets upon instantiation

Many widgets can be customised when they are instantiated. For example, the header can be set to show a clock with the current time and a button can have a non-default text:

from textual.app import App from textual.widgets import Button, Header class MyApp(App): def compose(self): yield Header(show_clock=True) yield Button("Click me!") MyApp().run()

If you run the app now, you will see a clock in the upper right corner and you will see the text “Click me!” inside the button:

A Textual app with two widgets showing how widgets can be customised when they are instantiated. The app shows a header with a clock and a button with custom text.
Customised header and button widgets.

Adding inputs to your app

Go to the Textual widgets reference and figure out how to include an input field in your app. Customise it so that it has some placeholder text saying “Name:”. Do this by locating the Input widget in the reference and looking at the subsection “A Simple Example”.

The solution to this is to add the widget Input to the import list and then yield it inside the method MyApp.compose. Additionally, the widget Input has a parameter placeholder that can be used to specify the placeholder text of the input field in the app:

from textual.app import App from textual.widgets import Button, Header, Input class MyApp(App): def compose(self): yield Header(show_clock=True) yield Button("Click me!") yield Input(placeholder="Name:") MyApp().run()

This app looks like this:

A Textual app with an input widget that contains some placeholder text. The input is under the button.
An input field with placeholder text.

Try swapping the lines yield Button(...) and yield Input(...) to see what difference that makes.

Compound widgets

Rationale

When building applications, it is common to group some fundamental widgets to create a more complex widget that has a particular significance in your app. For example, we can modify the previous application to include a label next to the input field:

from textual.app import App from textual.widgets import Button, Header, Input, Label class MyApp(App): def compose(self): yield Header(show_clock=True) yield Label("Name:") yield Input(placeholder="name") yield Button("Click me!") MyApp().run()

If you extend your application to also ask for the user's surname and email, you will likely want to repeat the pattern:

  • a label and an input for the surname; and
  • a label and an input for the email.

Something along these lines:

from textual.app import App from textual.widgets import Button, Header, Input, Label class MyApp(App): def compose(self): yield Header(show_clock=True) yield Label("Name:") yield Input(placeholder="name") yield Label("Surname:") yield Input(placeholder="surname") yield Label("Email:") yield Input(placeholder="email") yield Button("Click me!") MyApp().run()

As we notice the pattern, we may extract it and implement a widget called LabelledInput that is composed of a label and a related input. When we put together two or more widgets to create a more complex widget, we say we build a compound widget.

Defining a compound widget

Compound widgets are regular widgets, but instead of having to implement their functionality from scratch, you can use other widgets as a starting point. To do this, you need to create the class that is going to represent your compound widget.

Much like you inherited from textual.app.App to create your own app, you will need to inherit from textual.widget.Widget to create your own widget:

from textual.widget import Widget class LabelledInput(Widget): pass

This prepares LabelledInput to become a compound widget. What is left is specifying what this compound widget is composed of. Similarly to apps, compound widgets can have a method compose that specifies what are the widgets that make it up.

In the case of our LabelledInput, it will be composed of a label and an input:

from textual.widget import Widget from textual.widgets import Input, Label class LabelledInput(Widget): def compose(self): yield Label("Label:") yield Input(placeholder="label")

Using a compound widget

Compound widgets are used like any other widget. For instance, you can yield it inside the method compose of your app:

from textual.app import App from textual.widget import Widget from textual.widgets import Button, Header, Input, Label class LabelledInput(Widget): def compose(self): yield Label("Label:") yield Input(placeholder="label") class MyApp(App): def compose(self): yield Header(show_clock=True) yield LabelledInput() # !!! yield Button("Click me!") MyApp().run()

If you run this app, it will look like the button disappeared:

A Textual app with weird dimensioning problems.
The button isn't visible.

However, if you look closely, you will notice a scrollbar on the right that lets you scroll until you see the button. This odd behaviour is explained by the fact that Widget, which LabelledInput inherits from, is too tall by default.

The full discussion about Textual CSS will only happen later down the road, but you will get a quick taste now. Textual CSS is a markup language that you can use to modify the way your widgets look. In this particular case, we want the LabelledInput widgets to have a much smaller height, so that is what we will do.

Here is the updated compound widget, with 5 new lines of code:

from textual.app import App from textual.widget import Widget from textual.widgets import Button, Header, Input, Label class LabelledInput(Widget): DEFAULT_CSS = """ LabelledInput { height: 4; } """ def compose(self): yield Label("Label:") yield Input(placeholder="label") class MyApp(App): def compose(self): yield Header(show_clock=True) yield LabelledInput() yield Button("Click me!") MyApp().run()

The class variable DEFAULT_CSS is responsible for specifying that the height of the widget LabelledInput should be 4, instead of whatever value was set before.

This app now has the look that we expected:

Textual compound widgets that inherit from `textual.widgets.Static` have an automatic height by default.
The button is visible from the beginning.

Try playing around with the value in front of height: to see how the look of the app changes when that value changes.

Customising a compound widget upon instantiation

Like all other widgets, compound widgets can also be customised upon instantiation. Given that a compound widget contains sub-widgets, it is common for some of the customisation options of said sub-widgets to be exposed by the compound widget itself.

For our LabelledInput example, we will let the user customise the text inside the label:

## ... class LabelledInput(Widget): DEFAULT_CSS = """ LabelledInput { height: 4; } """ def __init__(self, label): super().__init__() # <- SUPER important. self.label = label def compose(self): yield Label(f"{self.label}:") yield Input(placeholder=self.label.lower())

When overriding the dunder method __init__ of your compound widget, be particularly careful about calling super().__init__()! If you forget that, you will get all sorts of funny errors when using your compound widget inside an app.

With this improved version of LabelledInput, we can now reproduce the form we had before:

from textual.app import App from textual.widget import Widget from textual.widgets import Button, Header, Input, Label class LabelledInput(Widget): DEFAULT_CSS = """ LabelledInput { height: 4; } """ def __init__(self, label): super().__init__() self.label = label def compose(self): yield Label(f"{self.label}:") yield Input(placeholder=self.label.lower()) class MyApp(App): def compose(self): yield Header(show_clock=True) yield LabelledInput("Name") yield LabelledInput("Surname") yield LabelledInput("Email") yield Button("Click me!") MyApp().run()

This application looks like this:

A Textual application that uses several compound widgets to make up its interface.
App with multiple compound widgets.
具有多个复合小部件的应用程序。

Key bindings and actions
键绑定和作

In Textual, you can bind key presses to methods in your application. To “bind” a key press means that you can automatically call a given method when the user presses a certain key or a set of keys. For example, you could make it so that pressing Esc quits your application or pressing Ctrl + D toggles dark mode.
在文本中,您可以将按键绑到应用程序中的方法。“绑定”按键意味着当用户按下某个键或一组键时,您可以自动调用给定方法。例如,您可以使其按退出 Esc 应用程序或按 Ctrl + D 切换深色模式。

Textual allows such bindings via actions. More precisely, via action methods.
文本允许通过进行此类绑定。更准确地说,通过行动方法

Action methods行动方法

Action methods are regular app methods that follow a naming convention. An action method should start with action_. In the app below, the method action_ring_a_bell is an action method:
作方法是遵循命名约定的常规应用方法。行动方法应以 action_ 开头。在下面的应用程序中,action_ring_a_bell 的方法是一个作方法:

from textual.app import App class MyApp(App): def action_ring_a_bell(self): self.bell() MyApp().run()

The action method action_ring_a_bell implicitly defines an action called ring_a_bell, which is what comes after action_ in the method name. Now, we need to tell Textual that the action ring_a_bell should be triggered when the user presses the key B.
action 方法 action_ring_a_bell 隐式定义了一个名为 ring_a_bell 的作,该作位于方法名称中 action_ 之后。现在,我们需要告诉 Textual,当用户按下 键时,应该触发作 ring_a_bell B

Declaring a binding声明绑定

Applications have a class variable BINDINGS that can be used to bind keys to actions. In its simplest form, the class variable BINDINGS is a list of tuples, each tuple declaring a binding.
应用程序有一个类变量 BINDINGS,可用于将键绑定到作。在最简单的形式中,类变量 BINDINGS 是一个元组列表,每个元组声明一个绑定。

To bind the key B to the action ring_a_bell, all we need is the tuple ("b", "ring_a_bell"):
要将键 B 绑定到动作 ring_a_bell,我们只需要元组 (“b”、“ring_a_bell”)

from textual.app import App class MyApp(App): BINDINGS = [("b", "ring_a_bell")] def action_ring_a_bell(self): self.bell() MyApp().run()

If you run this app and press B, you should hear the system bell. This means you successfully created your first key binding! Take a moment to celebrate! Turn the volume of your computer up and hit the key B repeatedly!
如果您运行此应用程序并按 B ,您应该会听到系统铃声。这意味着您成功创建了第一个键绑定!花点时间庆祝一下!调高电脑音量并反复按按键 B

Action naming作命名

Just to be clear, Textual does not care about the specific name that you give to your action. The only thing that Textual needs is for you to be consistent across the declaration of the class variable BINDINGS and the name of your action method.
需要明确的是,Textual 并不关心您给您的作起的具体名称。Textual 唯一需要的就是在类变量 BINDINGS 的声明和作方法的名称之间保持一致。

For example, we could simplify the action name to bell:
例如,我们可以将作名称简化为 bell

from textual.app import App class MyApp(App): BINDINGS = [("b", "bell")] # vvvv ^^^^ def action_bell(self): self.bell() MyApp().run()

In order to let the user know which key bindings are available, Textual provides the widget Footer. The widget Footer will show the key bindings at the bottom of your application. For that, you need two things:
为了让用户知道哪些键绑定可用,Textual 提供了小部件页脚 。小部件页脚将在应用程序底部显示键绑定。为此,您需要两件事:

  1. you need to add the widget Footer to your app; and
    您需要将小部件页脚添加到您的应用程序中;和
  2. you need to add a description to your key binding.
    您需要为密钥绑定添加描述。

In the class attribute BINDINGS, the description of a key binding is the third element of the tuple that defines the binding. So, if we revisit the previous example, the code below will display the footer widget which will contain an indication that the key B will ring a bell:
在类属性 BINDINGS 中,键绑定的描述是定义绑定的元组的第三个元素。因此,如果我们重新访问前面的示例,下面的代码将显示页脚小部件,其中包含键 B 将响起铃铛的指示:

from textual.app import App from textual.widgets import Footer class MyApp(App): BINDINGS = [("b", "bell", "Ring")] def compose(self): yield Footer() def action_bell(self): self.bell() MyApp().run()

The image below shows what the footer looks like:
下图显示了页脚的外观:

A Textual app that shows a key binding in the widget footer after the binding was updated with a description.
A footer showing a binding.
显示绑定的页脚。

Dynamic widget creation动态小部件创建

So far, the only widgets we had in our apps were instantiated inside the method compose. However, widgets can also be added dynamically to your app. You do this by calling the app method mount.
到目前为止,我们应用程序中唯一的小部件是在方法 compose 中实例化的。但是,也可以将微件动态添加到您的应用中。为此,可以调用应用方法 mount

For example, we can modify the previous key binding to add a label to the application whenever the key B is pressed:
例如,我们可以修改之前的键绑定,以便在按下该键 B 时向应用程序添加标签:

from textual.app import App from textual.widgets import Footer, Label class MyApp(App): BINDINGS = [("b", "bell", "Ring")] def compose(self): yield Footer() def action_bell(self): self.bell() self.mount(Label("Ring!")) MyApp().run()

If you run this application and press B a couple of times, you should see some labels with the text “Ring!” show up, as the next image shows:
如果您运行此应用程序并按 B 几次,您应该会看到一些带有文本“响铃”的标签,如下图所示:

A Textual app that contains many widgets that were added dynamically thanks to the usage of the application method `mount`.
An app with labels added dynamically.
动态添加标签的应用。

Build your first TODO app prototype
构建您的第一个 TODO 应用程序原型

Quick recap快速回顾

Take a moment to stretch, you already learned a lot! Here is a quick recap of the things you already know:
花点时间伸展一下,你已经学到了很多东西!以下是您已经知道的事情的快速回顾:

  • Textual applications are created by inheriting from textual.app.App;
    文本应用程序是通过继承自 textual.app.App 创建的;
  • the app method compose is responsible for putting widgets on the screen;
    app 方法 compose 负责将小部件放在屏幕上;
  • the best way to use the method compose is by yielding the widgets you want to put in the app;
    使用 Compose 方法的最佳方法是生成要放入应用程序中的小部件;
  • widgets can be customised upon instantiation;
    小部件可以在实例化时进行自定义;
  • how to create compound widgets by way of using the widget's method compose;
    如何使用小部件的 Compose 方法创建复合小部件;
  • the class variable DEFAULT_CSS can be used to control the height of a compound widget;
    类变量 DEFAULT_CSS 可用于控制复合控件的高度;
  • methods can be bound to key presses via the app's class variable BINDINGS;
    方法可以通过应用程序的类变量 BINDINGS 绑定到按键;
  • an action method is a method whose name starts with action_;
    作方法是名称以 action_ 开头的方法;
  • key bindings can have a description and then shown in the app widget Footer; and
    键绑定可以有描述,然后显示在应用小部件页中;和
  • you can dynamically add a widget to an app with the app method mount.
    您可以使用应用程序方法挂载将微件动态添加到应用程序。

Challenge挑战

I want to challenge you to take everything you learned so far and create your first prototype of the TODO app we will be creating.
我想挑战您,将您迄今为止学到的所有知识并创建我们将创建的 TODO 应用程序的第一个原型。

Here are the requirements for this challenge:
以下是此挑战的要求:

  • your app should have a header and a footer;
    您的应用应该有一个页眉和一个页脚;
  • you should define a compound widget called TodoItem that is going to represent each entry in your app;
    您应该定义一个名为 TodoItem 的复合小部件,它将表示应用程序中的每个条目;
    • the TodoItem should have two labels, one for the description of the item and the other for the due date; and
      TodoItem 应该有两个标签,一个用于项目的描述,另一个用于截止日期;和
    • create the labels inside TodoItem with some dummy data like “I should get this done!” for the description and “dd/mm/yyyy” for the date.
      TodoItem 中创建标签,其中包含一些虚拟数据,例如描述的“我应该完成此作”,日期的“dd/mm/yyyy”。
  • create a key binding so that pressing N creates a new TodoItem that gets added to the app.
    创建一个键绑定,以便按下创建一个 N 新的 TodoItem,该 TodoItem 将添加到应用中。

If you follow all the requirements, run your app, and press N a couple of times, you should get something like this:
如果您遵循所有要求,运行您的应用程序,然后按 N 几次,您应该会得到如下内容:

A first prototype of a Textual TODO app that has a key binding that dynamically adds a compound widget to the app.
The first prototype of the app.
应用程序的第一个原型。

Code for the first prototype
第一个原型的代码

There are multiple ways to implement an application that behaves as defined above. The code below is my proposal:
有多种方法可以实现行为如上所述的应用程序。下面的代码是我的建议:

from textual.app import App from textual.widget import Widget from textual.widgets import Footer, Header, Label class TodoItem(Widget): DEFAULT_CSS = """ TodoItem { height: 2; } """ def compose(self): yield Label("I should get this done!") yield Label("dd/mm/yyyy") class TodoApp(App): BINDINGS = [("n", "new_item", "New")] def compose(self): yield Header(show_clock=True) yield Footer() def action_new_item(self): self.mount(TodoItem()) TodoApp().run()

Layout containers布局容器

One thing that you probably noticed already is that all the widgets we have been creating and composing are getting stacked vertically. However, there are times when we would prefer widgets stacked horizontally. We will learn to do this with containers.
您可能已经注意到的一件事是,我们一直在创建和组合的所有小部件都被垂直堆叠。但是,有时我们更喜欢水平堆叠的小部件。我们将学习使用容器来做到这一点。

In Textual, a container is a widget that groups many widgets. Typically, Textual containers provide a bit of extra functionality on top of the grouping.
在文本中,容器是将许多小部件分组的小部件。通常,文本容器在分组之上提供了一些额外的功能。

For example, the containers Horizontal and Vertical change how their child widgets get stacked. A container is used like a context manager and inside the context manager you just have to yield the children of that container. The app below gives an example:
例如,容器 HorizontalVertical 会更改其子控件的堆叠方式。容器的使用方式类似于上下文管理器,在上下文管理器中,您只需生成该容器的子级即可。下面的应用程序给出了一个示例:

from textual.app import App from textual.containers import Horizontal from textual.widgets import Label class MyApp(App): def compose(self): yield Label("first label!") with Horizontal(): yield Label("second label.") yield Label("third label...") MyApp().run()

If you run this app, you will see the first label on a line of its own. The second and third labels, which are now side-by-side, will be under the first one. The image below shows this:
如果您运行此应用程序,您将在自己的一行上看到第一个标签。第二个和第三个标签现在并排,将位于第一个标签下方。下图显示了这一点:

A Textual application that makes use of a horizontal container to change the layout of widgets on the screen.
Two labels inside a container.
容器内有两个标签。

Messages and message handling
消息和消息处理

What are Textual messages?
什么是短信?

Messages are how Textual notifies the app that something occurred. For example, when the user presses a button, Textual issues a message for that. If you want your application to do something whenever the user presses a button, you need to handle that message.
消息是 Textual 通知应用程序发生某些事情的方式。例如,当用户按下按钮时,Textual 会为此发出一条消息。如果希望应用程序在用户按下按钮时执行某些作,则需要处理该消息

Many built-in widgets define useful messages that are classes defined inside the widget namespace. For example,
许多内置小部件定义了有用的消息,这些消息是在小部件命名空间中定义的类。例如

  • for the widget Button, the message that is sent when the button is pressed is Button.Pressed; and
    对于小组件 Button,按下按钮时发送的消息是 Button.Pressed;和
  • for the widget Input, the message that is sent when the input value changes is Input.Changed.
    对于小组件 Input,输入值更改时发送的消息是 Input.Changed

Built-in widgets have all of their messages listed in the widgets reference. For example, if you open the widget Button reference you will find the Button.Pressed message.
内置小组件的所有消息都列在小组件参考中。例如,如果打开控件按钮引用 ,您将找到 Button.Pressed 消息。

What is left is knowing how to handle such messages.
剩下的就是知道如何处理此类消息。

Handler methods处理程序方法

When a message is posted – such as the message Button.Pressed when the user presses a button – Textual will look for a special method called a handler method. Such a handler method is a regular Python method with a special naming convention.
发布消息时(例如消息 Button.Pressed when the user 按下按钮) Textual 将查找称为处理程序方法的特殊方法。这样的处理程序方法是具有特殊命名约定的常规 Python 方法。

If you implement a method called on_button_pressed, Textual will call that method whenever a button is pressed.
如果实现名为 on_button_pressed 的方法,则每当按下按钮时,Textual 都会调用该方法。

To test this out, we can create a simple app with a single button and a method called on_button_pressed that will play the app bell. Here it is:
为了测试这一点,我们可以创建一个简单的应用程序,其中包含一个按钮和一个名为 on_button_pressed 的方法来播放应用程序铃。在这里:

from textual.app import App from textual.widgets import Button class MyApp(App): def compose(self): yield Button("Ring") def on_button_pressed(self): self.bell() MyApp().run()

Try running your app and pressing the button. You should hear a bell.
尝试运行您的应用程序并按下按钮。你应该听到铃声。

Handler method naming处理程序方法命名

A handler method is any method that follows this naming convention:
处理程序方法是遵循此命名约定的任何方法:

  • the name starts with on_; and
    名字以 on_ 开头;和
  • the name ends with the message namespace and message name in snake case.
    名称以消息命名空间和消息名称(蛇形大小写)结尾。

Here are two examples of handler methods:
以下是处理程序方法的两个示例:

  1. The message Button.Pressed is associated with button presses and it can be handled by a method called on_button_pressed.
    消息 Button.Pressed 与按钮按下相关联,可以通过名为 on_button_pressed 的方法进行处理。
  2. The message Input.Changed is associated with changes to an input field and it can be handled by a method called on_input_changed.
    消息 Input.Changed 与输入字段的更改相关联,可以通过名为 on_input_changed 的方法进行处理。

Try implementing a handler method for the message Input.Changed that also calls the app bell. If you do it correctly, you should hear a bell whenever you type inside your app's input field.
尝试为消息 Input.Changed 实现处理程序方法,该方法也调用应用铃铛。如果作正确,每当您在应用程序的输入字段中键入内容时,您都应该听到铃声。

We can do this if we yield an input inside compose and if we implement a method called on_input_changed:
如果我们在 compose 中产生输入并且实现一个名为 on_input_changed 的方法,我们就可以做到这一点:

from textual.app import App from textual.widgets import Button, Input class MyApp(App): def compose(self): yield Button("Ring") yield Input() def on_button_pressed(self): self.bell() def on_input_changed(self): self.bell() MyApp().run()

Custom messages自定义消息

You can also create custom messages by inheriting from textual.message.Message. Custom messages are especially useful when you create your own (compound) widgets.
您还可以通过继承 textual.message.Message 来创建自定义消息。当您创建自己的(复合)小部件时,自定义消息特别有用。

Messages are the correct way for widgets to communicate with the app. Thus, if we want to be able to edit and dismiss TODO items, we will need custom messages for that.
消息是小部件与应用程序通信的正确方式。因此,如果我们希望能够编辑和关闭待办事项,我们将需要自定义消息。

Create a message创建消息

Creating a message can be as simple as just inheriting from Message:
创建消息可以像继承 Message 一样简单:

from textual.message import Message class Ring(Message): pass

The code above defines a new message Ring. Now, we need to see how to use this message.
上面的代码定义了一个新消息 Ring。现在,我们需要看看如何使用这条消息。

Posting a message发布消息

In Textual, we talk about posting messages, which essentially means that widgets and apps get notified of things. If you have a message that you would like to propagate, you can call the method post_message on the widget/app that should receive the notification.
在 Text 中,我们谈论发布消息 ,这本质上意味着小部件和应用程序会收到通知。如果要传播消息,可以在应接收通知的小组件/应用上调用方法 post_message

For example, whenever the user presses the button of the app below, the app is notified of the message Ring.
例如,每当用户按下下面应用的按钮时,应用都会收到消息“ 响铃” 的通知。

from textual.app import App from textual.message import Message from textual.widgets import Button class Ring(Message): pass class MyApp(App): def compose(self): yield Button("Ring") def on_button_pressed(self): self.post_message(Ring()) MyApp().run()

Handling custom messages
处理自定义消息

You can handle custom messages in the same way as you would handle a built-in message. All you need to do is follow the naming convention.
您可以像处理内置消息一样处理自定义消息。您需要做的就是遵循命名约定。

The message Ring is not nested inside anything, so its handler method is called on_ring. If we add that method to the app, now the app rings whenever the button is pressed:
消息 Ring 没有嵌套在任何内容中,因此其处理程序方法称为 on_ring。如果我们将该方法添加到应用程序中,那么现在每当按下按钮时,应用程序都会响铃:

from textual.app import App from textual.message import Message from textual.widgets import Button class Ring(Message): pass class MyApp(App): def compose(self): yield Button("Ring") def on_button_pressed(self): self.post_message(Ring()) def on_ring(self): self.bell() MyApp().run()

Custom messages inside compound widgets
复合小部件中的自定义消息

When compound widgets need custom messages, those are typically defined inside the widget, much like the message Pressed for buttons is actually defined inside Button.
当复合控件需要自定义消息时,这些消息通常在控件中定义,就像消息 Pressed for buttons 实际上是在 Button 中定义的一样。

Supposing that we had a custom widget RingerWidget, then we could move the message Ring inside that widget:
假设我们有一个自定义小部件 RingerWidget,那么我们可以将消息 Ring 移动到该小部件中:

from textual.message import Message from textual.widget import Widget class RingerWidget(Widget): class Ring(Message): pass

By doing so, the handler method for the message RingerWidget.Ring now becomes on_ringer_widget_ring.
通过这样做,消息 RingerWidget.Ring 的处理程序方法现在变为 on_ringer_widget_ring

Self-removing widget自移除小部件

Much like you can add widgets dynamically to your application, you can also remove them. To do this, all you need to do is call the method remove on the widget.
就像您可以动态地将小部件添加到应用程序一样,您也可以删除它们。为此,您需要做的就是在小部件上调用方法 remove

We will use this method to implement a widget that requests its own deletion. To achieve this, we will also need to use a custom message. Here is the whole flow for this pattern:

  • the compound widget posts a message to request deletion;
  • the deletion request holds a reference to the widget that wants to be deleted; and
  • the app handles the deletion request by calling the method remove.

Here is the implementation of this pattern:

from textual.app import App from textual.message import Message from textual.widget import Widget from textual.widgets import Button class Deletable(Widget): class DeletionRequest(Message): def __init__(self, to_delete): super().__init__() self.to_delete = to_delete def compose(self): yield Button("Delete me.") def on_button_pressed(self): self.post_message(Deletable.DeletionRequest(self)) class MyApp(App): def compose(self): yield Deletable() def on_deletable_deletion_request(self, message): message.to_delete.remove() MyApp().run()

This may sound like a convoluted pattern. For example, why don't we just do self.remove() inside Deletable.on_button_pressed?

Unless the only purpose of your widget is to delete itself, you are better off delegating the deletion of the widget to the app. By using a custom message, you give the app a chance to react to the deletion request and handle it in the best way. For example, you may need to perform some cleanup before/after deleting the widget.

Reactive attributes

A reactive attribute is a Textual mechanism that lets you react to attribute changes.

Automatic UI updates

For example, the app below rings a bell whenever the attribute counter is increased. To do this, we define counter at the class level as a reactive instance and we implement a special method:

from textual.app import App from textual.reactive import reactive from textual.widgets import Button class MyApp(App): counter = reactive(0) def compose(self): yield Button("+1") def on_button_pressed(self): self.counter += 1 def watch_counter(self): self.bell() MyApp().run()

The line counter = reactive(0) sets the attribute counter to a reactive attribute and initialises it with the value 0.

The method watch_counter is a watch method. A watch method is a regular method that follows the naming convention of starting with the prefix watch_.

When the reactive attribute counter = reactive(0) is changed, Textual will look for an associated watch method and it will call it. In this case, the associated watch method is watch_counter.

If the reactive attribute were named foo, then the watch method would be watch_foo. The naming scheme is similar to that of actions.

A slightly more interesting variation of the app above uses a label to display the value of the counter. We just need to use the method update to update the value that the label is displaying:

from textual.app import App from textual.reactive import reactive from textual.widgets import Button, Label class MyApp(App): counter = reactive(0) def compose(self): self.label = Label() yield self.label yield Button("+1") def on_button_pressed(self): self.counter += 1 def watch_counter(self): self.label.update(str(self.counter)) MyApp().run()

Notice how we save a reference to the label inside the method compose so that we can refer to it inside watch_counter.

If you run this app and you press the button repeatedly, you will see the value of the label increase, like the animation below shows:

A Textual app that uses a reactive attribute to dynamically update a label.
Label updates powered by a reactive attribute.

Reactive attribute lifecycle

Reactives warrant a warning, though. Watch methods of reactive attributes will often interact with other widgets, possibly other reactive attributes, etc. This means that you need to make sure that those things already exist when you assign to the reactive attribute the first time.

For instance, if your reactive attribute needs to interact with other widgets, those widgets are typically initialised inside the method __init__. Thus, a safer version of the previous app would be the following:

from textual.app import App from textual.reactive import reactive from textual.widgets import Button, Label class MyApp(App): counter = reactive(0) def __init__(self): self.label = Label() super().__init__() def compose(self): yield self.label yield Button("+1") def on_button_pressed(self): self.counter += 1 def watch_counter(self): self.label.update(str(self.counter)) MyApp().run()

Decorator on

The decorator on is a convenience decorator that you can use to declare arbitrary message handlers that don't follow the message handling naming convention.

For example, in the previous app, we could have written a method increment_counter that increments the counter:

def increment_counter(self): self.counter += 1

Then, we could have used the decorator on to say that that method should be called whenever the message Button.Pressed is posted. Here is how you would do it, after importing the decorator on from textual:

from textual import on # !!! from textual.app import App from textual.reactive import reactive from textual.widgets import Button, Label class MyApp(App): counter = reactive(0) def compose(self): self.label = Label() yield self.label yield Button("+1") @on(Button.Pressed) # !!! def update_counter(self): self.counter += 1 def watch_counter(self): self.label.update(str(self.counter)) MyApp().run()

For such a simple use case, the decorator on isn't particularly advantageous when compared to the message handling naming convention.

However, there is another scenario in which the decorator on becomes an excellent alternative.

Motivation for the decorator on

We want to extend the previous app to include buttons that increment the counter by 10 and by 100. We can add those buttons to the app easily:

from textual import on from textual.app import App from textual.reactive import reactive from textual.widgets import Button, Label class MyApp(App): counter = reactive(0) def compose(self): self.label = Label() yield self.label yield Button("+1") yield Button("+10") yield Button("+100") @on(Button.Pressed) # !!! def increment_counter(self): self.counter += 1 def watch_counter(self): self.label.update(str(self.counter)) MyApp().run()

However, regardless of the button you press, the counter only increases by 1:

A Textual app that should have different behaviours for different button clicks but, instead, does the same thing on each button click.
Different buttons do the same thing.

We need to be able to tell the buttons apart and then we need to increment the counter accordingly.

Widget identifiers

To tell the buttons apart, we can assign a unique identifier to each one of them:

## ... class MyApp(App): counter = reactive(0) def compose(self): self.label = Label() yield self.label yield Button("+1", id="one") yield Button("+10", id="ten") yield Button("+100", id="hundred") ## ...

Then, we can use the optional parameter of the decorator on to specify that a given method should only be called if we get a message Button.Pressed from the button with the given identifier:

## ... class MyApp(App): counter = reactive(0) def compose(self): self.label = Label() yield self.label yield Button("+1", id="one") yield Button("+10", id="ten") yield Button("+100", id="hundred") @on(Button.Pressed, "#one") def plus_one(self): self.counter += 1 @on(Button.Pressed, "#ten") def plus_ten(self): self.counter += 10 @on(Button.Pressed, "#hundred") def plus_hundred(self): self.counter += 100 ## ...

Notice that, to filter the buttons by their identifier inside the decorator on, we need to prefix the identifier with the character #. There is a reason for this and that reason should become clear when you learn about Textual CSS.

A Textual screen is like a page in your application. Different screens can have different purposes.

In our TODO app, we are going to have the main screen that shows all the pending TODO items and we are going to have a second screen where the user can fill in the details (description and date) of a TODO item.

We will use modal screens for this because, if I'm being honest, modal screens look really cool.

Creating a modal screen

Creating a modal screen is essentially like creating a compound widget or an app: you need to create a class that inherits from textual.screen.ModalScreen and then you use the method compose to determine what widgets go up on that screen.

The class MyModalScreen implements a modal screen with a button:

from textual.screen import ModalScreen from textual.widgets import Button, Label class MyModalScreen(ModalScreen): def compose(self): yield Label("My modal screen") yield Button("Exit")

Showing a modal screen

Custom screens are used when you push them with the method push_screen. The method push_screen will push its argument screen on top of the current interface. You can push many screens on top of each other and Textual will keep track of the screen stack for you.

To show our modal screen, we will create a barebones app that pushes the modal screen when the app's button is pressed:

from textual.app import App from textual.screen import ModalScreen from textual.widgets import Button, Label class MyModalScreen(ModalScreen): def compose(self): yield Label("My modal screen") yield Button("Exit") class MyApp(App): def compose(self): yield Button("Push modal!") def on_button_pressed(self): self.push_screen(MyModalScreen()) MyApp().run()

After the modal is pushed, this is what the application looks like:

A Textual application showing a modal screen that looks a bit clunky because it hasn't been styled yet.
Your first modal screen.

Styling the modal appropriately

Above I wrote the following: “modal screens look cool”. However, the modal screen I just showed doesn't look that good, but that's straightforward to fix. We just add these four lines to our modal screen class:

from textual.screen import ModalScreen from textual.widgets import Button, Label class MyModalScreen(ModalScreen): # Added this: DEFAULT_CSS = """ MyModalScreen { align: center middle; } """ def compose(self): yield Label("My modal screen") yield Button("Exit")

With this simple addition, that is again a sneak peek into the Textual CSS feature that I'm about to show you, the modal screen immediately looks infinitely better. It doesn't look great yet, but we can already see that what makes the modal screen so nice is the background transparency that lets you see what is under the modal screen, just like the image below shows:

A Textual modal screen whose transparent background allows one to see the interface that is under the active modal screen.
A Textual modal screen.

Exiting a modal screen

The main way in which you can exit a modal screen is via the dismiss method. In our modal screen class, we can add a handler method for the message Button.Pressed and we can dismiss the screen inside that method:

from textual.app import App from textual.screen import ModalScreen from textual.widgets import Button, Label class MyModalScreen(ModalScreen): DEFAULT_CSS = """ MyModalScreen { align: center middle; } """ def compose(self): yield Label("My modal screen") yield Button("Exit") def on_button_pressed(self): self.dismiss()

If you run the app now, you can open the modal screen and then you can dismiss it to get back to your application.

Screen callback

The final thing you need to learn about (modal) screens is that they can return results via a callback system. When you push the screen to the stack with the method push_screen, you can provide an additional argument that is a callback function. This callback function will be called when you use the method dismiss to leave the screen. If you pass an argument to dismiss, that argument will be passed into the callback function.

For example, the modal screen below takes note of the timestamp of when it was dismissed and displays it in the main app interface:

import time from textual.app import App from textual.screen import ModalScreen from textual.widgets import Button, Label class MyModalScreen(ModalScreen): DEFAULT_CSS = """ MyModalScreen { align: center middle; } """ def compose(self): yield Label("My modal screen") yield Button("Exit") def on_button_pressed(self): self.dismiss(time.time()) # <-- class MyApp(App): def compose(self): yield Button("Push modal!") def on_button_pressed(self): self.push_screen(MyModalScreen(), self.modal_screen_callback) # <-- def modal_screen_callback(self, time): # <-- self.mount(Label(f"Modal dismissed at {time}.")) MyApp().run()

Enhance your TODO app prototype

Quick recap

Get up, take a little stroll, and give your eyes and brain a rest. That was plenty of information!

Over the past sections, you learned about:

  • layout containers such as Horizontal;
  • Textual messages;
  • handler methods and the associated naming convention with on_;
  • creating custom messages for compound widgets;
  • message posting via the method post_message;
  • dynamic widget removal via the method remove;
  • reactive attributes and watcher methods (watch_);
  • the decorator on to handle messages;
  • using identifiers to distinguish widgets and their events;
  • modal screens and how to create them; and
  • getting values from a screen by using a screen callback.

Second challenge

Now, I want you to take the things you just learned, and use them to improve the prototype you have from the first challenge. Bear in mind that the features I will ask you to add to the app do not require many lines of code but they will also not be trivial to implement if you are just starting out with Textual!

So, here are the features I would like for you to add to your app:

  • create a modal screen that asks for a description and a date (no need to do parsing/validation for now);
  • add two buttons to your TODO item compound widget:
    • one to dismiss the TODO item; and
    • the other to edit the TODO item.
  • use a layout container so that your TODO item compound widget looks better;
  • add custom messages that the two buttons should post when they are pressed;
  • handle the custom messages in the app so that:
    • one of the buttons of the TODO item removes the TODO item from the app; and
    • the other button opens the modal to update the TODO item.
  • add two reactives to the TODO item, one for the description and another for the due date, so that updating those will in turn update the labels in the widget;

Code for the second iteration

A possible implementation of an app as defined above follows:

from functools import partial from textual import on from textual.app import App from textual.containers import Horizontal from textual.message import Message from textual.reactive import reactive from textual.screen import ModalScreen from textual.widget import Widget from textual.widgets import Button, Footer, Header, Input, Label class TodoItemDetailsScreen(ModalScreen): DEFAULT_CSS = """ TodoItemDetailsScreen { align: center middle; } """ def compose(self): self.description_input = Input(placeholder="description") self.date_input = Input(placeholder="date") yield Label("Description:") yield self.description_input yield Label("Date:") yield self.date_input yield Button("Submit") def on_button_pressed(self): data = (self.description_input.value, self.date_input.value) self.dismiss(data) class TodoItem(Widget): DEFAULT_CSS = """ TodoItem { height: 2; } """ description = reactive("") date = reactive("") class Edit(Message): def __init__(self, item): super().__init__() self.item = item class Delete(Message): def __init__(self, item): super().__init__() self.item = item def __init__(self): super().__init__() self.description_label = Label() self.date_label = Label() def compose(self): with Horizontal(): yield Button("Delete", id="delete") yield Button("Edit", id="edit") yield self.description_label yield self.date_label def watch_description(self, description): self.description_label.update(description) def watch_date(self, date): self.date_label.update(date) @on(Button.Pressed, "#edit") def edit_request(self): self.post_message(self.Edit(self)) @on(Button.Pressed, "#delete") def delete_request(self): self.post_message(self.Delete(self)) class TodoApp(App): BINDINGS = [("n", "new_item", "New")] def compose(self): yield Header(show_clock=True) yield Footer() def action_new_item(self): self.push_screen(TodoItemDetailsScreen(), self.new_item_callback) def new_item_callback(self, data): item = TodoItem() description, date = data item.description = description item.date = date self.mount(item) def edit_item_callback(self, item, data): description, date = data item.description = description item.date = date def on_todo_item_delete(self, message): message.item.remove() def on_todo_item_edit(self, message): self.push_screen( TodoItemDetailsScreen(), partial(self.edit_item_callback, message.item) ) TodoApp().run()

textual-dev

In preparation for the feature you will learn next, Textual CSS, it is recommended that you install the Textual devtools. The Textual devtools include a command textual that contains many helpful tools for developers writing Textual applications.

If you are currently going through this tutorial inside a virtual environment, installing textual-dev may be as simple as running the command python -m pip install textual-dev. After you install textual-dev, make sure it worked by running textual --version. The output should be a version number equal to or above 0.29.0, which is the current version at the time of writing.

Textual CSS

Textual CSS is a feature that I teased a couple of times already and that is fundamental if you want your app to look good. Based on the browser CSS, Textual CSS is a language that allows you to customise the look of your app.

Textual supports dozens of styles, so the purpose of this section is not to go over all of them. This section will show you how to use Textual CSS and you are then free to browse the Textual styles reference to learn about all the styles available and their usage.

Adding CSS to elements

Most of the time, and especially if you are starting out, the best way to add CSS to your app, your screens, and your custom widgets, is via an external CSS file. Then, you hook the external CSS file to your app via the class variable CSS_PATH.

The path in the class variable CSS_PATH is always taken relative to the path of the file in which the app is defined. Thus, it is practical and common to have your app file in the same directory as your CSS file.

The app below shows how to define an app that will read its CSS from a file called basic_css.css:

from textual.app import App from textual.widgets import Label class MyApp(App): CSS_PATH = "basic_css.css" def compose(self): yield Label("This is some text!") MyApp().run()

Now, it is just a matter of knowing how the Textual CSS syntax works and you are good to go!

Textual CSS syntax

A Textual CSS file is composed of rule blocks. Each rule block is composed of:

  1. a selector;
  2. an opening bracket {;
  3. any number of styles; and
  4. a closing bracket }.

The styles are lines of the form style-name: value;, where the style name is as seen in the Textual styles reference and the valid values are also shown there.

A valid CSS block would be:

Label { background: red; width: 50%; content-align-horizontal: right; }

The block above will make sure that all labels have a red background, a width equal to 50% of the width of its container, and the text aligned on the right.

To test this out, save the CSS above in the file basic_css.css and then run your application. You should see a red label:

A Textual application with a label that was styled using Textual CSS stored in an external file
A label styled with Textual CSS.

Hot reloading

After having installed textual-dev, you got access to a command textual that has a subcommand run. The command textual run can be used to run your Textual applications as per usual. However, if you run the command textual run --dev, then you are running your application in development mode, which enables a very useful feature when you are using Textual CSS: hot reloading.

If you run your application with textual run --dev app_file.py, if you make changes to the CSS file, and if you save the CSS file, the changes should take effect in the app without having to restart the app. This is very convenient.

To try this out, run the app from before and change the background colours to blue or green, for example, or perhaps try different percentages for the value of width.

The GIF below shows some of these changes.

A GIF showing that changes in the external CSS file get reflected in the app immediately because of the hot reloading feature of `textual run --dev`.
Demo of the CSS hot reloading.

Basic selectors

To learn more about selectors, we'll start by defining an app with some labels:

from textual.app import App from textual.widgets import Label class MyApp(App): CSS_PATH = "label_css.css" def compose(self): yield Label("Plain.") yield Label("With a class", classes="blue_bg") yield Label("With an id", classes="blue_bg", id="label") MyApp().run()

Notice the two keyword parameters used for the last two labels. Now, paste the CSS below into the file label_css.css and run your app with hot reloading.

Label { background: red; }

Running the app, the three labels should have a red background.

Type selectors

The word Label in the CSS file is a CSS selector that targets all widgets that are instances of Label. In our app, that's the three widgets there. Keep in mind that such a selector targets all widgets that are of the type Label or that inherited from Label. So, for example, Label in the CSS file could be replaced by Widget, which will target the 3 labels (because they are widgets) but also the whole screen!

Fix this by changing it back to Label.

Class selectors

In Textual, we can use the parameter classes when creating a widget to add information to it regarding how it should be styled. The same CSS class can be applied across different widgets of different types, which can help ensure a consistent look across your app.

Open the CSS file and change it to include a second block:

Label { background: red; } .blue_bg { background: blue; }

When a selector starts with a dot, it will target all widgets that contain that class. If you save the CSS, your app should now have two labels with a blue background: the two bottom ones. This also shows that CSS rules have different levels of precedence, and a class rule has precedence over a regular type rule.

Identifier selectors

Finally, Textual CSS can also use a widget's identifier to target that widget specifically. For example, if you change the CSS file to include a third block, you will see that the bottom label will now have a green background:

Label { background: red; } .blue_bg { background: blue; } #label { background: green; }

Identifier selectors start with a # and take precedence over class selectors and type selectors.

With these three rules in place, the app looks like this:

A Textual app with three differently-coloured labels that showcase the basic Textual CSS selectors and their precedence rules.
Labels coloured via Textual CSS.

CSS selectors in other Textual features

CSS selectors are used by other Textual features. For example, we already saw that the decorator on uses selectors to further filter when to use certain message handlers. I just didn't tell you explicitly we were using CSS selectors.

Another feature you are learning next, querying, also uses CSS selectors.

Combining selectors

Selectors can be further combined to target more specific widgets:

  • selectors separated by spaces indicate nesting, for example Horizontal Label selects all labels that are nested inside a container Horizontal;
  • selectors separated > indicate immediate nesting, for example, Horizontal > Label selects all labels that are nested directly inside a container Horizontal;
  • selectors that are concatenated together select widgets that match all of those selectors, for example, Label.blue_bg will select all labels that also have the class blue_bg.
Horizontal Label { /* all labels that are nested inside a container `Horizontal`. */ background: red; } Horizontal > Label { /* all labels that are nested directly inside a container `Horizontal`. */ background: green; } Label.blue_bg { /* all labels that also have the class `blue_bg`. */ background: blue; }

Querying

Querying is a way in which you can access your app's widgets from within other methods. You can access a single widget or you can access a group of (related) widgets and then work on those.

Single-result queries

We can consider a simple app that asks the user for its name:

from textual.app import App from textual.widgets import Button, Header, Input class MyApp(App): def compose(self): yield Header(show_clock=True) yield Input(placeholder="Name:") yield Button("Submit") MyApp().run()

Now, we want to implement the Button.Pressed handler so that the app creates a label with the user name. Because the application has a single widget Input, we can use the method query_one to fetch it. The method query_one accepts a CSS selector.

After accessing the widget Input with query_one(Input), we can use the attribute value to get access to the text written inside the field. Like so:

from textual.app import App from textual.widgets import Button, Header, Input, Label class MyApp(App): def compose(self): yield Header(show_clock=True) yield Input(placeholder="Name:") yield Button("Submit") def on_button_pressed(self): name = self.query_one(Input).value self.mount(Label(name)) MyApp().run()

If you run the app, type your name inside the input field, and then click the button, the name you typed shows up in a new label under the button:

A Textual app showing an interaction with an input widget via the querying system.
Input submission with Textual.

Querying a single widget and saving an explicit reference to that widget before composing it are typically two orthogonal approaches to managing widget interactions. So far, we had been saving explicit references to all widgets we cared about as class variables. Now, you can get to them with query_one.

Querying multiple widgets

Sometimes, your application will have more than one widget that matches the query. When that is the case, using query_one will raise an error. That happens because query_one expects exactly one widget in the app to match the selector given.

In all other cases you must use the method query. This method returns an iterable with all the results. On top of allowing iteration, the result of a query provides some useful methods to interact with all the results. For example, the methods first and last can be used to access the first and last widgets that matched the query, respectively.

Here, we modified the previous app to include one more Input and then use query to access all inputs and their values:

from textual.app import App from textual.widgets import Button, Header, Input, Label class MyApp(App): def compose(self): yield Header(show_clock=True) yield Input(placeholder="Name:") yield Input(placeholder="Surname:") yield Button("Submit") def on_button_pressed(self): data = " ".join(input.value for input in self.query(Input)) self.mount(Label(data)) MyApp().run()

Workers

Workers are a life-changing feature that you will want to use for any app that uses some form of concurrency. In complex apps that interact with many external APIs, you will probably want to use workers.

The issue

There are also simpler cases where workers are really useful, for example when loading lots of data into your app. If you are not careful, you might write a loading method that blocks the interface while it is loading, which will make it look like it froze!

We're talking about this here for precisely the same reason. While it is unlikely that you will add enough TODO items to your app that loading them would take more than a split second, the type of application lends itself nicely to this pattern and so I will take this opportunity to show it.

Consider the application below that reads a file and creates a label for each line on the fly:

## import time from textual.app import App from textual.widgets import Button, Input, Label class MyApp(App): def compose(self): yield Input(placeholder="filepath") yield Button("Load!") def on_button_pressed(self): filepath = self.query_one(Input).value self.load_data(filepath) def load_data(self, filepath): with open(filepath, "r") as f: for line in f: # time.sleep(2) self.mount(Label(line.strip())) MyApp().run()

If the file being read is too big, there might be a significant amount of time during which nothing seems to happen in the application. What is more, you won't even be able to interact with the input field or the other widgets.

To see this in action, it suffices to add a call to time.sleep(2) inside the loop for line in f: and you will see the app frozen.

The decorator work

The decorator work will use a thread to circumvent this problem. While the details of the inner workings are beyond my knowledge right now, I can tell you that fixing our loading is trivial:

## import time from textual import work from textual.app import App from textual.widgets import Button, Input, Label class MyApp(App): def compose(self): yield Input(placeholder="filepath") yield Button("Load!") def on_button_pressed(self): filepath = self.query_one(Input).value self.load_data(filepath) @work def load_data(self, filepath): with open(filepath, "r") as f: for line in f: # time.sleep(2) self.call_from_thread(self.mount, Label(line.strip())) MyApp().run()

We only needed to make two changes:

  1. we added the decorator work around the method that we want to not block the interface; and
  2. we started using the method self.call_from_thread instead of calling the method self.mount directly.

That's because most Textual methods aren't thread safe, so we need to use self.call_from_thread to schedule the method calls.

Even with the calls to sleep, the app above shouldn't block. You should be able to interact with it while the application loads the file you pointed to. The animation below shows this. While there are two second intervals between each new label appearance, the remainder of the application remains responsive:

  • the button style changes when I hover it;
  • the input is still focusable; and
  • I can edit the contents of the input.
A Textual application using workers to perform a long operation while keeping the application responsive.
A responsive UI while performing a slow task.

on_mount

Still thinking about the same problem, there will be times where you want to trigger a load action “as soon as possible” without the user having to intervene. When that is the case, you will likely want to consider using the handler method on_mount.

When an app, a screen, or a widget is composed, that widget will receive a built-in event called Mount. Thus, inside the handler on_mount is the earliest moment at which you know the widget was already put on the screen.

In an example similar to the previous one, if your data comes from a fixed source (for example, a specific file), you can call the load method from inside on_mount:

## import time from textual import work from textual.app import App from textual.widgets import Label class MyApp(App): def on_mount(self): self.load_data() @work def load_data(self): with open("path/to/data", "r") as f: for line in f: # time.sleep(2) self.call_from_thread(self.mount, Label(line.strip())) MyApp().run()

This is usually better than loading the data inside __init__, for example, because when something is initialised it doesn't mean it will be composed anytime soon, so we might be wasting resources if we start loading the data right away.

Finish your TODO app

This was the third and final stretch, where you learned about features such as Textual CSS, hot reloading, querying, workers, and the event Mount. Now, I'd like you to use this knowledge to polish your TODO app:

  • add data persistence to your app (keep it simple, like a JSON file or something of the sort); and
  • style your app to make it look as awesome as you can.

In case you are not very imaginative, you can try to make your app look like mine, which I show below.

A Textual app showing a couple of TODO items with a clean and polished look.
Some TODO items.
A Textual modal screen customised with some CSS.
The modal screen of my app.

To make such short buttons, you will want to look at the style min-width. Button sets a default value of 16, so you'll want to overwrite that with a smaller value.

To style the modal screen, it may also help to put all of the widgets inside a plain textual.containers.Container.

Final code

The final code for the app amounts to 130 lines of awesomeness:

from functools import partial import json from textual import on, work from textual.app import App from textual.containers import Center, Container, Horizontal from textual.message import Message from textual.reactive import reactive from textual.screen import ModalScreen from textual.widget import Widget from textual.widgets import Button, Footer, Header, Input, Label class TodoItemDetailsScreen(ModalScreen): DEFAULT_CSS = """ TodoItemDetailsScreen { align: center middle; } """ def compose(self): self.description_input = Input(placeholder="Description") self.date_input = Input(placeholder="Due date dd/mm/yyyy") with Container(): yield Label("Type description and due date in the format dd/mm/yyyy.") yield self.description_input yield self.date_input with Center(): yield Button("Submit") def on_button_pressed(self): data = (self.description_input.value, self.date_input.value) self.dismiss(data) class TodoItem(Widget): DEFAULT_CSS = """ TodoItem { height: 2; } """ description = reactive("") date = reactive("") class Edit(Message): def __init__(self, item): super().__init__() self.item = item class Delete(Message): def __init__(self, item): super().__init__() self.item = item def __init__(self): super().__init__() self.description_label = Label(id="description") self.date_label = Label(id="date") def compose(self): with Horizontal(): yield Button("✅", classes="emoji-button", id="delete") yield Button("📝", classes="emoji-button", id="edit") yield self.description_label yield self.date_label def watch_description(self, description): self.description_label.update(description) def watch_date(self, date): self.date_label.update(date) @on(Button.Pressed, "#edit") def edit_request(self): self.post_message(self.Edit(self)) @on(Button.Pressed, "#delete") def delete_request(self): self.post_message(self.Delete(self)) class TodoApp(App): BINDINGS = [("n", "new_item", "New")] CSS_PATH = "todo.css" def compose(self): yield Header(show_clock=True) yield Footer() def on_mount(self): self.load_data() def action_new_item(self): self.push_screen(TodoItemDetailsScreen(), self.new_item_callback) def new_item_callback(self, data): item = TodoItem() description, date = data item.description = description item.date = date self.mount(item) def edit_item_callback(self, item, data): description, date = data item.description = description item.date = date def on_todo_item_delete(self, message): message.item.remove() def on_todo_item_edit(self, message): self.push_screen( TodoItemDetailsScreen(), partial(self.edit_item_callback, message.item) ) @work def save_data(self): to_dump = [(item.description, item.date) for item in self.query(TodoItem)] with open("data.json", "w") as f: json.dump(to_dump, f, indent=4) @work def load_data(self): with open("data.json", "r") as f: loaded = json.load(f) for description, date in loaded: item = TodoItem() item.description = description item.date = date self.call_from_thread(self.mount, item) TodoApp().run()

As for the CSS, this is what I have:

TodoItem > Horizontal > Label { width: 1fr; height: 1fr; content-align: left middle; } TodoItem > Horizontal > #date { width: 10; } TodoItem > Horizontal > * { margin: 0 1; } TodoItem { align: center middle; height: 3; } TodoItemDetailsScreen { align: center middle; } TodoItemDetailsScreen > Container > Label { width: 100%; padding-left: 1; padding-right: 1; } TodoItemDetailsScreen > Container > Input { margin: 1 } TodoItemDetailsScreen > Container { border: thick $background; background: $boost; width: 50%; height: auto; } .emoji-button { min-width: 4; width: 4; content-align: center middle; }

Further challenges

To conclude this tutorial, I'd like to leave you with a couple more challenges for you to tackle. None of these are extremely difficult from the technical point of view, but they will require some thinking and you will also probably have to look around in the documentation for missing pieces of the puzzle.

  • Auto-fill the modal screen when you are editing a TODO item;
  • Validate the date before dismissing the modal to prevent bad dates;
  • Use Esc to dismiss modal screen, which should revert changes if you were editing an item and simply do nothing if you were creating a new item;
  • Sort items by due date; and
  • Add a separate tab where you keep all the items that have been completed already.

Take a crack at this and then let me know how you get on! You can share your progress, and eventually get some help, in the Textual Discord!

Become a better Python 🐍 developer, drop by drop 💧

Get a daily drop of Python knowledge. A short, effective tip to start writing better Python code: more idiomatic, more effective, more efficient, with fewer bugs. Subscribe here.

Previous Post Next Post