From 7aeeae717f37f3dfefe5918ed2dba18a922a701d Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 1 Jun 2026 17:45:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(editor):=20wires=20carry=20their=20type=20?= =?UTF-8?q?=E2=80=94=20animated=20flow=20+=20canvas=20depth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/src/widgets/edge_painter.dart | 115 +++++++++++++++++++++++++----- lib/src/widgets/flow_canvas.dart | 93 +++++++++++++++++++++--- lib/src/widgets/flow_node.dart | 41 +++++++---- pubspec.yaml | 2 +- 4 files changed, 208 insertions(+), 43 deletions(-) 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