渲染管线
`FlowEditorView` 的 `Render` 实现是一条分层的绘制流水线。理解这条流水线的层次,是排查「为什么我的按钮被节点盖住了」「为什么 tooltip 闪一下就没了」这类问题的前提。
外层布局:画布 + 分隔条 + 面板
最外层是一个水平 flex 容器,把窗口切成三块:
┌──────────────────────────────────────────────────────────┐
│ FlowEditorView │
│ ┌──────────────────────────────────┐ ┌──┐ ┌──────────┐ │
│ │ canvas (flex-1) │ │ │ │ panel │ │
│ │ │ │分│ │ panel_ │ │
│ │ edges / nodes / plus buttons │ │隔│ │ width │ │
│ │ tooltip / toolbar / picker │ │条│ │ 320px │ │
│ │ │ │ │ │ │ │
│ └──────────────────────────────────┘ └──┘ └──────────┘ │
└──────────────────────────────────────────────────────────┘
- 画布 `canvas` 占据 `flex-1`,是主要绘制区域。 - 分隔条是 1px 宽的可拖拽条,`resizing_panel` 状态下拖动可改变 `panel_width`。 - 面板宽度默认 `Pixels(320.0)`,由 `panel_width` 决定。
当 `panel_view` 为 `None` 时,分隔条与面板都不渲染,画布占满整个视图。
画布内部分层
画布本身又是一个堆叠容器,按以下顺序自下而上绘制:
| 层 | 内容 | 是否受视口缩放 | |----|------|----------------| | 1 | 网格背景(`show_grid` 为真时) | 是(按 scale 缩放间距) | | 2 | edges 层:所有可见边 | 是(路径变换) | | 3 | content 层:所有可见节点 | 是(逐元素缩放) | | 4 | edge_plus_buttons 层:边「+」按钮 | 是(按钮跟随边中点) | | 5 | tooltip:悬停提示 | 否(屏幕坐标) | | 6 | toolbar:自定义工具栏 | 否(屏幕坐标) | | 7 | node_picker:节点选择浮层 | 否(屏幕坐标) |
顺序很关键:边在节点下方,这样节点可以遮住边的端点;「+」按钮在节点上方,避免被节点遮挡;tooltip / toolbar / picker 在最上层,因为它们是「屏幕空间 UI」,不应被画布缩放影响。
节点的逐元素缩放
GPUI 的 `div()` 没有原生的「整体 transform: scale」。框架采用的策略是:对节点的每一个几何元素(position、size、字体大小等)手动乘以 `scale`。
let scale = self.viewport.scale;
let screen_pos = node.position * scale + self.viewport.offset;
let screen_size = node.size * scale;
div()
.absolute()
.left(screen_pos.x)
.top(screen_pos.y)
.width(screen_size.x)
.height(screen_size.y)
// 字体、padding、border 也都要乘 scale
.text_size(base_font * scale)
// ...
这种「逐元素缩放」的代价是写起来啰嗦,好处是每一层都能精确控制——比如可以让边框线宽不随缩放变化(保持 1px),只让内容缩放。
边的路径变换
边不能简单地「逐元素缩放」,因为它的几何是一条折线 + 圆角 + 箭头,逐点乘 scale 会丢失圆角关系。框架的做法是:
1. 在逻辑坐标系下计算完整路径(含 step gap、smoothstep 圆角)。 2. 用 `PathBuilder::scale(scale, scale)` 对整条路径做缩放。 3. 用 `PathBuilder::translate(offset.x, offset.y)` 平移到屏幕位置。 4. 线宽 `stroke_width` 手动乘 scale,保证视觉一致。
逻辑坐标路径 屏幕坐标路径
│ │
│ PathBuilder::path(...) │
├──────────────────────────► ├─ scale(s, s)
│ ├─ translate(ox, oy)
│ │
│ ▼
│ stroke_width *= s
这套变换在 `crates/gpui/src/editor/rendering/edges.rs` 中统一完成,边几何算法(`edge_geometry.rs`)只需关心逻辑坐标。
tooltip / toolbar / picker 的屏幕坐标
最上面三层故意不参与视口变换。原因:
- tooltip 跟随鼠标,本就是屏幕空间概念。 - toolbar 是固定 UI,缩放它会变模糊。 - picker(节点选择浮层)的 `anchor` 是屏幕坐标,直接用即可。
这也解释了为什么 `InteractionState::AddingNodeFromEdge` 的 `anchor` 字段是屏幕坐标——它直接喂给 picker 定位。
渲染与交互的解耦
渲染管线只「读」状态,不「改」状态。所有的状态变更(拖拽位置、绘制中的边终点、浮层显隐)都在交互回调里完成,再通过 `cx.notify()` 触发重绘。这种单向数据流让渲染函数保持纯函数性质,便于推理。
交互回调 ──修改──► InteractionState / graph / viewport
│
▼
cx.notify()
│
▼
render() ← 只读状态
│
▼
新一帧画面
小结
`FlowEditorView` 的渲染是一条「外层 flex → 画布堆叠 → 各层按缩放策略绘制」的流水线。节点走逐元素缩放,边走路径变换,浮层走屏幕坐标。记住「渲染只读」这条铁律,调试渲染问题时就不会误把状态改动塞进 render。
下一节:[视口变换与缩放](viewport-transform.md)