交互状态机
`InteractionState` 是一个枚举,每个变体代表「当前用户正在做的一件事」。它的核心价值是把复杂的鼠标行为收敛成有限个互斥状态,避免「同时又在拖节点又在画边」这种不可能态。
五种状态
pub enum InteractionState {
Idle,
Panning {
start_screen: PointF, // 屏幕坐标,用于防抖
origin: PointF, // 按下时的 viewport.offset
},
DraggingNode {
node_id: NodeId,
start: PointF, // 逻辑坐标,按下时的鼠标位置
node_origin: PointF, // 按下时的节点 position
},
DrawingEdge {
from_node: NodeId,
from_port: PortId,
current: PointF, // 逻辑坐标,当前鼠标位置
},
AddingNodeFromEdge {
edge_id: EdgeId,
anchor: PointF, // 屏幕坐标,浮层定位锚
},
}
坐标语义对照
不同状态用不同坐标系,并非随意,而是由「这个状态要和什么比较」决定:
| 状态 | 坐标系 | 原因 | |------|--------|------| | `Panning` | 屏幕坐标 | 平移是「屏幕上拖了多少像素」,与逻辑坐标无关 | | `DraggingNode` | 逻辑坐标 | 节点 position 是逻辑坐标,比较前必须统一 | | `DrawingEdge` | 逻辑坐标 | 边端点要连到端口,端口在逻辑坐标 | | `AddingNodeFromEdge` | 屏幕坐标 | 浮层是屏幕空间 UI,不参与视口变换 | | `Idle` | 无 | 不需要 |
`Panning` 用屏幕坐标是为了「防抖」:直接用 `current_screen - start_screen` 得到屏幕位移,加到 `origin` 上就是新 offset。如果中途 scale 变了(虽然平移时通常不变),这个算法依然正确,因为它只关心屏幕像素。
状态转移图
┌─────────────────────────────┐
│ │
▼ │
┌────────┐ 中键down ┌─────────┐ mouseup │
│ Idle │────────────►│ Panning │──────────►│
└────────┘ └─────────┘ │
│ │
│ 左键down │
│ hit_test 分发: │
│ ├─ DeleteButton ──► delete_node ──►Idle │
│ ├─ ToggleButton ──► toggle_collapse │
│ ├─ EdgePlusButton──► AddingNodeFromEdge │
│ ├─ OutPort ──► DrawingEdge │
│ ├─ Node ──► 选中+DraggingNode │
│ └─ Empty ──► 取消选中 │
│ │
│ │
▼ │
┌──────────────────┐ │
│ DraggingNode │ mouseup ─────────────────►│
└──────────────────┘ │
│
┌──────────────────┐ │
│ DrawingEdge │ mouseup: │
│ │ hit_test InPort? │
│ │ ├─ 是 ──► 创建边 ──► Idle│
│ │ └─ 否 ───────────► Idle │
└──────────────────┘ │
│
┌──────────────────┐ │
│AddingNodeFromEdge│ 点击浮层项 ──► │
│ │ insert_node_at_edge │
│ │ ──► Idle │
│ │ Esc / 空白点击 ──► Idle │
└──────────────────┘ │
│
所有路径最终回到 Idle ◄──────┘
on_mouse_down 的分派
`on_mouse_down` 是状态机的总入口,它的流程非常机械:
fn on_mouse_down(&mut self, screen: Point<Pixels>, button: MouseButton, cx: &mut Context<Self>) {
let logical = self.to_logical(screen);
match button {
MouseButton::Middle => {
self.interaction = InteractionState::Panning {
start_screen: screen.into(),
origin: self.viewport.offset,
};
return;
}
MouseButton::Left => {}
_ => return,
}
match self.hit_test(logical) {
HitResult::DeleteButton(id) => self.delete_node(id, cx),
HitResult::ToggleButton(id) => self.handle_node_action(id, NodeAction::ToggleCollapse, cx),
HitResult::EdgePlusButton(eid) => {
self.interaction = InteractionState::AddingNodeFromEdge {
edge_id: eid,
anchor: screen.into(),
};
}
HitResult::OutPort(node, port) => {
self.interaction = InteractionState::DrawingEdge {
from_node: node, from_port: port, current: logical,
};
}
HitResult::Node(id) => {
self.selected = Some(id);
if self.drag_enabled {
self.interaction = InteractionState::DraggingNode {
node_id: id,
start: logical,
node_origin: self.graph.node(id).position,
};
}
}
HitResult::InPort(_, _) => { /* 通常不作为起点 */ }
HitResult::Empty => {
self.selected = None;
}
}
cx.notify();
}
注意:分派完全由 `hit_test` 的结果驱动,状态机本身不做几何判断。这种「命中测试 + 状态分派」的分离让两个模块都可独立测试。
on_mouse_move 的状态相关行为
fn on_mouse_move(&mut self, screen: Point<Pixels>, cx: &mut Context<Self>) {
let logical = self.to_logical(screen);
match &mut self.interaction {
InteractionState::Panning { start_screen, origin } => {
let dx = screen.x.0 - start_screen.x;
let dy = screen.y.0 - start_screen.y;
self.viewport.offset = PointF::new(origin.x + dx, origin.y + dy);
}
InteractionState::DraggingNode { node_id, start, node_origin } => {
let new_pos = *node_origin + (logical - *start);
self.graph.node_mut(*node_id).position = new_pos;
}
InteractionState::DrawingEdge { current, .. } => {
*current = logical;
}
_ => {
// Idle:仅更新 hovered / hovered_plus
self.update_hover(logical);
}
}
cx.notify();
}
`DraggingNode` 的位移公式 `new_pos = node_origin + (logical - start)` 是「按下时的节点位置 + 鼠标逻辑位移」。这种「以按下点为基准」的算法能避免累积误差,即使中途 scale 变化也大致正确。
on_mouse_up 的收尾
| 当前状态 | 抬起动作 | |----------|----------| | `Panning` | 回到 `Idle` | | `DraggingNode` | 回到 `Idle`,可选触发 `relayout`(若开启了自动布局) | | `DrawingEdge` | 抬起点 `hit_test`,若 `InPort` 则创建边;否则丢弃。回到 `Idle` | | `AddingNodeFromEdge` | 通常不因 mouseup 退出,要等浮层点击或 Esc | | `Idle` | 无 |
`DrawingEdge` 抬起时的端口配对规则:源端口类型与目标端口类型需匹配,且不能连到自己。具体匹配逻辑由 `data_type_provider` 决定(若存在)。
为什么不用更多状态?
有人可能想加 `ResizingNode`、`BoxSelecting`、`ConnectingPort` 等状态。框架目前只实现了五态,原因:
- 五态覆盖了核心编辑场景,复杂度可控。 - 新状态需要同步考虑:转移条件、坐标语义、render 时的额外绘制、hit_test 是否需要调整。每加一个状态,状态矩阵翻一倍。 - 框架优先保证「数据驱动 + relayout」的简洁性,手动 resize 等高级交互留给后续扩展。
小结
`InteractionState` 用五个变体把鼠标行为切成互斥阶段。坐标语义因状态而异:平移用屏幕坐标,拖节点与画边用逻辑坐标,浮层用屏幕坐标。`on_mouse_down` 完全由 `hit_test` 驱动分派,状态机不做几何判断——这是它能保持简洁的关键。
下一节:[命中测试](hit-test.md)