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