chain-studio-flow-editor/lib/src/widgets/flow_node.dart
flemming-it efdfa7dd79 feat(editor): graph-pulse on errors + store-aware install / add-source
Three connected improvements to the analyzer-driven diagnostics:

  - **Pulsing halo on broken graph nodes**. Every step / pseudo-
    node (inputs / outputs) carrying an analyzer issue now
    breathes a red (error) or amber (warning) halo on the graph
    tab — operator sees the problem on the canvas without
    flipping to text. Honours prefers-reduced-motion: reduce-
    motion users get a static halo at the same intensity. The
    canvas reads severity via a new `stepIssueSeverity` map on
    FlowEditorController; pseudo-nodes use `__inputs__` /
    `__outputs__` sentinel ids.

  - **Store-aware install button**. The analyzer now takes a
    second closure, `storeCapabilities`, listing what the public
    store can install. Unknown-capability issues only carry the
    "Install …" quick-fix when the bare cap is in that list;
    otherwise the issue carries an "Add source for …" fix
    instead. Resolves the asymmetry the operator reported: the
    Store didn't show `htw.digiscout/onet.lookup` but the editor
    happily offered to install it (and would have failed). The
    install path no longer lies about itself.

  - **Did-you-mean suggestion**. When the unknown cap is within
    edit-distance two of an installed or store cap (different
    spelling — distance-0 stays hidden because that's an install
    case, not a typo), the analyzer emits a `ReplaceLineValueFix`
    suggesting the closest match. Preserves the version
    constraint by reusing the installed spec when present
    (e.g. `text.echi@^0.1` → `text.echo@^0.1`).

New public surface:

  - `AddModuleSourceFix` + `AddModuleSourceCallback`
  - `FlowEditorPage.storeCapabilities` + `onAddModuleSource`
  - `FlowAnalyzer.stepSeverity` + the `kInputsNodeId` /
    `kOutputsNodeId` sentinels
  - `FlowIssueSeverity` enum + `FlowNode.issueSeverity`

Tests:
  - Install fix only when in store
  - AddModuleSourceFix as fallback when not in store
  - Did-you-mean replaces install when a near miss exists
  - stepSeverity populated for both step ids and pseudo-nodes

All 36 editor tests green. Bumped to 0.18.0.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-09 00:21:48 +02:00

695 lines
24 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// FlowNode — the visual representation of one step on the
// canvas. Three visual variants:
//
// - module step: rounded card, gear icon, primary border
// when selected
// - approval step: tertiary tint, hand-up icon, marks where
// the flow waits for a human
// - inputs / outputs sidebar nodes: tall, pinned to edges
//
// The card exposes named ports on its left (one per `with:`
// key) and a single combined port on its right that all
// downstream edges leave from. Ports are small filled circles
// just outside the card border so edges can visually anchor
// to them.
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../model/flow_graph.dart';
import '../tokens.dart';
/// Geometry constants the canvas + edge painter rely on to
/// place ports without measuring widgets at runtime. Every
/// number here must match the actual widget layout produced
/// by FlowNode.build — when these drift, the connection
/// dots stop aligning with the inline port-labels and edges
/// look like they're attached to nothing in particular.
///
/// Layout reads top-to-bottom:
/// - header band (titleHeight + subtitleHeight + chrome)
/// - bodyTopPad
/// - N × portRowHeight (each a row that vertically centres
/// its content)
/// - bodyBottomPad
class NodeGeometry {
/// Minimum card width — used when labels are short. Cards
/// grow beyond this via [widthFor] when their longest port
/// label wouldn't fit.
static const double width = 220;
/// Approximate render width per character at the
/// `labelSmall` text style used in port labels. Not exact
/// — we don't lay out text per build — but close enough
/// to ensure long labels like `model_endpoint` or
/// `source_language` aren't ellipsis-clipped on the
/// stock width.
static const double _charWidth = 7.5;
/// Compute a card width that fits the longest input AND
/// output label without truncation. The two-column body
/// layout reserves a left gutter (input port dot), a
/// middle gap, and a right gutter (output port dot). We
/// add a small slack so labels don't kiss the column
/// boundary.
static double widthFor({
required int maxInputChars,
required int maxOutputChars,
}) {
const minMiddleGap = 12.0;
const slack = 6.0;
final inputW = maxInputChars * _charWidth;
final outputW = maxOutputChars * _charWidth;
final needed =
portGutter + inputW + minMiddleGap + outputW + portGutter + slack;
return needed > width ? needed : width;
}
// Header band is constant whether the node has a subtitle
// or not. Endpoint nodes (inputs / outputs) render an empty
// subtitle slot — that keeps port-row Y-offsets identical
// across every node kind, which is what the port-dot
// positioning relies on.
static const double titleLineHeight = 22;
static const double subtitleLineHeight = 16;
static const double headerHeight = titleLineHeight + subtitleLineHeight + 10;
static const double portRowHeight = 22;
static const double bodyTopPad = 6;
static const double bodyBottomPad = 10;
/// Reserved space on the port-bearing edge inside the body
/// — leaves room for the canvas-side port dot to land
/// without overlapping the label text.
static const double portGutter = 18;
/// Diameter of the canvas-side port dot. Sized so the
/// connected-vs-outlined state reads clearly at any zoom:
/// a 12 px outline tends to disappear into the background
/// at 50 % zoom; 14 px keeps the ring visible and gives
/// the filled (connected) variant enough mass to stand out
/// against the card shadow.
static const double portDotSize = 14;
/// Total card height for a node carrying [inputCount] inputs
/// on the left + [outputCount] outputs on the right. The
/// taller side wins; the card always reserves at least one
/// row of body so the header doesn't sit naked. Adds a
/// 2 px allowance for the 1 px border on the top + bottom
/// of the AnimatedContainer wrapping the card, otherwise
/// the inner Column gets shorted by the border and the
/// last port row overflows.
static double heightFor(int inputCount, [int outputCount = 0]) {
final rows = inputCount > outputCount ? inputCount : outputCount;
return headerHeight + bodyTopPad + rows * portRowHeight + bodyBottomPad + 2;
}
/// Y offset (from the card's top edge) where the input
/// port at row [index] is vertically centred. Match this
/// exactly with the inline 6-px dot inside FlowNode._body.
static double inputPortY(int index) {
return headerHeight +
bodyTopPad +
index * portRowHeight +
portRowHeight / 2;
}
/// Y offset of the output port at row [index] on the right
/// side of the card. Same row geometry as inputs so the
/// rows visually align.
///
/// When the node has NO declared outputs the canvas falls
/// back to a single anchor centred in the header (the
/// legacy "one output per step" behaviour) — call
/// [outputAnchorY] for that mode.
static double outputPortY(int index) {
return headerHeight +
bodyTopPad +
index * portRowHeight +
portRowHeight / 2;
}
/// Y offset of the legacy single output port — vertically
/// centred in the header. Used for nodes whose ModuleSpec
/// the host hasn't resolved yet, so the editor can still
/// draw edges before the per-field info arrives.
static double outputAnchorY() {
return headerHeight / 2;
}
}
enum NodeVisualKind { module, approval, inputs, outputs }
/// Which side of the card the labels' associated ports sit on.
/// Drives label text-alignment and informs the canvas where to
/// drop port-dot widgets.
///
/// - `left`: port dots hang off the left edge, labels
/// left-aligned (used for step inputs, outputs
/// endpoint inputs).
/// - `right`: port dots hang off the right edge, labels
/// right-aligned (used for inputs endpoint —
/// its body labels represent OUTPUTS of the
/// node since data flows from the inputs
/// endpoint OUT to downstream steps).
enum NodePortSide { left, right }
/// Severity tag passed to [FlowNode.issueSeverity]. Mirrors
/// flutter_code_editor's `IssueType` but lives in the editor
/// package so flow_node.dart doesn't pull the code-editor
/// transitively into widget tests that don't need it.
enum FlowIssueSeverity { error, warning }
class FlowNode extends StatelessWidget {
final String id;
final String title;
final String? subtitle;
final List<String> inputPortLabels;
/// Output port labels rendered on the RIGHT side of the
/// card, one per declared output field. Empty for endpoint
/// nodes (inputs / outputs sidebars) and for step nodes
/// whose ModuleSpec is still loading — in that case the
/// canvas draws a single legacy output anchor at the
/// header midpoint.
final List<String> outputPortLabels;
/// Optional tooltips per port label, keyed by label. Shown
/// when the operator hovers the label. Used to surface
/// each field's bilingual `description.en/de` from
/// schema_version 3 manifests.
final Map<String, String> portTooltips;
final NodeVisualKind kind;
final NodePortSide portSide;
final bool selected;
final VoidCallback? onTap;
final void Function(Offset delta)? onDrag;
final VoidCallback? onDragEnd;
/// Inputs that COULD be set but are intentionally optional
/// — drawn slightly muted so the operator can see at a
/// glance which slots are "must wire" vs "may wire". Until
/// we have module manifests, every step-input defaults to
/// "must wire" and this is empty.
final Set<String> optionalLabels;
/// How many of [inputPortLabels] are currently wired to an
/// upstream source. When equal to the total count, the
/// header paints a "complete" check mark; otherwise it
/// renders the ratio (e.g. "3/5") so the operator sees
/// at a glance what's still to wire. Set to null on
/// endpoint nodes where the ratio doesn't apply.
final int? wiredCount;
/// Right-click handler on the node body. Canvas wires this
/// to a popup menu at the cursor position (Duplicate /
/// Delete / Disconnect inputs etc.).
final void Function(Offset globalPos)? onContextMenu;
/// Whether to paint the layered drop shadows under the
/// node. Off = flat-design feel; on = depth. Driven by
/// the active editor style.
final bool elevated;
/// Explicit card width. Defaults to [NodeGeometry.width].
/// Callers pass a larger value computed from the longest
/// port label so labels like `model_endpoint` or
/// `source_language` don't ellipsis-clip.
final double width;
/// Live status from the most recent run, when this node is
/// a step. Coloured dot in the header so the operator can
/// glance at the canvas and see what's running.
final FlowNodeStatus status;
/// 0..1 oscillating value driving the "breathing pulse"
/// glow on running steps. The canvas hooks this to its
/// flow-animation controller, so it's already gated on the
/// active style's `flowAnimation` setting + on whether any
/// step is actually running. When null (or status != running),
/// no pulse is rendered.
final Listenable? pulse;
/// When non-null, the node renders a permanently pulsing
/// halo in the appropriate severity colour — red for an
/// analyzer-flagged error, amber for a warning — so the
/// operator scanning the canvas immediately sees which step
/// the YAML linter is unhappy about, without switching to
/// the text tab.
final FlowIssueSeverity? issueSeverity;
const FlowNode({
super.key,
required this.id,
required this.title,
this.subtitle,
this.inputPortLabels = const [],
this.outputPortLabels = const [],
this.portTooltips = const {},
this.kind = NodeVisualKind.module,
this.portSide = NodePortSide.left,
this.optionalLabels = const {},
this.wiredCount,
this.selected = false,
this.status = FlowNodeStatus.idle,
this.onTap,
this.onDrag,
this.onDragEnd,
this.onContextMenu,
this.elevated = true,
this.pulse,
this.issueSeverity,
this.width = NodeGeometry.width,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accent = _accent(theme.colorScheme);
final height = NodeGeometry.heightFor(
inputPortLabels.length,
outputPortLabels.length,
);
final cardWidget = _card(theme, accent, height);
// Breathing-pulse glow on running steps. The pulse
// listenable is the canvas's own flow-animation
// controller (0..1), so a single host-side tick drives
// every running step's halo in sync. Skipped when no
// animation source is wired or the step isn't running.
if (pulse != null && status == FlowNodeStatus.running) {
return SizedBox(
width: width,
height: height,
child: AnimatedBuilder(
animation: pulse!,
builder: (context, child) {
final t = (pulse! is Animation<double>)
? (pulse! as Animation<double>).value
: 0.0;
final breath = 0.5 + 0.5 * math.sin(t * 2 * math.pi);
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FaiRadius.md),
boxShadow: [
BoxShadow(
color: accent.withValues(alpha: 0.18 + 0.22 * breath),
blurRadius: 20 + 8 * breath,
spreadRadius: 0.5 + 1.5 * breath,
),
],
),
child: child,
);
},
child: cardWidget,
),
);
}
if (issueSeverity != null) {
// Always-on breathing halo in the severity colour. Owns
// its own AnimationController so it ticks even when no
// step is running (no shared pulse listenable upstream).
return SizedBox(
width: width,
height: height,
child: _IssueHalo(
severity: issueSeverity!,
child: cardWidget,
),
);
}
return SizedBox(width: width, height: height, child: cardWidget);
}
Widget _card(ThemeData theme, Color accent, double height) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
onPanUpdate: onDrag == null ? null : (d) => onDrag!(d.delta),
onPanEnd: onDragEnd == null ? null : (_) => onDragEnd!(),
onSecondaryTapDown: onContextMenu == null
? null
: (details) => onContextMenu!(details.globalPosition),
// Long-press is the trackpad fallback for the context
// menu — macOS trackpad users whose "two-finger click"
// isn't mapped to secondary still get the same menu.
onLongPressStart: onContextMenu == null
? null
: (details) => onContextMenu!(details.globalPosition),
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.
// When the active style turns elevation off, we
// drop the shadows entirely for a flat-design look.
boxShadow: elevated
? [
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,
),
]
: null,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(FaiRadius.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(theme, accent),
Expanded(child: _body(theme)),
],
),
),
),
);
}
Widget _header(ThemeData theme, Color accent) {
return Container(
// Fixed header height (regardless of whether subtitle
// is present) — so the body's port-row Y offsets are
// identical across every node kind. Endpoint nodes
// simply leave the subtitle line blank.
height: NodeGeometry.headerHeight,
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.16),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(FaiRadius.md),
topRight: Radius.circular(FaiRadius.md),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title line — icon + step id + optional status dot.
SizedBox(
height: NodeGeometry.titleLineHeight,
child: Row(
children: [
Icon(_iconFor(kind), size: 16, color: accent),
const SizedBox(width: FaiSpace.xs),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w600,
color: accent,
),
),
),
if (wiredCount != null && inputPortLabels.isNotEmpty)
_wiredBadge(theme, accent),
if (status != FlowNodeStatus.idle) ...[
const SizedBox(width: 4),
_statusDot(theme),
],
],
),
),
// Subtitle line — always reserved space, even for
// endpoint nodes that have nothing to put here.
SizedBox(
height: NodeGeometry.subtitleLineHeight,
child: subtitle == null
? const SizedBox.shrink()
: Padding(
// Indent to align with title text after the icon.
padding: const EdgeInsets.only(left: 20),
child: Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontFamily: 'monospace',
fontSize: 10,
),
),
),
),
],
),
);
}
Widget _body(ThemeData theme) {
if (inputPortLabels.isEmpty && outputPortLabels.isEmpty) {
return const SizedBox.shrink();
}
// Outer padding matches the side(s) that actually carry
// ports — the gutter exists so the canvas-side dot has
// somewhere to land without overlapping the label text.
final left = inputPortLabels.isNotEmpty || portSide == NodePortSide.left
? NodeGeometry.portGutter
: FaiSpace.sm;
final right = outputPortLabels.isNotEmpty || portSide == NodePortSide.right
? NodeGeometry.portGutter
: FaiSpace.sm;
return Padding(
padding: EdgeInsets.fromLTRB(
left,
NodeGeometry.bodyTopPad,
right,
NodeGeometry.bodyBottomPad,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (inputPortLabels.isNotEmpty)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final label in inputPortLabels)
_portLabel(theme, label, TextAlign.left),
],
),
),
if (outputPortLabels.isNotEmpty)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final label in outputPortLabels)
_portLabel(theme, label, TextAlign.right),
],
),
),
],
),
);
}
Widget _portLabel(ThemeData theme, String label, TextAlign align) {
final muted = optionalLabels.contains(label);
final widget = SizedBox(
height: NodeGeometry.portRowHeight,
child: Align(
alignment: align == TextAlign.right
? Alignment.centerRight
: Alignment.centerLeft,
child: Text(
label,
textAlign: align,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: muted
? theme.colorScheme.onSurface.withValues(alpha: 0.55)
: theme.colorScheme.onSurface,
fontStyle: muted ? FontStyle.italic : FontStyle.normal,
),
),
),
);
final tip = portTooltips[label];
if (tip == null || tip.isEmpty) return widget;
return Tooltip(
message: tip,
waitDuration: const Duration(milliseconds: 350),
child: widget,
);
}
Widget _wiredBadge(ThemeData theme, Color accent) {
final total = inputPortLabels.length;
final wired = wiredCount ?? 0;
final complete = wired >= total && total > 0;
if (complete) {
return Padding(
padding: const EdgeInsets.only(left: 4),
child: Icon(Icons.check_circle, size: 14, color: Colors.green.shade400),
);
}
return Padding(
padding: const EdgeInsets.only(left: 4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: accent.withValues(alpha: 0.4)),
),
child: Text(
'$wired/$total',
style: theme.textTheme.labelSmall?.copyWith(
fontFamily: 'monospace',
fontSize: 9,
fontWeight: FontWeight.w600,
color: accent,
),
),
),
);
}
Widget _statusDot(ThemeData theme) {
final color = switch (status) {
FlowNodeStatus.running => theme.colorScheme.primary,
FlowNodeStatus.done => Colors.green.shade400,
FlowNodeStatus.failed => theme.colorScheme.error,
FlowNodeStatus.awaiting => theme.colorScheme.tertiary,
FlowNodeStatus.idle => Colors.transparent,
};
return Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: status == FlowNodeStatus.running
? [BoxShadow(color: color.withValues(alpha: 0.6), blurRadius: 6)]
: null,
),
);
}
IconData _iconFor(NodeVisualKind k) {
return switch (k) {
NodeVisualKind.module => Icons.widgets_outlined,
NodeVisualKind.approval => Icons.pan_tool_outlined,
NodeVisualKind.inputs => Icons.input,
NodeVisualKind.outputs => Icons.output,
};
}
Color _accent(ColorScheme cs) {
return switch (kind) {
NodeVisualKind.module => cs.primary,
NodeVisualKind.approval => cs.tertiary,
NodeVisualKind.inputs => cs.secondary,
NodeVisualKind.outputs => cs.secondary,
};
}
}
enum FlowNodeStatus { idle, running, done, failed, awaiting }
/// Render-time helper: figure out which visual variant to use
/// for a parsed step.
NodeVisualKind kindForStep(FlowStep step) {
if (step.isApproval) return NodeVisualKind.approval;
return NodeVisualKind.module;
}
/// Permanently breathing halo painted around a node carrying
/// an analyzer issue. Owns its own AnimationController so it
/// ticks regardless of the canvas's run-state. Halo colour
/// matches severity — error red or warning amber. Honours
/// `MediaQuery.disableAnimations` so reduce-motion operators
/// still get a static halo without the breathing oscillation.
class _IssueHalo extends StatefulWidget {
final FlowIssueSeverity severity;
final Widget child;
const _IssueHalo({required this.severity, required this.child});
@override
State<_IssueHalo> createState() => _IssueHaloState();
}
class _IssueHaloState extends State<_IssueHalo>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1600),
)..repeat();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Color _tone(BuildContext context) {
final theme = Theme.of(context);
return widget.severity == FlowIssueSeverity.error
? theme.colorScheme.error
: const Color(0xFFEF6C00);
}
@override
Widget build(BuildContext context) {
final tone = _tone(context);
final reduceMotion = MediaQuery.disableAnimationsOf(context);
if (reduceMotion) {
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FaiRadius.md),
boxShadow: [
BoxShadow(
color: tone.withValues(alpha: 0.35),
blurRadius: 18,
spreadRadius: 1.5,
),
],
),
child: widget.child,
);
}
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final breath = 0.5 + 0.5 * math.sin(_controller.value * 2 * math.pi);
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FaiRadius.md),
boxShadow: [
BoxShadow(
color: tone.withValues(alpha: 0.22 + 0.30 * breath),
blurRadius: 16 + 8 * breath,
spreadRadius: 0.5 + 2.0 * breath,
),
],
),
child: child,
);
},
child: widget.child,
);
}
}