diff --git a/lib/src/widgets/edge_painter.dart b/lib/src/widgets/edge_painter.dart index 989675e..c3dc4c3 100644 --- a/lib/src/widgets/edge_painter.dart +++ b/lib/src/widgets/edge_painter.dart @@ -11,13 +11,22 @@ 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 { final Offset from; final Offset to; + final EdgeSide fromSide; + final EdgeSide toSide; final EdgeAccent accent; const EdgeSegment({ required this.from, required this.to, + required this.fromSide, + required this.toSide, this.accent = EdgeAccent.normal, }); } @@ -30,11 +39,21 @@ class EdgePainter extends CustomPainter { final Color highlightColor; 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({ required this.segments, required this.baseColor, required this.highlightColor, required this.draftColor, + this.portRadius = 6.0, }); @override @@ -48,24 +67,36 @@ class EdgePainter extends CustomPainter { final paint = Paint() ..color = color ..style = PaintingStyle.stroke - ..strokeWidth = seg.accent == EdgeAccent.highlight ? 2.5 : 1.8 + ..strokeWidth = seg.accent == EdgeAccent.highlight ? 2.6 : 2.0 ..strokeCap = StrokeCap.round; - final path = _bezier(seg.from, seg.to); - canvas.drawPath(path, paint); - // End-cap arrow: small filled triangle pointing at the - // target port so the reader can tell direction at a - // glance without inspecting the geometry. - _drawArrowHead(canvas, seg.to, seg.from, color); + // Shorten the segment so the endpoints land on the + // OUTER perimeter of the port dots, not at the centre. + // The shift sign depends on the side each port sits on: + // a left-side port has its outer perimeter to the left + // of its centre, so we move the endpoint LEFT by + // 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) { // Cubic with horizontal handles — typical "flow diagram" - // smooth curve. The handle length scales with the - // horizontal distance so steep verticals still look - // graceful. + // smooth curve. Handle length scales with the horizontal + // distance with a comfortable minimum (60 px) so vertical + // 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 handleLen = (dx / 2).clamp(40.0, 220.0); + final handleLen = (dx / 2).clamp(60.0, 260.0); return Path() ..moveTo(from.dx, from.dy) ..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 bool shouldRepaint(EdgePainter old) => old.segments != segments || old.baseColor != baseColor || old.highlightColor != highlightColor || - old.draftColor != draftColor; + old.draftColor != draftColor || + old.portRadius != portRadius; } diff --git a/lib/src/widgets/flow_canvas.dart b/lib/src/widgets/flow_canvas.dart index 5761fe7..ccac4e3 100644 --- a/lib/src/widgets/flow_canvas.dart +++ b/lib/src/widgets/flow_canvas.dart @@ -148,6 +148,7 @@ class _FlowCanvasState extends State { .withValues(alpha: 0.55), highlightColor: theme.colorScheme.primary, draftColor: theme.colorScheme.primary, + portRadius: NodeGeometry.portDotSize / 2, ), ), ), @@ -187,16 +188,25 @@ class _FlowCanvasState extends State { child: IgnorePointer( child: CustomPaint( 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: [ EdgeSegment( from: _draft!.from, to: _draft!.cursor, + fromSide: EdgeSide.right, + toSide: EdgeSide.left, accent: EdgeAccent.draftDrag, ), ], baseColor: theme.colorScheme.primary, highlightColor: theme.colorScheme.primary, draftColor: theme.colorScheme.primary, + portRadius: NodeGeometry.portDotSize / 2, ), ), ), @@ -445,12 +455,22 @@ class _FlowCanvasState extends State { for (final edge in graph.edges) { Offset? from; Offset? to; + EdgeSide? fromSide; + EdgeSide? toSide; if (edge.fromKind == EdgeEndpointKind.inputs) { 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) { from = _outputPortPosition(edge.fromId, layout); + // Step output is on the right edge. + fromSide = EdgeSide.right; } if (edge.toKind == EdgeEndpointKind.step) { final step = graph.steps.firstWhere( @@ -460,15 +480,19 @@ class _FlowCanvasState extends State { final idx = step.with_.keys.toList().indexOf(edge.toField); if (idx >= 0) { to = _inputPortPosition(edge.toId, idx, layout); + toSide = EdgeSide.left; } } else if (edge.toKind == EdgeEndpointKind.outputs) { final outputsList = graph.outputs.keys.toList(); final idx = outputsList.indexOf(edge.toField); if (idx >= 0) { 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 = edge.fromId == widget.controller.selectedStepId || edge.toId == widget.controller.selectedStepId; @@ -476,6 +500,8 @@ class _FlowCanvasState extends State { EdgeSegment( from: from, to: to, + fromSide: fromSide, + toSide: toSide, accent: highlight ? EdgeAccent.highlight : EdgeAccent.normal, ), ); @@ -745,20 +771,48 @@ class _FlowCanvasState extends State { onSecondaryTapDown: onContextMenu == null ? null : (details) => onContextMenu(details.globalPosition), - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: filled ? accent : theme.colorScheme.surface, - border: Border.all(color: accent, width: isClosest ? 2.5 : 1.8), - boxShadow: isClosest - ? [ - BoxShadow( - color: accent.withValues(alpha: 0.5), - blurRadius: 10, - ), - ] - : null, - ), + 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( + shape: BoxShape.circle, + color: filled ? accent : theme.colorScheme.surface, + border: Border.all( + color: accent, + width: isClosest ? 2.5 : 1.8, + ), + boxShadow: isClosest + ? [ + BoxShadow( + color: accent.withValues(alpha: 0.5), + blurRadius: 10, + ), + ] + : 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, + ), + ), + ], ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 8afeb27..37a4c7d 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.5.1 +version: 0.5.2 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor