Textual 实测:用 Python 在终端里搭出按钮和表格——DataTable、reactive 与自定义消息的无头测试记

举报
岚枫 发表于 2026/10/21 08:29:37 2026/10/21
【摘要】 实测 Textualize/textual(3.7 万星):终端里搭按钮表格,无头跑通 reactive 与自定义消息,附 TCSS 生效值与踩坑实录。

Textual 实测:用 Python 在终端里搭出按钮和表格——DataTable、reactive 与自定义消息的无头测试记录

命令行程序的界面一直很"素":print 输出、input 提问,顶多用 rich 给日志加点颜色。Textualize 开源的 Textual(GitHub star 约 3.7 万,MIT)想改的是这件事——把按钮、输入框、表格,甚至一套类似 CSS 的样式系统搬进终端。它和 rich 同源(作者 Will McGugan),底层渲染正是 rich,但上层是一套完整的组件化框架:widget 树、消息循环、响应式属性,写法向 web 开发靠拢。

我在本机(Windows,Python 3.13.14)装上 textual 8.2.8,写了一个带 DataTable + Input + Button 的待办清单小应用,然后用它官方的无头测试 API(App.run_test() + pilot)完整驱动了一遍。本机没有交互终端,所以这篇文章里所有"界面状态"都不是脑补的:全部来自 pilot 真实点击、按键之后读回的 widget 状态,第三节末尾还有一份无头导出的 SVG 佐证界面真的被画出来了。

一、它解决什么问题

标准库的 curses 能写 TUI,但 API 停留在上个世纪:手工画字符、平台差异大(Windows 上尤其难用)、没有组件和布局概念。Textual 的思路可以概括成"终端即浏览器":

  • 界面由 widget 搭成(DataTable、Input、Button、Tree、TextArea……),像搭积木一样组合;
  • 布局和样式用一套叫 TCSS 的类 CSS 语言描述,不手算坐标;
  • 交互走消息循环:按键、点击都变成消息,handler 按名字或装饰器接收;
  • 异步内核,I/O 不卡界面。

对普通开发者最实际的价值:给内部脚本、数据浏览面板、服务器上的小工具一个像样的界面,而且不引入浏览器或 GUI 依赖——SSH 上去、容器里、没有显示器的机器上都能跑。

二、三个核心概念

widget 树。compose() 是个生成器,yield 出的 widget 按顺序组成一棵树挂到 Screen 上。我实测打印了这棵树的真实结构:

screen 直接子 widget(按 compose 顺序):
  Header#- classes=frozenset()
  Horizontal#input-row classes=frozenset()
  DataTable#table classes=frozenset()
  Label#counter classes=frozenset()
  Footer#- classes=frozenset()
Horizontal 内的子 widget:
  Input#task-input
  Button#add-btn
counter styles.dock: bottom
counter styles.height: 1

找 widget 用 CSS 选择器:app.query_one("#counter", Label)——和浏览器里按 id 找元素一个思路。

消息循环。用户操作不直接调你的方法,而是发消息:Input.Submitted、Button.Pressed 是内置消息,你也可以自定义。消息沿 widget 树冒泡,handler 用 @on(消息类型) 装饰器声明。本文的自定义消息 TodoAdded 就是这条链路。

TCSS。Textual 的样式语言:id 选择器(#add-btn)、类型选择器(DataTable)、主题变量($primary、$success、$text)、flex 式尺寸(1fr)、dock 布局。样式写在 App 类的 CSS 字符串里(也可以放 .tcss 文件),第三节我把生效值从 widget.styles 里读回来了——不是"应该生效",是读出来生效。

三、我的实测:一个待办清单小应用

功能:输入框填内容,回车或点"添加"按钮,DataTable 多一行,底部 Label 显示总数,边框标题同步更新。完整代码(todo_app.py,本次实跑自写):

from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.message import Message
from textual.reactive import reactive
from textual.widgets import Button, DataTable, Footer, Header, Input, Label
from textual import on


class TodoAdded(Message):
    """自定义消息:一条待办被提交(回车或点按钮都走它)。"""

    def __init__(self, name: str) -> None:
        self.name = name
        super().__init__()


class TodoApp(App):
    CSS = """
    #input-row {
        height: auto;
        margin: 1 1 0 1;
    }
    #task-input {
        width: 1fr;
    }
    #add-btn {
        width: 14;
        margin-left: 1;
        background: $success;
    }
    #table {
        height: 1fr;
        margin: 1;
        border: round $primary;
    }
    #counter {
        dock: bottom;
        height: 1;
        background: $primary;
        color: $text;
        text-align: center;
    }
    """

    TITLE = "Textual 待办实测"

    # init=False:初始文本由 compose 直接给出,watch 只负责后续变化
    todo_count = reactive(0, init=False)

    def compose(self) -> ComposeResult:
        yield Header()
        with Horizontal(id="input-row"):
            yield Input(placeholder="输入待办内容,回车添加", id="task-input")
            yield Button("添加", variant="primary", id="add-btn")
        yield DataTable(id="table")
        yield Label("共 0 条待办", id="counter")
        yield Footer()

    def on_mount(self) -> None:
        table = self.query_one("#table", DataTable)
        table.add_columns("#", "待办内容", "状态")
        table.border_title = "待办清单"

    def _submit(self, raw: str) -> None:
        name = raw.strip()
        if not name:
            return
        # 关键演示点:不直接改表格,而是发一条自定义消息,走消息循环
        self.post_message(TodoAdded(name))
        self.query_one("#task-input", Input).value = ""

    @on(Input.Submitted)
    def handle_submit(self, event: Input.Submitted) -> None:
        self._submit(event.value)

    @on(Button.Pressed, "#add-btn")
    def handle_button(self, event: Button.Pressed) -> None:
        self._submit(self.query_one("#task-input", Input).value)

    @on(TodoAdded)
    def handle_todo_added(self, event: TodoAdded) -> None:
        self.todo_count += 1  # reactive 赋值 -> watch_todo_count 自动刷新 Label
        table = self.query_one("#table", DataTable)
        table.add_row(str(self.todo_count), event.name, "待办")
        table.border_title = f"待办清单 · {self.todo_count} 条"

    def watch_todo_count(self, count: int) -> None:
        self.query_one("#counter", Label).update(f"共 {count} 条待办")


if __name__ == "__main__":
    TodoApp().run()

无头测试脚本(test_todo.py):App.run_test() 返回一个 pilot,pilot.click(选择器) 点控件、pilot.press(*"buy milk") 逐键输入、pilot.pause() 等消息循环转一拍,然后直接读 widget 状态做断言——这套 API 让 TUI 也能像普通 Python 代码一样被测试(也可以直接写进 pytest 用例)。下面这段是独立脚本,不依赖 pytest,python test_todo.py 即可运行:

import asyncio
from pathlib import Path

from todo_app import TodoApp
from textual.widgets import Button, DataTable, Input, Label


async def main() -> None:
    app = TodoApp()
    async with app.run_test() as pilot:
        table = app.query_one("#table", DataTable)
        counter = app.query_one("#counter", Label)
        task_input = app.query_one("#task-input", Input)

        print("== 1. 初始状态(compose 完的 widget 树)==")
        print("DataTable 列数:", len(table.columns))
        print("DataTable 行数:", table.row_count)
        print("counter Label 文本:", repr(counter.content))
        print("Input placeholder:", repr(task_input.placeholder))
        print("table border_title:", repr(table.border_title))

        print("== 2. 回车添加两条(Input.Submitted -> TodoAdded)==")
        await pilot.click("#task-input")
        await pilot.press(*"buy milk")
        await pilot.press("enter")
        await pilot.pause()
        await pilot.click("#task-input")
        await pilot.press(*"write blog")
        await pilot.press("enter")
        await pilot.pause()
        print("行数:", table.row_count)
        print("第 1 行:", table.get_row_at(0))
        print("第 2 行:", table.get_row_at(1))
        print("counter:", repr(counter.content))
        print("border_title:", repr(table.border_title))
        print("input 已清空:", repr(task_input.value))

        print("== 3. 点击按钮添加(Button.Pressed -> TodoAdded)==")
        await pilot.click("#task-input")
        await pilot.press(*"walk dog")
        await pilot.click("#add-btn")
        await pilot.pause()
        print("行数:", table.row_count)
        print("第 3 行:", table.get_row_at(2))
        print("counter:", repr(counter.content))

        print("== 4. reactive 直接赋值 42(watch 自动刷新)==")
        app.todo_count = 42
        await pilot.pause()
        print("counter:", repr(counter.content))
        print("border_title:", repr(table.border_title))
        app.todo_count = 3
        await pilot.pause()

        print("== 5. 空输入不新增待办 ==")
        await pilot.click("#task-input")
        await pilot.press("enter")
        await pilot.click("#add-btn")
        await pilot.pause()
        print("行数(应仍为 3):", table.row_count)

        print("== 6. TCSS 样式断言(从 widget.styles 读回真实生效值)==")
        btn = app.query_one("#add-btn", Button)
        print("button styles.width:", btn.styles.width)
        print("button styles.background:", btn.styles.background)
        print("button styles.margin:", btn.styles.margin)
        print("input styles.width:", task_input.styles.width)
        print("counter styles.background:", counter.styles.background)
        print("counter styles.text_align:", counter.styles.text_align)
        print("table styles.border:", table.styles.border)
        print("table 内容区宽度(px):", table.content_size.width)

        print("== 7. 无头导出 SVG(证明界面真的被画出来了)==")
        svg = app.export_screenshot()
        Path("todo_final.svg").write_text(svg, encoding="utf-8")
        print("SVG 字节数:", len(svg))
        print("SVG 前 100 字符:", " ".join(svg[:100].split()))


asyncio.run(main())

真实输出(照贴,未删减):

== 1. 初始状态(compose 完的 widget 树)==
DataTable 列数: 3
DataTable 行数: 0
counter Label 文本: '共 0 条待办'
Input placeholder: '输入待办内容,回车添加'
table border_title: '待办清单'
== 2. 回车添加两条(Input.Submitted -> TodoAdded)==
行数: 2
第 1 行: ['1', 'buy milk', '待办']
第 2 行: ['2', 'write blog', '待办']
counter: '共 2 条待办'
border_title: '待办清单 · 2 条'
input 已清空: ''
== 3. 点击按钮添加(Button.Pressed -> TodoAdded)==
行数: 3
第 3 行: ['3', 'walk dog', '待办']
counter: '共 3 条待办'
== 4. reactive 直接赋值 42(watch 自动刷新)==
counter: '共 42 条待办'
border_title: '待办清单 · 3 条'
== 5. 空输入不新增待办 ==
行数(应仍为 3): 3
== 6. TCSS 样式断言(从 widget.styles 读回真实生效值)==
button styles.width: 14
button styles.background: Color(78, 191, 113)
button styles.margin: Spacing(top=0, right=0, bottom=0, left=1)
input styles.width: 1fr
counter styles.background: Color(1, 120, 212)
counter styles.text_align: center
table styles.border: Edges(top=('round', Color(1, 120, 212)), right=('round', Color(1, 120, 212)), bottom=('round', Color(1, 120, 212)), left=('round', Color(1, 120, 212)))
table 内容区宽度(字符单元): 76
== 7. 无头导出 SVG(证明界面真的被画出来了)==
SVG 字符数: 30631
SVG 前 100 字符: <svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">

几个值得说的点:回车和点按钮走的是同一条自定义消息链路(输出 2、3 行数一致递增);输出 4 直接把 todo_count 赋成 42,Label 自己变成"共 42 条待办"而表格标题没动——reactive 只触发它自己的 watch,边界很清晰;输出 5 空输入连敲回车和按钮,行数纹丝不动——注意 Input.Submitted / Button.Pressed 仍然会触发,只是 _submit 里 strip 后直接返回,没有发出 TodoAdded;输出 7 无头模式导出 30631 个字符的 SVG——没有交互终端,界面也实实在在地被渲染了。

四、进阶:reactive 与自定义消息

reactive 是 Textual 的响应式状态。声明 todo_count = reactive(0, init=False),再写一个 watch_todo_count(self, count) 方法,之后任何地方给 todo_count 赋值,watch 自动被调用。好处是"状态一变,UI 跟着变"不用手写刷新代码,而且同一帧内多次赋值会合并成一次重绘。我用 init=False 是因为初始文本 compose 时已经给了(Label("共 0 条待办", id="counter")),watch 只管后续变化——若用 init=True,watch 会在挂载完成后同样被调用一次(实测 8.2.8 此时 query 子 widget 正常),效果一致,只是初始那次对已经写好初值的 Label 来说是多余的。

自定义消息是把"发生了什么"和"怎么处理"解耦。_submit() 不直接操作表格,而是 post_message(TodoAdded(name));处理逻辑集中在 @on(TodoAdded) 一个方法里。好处:回车和按钮两个入口共用一条处理链(本文就是);将来把输入区搬进独立 widget,消息机制原样成立;测试时甚至可以不点 UI,直接 app.post_message(TodoAdded("x")) 验证处理逻辑。消息沿 widget 树冒泡,父级能收到子级发的消息——这是 Textual 版的"事件总线"。

五、三个坑(全部实测撞上)

  1. Label.renderable 从 6.0 起改成了 .content(8.2.8 里没有这个属性)。不少旧教程仍用 label.renderable 读文本,我第一版测试脚本直接 AttributeError: 'Label' object has no attribute 'renderable'。现在用 .content(原始内容)或 .visual(可视化对象)。
  2. styles.margin_left 不是独立属性。margin 没有拆成四个属性,而是 Spacing(top/right/bottom/left) 对象,只能从 styles.margin 整体读(btn.styles.margin 打出 Spacing(top=0, right=0, bottom=0, left=1))。想单独取左边用 .margin.left。
  3. rich 没有 __version__。import rich; rich.__version__ 报 AttributeError;textual 有 textual.__version__,rich 得用 importlib.metadata.version("rich")。

再加一条使用经验:pilot 的 click/press 本身会等待消息处理,一般不需要额外 await pilot.pause();但如果断言依赖异步 worker 或你自己 post_message 的后续处理,再补一次 await pilot.pause() 更稳妥。

六、适合谁与边界

适合:想给 Python 脚本加个像样界面的开发者;做内部数据面板、日志浏览器、部署小工具的;想把 TUI 交互纳入 pytest 的(无头测试是 Textual 相对 curses/urwid 的相对少见的能力);已经在用 rich、想再进一步的。

边界:只美化输出用 rich 就够,别上 Textual(杀鸡用牛刀);本文只跑了无头测试,真实交互终端里的鼠标、快捷键、多屏导航没验证;pilot.press 是逐键模拟,中文输入本次未测(待办名用的英文);workers 异步、textual-dev 热重载、textual-web 浏览器运行未涉及;版本迭代快(比如 renderable 在 6.0 就改成了 content),照抄旧教程代码前先看版本。

七、小结

Textual 把 web 开发的三件套——组件树、消息循环、样式表——平移进了终端,写法明显向 web 开发靠拢;再送一个可无头测试的 API,这在 TUI 框架里是稀缺的。我的待办小应用从零到无头跑通没花多少行代码,reactive 和自定义消息的边界都在真实输出里验过了。MIT 协议,内部工具场景基本可以无脑用。唯一要留心的:它迭代快,API 会变,教程要看版本号。

参考与署名:本文实测项目为 Textualize/textual(MIT License,实测版本 textual 8.2.8 + rich 15.0.0);文中 demo 与测试代码均为本次实跑自写;运行环境 Python 3.13.14 / Windows。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。