diff --git a/lib/src/widgets/flow_canvas.dart b/lib/src/widgets/flow_canvas.dart index cd103f6..213eff1 100644 --- a/lib/src/widgets/flow_canvas.dart +++ b/lib/src/widgets/flow_canvas.dart @@ -246,6 +246,25 @@ class _FlowCanvasState extends State return [...declared, ...implicit]; } + /// Width to render a step card with. Grows beyond the + /// default minimum when port labels would otherwise be + /// ellipsis-clipped. Returns the value all geometry + /// helpers (output-port position, fit-to-content, + /// drop-target hit-tests) should use for that step. + double _stepWidth(FlowStep step) { + final ins = _inputLabelsForStep(step); + final outs = _outputLabelsForStep(step); + var maxIn = 0; + for (final l in ins) { + if (l.length > maxIn) maxIn = l.length; + } + var maxOut = 0; + for (final l in outs) { + if (l.length > maxOut) maxOut = l.length; + } + return NodeGeometry.widthFor(maxInputChars: maxIn, maxOutputChars: maxOut); + } + /// Merge ModuleSpec-declared output names with whatever /// the flow YAML's edges reference for this step. Declared /// outputs keep their manifest order; implicit (referenced @@ -664,8 +683,13 @@ class _FlowCanvasState extends State if (pos == null) continue; if (pos.x < minX) minX = pos.x; if (pos.y < minY) minY = pos.y; - final right = pos.x + NodeGeometry.width; - final bottom = pos.y + NodeGeometry.heightFor(step.with_.length); + final right = pos.x + _stepWidth(step); + final bottom = + pos.y + + NodeGeometry.heightFor( + _inputLabelsForStep(step).length, + _outputLabelsForStep(step).length, + ); if (right > maxX) maxX = right; if (bottom > maxY) maxY = bottom; } @@ -738,10 +762,12 @@ class _FlowCanvasState extends State inputLabels.length, outputLabels.length, ); + final cardWidth = _stepWidth(step); return Positioned( left: pos.x, top: pos.y, child: FlowNode( + width: cardWidth, id: step.id, title: step.id, subtitle: step.use, @@ -860,7 +886,11 @@ class _FlowCanvasState extends State } else { y = NodeGeometry.outputAnchorY(); } - return Offset(pos.x + NodeGeometry.width, pos.y + y); + // Use the step's actual rendered width so the port dot + // hugs the right edge of cards that grew wider to fit + // long labels (e.g. model_endpoint / source_language). + final w = step.id == '__missing__' ? NodeGeometry.width : _stepWidth(step); + return Offset(pos.x + w, pos.y + y); } /// Left-edge input port for any node. Works for step nodes @@ -1370,23 +1400,53 @@ class _FlowCanvasState extends State // wiring state. Keyed by "nodeId:fieldName" both sides. final connectedPorts = _connectedPorts(graph); - // Output ports — step nodes' right edges. + // Output ports — step nodes' right edges. One dot per + // declared (or YAML-implied) output field so the operator + // can grab any specific output to drag a wire. Falls back + // to a single legacy anchor when the step has no resolved + // outputs at all (e.g. brand-new step with no edges yet). for (final step in graph.steps) { - final p = _outputPortPosition(step.id, layout); - final key = '${step.id}:__out__'; - yield _portDot( - portKey: key, - center: p, - isSource: true, - connected: connectedPorts.contains(key), - accent: Theme.of(context).colorScheme.primary, - onDragStart: () => _draft = _ConnectionDraft( - fromKind: _DraftSourceKind.step, - fromId: step.id, - from: p, - cursor: p, - ), - ); + final labels = _outputLabelsForStep(step); + if (labels.isEmpty) { + final p = _outputPortPosition(step.id, layout); + final key = '${step.id}:__out__'; + yield _portDot( + portKey: key, + center: p, + isSource: true, + connected: connectedPorts.contains(key), + accent: Theme.of(context).colorScheme.primary, + onDragStart: () => _draft = _ConnectionDraft( + fromKind: _DraftSourceKind.step, + fromId: step.id, + from: p, + cursor: p, + ), + ); + continue; + } + for (final field in labels) { + final p = _outputPortPosition(step.id, layout, fieldName: field); + final key = '${step.id}:$field'; + // A specific output is "connected" iff some edge + // actually leaves it. The drag handler stamps the + // edge's fromField so the new wire is bound to this + // specific output rather than the legacy anchor. + yield _portDot( + portKey: key, + center: p, + isSource: true, + connected: connectedPorts.contains(key), + accent: Theme.of(context).colorScheme.primary, + onDragStart: () => _draft = _ConnectionDraft( + fromKind: _DraftSourceKind.step, + fromId: step.id, + fromField: field, + from: p, + cursor: p, + ), + ); + } } // Inputs endpoint output ports — one per declared input. final inputsList = graph.inputs.keys.toList(); @@ -1472,7 +1532,14 @@ class _FlowCanvasState extends State if (edge.fromKind == EdgeEndpointKind.inputs) { set.add('inputs:${edge.fromField}'); } else if (edge.fromKind == EdgeEndpointKind.step) { - // Step's output is wired if ANY edge leaves it. + // Specific output is wired iff at least one edge + // leaves THIS field. The legacy `__out__` key is + // also seeded so the fallback single-anchor case + // (step with no declared outputs yet) still renders + // its dot as connected. + if (edge.fromField.isNotEmpty) { + set.add('${edge.fromId}:${edge.fromField}'); + } set.add('${edge.fromId}:__out__'); } // TO side @@ -1777,15 +1844,16 @@ class _FlowCanvasState extends State void _applyConnection(_ConnectionDraft draft, _DropTarget target) { final graph = widget.controller.graph; - // Compose the $source.field expression. For step - // sources, we don't know the precise output field name - // (modules have varied output names); use "result" as a - // placeholder so the YAML is syntactically valid, and - // let the operator refine it in the properties panel. + // Compose the $source.field expression. Per-field output + // ports stamp the precise field name into the draft; if + // the operator dragged from the legacy single anchor + // (no field), we fall back to "result" as the placeholder + // and let them rename in the properties panel. final String expression; switch (draft.fromKind) { case _DraftSourceKind.step: - expression = '\$${draft.fromId}.result'; + final field = draft.fromField.isNotEmpty ? draft.fromField : 'result'; + expression = '\$${draft.fromId}.$field'; case _DraftSourceKind.inputsField: expression = '\$inputs.${draft.fromId}'; } @@ -1972,17 +2040,26 @@ enum _DraftTargetKind { step, outputsField } class _ConnectionDraft { final _DraftSourceKind fromKind; final String fromId; + + /// Field name on the source step's output side. Empty for + /// the legacy single-anchor case or when the source is the + /// inputs endpoint (whose field is encoded in `fromId`). + /// When set, the new edge persists its `fromField` so the + /// per-field-output port stays addressable. + final String fromField; final Offset from; final Offset cursor; const _ConnectionDraft({ required this.fromKind, required this.fromId, + this.fromField = '', required this.from, required this.cursor, }); _ConnectionDraft withCursor(Offset c) => _ConnectionDraft( fromKind: fromKind, fromId: fromId, + fromField: fromField, from: from, cursor: c, ); diff --git a/lib/src/widgets/flow_node.dart b/lib/src/widgets/flow_node.dart index d0622ca..7d1414b 100644 --- a/lib/src/widgets/flow_node.dart +++ b/lib/src/widgets/flow_node.dart @@ -34,7 +34,38 @@ import '../tokens.dart'; /// its content) /// - bodyBottomPad class NodeGeometry { + /// Minimum card width — used when labels are short. Cards + /// grow beyond this via [widthFor] when their longest port + /// label wouldn't fit. static const double width = 220; + + /// Approximate render width per character at the + /// `labelSmall` text style used in port labels. Not exact + /// — we don't lay out text per build — but close enough + /// to ensure long labels like `model_endpoint` or + /// `source_language` aren't ellipsis-clipped on the + /// stock width. + static const double _charWidth = 7.5; + + /// Compute a card width that fits the longest input AND + /// output label without truncation. The two-column body + /// layout reserves a left gutter (input port dot), a + /// middle gap, and a right gutter (output port dot). We + /// add a small slack so labels don't kiss the column + /// boundary. + static double widthFor({ + required int maxInputChars, + required int maxOutputChars, + }) { + const minMiddleGap = 12.0; + const slack = 6.0; + final inputW = maxInputChars * _charWidth; + final outputW = maxOutputChars * _charWidth; + final needed = + portGutter + inputW + minMiddleGap + outputW + portGutter + slack; + return needed > width ? needed : width; + } + // Header band is constant whether the node has a subtitle // or not. Endpoint nodes (inputs / outputs) render an empty // subtitle slot — that keeps port-row Y-offsets identical @@ -167,6 +198,12 @@ class FlowNode extends StatelessWidget { /// the active editor style. final bool elevated; + /// Explicit card width. Defaults to [NodeGeometry.width]. + /// Callers pass a larger value computed from the longest + /// port label so labels like `model_endpoint` or + /// `source_language` don't ellipsis-clip. + final double width; + /// Live status from the most recent run, when this node is /// a step. Coloured dot in the header so the operator can /// glance at the canvas and see what's running. @@ -200,6 +237,7 @@ class FlowNode extends StatelessWidget { this.onContextMenu, this.elevated = true, this.pulse, + this.width = NodeGeometry.width, }); @override @@ -218,7 +256,7 @@ class FlowNode extends StatelessWidget { // animation source is wired or the step isn't running. if (pulse != null && status == FlowNodeStatus.running) { return SizedBox( - width: NodeGeometry.width, + width: width, height: height, child: AnimatedBuilder( animation: pulse!, diff --git a/pubspec.yaml b/pubspec.yaml index 1ae2e9b..b2c9d04 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.10.1 +version: 0.10.2 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor