GPUI Box GitHub

Components / canvas

GraphNode builder

A step of a run, as a card on the canvas.

A builder is RenderOnce. Construct and mount it in one expression.

use gpui_kit::canvas::GraphNode;

Reviewed in node-graph node-graph-layout node-graph-routing node-graph-motion.

Construct
new(ident: impl Into<Ident>, title: impl Into<SharedString>) -> Self
Options

Chain onto the value.

icon(icon: Icon) -> Self
thumbnail(thumbnail: impl IntoElement) -> Self
thumbnail_ratio(ratio: f32) -> Self
action(action: impl Into<SharedString>) -> Self
state(state: NodeState) -> Self
color(category: impl Into<ColorChoice>) -> Self
kind(kind: impl Into<SharedString>) -> Self
metric(label: impl Into<SharedString>, value: impl Into<SharedString>) -> Self
metrics(metrics: impl IntoIterator<Item = NodeMetric>) -> Self
note(text: impl Into<SharedString>) -> Self
note_lines(n: usize) -> Self
port(port: GraphPort) -> Self
ports(ports: impl IntoIterator<Item = GraphPort>) -> Self
diff(diff: Diff) -> Self
status(status: impl Into<SharedString>) -> Self
progress(fraction: Option<f32>) -> Self
child(child: impl IntoElement) -> Self
children(children: impl IntoIterator<Item = impl IntoElement>) -> Self
active_glass(preset: GlassPreset) -> Self
width(width: f32) -> Self
on_click(handler: impl Fn(&mut Window, &mut App) + 'static) -> Self
Example · node-graph
fn node_graph(_window: &mut Window, cx: &mut App) -> AnyElement {
    let records = PortType::new("records", "teal").glyph(Icon::List);
    let signal = PortType::new("signal", "orange").glyph(Icon::Graph);
    let artifact = PortType::new("artifact", "violet").glyph(Icon::ArchiveUp);
    if !cx.has_global::<SceneGraph>() {
        cx.set_global(SceneGraph {
            viewport: GraphViewport::default(),
            fit: 0,
            ingest: gpui::point(24.0, 190.0),
            validate: gpui::point(284.0, 40.0),
            persist: gpui::point(556.0, 40.0),
            observe: gpui::point(516.0, 300.0),
            publish: gpui::point(748.0, 190.0),
            selected: vec!["scene.graph.validate".into()],
            deleted: Vec::new(),
            sizes: std::collections::HashMap::new(),
            edges: vec![
                GraphEdge::new("scene.graph.ingest", "scene.graph.validate")
                    .id("scene.graph.edge.rows")
                    .ports("rows", "records")
                    .label("批量导入行 · src/ingest/rows.ts")
                    .active(true),
                GraphEdge::new("scene.graph.validate", "scene.graph.persist")
                    .id("scene.graph.edge.valid")
                    .ports("valid", "records")
                    .label("校验通过的记录 · src/validate/ok.ts")
                    .active(true),
                // A relationship label carrying a localized sentence, which
                // is what a host writing in Chinese actually hands this: the
                // ideographs advance about twice what a count of characters
                // suggests, and the sentence is longer than the route it
                // annotates. Fit has to frame the width the label really
                // takes, and the label has to stop rather than run out over
                // the cards either side of it.
                GraphEdge::new("scene.graph.validate", "scene.graph.observe")
                    .id("scene.graph.edge.telemetry")
                    .ports("telemetry", "events")
                    .label("跟随镜头 · src/camera/follow.ts")
                    .lane(-1),
                GraphEdge::new("scene.graph.persist", "scene.graph.publish")
                    .id("scene.graph.edge.commit")
                    .ports("commit", "artifact")
                    .label("提交产物快照 · src/publish/commit.ts")
                    .active(true),
                // A second long localized label, on the route that runs back
                // through the same band as the one above. Two annotations
                // competing for the same air is the case a real board hits
                // constantly and this catalogue did not cover: with one long
                // label the seating always found somewhere to put it, so
                // nothing here ever exercised what happens when it cannot.
                GraphEdge::new("scene.graph.observe", "scene.graph.validate")
                    .id("scene.graph.edge.retry")
                    .ports("retry", "retry")
                    .label("跳跃核心循环 · src/player/jump.ts")
                    .lane(1)
                    .feedback(),
            ],
        });
    }
    let scene = cx.global::<SceneGraph>();
    let viewport = scene.viewport;
    let fit = scene.fit;
    let ingest = scene.ingest;
    let validate = scene.validate;
    let persist = scene.persist;
    let observe = scene.observe;
    let publish = scene.publish;
    let selected = scene.selected.clone();
    let deleted = scene.deleted.clone();
    let edges = scene.edges.clone();
    let sizes = scene.sizes.clone();
    let theme = cx.theme().clone();
    stack(&theme)
        .child(
            div().w(px(860.0)).h(px(340.0)).child(
                NodeGraph::new("scene.graph")
                    .viewport(viewport)
                    .zoom_range(0.55, 1.8)
                    .minimap(true)
                    .toolbar(
                        CanvasToolbar::new(
                            "scene.graph.toolbar",
                            cx.numbers().percent(viewport.zoom),
                        )
                        // The editor already owns direct wheel zoom and pan.
                        // Fit is the one extra navigation intent this fixture
                        // can keep without inventing arrangement or snapping
                        // policy.
                        .actions([CanvasToolbarAction::Fit])
                        .glass(GlassPreset::Frosted)
                        .on_action(|_, _, cx| {
                            cx.update_global::<SceneGraph, ()>(|scene, _| {
                                scene.fit = scene.fit.wrapping_add(1);
                            });
                            cx.refresh_windows();
                        }),
                    )
                    .fit(GraphFit::Whole(fit))
                    .when(
                        !deleted.iter().any(|id| id == "scene.graph.ingest"),
                        |graph| {
                            graph.placed(sized(
                                &sizes,
                                GraphNode::new("scene.graph.ingest", "Stream ingest")
                                    .color("teal")
                                    .icon(Icon::SoundWave)
                                    .kind("source")
                                    .width(200.0)
                                    .state(NodeState::Succeeded)
                                    .status("Synced")
                                    .thumbnail(scene_picture("Input preview", cx))
                                    .action("orders.v2 · partition 18")
                                    .metric("rate", "3.2k/s")
                                    .port(GraphPort::input("source", "Source").side(PortSide::Top))
                                    .port(GraphPort::output("rows", "Rows").typed(records.clone()))
                                    .port(
                                        GraphPort::output("errors", "Errors").typed(signal.clone()),
                                    )
                                    .selected(selected.iter().any(|id| id == "scene.graph.ingest")),
                                ingest.x,
                                ingest.y,
                            ))
                        },
                    )
                    .when(
                        !deleted.iter().any(|id| id == "scene.graph.validate"),
                        |graph| {
                            graph.placed(sized(
                                &sizes,
                                GraphNode::new("scene.graph.validate", "Validate & enrich")
                                    .color("indigo")
                                    .icon(Icon::Checklist)
                                    .kind("transform")
                                    .width(200.0)
                                    .state(NodeState::Running)
                                    .status("Running")
                                    .progress(Some(0.62))
                                    .action("schema + fraud signals")
                                    .metric("p95", "18 ms")
                                    .port(
                                        GraphPort::input("records", "Records")
                                            .typed(records.clone()),
                                    )
                                    .port(GraphPort::input("retry", "Retry").side(PortSide::Bottom))
                                    .port(
                                        GraphPort::output("valid", "Valid").typed(records.clone()),
                                    )
                                    .child(
                                        Slider::new("scene.graph.validate.threshold")
                                            .range(0.0, 1.0)
                                            .step(0.05)
                                            .value(0.35)
                                            .display("0.35")
                                            .on_change(|_, _, _| {}),
                                    )
                                    .port(
                                        GraphPort::output("telemetry", "Events")
                                            .side(PortSide::Bottom),
                                    )
                                    .selected(
                                        selected.iter().any(|id| id == "scene.graph.validate"),
                                    ),
                                validate.x,
                                validate.y,
                            ))
                        },
                    )
                    .when(
                        !deleted.iter().any(|id| id == "scene.graph.persist"),
                        |graph| {
                            graph.placed(sized(
                                &sizes,
                                GraphNode::new("scene.graph.persist", "Persist batch")
                                    .color("violet")
                                    .icon(Icon::ArchiveUp)
                                    .kind("sink")
                                    .width(200.0)
                                    .state(NodeState::Succeeded)
                                    .status("Committed")
                                    .action("warehouse / orders")
                                    .metric("written", "12.3k")
                                    .port(
                                        GraphPort::input("records", "Records")
                                            .typed(records.clone()),
                                    )
                                    .port(
                                        GraphPort::output("commit", "Commit")
                                            .typed(artifact.clone()),
                                    )
                                    .selected(
                                        selected.iter().any(|id| id == "scene.graph.persist"),
                                    ),
                                persist.x,
                                persist.y,
                            ))
                        },
                    )
                    .when(
                        !deleted.iter().any(|id| id == "scene.graph.observe"),
                        |graph| {
                            graph.placed(sized(
                                &sizes,
                                GraphNode::new("scene.graph.observe", "Observe quality")
                                    .color("orange")
                                    .icon(Icon::Danger)
                                    .kind("monitor")
                                    .width(200.0)
                                    .state(NodeState::Failed)
                                    .status("Needs review")
                                    .action("drift threshold exceeded")
                                    .metric("rejected", "94")
                                    .port(GraphPort::input("events", "Events").side(PortSide::Top))
                                    .port(GraphPort::output("retry", "Retry").side(PortSide::Top))
                                    .selected(
                                        selected.iter().any(|id| id == "scene.graph.observe"),
                                    ),
                                observe.x,
                                observe.y,
                            ))
                        },
                    )
                    .when(
                        !deleted.iter().any(|id| id == "scene.graph.publish"),
                        |graph| {
                            graph.placed(sized(
                                &sizes,
                                GraphNode::new("scene.graph.publish", "Publish artifact")
                                    .color("lime")
                                    .icon(Icon::Global)
                                    .kind("release")
                                    .width(200.0)
                                    .state(NodeState::Pending)
                                    .status("Queued")
                                    .progress(None)
                                    .action("waiting for commit")
                                    .port(
                                        GraphPort::input("artifact", "Artifact")
                                            .side(PortSide::Top),
                                    )
                                    .port(
                                        GraphPort::output("release", "Release")
                                            .side(PortSide::Bottom),
                                    )
                                    .selected(
                                        selected.iter().any(|id| id == "scene.graph.publish"),
                                    ),
                                publish.x,
                                publish.y,
                            ))
                        },
                    )
                    .edges(edges)
                    .on_event(|event, _, cx| {
                        cx.update_global::<SceneGraph, ()>(|scene, _| match event {
                            NodeGraphEvent::ViewportChanged(viewport) => {
                                scene.viewport = *viewport;
                            }
                            NodeGraphEvent::SelectionChanged { ids } => {
                                scene.selected = ids.clone();
                            }
                            NodeGraphEvent::NodeMoved { id, position } => match id.as_ref() {
                                "scene.graph.ingest" => scene.ingest = *position,
                                "scene.graph.validate" => scene.validate = *position,
                                "scene.graph.persist" => scene.persist = *position,
                                "scene.graph.observe" => scene.observe = *position,
                                "scene.graph.publish" => scene.publish = *position,
                                _ => {}
                            },
                            NodeGraphEvent::NodeResized { id, size } => {
                                scene.sizes.insert(id.clone(), *size);
                            }
                            NodeGraphEvent::NodeDeleted { id } => {
                                scene.deleted.push(id.clone());
                                scene.selected.retain(|selected| selected != id);
                                scene
                                    .edges
                                    .retain(|edge| edge.from() != id && edge.to() != id);
                            }
                            NodeGraphEvent::ConnectionRequested { from, to } => {
                                let id = format!(
                                    "scene.graph.edge.user.{}.{}.{}.{}",
                                    from.node, from.port, to.node, to.port
                                );
                                if !scene
                                    .edges
                                    .iter()
                                    .any(|edge| edge.from() == &from.node && edge.to() == &to.node)
                                {
                                    scene.edges.push(
                                        GraphEdge::new(from.node.clone(), to.node.clone())
                                            .id(id)
                                            .ports(from.port.clone(), to.port.clone())
                                            .label("new connection"),
                                    );
                                }
                            }
                            NodeGraphEvent::ConnectionDropped { .. } => {}
                            // This scene has no way to add a step, so there is
                            // nothing truthful to do with a place on it.
                            NodeGraphEvent::SurfacePressed { .. } => {}
                            NodeGraphEvent::DisconnectRequested { id } => {
                                scene.edges.retain(|edge| edge.edge_id() != *id);
                            }
                        });
                        cx.refresh_windows();
                    }),
            ),
        )
        .child(
            div()
                .row()
                .w(px(860.0))
                .gap_token(&theme, Space::Sm)
                .children([
                    div()
                        .flex_1()
                        .h(px(132.0))
                        .child(NodeGraph::new("scene.graph.loading").state(GraphState::Loading)),
                    div().flex_1().h(px(132.0)).child(
                        NodeGraph::new("scene.graph.refused")
                            .state(GraphState::Refused("Graph access was refused".into())),
                    ),
                    div().flex_1().h(px(132.0)).child(
                        NodeGraph::new("scene.graph.failed")
                            .state(GraphState::Failed("Graph data could not be loaded".into())),
                    ),
                ]),
        )
        .child(caption(
            &theme,
            "the same canvas routed as curves, for a board sparse enough not to need lanes",
        ))
        .child(
            div().w(px(860.0)).h(px(212.0)).child(
                NodeGraph::new("scene.graph.curves")
                    .routing(GraphRouting::Curves)
                    .grid(false)
                    .node(
                        GraphNode::new("scene.graph.curves.brief", "Brief")
                            .color("indigo")
                            .kind("prompt")
                            .width(176.0)
                            .action("a lit interior, dusk")
                            .port(GraphPort::output("out", "Out")),
                        24.0,
                        44.0,
                    )
                    .node(
                        GraphNode::new("scene.graph.curves.render", "Render")
                            .color("teal")
                            .kind("image")
                            .width(176.0)
                            .state(NodeState::Succeeded)
                            .port(GraphPort::input("in", "In"))
                            .port(GraphPort::output("out", "Out")),
                        330.0,
                        20.0,
                    )
                    .node(
                        GraphNode::new("scene.graph.curves.grade", "Grade")
                            .color("orange")
                            .kind("adjust")
                            .width(176.0)
                            .action("warm, +8 contrast")
                            .port(GraphPort::input("in", "In")),
                        636.0,
                        84.0,
                    )
                    .edges(vec![
                        GraphEdge::new("scene.graph.curves.brief", "scene.graph.curves.render")
                            .id("scene.graph.curves.edge.render")
                            .ports("out", "in")
                            .marker(EdgeMarker::Arrow),
                        GraphEdge::new("scene.graph.curves.render", "scene.graph.curves.grade")
                            .id("scene.graph.curves.edge.grade")
                            .ports("out", "in")
                            .marker(EdgeMarker::Dot),
                    ]),
            ),
        )
        .child(caption(&theme, "node typography · 1× and 0.5× · four-line bilingual notes"))
        .child(div().row().gap(px(theme.spacing.lg)).children(
            [("full", 1.0), ("half", 0.5)].map(|(name, zoom)| {
                GraphNode::new(format!("scene.graph.typography.{name}"), "Prompt · 提示词")
                    .width(280.0)
                    .display_at(zoom, None)
                    .color("teal")
                    .kind("text")
                    .port(GraphPort::input("context", "Context · 上下文"))
                    .note("A quiet room at dusk.\n黄昏时分,安静的房间。\nKeep the window light soft.\n让窗边光线保持柔和。\nThis fifth line is clipped.\n第六行仍保留在语义描述中。")
                    .metrics([
                        NodeMetric::new("Model", "Studio").labelled(),
                        NodeMetric::new("比例", "16:9").labelled(),
                        NodeMetric::new("Seed", "118"),
                    ])
            }),
        ))
        .into_any_element()
}

Source: crates/gpui-kit/src/canvas/node.rs