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

@ -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 FI's
// $step.field references stand out via the surrounding
// string colour.
'tag': monoBase.copyWith(color: metaAccent),
'type': monoBase.copyWith(color: symbolAccent),
};
}
}

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) {

View file

@ -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) {

View file

@ -1,6 +1,6 @@
name: fai_studio_flow_editor
description: Swappable inline YAML editor for F∆I Studio flows.
version: 0.10.3
version: 0.11.0
publish_to: 'none'
repository: https://git.flemming.ai/fai/studio-flow-editor

112
test/flow_node_test.dart Normal file
View file

@ -0,0 +1,112 @@
// Widget tests for FlowNode the canvas card.
//
// Cover: dynamic width grows with long labels, two-column
// body renders both input + output labels, port tooltips are
// attached when supplied, height accounts for max(inputs,
// outputs).
import 'package:fai_studio_flow_editor/src/model/flow_graph.dart';
import 'package:fai_studio_flow_editor/src/widgets/flow_node.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
Widget pump(Widget child) =>
MaterialApp(home: Scaffold(body: Center(child: child)));
group('NodeGeometry', () {
test('widthFor grows beyond minimum when labels are long', () {
// Bare minimum case.
expect(
NodeGeometry.widthFor(maxInputChars: 4, maxOutputChars: 4),
NodeGeometry.width,
);
// Long labels push past the minimum.
final wide = NodeGeometry.widthFor(
maxInputChars: 13, // "system_prompt"
maxOutputChars: 14, // "model_endpoint"
);
expect(wide, greaterThan(NodeGeometry.width));
});
test('heightFor uses the taller of inputs / outputs', () {
// 3 inputs + 5 outputs 5-row body.
final h35 = NodeGeometry.heightFor(3, 5);
// 5 inputs + 3 outputs also 5-row body, same height.
final h53 = NodeGeometry.heightFor(5, 3);
expect(h35, h53);
});
test('inputPortY/outputPortY align row-for-row', () {
// Both sides walk the same row geometry from the body's
// top; that's how the labels stay visually aligned with
// their dots.
expect(NodeGeometry.inputPortY(0), NodeGeometry.outputPortY(0));
expect(NodeGeometry.inputPortY(2), NodeGeometry.outputPortY(2));
});
});
group('FlowNode rendering', () {
testWidgets('renders both input and output labels', (tester) async {
await tester.pumpWidget(
pump(
const FlowNode(
id: 'sum',
title: 'sum',
subtitle: 'llm.chat@^0',
inputPortLabels: ['prompt', 'endpoint'],
outputPortLabels: ['response', 'model_endpoint'],
),
),
);
expect(find.text('prompt'), findsOneWidget);
expect(find.text('endpoint'), findsOneWidget);
expect(find.text('response'), findsOneWidget);
expect(find.text('model_endpoint'), findsOneWidget);
});
testWidgets('honors explicit width parameter', (tester) async {
await tester.pumpWidget(
pump(
const FlowNode(
id: 'x',
title: 'x',
inputPortLabels: ['a_very_long_input_label'],
outputPortLabels: ['another_long_output_label'],
width: 320,
),
),
);
final size = tester.getSize(find.byType(FlowNode));
expect(size.width, 320);
});
testWidgets('attaches Tooltip when portTooltips is non-empty', (
tester,
) async {
await tester.pumpWidget(
pump(
const FlowNode(
id: 'sum',
title: 'sum',
inputPortLabels: ['prompt'],
portTooltips: {'prompt': 'The user-facing prompt text.'},
),
),
);
expect(find.byType(Tooltip), findsAtLeastNWidgets(1));
});
});
group('kindForStep', () {
test('classifies system.approval steps as approval kind', () {
final approval = FlowStep(id: 'r', use: 'system.approval@^0');
expect(kindForStep(approval), NodeVisualKind.approval);
});
test('classifies generic capabilities as module kind', () {
final mod = FlowStep(id: 's', use: 'llm.chat@^0');
expect(kindForStep(mod), NodeVisualKind.module);
});
});
}