chain-studio-flow-editor/lib/src/widgets/flow_node.dart
flemming-it 1e9968496e feat(editor): properties-panel toggle + LabVIEW-style type colours
Two visible UX fixes:

1. Properties-panel toggle. Tap a step → opens. Tap the
   same step again → closes (deselect via toggle). Tap the
   canvas background → also closes. selectStep now returns
   to null when called with the already-selected id; the
   editor-controller test updated for the new semantics.

2. LabVIEW-style type colours on every port + wire.
   _typeAccent now picks vibrant brightness-aware hues per
   datatype:
     text   → magenta/pink (LabVIEW string)
     json   → amber/orange (cluster feel)
     bytes  → cyan/teal (raw binary)
     file   → green (file reference)
     number → yellow/amber
   Step-input dots, step-output dots, and outputs-endpoint
   dots all switch from the generic theme.primary to the
   field's declared type accent. Operators read the
   payload from the dot alone.

Editor tests still pass (20).

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-02 11:13:01 +02:00

594 lines
21 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 }
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;
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.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) {
// Derive a 0..1 breath value from the controller.
// Use a sine wave so the glow eases in + out
// rather than ramping linearly — reads as
// "breathing" instead of "flickering".
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,
),
);
}
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;
}