Loop 循环迭代

4 端口拓扑

Loop 是最复杂的内置节点——它有 4 个端口,分两组:主线(In→Done)和循环体支线(LoopBody→LoopIn)。这种拓扑让它能表达「执行循环体直到完成」的语义。

纵向布局(主要标准):
              In
               ↓
        ┌──────────────────┐
        │     ⟳ Loop       │  标题栏 h=TITLE_H=36
        ├──────────────────┤
        │   For each item  │  循环条件区 h=BODY_H=44
        └─┬──────────────┬─┘
   ←LoopIn │              │ LoopBody──→ (body 节点,上进下出)
          ↑              ↓
          │           Done
          │
          └──回环边从 body 底部出,向下绕过,左进 loop_in

| 端口 | direction | 横向位置 | 纵向位置 | 语义 | |------|-----------|----------|----------|------| | `in` | In | 左中心 | 顶中心 | 主线入口 | | `done` | Out | 右中心 | 底中心 | 主线出口(循环完成) | | `loop_body` | Out | 右中心(条件区 Y) | 右中心(条件区 Y) | 循环体出口 | | `loop_in` | In | 左中心(条件区 Y) | 左中心(条件区 Y) | 循环体回连入口 |

**关键差异**:主线 In→Done 随布局方向变化(横向左右、纵向上/下),循环体支线 LoopBody/LoopIn **两种布局一致**——LoopBody 始终右出,LoopIn 始终左进。

为何支线方向固定

循环体节点始终纵向编排(上进下出),无论主图布局方向。回环边从 body 节点底部出,向下绕过 body 组合边界,左进 loop_in。如果支线方向也随主布局变化,横向布局下回环边会变得复杂且易与主线冲突。

固定支线方向让回环边路由算法简化为「向下绕圈」一种模式——这是 Loop 节点设计的核心权衡。

Schema 与字段

| 属性 | 值 | |------|-----| | kind | `"loop"` | | 端口 | in, done, loop_body, loop_in(全 Auto) | | 默认尺寸 | 220 × (TITLE_H + BODY_H) = 220 × 80 | | BODY_H | 44 | | Schema 字段 | `label`(Text), `loop_mode`(Dropdown), `loop_expr`(CodeBlock) |

`loop_mode` 的 4 个选项:

FieldType::Dropdown(vec![
    DropdownOption::new("for_each", "For each item"),
    DropdownOption::new("while", "while cond"),
    DropdownOption::new("for_loop", "for i in 0..n"),
    DropdownOption::new("batch_parallel", "parallel each"),
])

主体文案优先用 `node.data["desc"]`,否则按 `loop_mode` 显示模式标签(i18n)。

覆写的方法

| 方法 | 覆写 | 原因 | |------|------|------| | `ports_for_node` | 否 | 4 端口静态固定 | | `port_position` | 是 | 4 端口需精确对齐(主线 vs 支线 Y 坐标不同) | | `content_size` | 是(固定值) | Loop 自身不收起,高度固定 `TITLE_H + BODY_H` | | `plus_button_at_target` | 是 | done 出口的「+」按钮放目标侧避让 loop_body 按钮 |

port_position:主线 vs 支线 Y 坐标

let node_mid_y = node.position.y + node.size.h * 0.5;       // 主线 Y
let body_mid_y = node.position.y + TITLE_H + BODY_H * 0.5;  // 支线 Y(条件区中心)

match port_id.as_str() {
    "in"  => /* 横向: (left, node_mid_y)   纵向: (mid_x, top) */,
    "done" => /* 横向: (right, node_mid_y)  纵向: (mid_x, bottom) */,
    "loop_body" => Some(PointF::new(right, body_mid_y)),     // 始终右
    "loop_in"   => Some(PointF::new(left, body_mid_y)),      // 始终左
    _ => None,
}

主线用 `node_mid_y`(节点几何中心),支线用 `body_mid_y`(条件区中心)——两组端口在 Y 方向错开 18px,视觉上清晰区分主线与支线。

plus_button_at_target:避让按钮聚集

fn plus_button_at_target(&self, source_port: Option<&str>) -> bool {
    match source_port {
        Some("done") => true,   // done 的按钮放目标侧
        _ => false,             // loop_body 的按钮放源侧
    }
}

**为何只对 done 覆写**:Loop 的 done 和 loop_body 两个出口都在右侧,Y 坐标差仅 18px(`node_mid_y` 与 `body_mid_y` 的差)。如果两个按钮都放源侧(Loop 右侧),会聚集重叠。把 done(主线出口)的按钮移到目标节点侧,loop_body(支线出口)保持源侧(循环体节点就在 Loop 右侧,源侧按钮位置自然)。

这是 `plus_button_at_target` 方法的典型用例——**出口密集时的视觉冲突解决**。

Loop 不收起自身

fn content_size(&self, node: &Node) -> SizeF {
    SizeF::new(node.size.w, TITLE_H + BODY_H)  // 固定,不随 body_collapsed 变化
}

Loop 节点本身始终完整显示(标题栏 + 循环条件区),收起的是**外部循环体节点**。`is_body_collapsed` 读 `node.data["body_collapsed"]`,控制的是循环体节点的显隐,不是 Loop 自身的高度。

这与 Condition 的 `collapsed` 语义不同——Condition 收起自身内容,Loop 收起外部节点。`handle_node_action` 里 `if node.kind == "loop" { "body_collapsed" } else { "collapsed" }` 的分支正是为此。

循环体分组与回环边

Loop 的 4 端口拓扑支持回环边:

// 回环边:从循环体最后节点 → loop_in
edge.source = body_end_node_id;
edge.target = loop_node_id;
edge.target_port = Some("loop_in".to_string());
edge.kind = EdgeKind::LoopBack;  // 标记为回环边,路由算法用 U 形

`FlowGraph::loop_body_groups()` 通过 BFS 沿 `loop_body` 出边展开循环体节点集合,排除 `loop_in` 回环边与 `done` 出口节点。这是渲染层隐藏循环体、回环边路由的唯一数据源。

toggle 按钮的语义

body_collapsed=true  → ChevronRight(▷,循环体已收起,点击展开)
body_collapsed=false → ChevronDown(▽,循环体已展开,点击收起)

点击 toggle 按钮触发 `NodeAction::ToggleCollapse`,编辑器写入 `body_collapsed` 字段,然后 `sync_node_sizes` + `relayout`——因为收起循环体改变了图结构(隐藏一组节点),dagre 需要重新排版周围节点。

端口颜色语义

| 端口 | ring/dot 颜色 | 含义 | |------|--------------|------| | `in` | `node_in_ring`/`node_in_dot` | 靛蓝(主线输入) | | `done` | `node_out_ring`/`node_out_dot` | 橙色(主线输出) | | `loop_body` | `node_in_ring`/`node_in_dot` | 靛蓝(支线输出,与 in 同色) | | `loop_in` | `node_in_ring`/`node_in_dot` | 靛蓝(支线输入) |

**注意**:loop_body 是 Out 方向,但颜色用 `node_in_*`(靛蓝)——因为它连向循环体节点(作为循环体的输入),视觉上与 in 同色更直观。这是颜色语义对方向语义的覆盖,体现了 Loop 拓扑的特殊性。

完整布局示意

横向布局:
In ─▶│ ┌──────────────────────┐ │◀── Done
      │ │     ⟳ Loop          │ │
      │ ├──────────────────────┤ │
      │ │   For each item      │ │
      │ └─┬──────────────────┬─┘ │
      │   │ LoopIn   LoopBody│──→ (body 节点)
      │   ↑                  │
      │   │      回环边向下绕回
      └───┘

横向布局下,主线 In→Done 走节点左右中心,支线 LoopBody/LoopIn 走条件区中心 Y。回环边从 body 底部出,向下绕过 body 组合边界,左进 loop_in——与纵向布局的路由模式一致。

小结

Loop 是最复杂的内置节点:4 端口拓扑分主线(In→Done)与支线(LoopBody/LoopIn)。支线方向固定(右出/左进),简化回环边路由。覆写 `port_position` 让主线用节点中心 Y、支线用条件区中心 Y,视觉区分两组端口。覆写 `plus_button_at_target` 让 done 按钮放目标侧,避让 loop_body 按钮聚集。Loop 自身不收起,收起的是外部循环体节点(`body_collapsed` 字段)。

下一节:[Variable / Adapter / Agent](variable-adapter-agent.md)