feat(editor): per-field input/output ports + i18n tooltips + 4 jai_client patterns
Phase A of the per-field-output-ports roadmap. FlowRunDriver gains optional moduleInfo(capability) hook that returns a ModuleSpec carrying declared inputs + outputs (each ModuleField has name, type, locale-> description map). Default returns null so legacy hosts that didn't implement the hook keep compiling — the editor falls back to YAML-reference-derived ports. FlowCanvas caches ModuleSpec per step capability, kicked off lazily on each build. When the spec lands, the canvas rebuilds with: - Per-field input ports on the LEFT (declared input names, in stable manifest order) — replaces the with_-keys-as- port-labels shape. - Per-field output ports on the RIGHT — one anchor per declared output field. A flow like summarize that declares response/model_endpoint/model_name/model_digest now exposes four distinct anchors instead of fanning every downstream reference out of one collapsed point. - Tooltips on each port label, picked from the field's description.<locale> with English fallback. NodeGeometry refactored: heightFor(inputs, outputs) takes max(inputs, outputs); outputPortY(index) for the new multi-port output side; outputAnchorY() preserves the legacy single-anchor fallback so edges still draw before the spec resolves. Edges + hit-tester now use _outputPortPosition(stepId, fieldName: edge.fromField). Field index lookup falls through to outputAnchorY when the spec isn't loaded yet. Patterns: ported modern, classic, blueprint, minimal from jai_client's CanvasPatternPainter. Dropdown now has seven choices (dots, grid, modern, classic, blueprint, minimal, blank). Bumps fai_studio_flow_editor to 0.9.0. Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
1f5601a461
commit
b1fe765468
6 changed files with 505 additions and 87 deletions
|
|
@ -36,9 +36,11 @@ import 'package:flutter/material.dart';
|
|||
|
||||
import '../editor_controller.dart';
|
||||
import '../editor_style.dart';
|
||||
import '../l10n.dart';
|
||||
import '../model/auto_layout.dart';
|
||||
import '../model/flow_graph.dart';
|
||||
import '../model/layout_store.dart';
|
||||
import '../run_driver.dart';
|
||||
import '../tokens.dart';
|
||||
import 'edge_painter.dart';
|
||||
import 'flow_node.dart';
|
||||
|
|
@ -59,9 +61,25 @@ const NodePosition _outputsFallback = NodePosition(1200, 80);
|
|||
class FlowCanvas extends StatefulWidget {
|
||||
final FlowEditorController controller;
|
||||
final FaiEditorStyle style;
|
||||
|
||||
/// Bridge to the hub. The canvas calls `driver.moduleInfo`
|
||||
/// per step's `use:` capability to render declared input +
|
||||
/// output ports with their canonical field names + i18n
|
||||
/// tooltips. When null (e.g. test harness), the canvas falls
|
||||
/// back to YAML-derived port labels and single-anchor outputs.
|
||||
final FlowRunDriver? driver;
|
||||
|
||||
/// Active operator locale, used to pick the right peer from
|
||||
/// each field's `description.<locale>` map for port
|
||||
/// tooltips. "en" is the default; "de" is the supported
|
||||
/// peer today.
|
||||
final FlowEditorLocale locale;
|
||||
|
||||
const FlowCanvas({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.driver,
|
||||
this.locale = FlowEditorLocale.en,
|
||||
this.style = FaiEditorStyle.modern,
|
||||
});
|
||||
|
||||
|
|
@ -104,6 +122,19 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
// Operator-chosen background pattern. Cycles via the
|
||||
// pattern button on the bottom-right canvas controls.
|
||||
_CanvasPattern _pattern = _CanvasPattern.dots;
|
||||
|
||||
// ModuleSpec cache: capability identifier → declared
|
||||
// inputs/outputs. Populated lazily on each build by
|
||||
// walking the current graph's `step.use` values and
|
||||
// dispatching a host-side moduleInfo() lookup for any
|
||||
// capability not yet seen. The map value is null while
|
||||
// the lookup is in flight or has resolved to "no info"
|
||||
// (legacy v1/v2 manifest); the renderer falls back to
|
||||
// YAML-derived ports in that case.
|
||||
final Map<String, ModuleSpec?> _moduleSpecs = {};
|
||||
// Capabilities whose moduleInfo() call is already in
|
||||
// flight, so we don't re-trigger on every rebuild.
|
||||
final Set<String> _moduleSpecsInFlight = {};
|
||||
// Current zoom level (taken from the TransformationController)
|
||||
// so the bottom-right indicator can show it as a %.
|
||||
double _zoom = 1.0;
|
||||
|
|
@ -138,6 +169,54 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
}
|
||||
}
|
||||
|
||||
/// Walk the current graph's steps and kick off a
|
||||
/// `driver.moduleInfo` lookup for any `use:` capability
|
||||
/// we haven't seen yet. Results land in `_moduleSpecs`
|
||||
/// and trigger a rebuild so the next paint draws the
|
||||
/// per-field input/output ports.
|
||||
void _refreshModuleSpecs(FlowGraph graph) {
|
||||
final driver = widget.driver;
|
||||
if (driver == null) return;
|
||||
for (final step in graph.steps) {
|
||||
final cap = step.use;
|
||||
if (cap.isEmpty) continue;
|
||||
// Strip the @version suffix — moduleInfo looks up by
|
||||
// capability name, not by capability ref.
|
||||
final name = cap.split('@').first;
|
||||
if (_moduleSpecs.containsKey(name) ||
|
||||
_moduleSpecsInFlight.contains(name)) {
|
||||
continue;
|
||||
}
|
||||
_moduleSpecsInFlight.add(name);
|
||||
// ignore: discarded_futures
|
||||
driver
|
||||
.moduleInfo(name)
|
||||
.then((spec) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_moduleSpecsInFlight.remove(name);
|
||||
_moduleSpecs[name] = spec;
|
||||
});
|
||||
})
|
||||
.catchError((Object _) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_moduleSpecsInFlight.remove(name);
|
||||
// Record absence so we don't retry forever
|
||||
// when the hub doesn't know this capability.
|
||||
_moduleSpecs[name] = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up the cached ModuleSpec for a step's capability,
|
||||
/// stripping the `@version` suffix.
|
||||
ModuleSpec? _specForStep(FlowStep step) {
|
||||
final cap = step.use.split('@').first;
|
||||
return _moduleSpecs[cap];
|
||||
}
|
||||
|
||||
void _onControllerChanged() {
|
||||
if (!mounted) return;
|
||||
// Start the flow-animation controller only while a step
|
||||
|
|
@ -160,6 +239,11 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
final theme = Theme.of(context);
|
||||
final graph = widget.controller.graph;
|
||||
final layout = widget.controller.layout;
|
||||
// Best-effort lazy module-spec fetch so the per-field
|
||||
// input/output ports paint on the next frame. Cheap when
|
||||
// every spec is already cached; only triggers async work
|
||||
// for genuinely new capabilities.
|
||||
_refreshModuleSpecs(graph);
|
||||
// Endpoint positions now live in the layout sidecar
|
||||
// alongside every step's position — see
|
||||
// AutoLayout.layout() which seeds defaults. Reading them
|
||||
|
|
@ -391,6 +475,26 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
Icons.grid_on,
|
||||
'Grid',
|
||||
),
|
||||
_patternMenuItem(
|
||||
_CanvasPattern.modern,
|
||||
Icons.window_outlined,
|
||||
'Modern',
|
||||
),
|
||||
_patternMenuItem(
|
||||
_CanvasPattern.classic,
|
||||
Icons.grid_3x3,
|
||||
'Classic',
|
||||
),
|
||||
_patternMenuItem(
|
||||
_CanvasPattern.blueprint,
|
||||
Icons.architecture,
|
||||
'Blueprint',
|
||||
),
|
||||
_patternMenuItem(
|
||||
_CanvasPattern.minimal,
|
||||
Icons.density_small,
|
||||
'Minimal',
|
||||
),
|
||||
_patternMenuItem(
|
||||
_CanvasPattern.blank,
|
||||
Icons.layers_clear,
|
||||
|
|
@ -535,6 +639,33 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
.whereType<Object>()
|
||||
.where((v) => _isWiredExpression(v.toString()))
|
||||
.length;
|
||||
// Per-field input/output ports: prefer the resolved
|
||||
// ModuleSpec from the hub when present, else fall back
|
||||
// to the legacy YAML-derived shape (with_ keys on the
|
||||
// left, single anchor on the right).
|
||||
final spec = _specForStep(step);
|
||||
final inputLabels = spec != null
|
||||
? spec.inputs.map((f) => f.name).toList()
|
||||
: step.with_.keys.toList();
|
||||
final outputLabels = spec != null
|
||||
? spec.outputs.map((f) => f.name).toList()
|
||||
: const <String>[];
|
||||
final tooltips = <String, String>{};
|
||||
if (spec != null) {
|
||||
final loc = widget.locale == FlowEditorLocale.de ? 'de' : 'en';
|
||||
for (final f in spec.inputs) {
|
||||
final d = f.descriptionFor(loc);
|
||||
if (d != null) tooltips[f.name] = d;
|
||||
}
|
||||
for (final f in spec.outputs) {
|
||||
final d = f.descriptionFor(loc);
|
||||
if (d != null) tooltips[f.name] = d;
|
||||
}
|
||||
}
|
||||
final cardHeight = NodeGeometry.heightFor(
|
||||
inputLabels.length,
|
||||
outputLabels.length,
|
||||
);
|
||||
return Positioned(
|
||||
left: pos.x,
|
||||
top: pos.y,
|
||||
|
|
@ -542,19 +673,16 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
id: step.id,
|
||||
title: step.id,
|
||||
subtitle: step.use,
|
||||
inputPortLabels: step.with_.keys.toList(),
|
||||
inputPortLabels: inputLabels,
|
||||
outputPortLabels: outputLabels,
|
||||
portTooltips: tooltips,
|
||||
wiredCount: wired,
|
||||
kind: kindForStep(step),
|
||||
selected: selected,
|
||||
status: status,
|
||||
elevated: widget.style.nodeShadows,
|
||||
onTap: () => widget.controller.selectStep(step.id),
|
||||
onDrag: (delta) => _applyDrag(
|
||||
step.id,
|
||||
pos,
|
||||
delta,
|
||||
NodeGeometry.heightFor(step.with_.length),
|
||||
),
|
||||
onDrag: (delta) => _applyDrag(step.id, pos, delta, cardHeight),
|
||||
onContextMenu: (globalPos) => _showStepContextMenu(step, globalPos),
|
||||
),
|
||||
);
|
||||
|
|
@ -624,14 +752,36 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
|
||||
// --- Port positions in canvas coordinates ---
|
||||
|
||||
/// Right-edge output port for a step node.
|
||||
Offset _outputPortPosition(String nodeId, FlowLayout layout) {
|
||||
/// Right-edge output port for a step node. When the step's
|
||||
/// ModuleSpec has been resolved AND [fieldName] is one of
|
||||
/// its declared outputs, the port lands at that row;
|
||||
/// otherwise it falls back to the legacy header-centred
|
||||
/// anchor (single output per step).
|
||||
Offset _outputPortPosition(
|
||||
String nodeId,
|
||||
FlowLayout layout, {
|
||||
String? fieldName,
|
||||
}) {
|
||||
final pos = layout.positions[nodeId];
|
||||
if (pos == null) return Offset.zero;
|
||||
return Offset(
|
||||
pos.x + NodeGeometry.width,
|
||||
pos.y + NodeGeometry.outputPortY(),
|
||||
final step = widget.controller.graph.steps.firstWhere(
|
||||
(s) => s.id == nodeId,
|
||||
orElse: () => const FlowStep(id: '__missing__', use: ''),
|
||||
);
|
||||
final spec = _specForStep(step);
|
||||
final outputs = spec?.outputs ?? const [];
|
||||
double y;
|
||||
if (fieldName != null && outputs.isNotEmpty) {
|
||||
final idx = outputs.indexWhere((f) => f.name == fieldName);
|
||||
if (idx >= 0) {
|
||||
y = NodeGeometry.outputPortY(idx);
|
||||
} else {
|
||||
y = NodeGeometry.outputAnchorY();
|
||||
}
|
||||
} else {
|
||||
y = NodeGeometry.outputAnchorY();
|
||||
}
|
||||
return Offset(pos.x + NodeGeometry.width, pos.y + y);
|
||||
}
|
||||
|
||||
/// Left-edge input port for any node. Works for step nodes
|
||||
|
|
@ -675,7 +825,11 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
fromSide = EdgeSide.right;
|
||||
}
|
||||
} else if (edge.fromKind == EdgeEndpointKind.step) {
|
||||
from = _outputPortPosition(edge.fromId, layout);
|
||||
from = _outputPortPosition(
|
||||
edge.fromId,
|
||||
layout,
|
||||
fieldName: edge.fromField,
|
||||
);
|
||||
// Step output is on the right edge.
|
||||
fromSide = EdgeSide.right;
|
||||
}
|
||||
|
|
@ -684,7 +838,15 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
(s) => s.id == edge.toId,
|
||||
orElse: () => const FlowStep(id: '__missing__', use: ''),
|
||||
);
|
||||
final idx = step.with_.keys.toList().indexOf(edge.toField);
|
||||
// Prefer the resolved ModuleSpec's declared input
|
||||
// order when present (matches what the renderer
|
||||
// draws); fall back to the with_ keys for legacy
|
||||
// / unresolved modules.
|
||||
final spec = _specForStep(step);
|
||||
final inputLabels = spec != null
|
||||
? spec.inputs.map((f) => f.name).toList()
|
||||
: step.with_.keys.toList();
|
||||
final idx = inputLabels.indexOf(edge.toField);
|
||||
if (idx >= 0) {
|
||||
to = _inputPortPosition(edge.toId, idx, layout);
|
||||
toSide = EdgeSide.left;
|
||||
|
|
@ -776,14 +938,22 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
final idx = inputsList.indexOf(edge.fromField);
|
||||
if (idx >= 0) from = _inputsEndpointPortPosition(idx, layout);
|
||||
} else if (edge.fromKind == EdgeEndpointKind.step) {
|
||||
from = _outputPortPosition(edge.fromId, layout);
|
||||
from = _outputPortPosition(
|
||||
edge.fromId,
|
||||
layout,
|
||||
fieldName: edge.fromField,
|
||||
);
|
||||
}
|
||||
if (edge.toKind == EdgeEndpointKind.step) {
|
||||
final step = graph.steps.firstWhere(
|
||||
(s) => s.id == edge.toId,
|
||||
orElse: () => const FlowStep(id: '__missing__', use: ''),
|
||||
);
|
||||
final idx = step.with_.keys.toList().indexOf(edge.toField);
|
||||
final spec = _specForStep(step);
|
||||
final inputLabels = spec != null
|
||||
? spec.inputs.map((f) => f.name).toList()
|
||||
: step.with_.keys.toList();
|
||||
final idx = inputLabels.indexOf(edge.toField);
|
||||
if (idx >= 0) to = _inputPortPosition(edge.toId, idx, layout);
|
||||
} else if (edge.toKind == EdgeEndpointKind.outputs) {
|
||||
final outs = graph.outputs.keys.toList();
|
||||
|
|
@ -908,6 +1078,10 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
_CanvasPattern.dots => Icons.grain,
|
||||
_CanvasPattern.grid => Icons.grid_on,
|
||||
_CanvasPattern.blank => Icons.layers_clear,
|
||||
_CanvasPattern.modern => Icons.window_outlined,
|
||||
_CanvasPattern.classic => Icons.grid_3x3,
|
||||
_CanvasPattern.blueprint => Icons.architecture,
|
||||
_CanvasPattern.minimal => Icons.density_small,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1609,37 +1783,145 @@ FlowNodeStatus _toNodeStatus(StepRunStatus s) {
|
|||
};
|
||||
}
|
||||
|
||||
enum _CanvasPattern { dots, grid, blank }
|
||||
/// Background patterns the canvas can render. Ported from
|
||||
/// jai_client's CanvasPatternPainter so operators that lived
|
||||
/// in that editor find their preferred raster back here.
|
||||
enum _CanvasPattern { dots, grid, blank, modern, classic, blueprint, minimal }
|
||||
|
||||
class _PatternPainter extends CustomPainter {
|
||||
final Color color;
|
||||
final _CanvasPattern pattern;
|
||||
_PatternPainter({required this.color, required this.pattern});
|
||||
|
||||
static const double _spacing = 24.0;
|
||||
static const int _lineSkip = 3;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
const spacing = 24.0;
|
||||
final paint = Paint()..color = color;
|
||||
final dotPaint = Paint()..color = color;
|
||||
final primaryLine = Paint()
|
||||
..color = color.withValues(alpha: 0.6)
|
||||
..strokeWidth = 0.5;
|
||||
final secondaryLine = Paint()
|
||||
..color = color.withValues(alpha: 0.25)
|
||||
..strokeWidth = 0.3;
|
||||
|
||||
switch (pattern) {
|
||||
case _CanvasPattern.dots:
|
||||
for (double x = 0; x < size.width; x += spacing) {
|
||||
for (double y = 0; y < size.height; y += spacing) {
|
||||
canvas.drawCircle(Offset(x, y), 0.8, paint);
|
||||
for (double x = 0; x < size.width; x += _spacing) {
|
||||
for (double y = 0; y < size.height; y += _spacing) {
|
||||
canvas.drawCircle(Offset(x, y), 0.8, dotPaint);
|
||||
}
|
||||
}
|
||||
case _CanvasPattern.grid:
|
||||
final linePaint = Paint()
|
||||
..color = color.withValues(alpha: 0.5)
|
||||
..strokeWidth = 0.6;
|
||||
for (double x = 0; x < size.width; x += spacing) {
|
||||
for (double x = 0; x < size.width; x += _spacing) {
|
||||
canvas.drawLine(Offset(x, 0), Offset(x, size.height), linePaint);
|
||||
}
|
||||
for (double y = 0; y < size.height; y += spacing) {
|
||||
for (double y = 0; y < size.height; y += _spacing) {
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), linePaint);
|
||||
}
|
||||
case _CanvasPattern.blank:
|
||||
// No painting — the surface colour shows through.
|
||||
break;
|
||||
case _CanvasPattern.modern:
|
||||
// Sparse grid: only every Nth line, plus emphasised
|
||||
// every 5N. Dots at major intersections.
|
||||
final lineCount = (size.height / _spacing).ceil() + 1;
|
||||
final colCount = (size.width / _spacing).ceil() + 1;
|
||||
for (int i = 0; i < lineCount; i++) {
|
||||
if (i % _lineSkip != 0 && i % (_lineSkip * 5) != 0) continue;
|
||||
final y = i * _spacing;
|
||||
final p = i % (_lineSkip * 5) == 0 ? primaryLine : secondaryLine;
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), p);
|
||||
}
|
||||
for (int i = 0; i < colCount; i++) {
|
||||
if (i % _lineSkip != 0 && i % (_lineSkip * 5) != 0) continue;
|
||||
final x = i * _spacing;
|
||||
final p = i % (_lineSkip * 5) == 0 ? primaryLine : secondaryLine;
|
||||
canvas.drawLine(Offset(x, 0), Offset(x, size.height), p);
|
||||
}
|
||||
for (int i = 0; i < lineCount; i += _lineSkip * 5) {
|
||||
for (int j = 0; j < colCount; j += _lineSkip * 5) {
|
||||
canvas.drawCircle(
|
||||
Offset(j * _spacing, i * _spacing),
|
||||
0.7,
|
||||
dotPaint,
|
||||
);
|
||||
}
|
||||
}
|
||||
case _CanvasPattern.classic:
|
||||
// Dense grid: every line drawn; every 2Nth emphasised.
|
||||
final lineCount = (size.height / _spacing).ceil() + 1;
|
||||
final colCount = (size.width / _spacing).ceil() + 1;
|
||||
for (int i = 0; i < lineCount; i++) {
|
||||
final y = i * _spacing;
|
||||
final p = i % (_lineSkip * 2) == 0 ? primaryLine : secondaryLine;
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), p);
|
||||
}
|
||||
for (int i = 0; i < colCount; i++) {
|
||||
final x = i * _spacing;
|
||||
final p = i % (_lineSkip * 2) == 0 ? primaryLine : secondaryLine;
|
||||
canvas.drawLine(Offset(x, 0), Offset(x, size.height), p);
|
||||
}
|
||||
for (int i = 0; i < lineCount; i += _lineSkip * 2) {
|
||||
for (int j = 0; j < colCount; j += _lineSkip * 2) {
|
||||
canvas.drawCircle(
|
||||
Offset(j * _spacing, i * _spacing),
|
||||
0.9,
|
||||
dotPaint,
|
||||
);
|
||||
}
|
||||
}
|
||||
case _CanvasPattern.blueprint:
|
||||
// Engineering-paper look: every line drawn, every Nth
|
||||
// emphasised, dots at major intersections.
|
||||
final lineCount = (size.height / _spacing).ceil() + 1;
|
||||
final colCount = (size.width / _spacing).ceil() + 1;
|
||||
for (int i = 0; i < lineCount; i++) {
|
||||
final y = i * _spacing;
|
||||
final p = i % _lineSkip == 0 ? primaryLine : secondaryLine;
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), p);
|
||||
}
|
||||
for (int i = 0; i < colCount; i++) {
|
||||
final x = i * _spacing;
|
||||
final p = i % _lineSkip == 0 ? primaryLine : secondaryLine;
|
||||
canvas.drawLine(Offset(x, 0), Offset(x, size.height), p);
|
||||
}
|
||||
for (int i = 0; i < lineCount; i += _lineSkip) {
|
||||
for (int j = 0; j < colCount; j += _lineSkip) {
|
||||
canvas.drawCircle(
|
||||
Offset(j * _spacing, i * _spacing),
|
||||
1.0,
|
||||
dotPaint,
|
||||
);
|
||||
}
|
||||
}
|
||||
case _CanvasPattern.minimal:
|
||||
// Almost nothing: every 2Nth line only, tiny dots.
|
||||
final lineCount = (size.height / _spacing).ceil() + 1;
|
||||
final colCount = (size.width / _spacing).ceil() + 1;
|
||||
for (int i = 0; i < lineCount; i++) {
|
||||
if (i % (_lineSkip * 2) != 0) continue;
|
||||
final y = i * _spacing;
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), primaryLine);
|
||||
}
|
||||
for (int i = 0; i < colCount; i++) {
|
||||
if (i % (_lineSkip * 2) != 0) continue;
|
||||
final x = i * _spacing;
|
||||
canvas.drawLine(Offset(x, 0), Offset(x, size.height), primaryLine);
|
||||
}
|
||||
for (int i = 0; i < lineCount; i += _lineSkip * 2) {
|
||||
for (int j = 0; j < colCount; j += _lineSkip * 2) {
|
||||
canvas.drawCircle(
|
||||
Offset(j * _spacing, i * _spacing),
|
||||
0.5,
|
||||
dotPaint,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue