This is part one of my series on demystifying every damn aspect of Textual. In this article, we are going to explore the not-so-popular world of text-based user interfaces and how to create one using Textual. Iโve chosen to write about textual, partially because it has been used during my participation at the deepgram hackathon, but mostly because there isnโt any tutorial about textual yet in the wild world of the internet besides the readme file.
If you have experience with text user interfaces in the past, you might come across other frameworks such as urwid, curtsies, asciimatics, prompt-toolkit to name a few. Nevertheless, If you have not, you are just fine because you are in the right place to learn about TUIs in general and using Textual specifically. Iโll show you how to develop a wordle clone step by step.
Psst: It's me from the future. I just wanted to let you know that when you see a $
sign in front of a command that tells you to run it in the shell rather than the Python interpreter.
๐ Table Of Content (TOC).
- What is a TUI?
- What is Textual?
- Install Textual
- Install Textual: revisited
- Basic Textual App
- User Interface
- Textual Widgets
- Reusable components
- Organize with views
- Widget Event Handler
- Wrapping Up
- Upcoming blogs on dev
What is a TUI?
Arguably, the most popular place to look up definitions is Wikipedia, and I quote:
In computing, text-based user interfaces (TUI) (alternately terminal user interfaces, to reflect a dependence upon the properties of computer terminals and not just text), is a retronym describing a type of user interface (UI) common as an early form of humanโcomputer interaction, before the advent of graphical user interfaces (GUIs).
In other words, a text user interface (TUI) is a type of user interface that relies on text rather than graphics to display information. And the most common use of TUI is in the form of a command-line interface.
The CLI of a TUI is generally not graphical, may have no mouse support, which is not the case of Textual, and may be designed for keyboard input. For example, the Linux cat command, btw I love cats, can be called on a TUI, in this case, your terminal, with the command $ cat
.
This will bring up a text-based interface, where you can type text and then press enter to display back the typed text.
Now, having a high overview of TUIs, let's dive into the world of Textual.
What is Textual?
๐ Go To TOC.
Textual is a relatively new text-based user interface toolkit. It allows programmers to build interactive yet sophisticated, user-friendly TUIs within the terminal. It is attractive to many programmers like me, and it is going to revolutionalize the world of terminal applications. What makes Textual attractive is the following core features:
- Textual helps you to build your terminal application with ease elegantly.
- Textual is the only way to create a highly interactive yet complex application within your terminal.
- Textual removes the horrible boilerplates of earlier TUIs by getting rid of decorators for handling events and such.
Regardless of what reasons you are going to use Textual, Iโm glad you stumbled across this article. I will be going step by step through Textual basics and how to create a fully functional application inside your terminal. Each article I am going to publish from now on will dive into details about every element of Textual.
Iโve chosen to develop a wordle clone with textual, mainly because it has the right complexity level.
While I hope this article will appeal to a diverse range of programmers, I have a unique audience in mind as I write it. As with any job description, you donโt have to know every damn thing they mention, but it will help you to understand who Iโm thinking about and how you might differ. My intended audience is:
- A beginner to intermediate programming skills using Python.
- Looking for some advanced Python concepts, which I hope you are eager to learn.
- Wants to learn about programming workflow, not just Textual.
- And, of course, has a good sense of humor.
Nevertheless, if youโre interested in creating a working application in Textual, youโre still in the right place! Iโll be showing you how to develop a wordle clone step by step. Youโll start with setting up a development environment with poetry and end up with an application running on your terminal. How exciting is that!
Install Textual
๐ Go To TOC.
Itโs quite an unfortunate truth in the world of programming that the fun part has to come after a hustle. However, Getting Textual up and running is not a terribly complicated process, just by running one command pip install textual
or by checking out the repo and running poetry install
. Since I used the latter in deepwordle
, let me give you a high overview of what poetry
is and how it has been my go-to tool for building packages and managing dependencies.
Dependency management with poetry
๐ Go To TOC.
This section will highlight the most important things that will help you start working with poetry.
However, it is worth mentioning that there are many ways to install a python package: using pip, pypenv, pdm, poetry, and many more. Poetry is not only "yet another" python dependency management tool, but also it can be used to deliver packages. I just like this comic:
What is Poetry
๐ Go To TOC.
Poetry is a pretty intuitive yet an elegant command-line tool for installing, managing dependencies, projects, and virtual environments, an all-in-one solution. The idea of inventing this tool is because of the different conventions of package management (using requirements.txt, MANIFEST.ini, setup.cfg, and many others) seemed to the creator of Poetry not very convenient. Only one file should be required, namely pyproject.toml
, which aims at being clear, readable, and part of the PEP 517 and PEP 518 standard.
Whether you are new to dependency management or not, I recommend giving this tool a try. It is effortless, easy to use, and can simplify the maintenance and development of your python project.
Poetry installation
๐ Go To TOC.
To install this tool, you can follow along with the official installation guide.
Essentially, If you are a Linux user, all you need to do is run the following command in your terminal.
$ curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python -
python versionning
When it comes to python versioning, I recommend using pyenv
. Therefore I am going to explain how to install a specific version of python interpreter with pyenv
pyenv
pyenv is a handy tool that allows you to install a specific version of the python interpreter into your machine with ease. Unlike the traditional way of going to the official python website and following the daunting steps to install a specific python interpreter, pyenv makes it easier by just running a simple command.
Pyenv installation
๐ Go To TOC.
The Readme file of the pyenv repository is fruitful with information about the installation process and how to use this tool.
However, I have compiled the essential parts to get this tool up and running on your machine if you are a Linux user.
Configure pyenv on zsh:
$ cat << EOF >> ~/.zshrc
# pyenv config
export PATH="${HOME}/.pyenv/bin:${PATH}"
export PYENV_ROOT="${HOME}/.pyenv"
eval "$(pyenv init -)"
EOF
Or if you are using the default bash shell, run the following command instead:
$ cat << EOF >> ~/.bashrc
# pyenv config
export PATH="${HOME}/.pyenv/bin:${PATH}"
export PYENV_ROOT="${HOME}/.pyenv"
eval "$(pyenv init -)"
EOF
Close your terminal and open a new shell session. Now, You can verify the installation process by running the following command:
$ pyenv --version
Python versioning with pyenv
๐ Go To TOC.
You can issue the following command to install a specific python interpreter:
$ pyenv install <python_version>
For this tutorial, I am going to install python 3.10.1:
$ pyenv install 3.10.1
To list all available python versions you can install with pyenv, you can run the following command:
$ pyenv install --list | grep \ 3\.
To list all installed versions of python on your machine, you can run:
$ pyenv versions
system
* 3.10.1 (set by PYENV_VERSION environment variable)
3.8.10
3.9.10
You can make it globally available by running:
$ pyenv global system 3.10.1
These are the essential commands for python versioning with pyenv
. Now, let's go back to poetry
.
virtual environments with poetry
๐ Go To TOC.
Having the python executable in your PATH using pyenv
, you can use it with poetry:
$ poetry env use 3.10.1
However, you are most likely to run into the following issue if you have virtualenv already installed using apt(if you didn't get this issue, that is great!):
Creating virtualenv deepwordle-dxc671ba-py3.10 in ~/.cache/pypoetry/virtualenvs
ModuleNotFoundError
No module named 'virtualenv.seed.via_app_data'
at <frozen importlib._bootstrap>:973 in _find_and_load_unlocked
To resolve it, you need to reinstall virtualenv through pip:
$ sudo apt remove --purge python3-virtualenv virtualenv
$ python3 -m pip install -U virtualenv
Now, you can tell poetry to use the pre-installed Python interpreter, 3.10.1 in this case:
$ poetry env use 3.10.1
Using virtualenv: ~/.cache/pypoetry/virtualenvs/deepwordle-dxc671ba-py3.10
Poetry has a proper path for setting up virtual environments(under ~/.cache/pypoetry/virtualenvs/
). However, if you want to let poetry create a virtual env called .venv
inside your project directory, you can run the following command:
$ poetry config virtualenvs.in-project true
You can take a look at different configurations of poetry by running:
$ poetry config --list
Starting a new Project
๐ Go To TOC.
Having a virtual environment set up, you can use poetry to create a new project along with a virtual environment:
$ poetry new deepwordle && cd deeepwordle
This will equip you with the bare minimum to get things started.
deepwordle
โโโ pyproject.toml
โโโ README.rst
โโโ deepwordle
โ โโโ __init__.py
โโโ tests
โโโ __init__.py
โโโ test_deepwordle.py
Activating a virtual environment
๐ Go To TOC.
If you are inside the project directory, you can activate a virtual environment by simply running:
$ poetry shell
or you can source the virtualenv
path:
$ source ~/.cache/pypoetry/virtualenvs/<your_virtual_environment_name>/bin/activate
you can look up for previously installed virtual environments under ~/.cache/pypoetry/virtualenvs
:
$ ls ~/.cache/pypoetry/virtualenvs
To exit this virtual environment, use exit
, Ctrl-d
, deactivate
, or your favorite way to nuke the shell.
Adding packages to your project
๐ Go To TOC.
Like the traditional way of using pip to install packages, poetry is capable of doing so by running the following command to import a package into your project:
$ poetry add <name_of_the_package_you_want_to_install>
This will add an entry under the tool.poetry.dependencies
section:
[tool.poetry.dependencies]
name_of_the_package_you_want_to_install = "^package_version"
You can always refer to the official documentation for more detailed information about setting up projects with poetry.
I think this section is a good starting point for building a new project using poetry. In the follow-up sections, we are going to use this tool to install and manage Textual.
Install Textual: revisited
๐ Go To TOC.
As far as I can tell from the code base, Textual is currently available for Linux os only MacOS / Linux / Windows. To add textual to your project, run the following command:
$ poetry add textual
As you can tell, writing about dependency and environments setup is also frustrating for me. I donโt know what operating system youโre using; I hope a Linux flavor. Otherwise, I canโt predict how things might go wrong for you.
Basic Textual App
๐ Go To TOC.
Now everything is set up, let's create the most basic Textual app. To do so, create a file called app.py
using your favorite code editor and add the following code:
from textual.app import App
App.run()
This is the most basic Textual app you can ever code; Just importing the App class and then calls the run method class.
Run this code with python if you are still inside your virtualenv
by running the following command:
$ python app.py
or using poetry
, but it is not required because we have already done $ poetry shell
which means that you are inside the directory that contains the pyproject.toml
file and the virtualenv
is activated, still you can use:
$ poetry run python app.py
It will create a blank window with a black background within your terminal.
If a blank window with a black background is exactly the kind of application you were looking to write, then youโre done! Congratulations. Just kidding, ofcource it is not! right?
Now let's buid a slightly basic Textual app uisng the followin code:
from textual.app import App
class MainApp(App):
...
MainApp.run()
This version uses inheritance to create a new subclass of App called MainApp. This is the application youโll be developing throughout this series of article. You didnโt actually do anything woth the new class, so it still behaves like the previous basic version. However, we are going to do a lot in this and subsequent articles.
In the following sections, we will discuss the different components of Textual and how to use them for building our TUI app. But first, we need to design our app, aka mockup.
User Interface
๐ Go To TOC.
Being good at designing UIs is an essential skill to possess as a front-end developer or an engineer. It gives quick insights into how to lay out visual application components before the coding phase. Let's apply this practice to our deepwordle
app. Here are some capabilities I want to cover in this project:
- Render each word being guessed on the screen with spaces in between.
- From a basic wordle app standpoint, the word is only five letters long.
- Only six attempts are allowed for the user to guess the word.
- Display a nicely formatted message to tell the user what is happening.
- Tell the user the available keys for this game.
Given these features, itโs fairly easy to imagine the set of views and widgets for the application will require:
- A 6x5 grid view.
- A Button for each letter.
- Message panel.
- A header and footer.
Textual Widgets
๐ Go To TOC.
In Textual, a Widget is the base visual component of TUI interfaces. It comes with a Canvas that can be used to draw on the terminal. It receives events and reacts to them. I find it convenient to think of a widget as a container with reactive attributes and behaviors, and it can contain other containers. The Widget
class is the most basic such container.
Widgets come with twelve reactive attributes you can manipulate its visual properties such as the style of the widget, its border, padding, size and many more. Under the hood, A reactive attribute is implemented as a python descriptor.
Each reactive attribute has a separate watcher, which can be defined using the watch
keyword followed by _
and the name of the attribute to watch:
foo = Reactive("")
def watch_foo(self, val):
if val == "bar":
do_something()
#
# custom logic
#
When writing this article, Textual provides thirteen out-of-the-box types of widgets. We will discuss how to use the ones used in our project.
Placeholder
๐ Go To TOC.
For prototyping purposes, a Placeholder can be used to see what the app looks like before the implementation phase. For example, our application looks like the following in terms of placeholders.
from textual.app import App
from textual.widgets import Placeholder
class MainApp(App):
async def on_mount(self) -> None:
await self.view.dock(Placeholder(name="header"), edge="top", size=3)
await self.view.dock(Placeholder(name="footer"), edge="bottom", size=3)
await self.view.dock(Placeholder(name="stats"), edge="left", size=40)
await self.view.dock(Placeholder(name="message"), edge="right", size=40)
await self.view.dock(Placeholder(name="grid"), edge="top")
MainApp.run()
Button
๐ Go To TOC.
A button is a Label with associated events triggered when the button is clicked. A button has three properties:
- label: the text being rendered on the button.
- name: the name of the widget.
- style: label's style. It is defined using the 'foreground on background' notation. for example: style = "white on dark_blue"
from textual.app import App
from textual.widgets import Button
class MainApp(App):
async def on_mount(self) -> None:
button1 = Button(label='Hello', name='button1')
button2 = Button(label='world', name='button2', style='black on white')
await self.view.dock(button1, button2, edge="left")
MainApp.run()
Header
๐ Go To TOC.
This widget defines a header for a terminal app. It can be used to display information such as the app's title, time, and icon(not customizable at the moment, but it has been requested in one of the issues/PRs)...
from textual.app import App
from textual.widgets import Header
class MainApp(App):
async def on_mount(self) -> None:
header = Header(tall=False)
await self.view.dock(header)
MainApp.run(title="DeepWordle")
Footer
๐ Go To TOC.
This widget defines a footer for a terminal app, and it can be used to display the available keys for the user.
from textual.app import App
from textual.widgets import Footer
class MainApp(App):
async def on_load(self) -> None:
"""Bind keys here."""
await self.bind("q", "quit", "Quit")
await self.bind("t", "tweet", "Tweet")
await self.bind("r", "None", "Record")
async def on_mount(self) -> None:
footer = Footer()
await self.view.dock(footer, edge="bottom")
MainApp.run(title="DeepWordle")
ScrollView
๐ Go To TOC.
from textual.app import App
from textual.widgets import ScrollView, Button
class MainApp(App):
async def on_mount(self) -> None:
scroll_view = ScrollView(contents= Button(label='button'), auto_width=True)
await self.view.dock(scroll_view)
MainApp.run()
Static
๐ Go To TOC.
from textual.app import App
from textual.widgets import Static, Button
class MainApp(App):
async def on_mount(self) -> None:
static = Static(renderable= Button(label='button'), name='')
await self.view.dock(static)
MainApp.run()
You can play with other widgets such as TreeClick
, TreeControl
, TreeNode
, NodeID
, ButtonPressed
, DirectoryTree
, FileClick
.
Custom Widgets
๐ Go To TOC.
By extending the generic widget class, you can create any sort of widget you want.
from textual.app import App
from textual.widget import Widget
from textual.reactive import Reactive
from rich.console import RenderableType
from rich.padding import Padding
from rich.align import Align
from rich.text import Text
class Letter(Widget):
label = Reactive("")
def render(self) -> RenderableType:
return Padding(
Align.center(Text(text=self.label), vertical="middle"),
(0, 1),
style="white on rgb(51,51,51)",
)
class MainApp(App):
async def on_mount(self) -> None:
letter = Letter()
letter.label = "A"
await self.view.dock(letter)
MainApp.run(title="DeepWordle")
It's just a simple widget Class declaring your custom Letter component, with custom rendering.
For more information about Widgets, besides this article, the only place to look for is the Readme file and the code base.
Reusable components
๐ Go To TOC.
When developing web applications or any sort of apps in general, you tend to reuse existing code in your project. And to make your code reusable, the best practice is to create each component of the app in a separate file. This way, your codebase will look much more organized and structured.
โโโ deepwordle
โ โโโ __init__.py
โ โโโ app.py
โ โโโ components
โ โ โโโ __init__.py
โ โ โโโ constants.py
โ โ โโโ letter.py
โ โ โโโ letters_grid.py
โ โ โโโ message.py
โ โ โโโ rich_text.py
โ โ โโโ utils.py
Organize with views
๐ Go To TOC.
Widgets in Textual are organized within a view that uses a docking technique to arrange them on the terminal. Docking is similar to the css grid layout, and it can be customized.
By default, a widget will be rendered on the center of the terminal. In textual, you can dock or arrange widgets to the terminal's left, right, top, and bottom sides by changing the edge
argument to left
, right
, top
, bottom
respectively.
In Textual, there are five types of views:
DockView
๐ Go To TOC.
It is the default view used by a textual app. It groups widgets either vertically(default) or horizontally to fill up all the terminal space. The edge
argument can be used to control how the widget can be grouped.
By default edge = top
.
from textual.app import App
from textual.widgets import Placeholder
from textual.views import DockView
class SimpleApp(App):
async def on_mount(self) -> None:
view: DockView = await self.push_view(DockView())
await view.dock(Placeholder(), Placeholder(), Placeholder())
SimpleApp.run()
from textual.app import App
from textual.widgets import Placeholder
from textual.views import DockView
class SimpleApp(App):
async def on_mount(self) -> None:
view: DockView = await self.push_view(DockView())
await view.dock(Placeholder(), Placeholder(), Placeholder(), edge='left')
SimpleApp.run()
You can also control the size of each widget in terms of characters.
from textual.app import App
from textual.widgets import Placeholder
from textual.views import DockView
class SimpleApp(App):
async def on_mount(self) -> None:
view: DockView = await self.push_view(DockView())
await view.dock(Placeholder(), Placeholder(), Placeholder(), size=10)
SimpleApp.run()
As you can see, each widget(Placeholder) has a height of 10 characters.
GridView
๐ Go To TOC.
GridView is used to layout TUIs widgets in a rectangular/tabular manner by specifying the number of rows and columns, and a list of widgets to lay them out on the terminal.
Example of an empty grid:
from textual.app import App
from textual.widgets import Placeholder
from textual.views import GridView
class SimpleApp(App):
async def on_mount(self) -> None:
await self.view.dock(GridView(), size=10)
await self.view.dock(Placeholder(name='sad'), size=10)
await self.view.dock(GridView(), size=10)
SimpleApp.run(log="textual.log")
Example of a 6x6 placeholders' grid:
from textual.app import App
from textual import events
from textual.widgets import Placeholder
class GridView(App):
async def on_mount(self, event: events.Mount) -> None:
"""Create a grid with auto-arranging cells."""
grid = await self.view.dock_grid()
grid.add_column("col", repeat=6, size=7)
grid.add_row("row", repeat=6, size=7)
grid.set_align("stretch", "center")
placeholders = [Placeholder() for _ in range(36)]
grid.place(*placeholders)
GridView.run(title="Grid View", log="textual.log")
WindowView
๐ Go To TOC.
A placeholder for widget.
from textual.app import App
from textual.widgets import Placeholder
from textual.views import WindowView
class SimpleApp(App):
async def on_mount(self) -> None:
await self.view.dock(WindowView(widget=Placeholder(name='sad')), size=10)
await self.view.dock(WindowView(widget=Placeholder(name='sad')), size=10)
await self.view.dock(Placeholder(name='sad'), size=10)
SimpleApp.run(log="textual.log")
Widget Event Handler
๐ Go To TOC.
In Textual, you can assign handlers for a widget using the underscore naming convention, unlike the traditional way of decorating a handler. For example, a key event handler can be simply written as:
def on_key(self, event):
...
Instead of the usual way of capturing events like the following:
@on(event)
def key(self):
Using the underscore convention, your code looks more readable and contains less boilerplate code. However, as a python developer, you might argue that writing boilerplate looks more Pythonic than the so-called "best practices", and I would agree with that. For instance, at first, I wasn't aware of that underscore notation and what was going on under the hood, and how in the world an event is being fired and handled until I went into the source code, and everything made sense to me. I actually like that notation, and it is more readable.
Wrapping Up
๐ Go To TOC.
Building your own TUI-based app allows you to take your UI skills to the next level. By using Textual, you can build any sort of terminal application you want. It can save you a lot of time and give your audience a better experience.
The Textual package hides many low-level details, allowing you to focus on the logic of your app.
In this article, you learned how to:
- Install and use Poetry.
- Install and use pyenv.
- Install and build custom interfaces with Textual.
You are free to use the code in this article as a starting point for various needs. Donโt forget to take a look at the readme file and use your imagination to make more complex apps that are meaningful to your use case.
Upcoming blogs on dev
๐ Go To TOC.
I am currently planning to share my experience on this platform that is made for developers. I joined Dev.to a few weeks ago, and as you can see from my profile on medium(I will be writing why I left medium in a separate article.), I mainly publish technical blogs on data science and computer vision with Python. I think this is just the start of me regularly publishing here, so let's grow together!
So, what's the catch? Well, there isn't one. I'm just giving this article away. This is a gift to you, and you can share it with whomever you like or use it in any way that would be beneficial to your personal and professional development. Thank you in advance for your ultimate support!
Happy Coding, folks; see you in the next one.
Top comments (10)
Iโm very interested in reading this, but can I respectfully say that the article needs an editing pass? Iโm in the beginning of part 1 and Iโve run across 2 errors/typos. (โrinningโ and โeverything damn thingโ).
This is great, thanks for sharing!
Glad you enjoyed it!
Great stuff, please continue
Definitely!
Outstanding tutorial. Thanks for putting this together!
Welcome. Glad you find it useful!
Hey, I'm one of the maintainers of Textual ๐
If you're reading this in 2024 - Textual has moved on a lot since this post - this is now out of date and I strongly recommend you look elsewhere as most of the examples here are no longer working.
@wiseai Any chance you could remove this or at least add a warning to the top of the post? It seems to be confusing a bunch of folk now (including ChatGPT which we suspect is weighting this post heavily given some recent confusion on the official Discord).
I appreciate the work you put into this series. I also like the choice to talk about setting up the project front to back. That is not a common thing to talk about but it's very cool to hear about all the different processes people have for doing such things.
This tutorial is out of date. The example code will no longer run.
See the official tutorial for a guide to building apps with Textual.