chain-studio-flow-editor/lib/src/widgets/flow_canvas.dart
flemming-it 5c59f3a554 fix(editor): draft line reaches cursor + ports react on hover
Two operator-visible fixes from the 0.5.2 review:

(1) Draft line stopped short of the cursor. EdgePainter
shortens every segment endpoint by portRadius to dock at
port-dot perimeters — but the DRAFT segment's `to` is the
cursor itself, not a port. Result: while dragging a
connection, the live line ended 6 px short of where the
operator's mouse actually was. Felt like the cable
wouldn't reach.

EdgeSegment gains `shortenFrom` / `shortenTo` flags
(default true). The draft segment passes `shortenTo:
false`, so the line tip tracks the cursor exactly. From-
side stays shortened because the FROM is still a real
port dot.

(2) Ports had no hover affordance — mouse-over was
invisible. Operators couldn't tell ahead of time that a
port was interactive.

Adds `_hoveredPort` state on the canvas, updated via the
existing MouseRegion's onEnter / onExit. Port dot now has
three focus levels:

  resting → 12 px, 1.8 px border, no glow
  hover   → 15 px, 2.2 px border, soft accent glow
  drop-target (drag is over it) → 18 px, 2.5 px border,
            stronger glow

The hover state pre-shadows the drop-target state —
operators see "yes, this port is grabbable / droppable"
before they commit to dragging. When the drag does start,
the drop-target halo is the same family of treatment,
just stronger, so the visual progression reads as one
continuous interaction.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-01 17:13:14 +02:00

1176 lines
41 KiB
Dart

// FlowCanvas — the graphical flow editor surface.
//
// Layout:
//
// ┌─────────────────────────────────────────────────────┐
// │ ┌──────┐ ┌──────┐ ┌──────┐ ┌─────┐ │
// │ │ │ ─────── │ │ ─────── │ │ │ │ │
// │ │ in │ │ step │ │ step │ │ out │ │
// │ │ │ │ │ │ │ │ │ │
// │ └──────┘ └──────┘ └──────┘ └─────┘ │
// └─────────────────────────────────────────────────────┘
// InteractiveViewer (pan + zoom)
//
// Special "inputs" and "outputs" pseudo-nodes are pinned to
// the left and right of the layout so every flow has a
// visually obvious source and sink. Step nodes live in the
// middle and can be dragged anywhere by the operator. Edges
// are derived live from the FlowGraph's $ref expressions.
//
// Interactions:
//
// - Click a node: selects it; properties panel hooks
// into the selection.
// - Drag the node body: repositions the node in canvas
// coords (sidecar saved on pan end).
// - Drag an output port → drop on an input port:
// creates a `$source.field` reference
// in the target step's `with:` block.
// The properties panel will let the
// operator refine which output field
// the reference points at.
// - Background pan: scrolls the canvas via the parent
// InteractiveViewer.
import 'package:flutter/material.dart';
import '../editor_controller.dart';
import '../model/auto_layout.dart';
import '../model/flow_graph.dart';
import '../model/layout_store.dart';
import '../tokens.dart';
import 'edge_painter.dart';
import 'flow_node.dart';
/// Canvas dimensions. Big enough that any plausible flow fits
/// with margin to spare; the InteractiveViewer scrolls /
/// scales as needed.
const double _canvasWidth = 4000;
const double _canvasHeight = 3000;
// Fallback positions when the layout sidecar somehow lacks an
// entry for the inputs/outputs endpoint nodes (shouldn't
// happen — AutoLayout always seeds them — but defending in
// depth so a corrupt sidecar never renders an off-screen
// endpoint).
const NodePosition _inputsFallback = NodePosition(40, 80);
const NodePosition _outputsFallback = NodePosition(1200, 80);
class FlowCanvas extends StatefulWidget {
final FlowEditorController controller;
const FlowCanvas({super.key, required this.controller});
@override
State<FlowCanvas> createState() => _FlowCanvasState();
}
class _FlowCanvasState extends State<FlowCanvas> {
final TransformationController _transform = TransformationController();
// Track which flow we last fitted-to-screen for so we
// don't override the operator's manual pan/zoom every
// build. Re-fit when the active flow changes.
String? _fittedFor;
// Active connection-drag state. When non-null, the canvas
// paints a draft edge from the source port to the cursor
// and accepts a drop on any input port.
_ConnectionDraft? _draft;
// Currently-hovered port id. Drives the hover halo so the
// operator gets a clear "this port is interactive"
// affordance before they commit to dragging or clicking.
// String key = same shape used by _connectedPorts so we
// can pass a single hovered-key into the port widget.
String? _hoveredPort;
@override
void initState() {
super.initState();
widget.controller.addListener(_onControllerChanged);
}
@override
void dispose() {
widget.controller.removeListener(_onControllerChanged);
_transform.dispose();
super.dispose();
}
void _onControllerChanged() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final graph = widget.controller.graph;
final layout = widget.controller.layout;
// Endpoint positions now live in the layout sidecar
// alongside every step's position — see
// AutoLayout.layout() which seeds defaults. Reading them
// here (instead of recomputing _outputsX from current
// step positions every build) means the endpoints stay
// put when the operator drags a step around. The whole
// "outputs panel shifts when I move a node" pain Stefan
// flagged is solved structurally: there is no auto-
// recompute path left to trigger.
final inputsPos =
layout.positions[AutoLayout.inputsNodeId] ?? _inputsFallback;
final outputsPos =
layout.positions[AutoLayout.outputsNodeId] ?? _outputsFallback;
// Auto-fit on first build for each flow so the operator
// sees the whole graph immediately, even on flows whose
// auto-layout pushes nodes past the default viewport.
// Re-fit triggers only when the flow name changes — the
// operator's subsequent zooms / pans stay theirs.
final activeName = widget.controller.activeName;
if (activeName != null && activeName != _fittedFor) {
_fittedFor = activeName;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _fitToContent();
});
}
return Container(
color: theme.colorScheme.surface,
child: Stack(
children: [
InteractiveViewer(
transformationController: _transform,
constrained: false,
boundaryMargin: const EdgeInsets.all(400),
minScale: 0.4,
maxScale: 2.0,
child: SizedBox(
width: _canvasWidth,
height: _canvasHeight,
child: Stack(
children: [
_grid(theme),
// Edges first so nodes paint on top of them.
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: EdgePainter(
segments: _buildSegments(graph, layout),
baseColor: theme.colorScheme.onSurfaceVariant
.withValues(alpha: 0.55),
highlightColor: theme.colorScheme.primary,
draftColor: theme.colorScheme.primary,
portRadius: NodeGeometry.portDotSize / 2,
),
),
),
),
// Inputs endpoint — its body labels
// represent OUTPUTS of the node (data flows
// OUT to downstream steps), so the port
// side is RIGHT and labels right-align.
_endpointPositioned(
nodeId: AutoLayout.inputsNodeId,
pos: inputsPos,
title: 'inputs',
kind: NodeVisualKind.inputs,
portSide: NodePortSide.right,
labels: graph.inputs.keys
.map((k) => '$k: ${graph.inputs[k]!.type}')
.toList(),
),
// Outputs endpoint — body labels represent
// INPUTS (data flows IN from steps), so
// port side is LEFT and labels left-align.
_endpointPositioned(
nodeId: AutoLayout.outputsNodeId,
pos: outputsPos,
title: 'outputs',
kind: NodeVisualKind.outputs,
portSide: NodePortSide.left,
labels: graph.outputs.keys.toList(),
),
// Step nodes — positioned absolutely, drag to
// move, click to select.
for (final step in graph.steps) _stepPositioned(step, layout),
// Port hit-targets for connection drawing.
..._portOverlays(graph, layout),
if (_draft != null)
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: EdgePainter(
// Draft line follows the cursor;
// pretend the cursor is on the
// LEFT side so the line "approaches"
// it horizontally (matches the
// input-port orientation it will
// most likely snap to).
segments: [
EdgeSegment(
from: _draft!.from,
to: _draft!.cursor,
fromSide: EdgeSide.right,
toSide: EdgeSide.left,
accent: EdgeAccent.draftDrag,
// The draft target IS the
// cursor — not a port socket.
// Don't shorten on that end or
// the line stops short of
// where the operator's mouse
// actually is.
shortenTo: false,
),
],
baseColor: theme.colorScheme.primary,
highlightColor: theme.colorScheme.primary,
draftColor: theme.colorScheme.primary,
portRadius: NodeGeometry.portDotSize / 2,
),
),
),
),
],
),
),
),
// Floating canvas controls — pinned to the viewport
// bottom-right so they don't drift with pan. Fit re-
// centres on every node; Reset layout wipes the
// sidecar so AutoLayout repositions from scratch
// (useful when a flow's manual layout has drifted
// into spaghetti and the operator wants a clean
// starting point).
Positioned(
right: FaiSpace.md,
bottom: FaiSpace.md,
child: Material(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.sm),
elevation: 2,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: _resetLayout,
icon: const Icon(Icons.dashboard_outlined, size: 18),
tooltip: 'Reset layout',
visualDensity: VisualDensity.compact,
),
IconButton(
onPressed: _fitToContent,
icon: const Icon(Icons.fit_screen_outlined, size: 18),
tooltip: 'Fit to screen',
visualDensity: VisualDensity.compact,
),
],
),
),
),
],
),
);
}
/// Compute the bounding box of every visible node — steps
/// + the inputs / outputs endpoints — then set the
/// TransformationController so the box fills the visible
/// viewport with breathing room. No-op when there's no
/// active flow (nothing to fit).
void _fitToContent() {
final graph = widget.controller.graph;
final layout = widget.controller.layout;
if (widget.controller.activeName == null) return;
if (graph.steps.isEmpty && graph.inputs.isEmpty) return;
final inputsPos =
layout.positions[AutoLayout.inputsNodeId] ?? _inputsFallback;
final outputsPos =
layout.positions[AutoLayout.outputsNodeId] ?? _outputsFallback;
// Bounding box: start with the inputs + outputs endpoints
// since they're always present, then expand to include
// every step.
double minX = inputsPos.x;
double minY = inputsPos.y;
double maxX = outputsPos.x + NodeGeometry.width;
double maxY =
inputsPos.y +
NodeGeometry.heightFor(graph.inputs.length).clamp(110.0, 600.0);
if (outputsPos.x < minX) minX = outputsPos.x;
if (outputsPos.y < minY) minY = outputsPos.y;
final outputsBottom =
outputsPos.y +
NodeGeometry.heightFor(graph.outputs.length).clamp(110.0, 600.0);
if (outputsBottom > maxY) maxY = outputsBottom;
// Step nodes.
for (final step in graph.steps) {
final pos = layout.positions[step.id];
if (pos == null) continue;
if (pos.x < minX) minX = pos.x;
if (pos.y < minY) minY = pos.y;
final right = pos.x + NodeGeometry.width;
final bottom = pos.y + NodeGeometry.heightFor(step.with_.length);
if (right > maxX) maxX = right;
if (bottom > maxY) maxY = bottom;
}
// Padding so nodes don't kiss the viewport edge.
const pad = 80.0;
minX -= pad;
minY -= pad;
maxX += pad;
maxY += pad;
final boxW = maxX - minX;
final boxH = maxY - minY;
final size = (context.findRenderObject() as RenderBox?)?.size;
if (size == null || size.width <= 0 || size.height <= 0) return;
final scale = (size.width / boxW).clamp(0.0, 2.0).toDouble();
final scale2 = (size.height / boxH).clamp(0.0, 2.0).toDouble();
final finalScale = scale < scale2 ? scale : scale2;
final tx = -minX * finalScale + (size.width - boxW * finalScale) / 2;
final ty = -minY * finalScale + (size.height - boxH * finalScale) / 2;
_transform.value = Matrix4.identity()
..translateByDouble(tx, ty, 0, 1)
..scaleByDouble(finalScale, finalScale, 1, 1);
}
// --- Node positioning + drag ---
Widget _stepPositioned(FlowStep step, FlowLayout layout) {
final pos = layout.positions[step.id];
if (pos == null) return const SizedBox.shrink();
final selected = widget.controller.selectedStepId == step.id;
final raw = widget.controller.stepStatuses[step.id] ?? StepRunStatus.idle;
final status = _toNodeStatus(raw);
// How many with-fields carry a wired-up `$src.field`
// expression. Drives the header's "n/total" badge so the
// operator can see at a glance whether the module is
// fully connected.
final wired = step.with_.values
.whereType<Object>()
.where((v) => _isWiredExpression(v.toString()))
.length;
return Positioned(
left: pos.x,
top: pos.y,
child: FlowNode(
id: step.id,
title: step.id,
subtitle: step.use,
inputPortLabels: step.with_.keys.toList(),
wiredCount: wired,
kind: kindForStep(step),
selected: selected,
status: status,
onTap: () => widget.controller.selectStep(step.id),
onDrag: (delta) => _applyDrag(
step.id,
pos,
delta,
NodeGeometry.heightFor(step.with_.length),
),
onContextMenu: (globalPos) => _showStepContextMenu(step, globalPos),
),
);
}
/// Endpoint nodes (inputs / outputs) live on the canvas
/// just like step nodes — same drag handler, same position
/// stored in the layout sidecar. Difference: no select
/// affordance (no per-step properties to edit) and no
/// status indicator (endpoints don't run).
Widget _endpointPositioned({
required String nodeId,
required NodePosition pos,
required String title,
required NodeVisualKind kind,
required NodePortSide portSide,
required List<String> labels,
}) {
final selected = widget.controller.selectedStepId == nodeId;
return Positioned(
left: pos.x,
top: pos.y,
child: FlowNode(
id: nodeId,
title: title,
kind: kind,
portSide: portSide,
inputPortLabels: labels,
selected: selected,
// Endpoints are selectable too — selecting opens
// the inputs / outputs editor in the properties
// panel so the operator can rename, retype, or add
// entries graphically instead of editing YAML.
onTap: () => widget.controller.selectStep(nodeId),
onDrag: (delta) => _applyDrag(
nodeId,
pos,
delta,
NodeGeometry.heightFor(labels.length),
),
),
);
}
/// Single drag entry point used by every node on the
/// canvas. Converts a screen-space delta to canvas-space
/// (via the current InteractiveViewer scale), clamps the
/// new position to the canvas bounds, and forwards to the
/// controller which persists to the sidecar.
void _applyDrag(
String nodeId,
NodePosition current,
Offset delta,
double nodeHeight,
) {
final scale = _transform.value.getMaxScaleOnAxis();
final scaledDelta = delta / scale;
final newPos = NodePosition(
(current.x + scaledDelta.dx).clamp(
0.0,
_canvasWidth - NodeGeometry.width,
),
(current.y + scaledDelta.dy).clamp(0.0, _canvasHeight - nodeHeight),
);
widget.controller.moveStep(nodeId, newPos);
}
// --- Port positions in canvas coordinates ---
/// Right-edge output port for a step node.
Offset _outputPortPosition(String nodeId, FlowLayout layout) {
final pos = layout.positions[nodeId];
if (pos == null) return Offset.zero;
return Offset(
pos.x + NodeGeometry.width,
pos.y + NodeGeometry.outputPortY(),
);
}
/// Left-edge input port for any node. Works for step nodes
/// AND the outputs endpoint — both have input ports on
/// their left, both have a layout position.
Offset _inputPortPosition(String nodeId, int portIndex, FlowLayout layout) {
final pos = layout.positions[nodeId];
if (pos == null) return Offset.zero;
return Offset(pos.x, pos.y + NodeGeometry.inputPortY(portIndex));
}
/// Inputs endpoint exposes one port per declared input on
/// its RIGHT edge — every declared input is a "source" of
/// data that downstream steps can read from.
Offset _inputsEndpointPortPosition(int portIndex, FlowLayout layout) {
final pos = layout.positions[AutoLayout.inputsNodeId] ?? _inputsFallback;
return Offset(
pos.x + NodeGeometry.width,
pos.y + NodeGeometry.inputPortY(portIndex),
);
}
// --- Edge build (graph -> render segments) ---
List<EdgeSegment> _buildSegments(FlowGraph graph, FlowLayout layout) {
final out = <EdgeSegment>[];
final inputsList = graph.inputs.keys.toList();
for (final edge in graph.edges) {
Offset? from;
Offset? to;
EdgeSide? fromSide;
EdgeSide? toSide;
if (edge.fromKind == EdgeEndpointKind.inputs) {
final idx = inputsList.indexOf(edge.fromField);
if (idx >= 0) {
from = _inputsEndpointPortPosition(idx, layout);
// Inputs endpoint ports live on the node's right edge
// — that's where the dot sits, and where the bezier
// should originate.
fromSide = EdgeSide.right;
}
} else if (edge.fromKind == EdgeEndpointKind.step) {
from = _outputPortPosition(edge.fromId, layout);
// Step output is on the right edge.
fromSide = EdgeSide.right;
}
if (edge.toKind == EdgeEndpointKind.step) {
final step = graph.steps.firstWhere(
(s) => s.id == edge.toId,
orElse: () => const FlowStep(id: '__missing__', use: ''),
);
final idx = step.with_.keys.toList().indexOf(edge.toField);
if (idx >= 0) {
to = _inputPortPosition(edge.toId, idx, layout);
toSide = EdgeSide.left;
}
} else if (edge.toKind == EdgeEndpointKind.outputs) {
final outputsList = graph.outputs.keys.toList();
final idx = outputsList.indexOf(edge.toField);
if (idx >= 0) {
to = _inputPortPosition(AutoLayout.outputsNodeId, idx, layout);
toSide = EdgeSide.left;
}
}
if (from == null || to == null || fromSide == null || toSide == null) {
continue;
}
final highlight =
edge.fromId == widget.controller.selectedStepId ||
edge.toId == widget.controller.selectedStepId;
out.add(
EdgeSegment(
from: from,
to: to,
fromSide: fromSide,
toSide: toSide,
accent: highlight ? EdgeAccent.highlight : EdgeAccent.normal,
),
);
}
return out;
}
// --- Port overlays (drag handles for creating edges) ---
Iterable<Widget> _portOverlays(FlowGraph graph, FlowLayout layout) sync* {
// Compute the connected-port set once per build so every
// port dot can render filled or outlined based on real
// wiring state. Keyed by "nodeId:fieldName" both sides.
final connectedPorts = _connectedPorts(graph);
// Output ports — step nodes' right edges.
for (final step in graph.steps) {
final p = _outputPortPosition(step.id, layout);
final key = '${step.id}:__out__';
yield _portDot(
portKey: key,
center: p,
isSource: true,
connected: connectedPorts.contains(key),
accent: Theme.of(context).colorScheme.primary,
onDragStart: () => _draft = _ConnectionDraft(
fromKind: _DraftSourceKind.step,
fromId: step.id,
from: p,
cursor: p,
),
);
}
// Inputs endpoint output ports — one per declared input.
final inputsList = graph.inputs.keys.toList();
for (var i = 0; i < inputsList.length; i++) {
final p = _inputsEndpointPortPosition(i, layout);
final fieldName = inputsList[i];
final input = graph.inputs[fieldName]!;
final key = 'inputs:$fieldName';
yield _portDot(
portKey: key,
center: p,
isSource: true,
connected: connectedPorts.contains(key),
accent: _typeAccent(input.type, Theme.of(context)),
onDragStart: () => _draft = _ConnectionDraft(
fromKind: _DraftSourceKind.inputsField,
fromId: fieldName,
from: p,
cursor: p,
),
);
}
// Step input port targets — left edges. Right-click on a
// wired port opens the disconnect menu.
for (final step in graph.steps) {
final keys = step.with_.keys.toList();
for (var i = 0; i < keys.length; i++) {
final p = _inputPortPosition(step.id, i, layout);
final field = keys[i];
final value = step.with_[field]?.toString() ?? '';
final wired = _isWiredExpression(value);
yield _portDot(
portKey: '${step.id}:$field',
center: p,
isSource: false,
connected: wired,
accent: Theme.of(context).colorScheme.primary,
onContextMenu: !wired
? null
: (pos) => _disconnectInputPort(step.id, field, pos),
);
}
}
// Outputs endpoint input ports — same disconnect treatment.
final outs = graph.outputs.keys.toList();
for (var i = 0; i < outs.length; i++) {
final p = _inputPortPosition(AutoLayout.outputsNodeId, i, layout);
final field = outs[i];
final expr = graph.outputs[field] ?? '';
final wired = _isWiredExpression(expr);
yield _portDot(
portKey: 'outputs:$field',
center: p,
isSource: false,
connected: wired,
accent: Theme.of(context).colorScheme.primary,
onContextMenu: !wired
? null
: (pos) => _disconnectOutputPort(field, pos),
);
}
}
/// True when [expression] looks like a `$src.field` ref
/// (the canonical wired-up form). Literal text or empty
/// values count as unwired.
bool _isWiredExpression(String expression) {
return RegExp(
r'\$[A-Za-z_][A-Za-z0-9_-]*\.[A-Za-z_][A-Za-z0-9_-]*',
).hasMatch(expression);
}
/// All port keys that participate in an edge. Used to
/// decide whether each port dot renders filled (connected)
/// or outlined (dangling).
Set<String> _connectedPorts(FlowGraph graph) {
final set = <String>{};
for (final edge in graph.edges) {
// FROM side
if (edge.fromKind == EdgeEndpointKind.inputs) {
set.add('inputs:${edge.fromField}');
} else if (edge.fromKind == EdgeEndpointKind.step) {
// Step's output is wired if ANY edge leaves it.
set.add('${edge.fromId}:__out__');
}
// TO side
if (edge.toKind == EdgeEndpointKind.step) {
set.add('${edge.toId}:${edge.toField}');
} else if (edge.toKind == EdgeEndpointKind.outputs) {
set.add('outputs:${edge.toField}');
}
}
return set;
}
Color _typeAccent(String type, ThemeData theme) {
// Five distinct hues for the five payload types F-Delta-I
// flows declare today. Picked from a high-contrast set
// that survives both light and dark themes; intentionally
// NOT pulled exclusively from the theme's primary /
// secondary / tertiary slots because those collide once
// a custom theme plugin is active.
switch (type) {
case 'text':
return const Color(0xFF42A5F5); // blue
case 'bytes':
return const Color(0xFFFF7043); // deep orange
case 'json':
return const Color(0xFFAB47BC); // purple
case 'file':
return const Color(0xFF66BB6A); // green
case 'number':
case 'integer':
return const Color(0xFFFFCA28); // amber
default:
return theme.colorScheme.onSurfaceVariant;
}
}
Future<void> _disconnectInputPort(
String stepId,
String field,
Offset globalPos,
) async {
final action = await _showDisconnectMenu(globalPos);
if (!mounted || action != _PortAction.disconnect) return;
final graph = widget.controller.graph;
final step = graph.steps.firstWhere(
(s) => s.id == stepId,
orElse: () => const FlowStep(id: '', use: ''),
);
if (step.id.isEmpty) return;
final newWith = {...step.with_, field: ''};
widget.controller.applyGraphEdit(
graph.withStepUpdated(stepId, step.copyWith(with_: newWith)),
);
}
Future<void> _disconnectOutputPort(String field, Offset globalPos) async {
final action = await _showDisconnectMenu(globalPos);
if (!mounted || action != _PortAction.disconnect) return;
final graph = widget.controller.graph;
final next = {
for (final e in graph.outputs.entries)
e.key: e.key == field ? '' : e.value,
};
widget.controller.applyGraphEdit(
FlowGraph(
name: graph.name,
inputs: graph.inputs,
steps: graph.steps,
outputs: next,
leadingComment: graph.leadingComment,
),
);
}
Future<_PortAction?> _showDisconnectMenu(Offset globalPos) async {
final overlay =
Overlay.of(context).context.findRenderObject() as RenderBox?;
if (overlay == null) return null;
final theme = Theme.of(context);
return showMenu<_PortAction>(
context: context,
position: RelativeRect.fromRect(
Rect.fromPoints(globalPos, globalPos),
Offset.zero & overlay.size,
),
items: [
PopupMenuItem(
value: _PortAction.disconnect,
child: Row(
children: [
Icon(Icons.link_off, size: 16, color: theme.colorScheme.error),
const SizedBox(width: 8),
Text(
'Disconnect',
style: TextStyle(color: theme.colorScheme.error),
),
],
),
),
],
);
}
Widget _portDot({
required String portKey,
required Offset center,
required bool isSource,
required bool connected,
required Color accent,
VoidCallback? onDragStart,
void Function(Offset globalPos)? onContextMenu,
}) {
final theme = Theme.of(context);
final dragging = _draft != null;
final isInputDuringDrag = dragging && !isSource;
final isClosest = isInputDuringDrag && _isClosestDropTarget(center);
final isHovered = _hoveredPort == portKey;
// Size scales with focus level:
// - drop-target halo (drag is over this port) → 18 px
// - hover (mouse-over without dragging) → 15 px
// - resting → 12 px
// The hover bump is the new "this port is interactive"
// affordance Stefan asked for.
final size = isClosest
? 18.0
: (isHovered ? 15.0 : NodeGeometry.portDotSize);
// Fill rule: filled when this port participates in an
// edge, OR it's the closest drop target mid-drag. Plain
// outlined circle when neither — the operator sees at
// a glance which ports are wired.
final filled = connected || isClosest;
return Positioned(
left: center.dx - size / 2,
top: center.dy - size / 2,
width: size,
height: size,
child: MouseRegion(
cursor: isSource ? SystemMouseCursors.grab : SystemMouseCursors.cell,
onEnter: (_) => setState(() => _hoveredPort = portKey),
onExit: (_) {
if (_hoveredPort == portKey) {
setState(() => _hoveredPort = null);
}
},
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: !isSource
? null
: (details) {
onDragStart?.call();
setState(() {});
},
onPanUpdate: !isSource || _draft == null
? null
: (details) {
final scale = _transform.value.getMaxScaleOnAxis();
setState(() {
_draft = _draft!.withCursor(
_draft!.cursor + details.delta / scale,
);
});
},
onPanEnd: !isSource || _draft == null
? null
: (_) {
_finalizeDraft();
},
onSecondaryTapDown: onContextMenu == null
? null
: (details) => onContextMenu(details.globalPosition),
child: Stack(
alignment: Alignment.center,
children: [
// Outer ring — defines the port's footprint.
// Filled when connected (so the line "docks"
// into a visible target), surface-filled when
// dangling (a clear empty socket). Drop-target
// halo grows + glows.
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: filled ? accent : theme.colorScheme.surface,
border: Border.all(
color: accent,
width: isClosest ? 2.5 : (isHovered ? 2.2 : 1.8),
),
boxShadow: (isClosest || isHovered)
? [
BoxShadow(
color: accent.withValues(
alpha: isClosest ? 0.55 : 0.35,
),
blurRadius: isClosest ? 10 : 6,
),
]
: null,
),
),
// Inner pin — tiny surface-coloured dot at the
// centre when connected, giving the port the
// "socket with a pin in it" look that reads as
// an active electrical connector rather than a
// free-floating indicator. Skipped on empty
// ports so the hollow ring is unambiguous.
if (connected)
Container(
width: 4,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
shape: BoxShape.circle,
),
),
],
),
),
),
);
}
/// True if this input port is the nearest valid drop
/// target to the current draft cursor (within snap
/// distance). Used to paint the highlight halo so the
/// operator sees which port will accept the connection.
bool _isClosestDropTarget(Offset portCenter) {
final draft = _draft;
if (draft == null) return false;
final graph = widget.controller.graph;
final layout = widget.controller.layout;
const maxDist = 32.0;
double bestDist = double.infinity;
Offset? best;
for (final step in graph.steps) {
final keys = step.with_.keys.toList();
for (var i = 0; i < keys.length; i++) {
final p = _inputPortPosition(step.id, i, layout);
final d = (draft.cursor - p).distance;
if (d < bestDist && d <= maxDist) {
bestDist = d;
best = p;
}
}
}
final outs = graph.outputs.keys.toList();
for (var i = 0; i < outs.length; i++) {
final p = _inputPortPosition(AutoLayout.outputsNodeId, i, layout);
final d = (draft.cursor - p).distance;
if (d < bestDist && d <= maxDist) {
bestDist = d;
best = p;
}
}
if (best == null) return false;
return (best - portCenter).distance < 0.5;
}
void _finalizeDraft() {
final draft = _draft;
setState(() => _draft = null);
if (draft == null) return;
// Find the closest input port within tolerance.
final graph = widget.controller.graph;
final layout = widget.controller.layout;
_DropTarget? best;
double bestDist = double.infinity;
const maxDist = 32.0;
for (final step in graph.steps) {
final keys = step.with_.keys.toList();
for (var i = 0; i < keys.length; i++) {
final p = _inputPortPosition(step.id, i, layout);
final d = (draft.cursor - p).distance;
if (d < bestDist && d <= maxDist) {
bestDist = d;
best = _DropTarget(
kind: _DraftTargetKind.step,
id: step.id,
field: keys[i],
);
}
}
}
final outs = graph.outputs.keys.toList();
for (var i = 0; i < outs.length; i++) {
final p = _inputPortPosition(AutoLayout.outputsNodeId, i, layout);
final d = (draft.cursor - p).distance;
if (d < bestDist && d <= maxDist) {
bestDist = d;
best = _DropTarget(
kind: _DraftTargetKind.outputsField,
id: AutoLayout.outputsNodeId,
field: outs[i],
);
}
}
if (best == null) return;
_applyConnection(draft, best);
}
void _applyConnection(_ConnectionDraft draft, _DropTarget target) {
final graph = widget.controller.graph;
// Compose the $source.field expression. For step
// sources, we don't know the precise output field name
// (modules have varied output names); use "result" as a
// placeholder so the YAML is syntactically valid, and
// let the operator refine it in the properties panel.
final String expression;
switch (draft.fromKind) {
case _DraftSourceKind.step:
expression = '\$${draft.fromId}.result';
case _DraftSourceKind.inputsField:
expression = '\$inputs.${draft.fromId}';
}
switch (target.kind) {
case _DraftTargetKind.step:
final step = graph.steps.firstWhere((s) => s.id == target.id);
final newWith = {...step.with_, target.field: expression};
widget.controller.applyGraphEdit(
graph.withStepUpdated(target.id, step.copyWith(with_: newWith)),
);
case _DraftTargetKind.outputsField:
widget.controller.applyGraphEdit(
FlowGraph(
name: graph.name,
inputs: graph.inputs,
steps: graph.steps,
outputs: {...graph.outputs, target.field: expression},
leadingComment: graph.leadingComment,
),
);
}
}
// --- Context menu actions ---
/// Right-click menu on a step node. Duplicate creates a
/// sibling with a fresh id, same use + with-fields, placed
/// slightly offset so the operator sees both. Delete drops
/// the step entirely; any dangling refs in downstream
/// steps become run-time errors with clear messages, which
/// is by design (silently rewriting downstream YAML would
/// be more surprising than the error).
Future<void> _showStepContextMenu(FlowStep step, Offset globalPos) async {
final theme = Theme.of(context);
final overlay =
Overlay.of(context).context.findRenderObject() as RenderBox?;
if (overlay == null) return;
final result = await showMenu<_StepAction>(
context: context,
position: RelativeRect.fromRect(
Rect.fromPoints(globalPos, globalPos),
Offset.zero & overlay.size,
),
items: [
const PopupMenuItem(
value: _StepAction.duplicate,
child: Row(
children: [
Icon(Icons.copy_outlined, size: 16),
SizedBox(width: 8),
Text('Duplicate'),
],
),
),
const PopupMenuItem(
value: _StepAction.disconnectAll,
child: Row(
children: [
Icon(Icons.link_off, size: 16),
SizedBox(width: 8),
Text('Disconnect all inputs'),
],
),
),
PopupMenuItem(
value: _StepAction.delete,
child: Row(
children: [
Icon(
Icons.delete_outline,
size: 16,
color: theme.colorScheme.error,
),
const SizedBox(width: 8),
Text('Delete', style: TextStyle(color: theme.colorScheme.error)),
],
),
),
],
);
if (!mounted || result == null) return;
switch (result) {
case _StepAction.duplicate:
_duplicateStep(step);
case _StepAction.disconnectAll:
_disconnectAll(step);
case _StepAction.delete:
_deleteStep(step);
}
}
void _duplicateStep(FlowStep step) {
final graph = widget.controller.graph;
final layout = widget.controller.layout;
// Generate a unique id: <base>, <base>_2, <base>_3, ...
final existingIds = graph.steps.map((s) => s.id).toSet();
var i = 2;
var newId = '${step.id}_$i';
while (existingIds.contains(newId)) {
i++;
newId = '${step.id}_$i';
}
widget.controller.applyGraphEdit(
graph.withStepAdded(
FlowStep(
id: newId,
use: step.use,
with_: Map<String, dynamic>.from(step.with_),
),
),
);
// Offset the new node's position so it's visible next to
// the original instead of stacking on top.
final originalPos = layout.positions[step.id];
if (originalPos != null) {
widget.controller.moveStep(
newId,
NodePosition(originalPos.x + 40, originalPos.y + 40),
);
}
}
void _disconnectAll(FlowStep step) {
if (step.with_.isEmpty) return;
// Keep the with-field keys; just clear their values so
// the parameter list survives but no longer wires to any
// upstream step.
final cleared = {for (final k in step.with_.keys) k: ''};
widget.controller.applyGraphEdit(
widget.controller.graph.withStepUpdated(
step.id,
step.copyWith(with_: cleared),
),
);
}
void _deleteStep(FlowStep step) {
widget.controller.applyGraphEdit(
widget.controller.graph.withStepRemoved(step.id),
);
if (widget.controller.selectedStepId == step.id) {
widget.controller.selectStep(null);
}
}
void _resetLayout() {
widget.controller.resetLayout();
// Re-fit once layout settles so the operator sees the
// cleaned-up positions immediately.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _fitToContent();
});
}
// --- Background ---
Widget _grid(ThemeData theme) {
return Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _DotGridPainter(
color: theme.dividerColor.withValues(alpha: 0.55),
),
),
),
);
}
}
enum _StepAction { duplicate, disconnectAll, delete }
enum _PortAction { disconnect }
enum _DraftSourceKind { step, inputsField }
enum _DraftTargetKind { step, outputsField }
class _ConnectionDraft {
final _DraftSourceKind fromKind;
final String fromId;
final Offset from;
final Offset cursor;
const _ConnectionDraft({
required this.fromKind,
required this.fromId,
required this.from,
required this.cursor,
});
_ConnectionDraft withCursor(Offset c) => _ConnectionDraft(
fromKind: fromKind,
fromId: fromId,
from: from,
cursor: c,
);
}
class _DropTarget {
final _DraftTargetKind kind;
final String id;
final String field;
const _DropTarget({
required this.kind,
required this.id,
required this.field,
});
}
FlowNodeStatus _toNodeStatus(StepRunStatus s) {
return switch (s) {
StepRunStatus.idle => FlowNodeStatus.idle,
StepRunStatus.running => FlowNodeStatus.running,
StepRunStatus.done => FlowNodeStatus.done,
StepRunStatus.failed => FlowNodeStatus.failed,
StepRunStatus.awaiting => FlowNodeStatus.awaiting,
};
}
class _DotGridPainter extends CustomPainter {
final Color color;
_DotGridPainter({required this.color});
@override
void paint(Canvas canvas, Size size) {
const spacing = 24.0;
final paint = Paint()..color = color;
for (double x = 0; x < size.width; x += spacing) {
for (double y = 0; y < size.height; y += spacing) {
canvas.drawCircle(Offset(x, y), 0.8, paint);
}
}
}
@override
bool shouldRepaint(_DotGridPainter old) => old.color != color;
}