A 2026-feel pass on the visual language. Three structural
moves that change how the editor reads at a glance.
1. Wires now carry the colour of the data they transport.
Every edge leaving the inputs endpoint takes the
declared input's type accent — blue for text, orange
for bytes, purple for json, green for file, amber for
number. The operator can trace "this is the document
path, this is the prompt" by following colour, no
labels needed. Edges leaving step outputs stay neutral
(we don't have module manifests to look up the output
type yet — once the hub exposes them, this lookup
grows).
Hover / selection still wins the colour treatment so
the "I'm pointing at this one" signal isn't lost in
the type palette.
2. Edges entering a currently-running step animate.
New AnimationController on the canvas loops a 0..1
phase at 1.5 s when ANY step is running, stopped
otherwise (no idle cost). EdgePainter takes the phase
and renders animated edges as marching dashes in the
wire's accent colour over a 35%-alpha base — the
operator literally sees data moving along the wire in
the direction of flow while a step executes. When the
step completes, the animation stops, the wire returns
to a static stroke. Run a flow with a slow step and
the canvas comes alive.
3. Canvas gets depth.
- Background is now a subtle top-left → bottom-right
gradient between surfaceContainer and surface,
giving the working area a "lit centre, recessed
corners" cue instead of one flat dark page.
- Node cards swap their Material widget for an
AnimatedContainer with two-layer shadows: a sharp
short-blur shadow gives the card a real footprint,
a soft long-blur shadow casts depth onto the canvas
behind. Selected nodes get a third accent-coloured
halo so selection state reads from across the canvas.
- 180 ms ease-out-cubic transitions on the
AnimatedContainer mean hover-into-select and
run-status-changes morph smoothly instead of
snapping.
Edge stroke widths bumped slightly (2.1 / 2.8 from 2.0 /
2.6) so the typed wires read at a confident weight against
the new gradient backdrop.
Version 0.6.0 -> 0.7.0 — visible visual language change.
Signed-off-by: flemming-it <sf@flemming.it>
205 lines
7 KiB
Dart
205 lines
7 KiB
Dart
// 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<EdgeSegment> 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;
|
|
}
|