// EdgePainter — draws every connection on the canvas as a // cubic-bezier curve. Pure render layer: receives a flat list // of port-to-port positions (already resolved against node // positions) and paints them in one go. // // Connections are painted in a single CustomPaint so we don't // pay the widget-tree cost of one painter per edge. Hit // testing is delegated to port hit boxes drawn over the // canvas; the painter itself is wrapped in IgnorePointer in // the canvas so nothing here eats taps meant for nodes. 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; /// When false, the bezier endpoint is NOT shortened by /// `portRadius` on the corresponding side. Used by the /// draft-drag segment so the line actually reaches the /// cursor instead of stopping a port-radius short of it /// (the cursor is a mouse position, not a port socket). final bool shortenFrom; final bool shortenTo; /// Optional per-segment colour override. When set, takes /// precedence over the painter's accent-based base / /// highlight colours. Used to paint each wire in the /// colour of whatever data flows through it (text=blue, /// bytes=orange, …) instead of one uniform muted grey. final Color? color; /// When true, the segment renders as marching dashes /// driven by the painter's `phase` parameter — used to /// show "data is flowing right now" for edges pointing at /// a currently-running step. final bool animated; const EdgeSegment({ required this.from, required this.to, required this.fromSide, required this.toSide, this.accent = EdgeAccent.normal, this.shortenFrom = true, this.shortenTo = true, this.color, this.animated = false, }); } enum EdgeAccent { normal, highlight, draftDrag } class EdgePainter extends CustomPainter { final List segments; final Color baseColor; 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; /// 0..1 looping value driving the marching-dash animation /// on segments where `animated == true`. The painter /// computes the dash offset from this; the caller (canvas) /// drives it with an AnimationController gated on whether /// any step is currently running. final double phase; EdgePainter({ required this.segments, required this.baseColor, required this.highlightColor, required this.draftColor, this.portRadius = 6.0, this.phase = 0.0, Listenable? repaint, }) : super(repaint: repaint); @override void paint(Canvas canvas, Size size) { for (final seg in segments) { // Pick the segment's colour: explicit per-segment // override wins, then accent-based fallback. The // override is how each wire gets coloured by data // type instead of all looking the same. final color = seg.color ?? switch (seg.accent) { EdgeAccent.normal => baseColor, EdgeAccent.highlight => highlightColor, EdgeAccent.draftDrag => draftColor, }; final strokeWidth = seg.accent == EdgeAccent.highlight ? 2.8 : 2.1; final fromShift = seg.shortenFrom ? (seg.fromSide == EdgeSide.right ? portRadius : -portRadius) : 0.0; final toShift = seg.shortenTo ? (seg.toSide == EdgeSide.right ? portRadius : -portRadius) : 0.0; final shortenedFrom = Offset(seg.from.dx + fromShift, seg.from.dy); final shortenedTo = Offset(seg.to.dx + toShift, seg.to.dy); final path = _bezier(shortenedFrom, shortenedTo); if (seg.animated) { // Underlying muted line so the path's geometry stays // visible between dash gaps. final basePaint = Paint() ..color = color.withValues(alpha: 0.35) ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round; canvas.drawPath(path, basePaint); // Marching dashes on top — the operator sees data // moving along the wire in the direction of flow. _drawAnimatedDashes(canvas, path, color, strokeWidth); } else { final paint = Paint() ..color = color ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round; canvas.drawPath(path, paint); } } } void _drawAnimatedDashes( Canvas canvas, Path path, Color color, double strokeWidth, ) { const dashLength = 10.0; const gapLength = 8.0; const period = dashLength + gapLength; // Negative offset drives dashes "forward" along the // path (source → target). One full period per // animation cycle. final offset = -phase * period; final paint = Paint() ..color = color ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth + 0.4 ..strokeCap = StrokeCap.round; for (final metric in path.computeMetrics()) { final total = metric.length; var d = offset; // Walk backwards over the period so the first dash // is clipped naturally at d < 0 instead of jumping // visually when phase wraps from 1 → 0. while (d < total) { final start = d.clamp(0.0, total); final end = (d + dashLength).clamp(0.0, total); if (end > start) { canvas.drawPath(metric.extractPath(start, end), paint); } d += period; } } } Path _bezier(Offset from, Offset to) { // Cubic with horizontal handles — typical "flow diagram" // 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(60.0, 260.0); return Path() ..moveTo(from.dx, from.dy) ..cubicTo( from.dx + handleLen, from.dy, to.dx - handleLen, to.dy, to.dx, to.dy, ); } @override bool shouldRepaint(EdgePainter old) => old.segments != segments || old.baseColor != baseColor || old.highlightColor != highlightColor || old.draftColor != draftColor || old.portRadius != portRadius || old.phase != phase; }