feat(editor): type-checked connections + card-height fix + widget tests

Three changes:

1. Type-checked port connections. Drag from an output port
   only highlights compatible-type input ports as drop
   targets; incompatible drops are silently refused. Source
   of truth: ModuleSpec for declared types. When either side
   is unknown (no manifest, implicit YAML field) the check
   degrades to 'compatible with anything' so a missing
   manifest never blocks composition.

2. NodeGeometry.heightFor now adds a 2 px allowance for the
   1 px AnimatedContainer border on top + bottom of the
   card. Without this the last port-row's Column would
   overflow by 2 px under tight layout constraints (caught
   by the new widget tests; production canvas clipped it
   silently inside InteractiveViewer).

3. Width parameter on FlowNode now actually drives the
   outer SizedBox. The dynamic _stepWidth value from the
   canvas finally reaches the card border rather than being
   shadowed by NodeGeometry.width. Long labels
   (model_endpoint, source_language) no longer ellipsis-clip.

Tests: 8 new widget + geometry tests covering width growth,
two-column body layout, port-tooltip attachment,
row-alignment between input and output sides, and the
existing kindForStep classifier. All 20 editor tests pass.

Bumps fai_studio_flow_editor to 0.11.0.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-02 01:29:00 +02:00
parent a1d25fd48a
commit 7b256b5e35
5 changed files with 264 additions and 21 deletions

View file

@ -232,6 +232,67 @@ class _FlowCanvasState extends State<FlowCanvas>
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
@ -1764,21 +1825,27 @@ class _FlowCanvasState extends State<FlowCanvas>
);
}
/// True if this input port is the nearest valid drop
/// target to the current draft cursor (within snap
/// 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.
/// 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) {
@ -1787,6 +1854,9 @@ class _FlowCanvasState extends State<FlowCanvas>
}
}
}
// 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);
@ -1804,15 +1874,34 @@ class _FlowCanvasState extends State<FlowCanvas>
final draft = _draft;
setState(() => _draft = null);
if (draft == null) return;
// Find the closest input port within tolerance.
// 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) {