Создание отзывчивой терминальной панели данных с Textual
'Практическое руководство по созданию интерактивной терминальной панели на Textual: переиспользуемые виджеты, генерация данных и клавиатурные команды.'
Почему Textual для терминальных панелей?
Textual приносит реактивный, компонентный подход в терминальные интерфейсы, позволяя создавать интерфейсы, которые по поведению ближе к современным веб-панелям, оставаясь полностью на Python. Благодаря реактивным атрибутам, композиционным виджетам и обработчикам событий вы можете быстро прототипировать интерактивную панель и настраивать её макет, состояние и поведение без HTML или JavaScript.
Создание повторно используемых компонентов
Небольшой переиспользуемый виджет упрощает синхронизацию интерфейса с данными. В примере ниже StatsCard реагирует на изменения своего значения и автоматически обновляет метку:
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import (
Header, Footer, Button, DataTable, Static, Input,
Label, ProgressBar, Tree, Select
)
from textual.reactive import reactive
from textual import on
from datetime import datetime
import random
class StatsCard(Static):
value = reactive(0)
def __init__(self, title: str, *args, **kwargs):
super().__init__(*args, **kwargs)
self.title = title
def compose(self) -> ComposeResult:
yield Label(self.title)
yield Label(str(self.value), id="stat-value")
def watch_value(self, new_value: int) -> None:
if self.is_mounted:
try:
self.query_one("#stat-value", Label).update(str(new_value))
except Exception:
passЭтот подход демонстрирует, как реактивная система Textual позволяет виджетам обновляться при изменении состояния, сохраняя логику интерфейса локализованной и чистой.
Определение макета и стилей
Textual использует декларативный compose и строковые стили, похожие на CSS. Класс dashboard в примере задаёт стили, сочетания клавиш и реактивные атрибуты в одном месте, так что внешний вид и поведение приложения определены централизованно:
class DataDashboard(App):
CSS = """
Screen { background: $surface; }
#main-container { height: 100%; padding: 1; }
#stats-row { height: auto; margin-bottom: 1; }
StatsCard { border: solid $primary; height: 5; padding: 1; margin-right: 1; width: 1fr; }
#stat-value { text-style: bold; color: $accent; content-align: center middle; }
#control-panel { height: 12; border: solid $secondary; padding: 1; margin-bottom: 1; }
#data-section { height: 1fr; }
#left-panel { width: 30; border: solid $secondary; padding: 1; margin-right: 1; }
DataTable { height: 100%; border: solid $primary; }
Input { margin: 1 0; }
Button { margin: 1 1 1 0; }
ProgressBar { margin: 1 0; }
"""
BINDINGS = [
("d", "toggle_dark", "Toggle Dark Mode"),
("q", "quit", "Quit"),
("a", "add_row", "Add Row"),
("c", "clear_table", "Clear Table"),
]
total_rows = reactive(0)
total_sales = reactive(0)
avg_rating = reactive(0.0)Композиция интерфейса
Интерфейс строится путём yield-инга виджетов и контейнеров внутри compose. Вы можете объединить карточки, поля ввода, селекты, кнопки, навигационное дерево, индикатор прогресса и таблицу данных в единый макет:
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Container(id="main-container"):
with Horizontal(id="stats-row"):
yield StatsCard("Total Rows", id="card-rows")
yield StatsCard("Total Sales", id="card-sales")
yield StatsCard("Avg Rating", id="card-rating")
with Vertical(id="control-panel"):
yield Input(placeholder="Product Name", id="input-name")
yield Select(
[("Electronics", "electronics"),
("Books", "books"),
("Clothing", "clothing")],
prompt="Select Category",
id="select-category"
)
with Horizontal():
yield Button("Add Row", variant="primary", id="btn-add")
yield Button("Clear Table", variant="warning", id="btn-clear")
yield Button("Generate Data", variant="success", id="btn-generate")
yield ProgressBar(total=100, id="progress")
with Horizontal(id="data-section"):
with Container(id="left-panel"):
yield Label("Navigation")
tree = Tree("Dashboard")
tree.root.expand()
products = tree.root.add("Products", expand=True)
products.add_leaf("Electronics")
products.add_leaf("Books")
products.add_leaf("Clothing")
tree.root.add_leaf("Reports")
tree.root.add_leaf("Settings")
yield tree
yield DataTable(id="data-table")
yield Footer()Генерация и обновление данных
Логика генерирует примеры строк, обновляет агрегированные метрики и анимирует прогресс. Реактивные атрибуты и запросы виджетов упрощают синхронизацию интерфейса с моделью данных:
def on_mount(self) -> None:
table = self.query_one(DataTable)
table.add_columns("ID", "Product", "Category", "Price", "Sales", "Rating")
table.cursor_type = "row"
self.generate_sample_data(5)
self.set_interval(0.1, self.update_progress)
def generate_sample_data(self, count: int = 5) -> None:
table = self.query_one(DataTable)
categories = ["Electronics", "Books", "Clothing"]
products = {
"Electronics": ["Laptop", "Phone", "Tablet", "Headphones"],
"Books": ["Novel", "Textbook", "Magazine", "Comic"],
"Clothing": ["Shirt", "Pants", "Jacket", "Shoes"]
}
for _ in range(count):
category = random.choice(categories)
product = random.choice(products[category])
row_id = self.total_rows + 1
price = round(random.uniform(10, 500), 2)
sales = random.randint(1, 100)
rating = round(random.uniform(1, 5), 1)
table.add_row(
str(row_id),
product,
category,
f"${price}",
str(sales),
str(rating)
)
self.total_rows += 1
self.total_sales += sales
self.update_stats()
def update_stats(self) -> None:
self.query_one("#card-rows", StatsCard).value = self.total_rows
self.query_one("#card-sales", StatsCard).value = self.total_sales
if self.total_rows > 0:
table = self.query_one(DataTable)
total_rating = sum(float(row[5]) for row in table.rows)
self.avg_rating = round(total_rating / self.total_rows, 2)
self.query_one("#card-rating", StatsCard).value = self.avg_rating
def update_progress(self) -> None:
progress = self.query_one(ProgressBar)
progress.advance(1)
if progress.progress >= 100:
progress.progress = 0Связывание событий и управление
Обработчики соединяют кнопки и сочетания клавиш с действиями: добавление строк, очистка таблицы, генерация данных и переключение темы. Это делает поведение интерфейса интуитивным и удобным для клавиатуры:
@on(Button.Pressed, "#btn-add")
def handle_add_button(self) -> None:
name_input = self.query_one("#input-name", Input)
category = self.query_one("#select-category", Select).value
if name_input.value and category:
table = self.query_one(DataTable)
row_id = self.total_rows + 1
price = round(random.uniform(10, 500), 2)
sales = random.randint(1, 100)
rating = round(random.uniform(1, 5), 1)
table.add_row(
str(row_id),
name_input.value,
str(category),
f"${price}",
str(sales),
str(rating)
)
self.total_rows += 1
self.total_sales += sales
self.update_stats()
name_input.value = ""
@on(Button.Pressed, "#btn-clear")
def handle_clear_button(self) -> None:
table = self.query_one(DataTable)
table.clear()
self.total_rows = 0
self.total_sales = 0
self.avg_rating = 0
self.update_stats()
@on(Button.Pressed, "#btn-generate")
def handle_generate_button(self) -> None:
self.generate_sample_data(10)
def action_toggle_dark(self) -> None:
self.dark = not self.dark
def action_add_row(self) -> None:
self.handle_add_button()
def action_clear_table(self) -> None:
self.handle_clear_button()Запуск панели
Убедитесь, что зависимости установлены, и запустите цикл приложения в ноутбуке или локально:
!pip install textual textual-web nest-asyncioif __name__ == "__main__":
import nest_asyncio
nest_asyncio.apply()
app = DataDashboard()
app.run()С этим набором компонентов вы получите быстрый терминальный дашборд, который объединяет таблицы, дерево навигации, формы и индикатор прогресса в одном реактивном приложении. Дальше можно добавить графики, подключить API или реализовать мультистраничность, сохраняя проект на чистом Python.
Switch Language
Read this article in English