fix(editor): port docks elegantly — line terminates at perimeter

Stefan's "the connections to the round ports look
unelegant" feedback was about three concrete things:

(1) The bezier endpoint sat at the port dot's GEOMETRIC
CENTRE. The dot was rendered ON TOP, so the line visually
disappeared into the centre of the dot — looked like a
thread being eaten by a bead.

(2) A separate arrow-head triangle was drawn on top of the
dot, doubling the visual terminator and making the
endpoint look "noisy".

(3) The dots were hollow rings the same colour as the
line, so the boundary between line and dot blurred
visually. They didn't read as connectors.

Fix:

 - EdgeSegment now carries `fromSide` + `toSide`
   (left / right) so the painter knows which way to
   shorten each endpoint. The bezier ends `portRadius` px
   short of the centre — exactly on the dot's outer
   perimeter, on the side facing the line. The line now
   "docks" cleanly at the rim.

 - The end-cap triangle arrow is gone. The dot itself is
   the visual terminator; an arrow on top was redundant.

 - Port dot redesigned as a connector-socket: outer ring
   defines the footprint, inner 4-px surface-coloured pin
   appears when connected. The result reads as "an active
   socket with a plug seated in it" rather than "a hollow
   circle next to a card edge". Empty ports stay surface-
   filled rings — unambiguous empty-slot signal.

 - Bezier handle length floor raised from 40 to 60 px so
   vertical detours (e.g. inputs-endpoint → step far below)
   curve gracefully out of the start before dropping
   instead of kinking near the origin.

 - Stroke width nudged from 1.8 to 2.0 px for the normal
   accent so the line carries the right visual weight
   against the new docked-at-perimeter port treatment.

Visual: lines now look like cables seated into sockets,
not strings vanishing under beads.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-01 17:03:01 +02:00
parent e4e2d45374
commit 9afb51a4b5
3 changed files with 115 additions and 47 deletions

View file

@ -11,13 +11,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Which horizontal edge of its host node a port sits on.
/// Determines whether the bezier endpoint shortens INWARD or
/// OUTWARD when terminating at the port dot's perimeter.
enum EdgeSide { left, right }
class EdgeSegment { class EdgeSegment {
final Offset from; final Offset from;
final Offset to; final Offset to;
final EdgeSide fromSide;
final EdgeSide toSide;
final EdgeAccent accent; final EdgeAccent accent;
const EdgeSegment({ const EdgeSegment({
required this.from, required this.from,
required this.to, required this.to,
required this.fromSide,
required this.toSide,
this.accent = EdgeAccent.normal, this.accent = EdgeAccent.normal,
}); });
} }
@ -30,11 +39,21 @@ class EdgePainter extends CustomPainter {
final Color highlightColor; final Color highlightColor;
final Color draftColor; final Color draftColor;
/// Radius of the port dot the line is terminating at. The
/// bezier endpoint shortens by this amount on each end so
/// the line stops cleanly at the dot's outer perimeter
/// instead of vanishing under the dot's centre. Without
/// this, the connection looks like a string being eaten
/// by a bead; with it, the line "docks" at the port like
/// a cable at a socket.
final double portRadius;
EdgePainter({ EdgePainter({
required this.segments, required this.segments,
required this.baseColor, required this.baseColor,
required this.highlightColor, required this.highlightColor,
required this.draftColor, required this.draftColor,
this.portRadius = 6.0,
}); });
@override @override
@ -48,24 +67,36 @@ class EdgePainter extends CustomPainter {
final paint = Paint() final paint = Paint()
..color = color ..color = color
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = seg.accent == EdgeAccent.highlight ? 2.5 : 1.8 ..strokeWidth = seg.accent == EdgeAccent.highlight ? 2.6 : 2.0
..strokeCap = StrokeCap.round; ..strokeCap = StrokeCap.round;
final path = _bezier(seg.from, seg.to); // Shorten the segment so the endpoints land on the
canvas.drawPath(path, paint); // OUTER perimeter of the port dots, not at the centre.
// End-cap arrow: small filled triangle pointing at the // The shift sign depends on the side each port sits on:
// target port so the reader can tell direction at a // a left-side port has its outer perimeter to the left
// glance without inspecting the geometry. // of its centre, so we move the endpoint LEFT by
_drawArrowHead(canvas, seg.to, seg.from, color); // radius; right-side port goes the other way.
final shortenedFrom = Offset(
seg.from.dx +
(seg.fromSide == EdgeSide.right ? portRadius : -portRadius),
seg.from.dy,
);
final shortenedTo = Offset(
seg.to.dx + (seg.toSide == EdgeSide.right ? portRadius : -portRadius),
seg.to.dy,
);
canvas.drawPath(_bezier(shortenedFrom, shortenedTo), paint);
} }
} }
Path _bezier(Offset from, Offset to) { Path _bezier(Offset from, Offset to) {
// Cubic with horizontal handles typical "flow diagram" // Cubic with horizontal handles typical "flow diagram"
// smooth curve. The handle length scales with the // smooth curve. Handle length scales with the horizontal
// horizontal distance so steep verticals still look // distance with a comfortable minimum (60 px) so vertical
// graceful. // detours (e.g. an inputs-endpoint port reaching a step
// way down on the canvas) still curve gracefully out of
// the start before dropping.
final dx = (to.dx - from.dx).abs(); final dx = (to.dx - from.dx).abs();
final handleLen = (dx / 2).clamp(40.0, 220.0); final handleLen = (dx / 2).clamp(60.0, 260.0);
return Path() return Path()
..moveTo(from.dx, from.dy) ..moveTo(from.dx, from.dy)
..cubicTo( ..cubicTo(
@ -78,28 +109,11 @@ class EdgePainter extends CustomPainter {
); );
} }
void _drawArrowHead(Canvas canvas, Offset tip, Offset from, Color color) {
// Approximate the incoming direction by sampling a
// little before the tip. Good enough since the curve is
// nearly horizontal near the end.
final dir = tip.dx - from.dx;
final orient = dir >= 0 ? 1.0 : -1.0;
const size = 6.0;
final p1 = tip;
final p2 = Offset(tip.dx - size * orient, tip.dy - size * 0.7);
final p3 = Offset(tip.dx - size * orient, tip.dy + size * 0.7);
final path = Path()
..moveTo(p1.dx, p1.dy)
..lineTo(p2.dx, p2.dy)
..lineTo(p3.dx, p3.dy)
..close();
canvas.drawPath(path, Paint()..color = color);
}
@override @override
bool shouldRepaint(EdgePainter old) => bool shouldRepaint(EdgePainter old) =>
old.segments != segments || old.segments != segments ||
old.baseColor != baseColor || old.baseColor != baseColor ||
old.highlightColor != highlightColor || old.highlightColor != highlightColor ||
old.draftColor != draftColor; old.draftColor != draftColor ||
old.portRadius != portRadius;
} }

View file

@ -148,6 +148,7 @@ class _FlowCanvasState extends State<FlowCanvas> {
.withValues(alpha: 0.55), .withValues(alpha: 0.55),
highlightColor: theme.colorScheme.primary, highlightColor: theme.colorScheme.primary,
draftColor: theme.colorScheme.primary, draftColor: theme.colorScheme.primary,
portRadius: NodeGeometry.portDotSize / 2,
), ),
), ),
), ),
@ -187,16 +188,25 @@ class _FlowCanvasState extends State<FlowCanvas> {
child: IgnorePointer( child: IgnorePointer(
child: CustomPaint( child: CustomPaint(
painter: EdgePainter( painter: EdgePainter(
// Draft line follows the cursor;
// pretend the cursor is on the
// LEFT side so the line "approaches"
// it horizontally (matches the
// input-port orientation it will
// most likely snap to).
segments: [ segments: [
EdgeSegment( EdgeSegment(
from: _draft!.from, from: _draft!.from,
to: _draft!.cursor, to: _draft!.cursor,
fromSide: EdgeSide.right,
toSide: EdgeSide.left,
accent: EdgeAccent.draftDrag, accent: EdgeAccent.draftDrag,
), ),
], ],
baseColor: theme.colorScheme.primary, baseColor: theme.colorScheme.primary,
highlightColor: theme.colorScheme.primary, highlightColor: theme.colorScheme.primary,
draftColor: theme.colorScheme.primary, draftColor: theme.colorScheme.primary,
portRadius: NodeGeometry.portDotSize / 2,
), ),
), ),
), ),
@ -445,12 +455,22 @@ class _FlowCanvasState extends State<FlowCanvas> {
for (final edge in graph.edges) { for (final edge in graph.edges) {
Offset? from; Offset? from;
Offset? to; Offset? to;
EdgeSide? fromSide;
EdgeSide? toSide;
if (edge.fromKind == EdgeEndpointKind.inputs) { if (edge.fromKind == EdgeEndpointKind.inputs) {
final idx = inputsList.indexOf(edge.fromField); final idx = inputsList.indexOf(edge.fromField);
if (idx >= 0) from = _inputsEndpointPortPosition(idx, layout); if (idx >= 0) {
from = _inputsEndpointPortPosition(idx, layout);
// Inputs endpoint ports live on the node's right edge
// that's where the dot sits, and where the bezier
// should originate.
fromSide = EdgeSide.right;
}
} else if (edge.fromKind == EdgeEndpointKind.step) { } else if (edge.fromKind == EdgeEndpointKind.step) {
from = _outputPortPosition(edge.fromId, layout); from = _outputPortPosition(edge.fromId, layout);
// Step output is on the right edge.
fromSide = EdgeSide.right;
} }
if (edge.toKind == EdgeEndpointKind.step) { if (edge.toKind == EdgeEndpointKind.step) {
final step = graph.steps.firstWhere( final step = graph.steps.firstWhere(
@ -460,15 +480,19 @@ class _FlowCanvasState extends State<FlowCanvas> {
final idx = step.with_.keys.toList().indexOf(edge.toField); final idx = step.with_.keys.toList().indexOf(edge.toField);
if (idx >= 0) { if (idx >= 0) {
to = _inputPortPosition(edge.toId, idx, layout); to = _inputPortPosition(edge.toId, idx, layout);
toSide = EdgeSide.left;
} }
} else if (edge.toKind == EdgeEndpointKind.outputs) { } else if (edge.toKind == EdgeEndpointKind.outputs) {
final outputsList = graph.outputs.keys.toList(); final outputsList = graph.outputs.keys.toList();
final idx = outputsList.indexOf(edge.toField); final idx = outputsList.indexOf(edge.toField);
if (idx >= 0) { if (idx >= 0) {
to = _inputPortPosition(AutoLayout.outputsNodeId, idx, layout); to = _inputPortPosition(AutoLayout.outputsNodeId, idx, layout);
toSide = EdgeSide.left;
} }
} }
if (from == null || to == null) continue; if (from == null || to == null || fromSide == null || toSide == null) {
continue;
}
final highlight = final highlight =
edge.fromId == widget.controller.selectedStepId || edge.fromId == widget.controller.selectedStepId ||
edge.toId == widget.controller.selectedStepId; edge.toId == widget.controller.selectedStepId;
@ -476,6 +500,8 @@ class _FlowCanvasState extends State<FlowCanvas> {
EdgeSegment( EdgeSegment(
from: from, from: from,
to: to, to: to,
fromSide: fromSide,
toSide: toSide,
accent: highlight ? EdgeAccent.highlight : EdgeAccent.normal, accent: highlight ? EdgeAccent.highlight : EdgeAccent.normal,
), ),
); );
@ -745,11 +771,22 @@ class _FlowCanvasState extends State<FlowCanvas> {
onSecondaryTapDown: onContextMenu == null onSecondaryTapDown: onContextMenu == null
? null ? null
: (details) => onContextMenu(details.globalPosition), : (details) => onContextMenu(details.globalPosition),
child: Container( child: Stack(
alignment: Alignment.center,
children: [
// Outer ring defines the port's footprint.
// Filled when connected (so the line "docks"
// into a visible target), surface-filled when
// dangling (a clear empty socket). Drop-target
// halo grows + glows.
Container(
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: filled ? accent : theme.colorScheme.surface, color: filled ? accent : theme.colorScheme.surface,
border: Border.all(color: accent, width: isClosest ? 2.5 : 1.8), border: Border.all(
color: accent,
width: isClosest ? 2.5 : 1.8,
),
boxShadow: isClosest boxShadow: isClosest
? [ ? [
BoxShadow( BoxShadow(
@ -760,6 +797,23 @@ class _FlowCanvasState extends State<FlowCanvas> {
: null, : null,
), ),
), ),
// Inner pin tiny surface-coloured dot at the
// centre when connected, giving the port the
// "socket with a pin in it" look that reads as
// an active electrical connector rather than a
// free-floating indicator. Skipped on empty
// ports so the hollow ring is unambiguous.
if (connected)
Container(
width: 4,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
shape: BoxShape.circle,
),
),
],
),
), ),
), ),
); );

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.5.1 version: 0.5.2
publish_to: 'none' publish_to: 'none'
repository: https://git.flemming.ai/fai/studio-flow-editor repository: https://git.flemming.ai/fai/studio-flow-editor