2026-05-05
Python
0

目录

🧭 从"跑起来"到"搞明白"
🏗️ App:整个 TUI 世界的地基
App 的几个关键属性
🖥️ Screen:真正的"页面"单元
Screen 的生命周期
🔄 ComposeResult:组件树的"施工图"
动态组合:compose 不是静态模板
🔗 三者协作:一个完整的实战案例
🪟 Windows 下的几个注意事项
💬 聊聊设计哲学

🧭 从"跑起来"到"搞明白"

很多人第一次接触 Textual,是从官方文档的 Hello World 开始的。三行代码,终端里弹出一个带边框的窗口——酷。然后呢?然后就懵了。

App 是什么?Screen 又是什么?compose() 里返回的 ComposeResult 到底是个啥类型?这些问题不搞清楚,写出来的代码就是一堆"能跑但不知道为什么能跑"的玩意儿。

我在用 Textual 开发一个本地文件管理工具的时候,前两周基本上就是在和这套结构死磕。踩了不少坑,也慢慢摸出了门道。今天就把这些东西掰开揉碎讲一讲——不是翻译文档,是真正从"为什么这么设计"的角度来聊。


🏗️ App:整个 TUI 世界的地基

先说 App

简单粗暴地理解:App 就是你整个终端应用的容器。它负责启动事件循环、管理屏幕栈、处理全局按键绑定、控制主题切换……一句话,它是老板。

python
from textual.app import App, ComposeResult from textual.widgets import Header, Footer, Label class MyApp(App): """一个最简单的 Textual 应用""" CSS = """ Label { content-align: center middle; height: 1fr; } """ def compose(self) -> ComposeResult: yield Header() yield Label("你好,Textual!") yield Footer() if __name__ == "__main__": app = MyApp() app.run()

image.png

这段代码能跑。但你注意到没有——compose() 出现在了 App 里,而不是 Screen 里。这是初学者最容易混淆的地方。

App 本身也是一个隐式的 Screen 准确说,当你在 App 里直接写 compose(),Textual 会自动把它包成一个默认的 Screen 推入屏幕栈。这是个"便捷通道",适合写简单的单屏应用。但一旦你的应用有多个界面,这条路就走不通了。

App 的几个关键属性

TITLESUB_TITLE 控制顶部 Header 显示的内容。CSSCSS_PATH 用来注入样式。BINDINGS 定义全局快捷键。这三个是使用频率最高的类变量,几乎每个项目都要用到。

python
class MyApp(App): TITLE = "文件管理器" SUB_TITLE = "v1.0.0 - Windows 专属版" CSS_PATH = "style.tcss" BINDINGS = [ ("q", "quit", "退出"), ("d", "toggle_dark", "切换暗色模式"), ]

action_toggle_dark() 这个方法不用自己写,App 基类已经内置了。这是 Textual 的一个设计哲学——把常见操作都内置进去,让你专注业务逻辑


🖥️ Screen:真正的"页面"单元

Screen 才是你日常打交道最多的东西。

如果说 App 是整个剧场,那 Screen 就是舞台上的一幕。你可以有"主菜单"这一幕,有"设置界面"这一幕,有"确认对话框"这一幕——它们各自独立,各自有自己的组件树、自己的样式、自己的事件处理逻辑。

python
from textual.app import App, ComposeResult from textual.screen import Screen from textual.widgets import Header, Footer, Button, Label from textual.containers import Container class MainScreen(Screen): """主界面""" def compose(self) -> ComposeResult: yield Header() with Container(): yield Label("欢迎使用文件管理器", id="welcome") yield Button("打开设置", id="btn-settings", variant="primary") yield Button("退出", id="btn-quit", variant="error") yield Footer() def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "btn-settings": self.app.push_screen(SettingsScreen()) elif event.button.id == "btn-quit": self.app.exit() class SettingsScreen(Screen): """设置界面""" def compose(self) -> ComposeResult: yield Header() yield Label("这里是设置页面") yield Button("返回", id="btn-back") yield Footer() def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "btn-back": self.app.pop_screen() class MyApp(App): SCREENS = {"main": MainScreen, "settings": SettingsScreen} def on_mount(self) -> None: self.push_screen("main")

image.png

注意几个细节。

SCREENS 字典是可选的,用来提前注册屏幕——好处是可以用字符串名称来引用,而不是每次都实例化一个新对象。push_screen() 把新屏幕压入栈顶,pop_screen() 弹出当前屏幕回到上一层。这是个栈结构,不是替换,是叠加。

这个设计有个很实用的场景:弹出确认框。你不需要销毁当前界面再重建,直接 push_screen(ConfirmDialog()) 就行,用户确认或取消后 pop_screen() 回来,原来的界面状态完好无损。

Screen 的生命周期

on_mount() 在屏幕被推入栈并完成渲染后触发,适合做数据加载。on_unmount() 在屏幕被弹出时触发,适合做清理工作。

python
class DataScreen(Screen): def compose(self) -> ComposeResult: yield Header() yield Label("加载中...", id="status") yield Footer() async def on_mount(self) -> None: # 模拟异步数据加载 await self.load_data() async def load_data(self) -> None: import asyncio await asyncio.sleep(1) # 实际项目里换成真实的 IO 操作 self.query_one("#status", Label).update("数据加载完成!")

Textual 基于 asyncio,这意味着你可以在 on_mount 里直接写异步代码,不用担心阻塞 UI。这在 Windows 下开发尤其重要——很多文件 IO 操作如果同步执行,界面会直接卡死。


🔄 ComposeResult:组件树的"施工图"

ComposeResult 是个类型别名,本质上是 Iterable[Widget]

但它不只是一个列表。它是一个生成器协议——Textual 会在渲染时逐一消费你 yield 出来的组件,按顺序构建 DOM 树。这个设计让你可以用 Python 最自然的方式来描述界面结构。

python
from textual.app import ComposeResult from textual.widgets import Label, Button, Input from textual.containers import Vertical, Horizontal def compose(self) -> ComposeResult: # 最简单的用法:直接 yield 组件 yield Label("用户名") yield Input(placeholder="请输入用户名", id="username") # 用容器组织布局 with Horizontal(): yield Button("确认", variant="success") yield Button("取消", variant="default")

with 语法是个语法糖。Horizontal() 这类容器实现了 __enter____exit__,在 with 块里 yield 的组件会自动成为该容器的子节点。这比手动维护父子关系优雅多了。

动态组合:compose 不是静态模板

这是很多人没意识到的一点——compose() 是普通的 Python 函数,你可以在里面写逻辑。

python
def compose(self) -> ComposeResult: yield Header() # 根据条件动态生成组件 items = self.load_menu_items() # 从配置或数据库读取 with Vertical(id="menu"): for item in items: yield Button( label=item["label"], id=f"menu-{item['id']}", variant="primary" if item.get("highlight") else "default" ) yield Footer()

这种动态性在实际项目里非常有用。比如根据用户权限显示不同的菜单项,或者根据配置文件渲染不同数量的输入框——都不需要什么特殊 API,就是普通的 Python 控制流。


🔗 三者协作:一个完整的实战案例

光说不练假把式。来看一个稍微完整一点的例子——一个简单的任务管理器,有任务列表和添加任务两个界面。

python
from textual.app import App, ComposeResult from textual.screen import Screen from textual.widgets import ( Header, Footer, Button, Input, Label, ListView, ListItem ) from textual.containers import Vertical, Horizontal # 添加任务界面 class AddTaskScreen(Screen): """输入新任务的弹出界面""" def compose(self) -> ComposeResult: yield Header() with Vertical(id="form"): yield Label("新任务名称:") yield Input(placeholder="例如:完成技术文章", id="task-input") with Horizontal(id="buttons"): yield Button("添加", id="btn-add", variant="success") yield Button("取消", id="btn-cancel", variant="default") yield Footer() def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "btn-add": task_name = self.query_one("#task-input", Input).value.strip() if task_name: # 通过 dismiss 把数据传回上一个 Screen self.dismiss(task_name) elif event.button.id == "btn-cancel": self.dismiss(None) # 主界面 class TaskListScreen(Screen): """任务列表主界面""" BINDINGS = [("a", "add_task", "添加任务")] def __init__(self): super().__init__() self._tasks: list[str] = ["写周报", "Code Review"] def compose(self) -> ComposeResult: yield Header() with Vertical(): yield ListView( *[ListItem(Label(t)) for t in self._tasks], id="task-list" ) yield Button("+ 添加任务", id="btn-add", variant="primary") yield Footer() def action_add_task(self) -> None: """快捷键 'a' 触发""" self.app.push_screen(AddTaskScreen(), self._on_task_added) def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "btn-add": self.action_add_task() def _on_task_added(self, task_name: str | None) -> None: """AddTaskScreen dismiss 后的回调""" if task_name: self._tasks.append(task_name) list_view = self.query_one("#task-list", ListView) list_view.append(ListItem(Label(task_name))) # 应用入口 class TaskApp(App): TITLE = "任务管理器" CSS = """ #form { margin: 2 4; padding: 1 2; border: solid $primary; } #buttons { margin-top: 1; align: right middle; } #task-list { height: 1fr; } """ def on_mount(self) -> None: self.push_screen(TaskListScreen()) if __name__ == "__main__": TaskApp().run()

image.png

这个例子里有几个值得单独说的点。

push_screen() 的第二个参数是回调函数——当被推入的 Screen 调用 self.dismiss(value) 时,这个回调会被触发,并接收 value 作为参数。这是 Textual 在 Screen 间传递数据的标准方式,比全局变量干净多了。

BINDINGS 定义在 Screen 里的快捷键只在该 Screen 处于栈顶时有效。这个作用域隔离非常重要——不同界面可以绑定同一个按键到不同操作,互不干扰。


🪟 Windows 下的几个注意事项

在 Windows 上跑 Textual,有几个坑是 macOS/Linux 用户不会遇到的。

终端选择很关键。Windows 自带的 cmd.exe 对 ANSI 转义码的支持历来是个问题,虽然 Windows 10 之后改善了很多,但还是推荐用 Windows Terminal。PowerShell 7+ 也可以,体验比 5.x 好一大截。

字体问题。Textual 大量使用 Unicode 字符来绘制边框和图标。如果你发现界面里有奇怪的方块或者乱码,多半是字体不支持。换成 Cascadia Code 或者 Nerd Font 系列基本就能解决。

asyncio 事件循环。Windows 下 Python 3.10 之前默认使用 SelectorEventLoop,某些异步操作会有兼容性问题。如果遇到莫名其妙的错误,可以在入口处加一行:

python
import asyncio import sys if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())

💬 聊聊设计哲学

Textual 的这套 App → Screen → Widget 层级,其实和 Web 开发的思维非常接近。App 像是浏览器,Screen 像是页面,Widget 像是 DOM 元素。如果你有前端背景,上手会特别快。

ComposeResult 的生成器设计,则让界面描述变成了纯粹的 Python——没有模板语言,没有 DSL,就是函数和 yield。这种设计降低了心智负担,但也意味着你需要对 Python 的生成器机制有基本了解,否则某些行为会让你困惑。

从"能跑"到"搞明白",这中间的距离,往往就是把这几个核心概念真正理解透彻的距离。

欢迎在评论区聊聊你在使用 Textual 过程中遇到的问题,或者你觉得这套结构设计还有哪些值得深挖的地方。


#Python #Textual #TUI开发 #终端应用 #Windows开发

相关信息

我用夸克网盘给你分享了「texttual_20250505.zip」,点击链接或复制整段内容,打开「夸克APP」即可获取。 /9df93YQRWp:/ 链接:https://pan.quark.cn/s/9c284bc5310c 提取码:jQzm

本文作者:技术老小子

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!