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