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
|
|
@ -603,25 +603,63 @@ outputs:
|
|||
|
||||
Map<String, TextStyle> _yamlStyle(ThemeData theme) {
|
||||
final cs = theme.colorScheme;
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final monoBase = TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 13,
|
||||
height: 1.45,
|
||||
);
|
||||
// Pick clearly-distinguished hues so keys, strings,
|
||||
// numbers, anchors, and comments don't blur into each
|
||||
// other. We derive accents from the active ColorScheme
|
||||
// so theme plugins (sunflower / sunset / space / glass)
|
||||
// tint the highlight as expected without per-theme
|
||||
// overrides. Falls back to a balanced light/dark palette
|
||||
// when the scheme tints don't read well as code colours.
|
||||
final keyAccent = cs.primary;
|
||||
final stringAccent = isDark ? const Color(0xFFA5D6A7) : const Color(0xFF2E7D32);
|
||||
final numberAccent = isDark ? const Color(0xFFFFCC80) : const Color(0xFFE65100);
|
||||
final symbolAccent = isDark ? const Color(0xFFB39DDB) : const Color(0xFF6A1B9A);
|
||||
final commentAccent = cs.onSurfaceVariant.withValues(alpha: 0.7);
|
||||
final metaAccent = isDark ? const Color(0xFF80DEEA) : const Color(0xFF00838F);
|
||||
return {
|
||||
'root': monoBase.copyWith(color: cs.onSurface),
|
||||
// YAML key — bright primary so the structure pops at a
|
||||
// glance. attr is the highlight grammar's key token.
|
||||
'attr': monoBase.copyWith(color: cs.primary, fontWeight: FontWeight.w600),
|
||||
'string': monoBase.copyWith(color: cs.tertiary),
|
||||
'number': monoBase.copyWith(color: cs.secondary),
|
||||
'bullet': monoBase.copyWith(color: cs.onSurfaceVariant),
|
||||
'literal': monoBase.copyWith(color: cs.secondary),
|
||||
'comment': monoBase.copyWith(
|
||||
// YAML keys — bold + primary accent so the structure
|
||||
// reads top-down at a glance.
|
||||
'attr': monoBase.copyWith(color: keyAccent, fontWeight: FontWeight.w600),
|
||||
// Quoted + unquoted strings. The highlight grammar
|
||||
// tags both as 'string'.
|
||||
'string': monoBase.copyWith(color: stringAccent),
|
||||
// Numbers (integers, floats, durations).
|
||||
'number': monoBase.copyWith(color: numberAccent),
|
||||
// Sequence dashes — kept muted so list bullets don't
|
||||
// shout. Operators read them as structure, not content.
|
||||
'bullet': monoBase.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
// Booleans, null, named scalars.
|
||||
'literal': monoBase.copyWith(
|
||||
color: symbolAccent,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
// YAML comments — softened + italicised so reading the
|
||||
// structure ignores them, while a deliberate scan
|
||||
// still picks them out.
|
||||
'comment': monoBase.copyWith(
|
||||
color: commentAccent,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
'meta': monoBase.copyWith(color: cs.secondary),
|
||||
// Document markers, directives, tags (--- !!str etc.).
|
||||
'meta': monoBase.copyWith(color: metaAccent),
|
||||
// Anchor / alias names (& and *).
|
||||
'symbol': monoBase.copyWith(color: symbolAccent),
|
||||
// Templated values — the highlight grammar tags some
|
||||
// braced expressions as 'tag'; pick them out so F∆I's
|
||||
// $step.field references stand out via the surrounding
|
||||
// string colour.
|
||||
'tag': monoBase.copyWith(color: metaAccent),
|
||||
'type': monoBase.copyWith(color: symbolAccent),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -94,10 +94,18 @@ class NodeGeometry {
|
|||
/// Total card height for a node carrying [inputCount] inputs
|
||||
/// on the left + [outputCount] outputs on the right. The
|
||||
/// taller side wins; the card always reserves at least one
|
||||
/// row of body so the header doesn't sit naked.
|
||||
/// row of body so the header doesn't sit naked. Adds a
|
||||
/// 2 px allowance for the 1 px border on the top + bottom
|
||||
/// of the AnimatedContainer wrapping the card, otherwise
|
||||
/// the inner Column gets shorted by the border and the
|
||||
/// last port row overflows.
|
||||
static double heightFor(int inputCount, [int outputCount = 0]) {
|
||||
final rows = inputCount > outputCount ? inputCount : outputCount;
|
||||
return headerHeight + bodyTopPad + rows * portRowHeight + bodyBottomPad;
|
||||
return headerHeight +
|
||||
bodyTopPad +
|
||||
rows * portRowHeight +
|
||||
bodyBottomPad +
|
||||
2;
|
||||
}
|
||||
|
||||
/// Y offset (from the card's top edge) where the input
|
||||
|
|
@ -290,11 +298,7 @@ class FlowNode extends StatelessWidget {
|
|||
),
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
width: NodeGeometry.width,
|
||||
height: height,
|
||||
child: cardWidget,
|
||||
);
|
||||
return SizedBox(width: width, height: height, child: cardWidget);
|
||||
}
|
||||
|
||||
Widget _card(ThemeData theme, Color accent, double height) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue