diff --git a/lib/src/widgets/edge_painter.dart b/lib/src/widgets/edge_painter.dart index 06cbf3e..789691d 100644 --- a/lib/src/widgets/edge_painter.dart +++ b/lib/src/widgets/edge_painter.dart @@ -30,6 +30,19 @@ class EdgeSegment { /// (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, @@ -38,6 +51,8 @@ class EdgeSegment { this.accent = EdgeAccent.normal, this.shortenFrom = true, this.shortenTo = true, + this.color, + this.animated = false, }); } @@ -58,33 +73,38 @@ class EdgePainter extends CustomPainter { /// 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) { - final color = switch (seg.accent) { - EdgeAccent.normal => baseColor, - EdgeAccent.highlight => highlightColor, - EdgeAccent.draftDrag => draftColor, - }; - final paint = Paint() - ..color = color - ..style = PaintingStyle.stroke - ..strokeWidth = seg.accent == EdgeAccent.highlight ? 2.6 : 2.0 - ..strokeCap = StrokeCap.round; - // 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. + // 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; @@ -93,7 +113,63 @@ class EdgePainter extends CustomPainter { : 0.0; final shortenedFrom = Offset(seg.from.dx + fromShift, seg.from.dy); final shortenedTo = Offset(seg.to.dx + toShift, seg.to.dy); - canvas.drawPath(_bezier(shortenedFrom, shortenedTo), paint); + 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; + } } } @@ -124,5 +200,6 @@ class EdgePainter extends CustomPainter { old.baseColor != baseColor || old.highlightColor != highlightColor || old.draftColor != draftColor || - old.portRadius != portRadius; + old.portRadius != portRadius || + old.phase != phase; } diff --git a/lib/src/widgets/flow_canvas.dart b/lib/src/widgets/flow_canvas.dart index e749025..c24dec3 100644 --- a/lib/src/widgets/flow_canvas.dart +++ b/lib/src/widgets/flow_canvas.dart @@ -63,8 +63,15 @@ class FlowCanvas extends StatefulWidget { State createState() => _FlowCanvasState(); } -class _FlowCanvasState extends State { +class _FlowCanvasState extends State + with SingleTickerProviderStateMixin { final TransformationController _transform = TransformationController(); + + /// Continuous 0..1 loop that drives the marching-dash + /// animation on edges entering currently-running steps. + /// Stopped when no step is running so we don't burn frame + /// time on flows that are sitting idle. + late final AnimationController _flowAnim; // Track which flow we last fitted-to-screen for so we // don't override the operator's manual pan/zoom every // build. Re-fit when the active flow changes. @@ -103,6 +110,10 @@ class _FlowCanvasState extends State { // indicator at the bottom-right of the canvas can update // live as the operator pinches / scrolls. _transform.addListener(_onTransformChanged); + _flowAnim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + ); } @override @@ -110,6 +121,7 @@ class _FlowCanvasState extends State { widget.controller.removeListener(_onControllerChanged); _transform.removeListener(_onTransformChanged); _transform.dispose(); + _flowAnim.dispose(); super.dispose(); } @@ -121,7 +133,20 @@ class _FlowCanvasState extends State { } void _onControllerChanged() { - if (mounted) setState(() {}); + if (!mounted) return; + // Start the flow-animation controller only while a step + // is actually running; stop it otherwise so we don't + // tick every frame on idle flows. + final anyRunning = widget.controller.stepStatuses.values.any( + (s) => s == StepRunStatus.running, + ); + if (anyRunning && !_flowAnim.isAnimating) { + _flowAnim.repeat(); + } else if (!anyRunning && _flowAnim.isAnimating) { + _flowAnim.stop(); + _flowAnim.value = 0; + } + setState(() {}); } @override @@ -155,7 +180,21 @@ class _FlowCanvasState extends State { }); } return Container( - color: theme.colorScheme.surface, + // Two-stop linear gradient gives the canvas a subtle + // depth cue — corners feel slightly recessed, centre + // reads as the working area. Very low contrast so it + // doesn't compete with the nodes; just enough to make + // a flat dark page feel "lit" instead of "painted". + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + theme.colorScheme.surfaceContainer, + theme.colorScheme.surface, + ], + ), + ), child: Stack( children: [ InteractiveViewer( @@ -173,14 +212,18 @@ class _FlowCanvasState extends State { // Edges first so nodes paint on top of them. Positioned.fill( child: IgnorePointer( - child: CustomPaint( - painter: EdgePainter( - segments: _buildSegments(graph, layout), - baseColor: theme.colorScheme.onSurfaceVariant - .withValues(alpha: 0.55), - highlightColor: theme.colorScheme.primary, - draftColor: theme.colorScheme.primary, - portRadius: NodeGeometry.portDotSize / 2, + child: AnimatedBuilder( + animation: _flowAnim, + builder: (context, _) => CustomPaint( + painter: EdgePainter( + segments: _buildSegments(graph, layout), + baseColor: theme.colorScheme.onSurfaceVariant + .withValues(alpha: 0.55), + highlightColor: theme.colorScheme.primary, + draftColor: theme.colorScheme.primary, + portRadius: NodeGeometry.portDotSize / 2, + phase: _flowAnim.value, + ), ), ), ), @@ -616,6 +659,32 @@ class _FlowCanvasState extends State { _hoveredEdge == edgeKey || edge.fromId == widget.controller.selectedStepId || edge.toId == widget.controller.selectedStepId; + // Type-aware wire colour: edges leaving the inputs + // endpoint carry the declared type's accent so the + // operator can trace "this is the text input, this is + // the bytes input" by colour alone. Edges leaving step + // outputs use a softer "step result" colour because we + // don't have module manifests yet to know the actual + // output type. Once the hub exposes manifests, this + // lookup grows. + Color? wireColor; + if (edge.fromKind == EdgeEndpointKind.inputs) { + final input = graph.inputs[edge.fromField]; + if (input != null) { + wireColor = _typeAccent(input.type, Theme.of(context)); + } + } + // Edge is "live" when its target step is currently + // executing — marching dashes show the operator data + // is moving on this wire RIGHT NOW. + final targetStatus = edge.toKind == EdgeEndpointKind.step + ? widget.controller.stepStatuses[edge.toId] + : null; + final animated = targetStatus == StepRunStatus.running; + // Hover / selection always wins the colour treatment + // — operators need a clear "I'm looking at this one" + // signal that overrides the type colour. + final color = highlight ? null : wireColor; out.add( EdgeSegment( from: from, @@ -623,6 +692,8 @@ class _FlowCanvasState extends State { fromSide: fromSide, toSide: toSide, accent: highlight ? EdgeAccent.highlight : EdgeAccent.normal, + color: color, + animated: animated, ), ); } diff --git a/lib/src/widgets/flow_node.dart b/lib/src/widgets/flow_node.dart index ce0cf13..68b835c 100644 --- a/lib/src/widgets/flow_node.dart +++ b/lib/src/widgets/flow_node.dart @@ -175,19 +175,36 @@ class FlowNode extends StatelessWidget { onLongPressStart: onContextMenu == null ? null : (details) => onContextMenu!(details.globalPosition), - child: Material( - color: theme.colorScheme.surfaceContainerHigh, - elevation: selected ? 6 : 2, - shadowColor: selected ? accent.withValues(alpha: 0.35) : null, - borderRadius: BorderRadius.circular(FaiRadius.md), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(FaiRadius.md), - border: Border.all( - color: selected ? accent : theme.dividerColor, - width: selected ? 2 : 1, - ), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.md), + border: Border.all( + color: selected ? accent : theme.dividerColor, + width: selected ? 2 : 1, ), + // Layered shadows give nodes real depth: a sharp + // inner shadow + a softer outer halo. Selected + // nodes get a coloured accent halo on top so the + // selection state reads from across the canvas. + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: selected ? 0.32 : 0.18), + blurRadius: selected ? 18 : 10, + offset: const Offset(0, 4), + ), + if (selected) + BoxShadow( + color: accent.withValues(alpha: 0.30), + blurRadius: 24, + spreadRadius: 1, + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(FaiRadius.md), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/pubspec.yaml b/pubspec.yaml index 27f409a..4883c33 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.6.0 +version: 0.7.0 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor