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,
FlowOutputText,
FlowOutputJson,
FlowOutputBytes;
FlowOutputBytes,
ModuleSpec,
ModuleField;

View file

@ -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(),
],
),

View file

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

View file

@ -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,
);
}
}
}
}

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;