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:
parent
a1d25fd48a
commit
7b256b5e35
5 changed files with 264 additions and 21 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue