Sáu tháng trước, tôi đang triển khai một dashboard giám sát Python dùng tkinter. Nó hoạt động được — nhưng chỉ vừa đủ thôi. Vòng lặp sự kiện rất dễ hỏng, rendering đa nền tảng không nhất quán, và mỗi widget mới cảm giác như phải thương lượng với một framework từ năm 2003. Một đồng nghiệp chỉ tôi đến Textual, và tôi viết lại dashboard đó trong một cuối tuần. Nó đã chạy trên production từ đó đến nay, và tôi chưa đụng vào tkinter kể từ đó.
Những gì tiếp theo là những gì tôi thực sự học được khi xây dựng ứng dụng TUI thực tế với Textual — không phải các ví dụ đơn giản từ file README, mà là những thứ production-grade với reactive state, dữ liệu trực tiếp và điều hướng đa màn hình.
Bắt đầu nhanh: Tạo ứng dụng hoạt động trong 5 phút
Cài đặt Textual với extras dev — chỉ riêng CSS inspector cũng đã xứng đáng rồi:
pip install textual[dev]
Đây là ứng dụng Textual nhỏ nhất nhưng hữu ích — một skeleton theo dõi hệ thống trực tiếp:
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Static
from textual.reactive import reactive
class MonitorApp(App):
CSS = """
Static {
border: solid green;
padding: 1;
margin: 1;
}
"""
def compose(self) -> ComposeResult:
yield Header()
yield Static("CPU: đang tải...", id="cpu")
yield Static("Bộ nhớ: đang tải...", id="mem")
yield Footer()
if __name__ == "__main__":
app = MonitorApp()
app.run()
Chạy nó:
python monitor.py
Bạn sẽ có một ứng dụng terminal toàn màn hình với header, footer và các hộp có style — không cần cấu hình, không cần Tk root window, không cần boilerplate mainloop. Lần chạy đầu tiên đó là lúc tôi nhận ra tkinter và tôi đã xong nhau rồi.
Tìm hiểu sâu: Widgets, Layouts và hệ thống CSS
Layouts thực sự hoạt động như thế nào
Textual sử dụng layout engine lấy cảm hứng từ CSS, chạy hoàn toàn trong terminal. Nếu bạn đã làm frontend, mô hình positioning sẽ trở nên quen thuộc rất nhanh. Layouts được định nghĩa trực tiếp (dưới dạng chuỗi CSS ở cấp class) hoặc trong các file .tcss bên ngoài.
Hai chế độ layout bạn sẽ dùng thường xuyên nhất là horizontal và vertical. Một layout dạng panel chia đôi trông như thế này:
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Static
from textual.containers import Horizontal, Vertical
class SplitApp(App):
CSS = """
#sidebar {
width: 30%;
background: $panel;
border-right: solid $accent;
}
#main {
width: 70%;
}
"""
def compose(self) -> ComposeResult:
yield Header()
with Horizontal():
with Vertical(id="sidebar"):
yield Static("Điều hướng")
yield Static("Cài đặt")
with Vertical(id="main"):
yield Static("Vùng nội dung chính")
yield Footer()
if __name__ == "__main__":
SplitApp().run()
Các biến $panel và $accent đến từ theme tích hợp của Textual. Chúng tự động thích nghi khi người dùng chuyển đổi giữa chế độ tối và sáng bằng phím tắt mặc định.
Reactive Properties: Khái niệm cốt lõi
Reactive properties là khái niệm quan trọng nhất của Textual — và điều phân biệt nó với mọi thư viện TUI khác. Chúng là các thuộc tính cấp class tự động kích hoạt UI re-render khi giá trị thay đổi. Không cần gọi refresh thủ công, không cần dispatch event. Chỉ cần gán giá trị mới; widget sẽ tự cập nhật.
from textual.app import App, ComposeResult
from textual.widgets import Static
from textual.reactive import reactive
class CounterWidget(Static):
count = reactive(0)
def render(self) -> str:
return f"Đếm: {self.count}"
class CounterApp(App):
def compose(self) -> ComposeResult:
yield CounterWidget(id="counter")
def on_mount(self) -> None:
self.set_interval(1, self.increment)
def increment(self) -> None:
self.query_one("#counter", CounterWidget).count += 1
if __name__ == "__main__":
CounterApp().run()
Counter tăng mỗi giây mà không cần refresh thủ công. reactive xử lý dirty-checking và lên lịch re-render nội bộ. Sáu tháng chạy thứ này trong dashboard giám sát production — đọc các chỉ số CPU, bộ nhớ và ổ đĩa mỗi giây — zero rò rỉ bộ nhớ, zero cập nhật bị bỏ lỡ dưới tải liên tục.
Sử dụng nâng cao: Cập nhật thời gian thực và DataTable
Background Workers cho dữ liệu trực tiếp
Với dữ liệu thời gian thực (metrics, logs, polling API), Textual cung cấp decorator work chạy các coroutine dưới dạng background task mà không chặn UI thread:
import asyncio
import psutil
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Static
from textual.reactive import reactive
from textual import work
class CPUWidget(Static):
cpu_pct = reactive(0.0)
def render(self) -> str:
bar = "█" * int(self.cpu_pct / 5)
return f"CPU [{bar:<20}] {self.cpu_pct:.1f}%"
class LiveMonitor(App):
def compose(self) -> ComposeResult:
yield Header()
yield CPUWidget(id="cpu")
yield Footer()
def on_mount(self) -> None:
self.poll_metrics()
@work(exclusive=True)
async def poll_metrics(self) -> None:
while True:
cpu = psutil.cpu_percent(interval=None)
self.query_one("#cpu", CPUWidget).cpu_pct = cpu
await asyncio.sleep(1)
if __name__ == "__main__":
LiveMonitor().run()
DataTable cho dữ liệu có cấu trúc
DataTable xử lý các tập dữ liệu dạng bảng lớn một cách hiệu quả — nó ảo hóa các hàng, nên render 10.000 hàng tốn chi phí gần bằng render 10:
from textual.app import App, ComposeResult
from textual.widgets import DataTable
ROWS = [
("Tiến trình", "PID", "CPU%", "MEM%"),
("nginx", 1234, "0.1", "0.8"),
("postgres", 5678, "2.3", "12.4"),
("python", 9101, "18.7", "3.2"),
]
class TableApp(App):
def compose(self) -> ComposeResult:
yield DataTable()
def on_mount(self) -> None:
table = self.query_one(DataTable)
table.add_columns(*ROWS[0])
table.add_rows(ROWS[1:])
if __name__ == "__main__":
TableApp().run()
Bạn nhận được cột có thể sắp xếp, điều hướng bàn phím và highlight con trỏ miễn phí. Thêm tính năng chọn hàng với callback chỉ mất thêm hai dòng code.
Textual DevTools
CSS inspector là thứ tôi không ngờ sẽ tìm thấy trong một terminal framework. Chạy ứng dụng của bạn với:
textual run --dev monitor.py
Sau đó mở inspector trong terminal thứ hai:
textual console
Bạn có được một CSS inspector trực tiếp tô sáng các widget khi di chuột, hiển thị computed styles và cho phép chỉnh CSS theo thời gian thực. Đó là lãnh địa của browser DevTools — tôi không ngờ có nó ở đây. Những thay đổi layout mà trước đây phải restart lại toàn bộ, giờ chỉ mất khoảng 10 giây để kiểm tra.
Mẹo thực tế từ sáu tháng chạy production
Dùng file .tcss cho những gì vượt quá 20 dòng
Chuỗi CSS inline hoạt động tốt cho prototype nhanh. Sau đó, hãy tách style vào app.tcss và tham chiếu bằng:
class MyApp(App):
CSS_PATH = "app.tcss"
Trình soạn thảo của bạn sẽ nhận diện cú pháp, và DevTools console có thể hot-reload style thay đổi mà không cần restart ứng dụng.
Điều hướng theo màn hình cho ứng dụng đa view
Điều hướng đa trang sử dụng hệ thống Screen của Textual. Push và pop các screen như một stack:
from textual.screen import Screen
from textual.app import App
from textual.widgets import Button
class DetailScreen(Screen):
def compose(self):
yield Button("Quay lại", id="back")
def on_button_pressed(self, event):
self.app.pop_screen()
class MainApp(App):
def compose(self):
yield Button("Mở chi tiết", id="detail")
def on_button_pressed(self, event):
self.push_screen(DetailScreen())
Đây chính xác là cách tôi xử lý các configuration panel và drill-down view trong monitoring dashboard. Mỗi view là một Screen riêng biệt; phím tắt bàn phím khiến điều hướng cảm giác như một ứng dụng thực sự.
Chú ý đến Thread Safety
UI của Textual chạy trên asyncio event loop. Nếu bạn đang lấy dữ liệu từ một thread thông thường (không phải async) — chẳng hạn subprocess hoặc socket listener — hãy dùng app.call_from_thread() để post cập nhật an toàn:
import threading
def background_reader(app):
# Đang chạy trong một thread thông thường
while True:
data = fetch_some_data()
app.call_from_thread(app.update_display, data)
thread = threading.Thread(target=background_reader, args=(app,), daemon=True)
thread.start()
Bỏ qua điều này sẽ gây ra các race condition tinh tế chỉ xuất hiện khi có tải — tôi gặp phải điều này sớm và mất cả ngày để debug.
Key Bindings và Footer Widget
Khai báo key binding ở cấp class và widget Footer tự động render chúng như một thanh trợ giúp ở cuối màn hình:
class MyApp(App):
BINDINGS = [
("q", "quit", "Thoát"),
("r", "refresh", "Làm mới"),
("d", "toggle_dark", "Chế độ tối"),
]
def action_refresh(self) -> None:
# Logic làm mới tùy chỉnh
self.poll_metrics()
Tên phương thức phải khớp với action_<binding_name>. action_quit và action_toggle_dark là built-in — bạn không cần tự implement những cái đó.
Khi nào Textual không phải là lựa chọn phù hợp
Textual tăng thêm độ phức tạp thực sự cho các script đơn giản. Cần một progress bar cho một lệnh chạy một lần? Dùng Rich thay vào đó — Textual cũng được xây dựng trên đó. Textual xứng đáng với độ phức tạp khi bạn cần tương tác liên tục: dashboard, configuration manager, log viewer, database browser. Nếu người dùng cần điều hướng, lọc và thao tác trên dữ liệu trực tiếp theo thời gian, đó là công cụ phù hợp.
Stack production hiện tại của tôi dùng nó cho một deployment monitor theo dõi các service trên ba môi trường. Nhóm đã chấp nhận nó ngay lập tức vì nó hoạt động như một ứng dụng thực sự — phím tắt bàn phím, chế độ tối, hỗ trợ chuột — chạy hoàn toàn qua SSH mà không cần X forwarding hay VNC.

