编辑
2026-04-20
Python
00

目录

🤔 先说说,终端 UI 到底有没有价值
🧩 Textual 到底是什么
🚀 第一个 Textual 应用:五分钟跑起来
🏗️ 核心概念:理解 App、Screen 与 Widget
📊 上位机场景实战:实时数据面板
⚡ 异步才是灵魂:Worker 机制
🎨 TCSS:给终端界面"化妆"
🪤 踩坑预警:Windows 下的注意事项
💡 三句话总结
🔧 可复用代码模板
📚 延伸学习路径

做上位机的朋友,大概都经历过这样一个阶段:设备跑着,数据哗哗地来,但界面——要么是一堆 print() 滚屏,要么花大力气搭个 PyQt 窗口,结果光环境配置就搞了半天。

有没有一种方案,既不用写 HTML,也不用装 Qt,直接在终端里就能跑出一个有按钮、有表格、有实时刷新的现代界面?

有。它叫 Textual


🤔 先说说,终端 UI 到底有没有价值

很多人第一反应是:终端界面?那不是上个世纪的东西吗?

这个偏见,我理解,但确实是偏见。在上位机开发场景里,终端 UI 其实有几个 GUI 替代不了的优势——

部署零依赖。SSH 进服务器或者工控机,不需要显示器,不需要 X11,不需要 Qt 运行时,python main.py 直接跑。调试极方便。生产环境的嵌入式 Linux 主机,你总不能装个完整桌面环境吧。资源占用低。一个 Textual 应用跑起来,内存消耗比 Electron 少一个数量级不止。

所以这玩意儿不是复古,是务实。


🧩 Textual 到底是什么

Textual 是由 Will McGugan(Rich 库的作者)开发的一个 Python TUI(Terminal User Interface)框架。它构建在 Rich 之上,但定位完全不同——Rich 负责"让输出好看",而 Textual 负责"让终端变成一个真正的应用"。

说具体点,Textual 给你提供了:

  • 组件体系:Button、Input、DataTable、Log、ProgressBar、Tree……应有尽有
  • CSS 样式系统:没开玩笑,它真的有自己的 TCSS(Textual CSS),控制布局、颜色、边距
  • 事件驱动模型:点击、键盘、鼠标滚轮,全部事件化处理
  • 异步架构:基于 asyncio,天然支持后台任务,不会因为数据采集卡死 UI
  • 鼠标支持:可以用鼠标点击终端里的按钮,不是开玩笑

安装只需要一行:

bash
pip install textual

Windows 下完全支持,Windows Terminal 效果最佳。


🚀 第一个 Textual 应用:五分钟跑起来

先写个最简单的骨架,感受一下结构:

python
from textual.app import App, ComposeResult from textual.widgets import Header, Footer, Static class HelloApp(App): """最简单的 Textual 应用""" CSS = """ Static { background: $panel; border: round $primary; padding: 1 2; margin: 1; text-align: center; } """ def compose(self) -> ComposeResult: yield Header() # 顶部标题栏 yield Static("欢迎使用 Textual 上位机框架") # 正文内容 yield Footer() # 底部快捷键栏 if __name__ == "__main__": app = HelloApp() app.run()

image.png

运行 python hello_textual.py,终端里会出现一个带边框的完整界面,按 Ctrl+C 退出。

就这么简单。没有回调地狱,没有信号槽,compose 方法里 yield 什么组件,界面上就出现什么。


🏗️ 核心概念:理解 App、Screen 与 Widget

Textual 的架构分三层,搞清楚这三层,后面写复杂应用就不会乱。

App 是整个应用的根。它管理生命周期,持有全局状态,控制 Screen 的切换。一个应用只有一个 App 实例。

Screen 是"页面"。登录页是一个 Screen,主控台是一个 Screen,配置页是另一个 Screen。App 在 Screen 之间切换,类似手机 App 的页面栈。

Widget 是最小的 UI 单元。Button、Input、DataTable 都是 Widget。你也可以继承 Widget 写自己的自定义组件——这在上位机里特别有用,比如封装一个"设备状态卡片"。

三者关系:App → Screen → Widget,层层嵌套,职责清晰。


📊 上位机场景实战:实时数据面板

光说概念没意思。咱们直接写一个贴近实际的例子——一个能实时刷新传感器数据的监控面板

python
import random from textual.app import App, ComposeResult from textual.widgets import Header, Footer, DataTable, Log from textual.reactive import reactive # 模拟传感器数据(实际项目里换成串口读取) def read_sensor_data(): return { "温度": f"{random.uniform(20.0, 85.0):.1f} °C", "压力": f"{random.uniform(0.1, 1.0):.3f} MPa", "转速": f"{random.randint(800, 3600)} RPM", "状态": random.choice(["正常", "正常", "正常", "告警"]), } class MonitorApp(App): CSS = """ DataTable { height: 60%; border: round $accent; } Log { height: 35%; border: round $success; margin-top: 1; } """ def compose(self) -> ComposeResult: yield Header(show_clock=True) # 顶栏显示时钟 yield DataTable() yield Log() yield Footer() def on_mount(self) -> None: # 初始化表格列 table = self.query_one(DataTable) table.add_columns("参数", "数值", "单位") # 启动定时刷新,每 1 秒采集一次 self.set_interval(1.0, self.refresh_data) def refresh_data(self) -> None: table = self.query_one(DataTable) log = self.query_one(Log) data = read_sensor_data() table.clear() # 清空旧数据 for key, val in data.items(): # 状态告警时高亮显示 if key == "状态" and "告警" in val: table.add_row(key, f"[bold red]{val}[/bold red]", "—") else: table.add_row(key, val, "—") log.write_line(f"[{self.app.get_css_variables()}] 数据已刷新") if __name__ == "__main__": MonitorApp().run()

image.png

这个例子里,set_interval(1.0, self.refresh_data) 是关键——它每秒触发一次数据刷新,而且完全不阻塞 UI,因为 Textual 的事件循环本身是异步的。

实际项目里,把 read_sensor_data() 换成 pyserial 读串口,或者 pymodbus 读 Modbus 寄存器,逻辑完全一样。


⚡ 异步才是灵魂:Worker 机制

上面的例子用 set_interval 做轮询,适合简单场景。但如果采集操作本身耗时(比如 Modbus TCP 有网络延迟),就需要用 Worker 把它扔到后台。

python
from textual.app import App, ComposeResult from textual.widgets import Button, Log from textual import work class WorkerDemo(App): def compose(self) -> ComposeResult: yield Button("开始采集", id="start") yield Log() def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "start": self.run_collection() @work(exclusive=True) # exclusive=True:同一时间只跑一个 async def run_collection(self) -> None: log = self.query_one(Log) for i in range(10): # 模拟耗时的设备通信 await asyncio.sleep(0.5) log.write_line(f"第 {i+1} 次采集完成") if __name__ == "__main__": import asyncio WorkerDemo().run()

image.png

@work 装饰器把协程包装成 Worker,在后台线程池里执行,UI 始终保持响应。exclusive=True 防止用户重复点击时启动多个任务——这个细节在工控场景里很重要,避免指令重叠。


🎨 TCSS:给终端界面"化妆"

Textual 的样式系统借鉴了 CSS,但专门为终端设计。写在 CSS 类变量里,或者单独存成 .tcss 文件。

css
/* 设备状态卡片样式 */ .device-card { border: round $primary; padding: 1 2; margin: 0 1; width: 1fr; /* 弹性宽度,均分空间 */ background: $surface; } .device-card.warning { border: round $warning; background: $warning 10%; /* 10% 透明度的警告色背景 */ } .device-card.error { border: round $error; }

颜色用 $primary$warning$error 这样的语义变量,主题切换时自动适配。Textual 内置了亮色和暗色两套主题,按 Ctrl+D 可以在运行时切换——在工控室强光环境下,这个细节挺实用的。


🪤 踩坑预警:Windows 下的注意事项

在 Windows 开发 Textual 应用,有几个坑提前说:

推荐使用 Windows Terminal,老版 cmd.exe 对 ANSI 转义码支持有限,颜色和边框会乱。PowerShell 7+ 也没问题。

字体选 Nerd Font 或等宽字体。Textual 的边框用的是 Unicode 制表符,如果字体不支持,会显示成乱码方块。推荐 Cascadia Code 或 JetBrains Mono。

asyncio 事件循环策略。Windows 下 Python 3.10 之前,asyncio 默认用 SelectorEventLoop,某些异步 IO 操作可能有问题。加一行:

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

放在 app.run() 之前,基本上能解决 90% 的兼容问题。


💡 三句话总结

  • Textual 是 Python 终端 UI 的现代解法,有组件、有样式、有事件,不是玩具。
  • 异步架构是上位机场景的核心优势,数据采集和 UI 渲染天然解耦,不互相卡。
  • Windows 下完全可用,配好终端和字体,开发体验和 Linux 没有本质差异。

🔧 可复用代码模板

下面这个是我在项目里用得最多的骨架,可以直接拿去改:

python
from textual.app import App, ComposeResult from textual.widgets import Header, Footer, DataTable, Log, Button from textual.containers import Horizontal, Vertical from textual import work import asyncio class BaseMonitorApp(App): """上位机监控面板通用骨架""" CSS = """ Screen { layout: vertical; } Horizontal { height: auto; } DataTable { height: 1fr; border: round $accent; } Log { height: 8; border: round $success; } Button { margin: 0 1; } """ BINDINGS = [ ("q", "quit", "退出"), ("r", "refresh", "刷新"), ("d", "toggle_dark", "切换主题"), ] def compose(self) -> ComposeResult: yield Header(show_clock=True) with Horizontal(): yield Button("开始", id="start", variant="success") yield Button("停止", id="stop", variant="error") yield DataTable() yield Log() yield Footer() def on_mount(self) -> None: table = self.query_one(DataTable) table.add_columns("参数名", "当前值", "状态") def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "start": self.start_collection() elif event.button.id == "stop": self.notify("采集已停止", severity="warning") @work(exclusive=True) async def start_collection(self) -> None: log = self.query_one(Log) log.write_line("采集启动...") # TODO: 在这里接入你的设备通信逻辑 while True: await asyncio.sleep(1.0) # data = your_device.read() # self.update_table(data) def action_refresh(self) -> None: self.notify("手动刷新") if __name__ == "__main__": BaseMonitorApp().run()

image.png

start_collection 里的注释换成你的串口或 Modbus 读取逻辑,一个基础的监控面板就成型了。


📚 延伸学习路径

学完本文,下一步可以深入这几个方向:

  • 组件深入:DataTable 的动态更新、Tree 组件做设备树管理
  • 通信集成:pyserial + Textual Worker 实现串口实时采集
  • 多页面架构:Screen 切换与全局状态管理
  • 自定义组件:继承 Widget 封装设备卡片、状态指示灯

Textual 的官方文档质量相当高,示例丰富,英文阅读没障碍的话直接啃文档效率最高。


#Python #上位机开发 #终端UI #Textual #工控软件

本文作者:技术老小子

本文链接:

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