做过命令行工具的开发者,大概都有过这样的经历——辛辛苦苦写完一个脚本,功能完全没问题,但一打开就是黑乎乎一片,参数全靠 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 安装非常简单:
bashpip install textual
如果你想边开发边实时预览样式变化,可以额外安装开发工具包:
bashpip install textual-dev
安装完成后,运行官方自带的 demo 验证一下环境:
bashpython -m textual
如果终端里出现一个色彩丰富的演示界面,说明环境已经就绪。
在介绍具体组件之前,有必要先理解 Textual 应用的基本结构。所有 Textual 应用都继承自 App 类,通过 compose() 方法返回组件树,通过事件方法响应用户操作。
pythonfrom textual.app import App, ComposeResult
class MyApp(App):
def compose(self) -> ComposeResult:
# 在这里 yield 各种组件
yield ...
if __name__ == "__main__":
app = MyApp()
app.run()
这个结构非常固定,后续所有示例都基于这个骨架展开。
Header 是 Textual 应用顶部的标题栏组件,通常是 compose() 方法里第一个 yield 的东西。它会自动显示应用的标题和副标题,还内置了一个时钟。
pythonfrom 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()

常用参数说明:
show_clock:是否在右侧显示时钟,默认为 True,设为 False 可隐藏。pythonyield Header(show_clock=False)
TITLE 和 SUB_TITLE 是 App 类的类属性,Header 会自动读取并渲染。也可以在运行时动态修改:
pythonself.title = "新标题"
self.sub_title = "新副标题"
踩坑预警: Header 本身不接受 title 参数,标题必须通过 App 的类属性或实例属性设置,直接传参会报错。这是初学者最容易犯的错误之一。
Footer 显示在应用底部,它最大的作用是自动渲染已绑定的快捷键列表,让用户一眼就能知道当前可用的操作。
pythonfrom 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()

运行后,底部会自动出现 Q 退出 和 D 切换主题 两个快捷键提示,完全不需要手动维护这个列表。
Binding 参数说明:
| 参数 | 说明 |
|---|---|
key | 按键名称,如 "q"、"ctrl+c" |
action | 对应的 action 方法名(去掉 action_ 前缀) |
description | 显示在 Footer 中的描述文字 |
show | 是否在 Footer 中显示,默认 True |
Label 是最简单的组件,就是纯文本展示,但它支持 Rich 标记语言,这让它远比看起来强大。
pythonfrom 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()

Rich 标记语法是 Textual 的一大亮点。常用标记包括:
[bold]...[/bold]:加粗[italic]...[/italic]:斜体[red]...[/red]:颜色(支持所有 CSS 颜色名)[link=URL]...[/link]:可点击链接[on blue]...[/on blue]:背景色id 参数非常重要,后续如果需要动态更新 Label 内容,必须通过 id 来定位:
pythonyield Label("初始内容", id="status-label")
# 在其他方法中更新内容
label = self.query_one("#status-label", Label)
label.update("[green]更新后的内容[/green]")
Button 是 Textual 中使用频率最高的交互组件。它的事件处理方式和 Web 前端的思路非常接近,上手很快。
pythonfrom 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()

variant 参数控制按钮的视觉风格,Textual 内置了以下几种:
| variant 值 | 视觉效果 | 适用场景 |
|---|---|---|
default | 默认灰色 | 一般操作 |
primary | 蓝色高亮 | 主要操作 |
success | 绿色 | 确认/完成 |
warning | 黄色 | 需要注意 |
error | 红色 | 危险/删除 |
事件处理的两种写法值得特别说明。上面的 on_button_pressed 是全局监听写法,所有按钮的点击都会触发,需要在方法内部通过 event.button.id 区分来源。另一种是精确绑定写法,直接在方法名里指定按钮 id:
pythondef on_button_pressed_btn_confirm(self, event: Button.Pressed) -> None:
"""只响应 id 为 btn-confirm 的按钮"""
self.query_one("#result", Label).update("[green]确认![/green]")
注意方法名规则:on_ + 组件类名小写 + _pressed_ + 按钮id(中划线替换为下划线)。
踩坑预警: Button 的 disabled 属性可以禁用按钮,但禁用状态下按钮仍然会接收焦点(可以被 Tab 键选中),只是不会触发 Pressed 事件。如果想完全屏蔽交互,需要配合 CSS 处理。
Input 是处理用户文本输入的核心组件,支持占位符、密码模式、输入验证等功能。
pythonfrom 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()

Input 的核心参数:
placeholder:输入框为空时显示的提示文字password:设为 True 时输入内容显示为星号value:初始值validators:验证器列表,Textual 内置了 Length、Number、Regex 三种id:必须设置,否则后续无法通过 query_one 定位两个核心事件:
Input.Changed:每次输入内容变化时触发,适合做实时验证Input.Submitted:用户按下 Enter 键时触发,适合做提交操作pythondef on_input_submitted(self, event: Input.Submitted) -> None:
"""按 Enter 键时触发,效果等同于点击提交按钮"""
self.query_one("#submit", Button).press()
踩坑预警: Input.value 始终返回字符串,即使输入的是数字。如果需要数值类型,记得手动转换,同时做好异常捕获:
pythontry:
port = int(self.query_one("#port", Input).value)
except ValueError:
# 处理非数字输入
pass
把五个组件放在一起,做一个稍微有点实用价值的东西——一个模拟的系统配置面板。
pythonfrom 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()
这个示例覆盖了本文所有五个组件,同时演示了 set_timer 做异步延迟、CSS 内嵌样式、with 语法嵌套容器等实用技巧,可以直接作为项目模板使用。
Q:query_one 报 NoMatches 错误?
大概率是 id 写错了,或者组件还没渲染就去查询。确保在 on_mount 或事件回调里执行查询,而不是在 compose 里。
Q:Input 输入中文时出现乱码或无法输入? 这是 Windows 下的终端编码问题。确保 Windows Terminal 的编码设置为 UTF-8,并在脚本开头加上:
pythonimport sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
Q:Button 点击后界面没有刷新?
Textual 的 UI 更新是响应式的,直接修改组件属性(如 label.update())会自动触发重渲染,不需要手动调用刷新方法。如果没有更新,检查是否真的修改了正确的组件实例。
Q:多个 Input 怎么用 Tab 键切换焦点?
Textual 默认支持 Tab 键在可聚焦组件之间切换,Input 和 Button 都是可聚焦的,无需额外配置。
五个组件,各有分工:
variant 和 id 是两个最重要的参数Textual 的学习曲线比 PyQt 平缓很多,但它的组件系统、CSS 样式引擎和响应式更新机制其实相当完整。本文只是入门,后续还有 DataTable、ListView、Tree、ProgressBar 等更复杂的组件,以及 Screen 切换、Worker 异步任务等进阶话题,都值得深入研究。
如果你正在做命令行工具、运维脚本、或者工控上位机的简易界面,Textual 完全可以作为 PyQt 的轻量替代方案,值得在项目里试一试。
💬 互动话题
你在开发命令行工具时,遇到过哪些让用户体验很差的交互设计?有没有用过 Textual 或者其他 TUI 框架解决过类似问题?欢迎在评论区聊聊你的实践经验。
#Python #Textual #TUI #终端界面 #Python开发 #编程技巧 #工具开发
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!