GPUI 惯用法与所有权
为什么用命中测试而非 per-element 闭包
GPUI 的 `listener` 闭包(如 `cx.listener(|this, event, window, cx| {...})`)**无法捕获外部变量**如 `node_id`。这意味着不能这样写:
// ❌ 不可行:闭包无法捕获 node_id
for node in nodes {
div().on_click(cx.listener(move |this, _, _, cx| {
this.select_node(node_id, cx); // 编译错误:闭包捕获了 node_id
}))
}
rust-agent-flow 的解法是**命中测试(hit-test)**方案:画布统一处理鼠标事件,用几何计算确定点击目标:
fn on_mouse_down(&mut self, event: &MouseDownEvent, _, cx: &mut Context<Self>) {
let logical = self.to_logical(event.position);
match self.hit_test(logical) {
HitResult::Node(node_id) => { /* 选中 */ }
HitResult::OutPort(node_id, port) => { /* 开始连线 */ }
HitResult::Empty => { /* 取消选中 */ }
// ...
}
}
`hit_test` 逐层判断:删除按钮 → 切换按钮 → 边「+」按钮 → 端口 → 节点矩形 → 空白。
Entity 与 Context 模型
GPUI 采用 **Entity + Context** 所有权模型:
// 创建实体(返回 Entity<T>,即 Arc 语义的共享句柄)
let view = cx.new(|cx| FlowEditorView::new(graph, cx));
// 更新实体(闭包内获得 &mut T)
entity.update(cx, |this, cx| {
this.do_something(cx);
});
框架内的关键实践:
- `FlowEditorView` 实现 `Render` trait,是主实体 - `PanelView` 是独立的 `Entity<PanelView>`,作为子实体 - `FlowEditorView.panel_view: Option<PanelEntity>` 持有面板实体句柄
Entity 在扩展点中的传递
`ToolbarProvider::render_items` 通过 `ToolbarCtx.entity` 传递编辑器句柄,让扩展能在回调中更新编辑器:
impl ToolbarProvider for MyToolbar {
fn render_items(&self, ctx: &ToolbarCtx) -> Vec<AnyElement> {
let entity = ctx.entity.clone();
vec![
Button::new("my-btn")
.on_click(move |_, _, cx| {
entity.update(cx, |this, cx| {
this.toggle_theme(cx); // 通过句柄调用编辑器方法
});
})
.into_any_element()
]
}
}
这是「能力与 UI 分离」的实现基础——框架提供方法,扩展通过句柄调用。
借用冲突与解决
GPUI 的 `Render::render` 签名是 `&mut self`,容易与内部借用冲突。框架的典型处理模式:
模式一:先取可变借用,释放后再取不可变借用
fn render(&mut self, window, cx) -> impl IntoElement {
// 先调用 &mut self 方法(如 ensure_panel_view)
let panel = self.ensure_panel_view(entity, window, cx);
// 借用释放后,再引用不可变缓存
let body_groups = &self.cached_body_groups;
let edges = self.render_edges(body_groups);
// ...
}
模式二:克隆数据避免持有借用
// 克隆 fields(Vec<FieldSpec>)而非持有 flow_node 借用
let fields: Vec<FieldSpec> = fn_.schema().fields.clone();
for (i, field) in fields.iter().enumerate() {
col = col.child(self.render_field(i, field, ...)); // &mut self 调用
}
模式三:ActionCallback 闭包捕获 node_id
节点视图/面板通过 `ActionCallback`(`Arc<dyn Fn(NodeAction, &mut App)>`)向编辑器发动作,闭包在创建时捕获 `node_id`:
let on_action: ActionCallback = Arc::new({
let node_id = node.id;
move |action: NodeAction, cx: &mut App| {
entity.update(cx, |this, cx| {
this.handle_node_action(node_id, action, cx);
});
}
});
notify 与刷新
GPUI 的 `cx.notify()` 标记实体为脏,触发下次 `render`。框架在状态变更后必须调用:
pub fn set_drag_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
self.drag_enabled = enabled;
cx.notify(); // 不调用则界面不更新
}
主题同步的特殊处理
切换主题时,`FlowEditorView` 持有的 `panel_view` 内部有 `theme` 快照,必须**显式通知**:
pub fn toggle_theme(&mut self, cx: &mut Context<Self>) {
self.theme = self.theme.toggle();
gpui_component::Theme::change(mode, None, cx);
if let Some(panel) = &self.panel_view {
panel.set_theme(self.theme, cx); // 显式同步到面板
}
cx.refresh_windows();
cx.notify();
}
`set_data_type_provider` / `set_language` / `set_syntax_service` 注入新扩展时,会销毁现有 `panel_view`(置 None),下次 render 时用新配置重建——避免面板持有过期引用。
小结
GPUI 的闭包约束决定了命中测试方案;Entity + Context 模型决定了扩展点通过句柄传递;借用冲突需要「先可变后不可变」或克隆数据的处理模式。理解这些惯用法,才能在自定义节点与扩展中避免借用错误。
下一节:[渐进式披露与框架边界](progressive-disclosure.md)