feat(editor): unified port model — one dot per port, sided + typed + state-aware

Rewrites the port-rendering model around the operator's
expectation that each port is a single visible dot with
clear state. Closes four concrete complaints from the
0.4.0 review.

Removes the redundant inline dot:
 - FlowNode._body no longer paints any inline 8-px dot.
   The canvas-side dot is the SINGLE visible port. Size
   matches the label row (12-px diameter, NodeGeometry.
   portDotSize). No more "two dots per port" doubling.

Sides + alignment:
 - New NodePortSide enum on FlowNode. `left` for step
   inputs + outputs endpoint inputs; `right` for the
   inputs endpoint (its body labels represent OUTPUTS of
   the node — data flows OUT to downstream steps, so the
   port dots belong on the right edge).
 - Body labels right-align on right-side nodes,
   left-align elsewhere. The 18-px port-gutter inside the
   body keeps the dot from overlapping label text on the
   port-bearing edge.
 - Inputs endpoint dots now hang off the RIGHT edge of
   the node, matching where the visible label gravitates.
   Previously the dot was on the right but the labels
   crowded the left — visually disconnected.

State-aware rendering:
 - Port dots render FILLED when participating in an
   edge, OUTLINED when dangling. `_connectedPorts` walks
   graph.edges once per build and marks every endpoint.
   Operator sees at a glance which inputs are wired vs
   which need attention.
 - Type-coloured: inputs endpoint dots take the type
   accent (text → primary, bytes/file → tertiary,
   json → secondary, number → amber, unknown → muted).

Edge deletion via port context menu:
 - Right-click on a wired input port (step or outputs
   endpoint) opens a Disconnect popup. Confirming clears
   the with-field / output expression to empty, edge
   disappears, port flips outlined. No need to chase the
   YAML buffer manually.
 - Unwired ports get no context menu (nothing to
   disconnect) — keeps the menu honest.

The drop-target halo on drag stays — closest input port
within snap distance still inflates to 18 px and gets a
brighter halo, so the connection-drawing UX is unchanged.

Version 0.4.0 -> 0.5.0 — refactored API + visible port
model change warrants the minor bump.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-01 16:44:59 +02:00
parent fb8892687d
commit 099cd182b9
3 changed files with 261 additions and 117 deletions

View file

@ -152,26 +152,29 @@ class _FlowCanvasState extends State<FlowCanvas> {
), ),
), ),
), ),
// Inputs endpoint draggable, position // Inputs endpoint its body labels
// from the layout sidecar. No selection // represent OUTPUTS of the node (data flows
// affordance because there's no per-step // OUT to downstream steps), so the port
// properties to edit; click on the row // side is RIGHT and labels right-align.
// does nothing for now.
_endpointPositioned( _endpointPositioned(
nodeId: AutoLayout.inputsNodeId, nodeId: AutoLayout.inputsNodeId,
pos: inputsPos, pos: inputsPos,
title: 'inputs', title: 'inputs',
kind: NodeVisualKind.inputs, kind: NodeVisualKind.inputs,
portSide: NodePortSide.right,
labels: graph.inputs.keys labels: graph.inputs.keys
.map((k) => '$k: ${graph.inputs[k]!.type}') .map((k) => '$k: ${graph.inputs[k]!.type}')
.toList(), .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( _endpointPositioned(
nodeId: AutoLayout.outputsNodeId, nodeId: AutoLayout.outputsNodeId,
pos: outputsPos, pos: outputsPos,
title: 'outputs', title: 'outputs',
kind: NodeVisualKind.outputs, kind: NodeVisualKind.outputs,
portSide: NodePortSide.left,
labels: graph.outputs.keys.toList(), labels: graph.outputs.keys.toList(),
), ),
// Step nodes positioned absolutely, drag to // Step nodes positioned absolutely, drag to
@ -341,6 +344,7 @@ class _FlowCanvasState extends State<FlowCanvas> {
required NodePosition pos, required NodePosition pos,
required String title, required String title,
required NodeVisualKind kind, required NodeVisualKind kind,
required NodePortSide portSide,
required List<String> labels, required List<String> labels,
}) { }) {
final selected = widget.controller.selectedStepId == nodeId; final selected = widget.controller.selectedStepId == nodeId;
@ -351,6 +355,7 @@ class _FlowCanvasState extends State<FlowCanvas> {
id: nodeId, id: nodeId,
title: title, title: title,
kind: kind, kind: kind,
portSide: portSide,
inputPortLabels: labels, inputPortLabels: labels,
selected: selected, selected: selected,
// Endpoints are selectable too selecting opens // Endpoints are selectable too selecting opens
@ -472,12 +477,20 @@ class _FlowCanvasState extends State<FlowCanvas> {
// --- Port overlays (drag handles for creating edges) --- // --- Port overlays (drag handles for creating edges) ---
Iterable<Widget> _portOverlays(FlowGraph graph, FlowLayout layout) sync* { Iterable<Widget> _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. // Output ports step nodes' right edges.
for (final step in graph.steps) { for (final step in graph.steps) {
final p = _outputPortPosition(step.id, layout); final p = _outputPortPosition(step.id, layout);
final key = '${step.id}:__out__';
yield _portDot( yield _portDot(
center: p, center: p,
isSource: true, isSource: true,
connected: connectedPorts.contains(key),
accent: Theme.of(context).colorScheme.primary,
onDragStart: () => _draft = _ConnectionDraft( onDragStart: () => _draft = _ConnectionDraft(
fromKind: _DraftSourceKind.step, fromKind: _DraftSourceKind.step,
fromId: step.id, fromId: step.id,
@ -487,13 +500,17 @@ class _FlowCanvasState extends State<FlowCanvas> {
); );
} }
// Inputs endpoint output ports one per declared input. // Inputs endpoint output ports one per declared input.
final inputs = graph.inputs.keys.toList(); final inputsList = graph.inputs.keys.toList();
for (var i = 0; i < inputs.length; i++) { for (var i = 0; i < inputsList.length; i++) {
final p = _inputsEndpointPortPosition(i, layout); final p = _inputsEndpointPortPosition(i, layout);
final fieldName = inputs[i]; final fieldName = inputsList[i];
final input = graph.inputs[fieldName]!;
final key = 'inputs:$fieldName';
yield _portDot( yield _portDot(
center: p, center: p,
isSource: true, isSource: true,
connected: connectedPorts.contains(key),
accent: _typeAccent(input.type, Theme.of(context)),
onDragStart: () => _draft = _ConnectionDraft( onDragStart: () => _draft = _ConnectionDraft(
fromKind: _DraftSourceKind.inputsField, fromKind: _DraftSourceKind.inputsField,
fromId: fieldName, fromId: fieldName,
@ -502,49 +519,184 @@ class _FlowCanvasState extends State<FlowCanvas> {
), ),
); );
} }
// 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) { for (final step in graph.steps) {
final keys = step.with_.keys.toList(); final keys = step.with_.keys.toList();
for (var i = 0; i < keys.length; i++) { for (var i = 0; i < keys.length; i++) {
final p = _inputPortPosition(step.id, i, layout); final p = _inputPortPosition(step.id, i, layout);
final field = keys[i];
final value = step.with_[field]?.toString() ?? '';
final wired = _isWiredExpression(value);
yield _portDot( yield _portDot(
center: p, center: p,
isSource: false, 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(); final outs = graph.outputs.keys.toList();
for (var i = 0; i < outs.length; i++) { for (var i = 0; i < outs.length; i++) {
final p = _inputPortPosition(AutoLayout.outputsNodeId, i, layout); final p = _inputPortPosition(AutoLayout.outputsNodeId, i, layout);
final field = outs[i];
final expr = graph.outputs[field] ?? '';
final wired = _isWiredExpression(expr);
yield _portDot( yield _portDot(
center: p, center: p,
isSource: false, isSource: false,
onDropTarget: _dropTargetFor( connected: wired,
AutoLayout.outputsNodeId, accent: Theme.of(context).colorScheme.primary,
outs[i], onContextMenu: !wired
_DraftTargetKind.outputsField, ? 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<String> _connectedPorts(FlowGraph graph) {
final set = <String>{};
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<void> _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<void> _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({ Widget _portDot({
required Offset center, required Offset center,
required bool isSource, required bool isSource,
required bool connected,
required Color accent,
VoidCallback? onDragStart, VoidCallback? onDragStart,
void Function(Offset cursor)? onDropTarget, void Function(Offset globalPos)? onContextMenu,
}) { }) {
final theme = Theme.of(context); 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 dragging = _draft != null;
final isInputDuringDrag = dragging && !isSource; final isInputDuringDrag = dragging && !isSource;
final isClosest = isInputDuringDrag && _isClosestDropTarget(center); 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( return Positioned(
left: center.dx - size / 2, left: center.dx - size / 2,
top: center.dy - size / 2, top: center.dy - size / 2,
@ -575,24 +727,18 @@ class _FlowCanvasState extends State<FlowCanvas> {
: (_) { : (_) {
_finalizeDraft(); _finalizeDraft();
}, },
onSecondaryTapDown: onContextMenu == null
? null
: (details) => onContextMenu(details.globalPosition),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: isClosest color: filled ? accent : theme.colorScheme.surface,
? theme.colorScheme.primary border: Border.all(color: accent, width: isClosest ? 2.5 : 1.8),
: 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,
),
boxShadow: isClosest boxShadow: isClosest
? [ ? [
BoxShadow( BoxShadow(
color: theme.colorScheme.primary.withValues(alpha: 0.5), color: accent.withValues(alpha: 0.5),
blurRadius: 10, blurRadius: 10,
), ),
] ]
@ -640,17 +786,6 @@ class _FlowCanvasState extends State<FlowCanvas> {
return (best - portCenter).distance < 0.5; 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() { void _finalizeDraft() {
final draft = _draft; final draft = _draft;
@ -876,6 +1011,8 @@ class _FlowCanvasState extends State<FlowCanvas> {
enum _StepAction { duplicate, disconnectAll, delete } enum _StepAction { duplicate, disconnectAll, delete }
enum _PortAction { disconnect }
enum _DraftSourceKind { step, inputsField } enum _DraftSourceKind { step, inputsField }
enum _DraftTargetKind { step, outputsField } enum _DraftTargetKind { step, outputsField }

View file

@ -44,6 +44,15 @@ class NodeGeometry {
static const double portRowHeight = 22; static const double portRowHeight = 22;
static const double bodyTopPad = 6; static const double bodyTopPad = 6;
static const double bodyBottomPad = 10; 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; static const double portDotSize = 12;
/// Total card height for a node with [portCount] inputs. /// Total card height for a node with [portCount] inputs.
@ -75,20 +84,42 @@ class NodeGeometry {
enum NodeVisualKind { module, approval, inputs, outputs } 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 { class FlowNode extends StatelessWidget {
final String id; final String id;
final String title; final String title;
final String? subtitle; final String? subtitle;
final List<String> inputPortLabels; final List<String> inputPortLabels;
final NodeVisualKind kind; final NodeVisualKind kind;
final NodePortSide portSide;
final bool selected; final bool selected;
final VoidCallback? onTap; final VoidCallback? onTap;
final void Function(Offset delta)? onDrag; final void Function(Offset delta)? onDrag;
final VoidCallback? onDragEnd; final VoidCallback? onDragEnd;
/// Right-click handler. Canvas wires this to a popup menu /// Inputs that COULD be set but are intentionally optional
/// at the cursor position (Duplicate / Delete / Disconnect /// drawn slightly muted so the operator can see at a
/// inputs etc.). `null` = no context menu offered. /// 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<String> 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; final void Function(Offset globalPos)? onContextMenu;
/// Live status from the most recent run, when this node is /// Live status from the most recent run, when this node is
@ -103,6 +134,8 @@ class FlowNode extends StatelessWidget {
this.subtitle, this.subtitle,
this.inputPortLabels = const [], this.inputPortLabels = const [],
this.kind = NodeVisualKind.module, this.kind = NodeVisualKind.module,
this.portSide = NodePortSide.left,
this.optionalLabels = const {},
this.selected = false, this.selected = false,
this.status = FlowNodeStatus.idle, this.status = FlowNodeStatus.idle,
this.onTap, this.onTap,
@ -225,47 +258,53 @@ class FlowNode extends StatelessWidget {
if (inputPortLabels.isEmpty) { if (inputPortLabels.isEmpty) {
return const SizedBox.shrink(); 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( return Padding(
padding: const EdgeInsets.only( padding: padding,
top: NodeGeometry.bodyTopPad,
bottom: NodeGeometry.bodyBottomPad,
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: crossAlign,
children: [ children: [
for (final label in inputPortLabels) for (final label in inputPortLabels)
SizedBox( SizedBox(
height: NodeGeometry.portRowHeight, height: NodeGeometry.portRowHeight,
child: Padding( child: Align(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm), alignment: portSide == NodePortSide.right
child: Row( ? Alignment.centerRight
children: [ : Alignment.centerLeft,
// Inline port dot coloured the same as child: Text(
// the canvas-side dot so the operator can label,
// see exactly which row a connection lands textAlign: align,
// on. Same vertical centre as the canvas- maxLines: 1,
// side dot because both are vertically overflow: TextOverflow.ellipsis,
// centred in this 22-px row. style: theme.textTheme.labelSmall?.copyWith(
Container( color: optionalLabels.contains(label)
width: 8, ? theme.colorScheme.onSurface.withValues(alpha: 0.55)
height: 8, : theme.colorScheme.onSurface,
decoration: BoxDecoration( fontStyle: optionalLabels.contains(label)
color: _portColorFor(label, theme), ? FontStyle.italic
shape: BoxShape.circle, : FontStyle.normal,
), ),
),
const SizedBox(width: FaiSpace.xs),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurface,
),
),
),
],
), ),
), ),
), ),
@ -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) { Widget _statusDot(ThemeData theme) {
final color = switch (status) { final color = switch (status) {
FlowNodeStatus.running => theme.colorScheme.primary, FlowNodeStatus.running => theme.colorScheme.primary,

View file

@ -1,6 +1,6 @@
name: fai_studio_flow_editor name: fai_studio_flow_editor
description: Swappable inline YAML editor for F∆I Studio flows. description: Swappable inline YAML editor for F∆I Studio flows.
version: 0.4.0 version: 0.5.0
publish_to: 'none' publish_to: 'none'
repository: https://git.flemming.ai/fai/studio-flow-editor repository: https://git.flemming.ai/fai/studio-flow-editor