chain-studio-flow-editor/lib/src/widgets/flow_node.dart
flemming-it 1f5601a461 feat(editor): FaiEditorStyle + frosted glass panel + pattern/zoom dropdowns
Visual-effect knobs (FaiEditorStyle) the host can override —
distinct from ColorScheme, which stays orthogonal. Today
Studio passes it through FlowEditorPage's new style:
parameter; tomorrow a theme plugin can ship both colours and
effects in one move.

Two presets ship today: modern (frosted glass, gradient
backdrop, animated flow, shadowed nodes — the default) and
minimal (everything off, flat surfaces — accessibility /
reduce-motion / low-spec).

Properties panel rebuilt as a floating sidebar with
BackdropFilter blur when glass-style is on; falls back to
the pre-0.7 flush-divider layout under solid style.

Pattern + zoom controls promoted to PopupMenuButton dropdowns
so operators reach a specific zoom level / pattern in one
click instead of cycling.

Bumps fai_studio_flow_editor to 0.8.0.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-01 18:10:51 +02:00

444 lines
16 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 '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 {
static const double width = 220;
// 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 to the
/// body's labelSmall row (~11 px text + 11 px breathing
/// room) so the dot reads as the port without crowding.
static const double portDotSize = 12;
/// Total card height for a node with [portCount] inputs.
static double heightFor(int portCount) {
return headerHeight +
bodyTopPad +
portCount * portRowHeight +
bodyBottomPad;
}
/// 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 single output port. Vertically centred
/// in the header so the edge entry feels balanced and so
/// "the step's primary output" reads as a peer of the
/// title rather than buried under the with-fields.
static double outputPortY() {
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;
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;
/// 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;
const FlowNode({
super.key,
required this.id,
required this.title,
this.subtitle,
this.inputPortLabels = 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,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accent = _accent(theme.colorScheme);
final height = NodeGeometry.heightFor(inputPortLabels.length);
return SizedBox(
width: NodeGeometry.width,
height: height,
child: 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) {
return const SizedBox.shrink();
}
final align = portSide == NodePortSide.right
? TextAlign.right
: TextAlign.left;
final crossAlign = portSide == NodePortSide.right
? CrossAxisAlignment.end
: CrossAxisAlignment.start;
// Leave a clear gutter on the port side so the canvas
// dot has somewhere to land. Other side gets normal
// padding.
final padding = portSide == NodePortSide.right
? const EdgeInsets.fromLTRB(
FaiSpace.sm,
NodeGeometry.bodyTopPad,
NodeGeometry.portGutter,
NodeGeometry.bodyBottomPad,
)
: const EdgeInsets.fromLTRB(
NodeGeometry.portGutter,
NodeGeometry.bodyTopPad,
FaiSpace.sm,
NodeGeometry.bodyBottomPad,
);
return Padding(
padding: padding,
child: Column(
crossAxisAlignment: crossAlign,
children: [
for (final label in inputPortLabels)
SizedBox(
height: NodeGeometry.portRowHeight,
child: Align(
alignment: portSide == NodePortSide.right
? Alignment.centerRight
: Alignment.centerLeft,
child: Text(
label,
textAlign: align,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: optionalLabels.contains(label)
? theme.colorScheme.onSurface.withValues(alpha: 0.55)
: theme.colorScheme.onSurface,
fontStyle: optionalLabels.contains(label)
? FontStyle.italic
: FontStyle.normal,
),
),
),
),
],
),
);
}
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;
}