From 3a6e92fe09ae8f423ab99d990c9459d937810717 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Tue, 2 Jun 2026 00:37:03 +0200 Subject: [PATCH] fix(editor): per-field output dots + dynamic card width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues Stefan spotted in the screenshot: 1. Output port labels rendered on the right side of each step card, but the canvas-side port DOTS were missing — only the inputs side carried interactive dots. The _portOverlays generator looped once per step and stamped a single anchor; with per-field outputs we now loop per declared (or YAML-implied) output field and yield one dot per field, each with its own drag-start handler that stamps fromField into the ConnectionDraft. New edges out of those dots persist the precise output name (e.g. $summarize.response) instead of the placeholder $summarize.result. 2. Long labels (model_endpoint, source_language) were ellipsis-clipped because every card rendered at the 220 px fixed minimum. NodeGeometry now exposes widthFor(maxInputChars, maxOutputChars) that grows the card to fit the longest label on each side, plus gutters and slack. _stepWidth(step) on the canvas computes it per step from the merged label lists; _outputPortPosition, fit-to-content, and _stepPositioned all route through it so dots, edges, and bounding boxes stay aligned when cards stretch. Connected-ports set keys per-field on the source side too (${stepId}:${fieldName}) so the dot fills the way the input-side dots do once any edge actually leaves it. Legacy ${stepId}:__out__ stays seeded for the no-declared-outputs fallback. Bumps fai_studio_flow_editor to 0.10.2. Signed-off-by: flemming-it --- lib/src/widgets/flow_canvas.dart | 129 ++++++++++++++++++++++++------- lib/src/widgets/flow_node.dart | 40 +++++++++- pubspec.yaml | 2 +- 3 files changed, 143 insertions(+), 28 deletions(-) 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