做上位机的朋友,大概都经历过这样一个阶段:设备跑着,数据哗哗地来,但界面——要么是一堆 print() 滚屏,要么花大力气搭个 PyQt 窗口,结果光环境配置就搞了半天。
有没有一种方案,既不用写 HTML,也不用装 Qt,直接在终端里就能跑出一个有按钮、有表格、有实时刷新的现代界面?
有。它叫 Textual。
很多人第一反应是:终端界面?那不是上个世纪的东西吗?
这个偏见,我理解,但确实是偏见。在上位机开发场景里,终端 UI 其实有几个 GUI 替代不了的优势——
部署零依赖。SSH 进服务器或者工控机,不需要显示器,不需要 X11,不需要 Qt 运行时,python main.py 直接跑。调试极方便。生产环境的嵌入式 Linux 主机,你总不能装个完整桌面环境吧。资源占用低。一个 Textual 应用跑起来,内存消耗比 Electron 少一个数量级不止。
所以这玩意儿不是复古,是务实。
Textual 是由 Will McGugan(Rich 库的作者)开发的一个 Python TUI(Terminal User Interface)框架。它构建在 Rich 之上,但定位完全不同——Rich 负责"让输出好看",而 Textual 负责"让终端变成一个真正的应用"。
说具体点,Textual 给你提供了:
安装只需要一行:
bashpip install textual
Windows 下完全支持,Windows Terminal 效果最佳。
先写个最简单的骨架,感受一下结构:
pythonfrom 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()

运行 python hello_textual.py,终端里会出现一个带边框的完整界面,按 Ctrl+C 退出。
就这么简单。没有回调地狱,没有信号槽,compose 方法里 yield 什么组件,界面上就出现什么。
Textual 的架构分三层,搞清楚这三层,后面写复杂应用就不会乱。
App 是整个应用的根。它管理生命周期,持有全局状态,控制 Screen 的切换。一个应用只有一个 App 实例。
Screen 是"页面"。登录页是一个 Screen,主控台是一个 Screen,配置页是另一个 Screen。App 在 Screen 之间切换,类似手机 App 的页面栈。
Widget 是最小的 UI 单元。Button、Input、DataTable 都是 Widget。你也可以继承 Widget 写自己的自定义组件——这在上位机里特别有用,比如封装一个"设备状态卡片"。
三者关系:App → Screen → Widget,层层嵌套,职责清晰。
光说概念没意思。咱们直接写一个贴近实际的例子——一个能实时刷新传感器数据的监控面板。
pythonimport 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()

这个例子里,set_interval(1.0, self.refresh_data) 是关键——它每秒触发一次数据刷新,而且完全不阻塞 UI,因为 Textual 的事件循环本身是异步的。
实际项目里,把 read_sensor_data() 换成 pyserial 读串口,或者 pymodbus 读 Modbus 寄存器,逻辑完全一样。
上面的例子用 set_interval 做轮询,适合简单场景。但如果采集操作本身耗时(比如 Modbus TCP 有网络延迟),就需要用 Worker 把它扔到后台。
pythonfrom 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()

@work 装饰器把协程包装成 Worker,在后台线程池里执行,UI 始终保持响应。exclusive=True 防止用户重复点击时启动多个任务——这个细节在工控场景里很重要,避免指令重叠。
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 开发 Textual 应用,有几个坑提前说:
推荐使用 Windows Terminal,老版 cmd.exe 对 ANSI 转义码支持有限,颜色和边框会乱。PowerShell 7+ 也没问题。
字体选 Nerd Font 或等宽字体。Textual 的边框用的是 Unicode 制表符,如果字体不支持,会显示成乱码方块。推荐 Cascadia Code 或 JetBrains Mono。
asyncio 事件循环策略。Windows 下 Python 3.10 之前,asyncio 默认用 SelectorEventLoop,某些异步 IO 操作可能有问题。加一行:
pythonimport asyncio
import sys
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
放在 app.run() 之前,基本上能解决 90% 的兼容问题。
下面这个是我在项目里用得最多的骨架,可以直接拿去改:
pythonfrom 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()

把 start_collection 里的注释换成你的串口或 Modbus 读取逻辑,一个基础的监控面板就成型了。
学完本文,下一步可以深入这几个方向:
Textual 的官方文档质量相当高,示例丰富,英文阅读没障碍的话直接啃文档效率最高。
#Python #上位机开发 #终端UI #Textual #工控软件
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!