命中测试交互模型

为什么需要命中测试

GPUI 的 `listener` 闭包**无法捕获外部变量**(如 `node_id`),因此不能在每个节点 div 上绑定点击闭包。rust-agent-flow 采用**画布统一处理 + 几何命中测试**方案:

鼠标事件 → to_logical() → hit_test() → HitResult → 状态分发

所有鼠标事件绑定在画布根 div 上,通过几何计算确定点击目标。

HitResult 枚举

`hit_test` 返回 `HitResult`,按优先级从高到低:

pub enum HitResult {
    DeleteButton(NodeId),       // 节点删除按钮(最高优先级)
    ToggleButton(NodeId),       // 展开/收起按钮
    EdgePlusButton(EdgeId),     // 边「+」按钮
    OutPort(NodeId, PortId),    // 输出端口(连线起点)
    InPort(NodeId, PortId),     // 输入端口(连线终点)
    Node(NodeId),               // 节点主体
    Empty,                      // 空白区域
}

优先级保证:悬停按钮时不会误触节点拖拽;悬停端口时不会误触节点选中。

命中测试算法

节点命中

// 点是否在节点矩形内
point_in_rect(point, node.bounds())

端口命中

端口是圆形,命中测试用**点到圆心距离**判断:

// 端口外圆半径 port_outer,点击在圆内即命中
let dist = point.distance_to(port_center);
dist <= port_outer

端口位置由 `IFlowNode::port_position` 计算(或框架默认按 side 推导)。

边「+」按钮命中

「+」按钮位于边路径的中点附近。命中测试用**点到折线距离**:

let dist = point_to_polyline_distance(point, &edge_points);
dist <= PLUS_HIT_RADIUS

交互状态机

命中结果驱动 `InteractionState` 状态机:

pub enum InteractionState {
    Idle,                                              // 空闲
    Panning { start_screen, origin },                  // 平移视口
    DraggingNode { node_id, start, node_origin },      // 拖拽节点
    DrawingEdge { from_node, from_port, current },     // 绘制连线
    AddingNodeFromEdge { edge_id, anchor },            // 选节点类型插入
}

状态转移

Idle
  ├─ 中键按下 → Panning
  ├─ 左键命中 OutPort → DrawingEdge
  ├─ 左键命中 Node → DraggingNode(若 drag_enabled)
  ├─ 左键命中 EdgePlusButton → AddingNodeFromEdge
  ├─ 左键命中 DeleteButton → 删除节点 → Idle
  ├─ 左键命中 ToggleButton → 切换收起 → Idle
  └─ 左键命中 Empty → 取消选中 → Idle

Panning
  └─ 鼠标移动 → 更新 offset → 中键抬起 → Idle

DraggingNode
  └─ 鼠标移动 → 更新 node.position → 左键抬起 → Idle

DrawingEdge
  └─ 鼠标移动 → 更新 current → 左键抬起:
       命中 InPort → 创建边 → Idle
       其他 → 取消 → Idle

AddingNodeFromEdge
  └─ 点击节点类型 → insert_node_at_edge → Idle
  └─ 点击外部 → Idle

平移用屏幕坐标的原因

`Panning` 状态用**屏幕坐标**(而非逻辑坐标)记录起点:

Panning { start_screen: PointF, origin: PointF }

避免平移过程中 `viewport.offset` 变化导致逻辑坐标反馈抖动——这是参考 ReactFlow 的成熟方案。鼠标移动时:

let delta = screen_now - start_screen;
viewport.offset = origin + delta; // 纯屏幕空间运算

节点拖拽用逻辑坐标

`DraggingNode` 用**逻辑坐标**记录起点:

DraggingNode { node_id, start: PointF, node_origin: PointF }

因为节点 position 存储在逻辑空间:

let delta = logical_now - start;
node.position = node_origin + delta;

缩放下的坐标转换

所有鼠标事件先转换到逻辑坐标:

fn to_logical(&self, p: Point<Pixels>) -> PointF {
    self.viewport.to_logical(PointF::new(p.x.as_f32(), p.y.as_f32()))
}

`Viewport::to_logical` 的实现:

fn to_logical(&self, screen: PointF) -> PointF {
    (screen - self.offset) / self.scale
}

命中测试在逻辑空间进行,与节点 position 同一坐标系。

命中测试的性能

每帧鼠标事件触发一次 `hit_test`,遍历所有节点/边/端口。对于 < 200 节点的场景,线性遍历足够快。若需支持更大规模,可引入空间索引(如四叉树)——当前未内置。

小结

命中测试方案是 GPUI 闭包约束下的必然选择:画布统一处理鼠标事件,几何计算确定目标,状态机分发处理。优先级保证按钮/端口不误触节点。平移用屏幕坐标防抖,拖拽用逻辑坐标对齐数据。所有命中在逻辑空间进行,缩放通过 `to_logical` 统一转换。

下一章:[流程图数据模型](../05-graph-model/INDEX.md)