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:
flemming-it 2026-06-01 22:32:26 +02:00
parent 1f5601a461
commit b1fe765468
6 changed files with 505 additions and 87 deletions

View file

@ -37,4 +37,6 @@ export 'src/run_driver.dart'
FlowOutputValue, FlowOutputValue,
FlowOutputText, FlowOutputText,
FlowOutputJson, FlowOutputJson,
FlowOutputBytes; FlowOutputBytes,
ModuleSpec,
ModuleField;

View file

@ -503,7 +503,12 @@ outputs:
return Stack( return Stack(
children: [ children: [
Positioned.fill( Positioned.fill(
child: FlowCanvas(controller: _controller, style: style), child: FlowCanvas(
controller: _controller,
style: style,
driver: widget.runDriver,
locale: widget.locale,
),
), ),
if (!hasSteps) emptyOverlay(), if (!hasSteps) emptyOverlay(),
if (hasSelection) if (hasSelection)
@ -548,7 +553,12 @@ outputs:
Expanded( Expanded(
child: Stack( child: Stack(
children: [ children: [
FlowCanvas(controller: _controller, style: style), FlowCanvas(
controller: _controller,
style: style,
driver: widget.runDriver,
locale: widget.locale,
),
if (!hasSteps) emptyOverlay(), if (!hasSteps) emptyOverlay(),
], ],
), ),

View file

@ -38,6 +38,75 @@ abstract class FlowRunDriver {
/// may emit events for unrelated runs; the editor filters /// may emit events for unrelated runs; the editor filters
/// by [FlowRunEvent.flowName] before applying. /// by [FlowRunEvent.flowName] before applying.
Stream<FlowRunEvent> events(); 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 /// What the editor needs out of a single event tick. Maps

View file

@ -36,9 +36,11 @@ import 'package:flutter/material.dart';
import '../editor_controller.dart'; import '../editor_controller.dart';
import '../editor_style.dart'; import '../editor_style.dart';
import '../l10n.dart';
import '../model/auto_layout.dart'; import '../model/auto_layout.dart';
import '../model/flow_graph.dart'; import '../model/flow_graph.dart';
import '../model/layout_store.dart'; import '../model/layout_store.dart';
import '../run_driver.dart';
import '../tokens.dart'; import '../tokens.dart';
import 'edge_painter.dart'; import 'edge_painter.dart';
import 'flow_node.dart'; import 'flow_node.dart';
@ -59,9 +61,25 @@ const NodePosition _outputsFallback = NodePosition(1200, 80);
class FlowCanvas extends StatefulWidget { class FlowCanvas extends StatefulWidget {
final FlowEditorController controller; final FlowEditorController controller;
final FaiEditorStyle style; 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({ const FlowCanvas({
super.key, super.key,
required this.controller, required this.controller,
this.driver,
this.locale = FlowEditorLocale.en,
this.style = FaiEditorStyle.modern, this.style = FaiEditorStyle.modern,
}); });
@ -104,6 +122,19 @@ class _FlowCanvasState extends State<FlowCanvas>
// Operator-chosen background pattern. Cycles via the // Operator-chosen background pattern. Cycles via the
// pattern button on the bottom-right canvas controls. // pattern button on the bottom-right canvas controls.
_CanvasPattern _pattern = _CanvasPattern.dots; _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) // Current zoom level (taken from the TransformationController)
// so the bottom-right indicator can show it as a %. // so the bottom-right indicator can show it as a %.
double _zoom = 1.0; 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() { void _onControllerChanged() {
if (!mounted) return; if (!mounted) return;
// Start the flow-animation controller only while a step // Start the flow-animation controller only while a step
@ -160,6 +239,11 @@ class _FlowCanvasState extends State<FlowCanvas>
final theme = Theme.of(context); final theme = Theme.of(context);
final graph = widget.controller.graph; final graph = widget.controller.graph;
final layout = widget.controller.layout; 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 // Endpoint positions now live in the layout sidecar
// alongside every step's position — see // alongside every step's position — see
// AutoLayout.layout() which seeds defaults. Reading them // AutoLayout.layout() which seeds defaults. Reading them
@ -391,6 +475,26 @@ class _FlowCanvasState extends State<FlowCanvas>
Icons.grid_on, Icons.grid_on,
'Grid', '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( _patternMenuItem(
_CanvasPattern.blank, _CanvasPattern.blank,
Icons.layers_clear, Icons.layers_clear,
@ -535,6 +639,33 @@ class _FlowCanvasState extends State<FlowCanvas>
.whereType<Object>() .whereType<Object>()
.where((v) => _isWiredExpression(v.toString())) .where((v) => _isWiredExpression(v.toString()))
.length; .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( return Positioned(
left: pos.x, left: pos.x,
top: pos.y, top: pos.y,
@ -542,19 +673,16 @@ class _FlowCanvasState extends State<FlowCanvas>
id: step.id, id: step.id,
title: step.id, title: step.id,
subtitle: step.use, subtitle: step.use,
inputPortLabels: step.with_.keys.toList(), inputPortLabels: inputLabels,
outputPortLabels: outputLabels,
portTooltips: tooltips,
wiredCount: wired, wiredCount: wired,
kind: kindForStep(step), kind: kindForStep(step),
selected: selected, selected: selected,
status: status, status: status,
elevated: widget.style.nodeShadows, elevated: widget.style.nodeShadows,
onTap: () => widget.controller.selectStep(step.id), onTap: () => widget.controller.selectStep(step.id),
onDrag: (delta) => _applyDrag( onDrag: (delta) => _applyDrag(step.id, pos, delta, cardHeight),
step.id,
pos,
delta,
NodeGeometry.heightFor(step.with_.length),
),
onContextMenu: (globalPos) => _showStepContextMenu(step, globalPos), onContextMenu: (globalPos) => _showStepContextMenu(step, globalPos),
), ),
); );
@ -624,14 +752,36 @@ class _FlowCanvasState extends State<FlowCanvas>
// --- Port positions in canvas coordinates --- // --- Port positions in canvas coordinates ---
/// Right-edge output port for a step node. /// Right-edge output port for a step node. When the step's
Offset _outputPortPosition(String nodeId, FlowLayout layout) { /// 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]; final pos = layout.positions[nodeId];
if (pos == null) return Offset.zero; if (pos == null) return Offset.zero;
return Offset( final step = widget.controller.graph.steps.firstWhere(
pos.x + NodeGeometry.width, (s) => s.id == nodeId,
pos.y + NodeGeometry.outputPortY(), 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 /// Left-edge input port for any node. Works for step nodes
@ -675,7 +825,11 @@ class _FlowCanvasState extends State<FlowCanvas>
fromSide = EdgeSide.right; fromSide = EdgeSide.right;
} }
} else if (edge.fromKind == EdgeEndpointKind.step) { } 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. // Step output is on the right edge.
fromSide = EdgeSide.right; fromSide = EdgeSide.right;
} }
@ -684,7 +838,15 @@ class _FlowCanvasState extends State<FlowCanvas>
(s) => s.id == edge.toId, (s) => s.id == edge.toId,
orElse: () => const FlowStep(id: '__missing__', use: ''), 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) { if (idx >= 0) {
to = _inputPortPosition(edge.toId, idx, layout); to = _inputPortPosition(edge.toId, idx, layout);
toSide = EdgeSide.left; toSide = EdgeSide.left;
@ -776,14 +938,22 @@ class _FlowCanvasState extends State<FlowCanvas>
final idx = inputsList.indexOf(edge.fromField); final idx = inputsList.indexOf(edge.fromField);
if (idx >= 0) from = _inputsEndpointPortPosition(idx, layout); if (idx >= 0) from = _inputsEndpointPortPosition(idx, layout);
} else if (edge.fromKind == EdgeEndpointKind.step) { } else if (edge.fromKind == EdgeEndpointKind.step) {
from = _outputPortPosition(edge.fromId, layout); from = _outputPortPosition(
edge.fromId,
layout,
fieldName: edge.fromField,
);
} }
if (edge.toKind == EdgeEndpointKind.step) { if (edge.toKind == EdgeEndpointKind.step) {
final step = graph.steps.firstWhere( final step = graph.steps.firstWhere(
(s) => s.id == edge.toId, (s) => s.id == edge.toId,
orElse: () => const FlowStep(id: '__missing__', use: ''), 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); if (idx >= 0) to = _inputPortPosition(edge.toId, idx, layout);
} else if (edge.toKind == EdgeEndpointKind.outputs) { } else if (edge.toKind == EdgeEndpointKind.outputs) {
final outs = graph.outputs.keys.toList(); final outs = graph.outputs.keys.toList();
@ -908,6 +1078,10 @@ class _FlowCanvasState extends State<FlowCanvas>
_CanvasPattern.dots => Icons.grain, _CanvasPattern.dots => Icons.grain,
_CanvasPattern.grid => Icons.grid_on, _CanvasPattern.grid => Icons.grid_on,
_CanvasPattern.blank => Icons.layers_clear, _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 { class _PatternPainter extends CustomPainter {
final Color color; final Color color;
final _CanvasPattern pattern; final _CanvasPattern pattern;
_PatternPainter({required this.color, required this.pattern}); _PatternPainter({required this.color, required this.pattern});
static const double _spacing = 24.0;
static const int _lineSkip = 3;
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
const spacing = 24.0; final dotPaint = Paint()..color = color;
final paint = 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) { switch (pattern) {
case _CanvasPattern.dots: case _CanvasPattern.dots:
for (double x = 0; x < size.width; x += spacing) { for (double x = 0; x < size.width; x += _spacing) {
for (double y = 0; y < size.height; y += spacing) { for (double y = 0; y < size.height; y += _spacing) {
canvas.drawCircle(Offset(x, y), 0.8, paint); canvas.drawCircle(Offset(x, y), 0.8, dotPaint);
} }
} }
case _CanvasPattern.grid: case _CanvasPattern.grid:
final linePaint = Paint() final linePaint = Paint()
..color = color.withValues(alpha: 0.5) ..color = color.withValues(alpha: 0.5)
..strokeWidth = 0.6; ..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); 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); canvas.drawLine(Offset(0, y), Offset(size.width, y), linePaint);
} }
case _CanvasPattern.blank: case _CanvasPattern.blank:
// No painting the surface colour shows through. // No painting the surface colour shows through.
break; 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,
);
}
}
} }
} }

View file

@ -55,12 +55,13 @@ class NodeGeometry {
/// room) so the dot reads as the port without crowding. /// room) so the dot reads as the port without crowding.
static const double portDotSize = 12; static const double portDotSize = 12;
/// Total card height for a node with [portCount] inputs. /// Total card height for a node carrying [inputCount] inputs
static double heightFor(int portCount) { /// on the left + [outputCount] outputs on the right. The
return headerHeight + /// taller side wins; the card always reserves at least one
bodyTopPad + /// row of body so the header doesn't sit naked.
portCount * portRowHeight + static double heightFor(int inputCount, [int outputCount = 0]) {
bodyBottomPad; final rows = inputCount > outputCount ? inputCount : outputCount;
return headerHeight + bodyTopPad + rows * portRowHeight + bodyBottomPad;
} }
/// Y offset (from the card's top edge) where the input /// Y offset (from the card's top edge) where the input
@ -73,11 +74,26 @@ class NodeGeometry {
portRowHeight / 2; portRowHeight / 2;
} }
/// Y offset of the single output port. Vertically centred /// Y offset of the output port at row [index] on the right
/// in the header so the edge entry feels balanced and so /// side of the card. Same row geometry as inputs so the
/// "the step's primary output" reads as a peer of the /// rows visually align.
/// title rather than buried under the with-fields. ///
static double outputPortY() { /// 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; return headerHeight / 2;
} }
} }
@ -103,6 +119,20 @@ class FlowNode extends StatelessWidget {
final String title; final String title;
final String? subtitle; final String? subtitle;
final List<String> inputPortLabels; 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 NodeVisualKind kind;
final NodePortSide portSide; final NodePortSide portSide;
final bool selected; final bool selected;
@ -146,6 +176,8 @@ class FlowNode extends StatelessWidget {
required this.title, required this.title,
this.subtitle, this.subtitle,
this.inputPortLabels = const [], this.inputPortLabels = const [],
this.outputPortLabels = const [],
this.portTooltips = const {},
this.kind = NodeVisualKind.module, this.kind = NodeVisualKind.module,
this.portSide = NodePortSide.left, this.portSide = NodePortSide.left,
this.optionalLabels = const {}, this.optionalLabels = const {},
@ -163,7 +195,10 @@ class FlowNode extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final accent = _accent(theme.colorScheme); final accent = _accent(theme.colorScheme);
final height = NodeGeometry.heightFor(inputPortLabels.length); final height = NodeGeometry.heightFor(
inputPortLabels.length,
outputPortLabels.length,
);
return SizedBox( return SizedBox(
width: NodeGeometry.width, width: NodeGeometry.width,
height: height, height: height,
@ -304,41 +339,59 @@ class FlowNode extends StatelessWidget {
} }
Widget _body(ThemeData theme) { Widget _body(ThemeData theme) {
if (inputPortLabels.isEmpty) { if (inputPortLabels.isEmpty && outputPortLabels.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final align = portSide == NodePortSide.right // Outer padding matches the side(s) that actually carry
? TextAlign.right // ports the gutter exists so the canvas-side dot has
: TextAlign.left; // somewhere to land without overlapping the label text.
final crossAlign = portSide == NodePortSide.right final left = inputPortLabels.isNotEmpty || portSide == NodePortSide.left
? CrossAxisAlignment.end ? NodeGeometry.portGutter
: CrossAxisAlignment.start; : FaiSpace.sm;
// Leave a clear gutter on the port side so the canvas final right = outputPortLabels.isNotEmpty || portSide == NodePortSide.right
// dot has somewhere to land. Other side gets normal ? NodeGeometry.portGutter
// padding. : FaiSpace.sm;
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,
);
return Padding( return Padding(
padding: padding, padding: EdgeInsets.fromLTRB(
left,
NodeGeometry.bodyTopPad,
right,
NodeGeometry.bodyBottomPad,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (inputPortLabels.isNotEmpty)
Expanded(
child: Column( child: Column(
crossAxisAlignment: crossAlign, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
for (final label in inputPortLabels) for (final label in inputPortLabels)
SizedBox( _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),
],
),
),
],
),
);
}
Widget _portLabel(ThemeData theme, String label, TextAlign align) {
final muted = optionalLabels.contains(label);
final widget = SizedBox(
height: NodeGeometry.portRowHeight, height: NodeGeometry.portRowHeight,
child: Align( child: Align(
alignment: portSide == NodePortSide.right alignment: align == TextAlign.right
? Alignment.centerRight ? Alignment.centerRight
: Alignment.centerLeft, : Alignment.centerLeft,
child: Text( child: Text(
@ -347,18 +400,20 @@ class FlowNode extends StatelessWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
color: optionalLabels.contains(label) color: muted
? theme.colorScheme.onSurface.withValues(alpha: 0.55) ? theme.colorScheme.onSurface.withValues(alpha: 0.55)
: theme.colorScheme.onSurface, : theme.colorScheme.onSurface,
fontStyle: optionalLabels.contains(label) fontStyle: muted ? FontStyle.italic : FontStyle.normal,
? 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,
); );
} }

View file

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