Skip to content

Tutorial

Welcome to the Textual Tutorial!

By the end of this page you should have a solid understanding of app development with Textual.

Quote

If you want people to build things, make it fun.

Will McGugan (creator of Rich and Textual)

Stopwatch Application

We're going to build a stopwatch application. This application should show a list of stopwatches with buttons to start, stop, and reset the stopwatches. We also want the user to be able to add and remove stopwatches as required.

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:

StopwatchApp StopwatchApp ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Start 00:00:00.51 Reset  ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Stop 00:00:00.47 ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Stop 00:00:00.27 ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁  D  Toggle dark mode  A  Add  R  Remove 

Get the code

If you want to try the finished Stopwatch app and follow along with the code, first make sure you have Textual installed then check out the Textual repository:

git clone https://github.com/Textualize/textual.git
git clone git@github.com:Textualize/textual.git
gh repo clone Textualize/textual

With the repository cloned, navigate to docs/examples/tutorial and run stopwatch.py.

cd textual/docs/examples/tutorial
python stopwatch.py

Type hints (in brief)

Tip

Type hints are entirely optional in Textual. We've included them in the example code but it's up to you whether you add them to your own projects.

We're a big fan of Python type hints at Textualize. If you haven't encountered type hinting, it's a way to express the types of your data, parameters, and return values. Type hinting allows tools like mypy to catch bugs before your code runs.

The following function contains type hints:

def repeat(text: str, count: int) -> str:
    """Repeat a string a given number of times."""
    return text * count

Parameter types follow a colon. So text: str indicates that text requires a string and count: int means that count requires an integer.

Return types follow ->. So -> str: indicates this method returns a string.

The App class

The first step in building a Textual app is to import and extend the App class. Here's a basic app class we will use as a starting point for the stopwatch app.

stopwatch01.py
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer


class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    BINDINGS = [("d", "toggle_dark", "Toggle dark mode")]

    def compose(self) -> ComposeResult:
        """Create child widgets for the app."""
        yield Header()
        yield Footer()

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        self.dark = not self.dark


if __name__ == "__main__":
    app = StopwatchApp()
    app.run()

If you run this code, you should see something like the following:

StopwatchApp StopwatchApp  D  Toggle dark mode 

Hit the D key to toggle between light and dark mode.

TimerApp + dark StopwatchApp  D  Toggle dark mode 

Hit Ctrl+C to exit the app and return to the command prompt.

A closer look at the App class

Let's examine stopwatch01.py in more detail.

stopwatch01.py
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer


class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    BINDINGS = [("d", "toggle_dark", "Toggle dark mode")]

    def compose(self) -> ComposeResult:
        """Create child widgets for the app."""
        yield Header()
        yield Footer()

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        self.dark = not self.dark


if __name__ == "__main__":
    app = StopwatchApp()
    app.run()

The first line imports the Textual App class, which we will use as the base class for our App. The second line imports two builtin widgets: Footer which shows a bar at the bottom of the screen with bound keys, and Header which shows a title at the top of the screen. Widgets are re-usable components responsible for managing a part of the screen. We will cover how to build widgets in this tutorial.

The following lines define the app itself:

stopwatch01.py
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer


class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    BINDINGS = [("d", "toggle_dark", "Toggle dark mode")]

    def compose(self) -> ComposeResult:
        """Create child widgets for the app."""
        yield Header()
        yield Footer()

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        self.dark = not self.dark


if __name__ == "__main__":
    app = StopwatchApp()
    app.run()

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.

Here's what the above app defines:

  • BINDINGS is a list of tuples that maps (or binds) keys to actions in your app. The first value in the tuple is the key; the second value is the name of the action; the final value is a short description. We have a single binding which maps the D key on to the "toggle_dark" action. See key bindings in the guide for details.

  • compose() is where we construct a user interface with widgets. The compose() 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 an instance of each of the widget classes we imported, i.e. Header() and Footer().

  • action_toggle_dark() defines an action method. Actions are methods beginning with action_ followed by the name of the action. The BINDINGS list above tells Textual to run this action when the user hits the D key. See actions in the guide for details.

stopwatch01.py
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer


class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    BINDINGS = [("d", "toggle_dark", "Toggle dark mode")]

    def compose(self) -> ComposeResult:
        """Create child widgets for the app."""
        yield Header()
        yield Footer()

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        self.dark = not self.dark


if __name__ == "__main__":
    app = StopwatchApp()
    app.run()

The final three lines create an instance of the app and calls the run() method which puts your terminal in to application mode and runs the app until you exit with Ctrl+C. This happens within a __name__ == "__main__" block so we could run the app with python stopwatch01.py or import it as part of a larger project.

Designing a UI with widgets

Textual comes with a number of builtin widgets, like Header and Footer, which are versatile and re-usable. We will need to build some custom widgets for the stopwatch. Before we dive in to that, let's first sketch a design for the app — so we know what we're aiming for.

eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nNVcXGlP40pcdTAwMTb93r9cdTAwMDIxX2akxq/2paXRqFx1MDAwM4R9XHUwMDBiW8PMXHUwMDEzMrFDXGbxguNA4Kn/+5SddOwktuM4S/tFLVx1MDAxYVxcjuu66tx77lJVf33Z2NhcZj48c/PbxqbZb+pcdTAwMWTL8PX3za/h9TfT71quo5pQ9HfX7fnN6M52XHUwMDEweN1vf/xh6/6LXHUwMDE5eFx1MDAxZL1pam9Wt6d3ukHPsFxcrenaf1iBaXf/XHUwMDEz/jzVbfPfnmtcdTAwMWKBr8WdbJmGXHUwMDE1uP6gL7Nj2qZcdTAwMTN01dP/q/7e2Pgr+pmQzjebge48dczoXHUwMDBiUVMsIFx1MDAwNmzy6qnrRMJcbi5cdTAwMDWFXGaR0VxyVndHdVx1MDAxN5iGam0pkc24Jby0+aizxo/G/VPr6uX83Hu/ueqSPo97bVmdzmXw0Ymk6rrqZeK2buC7L+atZVx1MDAwNG3VXG4nrmd9y3d7T23H7HbHvuN6etNcbj7Ca1x1MDAwMIyuXHUwMDBlhuDbRnylXHUwMDFmTlx1MDAxMCdcdTAwMWFkXHUwMDAwUoboqCH8KpJcXCOEYpi4Plx1MDAxMGbb7ajBV8L8XHUwMDAzRJ9YnEe9+fKkZHKM+Fx1MDAxZVx1MDAwMpqAJlx1MDAwNuF9+IqqQ1xyy4lcdTAwMGXapvXUXHUwMDBlXHUwMDA2gmtcdTAwMWNcbp7o24yGXHUwMDFkXCLEOUFcYqNRS9ijd2BEXGL4c3Lg2rrvXHJcdTAwMDdos1x1MDAxYv6RkDZcdTAwMTR0d1x1MDAxMj5JXGIlpvag2/C2m5+HRzdcdTAwMTf1t7uL8114vnc4etZcdTAwMTjedN933zdHLT+Hv8Wi9TxDXHUwMDFmgFxiMlx1MDAwNiRcdTAwMTaSXHUwMDEwgmNcdTAwMWN2LOdFNTq9Tie+5jZfYtxFV39+LYF3gmBcdTAwMTbeJVx1MDAxNYJQSorj3XrEnty37P7JsSlcdTAwMWT97NV7/VGvON5cdTAwMDXRXHUwMDA0wXxcdTAwMWPsXHUwMDE4YlxyYyqT18uAvaVTRNE02JWGTWOcsSlwc8FcdTAwMDRUUCDrXHUwMDAy9y/MXHUwMDA0Zj9cdTAwMThH82CG67KPb+3+/bFcclx1MDAwZnZunFx1MDAwN3v/sL01XHUwMDE3tlx1MDAxOVx1MDAwNVxigWVhe0zOYmZcdTAwMWMqXHUwMDExXHUwMDE4R1x1MDAxY4nCuE5/63RcXLf1Zrvnm1VAtkxDNlZ4X1x1MDAxY9mBrztdT/dcdTAwMTWaUtBNU9CN8LTpJlAqky9WgO5lXHUwMDAyMJ5n11x0Lq3PcKhcdTAwMTNcdTAwMGZcYq/WddvqfIxNVYRMJell4CZcdTAwMDXVu6bqMcIhXHUwMDFmu/d7x3pcbpG72VTvYPpjoFx1MDAwZSzl6oxusC3DSFx1MDAxYfOmXHUwMDEyQFfP9Fx1MDAwZopcdTAwMThh17eeLEfvXFwl5SvPXHUwMDFmjMlM/lAug0SCo8J65lx1MDAxY2/3t06R6Fx1MDAxY8BG42brXHUwMDAzXFw1/I9q81x1MDAwN2NEXHUwMDAzWIJJd4lgpPRcZoGF/aWmaVx1MDAxMEMvSiFiSskgR8q1XHUwMDEyZG1cdTAwMTQymMpeXHUwMDFmn+zsideL9zuj9tK6sE9/bDfS/aNIU2Jcbvma/thZzJTeYXFmXHUwMDEyWPmXWMaTtVwiZqJcdOM4yUycXHUwMDAzyVxiT8zsLI3JXHUwMDFm5ooyk7JcdTAwMTnpOkOp8rpcdTAwMTbXmaWQXHUwMDEzXHUwMDA3XGIoXHUwMDFkS7jhqyenXHUwMDEyXHUwMDE4XFyMnFx1MDAxYWbXXGbWyk4zTPwkO1xyXHUwMDA0LE9PMGFcdTAwMTEntI1wXGKQXHUwMDE0rHh4c/zs9smja183ru5Ojp73jWtcdTAwMTI0q01PhCtVo2Lc34v8QMQ1oMhhPKYuXHUwMDEz4kSfND3DXHUwMDFhnExcdTAwMTiMXHUwMDE0XHUwMDBlU02OO6dDveMq5FRcdTAwMTaArkDtyvHKx0n/ljx8Xlx1MDAwN96xW/NZv+18st1cbkY8kmRcdTAwMDFcdTAwMWQyjpVZ4ax4wJP+0lx1MDAxNadcdTAwMTVcdTAwMTVIZGBd+WJ0knCWzitcdTAwMDRPwzyFV6hcdTAwMDQq5GGr8MeqXHUwMDEz9Fx1MDAwMPAt/Mc1XHUwMDA018ouMyz0JLskxSzPMVhmptCgQFxicVxuOSusende//LkZGf39uiqVoeg3Ttv+8e/k2RwkZQxXHUwMDA1krOpnDGBQmOccrGqIIgypnHO8Vx1MDAxOJOMJY1cdTAwMDVcdTAwMWRPZY/CXCJcdTAwMDRcdTAwMTX/XHUwMDEwsVYtXHUwMDE0XHUwMDEwYonm0MLyoKRcdTAwMTRngpJcdTAwMTPJkVx1MDAwNKQ4XHUwMDFmyGOhO/unXHKf1M97bbttXl1cdTAwMWZUvZAhqFx1MDAwNlx1MDAwNaWEXHQ+XHUwMDE5mlx1MDAxM00uI7ubVcoomt1ccrP8gvLEq6wlNP++3Xpr2O32XHUwMDE2Mz6fejY4erg74uOuz3JD8/RcdTAwMGWr50IpK5KlM0x5XHUwMDBmXHUwMDAwwDlcXKj8Ua6oXHUwMDBipaxCltJQppElKM1yYnMsXHUwMDAwXHUwMDEwfFx1MDAxNTW/6vhQl4Hurzc2n2HlpzPHoYBcdTAwMGL4TSiborCQiOE5XHUwMDEyYW7t5NW8QPzItnuNeqv/dnyB33+vuvFcIsE5XHUwMDAxqZkwXCKVT1xuVqxspVx1MDAwMnRKwuhFrrve/iDfL8+O/Ju9k7bVMPZcdTAwMGZcdTAwMDS+P2uvkrTSO6xcdTAwMWVpIZpZgFGBP8CcUFA8xZU/zFx1MDAxNVUjXHUwMDE192eoXHUwMDExpVx1MDAxYV9xOrlY2C9cdFx1MDAxMpJSuM5s8lx1MDAxMIFwXHUwMDBlXHUwMDA0LsZYw3BcdTAwMWFo41x1MDAwM7py3pph+zOi/kjM8uyFWKazKFx1MDAxOEZcdTAwMDBcdTAwMTFQ3Fl8RkivfaKzXHUwMDFh3vGI34JXd82b/d9cdTAwMTlfkdlBP9Mog1x1MDAxNOGpJVx1MDAwNsroaVx1MDAwYkf8Lf1cdTAwMTFcdTAwMDCaXHUwMDE28XNccipdXHUwMDFh0/WR4il9R1igsPeoXHUwMDBmOKmHXGJcdTAwMTBlXHUwMDEwMV1fOXRcdTAwMTbP6PpcdTAwMTXbu7o/OFx1MDAwMSe39nZw+eRcdTAwMWScXs63WkxIXHRji7NcIp6BIDvBLJByJOZYXHUwMDE3mf7O6XBv+m63u9XWg2b794NewEzQh1x1MDAwZVx1MDAxM4aMrDTJTCmfXHUwMDA2fUqAxCRcdTAwMDJUKcl6k8xzXHUwMDAzcTG62Td1I0lcdTAwMWJrYJpcdTAwMTl2epJphlx1MDAxMpYnXHUwMDE5XG4zSVx1MDAwNlwiyKOca2Gtc+y3k8tcdTAwMTf48Irk+71OwFx1MDAxYjx9LpVZRkvSNzpb34CGXGKcjIRcIq9cdTAwMTAqfWNkXCJEKUE0XGKJR5OlXHUwMDExXHKZVjWcsohcciNcdTAwMTLSUWVcYuU2QPrdzsOWj/pta5/dP+3hXHUwMDA3ez5CkYrd4/dZVeDCM0vzkGEogUCgePyf/ta/nVJcbkCcZkJcXPlRy4D4XGZGSYF5SvxcdTAwMTKuhyFcdTAwMDCtmVDmXHUwMDA14mKEUnfdYM2EMsMmT1x1MDAxMspQwkVcYiUn56ZAiOZSujP60NxvdY1cdTAwMWYt1Kyf39qn/aB5U/FaJdOYXHUwMDEwJKVYSTHXplLf1ShWXCLMJcOYraBamc4xw1x1MDAxMsZcdTAwMWU7O3uxrf7lNjk6291BXHUwMDFl+Vx1MDAxMOk5t1x1MDAxMntcXFx1MDAxMGF8PbVQxrM3XHUwMDAzIE64ilx1MDAwN+fINNvPp/a2d3x0dF4zOvt35lPt8sWpei2Ua5hcdTAwMTE0UYn/XHUwMDE42H9ccirVXzhgX7RcdTAwMTgqiFRTQcSai6E7XHI9eDyy2s9cdTAwMTdcdTAwMTf711x1MDAwZl3y2q5jP1x1MDAxZOPF8soreuwsry+9w3lcdTAwMTRS4DCHumqvj+akq7lcdTAwMDBcdTAwMTBcdFx1MDAwNouHNPnDXFzZXCIrytRGTjW2XHUwMDA0bVxcSpVcdTAwMTVcYkokoGtdXHUwMDAxXVx1MDAwMoaL+Xzrr7LO4I9lV1lcdMkmP4wogpTC4jt0asfXzkO7Z+yC163vV/XWNdjln5WvXHUwMDBmQY1yXHRTNjVTJrRVr1x1MDAwYi1XZoWIXHUwMDAxiClfRVxuL4+4LpqH6PFwz3Vfnlxm9/Ni79Opi93F+XDpj53Fh+lcdTAwMWRWj1x1MDAwZrHIXFw8ijBcdTAwMDVcXMGmeFx1MDAxOSl/lKuqnSxTOznRhFgxXHUwMDE5XHUwMDE2q98qXHUwMDE2RGqq1rxVdc1cXPi76rczSKV0/TYz84hpls5cdTAwMTGBJZtrb3jt7HnLe39/dPmLrd95dYs9YiND59aSVZ/tgEpGNSogSFnmR2G4LUjmXHUwMDFm84FcdNZJ2W2rXHUwMDAwpejbXHUwMDE0/SGiXGaCSM5D1TNcdTAwMWW59Cdr37dcdTAwMGbPXHUwMDFmxPfDmjh8faa017r/sbRMXG6lXHUwMDAwr6/sXHUwMDE2blx1MDAxOH9cdTAwMWbPnSdthFx1MDAxOPvCyFx1MDAwNHTMVpBjIcY2yY+bh7FcdTAwMTdJ270+XHUwMDEwJtdcdTAwMTJcZlx1MDAwNjWNfrOPP1FcdTAwMDAkmPB5zvvJn+Zq2lx1MDAwMlxuNFxmXHUwMDA1Y1x1MDAwMmMsMJw0XHUwMDA3QoOYhVx1MDAwYiZouFtyNTaBizBcdTAwMDWFZbhcdTAwMWSR8OSBXHUwMDAx8cJcdTAwMGWoQYSEomGGpVx1MDAwMvykvVAuNCfhSq/57UUkZNn6hGCiVH2iXHUwMDFiRnU1yzEs50k1xibj1/lVXHUwMDA3XHUwMDA1yCXS1WYvlHJcdTAwMGJoTKrRXHUwMDEzMFxc8IeT5yGFY6F7USSiodCLYThcdTAwMWNGQeTwhpHp2jRcdTAwMWQjlmn8NfRusO3atlx1MDAxNahcdTAwMDE4dy0nmLwjeqPvoZ61TX1Kb9WTk22TXG7phU9cdTAwMWO3zvFvXHUwMDFiMWSjP0a///k19e6tTDxFrZNQilx1MDAxZvcl+f/cplx1MDAwNKHszJZEUir9wsUzW/lMVFFTwjV1V9pcdTAwMGVMisP1zFxmQ45BaFNXYkaksmScyF9cdTAwMWaeslSGKFx0XHUwMDE5XHUwMDAyXHUwMDEw8TCyXHUwMDAyaOq4XGbVICVgskTOa1x1MDAxMTtCldqW8vOXbUeApiw9UapcdTAwMDEg41xckZ9I3DQwI0IjXHUwMDAyRJ5jvv3IkiW/aDgmXHUwMDBilzzccqIgxaREdFpcdTAwMTaENUSHO1x1MDAwZqek+VvZrEzwhp8p2M5pszKTXHUwMDBmXHS+nNzwhChnhOHiqcGjz7u911Zw8456jkktv9WA9duKWywsNJZusZQnpFx0wfLPR1gsXHUwMDEwimWO3ZxYvtFSPlx1MDAwZVx1MDAxMFwiZM1lsVx1MDAwNVx1MDAwMpbcQGhVXHUwMDAx1mqPY1x1MDAxNJSBeUrVS1xusP7n/DOyUKbxr9RYK5G6mi9cdTAwMWazcLSVlKyct4SzXHUwMDE3okjCwpNw5ti1kj/7XHUwMDE1NT1UxV2/llx1MDAxMidXqlx1MDAwZk6skJpiWIakcth5cqf2Mk1cdTAwMTBcdTAwMTOaXHUwMDE4nLhcdTAwMWFcdTAwMTGPiD2QODOjUVxuOFx1MDAwZuXkTFwiknagXHUwMDA1XHUwMDA2UGBaJlVT5dArn87G3Fx1MDAxNFxiiVQ2XHUwMDFhcCYoJ3FcYj1yU5SXMtixkO8x/X0jrkwkhZ9pXGbN6b7k1jd5zuJcdTAwMWWoaF1IJIpnc9+E3fQ+3G6999yRXHUwMDA3dv2cg+1SlmSdp6lRXHJcdTAwMDCkQszpbC5ccjclrGg5W9FcdTAwMDNcYjFV1mE1S0fzPILT21OvfaVcdTAwMDb70rm+wVcnwdbxoZ3uXHUwMDExzFPIXFz6Y2dcdTAwMTUy0zss7r4oJlxy/cd5dlx1MDAwMeYqY1YskdyJMJX+4FhKguY4ai1/mCu6sEe56pmayFXYXFyBo1x1MDAxM1x1MDAxMMRcZofnXHUwMDExrfPgm1x1MDAxMlx1MDAxMFxczINe/7GGM3gj71jDL0Pl3dQ97zJQ4zZyStTUWMbw5eOx2nyzzPda9kl8X4a6XHUwMDFiKolcdTAwMTlOzF8/v/z8P1xiXCJcdTAwMWT6In0= StopReset00:00:07.21Start00:00:00.00HeaderFooterStart00:00:00.00StopwatchStopwatch(started)Reset

Custom widgets

We need 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 widget which takes care of the first three components. All we need to build is the time display widget which will show the elapsed time and the stopwatch widget itself.

Let's add those to the app. Just a skeleton for now, we will add the rest of the features as we go.

stopwatch02.py
from textual.app import App, ComposeResult
from textual.containers import Container
from textual.widgets import Button, Header, Footer, Static


class TimeDisplay(Static):
    """A widget to display elapsed time."""


class Stopwatch(Static):
    """A stopwatch widget."""

    def compose(self) -> ComposeResult:
        """Create child widgets of a stopwatch."""
        yield Button("Start", id="start", variant="success")
        yield Button("Stop", id="stop", variant="error")
        yield Button("Reset", id="reset")
        yield TimeDisplay("00:00:00.00")


class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    BINDINGS = [("d", "toggle_dark", "Toggle dark mode")]

    def compose(self) -> ComposeResult:
        """Create child widgets for the app."""
        yield Header()
        yield Footer()
        yield Container(Stopwatch(), Stopwatch(), Stopwatch())

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        self.dark = not self.dark


if __name__ == "__main__":
    app = StopwatchApp()
    app.run()

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.containers which (as the name suggests) is a Widget which contains other widgets.

We've defined an empty TimeDisplay widget by extending Static. We will flesh this out later.

The Stopwatch widget class also extends Static. This class has a compose() method which yields child widgets, consisting of three Button objects and a single TimeDisplay object. These widgets will form the stopwatch in our sketch.

The buttons

The Button constructor takes a label to be displayed in the button ("Start", "Stop", or "Reset"). Additionally, some of the buttons set the following parameters:

  • id is an identifier we can use to tell the buttons apart in code and apply styles. More on that later.
  • variant is 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 add widgets to our application 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 of stopwatches. 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 and pass them to the container's constructor.

The unstyled app

Let's see what happens when we run stopwatch02.py.

stopwatch02.py StopwatchApp ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Start  ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Stop  ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Reset  ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 00:00:00.00 ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Start  ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Stop  ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▆▆  Reset  ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 00:00:00.00 ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Start   D  Toggle dark mode 

The elements of the stopwatch application are there. The buttons are clickable and you can scroll the container but it doesn't look like the sketch. This is because we have yet to apply any styles to our new widgets.

Writing Textual CSS

Every widget has a styles object with a number of attributes that impact how the widget will appear. Here's how you might set white text and a blue background for a widget:

self.styles.background = "blue"
self.styles.color = "white"

While it's possible to set all styles for an app this way, it is rarely necessary. Textual has support for CSS (Cascading Style Sheets), a technology used by web browsers. CSS files are data files loaded by your app which contain information about styles to apply to your widgets.

Info

The dialect of CSS used in Textual is greatly simplified over web based CSS and much easier to learn.

CSS makes it easy to iterate on the design of your app and enables live-editing — you can edit CSS and see the changes without restarting the app!

Let's add a CSS file to our application.

stopwatch03.py
from textual.app import App, ComposeResult
from textual.containers import Container
from textual.widgets import Button, Header, Footer, Static


class TimeDisplay(Static):
    """A widget to display elapsed time."""


class Stopwatch(Static):
    """A stopwatch widget."""

    def compose(self) -> ComposeResult:
        """Create child widgets of a stopwatch."""
        yield Button("Start", id="start", variant="success")
        yield Button("Stop", id="stop", variant="error")
        yield Button("Reset", id="reset")
        yield TimeDisplay("00:00:00.00")


class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    CSS_PATH = "stopwatch03.css"
    BINDINGS = [("d", "toggle_dark", "Toggle dark mode")]

    def compose(self) -> ComposeResult:
        """Create child widgets for the app."""
        yield Header()
        yield Footer()
        yield Container(Stopwatch(), Stopwatch(), Stopwatch())

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        self.dark = not self.dark


if __name__ == "__main__":
    app = StopwatchApp()
    app.run()

Adding the CSS_PATH class variable tells Textual to load the following file when the app starts:

stopwatch03.css
Stopwatch {
    layout: horizontal;
    background: $boost;
    height: 5;
    margin: 1;
    min-width: 50;
    padding: 1;
}

TimeDisplay {
    content-align: center middle;
    text-opacity: 60%;
    height: 3;
}

Button {
    width: 16;
}

#start {
    dock: left;
}

#stop {
    dock: left;
    display: none;
}

#reset {
    dock: right;
}

If we run the app now, it will look very different.

stopwatch03.py StopwatchApp ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Start 00:00:00.00 Reset  ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Start 00:00:00.00 Reset  ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Start 00:00:00.00 Reset  ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁  D  Toggle dark mode 

This app looks much more like our sketch. Let's look at how Textual uses stopwatch03.css to apply styles.

CSS basics

CSS files contain a number of declaration blocks. Here's the first such block from stopwatch03.css again:

Stopwatch {
    layout: horizontal;
    background: $boost;
    height: 5;
    margin: 1;
    min-width: 50;
    padding: 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 this CSS code changes how the Stopwatch widget is displayed.

eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXGlT40hcdTAwMTL93r/Cwe7Hdk3dR0dsbIChgaG5mqO7d2diQkjCViNbakvm8MT8901cdLBuY4xNm4lVXHUwMDEwgFVSVaoyX+XLrJT/fNdqrcV3obv2obXm3tqW7zlD62btfXL+2lx1MDAxZEZeMIAmmn6OgtHQTq/sxXFcdTAwMTh9+OWXvjW8cuPQt2xcdTAwMTdde9HI8qN45HhcdTAwMDGyg/4vXuz2o38nv1x1MDAwZqy++68w6DvxXHUwMDEwZYO0XceLg+H9WK7v9t1BXHUwMDFjQe//hc+t1p/p75x0Q9eOrUHXd9NcdTAwMWLSpkxAqWn57EEwSIVl2lAmqSaTXHUwMDBivGhcdTAwMTOGi11cdTAwMDdaL0FkN2tJTq2dnp9f9e6+XHUwMDFkn3S6t39cdTAwMDTc3/A+n5Fs1EvP90/iOz+VKlxu4GGytihcdTAwMWVcdTAwMDZX7lx1MDAxN8+Je9BKSueb7lx1MDAxYVx1MDAwNqNub+BGUeGeILRsL75LXHUwMDFlXHUwMDAxT07ez8CHVnbmNtGPYVxia2Y0Z0pMWpJbKedI6tzJe0k6gVx1MDAwZjNcdTAwMGaS/Fx1MDAwM6dHJsuFZV91QaCBk11ju1x1MDAwZXes7Jqbh+dcdTAwMTNSXCKlXHUwMDE0K4zac71uL06eXHUwMDA0Y6RcdTAwMDVcdTAwMTGS5kZ301knQjMpucTZ0yZjhrtOalx1MDAwML+X561nXHLDh/lZi5JcdTAwMGY5eVx1MDAxM1G3ytaTt6CcZte35eHhVd+7PenwvcOtTVx1MDAxYfI7PemrYG7WcFx1MDAxONysTVr+ej+tX+6PO0c3jjP6KK4x2dbtsO9/nK3fh/+yR1x1MDAxZYWOdW+bREpstMBCKqon7b43uILGwcj3s3OBfZWZ87ucwM+Dkc5ZWlx0RtxgsDBlzMwwuvtyvNUxu+3di+3401x1MDAxZnGwuf7Ht7ufXHQjMMgncMQwQ0pcdTAwMTglXHUwMDBiRpviSGJkuGCEvlxmSlx1MDAxY9tYqCqUiMRVXHUwMDA0SVlcdTAwMDFOYlx1MDAxMExhXCJeXHUwMDE3OJ/Wncue27fV2fZ3ur9zfni8e3xbb+Cxe1x1MDAxYs+Km7fSbeHq97NcdTAwMGX480BekDOHb0VcdTAwMWHxTYQgRlx1MDAxMDm7m5w+y0V89yy7N1x1MDAxYbqrgHDdhHBNXHUwMDExfznC46E1iEJrXGKoqkG5qEE5ZVx1MDAxNZRrwjHhkpvFo3yRNpjpOlx1MDAxOMQn3jiZbopcdTAwMGJnP1p9z78rqCs1TpD0JLaGcX4uI1x1MDAxN4ZMbVFcdTAwMTUuXve9bmK9azY8hDssXHUwMDE4duxcdTAwMDGdnFxc0PdcdTAwMWMn79lskMCCPoe7s3ikYOh1vYHln1x1MDAxNlx1MDAwNJzfm0rcjDaMqVx1MDAxNJRjNTPcLs87R+2D/e92XHUwMDE0nV7zL52rbXbo/VxcuKmn0MaNQkZcdTAwMDHPM1x1MDAxNV6qXHUwMDA0opRcdTAwMTZhuHi4MUTKmJ7gjlx0VFwizFx1MDAwZvCTXHUwMDAyXFwsl2pcdOib5rV+dH6cXHUwMDFkdunBRnggo1x1MDAwM1x1MDAxNq7v8976y53hW+n2KVx1MDAxZls/4Or5WMFcdTAwMWFDUVx1MDAwMlx1MDAxZZZJxujsTnb6NK8m6lx1MDAwNaZNqNdcdTAwMDQpwkuoWzTqOauCvcbJYlxmfIfklfE3dLJcdTAwMTh/SH9QcUaX7mqf8FZlV5tcdTAwMTdzfodrXHUwMDE4aYKe5FxcUaPV7OEricfHwaX+KI6v8d5Fe+ey2zXj1Vx1MDAwZV8lwKs2XHQkKVLSvDx6bUpcdTAwMDTVR6+6XHUwMDAyOVx1MDAwMUJcdTAwMTBDc2B8XHUwMDE1z0rWnWM2tP2uPmLYXHUwMDFm71njz/b+y13gW+n2Kc9aP+CM0r6h1FeTx9Y5XHUwMDE2Xs56UWokZSYz2CeXjanaW9GoWGpZv3BoYOlcdTAwMGJYOFx1MDAxNlx1MDAxMlx1MDAxNFx1MDAxM1xylFxcSP639tef3ch93aD4XHQ/V/bU91x1MDAwMs6FM66a42HKhKKEPiNcdTAwMWW2zU1b3vw63tzZMfZG2P66fbHprzrQXGZcdTAwMDeKU0DTPcxcZjLLpMSS1yCsXHUwMDEy91JcdTAwMDIqXHUwMDEwS/HOq1x1MDAwMzDRSvBcdTAwMTTVQ0zPXHSxOFxim/BVeJAymFx1MDAxZYWZXG6ne7dZhycjmvBkXGZcdTAwMTVKUj473Y10ZH23w/Obs30pdLBcdTAwMWZcdTAwMWSNembl4URcZlLlLFKKKGNcdTAwMTCBSSAv3PqciiqMuMa8sLs6QVx1MDAxNzdIXHUwMDBiUWx8hFx1MDAxOWaYXHUwMDAzrVx1MDAxMOr5OEule22cRUlcdTAwMWV0w1x1MDAxYjjeoFu+xVx1MDAxZDhcci2+XHUwMDE1xZ2g3/diXHUwMDEw4yjwXHUwMDA2cfmKtN/1xLp7rlXBXG70nG8rwyBMeiwy/+y/VmYn6YfJ/7+/r726XVVlejqnxayLd/m/z1x1MDAwNq0gsnx24lx1MDAwNDnmXHUwMDFjQD17dijY2/xOPlx1MDAwZa797e/H7lx1MDAwMVx1MDAxZrEtvi9XXHUwMDFmtFx1MDAxMqlyYcB9mYNB8PzLXHUwMDA1LUEwXHUwMDAwVlxuw+iSwu9MXHUwMDFkXHUwMDE5eDGSxIBFSNA+OMVcblx1MDAxNWVcZtyk0PPsz/xcdTAwMWbBS0Fws1qTo6zQZ4I5pVx1MDAwNzVYXHUwMDA2XCLXiGUjTFLjwmaPXHUwMDFjN1x1MDAxONk4wieCXW73Ozf/6Vx1MDAxZI5D/9eVXHUwMDA3M2BWYGZwxVx1MDAwM3OJsJRcdTAwMWMvc4OHYlx1MDAxOFxc6drtXHUwMDFkaCqJNUn8ck3BQH5cdTAwMDZ6syHfXHUwMDFhelx1MDAwYm1cdTAwMGKFblWJ6W2P6ltcdTAwMTBWqWDls1x1MDAxM6wqaSSsXHUwMDBms5Nlucv3jszh7fru4W7Q+1witkbOsVp9qCqkyphI/a6AoJSWyvhcdTAwMTZcdTAwMGVVXc0kZVitYJRcdTAwMDBDVtrIn8CR/4/R6tU12ivdN1x1MDAxMzin7t5wzctnXHUwMDFmXHUwMDExXG5KMYxqPXt2SG/g/tG+/Wn75uLzKNZb23a3c7lghDpW1HNcdTAwMTdcblGqXHUwMDE04qounqVcdTAwMDKxUqy58CRcdTAwMTGmSJQ9dlbNy1xmwmkoxE16ZLp4wKzB2Fx1MDAxME3wK5cmjkPdXHUwMDFi75OvO9+Ogo2wz3bUOs/yl1x1MDAwNfN7Vk3v5rfo6sw56O7cee3DXHUwMDFk7VxcR6eDzkI3Np6z0EyFVVPCleJGRIGaKFdS5lJIT0Fqz1x1MDAxY+3fnlx1MDAwZsbHwUdcdTAwMGKf7nb2TmPHWX1IXHUwMDE5jYypqUVcdTAwMDBTRcvmp0SaKpJqXHUwMDE4qVx1MDAxNNhQxl+7qvdcdTAwMDXQedrEXHRXOleJsey8bi9cdTAwMTh64yTz6rd86y5cdTAwMTg1bKI0ZHh997KIoMXkd6tCTcVxY9JcYqazXHTIUlx0joUms7vG6VpfUfLKXHJDtFxcOZTealx1MDAwNDIk2T9cdTAwMDKUU86XXGJmXCKRllx1MDAxNKxcdTAwMWGEXHUwMDAxXHUwMDBiVzVlRlx1MDAxODFcdTAwMDJOklx1MDAwMFHSXHUwMDFhlJKLMbNcbn5cdTAwMDFNks2xjflcdTAwMDJmXHUwMDBif2hun3V+ZpstI4/vbO3OQLhS/NqjRMo2QVx1MDAxY+bAMIk1T4xX69xVXStMXHUwMDE3bcRcZuOCXGJcZvpcdTAwMTaPMeDEYVx1MDAxNyn1i0VcIohpLFx1MDAxNVx1MDAxM1RcYqXAlOolXHUwMDEyXHUwMDE4KyEhguKYU1qR6U2ly1x1MDAxYW05OdpVM34m0W9kJKaxQkthTohiz4jCXHUwMDBmgzuLO9H19Zfx3bmOzm6xOdtb9YWMXG6NOOGVIJxcdTAwMTODhC5cdTAwMTckL3xcdMvcxDQ+wiTRmC2l1OLtUO6X8Vx1MDAxMdKyXd9v9a0hMIJV4FwiRYHm4yHCNFZKUaqowvRcdTAwMTn57unaXlH4MspcdTAwMTBcdTAwMTdcXFx1MDAwMThcZqBVZo97XHUwMDBmYoJMeU940SBcdTAwMTZcdTAwMThcdTAwMTGpXHUwMDE0JlxuYnFw6zU0hHOEy0n5XHS2qZFJwDFHlccqZNaanP10Z5DnXHUwMDFmXHUwMDE4USog8FOCJb48tyE78fZcbt1cdTAwMDeMS+ZcdTAwMWSJmzWGcCY1XHUwMDA1i6EsK3+aiMKQJIroXHUwMDFhWd5cdTAwMTLfaLTZ5GjnzHVBPIOr5p05iLUhYlJ69pUq+Fx1MDAxNG6ffu13Tva2NsdfutxcYra56GTiwlcqxVx1MDAxNYI4Q1x1MDAxNXbT00VcbrgswWTJb15l8z9ZlUQ1zS+VTjbTzStcdTAwMTONpVx1MDAxNi1cdTAwMTPK8/tcdTAwMTavRDRCy0mWpd9cdTAwMDZWqqbWxSiOg0F9nVsuLfM6dW5PyDhcdTAwMWZcdTAwMTkxZlxuxjlOtPGMctLpJrGiXHUwMDE411hcdTAwMDPfMIIkO9qGq6yXezai0XJLt2lcdTAwMTLLY82ESjhPUvJQRb1cdTAwMDRcdTAwMTEhklx1MDAxMFx1MDAxOEiRVEbWZD8hxKRGiVfOiCQwXUQ1TVx1MDAxM1xymO41ioxcdTAwMDRzoaRkQlx1MDAwYkY1XHUwMDE1mVwiJzyAYFx1MDAwNLOcaHM+UjL9S22K0lx1MDAwMLflSmtsYIJcdTAwMTRhplwiXGb4a5pcdTAwMDSIoDdGMVx1MDAxM287XHUwMDE50m4247S5YsFcdTAwMGJcIimUNpdcdTAwMDKSpEZUMzX7XHUwMDAy5l9dbv+wP5newa72+1x1MDAxZsVmcOZcZld9XHUwMDAx41x1MDAxYdh4qfIgXbpcdTAwMThFklx1MDAxMrzUqniiamr/qiRFY2U0qP+VX1r7XHUwMDE2fVxyZW/zx+2FP+j9uNnvXHUwMDBl+782fInJXHUwMDFjXHUwMDFjXHUwMDA1rF3OlVx1MDAwZZ6Lo2SaadmJan5cdTAwMWJ4UeufoTVw/baTfIfYoE1Wg6nMJOl8fIU0v2ZcdTAwMDZol8IwIWZPfk43j1x1MDAxNYW7kFx1MDAwMtWAnXK03C+CkM1lgkai0pfBTF5WNVx1MDAxMDlcdTAwMDGFfGVaMjcyZ6Ql0/1EiZZcYsW0Tt7M4lx1MDAwMmtMeO6yx0xcdLjGNGk9nZa82cLiiukkR3tiNU1U4N1Dh2tWXHUwMDE4nsSg4olGwIo852FcdTAwMDXNnmrt2nNvNmpeub5Mj2TJSWc2XHUwMDAxtps8259/vfvrf4F0XHUwMDFiRyJ9 Start00:00:00.00Reset5 lineshorizontal layout1 cell margin1 cell paddingaround buttonsbackground coloris $panel-darken-1
  • layout: horizontal aligns child widgets horizontally from left to right.
  • background: $boost sets the background color to $boost. The $ prefix picks a pre-defined color from the builtin theme. There are other ways to specify colors such as "blue" or rgb(20,46,210).
  • height: 5 sets the height of our widget to 5 lines of text.
  • margin: 1 sets a margin of 1 cell around the Stopwatch widget to create a little space between widgets in the list.
  • min-width: 50 sets the minimum width of our widget to 50 cells.
  • padding: 1 sets a padding of 1 cell around the child widgets.

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 to widgets with a matching "id" attribute. We've set an ID 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 display 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.

eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nNVaa1PiSFx1MDAxNP3ur7CYr5rp98OqrS1fOO4oXCLq6ri1ZYWkIZFAMFx0XHUwMDAzOOV/305wJYFEXHUwMDEwXHUwMDExXHUwMDE5Synp7vS96b6nz+nb/Wtjc7NcdTAwMTRccruqtLNZUlx1MDAwM8v0XFw7MPulrbj8p1xuQtfv6CqUfFx1MDAwZv1eYCUtnSjqhjtfv7bNoKWirmdayvjphj3TXHUwMDBio57t+oblt7+6kWqHf8afXHUwMDE1s63+6PptO1xujLGRbWW7kVx1MDAxZoxsKU+1VSdcbnXv/+jvm5u/ks+Ud4GyXCKz0/RU8kBSNXZcdTAwMTBTNlla8TuJs1x1MDAxMENcdTAwMDExxlx1MDAwMr20cMNcdTAwMDNtL1K2rm5on9W4Ji4qoW23o/reoSjzk6FPh+JQXHUwMDBl62OzXHLX8y6ioZe4XHUwMDE1+vptxnVhXHUwMDE0+C117dqRXHUwMDEz254oL3oq8HtNp6PCMPOM3zUtN1x1MDAxYcZlXHUwMDAwvJSOxmBnc1xcMohniFx1MDAwMlx1MDAwM0GJ9GtcbojoS138NELSoIhiXHRYqmbk0b7v6SnQXHUwMDFlfVx1MDAwMcnP2Ke6abWa2rGOPW5DgFx1MDAwNShcdTAwMWa36T+/J2XIwJJQnDbtKLfpRCPvXHJcdTAwMGVcdTAwMDVP2VbJ2ENcdTAwMDRcdTAwMDRHXHUwMDEwkfHUxFx1MDAxNrvHdlx1MDAxMlx1MDAwN/9Ojp5jXHUwMDA23edRKoXxl5S3saOHk0GUXHUwMDBlpNT87pFcdTAwMTCE/UFj96T5sCtxXHUwMDEz/+Wdf3/pK1x1MDAxM3VmXHUwMDEw+P3SS83T839j13pd21x1MDAxY0VcdTAwMTJkXGZxyKCk+u+l3nM7LV3Z6XneuMy3WuPgS0qftlx1MDAxNoh6wkFh1EtcdTAwMGUklZSLuaM+qNTR7vZcdL4/sitn1UrtR3hb21vzqGfAkFx1MDAwMFx0jFx1MDAwMJ6KekJcclx1MDAwMlx1MDAxOYD0vVHfMDV60HTU686ng52xqSiHUlxuXHUwMDAyQGqyVlx1MDAxMuWHu4d30VEzVJWWffvQOFx1MDAwNPBuu5dcdTAwMWblkVx1MDAxYUSpIN/K7zbTemteg5+HnYyfXHUwMDE5siBFsEFcYnKs10syN2peXHUwMDFm5SxqXHUwMDFj03J6gVpcdTAwMDfc0GLcMGbIZeAmXG7MTtg1XHUwMDAzXHUwMDFkqznYoTnYQXhcbjtSYqqdoWL52FlmXHUwMDFjjufb70RcdTAwMTfuY1x1MDAxMksgU1o22643zExZXHUwMDEyoNrTi8hPO2qGSltMVnGeabvruc04gEuWflx1MDAwN1x1MDAxNWRiO3K1rnpp0HZtO81cdTAwMTmWdsDUfVx1MDAwNsfzLPV+4Dbdjuldpv1bnKZQ6jUmaUpALUpcdTAwMDCZX5v1XHUwMDBlr1x1MDAwM+fS/SZJvyo6dXl601x1MDAxMt/Wm6VcYqVcdTAwMDaEnHHNxlNoo9JcdTAwMDBcdTAwMTJyyTL6aFx1MDAxMZZKfvKQhlxySIq0XHUwMDE51lx1MDAwYoEgOEebMY6JXlxiXHRbPvJeY63WXfPQbLdcdTAwMWQk78hjXHUwMDE5n4fSuyrQZsthrXyD68dakMhCXHUwMDE0SUxcdTAwMDSjLKUwZsHo9WFeU9pcIlruXHUwMDE1XHUwMDAySVNcdTAwMWFcdTAwMTV4MpiXTVtcdTAwMDRPY2iatqhcdTAwMDZcdTAwMWRcdTAwMTP4I7CzPqxcdTAwMDXATvJrZFx1MDAwN/TDyWtcdTAwMDZcdTAwMDNMklfazcU5jMvirVx1MDAxNlx1MDAxMZzogYfzi0bYP6uehFF9t7ZrXHUwMDFmPJRZ11xu67X1JjGGpcFFNoVcdTAwMTA/uc1cclx1MDAxZOlUvlx1MDAxM3VfLGVcdTAwMTPbnHeTJSZcdTAwMTHHXHUwMDA0xVxcolXvsS49//6x45fvavVqZ7/Svq5W9uvvYavfq9tZ3Jpv8Fxy3Eo5kzKlXHUwMDBlP4hbXHUwMDE5K1aoVCAuxVx1MDAxYsD9+iivKbUyQnLhXHKJISAj781cdTAwMWMuZy9IuVx1MDAxNrJcdTAwMWN/XHUwMDAwxpdcdTAwMTmB72PVmlxuVbRSPp1BRpN8OnJwcSalQFx1MDAxNGGNx0hcdTAwMTNcZr9cdTAwMDFrXHUwMDAz+O3+7j4kofqhRexpMPjR3f5MXCLFcyXqqeSMTjEpgsYyXHUwMDA0bCGVUsZcZs71ti5tIJOlXHUwMDE3dFwi6/N/nlx1MDAxZSMtcEBqXHUwMDExXFyJno1XXHUwMDFl+Fx1MDAwNuQtXHUwMDFllIyjoqDEXHUwMDE4MoTwXHUwMDFiUlx1MDAxNOft87NK1ynT/burRlx1MDAxN3lcdTAwMTXsW0frre6SRDqmlGTSXHUwMDEwSVTG26qJNOEyj47mTaJTXHUwMDBl9NKPwYrTXHUwMDExe41WebD7/Vie3J62L09cdTAwMDfgxqyU36/EfpduZ1x0vHyD6yfwaHHKX6+4klxuXCLnz528PsprKvCSlH8uwqGuWEJcdTAwMDJyXHUwMDE5XHUwMDEyXHUwMDBmaX2nXaFopYmTVUu8i8hcZlYr8WYw0nS+P3ZwcTYlpJBNIcNcdTAwMTJLXHUwMDFkb/NrvL9r29fSqdVOq4KU/dtcdTAwMDPn4LzZ/FxcuPG5Uv5cdTAwMTTg6VxylTCYpO9l01x1MDAxOVhbLOFPmdB2Kf6Ao7bXOOvCeri+8/f/dlx1MDAwNvXHq+F5w2pcdTAwMGWCo/dT4e/S7SyGzTe4flxmi3nhXHIsxiiIz3Hnh/zro7ymkI9cdTAwMGYnciGvXHRcdTAwMTZ+KLnOdyghpN5eM/BcdTAwMTH4Xlx1MDAxZm79rEOJXHUwMDE5JLXwoURcdTAwMTHcXGIovsNCMJGI0vlcdTAwMDXtzZk68Fx1MDAxYex+31x1MDAxOYKa1+Ku6Fx1MDAwNnTdXHUwMDA1LYlcdTAwMTMpOVlcdTAwMTQujUmVu3TAXHUwMDAxlFx1MDAwM7hpRiWAUckkXm3aXHUwMDA0QcRTm+pVXFxe6ZuR5eTjTeTjzVON6Fx1MDAxNbRlLsRkoZZ5kbybKiNnXHUwMDE2Qlx1MDAxNS1GXHUwMDE1RHqXKKVEb4BVs/5Ar1x1MDAxYSdcdTAwMDdu9fjspkrCI+ZcdTAwMDRrXHUwMDBmK6hcdTAwMTUqZtn7usmjnFx1MDAxOIxcIvyx5+tcdTAwMTLOhSwmXHUwMDEwkECI1e1cdTAwMTOJ5jLGqcSrRJbehil7czGEzWS0xTE26dZcYmtcdTAwMWLPKrRkdru6TVx1MDAxNDs3Qp6eXHUwMDFj135+/XHXpZ+u6u9cdTAwMTXfZNp4xm9cZlx1MDAxNFx1MDAxNU/Nr6eNp/9cdTAwMDDD6SGzIn0= Stop00:00:00.00ResetStart00:00:00.00StopwatchStarted Stopwatch

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:

stopwatch04.css
Stopwatch {
    layout: horizontal;
    background: $boost;
    height: 5;
    margin: 1;
    min-width: 50;
    padding: 1;
}

TimeDisplay {
    content-align: center middle;
    text-opacity: 60%;
    height: 3;
}

Button {
    width: 16;
}

#start {
    dock: left;
}

#stop {
    dock: left;
    display: none;
}

#reset {
    dock: right;
}

.started {
    text-style: bold;
    background: $success;
    color: $text;
}

.started TimeDisplay {
    text-opacity: 100%;
}

.started #start {
    display: none
}

.started #stop {
    display: block
}

.started #reset {
    visibility: hidden
}

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 this CSS class.

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 .started selector matches any widget with a "started" CSS class. While #start matches a child widget with an ID of "start". So it matches the Start button only for Stopwatches in a started state.

The rule is "display: none" which tells Textual to hide the button.

Manipulating classes

Modifying a widget's CSS classes is a convenient way to update visuals without introducing a lot of messy display related code.

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 will start or stop the stopwatches in response to clicking a button.

stopwatch04.py
from textual.app import App, ComposeResult
from textual.containers import Container
from textual.widgets import Button, Header, Footer, Static


class TimeDisplay(Static):
    """A widget to display elapsed time."""


class Stopwatch(Static):
    """A stopwatch widget."""

    def on_button_pressed(self, event: Button.Pressed) -> None:
        """Event handler called when a button is pressed."""
        if event.button.id == "start":
            self.add_class("started")
        elif event.button.id == "stop":
            self.remove_class("started")

    def compose(self) -> ComposeResult:
        """Create child widgets of a stopwatch."""
        yield Button("Start", id="start", variant="success")
        yield Button("Stop", id="stop", variant="error")
        yield Button("Reset", id="reset")
        yield TimeDisplay("00:00:00.00")


class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    CSS_PATH = "stopwatch04.css"
    BINDINGS = [("d", "toggle_dark", "Toggle dark mode")]

    def compose(self) -> ComposeResult:
        """Create child widgets for the app."""
        yield Header()
        yield Footer()
        yield Container(Stopwatch(), Stopwatch(), Stopwatch())

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        self.dark = not self.dark


if __name__ == "__main__":
    app = StopwatchApp()
    app.run()

The on_button_pressed method is an event handler. Event handlers are methods called by Textual in response to an event such as a key press, mouse click, etc. Event handlers begin with on_ followed by the name of the event they will handle. Hence on_button_pressed will handle the button pressed event.

If you run stopwatch04.py now you will be able to toggle between the two states by clicking the first button:

stopwatch04.py StopwatchApp ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Start 00:00:00.00 Reset  ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Stop 00:00:00.00 ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Start 00:00:00.00 Reset  ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁  D  Toggle dark mode 

Reactive attributes

A recurring 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 reactive. Let's use this feature to create a timer that displays elapsed time and keeps it updated.

stopwatch05.py
from time import monotonic

from textual.app import App, ComposeResult
from textual.containers import Container
from textual.reactive import reactive
from textual.widgets import Button, Header, Footer, Static


class TimeDisplay(Static):
    """A widget to display elapsed time."""

    start_time = reactive(monotonic)
    time = reactive(0.0)

    def on_mount(self) -> None:
        """Event handler called when widget is added to the app."""
        self.set_interval(1 / 60, self.update_time)

    def update_time(self) -> None:
        """Method to update the time to the current time."""
        self.time = monotonic() - self.start_time

    def watch_time(self, time: float) -> None:
        """Called when the time attribute changes."""
        minutes, seconds = divmod(time, 60)
        hours, minutes = divmod(minutes, 60)
        self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")


class Stopwatch(Static):
    """A stopwatch widget."""

    def on_button_pressed(self, event: Button.Pressed) -> None:
        """Event handler called when a button is pressed."""
        if event.button.id == "start":
            self.add_class("started")
        elif event.button.id == "stop":
            self.remove_class("started")

    def compose(self) -> ComposeResult:
        """Create child widgets of a stopwatch."""
        yield Button("Start", id="start", variant="success")
        yield Button("Stop", id="stop", variant="error")
        yield Button("Reset", id="reset")
        yield TimeDisplay()


class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    CSS_PATH = "stopwatch04.css"
    BINDINGS = [("d", "toggle_dark", "Toggle dark mode")]

    def compose(self) -> ComposeResult:
        """Create child widgets for the app."""
        yield Header()
        yield Footer()
        yield Container(Stopwatch(), Stopwatch(), Stopwatch())

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        self.dark = not self.dark


if __name__ == "__main__":
    app = StopwatchApp()
    app.run()

We have added two reactive attributes: start_time will contain the time in seconds when the stopwatch was started, and time will contain the time to be displayed on the Stopwatch.

Both 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

The monotonic function in this example is imported from the standard library time module. It is similar to time.time but won't go backwards if the system clock is changed.

The first argument to reactive may be a default value or a callable that returns the default value. The default for start_time is monotonic. When TimeDisplay is added to the app, the start_time attribute will be set to the result of monotonic().

The time attribute has a simple float as the default value, so self.time will be 0 on start.

The on_mount method is an event handler called then the widget is first added to the application (or mounted). In this method we call set_interval() to create a timer which calls self.update_time sixty times a second. This update_time method calculates the time elapsed since the widget started and assigns 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 the Stopwatch widgets show the time elapsed since the widget was created:

stopwatch05.py StopwatchApp ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Start 00:00:00.00 Reset  ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Start 00:00:00.00 Reset  ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Start 00:00:00.00 Reset  ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁  D  Toggle dark mode 

We've seen how we can update widgets with a timer, but we still need to wire up the buttons so we can operate stopwatches independently.

Wiring buttons

We need to be able to start, stop, and reset each stopwatch independently. We can do this by adding a few more methods to the TimeDisplay class.

stopwatch06.py
from time import monotonic

from textual.app import App, ComposeResult
from textual.containers import Container
from textual.reactive import reactive
from textual.widgets import Button, Header, Footer, Static


class TimeDisplay(Static):
    """A widget to display elapsed time."""

    start_time = reactive(monotonic)
    time = reactive(0.0)
    total = reactive(0.0)

    def on_mount(self) -> None:
        """Event handler called when widget is added to the app."""
        self.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)

    def update_time(self) -> None:
        """Method to update time to current."""
        self.time = self.total + (monotonic() - self.start_time)

    def watch_time(self, time: float) -> None:
        """Called when the time attribute changes."""
        minutes, seconds = divmod(time, 60)
        hours, minutes = divmod(minutes, 60)
        self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")

    def start(self) -> None:
        """Method to start (or resume) time updating."""
        self.start_time = monotonic()
        self.update_timer.resume()

    def stop(self):
        """Method to stop the time display updating."""
        self.update_timer.pause()
        self.total += monotonic() - self.start_time
        self.time = self.total

    def reset(self):
        """Method to reset the time display to zero."""
        self.total = 0
        self.time = 0


class Stopwatch(Static):
    """A stopwatch widget."""

    def on_button_pressed(self, event: Button.Pressed) -> None:
        """Event handler called when a button is pressed."""
        button_id = event.button.id
        time_display = self.query_one(TimeDisplay)
        if button_id == "start":
            time_display.start()
            self.add_class("started")
        elif button_id == "stop":
            time_display.stop()
            self.remove_class("started")
        elif button_id == "reset":
            time_display.reset()

    def compose(self) -> ComposeResult:
        """Create child widgets of a stopwatch."""
        yield Button("Start", id="start", variant="success")
        yield Button("Stop", id="stop", variant="error")
        yield Button("Reset", id="reset")
        yield TimeDisplay()


class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    CSS_PATH = "stopwatch04.css"
    BINDINGS = [("d", "toggle_dark", "Toggle dark mode")]

    def compose(self) -> ComposeResult:
        """Called to add widgets to the app."""
        yield Header()
        yield Footer()
        yield Container(Stopwatch(), Stopwatch(), Stopwatch())

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        self.dark = not self.dark


if __name__ == "__main__":
    app = StopwatchApp()
    app.run()

Here's a summary of the changes made to TimeDisplay.

  • We've added a total reactive attribute to store the total time elapsed between clicking the start and stop buttons.
  • The call to set_interval has grown a pause=True argument which starts the timer in pause mode (when a timer is paused it won't run until resume() is called). This is because we don't want the time to update until the user hits the start button.
  • The update_time method now adds total to the current time to account for the time between any previous clicks of the start and stop buttons.
  • We've stored the result of set_interval which returns a Timer object. We will use this later to resume the timer when we start the Stopwatch.
  • We've added start(), stop(), and reset() methods.

In addition, the on_button_pressed method on Stopwatch has grown some code to manage the time display when the user clicks 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."""
        button_id = event.button.id
        time_display = self.query_one(TimeDisplay)
        if button_id == "start":
            time_display.start()
            self.add_class("started")
        elif button_id == "stop":
            time_display.stop()
            self.remove_class("started")
        elif button_id == "reset":
            time_display.reset()

This code supplies missing features and makes our app useful. We've made the following changes.

  • The first line retrieves id attribute of the button that was pressed. We can use this to decide what to do in response.
  • The second line calls query_one to get a reference to the TimeDisplay widget.
  • We call the method on TimeDisplay that matches the pressed button.
  • We add the "started" class when the Stopwatch is started (self.add_class("started")), and remove it (self.remove_class("started")) when it is stopped. This will update the Stopwatch visuals via CSS.

If you run stopwatch06.py you will be able to use the stopwatches independently.

stopwatch06.py StopwatchApp ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Stop 00:00:00.35 ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Stop 00:00:00.13 ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Start 00:00:00.00 Reset  ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁  D  Toggle dark mode 

The only remaining feature of the Stopwatch app left to implement is the ability to add and remove stopwatches.

Dynamic widgets

The Stopwatch app creates widgets when it starts via the compose method. We will also need to create new widgets while the app is running, and remove widgets we no longer need. We can do this by calling mount() to add a widget, and remove() to remove a widget.

Let's use these methods to implement adding and removing stopwatches to our app.

stopwatch.py
from time import monotonic

from textual.app import App, ComposeResult
from textual.containers import Container
from textual.reactive import reactive
from textual.widgets import Button, Header, Footer, Static


class TimeDisplay(Static):
    """A widget to display elapsed time."""

    start_time = reactive(monotonic)
    time = reactive(0.0)
    total = reactive(0.0)

    def on_mount(self) -> None:
        """Event handler called when widget is added to the app."""
        self.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)

    def update_time(self) -> None:
        """Method to update time to current."""
        self.time = self.total + (monotonic() - self.start_time)

    def watch_time(self, time: float) -> None:
        """Called when the time attribute changes."""
        minutes, seconds = divmod(time, 60)
        hours, minutes = divmod(minutes, 60)
        self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")

    def start(self) -> None:
        """Method to start (or resume) time updating."""
        self.start_time = monotonic()
        self.update_timer.resume()

    def stop(self):
        """Method to stop the time display updating."""
        self.update_timer.pause()
        self.total += monotonic() - self.start_time
        self.time = self.total

    def reset(self):
        """Method to reset the time display to zero."""
        self.total = 0
        self.time = 0


class Stopwatch(Static):
    """A stopwatch widget."""

    def on_button_pressed(self, event: Button.Pressed) -> None:
        """Event handler called when a button is pressed."""
        button_id = event.button.id
        time_display = self.query_one(TimeDisplay)
        if button_id == "start":
            time_display.start()
            self.add_class("started")
        elif button_id == "stop":
            time_display.stop()
            self.remove_class("started")
        elif button_id == "reset":
            time_display.reset()

    def compose(self) -> ComposeResult:
        """Create child widgets of a stopwatch."""
        yield Button("Start", id="start", variant="success")
        yield Button("Stop", id="stop", variant="error")
        yield Button("Reset", id="reset")
        yield TimeDisplay()


class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    CSS_PATH = "stopwatch.css"

    BINDINGS = [
        ("d", "toggle_dark", "Toggle dark mode"),
        ("a", "add_stopwatch", "Add"),
        ("r", "remove_stopwatch", "Remove"),
    ]

    def compose(self) -> ComposeResult:
        """Called to add widgets to the app."""
        yield Header()
        yield Footer()
        yield Container(Stopwatch(), Stopwatch(), Stopwatch(), id="timers")

    def action_add_stopwatch(self) -> None:
        """An action to add a timer."""
        new_stopwatch = Stopwatch()
        self.query_one("#timers").mount(new_stopwatch)
        new_stopwatch.scroll_visible()

    def action_remove_stopwatch(self) -> None:
        """Called to remove a timer."""
        timers = self.query("Stopwatch")
        if timers:
            timers.last().remove()

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        self.dark = not self.dark


if __name__ == "__main__":
    app = StopwatchApp()
    app.run()

Here's a summary of the changes:

  • The Container object in StopWatchApp grew a "timers" ID.
  • Added action_add_stopwatch to add a new stopwatch.
  • Added action_remove_stopwatch to remove a stopwatch.
  • Added keybindings for the actions.

The action_add_stopwatch method creates and mounts a new stopwatch. Note the call to query_one() with a CSS selector of "#timers" which gets the timer's container via its ID. Once mounted, the new Stopwatch will appear in the terminal. That last line in action_add_stopwatch calls scroll_visible() which will scroll the container to make the new Stopwatch visible (if required).

The action_remove_stopwatch function calls query() with a CSS selector of "Stopwatch" which gets all the Stopwatch widgets. If there are stopwatches then the action calls last() to get the last stopwatch, and remove() to remove it.

If you run stopwatch.py now you can add a new stopwatch with the A key and remove a stopwatch with R.

StopwatchApp StopwatchApp ▆▆ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Stop 00:00:00.35 ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Start 00:00:00.00 Reset ▆▆ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Start 00:00:00.00 Reset  ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Start 00:00:00.00 Reset  ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁  D  Toggle dark mode  A  Add  R  Remove 

What next?

Congratulations on building your first Textual application! This tutorial has covered a lot of ground. If you are the type that prefers to learn a framework by coding, feel free. You could tweak stopwatch.py or look through the examples.

Read the guide for the full details on how to build sophisticated TUI applications with Textual.