diff --git a/lib/src/widgets/flow_canvas.dart b/lib/src/widgets/flow_canvas.dart index 3536a01..5886441 100644 --- a/lib/src/widgets/flow_canvas.dart +++ b/lib/src/widgets/flow_canvas.dart @@ -152,26 +152,29 @@ class _FlowCanvasState extends State { ), ), ), - // Inputs endpoint — draggable, position - // from the layout sidecar. No selection - // affordance because there's no per-step - // properties to edit; click on the row - // does nothing for now. + // Inputs endpoint — its body labels + // represent OUTPUTS of the node (data flows + // OUT to downstream steps), so the port + // side is RIGHT and labels right-align. _endpointPositioned( nodeId: AutoLayout.inputsNodeId, pos: inputsPos, title: 'inputs', kind: NodeVisualKind.inputs, + portSide: NodePortSide.right, labels: graph.inputs.keys .map((k) => '$k: ${graph.inputs[k]!.type}') .toList(), ), - // Outputs endpoint — same model. + // Outputs endpoint — body labels represent + // INPUTS (data flows IN from steps), so + // port side is LEFT and labels left-align. _endpointPositioned( nodeId: AutoLayout.outputsNodeId, pos: outputsPos, title: 'outputs', kind: NodeVisualKind.outputs, + portSide: NodePortSide.left, labels: graph.outputs.keys.toList(), ), // Step nodes — positioned absolutely, drag to @@ -341,6 +344,7 @@ class _FlowCanvasState extends State { required NodePosition pos, required String title, required NodeVisualKind kind, + required NodePortSide portSide, required List labels, }) { final selected = widget.controller.selectedStepId == nodeId; @@ -351,6 +355,7 @@ class _FlowCanvasState extends State { id: nodeId, title: title, kind: kind, + portSide: portSide, inputPortLabels: labels, selected: selected, // Endpoints are selectable too — selecting opens @@ -472,12 +477,20 @@ class _FlowCanvasState extends State { // --- Port overlays (drag handles for creating edges) --- Iterable _portOverlays(FlowGraph graph, FlowLayout layout) sync* { + // Compute the connected-port set once per build so every + // port dot can render filled or outlined based on real + // wiring state. Keyed by "nodeId:fieldName" both sides. + final connectedPorts = _connectedPorts(graph); + // Output ports — step nodes' right edges. for (final step in graph.steps) { final p = _outputPortPosition(step.id, layout); + final key = '${step.id}:__out__'; yield _portDot( center: p, isSource: true, + connected: connectedPorts.contains(key), + accent: Theme.of(context).colorScheme.primary, onDragStart: () => _draft = _ConnectionDraft( fromKind: _DraftSourceKind.step, fromId: step.id, @@ -487,13 +500,17 @@ class _FlowCanvasState extends State { ); } // Inputs endpoint output ports — one per declared input. - final inputs = graph.inputs.keys.toList(); - for (var i = 0; i < inputs.length; i++) { + final inputsList = graph.inputs.keys.toList(); + for (var i = 0; i < inputsList.length; i++) { final p = _inputsEndpointPortPosition(i, layout); - final fieldName = inputs[i]; + final fieldName = inputsList[i]; + final input = graph.inputs[fieldName]!; + final key = 'inputs:$fieldName'; yield _portDot( center: p, isSource: true, + connected: connectedPorts.contains(key), + accent: _typeAccent(input.type, Theme.of(context)), onDragStart: () => _draft = _ConnectionDraft( fromKind: _DraftSourceKind.inputsField, fromId: fieldName, @@ -502,49 +519,184 @@ class _FlowCanvasState extends State { ), ); } - // Step input port targets — left edges. + // Step input port targets — left edges. Right-click on a + // wired port opens the disconnect menu. for (final step in graph.steps) { final keys = step.with_.keys.toList(); for (var i = 0; i < keys.length; i++) { final p = _inputPortPosition(step.id, i, layout); + final field = keys[i]; + final value = step.with_[field]?.toString() ?? ''; + final wired = _isWiredExpression(value); yield _portDot( center: p, isSource: false, - onDropTarget: _dropTargetFor(step.id, keys[i], _DraftTargetKind.step), + connected: wired, + accent: Theme.of(context).colorScheme.primary, + onContextMenu: !wired + ? null + : (pos) => _disconnectInputPort(step.id, field, pos), ); } } - // Outputs endpoint input ports. + // Outputs endpoint input ports — same disconnect treatment. final outs = graph.outputs.keys.toList(); for (var i = 0; i < outs.length; i++) { final p = _inputPortPosition(AutoLayout.outputsNodeId, i, layout); + final field = outs[i]; + final expr = graph.outputs[field] ?? ''; + final wired = _isWiredExpression(expr); yield _portDot( center: p, isSource: false, - onDropTarget: _dropTargetFor( - AutoLayout.outputsNodeId, - outs[i], - _DraftTargetKind.outputsField, - ), + connected: wired, + accent: Theme.of(context).colorScheme.primary, + onContextMenu: !wired + ? null + : (pos) => _disconnectOutputPort(field, pos), ); } } + /// True when [expression] looks like a `$src.field` ref + /// (the canonical wired-up form). Literal text or empty + /// values count as unwired. + bool _isWiredExpression(String expression) { + return RegExp( + r'\$[A-Za-z_][A-Za-z0-9_-]*\.[A-Za-z_][A-Za-z0-9_-]*', + ).hasMatch(expression); + } + + /// All port keys that participate in an edge. Used to + /// decide whether each port dot renders filled (connected) + /// or outlined (dangling). + Set _connectedPorts(FlowGraph graph) { + final set = {}; + for (final edge in graph.edges) { + // FROM side + 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. + set.add('${edge.fromId}:__out__'); + } + // TO side + if (edge.toKind == EdgeEndpointKind.step) { + set.add('${edge.toId}:${edge.toField}'); + } else if (edge.toKind == EdgeEndpointKind.outputs) { + set.add('outputs:${edge.toField}'); + } + } + return set; + } + + Color _typeAccent(String type, ThemeData theme) { + final cs = theme.colorScheme; + switch (type) { + case 'text': + return cs.primary; + case 'bytes': + case 'file': + return cs.tertiary; + case 'json': + return cs.secondary; + case 'number': + case 'integer': + return Colors.amber.shade400; + default: + return cs.onSurfaceVariant; + } + } + + Future _disconnectInputPort( + String stepId, + String field, + Offset globalPos, + ) async { + final action = await _showDisconnectMenu(globalPos); + if (!mounted || action != _PortAction.disconnect) return; + final graph = widget.controller.graph; + final step = graph.steps.firstWhere( + (s) => s.id == stepId, + orElse: () => const FlowStep(id: '', use: ''), + ); + if (step.id.isEmpty) return; + final newWith = {...step.with_, field: ''}; + widget.controller.applyGraphEdit( + graph.withStepUpdated(stepId, step.copyWith(with_: newWith)), + ); + } + + Future _disconnectOutputPort(String field, Offset globalPos) async { + final action = await _showDisconnectMenu(globalPos); + if (!mounted || action != _PortAction.disconnect) return; + final graph = widget.controller.graph; + final next = { + for (final e in graph.outputs.entries) + e.key: e.key == field ? '' : e.value, + }; + widget.controller.applyGraphEdit( + FlowGraph( + name: graph.name, + inputs: graph.inputs, + steps: graph.steps, + outputs: next, + leadingComment: graph.leadingComment, + ), + ); + } + + Future<_PortAction?> _showDisconnectMenu(Offset globalPos) async { + final overlay = + Overlay.of(context).context.findRenderObject() as RenderBox?; + if (overlay == null) return null; + final theme = Theme.of(context); + return showMenu<_PortAction>( + context: context, + position: RelativeRect.fromRect( + Rect.fromPoints(globalPos, globalPos), + Offset.zero & overlay.size, + ), + items: [ + PopupMenuItem( + value: _PortAction.disconnect, + child: Row( + children: [ + Icon(Icons.link_off, size: 16, color: theme.colorScheme.error), + const SizedBox(width: 8), + Text( + 'Disconnect', + style: TextStyle(color: theme.colorScheme.error), + ), + ], + ), + ), + ], + ); + } + Widget _portDot({ required Offset center, required bool isSource, + required bool connected, + required Color accent, VoidCallback? onDragStart, - void Function(Offset cursor)? onDropTarget, + void Function(Offset globalPos)? onContextMenu, }) { final theme = Theme.of(context); - // During a connection drag, highlight every input port - // so the operator sees the landing options; the closest - // one gets a larger halo so they know which port the - // drop will snap to if they release now. final dragging = _draft != null; final isInputDuringDrag = dragging && !isSource; final isClosest = isInputDuringDrag && _isClosestDropTarget(center); - final size = isClosest ? 22.0 : 16.0; + // Size logic: drop-target halo when a drag is hovering + // → 18 px; otherwise the standard 12 px port that + // matches NodeGeometry.portDotSize. One single dot per + // port — no more separate inline/canvas dots. + final size = isClosest ? 18.0 : NodeGeometry.portDotSize; + // Fill rule: filled when this port participates in an + // edge, OR it's the closest drop target mid-drag. Plain + // outlined circle when neither — the operator sees at + // a glance which ports are wired. + final filled = connected || isClosest; return Positioned( left: center.dx - size / 2, top: center.dy - size / 2, @@ -575,24 +727,18 @@ class _FlowCanvasState extends State { : (_) { _finalizeDraft(); }, + onSecondaryTapDown: onContextMenu == null + ? null + : (details) => onContextMenu(details.globalPosition), child: Container( decoration: BoxDecoration( shape: BoxShape.circle, - color: isClosest - ? theme.colorScheme.primary - : isSource - ? theme.colorScheme.primary - : isInputDuringDrag - ? theme.colorScheme.primary.withValues(alpha: 0.35) - : theme.colorScheme.surfaceContainerHighest, - border: Border.all( - color: theme.colorScheme.primary, - width: isClosest ? 2.5 : 1.5, - ), + color: filled ? accent : theme.colorScheme.surface, + border: Border.all(color: accent, width: isClosest ? 2.5 : 1.8), boxShadow: isClosest ? [ BoxShadow( - color: theme.colorScheme.primary.withValues(alpha: 0.5), + color: accent.withValues(alpha: 0.5), blurRadius: 10, ), ] @@ -640,17 +786,6 @@ class _FlowCanvasState extends State { return (best - portCenter).distance < 0.5; } - void Function(Offset) _dropTargetFor( - String targetId, - String targetField, - _DraftTargetKind kind, - ) { - return (cursor) { - // Snap detection happens in _finalizeDraft via spatial - // search; this callback is for future port-level hit - // tracking if we add fine-grained drop highlighting. - }; - } void _finalizeDraft() { final draft = _draft; @@ -876,6 +1011,8 @@ class _FlowCanvasState extends State { enum _StepAction { duplicate, disconnectAll, delete } +enum _PortAction { disconnect } + enum _DraftSourceKind { step, inputsField } enum _DraftTargetKind { step, outputsField } diff --git a/lib/src/widgets/flow_node.dart b/lib/src/widgets/flow_node.dart index 95f78eb..a2c1290 100644 --- a/lib/src/widgets/flow_node.dart +++ b/lib/src/widgets/flow_node.dart @@ -44,6 +44,15 @@ class NodeGeometry { static const double portRowHeight = 22; static const double bodyTopPad = 6; static const double bodyBottomPad = 10; + + /// Reserved space on the port-bearing edge inside the body + /// — leaves room for the canvas-side port dot to land + /// without overlapping the label text. + static const double portGutter = 18; + + /// Diameter of the canvas-side port dot — sized to the + /// body's labelSmall row (~11 px text + 11 px breathing + /// room) so the dot reads as the port without crowding. static const double portDotSize = 12; /// Total card height for a node with [portCount] inputs. @@ -75,20 +84,42 @@ class NodeGeometry { enum NodeVisualKind { module, approval, inputs, outputs } +/// Which side of the card the labels' associated ports sit on. +/// Drives label text-alignment and informs the canvas where to +/// drop port-dot widgets. +/// +/// - `left`: port dots hang off the left edge, labels +/// left-aligned (used for step inputs, outputs +/// endpoint inputs). +/// - `right`: port dots hang off the right edge, labels +/// right-aligned (used for inputs endpoint — +/// its body labels represent OUTPUTS of the +/// node since data flows from the inputs +/// endpoint OUT to downstream steps). +enum NodePortSide { left, right } + class FlowNode extends StatelessWidget { final String id; final String title; final String? subtitle; final List inputPortLabels; final NodeVisualKind kind; + final NodePortSide portSide; final bool selected; final VoidCallback? onTap; final void Function(Offset delta)? onDrag; final VoidCallback? onDragEnd; - /// Right-click handler. Canvas wires this to a popup menu - /// at the cursor position (Duplicate / Delete / Disconnect - /// inputs etc.). `null` = no context menu offered. + /// Inputs that COULD be set but are intentionally optional + /// — drawn slightly muted so the operator can see at a + /// glance which slots are "must wire" vs "may wire". Until + /// we have module manifests, every step-input defaults to + /// "must wire" and this is empty. + final Set optionalLabels; + + /// Right-click handler on the node body. Canvas wires this + /// to a popup menu at the cursor position (Duplicate / + /// Delete / Disconnect inputs etc.). final void Function(Offset globalPos)? onContextMenu; /// Live status from the most recent run, when this node is @@ -103,6 +134,8 @@ class FlowNode extends StatelessWidget { this.subtitle, this.inputPortLabels = const [], this.kind = NodeVisualKind.module, + this.portSide = NodePortSide.left, + this.optionalLabels = const {}, this.selected = false, this.status = FlowNodeStatus.idle, this.onTap, @@ -225,47 +258,53 @@ class FlowNode extends StatelessWidget { if (inputPortLabels.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, + ); return Padding( - padding: const EdgeInsets.only( - top: NodeGeometry.bodyTopPad, - bottom: NodeGeometry.bodyBottomPad, - ), + padding: padding, child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + crossAxisAlignment: crossAlign, children: [ for (final label in inputPortLabels) SizedBox( height: NodeGeometry.portRowHeight, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm), - child: Row( - children: [ - // Inline port dot — coloured the same as - // the canvas-side dot so the operator can - // see exactly which row a connection lands - // on. Same vertical centre as the canvas- - // side dot because both are vertically - // centred in this 22-px row. - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: _portColorFor(label, theme), - shape: BoxShape.circle, - ), - ), - const SizedBox(width: FaiSpace.xs), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurface, - ), - ), - ), - ], + 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, + ), ), ), ), @@ -274,38 +313,6 @@ class FlowNode extends StatelessWidget { ); } - /// Colour for the inline port dot. For now we only have - /// type info for the inputs endpoint (labels carry - /// `name: type` like `doc: bytes`); step inputs don't - /// declare types in the YAML and we'd need module - /// manifests to colour them properly. Until that lands, - /// step input ports fall back to neutral and only the - /// inputs sidebar surfaces type colours. - Color _portColorFor(String label, ThemeData theme) { - final colon = label.indexOf(':'); - if (colon < 0) return theme.colorScheme.onSurfaceVariant; - final type = label.substring(colon + 1).trim(); - return _typeAccent(type, theme); - } - - Color _typeAccent(String type, ThemeData theme) { - final cs = theme.colorScheme; - switch (type) { - case 'text': - return cs.primary; - case 'bytes': - case 'file': - return cs.tertiary; - case 'json': - return cs.secondary; - case 'number': - case 'integer': - return Colors.amber.shade400; - default: - return cs.onSurfaceVariant; - } - } - Widget _statusDot(ThemeData theme) { final color = switch (status) { FlowNodeStatus.running => theme.colorScheme.primary, diff --git a/pubspec.yaml b/pubspec.yaml index da58708..8ee09ce 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.4.0 +version: 0.5.0 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor