// 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 '../editor_style.dart'; import '../l10n.dart'; import '../model/auto_layout.dart'; import '../model/flow_graph.dart'; import '../model/layout_store.dart'; import '../run_driver.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; final FaiEditorStyle style; /// Bridge to the hub. The canvas calls `driver.moduleInfo` /// per step's `use:` capability to render declared input + /// output ports with their canonical field names + i18n /// tooltips. When null (e.g. test harness), the canvas falls /// back to YAML-derived port labels and single-anchor outputs. final FlowRunDriver? driver; /// Active operator locale, used to pick the right peer from /// each field's `description.` map for port /// tooltips. "en" is the default; "de" is the supported /// peer today. final FlowEditorLocale locale; const FlowCanvas({ super.key, required this.controller, this.driver, this.locale = FlowEditorLocale.en, this.style = FaiEditorStyle.modern, }); @override State createState() => _FlowCanvasState(); } class _FlowCanvasState extends State with SingleTickerProviderStateMixin { final TransformationController _transform = TransformationController(); /// Continuous 0..1 loop that drives the marching-dash /// animation on edges entering currently-running steps. /// Stopped when no step is running so we don't burn frame /// time on flows that are sitting idle. late final AnimationController _flowAnim; // 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; // Currently-hovered edge — identified by `toId:toField` // (the target side, which is what gets cleared on // disconnect). When non-null, that edge renders with the // highlight accent and is the target of right-click / // long-press menus on the empty canvas area. String? _hoveredEdge; // (intentionally no cursor cache here — the hit-test reads // event.localPosition directly each frame.) // Operator-chosen background pattern. Cycles via the // pattern button on the bottom-right canvas controls. _CanvasPattern _pattern = _CanvasPattern.dots; // Effective style for this build pass — `widget.style` // (or _styleOverride) clamped against MediaQuery's // reduce-motion preference. Set at the start of every // build so subsequent helpers (_buildSegments, // _stepPositioned, etc.) read a single coherent style // instead of separately querying the source + OS // preference. FaiEditorStyle _style = FaiEditorStyle.modern; // Operator's runtime style override — set by the // bottom-right ⚙ Style sheet. When non-null, replaces // `widget.style` for the lifetime of the canvas state. // null = follow whatever the host passed. FaiEditorStyle? _styleOverride; // ModuleSpec cache: capability identifier → declared // inputs/outputs. Populated lazily on each build by // walking the current graph's `step.use` values and // dispatching a host-side moduleInfo() lookup for any // capability not yet seen. The map value is null while // the lookup is in flight or has resolved to "no info" // (legacy v1/v2 manifest); the renderer falls back to // YAML-derived ports in that case. final Map _moduleSpecs = {}; // Capabilities whose moduleInfo() call is already in // flight, so we don't re-trigger on every rebuild. final Set _moduleSpecsInFlight = {}; // Current zoom level (taken from the TransformationController) // so the bottom-right indicator can show it as a %. double _zoom = 1.0; @override void initState() { super.initState(); widget.controller.addListener(_onControllerChanged); // Watch the transformation controller so the zoom // indicator at the bottom-right of the canvas can update // live as the operator pinches / scrolls. _transform.addListener(_onTransformChanged); _flowAnim = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), ); } @override void dispose() { widget.controller.removeListener(_onControllerChanged); _transform.removeListener(_onTransformChanged); _transform.dispose(); _flowAnim.dispose(); super.dispose(); } void _onTransformChanged() { final z = _transform.value.getMaxScaleOnAxis(); if ((z - _zoom).abs() > 0.005) { setState(() => _zoom = z); } } /// Walk the current graph's steps and kick off a /// `driver.moduleInfo` lookup for any `use:` capability /// we haven't seen yet. Results land in `_moduleSpecs` /// and trigger a rebuild so the next paint draws the /// per-field input/output ports. void _refreshModuleSpecs(FlowGraph graph) { final driver = widget.driver; if (driver == null) return; for (final step in graph.steps) { final cap = step.use; if (cap.isEmpty) continue; // Strip the @version suffix — moduleInfo looks up by // capability name, not by capability ref. final name = cap.split('@').first; if (_moduleSpecs.containsKey(name) || _moduleSpecsInFlight.contains(name)) { continue; } _moduleSpecsInFlight.add(name); // ignore: discarded_futures driver .moduleInfo(name) .then((spec) { if (!mounted) return; setState(() { _moduleSpecsInFlight.remove(name); _moduleSpecs[name] = spec; }); }) .catchError((Object _) { if (!mounted) return; setState(() { _moduleSpecsInFlight.remove(name); // Record absence so we don't retry forever // when the hub doesn't know this capability. _moduleSpecs[name] = null; }); }); } } /// Look up the cached ModuleSpec for a step's capability, /// stripping the `@version` suffix. ModuleSpec? _specForStep(FlowStep step) { final cap = step.use.split('@').first; return _moduleSpecs[cap]; } /// Type descriptor of a step's input field, when known. /// Returns null when neither the resolved ModuleSpec nor /// the YAML can determine it — the editor then leaves the /// drop target compatible-with-anything so a missing /// manifest doesn't break composition. String? _stepInputType(FlowStep step, String fieldName) { final spec = _specForStep(step); if (spec != null) { for (final f in spec.inputs) { if (f.name == fieldName && f.type.isNotEmpty) return f.type; } } return null; } /// Type descriptor of a step's output field. ModuleSpec /// is the only authority for declared outputs; for /// YAML-implied outputs (no manifest) we return null so /// the connection layer treats them as /// compatible-with-everything. String? _stepOutputType(FlowStep step, String fieldName) { final spec = _specForStep(step); if (spec != null) { for (final f in spec.outputs) { if (f.name == fieldName && f.type.isNotEmpty) return f.type; } } return null; } /// Resolve the type of the source the operator is dragging /// FROM. Used by drop-target predicates to refuse wires /// that would couple incompatible types. String? _typeAtDraftSource() { final draft = _draft; if (draft == null) return null; switch (draft.fromKind) { case _DraftSourceKind.inputsField: return widget.controller.graph.inputs[draft.fromId]?.type; case _DraftSourceKind.step: if (draft.fromField.isEmpty) return null; final step = widget.controller.graph.steps.firstWhere( (s) => s.id == draft.fromId, orElse: () => const FlowStep(id: '__missing__', use: ''), ); if (step.id == '__missing__') return null; return _stepOutputType(step, draft.fromField); } } /// True when [sourceType] can flow into [targetType]. Same /// type matches; unknown on either side matches everything /// (graceful degradation when the manifest hasn't shipped /// or when the field is implicit from the YAML alone). /// Different known types are refused. bool _typesCompatible(String? sourceType, String? targetType) { if (sourceType == null || sourceType.isEmpty) return true; if (targetType == null || targetType.isEmpty) return true; return sourceType == targetType; } /// Merge ModuleSpec-declared input names with whatever the /// flow YAML's `with_:` block carries that the manifest /// didn't declare. Declared inputs keep their manifest /// order; implicit ones land alphabetically after. Same /// list used by [_stepPositioned] for rendering and by /// [_buildSegments] / [_hitTestEdge] for port positions. List _inputLabelsForStep(FlowStep step) { final spec = _specForStep(step); final declared = spec?.inputs.map((f) => f.name).toList() ?? const []; final implicit = step.with_.keys.where((k) => !declared.contains(k)).toList()..sort(); return [...declared, ...implicit]; } /// Width to render a step card with. Grows beyond the /// default minimum when port labels would otherwise be /// ellipsis-clipped. Returns the value all geometry /// helpers (output-port position, fit-to-content, /// drop-target hit-tests) should use for that step. double _stepWidth(FlowStep step) { final ins = _inputLabelsForStep(step); final outs = _outputLabelsForStep(step); var maxIn = 0; for (final l in ins) { if (l.length > maxIn) maxIn = l.length; } var maxOut = 0; for (final l in outs) { if (l.length > maxOut) maxOut = l.length; } return NodeGeometry.widthFor(maxInputChars: maxIn, maxOutputChars: maxOut); } /// Merge ModuleSpec-declared output names with whatever /// the flow YAML's edges reference for this step. Declared /// outputs keep their manifest order; implicit (referenced /// but undeclared) outputs land alphabetically after. Lets /// the editor render per-field output anchors even when the /// hub hasn't shipped a manifest (built-ins like /// system.approval, or older hub binaries). List _outputLabelsForStep(FlowStep step) { final spec = _specForStep(step); final declared = spec?.outputs.map((f) => f.name).toList() ?? const []; final implicit = {}; for (final edge in widget.controller.graph.edges) { if (edge.fromKind == EdgeEndpointKind.step && edge.fromId == step.id && edge.fromField.isNotEmpty && !declared.contains(edge.fromField)) { implicit.add(edge.fromField); } } return [...declared, ...(implicit.toList()..sort())]; } void _onControllerChanged() { if (!mounted) return; // Start the flow-animation controller only while a step // is actually running; stop it otherwise so we don't // tick every frame on idle flows. final anyRunning = widget.controller.stepStatuses.values.any( (s) => s == StepRunStatus.running, ); if (anyRunning && !_flowAnim.isAnimating) { _flowAnim.repeat(); } else if (!anyRunning && _flowAnim.isAnimating) { _flowAnim.stop(); _flowAnim.value = 0; } setState(() {}); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final graph = widget.controller.graph; final layout = widget.controller.layout; // Clamp the host-supplied style against the OS reduce- // motion preference. Operators on accessibility settings // — or kiosk-mode deployments — get the same editor with // GPU-heavy blur, gradients, and dash animation switched // off. Helpers below read `_style` instead of `widget.style` // so the effective shape is single-sourced. _style = (_styleOverride ?? widget.style).clampedForA11y( disableAnimations: MediaQuery.disableAnimationsOf(context), ); // Best-effort lazy module-spec fetch so the per-field // input/output ports paint on the next frame. Cheap when // every spec is already cached; only triggers async work // for genuinely new capabilities. _refreshModuleSpecs(graph); // 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( // Backdrop honours the active editor style. Default is // a subtle two-stop diagonal gradient that gives the // canvas depth; "flat" preset drops it for the older // single-surface look (lower visual chrome, useful for // low-spec terminals). decoration: _style.canvasBackdrop == EditorCanvasBackdrop.gradient ? BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ theme.colorScheme.surfaceContainer, theme.colorScheme.surface, ], ), ) : BoxDecoration(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: AnimatedBuilder( animation: _flowAnim, builder: (context, _) => 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, phase: _flowAnim.value, ), ), ), ), ), // Edge interaction layer — sits BEHIND nodes // in the Stack (which means nodes get hit- // tested first for right-clicks etc.), but // catches mouse hover globally + secondary- // tap / long-press anywhere the click misses // a node. Each cursor-move runs the spatial // hit-test against every edge's sampled path // and marks the closest one as hovered. Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.translucent, // Tap on the canvas background (no node, // no port, no edge) deselects the active // step — closes the properties panel. // Tapping a node's hit area is consumed // by the node's own GestureDetector // (opaque), so this fires only on misses. // Edges shadow the same hit-test footprint // via _hitTestEdge; if the cursor was over // an edge we let the edge keep its hover / // context-menu treatment instead of // deselecting. onTapUp: (details) { final edge = _hitTestEdge( details.localPosition, graph, layout, ); if (edge == null) { widget.controller.selectStep(null); } }, onSecondaryTapDown: (details) { final edge = _hitTestEdge( details.localPosition, graph, layout, ); if (edge != null) { _showEdgeContextMenu(edge, details.globalPosition); } }, onLongPressStart: (details) { final edge = _hitTestEdge( details.localPosition, graph, layout, ); if (edge != null) { _showEdgeContextMenu(edge, details.globalPosition); } }, child: MouseRegion( onHover: (event) { final newHover = _hitTestEdge( event.localPosition, graph, layout, ); if (newHover != _hoveredEdge) { setState(() => _hoveredEdge = newHover); } }, onExit: (_) { if (_hoveredEdge != null) { setState(() => _hoveredEdge = null); } }, child: const SizedBox.expand(), ), ), ), // 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. // - Pattern: cycles dots → grid → blank. // - Reset layout: AutoLayout regenerates positions. // - Fit to screen: recentres on every node. // - Zoom indicator: current scale as a %. 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: [ // Pattern dropdown — pick directly instead // of cycling. Faster when the operator // already knows which look they want. PopupMenuButton<_CanvasPattern>( icon: Icon(_patternIcon(), size: 18), tooltip: 'Background', initialValue: _pattern, onSelected: (p) => setState(() => _pattern = p), itemBuilder: (_) => [ _patternMenuItem( _CanvasPattern.dots, Icons.grain, 'Dots', ), _patternMenuItem( _CanvasPattern.grid, Icons.grid_on, 'Grid', ), _patternMenuItem( _CanvasPattern.modern, Icons.window_outlined, 'Modern', ), _patternMenuItem( _CanvasPattern.classic, Icons.grid_3x3, 'Classic', ), _patternMenuItem( _CanvasPattern.blueprint, Icons.architecture, 'Blueprint', ), _patternMenuItem( _CanvasPattern.minimal, Icons.density_small, 'Minimal', ), _patternMenuItem( _CanvasPattern.blank, Icons.layers_clear, 'Blank', ), ], ), IconButton( onPressed: _openStyleSheet, icon: const Icon(Icons.tune, size: 18), tooltip: 'Style', visualDensity: VisualDensity.compact, ), 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, ), // Zoom % dropdown — preset levels (25 % … // 200 %) plus "Fit". Faster than scroll- // wheel zooming to a specific level. PopupMenuButton( tooltip: 'Zoom', onSelected: _setZoom, itemBuilder: (_) => [ _zoomItem(0.25), _zoomItem(0.5), _zoomItem(0.75), _zoomItem(1.0), _zoomItem(1.25), _zoomItem(1.5), _zoomItem(2.0), const PopupMenuDivider(), const PopupMenuItem( value: -1.0, child: Row( children: [ Icon(Icons.fit_screen_outlined, size: 16), SizedBox(width: 8), Text('Fit to screen'), ], ), ), ], child: Padding( padding: const EdgeInsets.symmetric( horizontal: FaiSpace.sm, vertical: 6, ), child: Text( '${(_zoom * 100).round()}%', style: theme.textTheme.labelSmall?.copyWith( fontFamily: 'monospace', color: theme.colorScheme.onSurfaceVariant, ), ), ), ), ], ), ), ), ], ), ); } /// 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 + _stepWidth(step); final bottom = pos.y + NodeGeometry.heightFor( _inputLabelsForStep(step).length, _outputLabelsForStep(step).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; // Per-field input/output ports: merge whatever the // ModuleSpec from the hub declares with whatever the // flow YAML actually references. The union ensures we // render ports even when the hub hasn't shipped a // manifest (built-in capabilities like system.approval), // when the hub binary is older than the per-field-port // RPC, or when the operator hasn't re-installed the // module to pick up its new schema_version 3 manifest. // // Declared fields keep their manifest order (alphabetical // server-side). Implicit fields (those that show up in // the YAML but aren't declared) are appended alphabetically // afterwards — so the layout stays stable when a new // manifest later lands and adds them officially. final spec = _specForStep(step); final inputLabels = _inputLabelsForStep(step); final outputLabels = _outputLabelsForStep(step); // Tooltips: every visible label gets one. When the // manifest carries a locale description, use it. Else // fall back to `name (type)` for declared fields whose // description is missing, or to the bare field name for // implicit (YAML-only) fields. Operators always see // something on hover — useful at least to confirm the // exact field they're pointing at. final tooltips = {}; final loc = widget.locale == FlowEditorLocale.de ? 'de' : 'en'; final declaredFields = {}; if (spec != null) { for (final f in [...spec.inputs, ...spec.outputs]) { declaredFields[f.name] = f; } } for (final label in [...inputLabels, ...outputLabels]) { final f = declaredFields[label]; if (f != null) { final d = f.descriptionFor(loc); if (d != null && d.isNotEmpty) { tooltips[label] = d; } else if (f.type.isNotEmpty) { tooltips[label] = '$label · ${f.type}'; } else { tooltips[label] = label; } } else { tooltips[label] = label; } } final cardHeight = NodeGeometry.heightFor( inputLabels.length, outputLabels.length, ); final cardWidth = _stepWidth(step); return Positioned( left: pos.x, top: pos.y, child: FlowNode( width: cardWidth, id: step.id, title: step.id, subtitle: step.use, inputPortLabels: inputLabels, outputPortLabels: outputLabels, portTooltips: tooltips, wiredCount: wired, kind: kindForStep(step), selected: selected, status: status, elevated: _style.nodeShadows, // Breathing-pulse on running steps when the active // style allows flow animation. The canvas's existing // _flowAnim ticks 0..1 during runs and is gated on // the same accessibility / style preferences, so we // can route it straight through. pulse: _style.flowAnimation && status == FlowNodeStatus.running ? _flowAnim : null, onTap: () => widget.controller.selectStep(step.id), onDrag: (delta) => _applyDrag(step.id, pos, delta, cardHeight), 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. When the step's /// ModuleSpec has been resolved AND [fieldName] is one of /// its declared outputs, the port lands at that row; /// otherwise it falls back to the legacy header-centred /// anchor (single output per step). Offset _outputPortPosition( String nodeId, FlowLayout layout, { String? fieldName, }) { final pos = layout.positions[nodeId]; if (pos == null) return Offset.zero; final step = widget.controller.graph.steps.firstWhere( (s) => s.id == nodeId, orElse: () => const FlowStep(id: '__missing__', use: ''), ); final labels = _outputLabelsForStep(step); double y; if (fieldName != null && labels.isNotEmpty) { final idx = labels.indexOf(fieldName); if (idx >= 0) { y = NodeGeometry.outputPortY(idx); } else { y = NodeGeometry.outputAnchorY(); } } else { y = NodeGeometry.outputAnchorY(); } // Use the step's actual rendered width so the port dot // hugs the right edge of cards that grew wider to fit // long labels (e.g. model_endpoint / source_language). final w = step.id == '__missing__' ? NodeGeometry.width : _stepWidth(step); return Offset(pos.x + w, pos.y + y); } /// 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, fieldName: edge.fromField, ); // 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 = _inputLabelsForStep(step).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 edgeKey = '${edge.toId}:${edge.toField}'; final highlight = _hoveredEdge == edgeKey || edge.fromId == widget.controller.selectedStepId || edge.toId == widget.controller.selectedStepId; // Type-aware wire colours at both endpoints. Inputs- // endpoint side carries the declared input type's // accent; step-output side reads the ModuleSpec when // resolved so the wire's target colour matches the // declared output type. With both ends typed, the // painter draws a source→target gradient — operators // read flow direction from colour alone, no arrow // heads needed. final theme2 = Theme.of(context); Color? wireColorStart; if (edge.fromKind == EdgeEndpointKind.inputs) { final input = graph.inputs[edge.fromField]; if (input != null) { wireColorStart = _typeAccent(input.type, theme2); } } else if (edge.fromKind == EdgeEndpointKind.step) { final fromStep = graph.steps.firstWhere( (s) => s.id == edge.fromId, orElse: () => const FlowStep(id: '__missing__', use: ''), ); final fromSpec = _specForStep(fromStep); if (fromSpec != null) { final f = fromSpec.outputs.firstWhere( (f) => f.name == edge.fromField, orElse: () => const ModuleField(name: '', type: ''), ); if (f.type.isNotEmpty) { wireColorStart = _typeAccent(f.type, theme2); } } } Color? wireColorEnd; // outputs endpoint has no declared type of its own — // it's a pass-through. The wire reads as the source // type, so we leave wireColorEnd null and the painter // collapses to a solid wire. if (edge.toKind == EdgeEndpointKind.step) { final toStep = graph.steps.firstWhere( (s) => s.id == edge.toId, orElse: () => const FlowStep(id: '__missing__', use: ''), ); final toSpec = _specForStep(toStep); if (toSpec != null) { final f = toSpec.inputs.firstWhere( (f) => f.name == edge.toField, orElse: () => const ModuleField(name: '', type: ''), ); if (f.type.isNotEmpty) { wireColorEnd = _typeAccent(f.type, theme2); } } } // Edge is "live" when its target step is currently // executing AND the active style allows flow animation // (operators on reduce-motion preferences get a static // edge during runs). final targetStatus = edge.toKind == EdgeEndpointKind.step ? widget.controller.stepStatuses[edge.toId] : null; final animated = _style.flowAnimation && targetStatus == StepRunStatus.running; // Hover / selection always wins the colour treatment // — operators need a clear "I'm looking at this one" // signal that overrides the type colour. final color = highlight ? null : (wireColorStart ?? wireColorEnd); final colorEnd = highlight ? null : (wireColorEnd ?? wireColorStart); // Only attach the gradient end-colour when both sides // resolved to a real type AND they differ. Same colour // both ends → solid wire (the painter's existing path). final gradientEnd = (color != null && colorEnd != null && color != colorEnd) ? colorEnd : null; out.add( EdgeSegment( from: from, to: to, fromSide: fromSide, toSide: toSide, accent: highlight ? EdgeAccent.highlight : EdgeAccent.normal, color: color, colorEnd: gradientEnd, animated: animated, ), ); } return out; } // --- Edge hit-testing --- /// Hit-test the cursor's canvas position against every /// edge in the graph. Returns the target-key /// (`:`) of the closest edge within /// [hitThreshold] canvas pixels, or null if no edge is /// near enough. /// /// Each edge's curve is sampled at 24 points and we test /// distance to every sample. For the flow sizes operators /// work with (dozens of edges max), this is fast enough to /// run on every mouse-move frame. String? _hitTestEdge( Offset cursorCanvas, FlowGraph graph, FlowLayout layout, ) { const hitThreshold = 8.0; String? bestKey; double bestDist = double.infinity; final inputsList = graph.inputs.keys.toList(); for (final edge in graph.edges) { Offset? from; Offset? to; if (edge.fromKind == EdgeEndpointKind.inputs) { final idx = inputsList.indexOf(edge.fromField); if (idx >= 0) from = _inputsEndpointPortPosition(idx, layout); } else if (edge.fromKind == EdgeEndpointKind.step) { from = _outputPortPosition( edge.fromId, layout, fieldName: edge.fromField, ); } if (edge.toKind == EdgeEndpointKind.step) { final step = graph.steps.firstWhere( (s) => s.id == edge.toId, orElse: () => const FlowStep(id: '__missing__', use: ''), ); final idx = _inputLabelsForStep(step).indexOf(edge.toField); if (idx >= 0) to = _inputPortPosition(edge.toId, idx, layout); } else if (edge.toKind == EdgeEndpointKind.outputs) { final outs = graph.outputs.keys.toList(); final idx = outs.indexOf(edge.toField); if (idx >= 0) { to = _inputPortPosition(AutoLayout.outputsNodeId, idx, layout); } } if (from == null || to == null) continue; final samples = _sampleEdgePath(from, to); for (final sample in samples) { final d = (cursorCanvas - sample).distance; if (d < bestDist && d <= hitThreshold) { bestDist = d; bestKey = '${edge.toId}:${edge.toField}'; } } } return bestKey; } /// Sample 24 points along the cubic bezier path used by /// EdgePainter. The same formula is replicated here so the /// hit-test geometry matches what's drawn pixel-for-pixel /// — when the renderer changes, this needs to follow. List _sampleEdgePath(Offset from, Offset to) { final dx = (to.dx - from.dx).abs(); final handleLen = (dx / 2).clamp(60.0, 260.0); final cp1 = Offset(from.dx + handleLen, from.dy); final cp2 = Offset(to.dx - handleLen, to.dy); const samples = 24; final pts = []; for (var i = 0; i <= samples; i++) { final t = i / samples; final mt = 1 - t; final x = mt * mt * mt * from.dx + 3 * mt * mt * t * cp1.dx + 3 * mt * t * t * cp2.dx + t * t * t * to.dx; final y = mt * mt * mt * from.dy + 3 * mt * mt * t * cp1.dy + 3 * mt * t * t * cp2.dy + t * t * t * to.dy; pts.add(Offset(x, y)); } return pts; } /// Open the Disconnect popup for an edge. The edge is /// identified by its target-key (`:`); we /// split it back into the parts the action needs. Future _showEdgeContextMenu(String edgeKey, Offset globalPos) async { final overlay = Overlay.of(context).context.findRenderObject() as RenderBox?; if (overlay == null) return; final theme = Theme.of(context); final result = await showMenu<_EdgeAction>( context: context, position: RelativeRect.fromRect( Rect.fromPoints(globalPos, globalPos), Offset.zero & overlay.size, ), items: [ PopupMenuItem( value: _EdgeAction.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), ), ], ), ), ], ); if (!mounted || result != _EdgeAction.disconnect) return; final colon = edgeKey.indexOf(':'); if (colon < 0) return; final toId = edgeKey.substring(0, colon); final toField = edgeKey.substring(colon + 1); _disconnectEdgeTarget(toId, toField); } void _disconnectEdgeTarget(String toId, String toField) { final graph = widget.controller.graph; if (toId == AutoLayout.outputsNodeId) { final next = { for (final e in graph.outputs.entries) e.key: e.key == toField ? '' : e.value, }; widget.controller.applyGraphEdit( FlowGraph( name: graph.name, inputs: graph.inputs, steps: graph.steps, outputs: next, leadingComment: graph.leadingComment, ), ); } else { final step = graph.steps.firstWhere( (s) => s.id == toId, orElse: () => const FlowStep(id: '', use: ''), ); if (step.id.isEmpty) return; final newWith = {...step.with_, toField: ''}; widget.controller.applyGraphEdit( graph.withStepUpdated(toId, step.copyWith(with_: newWith)), ); } } // --- Canvas chrome controls --- IconData _patternIcon() { return switch (_pattern) { _CanvasPattern.dots => Icons.grain, _CanvasPattern.grid => Icons.grid_on, _CanvasPattern.blank => Icons.layers_clear, _CanvasPattern.modern => Icons.window_outlined, _CanvasPattern.classic => Icons.grid_3x3, _CanvasPattern.blueprint => Icons.architecture, _CanvasPattern.minimal => Icons.density_small, }; } PopupMenuItem<_CanvasPattern> _patternMenuItem( _CanvasPattern value, IconData icon, String label, ) { final selected = _pattern == value; return PopupMenuItem( value: value, child: Row( children: [ Icon(icon, size: 16), const SizedBox(width: 8), Text(label), if (selected) ...[const Spacer(), const Icon(Icons.check, size: 14)], ], ), ); } PopupMenuItem _zoomItem(double level) { final selected = (_zoom - level).abs() < 0.02; return PopupMenuItem( value: level, child: Row( children: [ SizedBox( width: 48, child: Text( '${(level * 100).round()}%', style: const TextStyle(fontFamily: 'monospace'), ), ), if (selected) const Icon(Icons.check, size: 14), ], ), ); } /// Apply a discrete zoom level from the dropdown. /// -1.0 is the sentinel meaning "fit to screen". Anything /// positive = exact scale; preserve the current translation /// so the operator doesn't lose their pan when they pick /// a zoom value. void _setZoom(double level) { if (level < 0) { _fitToContent(); return; } final tx = _transform.value.getTranslation(); _transform.value = Matrix4.identity() ..translateByDouble(tx.x, tx.y, 0, 1) ..scaleByDouble(level, level, 1, 1); } /// Modal bottom sheet listing the four style toggles /// (glass, gradient backdrop, flow animation, node /// shadows). Edits are kept in `_styleOverride` for the /// lifetime of the canvas; "Reset to host default" drops /// the override and the host-supplied style wins again. Future _openStyleSheet() async { // Seed from the current effective source (override or // host) so toggles open in the right state. final base = _styleOverride ?? widget.style; await showModalBottomSheet( context: context, showDragHandle: true, builder: (ctx) { var draft = base; return StatefulBuilder( builder: (ctx, setLocal) { Widget tile({ required String title, required String subtitle, required bool value, required ValueChanged onChanged, }) { return SwitchListTile( title: Text(title), subtitle: Text( subtitle, style: Theme.of(ctx).textTheme.bodySmall, ), value: value, onChanged: (v) { setLocal(() => onChanged(v)); }, ); } return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(8, 0, 8, 12), child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( title: const Text('Editor style'), subtitle: const Text( 'Toggles apply to this canvas for the rest of the session.', ), trailing: TextButton( onPressed: () { setState(() => _styleOverride = null); Navigator.of(ctx).pop(); }, child: const Text('Reset to default'), ), ), tile( title: 'Frosted glass panels', subtitle: 'BackdropFilter blur on the properties panel.', value: draft.panelStyle == EditorPanelStyle.glass, onChanged: (v) { draft = draft.copyWith( panelStyle: v ? EditorPanelStyle.glass : EditorPanelStyle.solid, ); setState(() => _styleOverride = draft); }, ), tile( title: 'Gradient canvas backdrop', subtitle: 'Subtle diagonal gradient on the canvas.', value: draft.canvasBackdrop == EditorCanvasBackdrop.gradient, onChanged: (v) { draft = draft.copyWith( canvasBackdrop: v ? EditorCanvasBackdrop.gradient : EditorCanvasBackdrop.flat, ); setState(() => _styleOverride = draft); }, ), tile( title: 'Flow animation', subtitle: 'Marching dashes + breathing pulse during runs.', value: draft.flowAnimation, onChanged: (v) { draft = draft.copyWith(flowAnimation: v); setState(() => _styleOverride = draft); }, ), tile( title: 'Node shadows', subtitle: 'Layered drop shadows under each step card.', value: draft.nodeShadows, onChanged: (v) { draft = draft.copyWith(nodeShadows: v); setState(() => _styleOverride = draft); }, ), ], ), ), ); }, ); }, ); } // --- 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. One dot per // declared (or YAML-implied) output field so the operator // can grab any specific output to drag a wire. Falls back // to a single legacy anchor when the step has no resolved // outputs at all (e.g. brand-new step with no edges yet). for (final step in graph.steps) { final labels = _outputLabelsForStep(step); if (labels.isEmpty) { 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, ), ); continue; } for (final field in labels) { final p = _outputPortPosition(step.id, layout, fieldName: field); final key = '${step.id}:$field'; // Output port colour matches the declared field's // type so operators read the wire's payload from the // dot alone (LabVIEW convention). Falls back to the // theme's primary when the field's type can't be // resolved (legacy v1/v2 manifest, hub not loaded). final type = _stepOutputType(step, field); final accent = type != null ? _typeAccent(type, Theme.of(context)) : Theme.of(context).colorScheme.primary; yield _portDot( portKey: key, center: p, isSource: true, connected: connectedPorts.contains(key), accent: accent, onDragStart: () => _draft = _ConnectionDraft( fromKind: _DraftSourceKind.step, fromId: step.id, fromField: field, 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. We render every // label the merged-labels helper emits (declared by the // manifest + implicit from with_) so the dot positions // match what FlowNode.build draws on the card body. for (final step in graph.steps) { final labels = _inputLabelsForStep(step); for (var i = 0; i < labels.length; i++) { final p = _inputPortPosition(step.id, i, layout); final field = labels[i]; final value = step.with_[field]?.toString() ?? ''; final wired = _isWiredExpression(value); // Step-input dot wears the declared field's type // colour so a `bytes` slot reads differently from a // `text` slot at a glance. final type = _stepInputType(step, field); final accent = type != null ? _typeAccent(type, Theme.of(context)) : Theme.of(context).colorScheme.primary; yield _portDot( portKey: '${step.id}:$field', center: p, isSource: false, connected: wired, accent: accent, onContextMenu: !wired ? null : (pos) => _disconnectInputPort(step.id, field, pos), ); } } // Outputs endpoint input ports — colour by the upstream // type when the YAML's $step.field expression resolves // to a step output whose type we know. Otherwise stays // the theme's primary so the dot is still visible. 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); final upstreamType = _outputsEndpointType(field, graph); final accent = upstreamType != null ? _typeAccent(upstreamType, Theme.of(context)) : Theme.of(context).colorScheme.primary; yield _portDot( portKey: 'outputs:$field', center: p, isSource: false, connected: wired, accent: accent, onContextMenu: !wired ? null : (pos) => _disconnectOutputPort(field, pos), ); } } /// Resolve the upstream type for an outputs-endpoint slot. /// Walks the edges into the outputs endpoint; when the /// source is a step whose ModuleSpec exposes the field's /// type, returns it. When source is an inputs endpoint /// (pass-through), returns the FlowInput's declared type. String? _outputsEndpointType(String field, FlowGraph graph) { for (final edge in graph.edges) { if (edge.toKind != EdgeEndpointKind.outputs) continue; if (edge.toField != field) continue; if (edge.fromKind == EdgeEndpointKind.inputs) { return graph.inputs[edge.fromField]?.type; } if (edge.fromKind == EdgeEndpointKind.step) { final step = graph.steps.firstWhere( (s) => s.id == edge.fromId, orElse: () => const FlowStep(id: '__missing__', use: ''), ); if (step.id == '__missing__') return null; return _stepOutputType(step, edge.fromField); } } return null; } /// 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) { // Specific output is wired iff at least one edge // leaves THIS field. The legacy `__out__` key is // also seeded so the fallback single-anchor case // (step with no declared outputs yet) still renders // its dot as connected. if (edge.fromField.isNotEmpty) { set.add('${edge.fromId}:${edge.fromField}'); } 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; } /// Datatype → port + wire accent. Inspired by LabVIEW's /// long-running convention (string → pink/magenta, cluster /// → brown, numeric → amber/orange) where each type carries /// a sticky colour you learn after one flow. Tuned so the /// five F∆I types stay distinguishable both at glance /// (saturation differences) and for colour-blind operators /// (different luminance, not only different hue). Color _typeAccent(String type, ThemeData theme) { final isDark = theme.brightness == Brightness.dark; switch (type) { case 'text': // Magenta / pink — LabVIEW's string colour. Pops on // both light + dark surfaces; high saturation reads // as "text flows here". return isDark ? const Color(0xFFFF6FB5) : const Color(0xFFD81B60); case 'json': // Amber / orange — structured-data colour, evokes // LabVIEW's cluster brown. Reads as "compound payload". return isDark ? const Color(0xFFFFB74D) : const Color(0xFFEF6C00); case 'bytes': // Cyan / teal — raw binary. Distinct from string-pink // and from the green of "file reference" so the // operator never confuses "I'm sending raw bytes" with // "I'm sending a file handle". return isDark ? const Color(0xFF4DD0E1) : const Color(0xFF00838F); case 'file': // Green — file reference / resource handle. LabVIEW // uses green for refnums; reads as "this is a pointer // to something on disk". return isDark ? const Color(0xFF81C784) : const Color(0xFF2E7D32); case 'number': case 'integer': return isDark ? const Color(0xFFFFD54F) : const Color(0xFFF9A825); default: // Unknown type — neutral, deliberately desaturated // so the operator notices "I haven't typed this". 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), // Long-press fallback for trackpad-only users. onLongPressStart: onContextMenu == null ? null : (details) => onContextMenu(details.globalPosition), child: Container( // Connected = solid filled circle in accent. // Dangling = clear ring with surface fill so the // empty socket is obvious. Drop-target halo // glows in addition. No inner pin — Stefan // explicitly wants the connected variant to read // as one continuous filled disc, not a ring with // a bullseye in it. 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, ), ), ), ), ); } /// True if this input port is the nearest type-compatible /// 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 — /// AND only sees compatible ones (a text source dragged /// at a json input won't light up). bool _isClosestDropTarget(Offset portCenter) { final draft = _draft; if (draft == null) return false; final graph = widget.controller.graph; final layout = widget.controller.layout; final sourceType = _typeAtDraftSource(); const maxDist = 32.0; double bestDist = double.infinity; Offset? best; for (final step in graph.steps) { final labels = _inputLabelsForStep(step); for (var i = 0; i < labels.length; i++) { if (!_typesCompatible(sourceType, _stepInputType(step, labels[i]))) { continue; } final p = _inputPortPosition(step.id, i, layout); final d = (draft.cursor - p).distance; if (d < bestDist && d <= maxDist) { bestDist = d; best = p; } } } // The outputs endpoint carries no declared type of its // own — it inherits whatever the source feeds it. Always // a valid drop target as long as proximity matches. 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 type-compatible input port within // tolerance. Incompatible candidates are filtered out so // a snapped drop never creates a mismatched wire. final graph = widget.controller.graph; final layout = widget.controller.layout; final sourceType = (() { switch (draft.fromKind) { case _DraftSourceKind.inputsField: return graph.inputs[draft.fromId]?.type; case _DraftSourceKind.step: if (draft.fromField.isEmpty) return null; final step = graph.steps.firstWhere( (s) => s.id == draft.fromId, orElse: () => const FlowStep(id: '__missing__', use: ''), ); if (step.id == '__missing__') return null; return _stepOutputType(step, draft.fromField); } })(); _DropTarget? best; double bestDist = double.infinity; const maxDist = 32.0; for (final step in graph.steps) { final labels = _inputLabelsForStep(step); for (var i = 0; i < labels.length; i++) { if (!_typesCompatible(sourceType, _stepInputType(step, labels[i]))) { continue; } 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: labels[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. Per-field output // ports stamp the precise field name into the draft; if // the operator dragged from the legacy single anchor // (no field), we fall back to "result" as the placeholder // and let them rename in the properties panel. final String expression; switch (draft.fromKind) { case _DraftSourceKind.step: final field = draft.fromField.isNotEmpty ? draft.fromField : 'result'; expression = '\$${draft.fromId}.$field'; 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) { if (_pattern == _CanvasPattern.blank) { return const SizedBox.shrink(); } return Positioned.fill( child: IgnorePointer( child: CustomPaint( painter: _PatternPainter( color: theme.dividerColor.withValues(alpha: 0.55), pattern: _pattern, ), ), ), ); } } enum _StepAction { duplicate, disconnectAll, delete } enum _PortAction { disconnect } enum _EdgeAction { disconnect } enum _DraftSourceKind { step, inputsField } enum _DraftTargetKind { step, outputsField } class _ConnectionDraft { final _DraftSourceKind fromKind; final String fromId; /// Field name on the source step's output side. Empty for /// the legacy single-anchor case or when the source is the /// inputs endpoint (whose field is encoded in `fromId`). /// When set, the new edge persists its `fromField` so the /// per-field-output port stays addressable. final String fromField; final Offset from; final Offset cursor; const _ConnectionDraft({ required this.fromKind, required this.fromId, this.fromField = '', required this.from, required this.cursor, }); _ConnectionDraft withCursor(Offset c) => _ConnectionDraft( fromKind: fromKind, fromId: fromId, fromField: fromField, 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, }; } /// Background patterns the canvas can render. Ported from /// jai_client's CanvasPatternPainter so operators that lived /// in that editor find their preferred raster back here. enum _CanvasPattern { dots, grid, blank, modern, classic, blueprint, minimal } class _PatternPainter extends CustomPainter { final Color color; final _CanvasPattern pattern; _PatternPainter({required this.color, required this.pattern}); static const double _spacing = 24.0; static const int _lineSkip = 3; @override void paint(Canvas canvas, Size size) { final dotPaint = Paint()..color = color; final primaryLine = Paint() ..color = color.withValues(alpha: 0.6) ..strokeWidth = 0.5; final secondaryLine = Paint() ..color = color.withValues(alpha: 0.25) ..strokeWidth = 0.3; switch (pattern) { case _CanvasPattern.dots: 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, dotPaint); } } case _CanvasPattern.grid: final linePaint = Paint() ..color = color.withValues(alpha: 0.5) ..strokeWidth = 0.6; for (double x = 0; x < size.width; x += _spacing) { canvas.drawLine(Offset(x, 0), Offset(x, size.height), linePaint); } for (double y = 0; y < size.height; y += _spacing) { canvas.drawLine(Offset(0, y), Offset(size.width, y), linePaint); } case _CanvasPattern.blank: // No painting — the surface colour shows through. break; case _CanvasPattern.modern: // Sparse grid: only every Nth line, plus emphasised // every 5N. Dots at major intersections. final lineCount = (size.height / _spacing).ceil() + 1; final colCount = (size.width / _spacing).ceil() + 1; for (int i = 0; i < lineCount; i++) { if (i % _lineSkip != 0 && i % (_lineSkip * 5) != 0) continue; final y = i * _spacing; final p = i % (_lineSkip * 5) == 0 ? primaryLine : secondaryLine; canvas.drawLine(Offset(0, y), Offset(size.width, y), p); } for (int i = 0; i < colCount; i++) { if (i % _lineSkip != 0 && i % (_lineSkip * 5) != 0) continue; final x = i * _spacing; final p = i % (_lineSkip * 5) == 0 ? primaryLine : secondaryLine; canvas.drawLine(Offset(x, 0), Offset(x, size.height), p); } for (int i = 0; i < lineCount; i += _lineSkip * 5) { for (int j = 0; j < colCount; j += _lineSkip * 5) { canvas.drawCircle( Offset(j * _spacing, i * _spacing), 0.7, dotPaint, ); } } case _CanvasPattern.classic: // Dense grid: every line drawn; every 2Nth emphasised. final lineCount = (size.height / _spacing).ceil() + 1; final colCount = (size.width / _spacing).ceil() + 1; for (int i = 0; i < lineCount; i++) { final y = i * _spacing; final p = i % (_lineSkip * 2) == 0 ? primaryLine : secondaryLine; canvas.drawLine(Offset(0, y), Offset(size.width, y), p); } for (int i = 0; i < colCount; i++) { final x = i * _spacing; final p = i % (_lineSkip * 2) == 0 ? primaryLine : secondaryLine; canvas.drawLine(Offset(x, 0), Offset(x, size.height), p); } for (int i = 0; i < lineCount; i += _lineSkip * 2) { for (int j = 0; j < colCount; j += _lineSkip * 2) { canvas.drawCircle( Offset(j * _spacing, i * _spacing), 0.9, dotPaint, ); } } case _CanvasPattern.blueprint: // Engineering-paper look: every line drawn, every Nth // emphasised, dots at major intersections. final lineCount = (size.height / _spacing).ceil() + 1; final colCount = (size.width / _spacing).ceil() + 1; for (int i = 0; i < lineCount; i++) { final y = i * _spacing; final p = i % _lineSkip == 0 ? primaryLine : secondaryLine; canvas.drawLine(Offset(0, y), Offset(size.width, y), p); } for (int i = 0; i < colCount; i++) { final x = i * _spacing; final p = i % _lineSkip == 0 ? primaryLine : secondaryLine; canvas.drawLine(Offset(x, 0), Offset(x, size.height), p); } for (int i = 0; i < lineCount; i += _lineSkip) { for (int j = 0; j < colCount; j += _lineSkip) { canvas.drawCircle( Offset(j * _spacing, i * _spacing), 1.0, dotPaint, ); } } case _CanvasPattern.minimal: // Almost nothing: every 2Nth line only, tiny dots. final lineCount = (size.height / _spacing).ceil() + 1; final colCount = (size.width / _spacing).ceil() + 1; for (int i = 0; i < lineCount; i++) { if (i % (_lineSkip * 2) != 0) continue; final y = i * _spacing; canvas.drawLine(Offset(0, y), Offset(size.width, y), primaryLine); } for (int i = 0; i < colCount; i++) { if (i % (_lineSkip * 2) != 0) continue; final x = i * _spacing; canvas.drawLine(Offset(x, 0), Offset(x, size.height), primaryLine); } for (int i = 0; i < lineCount; i += _lineSkip * 2) { for (int j = 0; j < colCount; j += _lineSkip * 2) { canvas.drawCircle( Offset(j * _spacing, i * _spacing), 0.5, dotPaint, ); } } } } @override bool shouldRepaint(_PatternPainter old) => old.color != color || old.pattern != pattern; }