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.