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
|
|
@ -37,4 +37,6 @@ export 'src/run_driver.dart'
|
|||
FlowOutputValue,
|
||||
FlowOutputText,
|
||||
FlowOutputJson,
|
||||
FlowOutputBytes;
|
||||
FlowOutputBytes,
|
||||
ModuleSpec,
|
||||
ModuleField;
|
||||
|
|
|
|||
|
|
@ -503,7 +503,12 @@ outputs:
|
|||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: FlowCanvas(controller: _controller, style: style),
|
||||
child: FlowCanvas(
|
||||
controller: _controller,
|
||||
style: style,
|
||||
driver: widget.runDriver,
|
||||
locale: widget.locale,
|
||||
),
|
||||
),
|
||||
if (!hasSteps) emptyOverlay(),
|
||||
if (hasSelection)
|
||||
|
|
@ -548,7 +553,12 @@ outputs:
|
|||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
FlowCanvas(controller: _controller, style: style),
|
||||
FlowCanvas(
|
||||
controller: _controller,
|
||||
style: style,
|
||||
driver: widget.runDriver,
|
||||
locale: widget.locale,
|
||||
),
|
||||
if (!hasSteps) emptyOverlay(),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -38,6 +38,75 @@ abstract class FlowRunDriver {
|
|||
/// may emit events for unrelated runs; the editor filters
|
||||
/// by [FlowRunEvent.flowName] before applying.
|
||||
Stream<FlowRunEvent> events();
|
||||
|
||||
/// Look up an installed module's manifest by capability
|
||||
/// reference. The editor uses this to render per-field
|
||||
/// input/output ports with their declared names + types +
|
||||
/// tooltips, instead of collapsing every downstream reference
|
||||
/// into a single output anchor.
|
||||
///
|
||||
/// Default returns `null` so legacy hosts that didn't update
|
||||
/// their FlowRunDriver implementation keep compiling — the
|
||||
/// editor falls back to the YAML-reference-derived ports.
|
||||
/// A real implementation hits the hub's ModuleInfo RPC and
|
||||
/// maps the result to [ModuleSpec].
|
||||
Future<ModuleSpec?> moduleInfo(String capability) async => null;
|
||||
}
|
||||
|
||||
/// Declared inputs + outputs of one installed module, as seen
|
||||
/// by the editor. The shape mirrors `ModuleInfoResponse.inputs`
|
||||
/// / `outputs` over gRPC but without proto dependencies.
|
||||
class ModuleSpec {
|
||||
/// Capability identifier the host resolved (e.g. `text.summarize`).
|
||||
final String capability;
|
||||
|
||||
/// Declared inputs, in stable alphabetical order. The editor
|
||||
/// draws one input port per entry.
|
||||
final List<ModuleField> inputs;
|
||||
|
||||
/// Declared outputs, in stable alphabetical order. The editor
|
||||
/// draws one output port per entry — this is what makes
|
||||
/// `summarize.response`, `summarize.model_endpoint`,
|
||||
/// `summarize.model_name`, `summarize.model_digest` appear as
|
||||
/// four distinct anchors instead of one.
|
||||
final List<ModuleField> outputs;
|
||||
|
||||
const ModuleSpec({
|
||||
required this.capability,
|
||||
required this.inputs,
|
||||
required this.outputs,
|
||||
});
|
||||
}
|
||||
|
||||
/// One declared input or output field. Carries the type
|
||||
/// descriptor (`text` / `json` / `bytes` / `file`) and a locale
|
||||
/// → description map for tooltip rendering.
|
||||
class ModuleField {
|
||||
/// Field name, e.g. `prompt` or `model_endpoint`.
|
||||
final String name;
|
||||
|
||||
/// Type descriptor.
|
||||
final String type;
|
||||
|
||||
/// IETF locale tag → description string. Empty when the
|
||||
/// manifest used the shorthand form (no description).
|
||||
final Map<String, String> description;
|
||||
|
||||
const ModuleField({
|
||||
required this.name,
|
||||
required this.type,
|
||||
this.description = const {},
|
||||
});
|
||||
|
||||
/// Description in [locale] with English fallback. Returns
|
||||
/// null when the manifest carries no description for either.
|
||||
String? descriptionFor(String locale) {
|
||||
final exact = description[locale];
|
||||
if (exact != null && exact.isNotEmpty) return exact;
|
||||
final en = description['en'];
|
||||
if (en != null && en.isNotEmpty) return en;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// What the editor needs out of a single event tick. Maps
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,12 +55,13 @@ class NodeGeometry {
|
|||
/// room) so the dot reads as the port without crowding.
|
||||
static const double portDotSize = 12;
|
||||
|
||||
/// Total card height for a node with [portCount] inputs.
|
||||
static double heightFor(int portCount) {
|
||||
return headerHeight +
|
||||
bodyTopPad +
|
||||
portCount * portRowHeight +
|
||||
bodyBottomPad;
|
||||
/// 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.
|
||||
static double heightFor(int inputCount, [int outputCount = 0]) {
|
||||
final rows = inputCount > outputCount ? inputCount : outputCount;
|
||||
return headerHeight + bodyTopPad + rows * portRowHeight + bodyBottomPad;
|
||||
}
|
||||
|
||||
/// Y offset (from the card's top edge) where the input
|
||||
|
|
@ -73,11 +74,26 @@ class NodeGeometry {
|
|||
portRowHeight / 2;
|
||||
}
|
||||
|
||||
/// Y offset of the single output port. Vertically centred
|
||||
/// in the header so the edge entry feels balanced and so
|
||||
/// "the step's primary output" reads as a peer of the
|
||||
/// title rather than buried under the with-fields.
|
||||
static double outputPortY() {
|
||||
/// Y offset of the output port at row [index] on the right
|
||||
/// side of the card. Same row geometry as inputs so the
|
||||
/// rows visually align.
|
||||
///
|
||||
/// When the node has NO declared outputs the canvas falls
|
||||
/// back to a single anchor centred in the header (the
|
||||
/// legacy "one output per step" behaviour) — call
|
||||
/// [outputAnchorY] for that mode.
|
||||
static double outputPortY(int index) {
|
||||
return headerHeight +
|
||||
bodyTopPad +
|
||||
index * portRowHeight +
|
||||
portRowHeight / 2;
|
||||
}
|
||||
|
||||
/// Y offset of the legacy single output port — vertically
|
||||
/// centred in the header. Used for nodes whose ModuleSpec
|
||||
/// the host hasn't resolved yet, so the editor can still
|
||||
/// draw edges before the per-field info arrives.
|
||||
static double outputAnchorY() {
|
||||
return headerHeight / 2;
|
||||
}
|
||||
}
|
||||
|
|
@ -103,6 +119,20 @@ class FlowNode extends StatelessWidget {
|
|||
final String title;
|
||||
final String? subtitle;
|
||||
final List<String> inputPortLabels;
|
||||
|
||||
/// Output port labels rendered on the RIGHT side of the
|
||||
/// card, one per declared output field. Empty for endpoint
|
||||
/// nodes (inputs / outputs sidebars) and for step nodes
|
||||
/// whose ModuleSpec is still loading — in that case the
|
||||
/// canvas draws a single legacy output anchor at the
|
||||
/// header midpoint.
|
||||
final List<String> outputPortLabels;
|
||||
|
||||
/// Optional tooltips per port label, keyed by label. Shown
|
||||
/// when the operator hovers the label. Used to surface
|
||||
/// each field's bilingual `description.en/de` from
|
||||
/// schema_version 3 manifests.
|
||||
final Map<String, String> portTooltips;
|
||||
final NodeVisualKind kind;
|
||||
final NodePortSide portSide;
|
||||
final bool selected;
|
||||
|
|
@ -146,6 +176,8 @@ class FlowNode extends StatelessWidget {
|
|||
required this.title,
|
||||
this.subtitle,
|
||||
this.inputPortLabels = const [],
|
||||
this.outputPortLabels = const [],
|
||||
this.portTooltips = const {},
|
||||
this.kind = NodeVisualKind.module,
|
||||
this.portSide = NodePortSide.left,
|
||||
this.optionalLabels = const {},
|
||||
|
|
@ -163,7 +195,10 @@ class FlowNode extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final accent = _accent(theme.colorScheme);
|
||||
final height = NodeGeometry.heightFor(inputPortLabels.length);
|
||||
final height = NodeGeometry.heightFor(
|
||||
inputPortLabels.length,
|
||||
outputPortLabels.length,
|
||||
);
|
||||
return SizedBox(
|
||||
width: NodeGeometry.width,
|
||||
height: height,
|
||||
|
|
@ -304,57 +339,46 @@ class FlowNode extends StatelessWidget {
|
|||
}
|
||||
|
||||
Widget _body(ThemeData theme) {
|
||||
if (inputPortLabels.isEmpty) {
|
||||
if (inputPortLabels.isEmpty && outputPortLabels.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final align = portSide == NodePortSide.right
|
||||
? TextAlign.right
|
||||
: TextAlign.left;
|
||||
final crossAlign = portSide == NodePortSide.right
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start;
|
||||
// Leave a clear gutter on the port side so the canvas
|
||||
// dot has somewhere to land. Other side gets normal
|
||||
// padding.
|
||||
final padding = portSide == NodePortSide.right
|
||||
? const EdgeInsets.fromLTRB(
|
||||
FaiSpace.sm,
|
||||
NodeGeometry.bodyTopPad,
|
||||
NodeGeometry.portGutter,
|
||||
NodeGeometry.bodyBottomPad,
|
||||
)
|
||||
: const EdgeInsets.fromLTRB(
|
||||
NodeGeometry.portGutter,
|
||||
NodeGeometry.bodyTopPad,
|
||||
FaiSpace.sm,
|
||||
NodeGeometry.bodyBottomPad,
|
||||
);
|
||||
// Outer padding matches the side(s) that actually carry
|
||||
// ports — the gutter exists so the canvas-side dot has
|
||||
// somewhere to land without overlapping the label text.
|
||||
final left = inputPortLabels.isNotEmpty || portSide == NodePortSide.left
|
||||
? NodeGeometry.portGutter
|
||||
: FaiSpace.sm;
|
||||
final right = outputPortLabels.isNotEmpty || portSide == NodePortSide.right
|
||||
? NodeGeometry.portGutter
|
||||
: FaiSpace.sm;
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: Column(
|
||||
crossAxisAlignment: crossAlign,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
left,
|
||||
NodeGeometry.bodyTopPad,
|
||||
right,
|
||||
NodeGeometry.bodyBottomPad,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final label in inputPortLabels)
|
||||
SizedBox(
|
||||
height: NodeGeometry.portRowHeight,
|
||||
child: Align(
|
||||
alignment: portSide == NodePortSide.right
|
||||
? Alignment.centerRight
|
||||
: Alignment.centerLeft,
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: align,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: optionalLabels.contains(label)
|
||||
? theme.colorScheme.onSurface.withValues(alpha: 0.55)
|
||||
: theme.colorScheme.onSurface,
|
||||
fontStyle: optionalLabels.contains(label)
|
||||
? FontStyle.italic
|
||||
: FontStyle.normal,
|
||||
),
|
||||
),
|
||||
if (inputPortLabels.isNotEmpty)
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final label in inputPortLabels)
|
||||
_portLabel(theme, label, TextAlign.left),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (outputPortLabels.isNotEmpty)
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (final label in outputPortLabels)
|
||||
_portLabel(theme, label, TextAlign.right),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -362,6 +386,37 @@ class FlowNode extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _portLabel(ThemeData theme, String label, TextAlign align) {
|
||||
final muted = optionalLabels.contains(label);
|
||||
final widget = SizedBox(
|
||||
height: NodeGeometry.portRowHeight,
|
||||
child: Align(
|
||||
alignment: align == TextAlign.right
|
||||
? Alignment.centerRight
|
||||
: Alignment.centerLeft,
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: align,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: muted
|
||||
? theme.colorScheme.onSurface.withValues(alpha: 0.55)
|
||||
: theme.colorScheme.onSurface,
|
||||
fontStyle: muted ? FontStyle.italic : FontStyle.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final tip = portTooltips[label];
|
||||
if (tip == null || tip.isEmpty) return widget;
|
||||
return Tooltip(
|
||||
message: tip,
|
||||
waitDuration: const Duration(milliseconds: 350),
|
||||
child: widget,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _wiredBadge(ThemeData theme, Color accent) {
|
||||
final total = inputPortLabels.length;
|
||||
final wired = wiredCount ?? 0;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue