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

@ -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<String> 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<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;
/// 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,