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:
parent
fb8892687d
commit
099cd182b9
3 changed files with 261 additions and 117 deletions
|
|
@ -152,26 +152,29 @@ class _FlowCanvasState extends State<FlowCanvas> {
|
|||
),
|
||||
),
|
||||
),
|
||||
// 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<FlowCanvas> {
|
|||
required NodePosition pos,
|
||||
required String title,
|
||||
required NodeVisualKind kind,
|
||||
required NodePortSide portSide,
|
||||
required List<String> labels,
|
||||
}) {
|
||||
final selected = widget.controller.selectedStepId == nodeId;
|
||||
|
|
@ -351,6 +355,7 @@ class _FlowCanvasState extends State<FlowCanvas> {
|
|||
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<FlowCanvas> {
|
|||
// --- Port overlays (drag handles for creating edges) ---
|
||||
|
||||
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.
|
||||
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<FlowCanvas> {
|
|||
);
|
||||
}
|
||||
// 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<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) {
|
||||
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<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({
|
||||
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<FlowCanvas> {
|
|||
: (_) {
|
||||
_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<FlowCanvas> {
|
|||
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<FlowCanvas> {
|
|||
|
||||
enum _StepAction { duplicate, disconnectAll, delete }
|
||||
|
||||
enum _PortAction { disconnect }
|
||||
|
||||
enum _DraftSourceKind { step, inputsField }
|
||||
|
||||
enum _DraftTargetKind { step, outputsField }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue