Components / canvas
NodeGraph builder
A run drawn as connected steps.
A builder is RenderOnce. Construct and mount it in one expression.
use gpui_kit::canvas::NodeGraph;
Reviewed in node-graph node-graph-layout node-graph-routing node-graph-motion canvas-regions.
Construct
new(ident: impl Into<Ident>) -> Self
Options
Chain onto the value.
animate_layout(animate: bool) -> Self
node(node: GraphNode, x: f32, y: f32) -> Self
placed(placed: Placed) -> Self
edge(edge: GraphEdge) -> Self
edges(edges: impl IntoIterator<Item = GraphEdge>) -> Self
source(source: GraphSource) -> Self
band(band: GraphBand) -> Self
bands(bands: impl IntoIterator<Item = GraphBand>) -> Self
state(state: GraphState) -> Self
empty(empty: EmptyState) -> Self
grid(grid: bool) -> Self
axes(axes: bool) -> Self
ground_light(ground_light: bool) -> Self
offset(x: f32, y: f32) -> Self
viewport(viewport: GraphViewport) -> Self
zoom(zoom: f32) -> Self
zoom_range(min: f32, max: f32) -> Self
interaction(interaction: GraphInteraction) -> Self
on_event(handler: impl Fn(&NodeGraphEvent, &mut Window, &mut App) + 'static) -> Self
can_connect(validator: impl Fn(&GraphEndpoint, &GraphEndpoint) -> bool + 'static) -> Self
minimap(show: bool) -> Self
toolbar(toolbar: CanvasToolbar) -> Self
fit(fit: GraphFit) -> Self
fit_clearance(clearance: Edges<f32>) -> Self
routing(routing: GraphRouting) -> Self
Reports
The variants of the event it emits. It never applies the change itself.
- ViewportChanged
- SelectionChanged
- NodeMoved
- NodeResized
- NodeDeleted
- SurfacePressed
- ConnectionRequested
- ConnectionDropped
- DisconnectRequested
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()
}