diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index 24664d0..e7a58c3 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -603,25 +603,63 @@ outputs: Map _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), }; } } diff --git a/lib/src/widgets/flow_canvas.dart b/lib/src/widgets/flow_canvas.dart index bf32ba8..463f582 100644 --- a/lib/src/widgets/flow_canvas.dart +++ b/lib/src/widgets/flow_canvas.dart @@ -232,6 +232,67 @@ class _FlowCanvasState extends State 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 ); } - /// 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 } } } + // 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 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) { diff --git a/lib/src/widgets/flow_node.dart b/lib/src/widgets/flow_node.dart index 1628fc7..249ab73 100644 --- a/lib/src/widgets/flow_node.dart +++ b/lib/src/widgets/flow_node.dart @@ -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) { diff --git a/pubspec.yaml b/pubspec.yaml index 7bed44a..e96aa9b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/flow_node_test.dart b/test/flow_node_test.dart new file mode 100644 index 0000000..d443926 --- /dev/null +++ b/test/flow_node_test.dart @@ -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); + }); + }); +}