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

@ -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;