// 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 createState() => _FlowCanvasState(); } class _FlowCanvasState extends State { 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() .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 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 _buildSegments(FlowGraph graph, FlowLayout layout) { final out = []; 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 _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 _connectedPorts(FlowGraph graph) { final set = {}; 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 _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 _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 _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: , _2, _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.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; }