FieldState 与同步机制
属性面板的核心难题不是「渲染控件」,而是「双向同步」:用户在面板编辑要写回节点;节点被外部改写要同步回面板——且两条路径都不能让光标乱跳、不能让事件无限回环。`FieldState` 与 `sync_from_node` 是这套机制的答案。
FieldState:四种字段形态
`FieldState` 是个枚举,与 `FieldType` 一一对应:
enum FieldState {
Input(Entity<InputState>), // Text/Number/TextArea/CodeEditor/CodeBlock
Switch(bool), // Switch
Dropdown(String), // Dropdown(存当前值)
List(Vec<Vec<Entity<InputState>>>), // List(每行一组 InputState)
}
对照关系:
| FieldType | FieldState | 说明 | |-----------|-----------|------| | Text / Number | `Input` | 单行输入框 | | TextArea | `Input` | 多行,`multi_line(true).rows(4)` | | CodeEditor | `Input` | 单行代码,`code_editor(lang).multi_line(false)` | | CodeBlock | `Input` | 多行代码,`code_editor(lang).line_number(true).rows(4)` | | Switch | `Switch(bool)` | 内联布尔,无需 InputState | | Dropdown | `Dropdown(String)` | 内联字符串,菜单渲染时读 schema 选项 | | List | `List(Vec<Vec<...>>)` | 二维:行 × 列,列对应 `item_fields` |
代码类字段共用 `Input` 形态,区别仅在 `InputState` 的构造参数(是否 `code_editor`、是否多行、是否显示行号)。
List 的二维结构
List 字段最复杂,是「行 × 列」的二维数组:
schema.fields[i].field_type = List(ListSpec {
item_fields: [FieldSpec{key:"name",...}, FieldSpec{key:"expr",...}],
})
field_states[i] = List(vec![
vec![ InputState(name1), InputState(expr1) ], // 第1行
vec![ InputState(name2), InputState(expr2) ], // 第2行
])
三个操作配套:
- `add_list_item`:按 `item_fields` 创建一行新 `InputState`,订阅变化,push 进 `rows`,再 `sync_list_to_node`。 - `delete_list_item`:`rows.remove(row_idx)`,再 `sync_list_to_node`。 - `sync_list_to_node`:遍历所有行,收集每个 `item_field.key` 的值,组装成 `Vec<Value>` 数组,`dispatch_set_data` 写回节点。
注意 `add_list_item` 创建的每个行内 Input 都被订阅到同一个闭包,触发 `sync_list_to_node(field_idx, cx)`——任何一格改动都会整体回写。
事件链:从输入到回写
用户敲一个字符,事件链如下:
用户编辑 Input
│
▼
InputState 发出 InputEvent::Change
│
▼
subscribe_in 回调(on_input_field_change / on_label_change)
│ 检查 syncing 标记:若 syncing=true 直接 return
▼
dispatch_set_data(key, json!(value))
│
▼
on_action(NodeAction::SetData(key, value))
│
▼
FlowEditorView::handle_node_action
│ 更新 node.data[key] = value
▼
relayout(如有必要)+ cx.notify
│
▼
FlowEditorView::render 检测节点数据变化
│
▼
panel_view.sync_from_node(new_node)
│ 快速路径:node.data 一致 → 直接返回(不碰 InputState)
▼
逐字段比较,仅变化时 set_value
关键点:`dispatch_set_data` 不直接修改 `self.node`,而是绕一圈由编辑器回灌。这保证 `self.node` 始终是「编辑器认可的真相」,避免面板与编辑器状态分裂。
syncing:防止回环
`sync_from_node` 在更新 InputState 前置 `syncing = true`,结束后置 false。所有 `on_*_change` 回调首行检查:
fn on_input_field_change(&mut self, field_idx, event, cx) {
if self.syncing || !matches!(event, InputEvent::Change) {
return;
}
...
}
为什么需要它?`set_value` 会触发 `InputEvent::Change`。若不加保护:
sync_from_node 调 set_value → 触发 Change → on_change 调 dispatch_set_data
→ 编辑器更新 node.data → 又触发 sync_from_node → 又调 set_value ...
`syncing` 在同步窗口内屏蔽回调,斩断回环。
sync_from_node:双路径优化
`ensure_panel_view` 每帧都会调用 `sync_from_node`,因此它必须极快。两个优化路径:
快速路径:数据一致直接返回
pub fn sync_from_node(&mut self, node: Node, window, cx) {
if self.node.id != node.id { return; }
// 快速路径:数据完全一致时跳过所有更新
if self.node.data == node.data {
return;
}
...
}
`serde_json::Value` 的 `==` 是结构化比较。绝大多数帧里用户没编辑,节点也没被外部改,这条路径一比较即返回,零成本。
慢路径:逐字段比较,仅变化时 set_value
数据不一致时进入慢路径,但仍避免无脑 `set_value`:
match &mut self.field_states[i] {
FieldState::Input(entity) => {
let text = value_to_string(&value);
let current = entity.read(cx).value().to_string();
if current != text { // 仅变化时才更新
entity.update(cx, |s, cx| s.set_value(text.as_str(), window, cx));
}
}
FieldState::Switch(b) => { *b = value.as_bool().unwrap_or(false); }
FieldState::Dropdown(s) => { if let Some(v) = value.as_str() { *s = v.to_string(); } }
FieldState::List(rows) => { sync_list_rows(rows, &value, field, ...); }
}
**为什么不能直接 set_value?** `set_value` 会重置光标位置。如果用户正在某个输入框打字,外部(如 undo/redo、另一处面板)触发同步,无脑 `set_value` 会把光标甩到末尾。逐字段比较确保「用户正在编辑的字段」因为值已是最新的而跳过更新,光标纹丝不动。
List 同步的两个分支
`sync_list_rows` 同样有快慢两条路径,但判断依据是**行数**:
if arr.len() == rows.len() {
// 行数一致:逐格比较更新值
for (i, item) in arr.iter().enumerate() {
for (col, item_field) in list_spec.item_fields.iter().enumerate() {
let text = value_to_string(&val);
if current != text { rows[i][col].update(...set_value...); }
}
}
} else {
// 行数变化:整体重建(丢失订阅,但 syncing=true 不触发回调)
rows.clear();
for item in &arr { /* 重建每行 */ }
}
行数变化时只能重建——但此时 `syncing=true`,新建的 InputState 即使触发 Change 也被屏蔽,不会回写。
小结
`FieldState` 四种形态覆盖 8 种 `FieldType`;事件链「Input→Change→on_action→SetData→relayout→sync_from_node」把面板与节点数据绑成一体;`syncing` 标记斩断回环;`sync_from_node` 用「数据一致快速返回 + 逐字段比较按需 set_value」双路径,既快又不打扰用户光标。
下一节:[Start 节点专属面板](start-panel.md)