Back to Posts
A laptop displaying code using the Textual Python library for creating interactive terminal applications, set on a wooden desk with a cup of coffee and a backpack nearby.

Guide to Building Interactive Terminal Apps with Textual

By Alyce Osbourne

Textual is an advanced library built on top of the Rich library, designed to offer a fully interactive, GUI-like experience within the terminal. It provides a wide range of widgets and interactive features, allowing developers to create visually appealing and functional terminal applications. Textual supports various styling options, offers a clickable interface, and is cross-platform, even running on the web.

Creating a simple application

Creating a simple application with Textual is straightforward. Below is an example that demonstrates the basic setup:

from textual.app import App, ComposeResult
from textual.widgets import Placeholder, Header, Footer

class Application(App):
    def compose(self) -> ComposeResult:
        yield Header(show_clock=True)
        yield Placeholder("Hello World")
        yield Footer()

def main() -> None:
    app = Application()
    app.run()

if __name__ == "__main__":
    main()

In this example, the compose function is used to assemble the layout by yielding the desired components:

  • Header: Adds a header to your CLI, featuring a command palette and an optional clock.
  • Placeholder: Serves as a placeholder widget, useful for initial layout compositions.
  • Footer: Provides a footer that can list hotkeys for the given screen if bound.

Note

Textual applications may not function well on IDE-embedded consoles. It is best to run textual applications on a terminal, as most IDEs do not emulate interactive terminals, which textual relies on.

Building a more interactive application

Let’s enhance the application to be more interactive by logging user input and displaying messages:

from typing import Literal
from textual.app import App, ComposeResult, on
from textual.widgets import Input, Header, Footer, RichLog
from rich.panel import Panel
from rich.align import Align

class Application(App):
    def compose(self) -> ComposeResult:
        yield Header(show_clock=True, name="Chat")
        yield RichLog(markup=True)
        yield Input(placeholder="Message", id="input")
        yield Footer()

    async def write_message(self, message: str, title: str, side: Literal["left", "right"], colour: str):
        msg = Align(
            Panel(
                message,
                title=f"[{colour}]{title}[/]",
                title_align=side,
                width=max(self.app.console.width // 3, 80)
            ),
            side
        )
        self.query_one(RichLog).write(msg, expand=True)

    @on(Input.Submitted)
    async def submit(self, message: Input.Submitted) -> None:
        await self.write_message(message.value, "You", "left", "blue")
        self.query_one("#input", Input).clear()

def main() -> None:
    app = Application()
    app.run()

if __name__ == "__main__":
    main()

This code demonstrates an interactive interface that logs user input:

  • RichLog: Provides a vertically aligned log to which we can write. Setting markup=True allows the use of Rich’s markup features, such as color.
  • Input: Creates a text box for user input, which can be processed by other functions.
  • on: Decorator used to assign functions as message handlers. This enables the CLI to react to user inputs.
  • query_one: Queries objects by type or ID, using CSS-style selectors for IDs.

Textual widgets overview

Textual comes with a variety of widgets that can be used to build sophisticated terminal user interfaces. Here are some of the key widgets and how they can be used in your applications.

Button

Buttons are interactive elements that can trigger actions when clicked.

from textual.app import App, ComposeResult, on
from textual.widgets import Button, Header, Footer

class Application(App):
    def compose(self) -> ComposeResult:
        yield Header(show_clock=True)
        yield Button(label="Click Me", id="button")
        yield Footer()

    @on(Button.Pressed)
    async def button_pressed(self, event: Button.Pressed) -> None:
        button = self.query_one("#button", Button)
        button.label = "Clicked!"

def main() -> None:
    app = Application()
    app.run()

if __name__ == "__main__":
    main()

Checkbox

Checkboxes allow users to make binary choices.

from textual.app import App, ComposeResult, on
from textual.widgets import Checkbox, Header, Footer

class Application(App):
    def compose(self) -> ComposeResult:
        yield Header(show_clock=True)
        yield Checkbox(label="Accept Terms and Conditions", id="checkbox")
        yield Footer()

    @on(Checkbox.Changed)
    async def checkbox_changed(self, event: Checkbox.Changed) -> None:
        checkbox = self.query_one("#checkbox", Checkbox)
        checkbox.label = "Accepted!" if event.value else "Accept Terms and Conditions"

def main() -> None:
    app = Application()
    app.run()

if __name__ == "__main__":
    main()

ProgressBar

Progress bars display the progress of a task.

from textual import events, on
from textual.app import App, ComposeResult
from textual.widgets import ProgressBar, Header, Footer
from asyncio import sleep

class Application(App):
    def compose(self) -> ComposeResult:
        yield Header(show_clock=True)
        yield ProgressBar(total=100, id="progress")
        yield Footer()

    @on(events.Ready)
    async def on_startup(self) -> None:
        progress = self.query_one("#progress", ProgressBar)
        for i in range(101):
            progress.update(progress=i)
            await sleep(0.1)

def main() -> None:
    app = Application()
    app.run()

if __name__ == "__main__":
    main()

Implementing multiple screens

Textual allows the creation of multiple screens within the same application, useful for settings pages, logs, etc. Below is an example:

from typing import Literal

from textual.app import App, ComposeResult, on
from textual.widgets import Input, Header, Footer, RichLog
from textual.screen import ModalScreen
from rich.panel import Panel
from rich.align import Align

class ChatScreen(ModalScreen):
    app: "Application"
    BINDINGS = [
        ("ctrl+s", "app.switch_mode('settings')", "Settings"),
    ]
    def compose(self) -> ComposeResult:
        yield Header(show_clock=True, name="Logs")
        yield RichLog(markup=True, id="output")
        yield Input(placeholder="Type a log message", id="input")
        yield Footer()

    async def write_log(self, message: str, title: str, side: Literal["left", "right"], colour: str):
        msg = Align(
            Panel(
                message,
                title=f"[{colour}]{title}[/]",
                title_align=side,
                width=max(self.app.console.width // 3, 80)
            ),
            side
        )
        self.query_one(RichLog).write(msg, expand=True)

    @on(Input.Submitted)
    async def submit_handler(self, event: Input.Submitted) -> None:
        await self.write_log(event.value, "Message", "left", "green")
        self.query_one("#input", Input).clear()
        await self.write_log("A response", "Response", "right", "blue")


class SettingsScreen(ModalScreen):
    app: "Application"
    BINDINGS = [
        ("escape", "app.switch_mode('chat')", "Logs"),
    ]

    def compose(self) -> ComposeResult:
        yield Header(show_clock=True)
        yield Input(value=self.app.config_setting, id="input")
        yield Footer()

    @on(Input.Submitted)
    async def submit_handler(self, event: Input.Submitted) -> None:
        self.app.config_setting = event.value
        self.app.pop_screen()

class Application(App):
    MODES = {"chat": ChatScreen, "settings": SettingsScreen}

    def __init__(self, config_setting="Default Setting"):
        self.config_setting = config_setting
        super().__init__()

    async def on_mount(self) -> None:
        await self.switch_mode("chat")


def main() -> None:
    app = Application()
    app.run()

if __name__ == "__main__":
    main()
  • ModalScreen: Used to create custom screens. This allows for the definition of separate modes within the application.
  • MODES: This allows assigning names to the various modes, so we can switch between them by calling app.switch_mode.

Final thoughts

Textual offers a wide range of widgets and tools for creating visually stunning and highly responsive terminal user interfaces (TUIs). This guide covers basic concepts and examples, but just scratches the surface of textual’s potential. With robust features and the ability to create sophisticated, interactive terminal experiences, Textual is a game-changer for developers building CLI applications.

To learn more about Textual and its features, visit the official documentation here.

Improve your code with my 3-part code diagnosis framework

Watch my free 30 minutes code diagnosis workshop on how to quickly detect problems in your code and review your code more effectively.

When you sign up, you'll get an email from me regularly with additional free content. You can unsubscribe at any time.

Recent posts