// 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 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 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 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 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) ? (pulse! as Animation).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, ); } }