ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Textual 1.0 高性能终端渲染内核解析:Compositor 合成器与 Spatial Map 空间映射

Textual 1.0 高性能终端渲染内核解析:Compositor 合成器与 Spatial Map 空间映射 Textual 1.0 高性能终端渲染内核解析Compositor 合成器与 Spatial Map 空间映射【免费下载链接】textualThe lean application framework for Python. Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal and a web browser.项目地址: https://gitcode.com/gh_mirrors/te/textualTextual 在 1.0 里程碑2024-12-12见 CHANGELOG.md之际由作者 Will McGugan 撰写了一篇名为 Algorithms for high performance terminal apps 的技术博客首次系统地公开了支撑 Textual 高性能渲染的两大核心算法Compositor合成器与Spatial Map空间映射。本文以该博客为骨架结合当前仓库中 src/textual/_compositor.py 与 src/textual/_spatial_map.py 的真实实现代码逐层拆解终端如何像桌面 GUI 一样渲染重叠窗口这一核心难题读者读完后将理解 Textual 的渲染管线、线段裁剪算法、脏区域局部刷新机制以及如何让上千个 Widget 的可见性判断在恒定时间内完成。背景为什么终端应用需要渲染算法Textual 是作者全职投入三年以上的开源项目。与桌面 GUI 不同终端的规范specification对如何构建现代用户界面只字未提——它只提供了最基础的能力移动光标、写彩色文本、读取按键与鼠标事件。从最基本的 Button 到带语法高亮的 TextArea所有 UI 基础设施都必须从零构建。终端最大的心智陷阱是它看似是一个字符组成的规则网格但并不是。部分字符如亚洲语言文字和大量 emoji宽度是拉丁字母的两倍这给把屏幕当作二维数组、用画家算法painters algorithm从后往前逐层绘制的朴素方案带来了根本性困难。Textual 为此设计了两层算法来解决问题Compositor把多个 Widget 渲染出的内容合并成单一视图处理重叠、裁剪与局部刷新Spatial Map快速丢弃视口外的 Widget避免在不可见内容上浪费渲染时间。在深入源码之前可以先通过官方 demo 直观感受这套渲染引擎的能力。安装uv后单行命令即可运行1.0 时代推荐 Python 3.12uvx --python 3.12 textual-demoCompositor把多个 Widget 合成一个视图Compositor 的职责定义在其模块 docstring 中src/textual/_compositor.py合成器负责把多个 Widget 合并成一个屏幕即 compositing。它同时存储合成结果使 Textual 知道屏幕上有哪些 Widget 及其位置合成器利用这些信息回答某个偏移量下是哪个 Widget、某个偏移量下的样式是什么之类的查询。此外合成器还能只渲染屏幕中已更新的部分而不必渲染整个屏幕。我们需要它是因为终端本身没有桌面系统那种重叠窗口的概念——在终端里写入内容后字符就停留在那里没有 Z 序、没有层叠。所有窗口语义都必须由框架自己模拟。算法前提从字符思维切换到Segment 思维Textual 的处理方式继承自其姊妹项目 Rich任何要打印的内容都会先被生成一个Segment列表。一个 Segment 由一段字符串 关联样式组成。直到渲染流程的最后一步这些 Segment 才被转换成带 ANSI 转义序列的文本。Compositor 拿到 Widget 渲染器产出的 Segment 列表后通过**切分divide与合并combine**操作最终产出一行行没有重叠的最终输出。事实上Textual 几乎所有的操作都在以各种方式处理 Segment。作者把这条经验总结为一个方法论切换基元Switching the Primitive——当一个问题难以解决时往往可以通过改变你眼中的原子数据和原子操作来简化它。在 Rich 中就是从以字符为单位思考切换到以 Segment 为单位思考。在下图中一个应用有三个 Widget作为背景的屏幕蓝色以及两个浮动的 Widget红色和绿色。真实应用中的 Widget 会多得多但这三个足以说明工作原理。图中每一行都是 Widget 渲染器产出的 Segment 列表Compositor 要把它们合并成一个互不重叠的单一列表合成一行从横截面到最终输出把终端和 Widget 侧着看取一个横截面可以看到类似下面的剖面——背景行被两个浮动 Widget 的层覆盖。此时我们还不能直接输出因为逐层独立写入会产生闪烁且会写入远超必要的数据量把这一行真正合并成单行需要四个步骤正好对应源码中的实现路径。Step 1找到切割点cutsCompositor 做的第一件事是找出每一条 Segment 列表开始或结束的所有偏移量这些偏移量被称为cuts切割点。在源码中对应Compositor.cuts属性src/textual/_compositor.py它对屏幕每一行维护一个[0, width]的初始列表然后遍历所有可见 Widget 的区域region与裁剪区clip的交集把每个 Widget 的左右边界(x, x width)追加进对应行最后每行做sorted(set(...))去重排序。property def cuts(self) - list[list[int]]: A cut is every point on a line where a widget starts or ends. width, height self.size cuts [[0, width] for _ in range(height)] for region, clip in self.visible_widgets.values(): x, y, region_width, region_height Region.intersection(region, clip) if region_width and region_height: region_cuts (x, x region_width) for cut in cuts[y : y region_height]: cut.extend(region_cuts) # Sort the cuts for each line self._cuts [sorted(set(line_cuts)) for line_cuts in cuts] return self._cuts切割点结果示意如下——每条竖线代表某个 Widget 在该行开始或结束的位置Step 2应用切割点产生 chops第二步在每个切割偏移处把每一条 Segment 列表切分。切出的较小 Segment 列表被称为chops。这一步之后所有 chops 大小一致、互不重叠——正是互不重叠这个性质让下一步成为可能。源码中对应_render_chops方法src/textual/_compositor.pydef _render_chops(self, crop, is_rendered_line): cuts self.cuts chops [dict.fromkeys(cut_set[:-1]) for cut_set in cuts] # 逆序遍历所有渲染填充未被渲染占据的桶 renders self._get_renders(crop) for region, clip, strips in renders: render_region Region.intersection(region, clip) render_x render_region.x first_cut, last_cut render_region.column_span for y, strip in zip(render_region.line_range, strips): if not is_rendered_line(y): continue chops_line chops[y] final_cuts [cut for cut in cuts[y] if last_cut cut first_cut] cut_strips strip.divide([cut - render_x for cut in final_cuts[1:]]) # 从前到后绘制第一个写入的 Segment 胜出 for cut, strip in zip(final_cuts, cut_strips): if chops_line.get(cut) is None: chops_line[cut] strip return chops关键细节在于strip.divide([...])——这正是 Rich 的Segment.divide按列偏移把一条 Segment 行切成多段的能力它让把同一行按所有切割点切成等宽小段成为一个 O(行内段数) 的操作。Step 3丢弃被遮挡的 chops只有最顶层的 chops 对用户可见。任何不在顶层的部分都会被遮挡可以直接丢弃。在源码中这一步体现为_render_chops的front-to-back策略_get_renders返回的渲染按 Z 序排列代码注释明确写道Since we are painting front to back, the first segments for a cut wins——即chops_line.get(cut) is None才写入先到先得后到的被遮挡内容自然被跳过。Step 4合并Combine现在剩下的工作就是把所有最顶层的 chops 合并成一个 Segment 列表——这个列表最终成为终端中的一行。合并发生在 render_full_update 等方法的末尾Strip.join(chop.values())把同一行的各段拼接随后LayoutUpdate.render_segmentssrc/textual/_compositor.py把它们转成移动光标 文本 ANSI 转义序列的原始字节流写入输出。被简化掉的部分裁剪、滚动、屏幕栈与局部刷新上述四步是对核心算法的理想化描述真实实现远比这复杂源码中有多处佐证父级裁剪clippingWidget 可能包含子 Widget子 Widget 会被裁剪到父级边界内包含子 Widget 的 Widget 还可以滚动。_get_renderssrc/textual/_compositor.py在生成渲染行时会对每个 Widget 计算region与clip的交集并只在交集非空时调用widget.render_lines(...)而render_linessrc/textual/widget.py接收的正是裁剪后的 crop 区域。这套region clip机制是父级裁剪与滚动的实现基础。多屏幕栈screen stack屏幕之上还可以堆叠多个屏幕低层屏幕还会被施加模态淡出modal fade效果——Its widgets all the way down。render_update通过visible_screen_stack上下文跟踪当前可见的屏幕栈src/textual/_compositor.py。局部更新partial updates这是性能关键。Compositor 维护一个脏区域集合_dirty_regions。当某个 Widget 变化比如点击按钮改变颜色时update_widgetssrc/textual/_compositor.py会把受影响的区域翻译进脏区域下次刷新时render_update判断若full为真或整个屏幕区域都在脏区域内则全量刷新否则走render_partial_updatesrc/textual/_compositor.py——它把所有脏区域做并集Region.from_union(update_regions)得到一个包围全部更新的 crop 区域只对该区域重新合成 chops最终产出一个ChopsUpdate只把变化部分写回终端。正是这一机制让屏幕上有海量Widget 时依然能平滑滚动。Spatial Map常数时间内的可见性判断Textual 应用通常包含大量大小不一、位置各异的 Widget且并非所有 Widget 都在最终视图中可见比如位于可滚动容器内。Compositor 需要一种数据结构来极快地丢弃指定区域内不可见的 Widget——这就是spatial map作者为 Textual 自创的术语。问题定义千级 Widget × 每秒 30 帧考虑下面这个 Widget 排列一共 8 个 Widget根据滚动条位置的不同任意时刻只有 3 或 4 个可见。我们希望避免对下一帧中不可见的 Widget 做任何渲染工作。朴素做法是遍历每个 Widget 的 Region逐一检查其是否与可见区域重叠。这在 Widget 数量少时完全合理但无法扩展一旦进入上千个 Widget 的量级逐一遍历就可能成为瓶颈——而且滚动时每秒要做 30 次这样的判断。网格化每个 Widget 关联到若干网格瓦片Spatial Map 的第一步是把每个 Widget 与一个规则网格中的若干瓦片tile关联。网格大小是相当任意的只要保证能用较少的瓦片数覆盖可视区域即可——Textual 采用100 字符 × 20 行的网格尺寸。这与源码中SpatialMap.__init__的默认参数完全一致src/textual/_spatial_map.pyclass SpatialMap(Generic[ValueType]): A spatial map allows for data to be associated with rectangular regions in Euclidean space, and efficiently queried. def __init__(self, grid_width: int 100, grid_height: int 20) - None: self._grid_size (grid_width, grid_height) self._map: defaultdict[GridCoordinate, list[ValueType]] defaultdict(list) self._fixed: list[ValueType] []在 Spatial Map 首次创建插入时每个 Widget 会被放进一个或多个网格瓦片。瓦片坐标的计算见_region_to_grid_coordinatessrc/textual/_spatial_map.py取区域左上与右下角点分别做整数除法x // grid_width、y // grid_height再用itertools.product枚举覆盖到的全部瓦片坐标。插入逻辑在insert方法中src/textual/_spatial_map.py它还区分两种特殊值fixed不随滚动移动、永远可见的固定区域直接存入独立的_fixed列表overlay覆盖层如弹出层不计入total_region的并集。插入完成后我们得到一个把每个网格坐标映射到 Widget 列表的字典形如{ (0, 0): [widget1, widget2, widget3], (1, 0): [widget1, widget2, widget3], (0, 1): [widget4, widget5, widget6], (1, 1): [widget4, widget5, widget6], (0, 2): [widget7, widget8], (1, 2): [widget7, widget8], }这段一次性计算的成本相当低而且高度可缓存——用户只是滚动时完全不需要重算源码注释也印证了这一点滚动的可见性查询走的是同一份静态网格数据。查询合并瓦片、去重、逐个精查Spatial Map 的加速收益体现在哪些 Widget 可见的查询上。做法是先构造一个覆盖目标区域的 Region可以是整个屏幕也可以是某个较小的可滚动容器再确定哪些网格瓦片与该区域重叠。比如把屏幕向上滚动一点、让 Widget 3 位于屏幕顶部后与可视区域重叠的瓦片坐标是(0,0)、(1,0)、(0,1)、(1,1)——在数据结构中查这四个坐标得到 4 个列表[ [widget1, widget2, widget3], [widget1, widget2, widget3], [widget4, widget5, widget6], [widget4, widget5, widget6], ]合并并去重后[widget1, widget2, widget3, widget4, widget5, widget6]这些 Widget 要么就在可视区域内要么紧邻其旁。因此可以确信不在这个列表中的 Widget 一定不在视图中。如果还需要精确判断再对候选列表中的每个 Widget 单独做 Region 相交检查即可。源码实现就是get_values_in_regionsrc/textual/_spatial_map.pydef get_values_in_region(self, region: Region) - list[ValueType]: Get a superset of all the values that intersect with a given region. Note that this may return false positives. results self._fixed.copy() for grid_coordinate in self._region_to_grid_coordinates(region): grid_values self._map.get(grid_coordinate) if grid_values is not None: results.extend(grid_values) unique_values list(dict.fromkeys(results)) return unique_values注意 docstring 中的关键声明可能会返回误报false positives——它返回的是可见 Widget 的一个超集精确性由调用方用 Region 相交进一步收窄。这是一个典型的用空间换时间、用保守估计换性能的取舍。这个算法的美妙性质在于随着 Widget 数量增长判断哪些可见所需的时间保持相对恒定。滚动一个含 8 个 Widget 的视图与滚动一个含 1000 甚至更多 Widget 的视图耗时基本一致——因为查询只访问可见区域覆盖的那几个网格瓦片与总 Widget 数无关。实现位置与 API 边界SpatialMap是泛型类SpatialMap(Generic[ValueType])可关联任意类型的值Textual 中自然就是 Widget。它不是公开 API因此不在官方 API 文档中但它被 Compositor 内部使用用于快速剔除视口外 Widget、并支撑某个屏幕坐标下是哪个 Widget/什么样式的查询Compositor模块 docstring 中提到的两类查询正是基于此。总结两条算法原则的复用价值回顾这篇发布博文可以提炼出两条具有普适性的工程原则更换基元往往比修补算法更有效——Rich/Textual 从字符切换到Segment使裁剪、合并、局部更新都成为对统一数据结构的廉价操作用网格分桶把全局扫描降维成局部查表——Spatial Map 把N 个 Widget 谁可见这一 O(N) 问题转化为可见区域覆盖哪几个网格瓦片的近似常数时间查询这正是经典游戏开发空间分区思路在终端 UI 领域的移植。如果你对这两份代码感兴趣作者在博文末尾明确表示可以放心借鉴steal-this-code.mdCompositor 的完整实现src/textual/_compositor.pySpatial Map 的完整实现src/textual/_spatial_map.py裁剪与滚动的入口Widget.render_linessrc/textual/widget.pyRegion 几何类型定义src/textual/geometry.py需要提醒的是本仓库对应的是 1.0 发布时刻及其后的代码网格尺寸等参数100×20取自SpatialMap.__init__的默认值若你 fork 或在其他版本中使用建议以仓库内 src/textual/_spatial_map.py 的实际实现为准。理解这两套算法就理解了 Textual 渲染管线最核心的一半——终端里的桌面级界面体验正是建立在这些底层数据结构与算法之上的。【免费下载链接】textualThe lean application framework for Python. Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal and a web browser.项目地址: https://gitcode.com/gh_mirrors/te/textual创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表