2026-05-17
Python
0

目录

🎬 开篇:终端界面,也可以很优雅
🔧 环境准备
🏗️ Textual 应用的基本骨架
📌 Header:给应用一个"门面"
📌 Footer:快捷键的"说明书"
📌 Label:最朴素的文本展示
📌 Button:响应点击的核心交互组件
📌 Input:文本输入,比想象中强大
🚀 综合示例:一个完整的"系统配置工具"
⚠️ 常见问题汇总
🎯 总结

🎬 开篇:终端界面,也可以很优雅

做过命令行工具的开发者,大概都有过这样的经历——辛辛苦苦写完一个脚本,功能完全没问题,但一打开就是黑乎乎一片,参数全靠 argparse 堆,交互全靠 input() 凑。给同事演示的时候,对方第一句话往往是:"这个……能不能做个界面?"

做 GUI 吧,PyQt 和 tkinter 学习成本不低,打包部署也麻烦。不做吧,纯命令行的体验确实差强人意。

Textual 就是为了解决这个尴尬而生的。 它是一个基于 Python 的 TUI(Terminal User Interface)框架,让你在终端里就能渲染出媲美现代 Web 应用的界面——有布局、有组件、有事件系统,甚至支持 CSS 样式。

本文聚焦 Textual 最核心的五个内置组件:Button、Label、Input、Header、Footer。读完你将掌握每个组件的实际用法、常见参数、踩坑点,以及一个可以直接跑起来的综合示例。无需提前有 Textual 经验,只要会基础 Python 就够了。

测试环境:Windows 11 + Python 3.11 + Textual 0.52.1,终端使用 Windows Terminal。


🔧 环境准备

在开始之前,先把环境搭好。Textual 安装非常简单:

bash
pip install textual

如果你想边开发边实时预览样式变化,可以额外安装开发工具包:

bash
pip install textual-dev

安装完成后,运行官方自带的 demo 验证一下环境:

bash
python -m textual

如果终端里出现一个色彩丰富的演示界面,说明环境已经就绪。


🏗️ Textual 应用的基本骨架

在介绍具体组件之前,有必要先理解 Textual 应用的基本结构。所有 Textual 应用都继承自 App 类,通过 compose() 方法返回组件树,通过事件方法响应用户操作。

python
from textual.app import App, ComposeResult class MyApp(App): def compose(self) -> ComposeResult: # 在这里 yield 各种组件 yield ... if __name__ == "__main__": app = MyApp() app.run()

这个结构非常固定,后续所有示例都基于这个骨架展开。


📌 Header:给应用一个"门面"

Header 是 Textual 应用顶部的标题栏组件,通常是 compose() 方法里第一个 yield 的东西。它会自动显示应用的标题和副标题,还内置了一个时钟。

python
from textual.app import App, ComposeResult from textual.widgets import Header class HeaderDemo(App): # 通过类属性设置标题和副标题 TITLE = "我的工具箱" SUB_TITLE = "基于 Textual 构建" def compose(self) -> ComposeResult: yield Header() if __name__ == "__main__": HeaderDemo().run()

image.png

常用参数说明:

  • show_clock:是否在右侧显示时钟,默认为 True,设为 False 可隐藏。
python
yield Header(show_clock=False)

TITLESUB_TITLEApp 类的类属性,Header 会自动读取并渲染。也可以在运行时动态修改:

python
self.title = "新标题" self.sub_title = "新副标题"

踩坑预警: Header 本身不接受 title 参数,标题必须通过 App 的类属性或实例属性设置,直接传参会报错。这是初学者最容易犯的错误之一。


📌 Footer:快捷键的"说明书"

Footer 显示在应用底部,它最大的作用是自动渲染已绑定的快捷键列表,让用户一眼就能知道当前可用的操作。

python
from textual.app import App, ComposeResult from textual.widgets import Header, Footer from textual.binding import Binding class FooterDemo(App): TITLE = "Footer 演示" # 通过 BINDINGS 绑定快捷键 BINDINGS = [ Binding("q", "quit", "退出"), Binding("d", "toggle_dark", "切换主题"), ] def compose(self) -> ComposeResult: yield Header() yield Footer() def action_toggle_dark(self) -> None: self.dark = not self.dark if __name__ == "__main__": FooterDemo().run()

image.png

运行后,底部会自动出现 Q 退出D 切换主题 两个快捷键提示,完全不需要手动维护这个列表。

Binding 参数说明:

参数说明
key按键名称,如 "q""ctrl+c"
action对应的 action 方法名(去掉 action_ 前缀)
description显示在 Footer 中的描述文字
show是否在 Footer 中显示,默认 True

📌 Label:最朴素的文本展示

Label 是最简单的组件,就是纯文本展示,但它支持 Rich 标记语言,这让它远比看起来强大。

python
from textual.app import App, ComposeResult from textual.widgets import Header, Footer, Label class LabelDemo(App): TITLE = "Label 演示" BINDINGS = [("q", "quit", "退出")] def compose(self) -> ComposeResult: yield Header() # 普通文本 yield Label("这是一段普通文字") # 使用 Rich 标记实现样式 yield Label("[bold red]这是加粗红色文字[/bold red]") yield Label("[green]✅ 操作成功[/green]") yield Label("[yellow]⚠️ 请注意这个警告[/yellow]") yield Footer() if __name__ == "__main__": LabelDemo().run()

image.png

Rich 标记语法是 Textual 的一大亮点。常用标记包括:

  • [bold]...[/bold]:加粗
  • [italic]...[/italic]:斜体
  • [red]...[/red]:颜色(支持所有 CSS 颜色名)
  • [link=URL]...[/link]:可点击链接
  • [on blue]...[/on blue]:背景色

id 参数非常重要,后续如果需要动态更新 Label 内容,必须通过 id 来定位:

python
yield Label("初始内容", id="status-label") # 在其他方法中更新内容 label = self.query_one("#status-label", Label) label.update("[green]更新后的内容[/green]")

📌 Button:响应点击的核心交互组件

Button 是 Textual 中使用频率最高的交互组件。它的事件处理方式和 Web 前端的思路非常接近,上手很快。

python
from textual.app import App, ComposeResult from textual.widgets import Header, Footer, Button, Label from textual.containers import Horizontal class ButtonDemo(App): TITLE = "Button 演示" BINDINGS = [("q", "quit", "退出")] def compose(self) -> ComposeResult: yield Header() yield Label("点击按钮查看效果", id="result") yield Horizontal( Button("确认", id="btn-confirm", variant="success"), Button("警告", id="btn-warn", variant="warning"), Button("危险", id="btn-danger", variant="error"), Button("普通", id="btn-default", variant="default"), ) yield Footer() def on_button_pressed(self, event: Button.Pressed) -> None: """所有按钮的点击事件都会触发这个方法""" button_id = event.button.id result_label = self.query_one("#result", Label) if button_id == "btn-confirm": result_label.update("[green]你点了确认按钮[/green]") elif button_id == "btn-warn": result_label.update("[yellow]你点了警告按钮[/yellow]") elif button_id == "btn-danger": result_label.update("[red]你点了危险按钮[/red]") else: result_label.update("你点了普通按钮") if __name__ == "__main__": ButtonDemo().run()

image.png

variant 参数控制按钮的视觉风格,Textual 内置了以下几种:

variant 值视觉效果适用场景
default默认灰色一般操作
primary蓝色高亮主要操作
success绿色确认/完成
warning黄色需要注意
error红色危险/删除

事件处理的两种写法值得特别说明。上面的 on_button_pressed全局监听写法,所有按钮的点击都会触发,需要在方法内部通过 event.button.id 区分来源。另一种是精确绑定写法,直接在方法名里指定按钮 id:

python
def on_button_pressed_btn_confirm(self, event: Button.Pressed) -> None: """只响应 id 为 btn-confirm 的按钮""" self.query_one("#result", Label).update("[green]确认![/green]")

注意方法名规则:on_ + 组件类名小写 + _pressed_ + 按钮id(中划线替换为下划线)。

踩坑预警: Buttondisabled 属性可以禁用按钮,但禁用状态下按钮仍然会接收焦点(可以被 Tab 键选中),只是不会触发 Pressed 事件。如果想完全屏蔽交互,需要配合 CSS 处理。


📌 Input:文本输入,比想象中强大

Input 是处理用户文本输入的核心组件,支持占位符、密码模式、输入验证等功能。

python
from textual.app import App, ComposeResult from textual.widgets import Header, Footer, Input, Label, Button from textual.validation import Length, Regex class InputDemo(App): TITLE = "Input 演示" BINDINGS = [("q", "quit", "退出")] def compose(self) -> ComposeResult: yield Header() yield Label("用户名(4-16位字符):") yield Input( placeholder="请输入用户名", id="username", validators=[Length(minimum=4, maximum=16)], ) yield Label("密码:") yield Input( placeholder="请输入密码", password=True, # 密码模式,输入显示为 * id="password", ) yield Label("邮箱:") yield Input( placeholder="example@domain.com", id="email", validators=[ Regex(r"^[\w.-]+@[\w.-]+\.\w+$", failure_description="邮箱格式不正确") ], ) yield Button("提交", id="submit", variant="primary") yield Label("", id="feedback") yield Footer() def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id != "submit": return username = self.query_one("#username", Input).value password = self.query_one("#password", Input).value email = self.query_one("#email", Input).value feedback = self.query_one("#feedback", Label) if not username or not password or not email: feedback.update("[red]所有字段均为必填项[/red]") return feedback.update( f"[green]提交成功!用户名:{username},邮箱:{email}[/green]" ) def on_input_changed(self, event: Input.Changed) -> None: """实时监听输入变化""" # event.input 是触发事件的 Input 组件 # event.value 是当前输入值 # event.validation_result 是验证结果 if event.validation_result and not event.validation_result.is_valid: # 可以在这里做实时错误提示 pass if __name__ == "__main__": InputDemo().run()

image.png

Input 的核心参数:

  • placeholder:输入框为空时显示的提示文字
  • password:设为 True 时输入内容显示为星号
  • value:初始值
  • validators:验证器列表,Textual 内置了 LengthNumberRegex 三种
  • id:必须设置,否则后续无法通过 query_one 定位

两个核心事件:

  • Input.Changed:每次输入内容变化时触发,适合做实时验证
  • Input.Submitted:用户按下 Enter 键时触发,适合做提交操作
python
def on_input_submitted(self, event: Input.Submitted) -> None: """按 Enter 键时触发,效果等同于点击提交按钮""" self.query_one("#submit", Button).press()

踩坑预警: Input.value 始终返回字符串,即使输入的是数字。如果需要数值类型,记得手动转换,同时做好异常捕获:

python
try: port = int(self.query_one("#port", Input).value) except ValueError: # 处理非数字输入 pass

🚀 综合示例:一个完整的"系统配置工具"

把五个组件放在一起,做一个稍微有点实用价值的东西——一个模拟的系统配置面板。

python
from textual.app import App, ComposeResult from textual.widgets import Header, Footer, Button, Label, Input from textual.containers import Vertical, Horizontal from textual.binding import Binding class ConfigPanel(App): """系统配置面板示例""" TITLE = "系统配置工具" SUB_TITLE = "v1.0.0" CSS = """ Screen { align: center middle; } Vertical { width: 60; border: round $primary; padding: 1 2; } Input { margin-bottom: 1; } Horizontal { height: auto; align: center middle; margin-top: 1; } Button { margin: 0 1; } #feedback { margin-top: 1; text-align: center; } """ BINDINGS = [ Binding("q", "quit", "退出"), Binding("ctrl+s", "save", "保存配置"), ] def compose(self) -> ComposeResult: yield Header(show_clock=True) with Vertical(): yield Label("[bold]数据库配置[/bold]") yield Label("主机地址:") yield Input(placeholder="127.0.0.1", value="127.0.0.1", id="db-host") yield Label("端口:") yield Input(placeholder="3306", value="3306", id="db-port") yield Label("数据库名:") yield Input(placeholder="mydb", id="db-name") yield Label("密码:") yield Input(placeholder="请输入数据库密码", password=True, id="db-pass") with Horizontal(): yield Button("测试连接", id="btn-test", variant="default") yield Button("保存配置", id="btn-save", variant="primary") yield Button("重置", id="btn-reset", variant="warning") yield Label("", id="feedback") yield Footer() def on_button_pressed(self, event: Button.Pressed) -> None: feedback = self.query_one("#feedback", Label) if event.button.id == "btn-test": host = self.query_one("#db-host", Input).value port = self.query_one("#db-port", Input).value feedback.update(f"[yellow]正在测试连接 {host}:{port} ...[/yellow]") # 实际项目中这里会发起真实连接测试 # 为演示目的,直接模拟成功 self.set_timer(1.0, self._mock_test_result) elif event.button.id == "btn-save": self.action_save() elif event.button.id == "btn-reset": self._reset_form() feedback.update("[yellow]表单已重置[/yellow]") def _mock_test_result(self) -> None: self.query_one("#feedback", Label).update("[green]✅ 连接测试成功[/green]") def action_save(self) -> None: host = self.query_one("#db-host", Input).value port = self.query_one("#db-port", Input).value db_name = self.query_one("#db-name", Input).value feedback = self.query_one("#feedback", Label) if not db_name: feedback.update("[red]❌ 数据库名不能为空[/red]") return # 实际项目中这里写入配置文件 feedback.update( f"[green]✅ 配置已保存:{host}:{port}/{db_name}[/green]" ) def _reset_form(self) -> None: self.query_one("#db-host", Input).value = "127.0.0.1" self.query_one("#db-port", Input).value = "3306" self.query_one("#db-name", Input).value = "" self.query_one("#db-pass", Input).value = "" if __name__ == "__main__": ConfigPanel().run()

image.png 这个示例覆盖了本文所有五个组件,同时演示了 set_timer 做异步延迟、CSS 内嵌样式、with 语法嵌套容器等实用技巧,可以直接作为项目模板使用。


⚠️ 常见问题汇总

Q:query_oneNoMatches 错误? 大概率是 id 写错了,或者组件还没渲染就去查询。确保在 on_mount 或事件回调里执行查询,而不是在 compose 里。

Q:Input 输入中文时出现乱码或无法输入? 这是 Windows 下的终端编码问题。确保 Windows Terminal 的编码设置为 UTF-8,并在脚本开头加上:

python
import sys import io sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')

Q:Button 点击后界面没有刷新? Textual 的 UI 更新是响应式的,直接修改组件属性(如 label.update())会自动触发重渲染,不需要手动调用刷新方法。如果没有更新,检查是否真的修改了正确的组件实例。

Q:多个 Input 怎么用 Tab 键切换焦点? Textual 默认支持 Tab 键在可聚焦组件之间切换,InputButton 都是可聚焦的,无需额外配置。


🎯 总结

五个组件,各有分工:

  • Header / Footer 负责应用的"框架感",一行代码搞定标题栏和快捷键提示
  • Label 负责信息展示,配合 Rich 标记可以实现丰富的样式
  • Button 负责触发操作,variantid 是两个最重要的参数
  • Input 负责收集用户输入,内置验证器让表单处理变得简洁

Textual 的学习曲线比 PyQt 平缓很多,但它的组件系统、CSS 样式引擎和响应式更新机制其实相当完整。本文只是入门,后续还有 DataTableListViewTreeProgressBar 等更复杂的组件,以及 Screen 切换、Worker 异步任务等进阶话题,都值得深入研究。

如果你正在做命令行工具、运维脚本、或者工控上位机的简易界面,Textual 完全可以作为 PyQt 的轻量替代方案,值得在项目里试一试。


💬 互动话题

你在开发命令行工具时,遇到过哪些让用户体验很差的交互设计?有没有用过 Textual 或者其他 TUI 框架解决过类似问题?欢迎在评论区聊聊你的实践经验。


#Python #Textual #TUI #终端界面 #Python开发 #编程技巧 #工具开发

本文作者:技术老小子

本文链接:

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