实现 IFlowNode

最小可工作节点

实现一个自定义节点只需 4 个必须方法 + 一次注册。下面以「HttpCall」节点为例,演示完整流程。

第一步:定义结构体与 schema

use gpui::{div, px, AnyElement, IntoElement, ParentElement, Styled};
use gpui_component::{Icon, Sizable, StyledExt};
use rust_agent_flow::{
    FieldSpec, FieldType, LayoutDirection, Node, NodeSchema, PortDirection, PortId,
    PortSide, PortSpec, SizeF, PointF,
};
use crate::node::{IFlowNode, NodeViewCtx};
use crate::builtin::common::{label_of_localized, node_icon, TITLE_H};

const BODY_H: f32 = 28.0;

pub struct HttpCallNode {
    schema: NodeSchema,
}

impl Default for HttpCallNode {
    fn default() -> Self { Self::new() }
}

impl HttpCallNode {
    pub fn new() -> Self {
        Self {
            schema: NodeSchema::new("http_call", "HTTP Call")
                .with_size(SizeF::new(200.0, TITLE_H + BODY_H))
                .with_port(PortSpec::new("in", PortDirection::In, PortSide::Auto))
                .with_port(PortSpec::new("out", PortDirection::Out, PortSide::Auto))
                .with_field(
                    FieldSpec::new("label", "Label", FieldType::Text)
                        .with_default(serde_json::json!("")),
                )
                .with_field(
                    FieldSpec::new("url", "URL", FieldType::Text)
                        .with_default(serde_json::json!("https://")),
                )
                .with_field(
                    FieldSpec::new("method", "Method",
                        FieldType::Dropdown(vec![
                            DropdownOption::new("GET", "GET"),
                            DropdownOption::new("POST", "POST"),
                        ]))
                        .with_default(serde_json::json!("GET")),
                ),
        }
    }
}

schema 定义端口(in/out)、字段(label/url/method)和默认尺寸。FieldType 决定面板渲染控件——Text 单行输入,Dropdown 下拉选择。

第二步:实现 IFlowNode

impl IFlowNode for HttpCallNode {
    fn kind(&self) -> &str { "http_call" }

    fn get_view(&self, node: &Node, ctx: &mut NodeViewCtx) -> AnyElement {
        let s = ctx.scale;
        let w = node.size.w * s;
        let h = (TITLE_H + BODY_H) * s;
        let title_h = TITLE_H * s;
        let body_h = BODY_H * s;
        let t = &ctx.theme;
        let layout = ctx.layout;

        let label = label_of_localized(node, ctx.language);
        let url = node.data.get("url").and_then(|v| v.as_str()).unwrap_or("");
        let method = node.data.get("method").and_then(|v| v.as_str()).unwrap_or("GET");
        let body_text = format!("{} {}", method, url);

        let border_color = if ctx.selected { t.node_border_selected } else { t.node_border };

        let mut container = div().relative().w(px(w)).h(px(h));

        // 标题栏
        container = container.child(
            div().absolute().top_0().left_0().w(px(w)).h(px(title_h))
                .bg(t.node_title_bg).rounded_t_lg().border_1()
                .border_color(border_color).border_b_0()
                .flex().items_center().px(px(12.0 * s)).gap(px(6.0 * s))
                .child(Icon::new(node_icon("http_call")).with_size(px(16.0 * s))
                    .text_color(t.node_title_text))
                .child(div().text_size(px(14.0 * s)).font_semibold()
                    .text_color(t.node_title_text).child(label)),
        );

        // 主体
        container = container.child(
            div().absolute().left_0().top(px(title_h)).w(px(w)).h(px(body_h))
                .bg(t.node_bg).rounded_b_lg().border_1()
                .border_color(border_color).border_t_0()
                .flex().items_center().px(px(12.0 * s))
                .child(div().text_size(px(12.0 * s)).text_color(t.node_subtext)
                    .child(body_text)),
        );

        // 端口(横向:左右中心;纵向:顶/底中心)
        // ... 用 make_port 渲染,参考 Action 节点实现

        container.into_any_element()
    }

    fn get_panel(&self, node: &Node, ctx: &mut NodeViewCtx) -> AnyElement {
        // 用通用 Schema 面板(PanelView 会根据 schema 字段渲染)
        // 也可以返回空 div,让 PanelView 接管
        crate::builtin::common::render_simple_panel(node, ctx.language, &ctx.theme)
    }

    fn schema(&self) -> &NodeSchema { &self.schema }
}

第三步:注册

use std::sync::Arc;
use crate::node::NodeRegistry;

// 在 FlowEditorView 构造后注册
let mut view = FlowEditorView::new(window, cx);
view.registry.register(Arc::new(HttpCallNode::new()));

注册后,画布上添加 kind=`"http_call"` 的节点即可渲染。

必须方法的实现要点

| 方法 | 要点 | |------|------| | `kind` | 返回与 schema 一致的字符串,作为注册表键 | | `get_view` | 用 `ctx.scale` 乘所有尺寸;颜色从 `ctx.theme` 取;selected 切换边框色 | | `get_panel` | 简单节点用 `render_simple_panel`;复杂面板返回空 div 由 PanelView 接管 | | `schema` | 返回 `&self.schema`,schema 在 `new()` 时构造一次 |

主题与缩放的正确处理

**颜色**:永远从 `ctx.theme` 取,不要硬编码 `rgba(...)`。这样主题切换(亮/暗)自动生效。

**缩放**:所有尺寸乘 `ctx.scale`:

let w = node.size.w * s;        // 节点宽
let title_h = TITLE_H * s;      // 标题栏高
let font_size = 14.0 * s;       // 字号
let port_size = 6.0 * s;        // 端口圆点

`node.size` 是逻辑坐标,渲染时乘 scale 转屏幕坐标。框架的视口变换只负责节点位置(`left = pos.x * s`),节点内部元素由节点实现自己乘 scale。

label 的本地化回退

let label = label_of_localized(node, ctx.language);

`label_of_localized` 优先用 `node.data["label"]`,为空时回退到 `kind_label(lang, &node.kind)`——i18n 的类型名。这保证用户未自定义 label 时显示本地化文案,切换语言自动同步。

如果自定义节点有专属 i18n key,需在 `i18n` 模块的 `kind_label` 函数里追加映射:

pub fn kind_label(lang: Language, kind: &str) -> &'static str {
    match kind {
        "http_call" => match lang {
            Language::Zh => "HTTP 调用",
            Language::En => "HTTP Call",
        },
        // ... 其他 kind
        _ => kind,
    }
}

图标的扩展

`common::node_icon` 是按 kind 字符串匹配的函数:

pub(crate) fn node_icon(kind: &str) -> NodeIcon {
    match kind {
        "start" => NodeIcon::Builtin(IconName::Play),
        // ...
        "http_call" => NodeIcon::Builtin(IconName::Globe),  // 追加自定义映射
        _ => NodeIcon::Builtin(IconName::Settings),
    }
}

`NodeIcon::Builtin` 用 gpui-component 内置图标,`NodeIcon::Custom` 用 assets/ 下的自定义 SVG。优先用内置图标(语义匹配时),无合适图标时用 Custom。

错误示范:硬编码颜色

// 错误:主题切换后颜色不变
.bg(gpui::rgb(0xffffff))

// 正确:从 theme 取
.bg(t.node_bg)

硬编码颜色是自定义节点最常见的坑——开发时在亮色主题下看起来正常,切到暗色主题后文字与背景同色。所有颜色必须从 `ctx.theme` 取。

错误示范:忘记乘 scale

// 错误:缩放时节点内部元素不缩放
let font_size = 14.0;

// 正确:乘 scale
let font_size = 14.0 * s;

忘记乘 scale 会导致缩放时节点内部元素大小不变——视口缩小时节点变小但文字仍 14px,溢出节点边界;视口放大时文字相对节点变小。

小结

实现自定义节点的最小步骤:定义结构体与 schema(端口+字段+尺寸)、实现 4 个必须方法(kind/get_view/get_panel/schema)、注册到 NodeRegistry。关键规范:颜色从 theme 取、尺寸乘 scale、label 用 `label_of_localized` 回退、icon 在 `node_icon` 追加映射。简单节点无需覆写可选方法——默认实现已够用。

下一节:[动态端口与 ports_for_node](dynamic-ports.md)