18 KiB
Introduction
Welcome to the Textual Introduction!
By the end of this page you should have a good idea of the steps involved in creating an application with Textual.
!!! quote
This page goes in to more detail than you may expect from an introduction. I like documentation to have complete working examples and I wanted the first app to be realistic.
— **Will McGugan** (creator of Rich and Textual)
Stopwatch Application
We're going to build a stopwatch app. This app will display the elapsed time since the user hit a "Start" button. The user will be able to stop, resume, and reset each stopwatch in addition to adding or removing them.
This will be a simple yet fully featured app — you could distribute this app if you wanted to!
Here's what the finished app will look like:
Try the code
If you want to try this out before reading the rest of this introduction (we recommend it), navigate to "docs/examples/introduction" within the repository and run the following:
TODO: instructions how to checkout repo
python stopwatch.py
Type hints (in brief)
We're a big fan of Python type hints at Textualize. If you haven't encountered type hinting, its a way to express the types of your data, parameters, and returns. Type hinting allows tools like Mypy to catch potential bugs before your code runs.
The following function contains type hints:
def repeat(text: str, count: int) -> str:
return text * count
- Parameter types follow a colon, so
text: strmeans thattextshould be a string andcount: intmeans thatcountshould be an integer. - Return types follow
->So-> str:says that this method returns a string.
!!! note
Type hints are entirely optional in Textual. We've included them in the example code but it's up to you wether you add them to your own projects.
The App class
The first step in building a Textual app is to import and extend the App class. Here's our basic app class with a few methods which we will cover below.
--8<-- "docs/examples/introduction/stopwatch01.py"
If you run this code, you should see something like the following:
Hit the ++d++ key to toggle dark mode.
Hit ++ctrl+c++ to exit the app and return to the command prompt.
Looking at the code
Let's examine stopwatch01.py in more detail.
--8<-- "docs/examples/introduction/stopwatch01.py"
The first line imports the Textual App class. The second line imports two builtin widgets: Footer which shows available keys and Header which shows a title and the current time.
Widgets are re-usable components responsible for managing a part of the screen. We will cover how to build such widgets in this introduction.
--8<-- "docs/examples/introduction/stopwatch01.py"
The App class is where most of the logic of Textual apps is written. It is responsible for loading configuration, setting up widgets, handling keys, and more.
There are three methods in our stopwatch app currently.
-
compose()is where we construct a user interface with widgets. Thecompose()method may return a list of widgets, but it is generally easier to yield them (making this method a generator). In the example code we yield instances of the widget classes we imported, i.e. the header and the footer. -
on_load()is an event handler method. Event handlers are called by Textual in response to external events like keys and mouse movements, and internal events needed to manage your application. Event handler methods begin withon_followed by the name of the event (in lower case). Hence,on_loadit is called in response to the Load event which is sent just after the app starts. We're using this event to callApp.bind()which connects a key to an action. -
action_toggle_dark()defines an action method. Actions are methods beginning withaction_followed by the name of the action. The call tobind()inon_load()binds this the ++d++ key to this action. The body of this method flips the state of thedarkboolean to toggle dark mode.
!!! note
You may have noticed that the the `toggle_dark` doesn't do anything to explicitly change the _screen_, and yet hitting ++d++ refreshes and updates the whole terminal. This is an example of _reactivity_. Changing certain attributes will schedule an automatic update.
--8<-- "docs/examples/introduction/stopwatch01.py"
The last lines in "stopwatch01.py" may be familiar to you. We create an instance of our app class, and call run() within a __name__ == "__main__" conditional block. This is so that we could import app if we want to. Or we could run it with python stopwatch01.py.
Creating a custom widget
The header and footer are builtin widgets. For our Stopwatch application we will need to build custom widgets.
Let's sketch out a design for our app:
We will need to build a Stopwatch widget composed of the following child widgets:
- A "start" button
- A "stop" button
- A "reset" button
- A time display
Textual has a builtin Button widgets which takes care of the first three components. All we need to build is the time display which will show the elapsed time in HOURS:MINUTES:SECONDS format, and the stopwatch itself.
Let's add those to the app. Just a skeleton for now, we will add the rest of the features as we go.
--8<-- "docs/examples/introduction/stopwatch02.py"
Extending widget classes
We've imported two new widgets in this code: Button, which creates a clickable button, and Static which is a base class for a simple control. We've also imported Container from textual.layout. As the name suggests, Container is a Widget which contains other widgets. We will use this container to create a scrolling list of stopwatches.
We're extending Static as a foundation for our TimeDisplay widget. There are no methods on this class yet.
The Stopwatch class also extends Static to define a new widget. This class has a compose() method which yields its child widgets, consisting of of three Button objects and a single TimeDisplay. These are all we need to build a stopwatch as in the sketch.
The Button constructor takes a label to be displayed in the button ("Start", "Stop", or "Reset"). There are two additional parameters to the Button constructor we are using:
idis an identifier we can use to tell the buttons apart in code and apply styles. More on that later.variantis a string which selects a default style. The "success" variant makes the button green, and the "error" variant makes it red.
Composing the widgets
To see our widgets with we first need to yield them from the app's compose() method:
The new line in Stopwatch.compose() yields a single Container object which will create a scrolling list. When classes contain other widgets (like Container) they will typically accept their child widgets as positional arguments. We want to start the app with three stopwatches, so we construct three Stopwatch instances as child widgets of the container.
The unstyled app
Let's see what happens when we run "stopwatch02.py":
The elements of the stopwatch application are there. The buttons are clickable and you can scroll the container, but it doesn't look much like the sketch. This is because we have yet to apply any styles to our new widget.
Writing Textual CSS
Every widget has a styles object which contains information regarding how that widget will look. Setting any of the attributes on that styles object will change how Textual displays the widget.
Here's how you might set white text and a blue background for a widget:
self.styles.background = "blue"
self.styles.color = "white"
While its possible to set all styles for an app this way, Textual prefers to use CSS.
CSS files are data files loaded by your app which contain information about styles to apply to your widgets.
!!! note
Don't worry if you have never worked with CSS before. The dialect of CSS we use is greatly simplified over web based CSS and easy to learn!
Let's add a CSS file to our application.
--8<-- "docs/examples/introduction/stopwatch03.py"
Adding the css_path attribute to the app constructor tells textual to load the following file when it starts the app:
--8<-- "docs/examples/introduction/stopwatch03.css"
If we run the app now, it will look very different.
This app looks much more like our sketch. Textual has read style information from stopwatch03.css and applied it to the widgets.
CSS basics
CSS files contain a number of declaration blocks. Here's the first such block from stopwatch03.css again:
Stopwatch {
layout: horizontal;
background: $panel-darken-1;
height: 5;
padding: 1;
margin: 1;
}
The first line tells Textual that the styles should apply to the Stopwatch widget. The lines between the curly brackets contain the styles themselves.
Here's how the Stopwatch block in the CSS impacts our Stopwatch widget:
layout: horizontalaligns child widgets horizontally from left to right.background: $panel-darken-1sets the background color to$panel-darken-1. The$prefix picks a pre-defined color from the builtin theme. There are other ways to specify colors such as"blue"orrgb(20,46,210).height: 5sets the height of our widget to 5 lines of text.padding: 1sets a padding of 1 cell around the child widgets.margin: 1sets a margin of 1 cell around the Stopwatch widget to create a little space between widgets in the list.
Here's the rest of stopwatch03.css which contains further declaration blocks:
TimeDisplay {
content-align: center middle;
opacity: 60%;
height: 3;
}
Button {
width: 16;
}
#start {
dock: left;
}
#stop {
dock: left;
display: none;
}
#reset {
dock: right;
}
The TimeDisplay block aligns text to the center (content-align), fades it slightly (opacity), and sets its height (height) to 3 lines.
The Button block sets the width (width) of buttons to 16 cells (character widths).
The last 3 blocks have a slightly different format. When the declaration begins with a # then the styles will be applied widgets with a matching "id" attribute. We've set an ID attribute on the Button widgets we yielded in compose. For instance the first button has id="start" which matches #start in the CSS.
The buttons have a dock style which aligns the widget to a given edge. The start and stop buttons are docked to the left edge, while the reset button is docked to the right edge.
You may have noticed that the stop button (#stop in the CSS) has display: none;. This tells Textual to not show the button. We do this because we don't want to dsplay the stop button when the timer is not running. Similarly we don't want to show the start button when the timer is running. We will cover how to manage such dynamic user interfaces in the next section.
Dynamic CSS
We want our Stopwatch widget to have two states: a default state with a Start and Reset button; and a started state with a Stop button. When a stopwatch is started it should also have a green background and bold text.
We can accomplish this with a CSS class. Not to be confused with a Python class, a CSS class is like a tag you can add to a widget to modify its styles.
Here's the new CSS:
--8<-- "docs/examples/introduction/stopwatch04.css"
These new rules are prefixed with .started. The . indicates that .started refers to a CSS class called "started". The new styles will be applied only to widgets that have these styles.
Some of the new styles have more than one selector separated by a space. The space indicates that the rule should match the second selector if it is a child of the first. Let's look at one of these styles:
.started #start {
display: none
}
The purpose of this CSS is to hide the start button when the stopwatch has started. The .started selector matches any widget with a "started" CSS class. While "#start" matches a child widget with an id of "start". The rule is applied to the button, so "display: none" tells Textual to hide the button.
Manipulating classes
The easiest way to manipulate visuals with Textual is to modify CSS classes. This way your (Python) code can remain free of display related code which tends to be hard to maintain.
You can add and remove CSS classes with the add_class() and remove_class() methods. We will use these methods to connect the started state to the Start / Stop buttons.
The following code adds a event handler for the Button.Pressed event.
--8<-- "docs/examples/introduction/stopwatch04.py"
The on_button_pressed event handler is called when the user clicks a button. This method adds the "started" class when the "start" button was clicked, and removes the class when the "stop" button is clicked.
If you run "stopwatch04.py" now you will be able to toggle between the two states by clicking the first button:
Reactive attributes
A reoccurring theme in Textual is that you rarely need to explicitly update a widget. It is possible: you can call refresh() to display new data. However, Textual prefers to do this automatically via reactive attributes.
You can declare a reactive attribute with textual.reactive.Reactive. Let's use this feature to create a timer that displays elapsed time and keeps it updated.
--8<-- "docs/examples/introduction/stopwatch05.py"
Here we have created two reactive attributes: start_time and time. These attributes will be available on self as if you had assigned them in __init__. If you write to either of these attributes the widget will update automatically.
!!! info
`Reactive` is an example of a Python _descriptor_, which allows you to dynamically create properties.
The first argument to Reactive may be a default value, or a callable that returns the default value. In the example, the default for start_time is monotonic which is a function that returns the time. When TimeDisplay is mounted the start_time attribute will automatically be assigned the value returned by monotonic().
The time attribute has a simple float as the default value, so self.time will be 0 on start.
To update the time automatically we will use the set_interval method which tells Textual to call a function at given intervals. The on_mount method does this to call self.update_time 60 times a second.
In update_time we calculate the time elapsed since the widget started and assign it to self.time. Which brings us to one of Reactive's super-powers.
If you implement a method that begins with watch_ followed by the name of a reactive attribute (making it a watch method), that method will be called when the attribute is modified.
Because watch_time watches the time attribute, when we update self.time 60 times a second we also implicitly call watch_time which converts the elapsed time in to a string and updates the widget with a call to self.update.
The end result is that all the Stopwatch widgets show the time elapsed since the widget was created:
We've seen how we can update widgets with a timer. But we still need to wire buttons to the widget
Wiring the Stopwatch
To make a useful stopwatch we will need to add a little more code to TimeDisplay, to be able to start, stop, and reset the timer.
--8<-- "docs/examples/introduction/stopwatch06.py"
Here's a summary of the changes made to TimeDisplay.
- We've added a
totalreactive attribute to store the total time elapsed between clicking Stop and Start. - The call to
set_intervalhas grown apause=Trueattribute which starts the timer in pause mode. This is because we don't want to update the timer until the user hits the Start button. - We've stored the result of
set_intervalwhich returns a timer object. We will use this later to resume the timer when we start the Stopwatch. - We've added
start(),stop(), andreset()methods.
The on_button_pressed method on Stopwatch has grown some code to manage the time display when the user clicked a button. Let's look at that in detail:
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Event handler called when a button is pressed."""
time_display = self.query_one(TimeDisplay)
if event.button.id == "start":
time_display.start()
self.add_class("started")
elif event.button.id == "stop":
time_display.stop()
self.remove_class("started")
elif event.button.id == "reset":
time_display.reset()
This code supplies the missing features and makes our app really useful. If you run it now you can start and stop timers independently.
- The first line calls
query_oneto get a reference to theTimeDisplaywidget. This method queries for a child widget. You may supply a Widget type or a CSS selector. - We call the
TimeDisplaymethod that matches the button pressed. - We add the "started" class when the Stopwatch is started, and remove it when it is stopped.