Textual
Textual is a TUI (Text User Interface) framework for Python inspired by modern web development. Currently a work in progress, but usable by brave souls who don't mind some API instability between updates.
Textual currently runs on MacOS / Linux only. Windows support is in the pipeline.
Follow @willmcgugan for progress updates, or post in Discussions if you have any requests / suggestions.
How it works
Textual uses Rich to render rich text, so anything that Rich can render may be used in Textual.
Event handling in Textual is asynchronous (using async
and await
keywords). Widgets (UI components) can independently update and communicate with each other via message passing.
Textual has more in common with modern web development than it does with curses; layout is done with CSS grid and (soon) the theme may be customized with CSS. Other techniques are borrowed from JS frameworks such as Vue and Reactive.
Installation
You can install Textual via pip (pip install textual
), or by checking out the repo and installing with poetry.
poetry install
Once installed you can run the following command for a quick test, or see examples (below):
python -m textual.app
Textual requires Python 3.7 or above.
Examples
Until I've written the documentation, the examples may be the best way to learn Textual.
You can see some of these examples in action in the Developer Video Log.
- animation.py Demonstration of 60fps animation easing function
- calculator.py A "clone" of the MacOS calculator using Grid layout
- code_viewer.py A demonstration of a tree view which loads syntax highlighted code
- grid.py A simple demonstration of adding widgets in a Grid layout
- grid_auto.py A demonstration of automatic Grid layout
- simple.py A very simple Textual app with scrolling Markdown view
Building Textual applications
This guide is a work in progress
Let's look at the simplest Textual app which does something:
from textual.app import App
class Beeper(App):
async def on_key(self, event):
self.console.bell()
Beeper.run()
Here we can see a textual app with a single on_key
method which will receive key events. Any key event will result in playing the terminal bell (which will generally emit an irritating beep). Hit Ctrl+C to exit.
Event handlers in Textual are defined by convention, not by inheritance (so you won't find an on_key
method in the base class). Each event has a name
attribute which for the key event is simply "key"
. Textual will call the method named on_
if it exists.
Let's look at a slightly more interesting example:
from textual.app import App
class ColorChanger(App):
async def on_key(self, event):
if event.key.isdigit():
self.background = f"on color({event.key})"
ColorChanger.run(log="textual.log")
This example also handles key events, and will set App.background
if the key is a digit. So pressing the keys 0 to 9 will change the background color to the corresponding ansi color.
Note that we didn't need to explicitly refresh the screen or draw anything. Setting the background
attribute to a Rich style is enough for Textual to update the visuals. This is an example of reactivity in Textual. To make changes to the terminal interface you modify the state and let Textual update the UI.
Widgets
To make more interesting apps you will need to make use of widgets, which are independent user interface elements. Textual comes with a (growing) library of widgets, but you can develop your own.
Let's look at an app which contains widgets. We will be using the built in Placeholder
widget which you can use to design application layouts before you implement the real content.
from textual import events
from textual.app import App
from textual.widgets import Placeholder
class SimpleApp(App):
async def on_mount(self, event: events.Mount) -> None:
await self.view.dock(Placeholder(), edge="left", size=40)
await self.view.dock(Placeholder(), Placeholder(), edge="top")
SimpleApp.run(log="textual.log")
This app contains a single event handler on_mount
. The mount event is sent when the app or widget is ready to start processing events, and is typically used for initialization. In this case we are going to call self.view.dock
to add widgets to the interface.
Here's the first line in the mount handler:
await self.view.dock(Placeholder(), edge="left", size=40)
Note this method is asynchronous like almost all API methods in Textual. We are awaiting self.view.dock
which takes a newly constructed Placeholder widget, and docks it on to the "left"
edge of the terminal with a size of 40 characters. In a real app you might use this to display a side-bar.
The following line is similar:
await self.view.dock(Placeholder(), Placeholder(), edge="top")
You will notice that this time we are docking two Placeholder objects onto the "top"
edge. We haven't set an explicit size this time so Textual will divide the remaining size amongst the two new widgets.
The last line calls the run
class method in the usual way, but with an argument we haven't seen before: log="textual.log"
tells Textual to write log information to the given file. You can tail textual.log to see events being processed and other debug information.
If you run the above example, you will see something like the following:
If you move the mouse over the terminal you will notice that widgets receive mouse events. You can click any of the placeholders to give it input focus.
The dock layout feature is very flexible, but for more sophisticated layouts we can use the grid API. See the calculator.py example which makes use of Grid.
Creating Widgets
You can create your own widgets by subclassing the textual.widget.Widget
class and implementing a render()
method which should return anything that can be rendered with Rich, including a plain string which will be interpreted as console markup.
Let's look at an example with a custom widget:
from rich.panel import Panel
from textual import events
from textual.app import App
from textual.reactive import Reactive
from textual.widget import Widget
class Hover(Widget):
mouse_over: Reactive[bool] = Reactive(False)
def render(self) -> Panel:
return Panel("Hello [b]World[/b]", style=("on red" if self.mouse_over else ""))
async def on_enter(self, event: events.Enter) -> None:
self.mouse_over = True
async def on_leave(self, event: events.Leave) -> None:
self.mouse_over = False
class HoverApp(App):
"""Hover widget demonstration."""
async def on_mount(self, event: events.Mount) -> None:
hovers = (Hover() for _ in range(10))
await self.view.dock(*hovers, edge="top")
HoverApp.run(log="textual.log")
The Hover
class is a custom widget which displays a panel containing the classic text "Hello World". The first line in the Hover class may seem a little mysterious at this point:
mouse_over: Reactive[bool] = Reactive(False)
This adds a mouse_over
attribute to your class which is a bool with a default of False
. The typing part (Reactive[bool]
) is not required, but will help you find bugs if you are using a tool like Mypy. Adding attributes like this makes them reactive, and any changes will result in the widget updating.
The following render()
method is where you define how the widget should be displayed. In the Hover widget we return a Panel containing rich text with a background that changes depending on the value of mouse_over
. The goal here is to add a mouse hover effect to the widget, which we can achieve by handling two events: Enter
and Leave
. These events are sent when the mouse enters or leaves the widget.
Here are the two event handlers again:
async def on_enter(self, event: events.Enter) -> None:
self.mouse_over = True
async def on_leave(self, event: events.Leave) -> None:
self.mouse_over = False
Both event handlers set the mouse_over
attribute which, because it is reactive, will result in the widget's render()
method being called.
The app class has a Mount
handler where we dock 10 of these custom widgets from the top edge, stacking them vertically. If you run this script you will see something like the following:
If you move your mouse over the terminal you should see that the widget under the mouse cursor changes to a red background.
Actions and key bindings
Actions in Textual are white-listed functions that may be bound to keys. Let's look at a trivial example of binding a key to an action. Here is an app which exits when we hit the Q key:
from textual.app import App
class Quitter(App):
async def on_load(self, event):
await self.bind("q", "quit")
Quitter.run()
If you run this you will get a blank terminal which will return to the prompt when you press Q.
Binding is done in the Load event handler. The bind
method takes the key (in this case "q") and binds it to an action ("quit"). The quit action is built in to Textual and simply exits the app.
To define your own actions, add a method that begins with action_
, which may take parameters. Let's create a simple action that changes the color of the terminal and binds keys to it:
from textual.app import App
class Colorizer(App):
async def on_load(self, event):
await self.bind("r", "color('red')")
await self.bind("g", "color('green')")
await self.bind("b", "color('blue')")
async def action_color(self, color:str) -> None:
self.background = f"on {color}"
Colorizer.run()
If you run this app you can hit the keys R, G, or B to change the color of the background.
In the on_load
method we have bound the keys R, G, and B to the color
action with a single parameter. When you press any of these three keys Textual will call the method action_color
with the appropriate parameter.
You could be forgiven for thinking that "color('red')"
is Python code which Textual evaluates. This is not the case. The action strings are parsed and may not include expressions or arbitrary code. The reason that strings are used over a callable is that (in a future update) key bindings may be loaded from a configuration file.
More on Events
TODO
Watchers
TODO
Animation
TODO
Timers and Intervals
Textual has a set_timer
and a set_interval
method which work much like their Javascript counterparts. The set_timer
method will invoke a callable after a given period of time, and set_interval
will invoke a callable repeatedly. Unlike Javascript, these methods expect the time to be in seconds, not milliseconds.
Let's create a simple terminal based clock with the set_interval
method:
from datetime import datetime
from rich.align import Align
from textual.app import App
from textual.widget import Widget
class Clock(Widget):
async def on_mount(self, event):
self.set_interval(1, callback=self.refresh)
def render(self) -> Align:
time = datetime.now().strftime("%X")
return Align.center(time, vertical="middle")
class ClockApp(App):
async def on_mount(self, event):
await self.view.dock(Clock())
ClockApp.run()
If you run this app you will see the current time in the center of the terminal until you hit Ctrl+C.
The Clock widget displays the time using rich.align.Align to position it in the center. In the clock's Mount handler there is the following call to set_interval
:
self.set_interval(1, callback=self.refresh)
This tells Textual to call a function (in this case self.refresh
which updates the widget) once a second. When a widget is refreshed it calls Clock.render
again to display the latest time.
Developer Video Log
Since Textual is a visual medium, I'll be documenting new features and milestones here.
Update 1 - Basic scrolling
Update 2 - Keyboard toggle
Update 3 - New scrollbars and smooth scrolling
Update 4 - Animation system with easing function
Now with a system to animate changes to values, going from the initial to the final value in small increments over time . Here applied to the scroll position. The animation system supports CSS like easing functions. You may be able to tell from the video that the page up / down keys cause the window to first speed up and then slow down.
Update 5 - New Layout system
A new update system allows for overlapping layers. Animation is now synchronized with the display which makes it very smooth!
Update 6 - New Layout API
New version (0.1.4) with API updates and the new layout system.
Update 7 - New Grid Layout
11 July 2021
Added a new layout system modelled on CSS grid. The example demonstrates how once created a grid will adapt to the available space.
Update 8 - Tree control and scroll views
6 Aug 2021
Added a tree control and refactored the renderer to allow for widgets within a scrollable view