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>
444 lines
16 KiB
Dart
444 lines
16 KiB
Dart
// 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;
|
||
}
|