diff --git a/lib/fai_studio_flow_editor.dart b/lib/fai_studio_flow_editor.dart index 0ca9e2d..65002c6 100644 --- a/lib/fai_studio_flow_editor.dart +++ b/lib/fai_studio_flow_editor.dart @@ -37,4 +37,6 @@ export 'src/run_driver.dart' FlowOutputValue, FlowOutputText, FlowOutputJson, - FlowOutputBytes; + FlowOutputBytes, + ModuleSpec, + ModuleField; diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index b9617e0..71bcdf3 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -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(), ], ), diff --git a/lib/src/run_driver.dart b/lib/src/run_driver.dart index 1aa16e6..a6351ce 100644 --- a/lib/src/run_driver.dart +++ b/lib/src/run_driver.dart @@ -38,6 +38,75 @@ abstract class FlowRunDriver { /// may emit events for unrelated runs; the editor filters /// by [FlowRunEvent.flowName] before applying. Stream 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 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 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 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 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 diff --git a/lib/src/widgets/flow_canvas.dart b/lib/src/widgets/flow_canvas.dart index 3737bb4..89f459a 100644 --- a/lib/src/widgets/flow_canvas.dart +++ b/lib/src/widgets/flow_canvas.dart @@ -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.` 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 // 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 _moduleSpecs = {}; + // Capabilities whose moduleInfo() call is already in + // flight, so we don't re-trigger on every rebuild. + final Set _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 } } + /// 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 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 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 .whereType() .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 []; + final tooltips = {}; + 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 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 // --- 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 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 (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 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 _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, + ); + } + } } } diff --git a/lib/src/widgets/flow_node.dart b/lib/src/widgets/flow_node.dart index d2ff009..a0a345f 100644 --- a/lib/src/widgets/flow_node.dart +++ b/lib/src/widgets/flow_node.dart @@ -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 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 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 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; diff --git a/pubspec.yaml b/pubspec.yaml index 6533595..adb748e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: fai_studio_flow_editor description: Swappable inline YAML editor for F∆I Studio flows. -version: 0.8.0 +version: 0.9.0 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor