chain-studio-flow-editor/lib/src/widgets/flow_canvas.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

2403 lines
89 KiB
Dart

// FlowCanvas — the graphical flow editor surface.
//
// Layout:
//
// ┌─────────────────────────────────────────────────────┐
// │ ┌──────┐ ┌──────┐ ┌──────┐ ┌─────┐ │
// │ │ │ ─────── │ │ ─────── │ │ │ │ │
// │ │ in │ │ step │ │ step │ │ out │ │
// │ │ │ │ │ │ │ │ │ │
// │ └──────┘ └──────┘ └──────┘ └─────┘ │
// └─────────────────────────────────────────────────────┘
// InteractiveViewer (pan + zoom)
//
// Special "inputs" and "outputs" pseudo-nodes are pinned to
// the left and right of the layout so every flow has a
// visually obvious source and sink. Step nodes live in the
// middle and can be dragged anywhere by the operator. Edges
// are derived live from the FlowGraph's $ref expressions.
//
// Interactions:
//
// - Click a node: selects it; properties panel hooks
// into the selection.
// - Drag the node body: repositions the node in canvas
// coords (sidecar saved on pan end).
// - Drag an output port → drop on an input port:
// creates a `$source.field` reference
// in the target step's `with:` block.
// The properties panel will let the
// operator refine which output field
// the reference points at.
// - Background pan: scrolls the canvas via the parent
// InteractiveViewer.
import 'package:flutter/material.dart';
import '../editor_controller.dart';
import '../editor_style.dart';
import '../l10n.dart';
import '../model/auto_layout.dart';
import '../model/flow_graph.dart';
import '../model/layout_store.dart';
import '../run_driver.dart';
import '../tokens.dart';
import 'edge_painter.dart';
import 'flow_node.dart';
/// Canvas dimensions. Big enough that any plausible flow fits
/// with margin to spare; the InteractiveViewer scrolls /
/// scales as needed.
const double _canvasWidth = 4000;
const double _canvasHeight = 3000;
// Fallback positions when the layout sidecar somehow lacks an
// entry for the inputs/outputs endpoint nodes (shouldn't
// happen — AutoLayout always seeds them — but defending in
// depth so a corrupt sidecar never renders an off-screen
// endpoint).
const NodePosition _inputsFallback = NodePosition(40, 80);
const NodePosition _outputsFallback = NodePosition(1200, 80);
class FlowCanvas extends StatefulWidget {
final FlowEditorController controller;
final FaiEditorStyle style;
/// Bridge to the hub. The canvas calls `driver.moduleInfo`
/// per step's `use:` capability to render declared input +
/// output ports with their canonical field names + i18n
/// tooltips. When null (e.g. test harness), the canvas falls
/// back to YAML-derived port labels and single-anchor outputs.
final FlowRunDriver? driver;
/// Active operator locale, used to pick the right peer from
/// each field's `description.<locale>` map for port
/// tooltips. "en" is the default; "de" is the supported
/// peer today.
final FlowEditorLocale locale;
const FlowCanvas({
super.key,
required this.controller,
this.driver,
this.locale = FlowEditorLocale.en,
this.style = FaiEditorStyle.modern,
});
@override
State<FlowCanvas> createState() => _FlowCanvasState();
}
class _FlowCanvasState extends State<FlowCanvas>
with SingleTickerProviderStateMixin {
final TransformationController _transform = TransformationController();
/// Continuous 0..1 loop that drives the marching-dash
/// animation on edges entering currently-running steps.
/// Stopped when no step is running so we don't burn frame
/// time on flows that are sitting idle.
late final AnimationController _flowAnim;
// Track which flow we last fitted-to-screen for so we
// don't override the operator's manual pan/zoom every
// build. Re-fit when the active flow changes.
String? _fittedFor;
// Active connection-drag state. When non-null, the canvas
// paints a draft edge from the source port to the cursor
// and accepts a drop on any input port.
_ConnectionDraft? _draft;
// Currently-hovered port id. Drives the hover halo so the
// operator gets a clear "this port is interactive"
// affordance before they commit to dragging or clicking.
// String key = same shape used by _connectedPorts so we
// can pass a single hovered-key into the port widget.
String? _hoveredPort;
// Currently-hovered edge — identified by `toId:toField`
// (the target side, which is what gets cleared on
// disconnect). When non-null, that edge renders with the
// highlight accent and is the target of right-click /
// long-press menus on the empty canvas area.
String? _hoveredEdge;
// (intentionally no cursor cache here — the hit-test reads
// event.localPosition directly each frame.)
// Operator-chosen background pattern. Cycles via the
// pattern button on the bottom-right canvas controls.
_CanvasPattern _pattern = _CanvasPattern.dots;
// Effective style for this build pass — `widget.style`
// (or _styleOverride) clamped against MediaQuery's
// reduce-motion preference. Set at the start of every
// build so subsequent helpers (_buildSegments,
// _stepPositioned, etc.) read a single coherent style
// instead of separately querying the source + OS
// preference.
FaiEditorStyle _style = FaiEditorStyle.modern;
// Operator's runtime style override — set by the
// bottom-right ⚙ Style sheet. When non-null, replaces
// `widget.style` for the lifetime of the canvas state.
// null = follow whatever the host passed.
FaiEditorStyle? _styleOverride;
// ModuleSpec cache: capability identifier → declared
// inputs/outputs. Populated lazily on each build by
// walking the current graph's `step.use` values and
// dispatching a host-side moduleInfo() lookup for any
// capability not yet seen. The map value is null while
// the lookup is in flight or has resolved to "no info"
// (legacy v1/v2 manifest); the renderer falls back to
// YAML-derived ports in that case.
final Map<String, ModuleSpec?> _moduleSpecs = {};
// Capabilities whose moduleInfo() call is already in
// flight, so we don't re-trigger on every rebuild.
final Set<String> _moduleSpecsInFlight = {};
// Current zoom level (taken from the TransformationController)
// so the bottom-right indicator can show it as a %.
double _zoom = 1.0;
@override
void initState() {
super.initState();
widget.controller.addListener(_onControllerChanged);
// Watch the transformation controller so the zoom
// indicator at the bottom-right of the canvas can update
// live as the operator pinches / scrolls.
_transform.addListener(_onTransformChanged);
_flowAnim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
}
@override
void dispose() {
widget.controller.removeListener(_onControllerChanged);
_transform.removeListener(_onTransformChanged);
_transform.dispose();
_flowAnim.dispose();
super.dispose();
}
void _onTransformChanged() {
final z = _transform.value.getMaxScaleOnAxis();
if ((z - _zoom).abs() > 0.005) {
setState(() => _zoom = z);
}
}
/// Walk the current graph's steps and kick off a
/// `driver.moduleInfo` lookup for any `use:` capability
/// we haven't seen yet. Results land in `_moduleSpecs`
/// and trigger a rebuild so the next paint draws the
/// per-field input/output ports.
void _refreshModuleSpecs(FlowGraph graph) {
final driver = widget.driver;
if (driver == null) return;
for (final step in graph.steps) {
final cap = step.use;
if (cap.isEmpty) continue;
// Strip the @version suffix — moduleInfo looks up by
// capability name, not by capability ref.
final name = cap.split('@').first;
if (_moduleSpecs.containsKey(name) ||
_moduleSpecsInFlight.contains(name)) {
continue;
}
_moduleSpecsInFlight.add(name);
// ignore: discarded_futures
driver
.moduleInfo(name)
.then((spec) {
if (!mounted) return;
setState(() {
_moduleSpecsInFlight.remove(name);
_moduleSpecs[name] = spec;
});
})
.catchError((Object _) {
if (!mounted) return;
setState(() {
_moduleSpecsInFlight.remove(name);
// Record absence so we don't retry forever
// when the hub doesn't know this capability.
_moduleSpecs[name] = null;
});
});
}
}
/// Look up the cached ModuleSpec for a step's capability,
/// stripping the `@version` suffix.
ModuleSpec? _specForStep(FlowStep step) {
final cap = step.use.split('@').first;
return _moduleSpecs[cap];
}
/// Type descriptor of a step's input field, when known.
/// Returns null when neither the resolved ModuleSpec nor
/// the YAML can determine it — the editor then leaves the
/// drop target compatible-with-anything so a missing
/// manifest doesn't break composition.
String? _stepInputType(FlowStep step, String fieldName) {
final spec = _specForStep(step);
if (spec != null) {
for (final f in spec.inputs) {
if (f.name == fieldName && f.type.isNotEmpty) return f.type;
}
}
return null;
}
/// Type descriptor of a step's output field. ModuleSpec
/// is the only authority for declared outputs; for
/// YAML-implied outputs (no manifest) we return null so
/// the connection layer treats them as
/// compatible-with-everything.
String? _stepOutputType(FlowStep step, String fieldName) {
final spec = _specForStep(step);
if (spec != null) {
for (final f in spec.outputs) {
if (f.name == fieldName && f.type.isNotEmpty) return f.type;
}
}
return null;
}
/// Resolve the type of the source the operator is dragging
/// FROM. Used by drop-target predicates to refuse wires
/// that would couple incompatible types.
String? _typeAtDraftSource() {
final draft = _draft;
if (draft == null) return null;
switch (draft.fromKind) {
case _DraftSourceKind.inputsField:
return widget.controller.graph.inputs[draft.fromId]?.type;
case _DraftSourceKind.step:
if (draft.fromField.isEmpty) return null;
final step = widget.controller.graph.steps.firstWhere(
(s) => s.id == draft.fromId,
orElse: () => const FlowStep(id: '__missing__', use: ''),
);
if (step.id == '__missing__') return null;
return _stepOutputType(step, draft.fromField);
}
}
/// True when [sourceType] can flow into [targetType]. Same
/// type matches; unknown on either side matches everything
/// (graceful degradation when the manifest hasn't shipped
/// or when the field is implicit from the YAML alone).
/// Different known types are refused.
bool _typesCompatible(String? sourceType, String? targetType) {
if (sourceType == null || sourceType.isEmpty) return true;
if (targetType == null || targetType.isEmpty) return true;
return sourceType == targetType;
}
/// Merge ModuleSpec-declared input names with whatever the
/// flow YAML's `with_:` block carries that the manifest
/// didn't declare. Declared inputs keep their manifest
/// order; implicit ones land alphabetically after. Same
/// list used by [_stepPositioned] for rendering and by
/// [_buildSegments] / [_hitTestEdge] for port positions.
List<String> _inputLabelsForStep(FlowStep step) {
final spec = _specForStep(step);
final declared = spec?.inputs.map((f) => f.name).toList() ?? const [];
final implicit =
step.with_.keys.where((k) => !declared.contains(k)).toList()..sort();
return [...declared, ...implicit];
}
/// Width to render a step card with. Grows beyond the
/// default minimum when port labels would otherwise be
/// ellipsis-clipped. Returns the value all geometry
/// helpers (output-port position, fit-to-content,
/// drop-target hit-tests) should use for that step.
double _stepWidth(FlowStep step) {
final ins = _inputLabelsForStep(step);
final outs = _outputLabelsForStep(step);
var maxIn = 0;
for (final l in ins) {
if (l.length > maxIn) maxIn = l.length;
}
var maxOut = 0;
for (final l in outs) {
if (l.length > maxOut) maxOut = l.length;
}
return NodeGeometry.widthFor(maxInputChars: maxIn, maxOutputChars: maxOut);
}
/// Merge ModuleSpec-declared output names with whatever
/// the flow YAML's edges reference for this step. Declared
/// outputs keep their manifest order; implicit (referenced
/// but undeclared) outputs land alphabetically after. Lets
/// the editor render per-field output anchors even when the
/// hub hasn't shipped a manifest (built-ins like
/// system.approval, or older hub binaries).
List<String> _outputLabelsForStep(FlowStep step) {
final spec = _specForStep(step);
final declared = spec?.outputs.map((f) => f.name).toList() ?? const [];
final implicit = <String>{};
for (final edge in widget.controller.graph.edges) {
if (edge.fromKind == EdgeEndpointKind.step &&
edge.fromId == step.id &&
edge.fromField.isNotEmpty &&
!declared.contains(edge.fromField)) {
implicit.add(edge.fromField);
}
}
return [...declared, ...(implicit.toList()..sort())];
}
void _onControllerChanged() {
if (!mounted) return;
// Start the flow-animation controller only while a step
// is actually running; stop it otherwise so we don't
// tick every frame on idle flows.
final anyRunning = widget.controller.stepStatuses.values.any(
(s) => s == StepRunStatus.running,
);
if (anyRunning && !_flowAnim.isAnimating) {
_flowAnim.repeat();
} else if (!anyRunning && _flowAnim.isAnimating) {
_flowAnim.stop();
_flowAnim.value = 0;
}
setState(() {});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final graph = widget.controller.graph;
final layout = widget.controller.layout;
// Clamp the host-supplied style against the OS reduce-
// motion preference. Operators on accessibility settings
// — or kiosk-mode deployments — get the same editor with
// GPU-heavy blur, gradients, and dash animation switched
// off. Helpers below read `_style` instead of `widget.style`
// so the effective shape is single-sourced.
_style = (_styleOverride ?? widget.style).clampedForA11y(
disableAnimations: MediaQuery.disableAnimationsOf(context),
);
// Best-effort lazy module-spec fetch so the per-field
// input/output ports paint on the next frame. Cheap when
// every spec is already cached; only triggers async work
// for genuinely new capabilities.
_refreshModuleSpecs(graph);
// Endpoint positions now live in the layout sidecar
// alongside every step's position — see
// AutoLayout.layout() which seeds defaults. Reading them
// here (instead of recomputing _outputsX from current
// step positions every build) means the endpoints stay
// put when the operator drags a step around. The whole
// "outputs panel shifts when I move a node" pain Stefan
// flagged is solved structurally: there is no auto-
// recompute path left to trigger.
final inputsPos =
layout.positions[AutoLayout.inputsNodeId] ?? _inputsFallback;
final outputsPos =
layout.positions[AutoLayout.outputsNodeId] ?? _outputsFallback;
// Auto-fit on first build for each flow so the operator
// sees the whole graph immediately, even on flows whose
// auto-layout pushes nodes past the default viewport.
// Re-fit triggers only when the flow name changes — the
// operator's subsequent zooms / pans stay theirs.
final activeName = widget.controller.activeName;
if (activeName != null && activeName != _fittedFor) {
_fittedFor = activeName;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _fitToContent();
});
}
return Container(
// Backdrop honours the active editor style. Default is
// a subtle two-stop diagonal gradient that gives the
// canvas depth; "flat" preset drops it for the older
// single-surface look (lower visual chrome, useful for
// low-spec terminals).
decoration: _style.canvasBackdrop == EditorCanvasBackdrop.gradient
? BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
theme.colorScheme.surfaceContainer,
theme.colorScheme.surface,
],
),
)
: BoxDecoration(color: theme.colorScheme.surface),
child: Stack(
children: [
InteractiveViewer(
transformationController: _transform,
constrained: false,
boundaryMargin: const EdgeInsets.all(400),
minScale: 0.4,
maxScale: 2.0,
child: SizedBox(
width: _canvasWidth,
height: _canvasHeight,
child: Stack(
children: [
_grid(theme),
// Edges first so nodes paint on top of them.
Positioned.fill(
child: IgnorePointer(
child: AnimatedBuilder(
animation: _flowAnim,
builder: (context, _) => CustomPaint(
painter: EdgePainter(
segments: _buildSegments(graph, layout),
baseColor: theme.colorScheme.onSurfaceVariant
.withValues(alpha: 0.55),
highlightColor: theme.colorScheme.primary,
draftColor: theme.colorScheme.primary,
portRadius: NodeGeometry.portDotSize / 2,
phase: _flowAnim.value,
),
),
),
),
),
// Edge interaction layer — sits BEHIND nodes
// in the Stack (which means nodes get hit-
// tested first for right-clicks etc.), but
// catches mouse hover globally + secondary-
// tap / long-press anywhere the click misses
// a node. Each cursor-move runs the spatial
// hit-test against every edge's sampled path
// and marks the closest one as hovered.
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
// Tap on the canvas background (no node,
// no port, no edge) deselects the active
// step — closes the properties panel.
// Tapping a node's hit area is consumed
// by the node's own GestureDetector
// (opaque), so this fires only on misses.
// Edges shadow the same hit-test footprint
// via _hitTestEdge; if the cursor was over
// an edge we let the edge keep its hover /
// context-menu treatment instead of
// deselecting.
onTapUp: (details) {
final edge = _hitTestEdge(
details.localPosition,
graph,
layout,
);
if (edge == null) {
widget.controller.selectStep(null);
}
},
onSecondaryTapDown: (details) {
final edge = _hitTestEdge(
details.localPosition,
graph,
layout,
);
if (edge != null) {
_showEdgeContextMenu(edge, details.globalPosition);
}
},
onLongPressStart: (details) {
final edge = _hitTestEdge(
details.localPosition,
graph,
layout,
);
if (edge != null) {
_showEdgeContextMenu(edge, details.globalPosition);
}
},
child: MouseRegion(
onHover: (event) {
final newHover = _hitTestEdge(
event.localPosition,
graph,
layout,
);
if (newHover != _hoveredEdge) {
setState(() => _hoveredEdge = newHover);
}
},
onExit: (_) {
if (_hoveredEdge != null) {
setState(() => _hoveredEdge = null);
}
},
child: const SizedBox.expand(),
),
),
),
// Inputs endpoint — its body labels
// represent OUTPUTS of the node (data flows
// OUT to downstream steps), so the port
// side is RIGHT and labels right-align.
_endpointPositioned(
nodeId: AutoLayout.inputsNodeId,
pos: inputsPos,
title: 'inputs',
kind: NodeVisualKind.inputs,
portSide: NodePortSide.right,
labels: graph.inputs.keys
.map((k) => '$k: ${graph.inputs[k]!.type}')
.toList(),
),
// Outputs endpoint — body labels represent
// INPUTS (data flows IN from steps), so
// port side is LEFT and labels left-align.
_endpointPositioned(
nodeId: AutoLayout.outputsNodeId,
pos: outputsPos,
title: 'outputs',
kind: NodeVisualKind.outputs,
portSide: NodePortSide.left,
labels: graph.outputs.keys.toList(),
),
// Step nodes — positioned absolutely, drag to
// move, click to select.
for (final step in graph.steps) _stepPositioned(step, layout),
// Port hit-targets for connection drawing.
..._portOverlays(graph, layout),
if (_draft != null)
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: EdgePainter(
// Draft line follows the cursor;
// pretend the cursor is on the
// LEFT side so the line "approaches"
// it horizontally (matches the
// input-port orientation it will
// most likely snap to).
segments: [
EdgeSegment(
from: _draft!.from,
to: _draft!.cursor,
fromSide: EdgeSide.right,
toSide: EdgeSide.left,
accent: EdgeAccent.draftDrag,
// The draft target IS the
// cursor — not a port socket.
// Don't shorten on that end or
// the line stops short of
// where the operator's mouse
// actually is.
shortenTo: false,
),
],
baseColor: theme.colorScheme.primary,
highlightColor: theme.colorScheme.primary,
draftColor: theme.colorScheme.primary,
portRadius: NodeGeometry.portDotSize / 2,
),
),
),
),
],
),
),
),
// Floating canvas controls — pinned to the viewport
// bottom-right so they don't drift with pan.
// - Pattern: cycles dots → grid → blank.
// - Reset layout: AutoLayout regenerates positions.
// - Fit to screen: recentres on every node.
// - Zoom indicator: current scale as a %.
Positioned(
right: FaiSpace.md,
bottom: FaiSpace.md,
child: Material(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.sm),
elevation: 2,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Pattern dropdown — pick directly instead
// of cycling. Faster when the operator
// already knows which look they want.
PopupMenuButton<_CanvasPattern>(
icon: Icon(_patternIcon(), size: 18),
tooltip: 'Background',
initialValue: _pattern,
onSelected: (p) => setState(() => _pattern = p),
itemBuilder: (_) => [
_patternMenuItem(
_CanvasPattern.dots,
Icons.grain,
'Dots',
),
_patternMenuItem(
_CanvasPattern.grid,
Icons.grid_on,
'Grid',
),
_patternMenuItem(
_CanvasPattern.modern,
Icons.window_outlined,
'Modern',
),
_patternMenuItem(
_CanvasPattern.classic,
Icons.grid_3x3,
'Classic',
),
_patternMenuItem(
_CanvasPattern.blueprint,
Icons.architecture,
'Blueprint',
),
_patternMenuItem(
_CanvasPattern.minimal,
Icons.density_small,
'Minimal',
),
_patternMenuItem(
_CanvasPattern.blank,
Icons.layers_clear,
'Blank',
),
],
),
IconButton(
onPressed: _openStyleSheet,
icon: const Icon(Icons.tune, size: 18),
tooltip: 'Style',
visualDensity: VisualDensity.compact,
),
IconButton(
onPressed: _resetLayout,
icon: const Icon(Icons.dashboard_outlined, size: 18),
tooltip: 'Reset layout',
visualDensity: VisualDensity.compact,
),
IconButton(
onPressed: _fitToContent,
icon: const Icon(Icons.fit_screen_outlined, size: 18),
tooltip: 'Fit to screen',
visualDensity: VisualDensity.compact,
),
// Zoom % dropdown — preset levels (25 % …
// 200 %) plus "Fit". Faster than scroll-
// wheel zooming to a specific level.
PopupMenuButton<double>(
tooltip: 'Zoom',
onSelected: _setZoom,
itemBuilder: (_) => [
_zoomItem(0.25),
_zoomItem(0.5),
_zoomItem(0.75),
_zoomItem(1.0),
_zoomItem(1.25),
_zoomItem(1.5),
_zoomItem(2.0),
const PopupMenuDivider(),
const PopupMenuItem(
value: -1.0,
child: Row(
children: [
Icon(Icons.fit_screen_outlined, size: 16),
SizedBox(width: 8),
Text('Fit to screen'),
],
),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
vertical: 6,
),
child: Text(
'${(_zoom * 100).round()}%',
style: theme.textTheme.labelSmall?.copyWith(
fontFamily: 'monospace',
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
],
),
),
),
],
),
);
}
/// Compute the bounding box of every visible node — steps
/// + the inputs / outputs endpoints — then set the
/// TransformationController so the box fills the visible
/// viewport with breathing room. No-op when there's no
/// active flow (nothing to fit).
void _fitToContent() {
final graph = widget.controller.graph;
final layout = widget.controller.layout;
if (widget.controller.activeName == null) return;
if (graph.steps.isEmpty && graph.inputs.isEmpty) return;
final inputsPos =
layout.positions[AutoLayout.inputsNodeId] ?? _inputsFallback;
final outputsPos =
layout.positions[AutoLayout.outputsNodeId] ?? _outputsFallback;
// Bounding box: start with the inputs + outputs endpoints
// since they're always present, then expand to include
// every step.
double minX = inputsPos.x;
double minY = inputsPos.y;
double maxX = outputsPos.x + NodeGeometry.width;
double maxY =
inputsPos.y +
NodeGeometry.heightFor(graph.inputs.length).clamp(110.0, 600.0);
if (outputsPos.x < minX) minX = outputsPos.x;
if (outputsPos.y < minY) minY = outputsPos.y;
final outputsBottom =
outputsPos.y +
NodeGeometry.heightFor(graph.outputs.length).clamp(110.0, 600.0);
if (outputsBottom > maxY) maxY = outputsBottom;
// Step nodes.
for (final step in graph.steps) {
final pos = layout.positions[step.id];
if (pos == null) continue;
if (pos.x < minX) minX = pos.x;
if (pos.y < minY) minY = pos.y;
final right = pos.x + _stepWidth(step);
final bottom =
pos.y +
NodeGeometry.heightFor(
_inputLabelsForStep(step).length,
_outputLabelsForStep(step).length,
);
if (right > maxX) maxX = right;
if (bottom > maxY) maxY = bottom;
}
// Padding so nodes don't kiss the viewport edge.
const pad = 80.0;
minX -= pad;
minY -= pad;
maxX += pad;
maxY += pad;
final boxW = maxX - minX;
final boxH = maxY - minY;
final size = (context.findRenderObject() as RenderBox?)?.size;
if (size == null || size.width <= 0 || size.height <= 0) return;
final scale = (size.width / boxW).clamp(0.0, 2.0).toDouble();
final scale2 = (size.height / boxH).clamp(0.0, 2.0).toDouble();
final finalScale = scale < scale2 ? scale : scale2;
final tx = -minX * finalScale + (size.width - boxW * finalScale) / 2;
final ty = -minY * finalScale + (size.height - boxH * finalScale) / 2;
_transform.value = Matrix4.identity()
..translateByDouble(tx, ty, 0, 1)
..scaleByDouble(finalScale, finalScale, 1, 1);
}
// --- Node positioning + drag ---
Widget _stepPositioned(FlowStep step, FlowLayout layout) {
final pos = layout.positions[step.id];
if (pos == null) return const SizedBox.shrink();
final selected = widget.controller.selectedStepId == step.id;
final raw = widget.controller.stepStatuses[step.id] ?? StepRunStatus.idle;
final status = _toNodeStatus(raw);
// How many with-fields carry a wired-up `$src.field`
// expression. Drives the header's "n/total" badge so the
// operator can see at a glance whether the module is
// fully connected.
final wired = step.with_.values
.whereType<Object>()
.where((v) => _isWiredExpression(v.toString()))
.length;
// Per-field input/output ports: merge whatever the
// ModuleSpec from the hub declares with whatever the
// flow YAML actually references. The union ensures we
// render ports even when the hub hasn't shipped a
// manifest (built-in capabilities like system.approval),
// when the hub binary is older than the per-field-port
// RPC, or when the operator hasn't re-installed the
// module to pick up its new schema_version 3 manifest.
//
// Declared fields keep their manifest order (alphabetical
// server-side). Implicit fields (those that show up in
// the YAML but aren't declared) are appended alphabetically
// afterwards — so the layout stays stable when a new
// manifest later lands and adds them officially.
final spec = _specForStep(step);
final inputLabels = _inputLabelsForStep(step);
final outputLabels = _outputLabelsForStep(step);
// Tooltips: every visible label gets one. When the
// manifest carries a locale description, use it. Else
// fall back to `name (type)` for declared fields whose
// description is missing, or to the bare field name for
// implicit (YAML-only) fields. Operators always see
// something on hover — useful at least to confirm the
// exact field they're pointing at.
final tooltips = <String, String>{};
final loc = widget.locale == FlowEditorLocale.de ? 'de' : 'en';
final declaredFields = <String, ModuleField>{};
if (spec != null) {
for (final f in [...spec.inputs, ...spec.outputs]) {
declaredFields[f.name] = f;
}
}
for (final label in [...inputLabels, ...outputLabels]) {
final f = declaredFields[label];
if (f != null) {
final d = f.descriptionFor(loc);
if (d != null && d.isNotEmpty) {
tooltips[label] = d;
} else if (f.type.isNotEmpty) {
tooltips[label] = '$label · ${f.type}';
} else {
tooltips[label] = label;
}
} else {
tooltips[label] = label;
}
}
final cardHeight = NodeGeometry.heightFor(
inputLabels.length,
outputLabels.length,
);
final cardWidth = _stepWidth(step);
return Positioned(
left: pos.x,
top: pos.y,
child: FlowNode(
width: cardWidth,
id: step.id,
title: step.id,
subtitle: step.use,
inputPortLabels: inputLabels,
outputPortLabels: outputLabels,
portTooltips: tooltips,
wiredCount: wired,
kind: kindForStep(step),
selected: selected,
status: status,
elevated: _style.nodeShadows,
// Breathing-pulse on running steps when the active
// style allows flow animation. The canvas's existing
// _flowAnim ticks 0..1 during runs and is gated on
// the same accessibility / style preferences, so we
// can route it straight through.
pulse: _style.flowAnimation && status == FlowNodeStatus.running
? _flowAnim
: null,
onTap: () => widget.controller.selectStep(step.id),
onDrag: (delta) => _applyDrag(step.id, pos, delta, cardHeight),
onContextMenu: (globalPos) => _showStepContextMenu(step, globalPos),
),
);
}
/// Endpoint nodes (inputs / outputs) live on the canvas
/// just like step nodes — same drag handler, same position
/// stored in the layout sidecar. Difference: no select
/// affordance (no per-step properties to edit) and no
/// status indicator (endpoints don't run).
Widget _endpointPositioned({
required String nodeId,
required NodePosition pos,
required String title,
required NodeVisualKind kind,
required NodePortSide portSide,
required List<String> labels,
}) {
final selected = widget.controller.selectedStepId == nodeId;
return Positioned(
left: pos.x,
top: pos.y,
child: FlowNode(
id: nodeId,
title: title,
kind: kind,
portSide: portSide,
inputPortLabels: labels,
selected: selected,
// Endpoints are selectable too — selecting opens
// the inputs / outputs editor in the properties
// panel so the operator can rename, retype, or add
// entries graphically instead of editing YAML.
onTap: () => widget.controller.selectStep(nodeId),
onDrag: (delta) => _applyDrag(
nodeId,
pos,
delta,
NodeGeometry.heightFor(labels.length),
),
),
);
}
/// Single drag entry point used by every node on the
/// canvas. Converts a screen-space delta to canvas-space
/// (via the current InteractiveViewer scale), clamps the
/// new position to the canvas bounds, and forwards to the
/// controller which persists to the sidecar.
void _applyDrag(
String nodeId,
NodePosition current,
Offset delta,
double nodeHeight,
) {
final scale = _transform.value.getMaxScaleOnAxis();
final scaledDelta = delta / scale;
final newPos = NodePosition(
(current.x + scaledDelta.dx).clamp(
0.0,
_canvasWidth - NodeGeometry.width,
),
(current.y + scaledDelta.dy).clamp(0.0, _canvasHeight - nodeHeight),
);
widget.controller.moveStep(nodeId, newPos);
}
// --- Port positions in canvas coordinates ---
/// Right-edge output port for a step node. When the step's
/// ModuleSpec has been resolved AND [fieldName] is one of
/// its declared outputs, the port lands at that row;
/// otherwise it falls back to the legacy header-centred
/// anchor (single output per step).
Offset _outputPortPosition(
String nodeId,
FlowLayout layout, {
String? fieldName,
}) {
final pos = layout.positions[nodeId];
if (pos == null) return Offset.zero;
final step = widget.controller.graph.steps.firstWhere(
(s) => s.id == nodeId,
orElse: () => const FlowStep(id: '__missing__', use: ''),
);
final labels = _outputLabelsForStep(step);
double y;
if (fieldName != null && labels.isNotEmpty) {
final idx = labels.indexOf(fieldName);
if (idx >= 0) {
y = NodeGeometry.outputPortY(idx);
} else {
y = NodeGeometry.outputAnchorY();
}
} else {
y = NodeGeometry.outputAnchorY();
}
// Use the step's actual rendered width so the port dot
// hugs the right edge of cards that grew wider to fit
// long labels (e.g. model_endpoint / source_language).
final w = step.id == '__missing__' ? NodeGeometry.width : _stepWidth(step);
return Offset(pos.x + w, pos.y + y);
}
/// Left-edge input port for any node. Works for step nodes
/// AND the outputs endpoint — both have input ports on
/// their left, both have a layout position.
Offset _inputPortPosition(String nodeId, int portIndex, FlowLayout layout) {
final pos = layout.positions[nodeId];
if (pos == null) return Offset.zero;
return Offset(pos.x, pos.y + NodeGeometry.inputPortY(portIndex));
}
/// Inputs endpoint exposes one port per declared input on
/// its RIGHT edge — every declared input is a "source" of
/// data that downstream steps can read from.
Offset _inputsEndpointPortPosition(int portIndex, FlowLayout layout) {
final pos = layout.positions[AutoLayout.inputsNodeId] ?? _inputsFallback;
return Offset(
pos.x + NodeGeometry.width,
pos.y + NodeGeometry.inputPortY(portIndex),
);
}
// --- Edge build (graph -> render segments) ---
List<EdgeSegment> _buildSegments(FlowGraph graph, FlowLayout layout) {
final out = <EdgeSegment>[];
final inputsList = graph.inputs.keys.toList();
for (final edge in graph.edges) {
Offset? from;
Offset? to;
EdgeSide? fromSide;
EdgeSide? toSide;
if (edge.fromKind == EdgeEndpointKind.inputs) {
final idx = inputsList.indexOf(edge.fromField);
if (idx >= 0) {
from = _inputsEndpointPortPosition(idx, layout);
// Inputs endpoint ports live on the node's right edge
// — that's where the dot sits, and where the bezier
// should originate.
fromSide = EdgeSide.right;
}
} else if (edge.fromKind == EdgeEndpointKind.step) {
from = _outputPortPosition(
edge.fromId,
layout,
fieldName: edge.fromField,
);
// Step output is on the right edge.
fromSide = EdgeSide.right;
}
if (edge.toKind == EdgeEndpointKind.step) {
final step = graph.steps.firstWhere(
(s) => s.id == edge.toId,
orElse: () => const FlowStep(id: '__missing__', use: ''),
);
final idx = _inputLabelsForStep(step).indexOf(edge.toField);
if (idx >= 0) {
to = _inputPortPosition(edge.toId, idx, layout);
toSide = EdgeSide.left;
}
} else if (edge.toKind == EdgeEndpointKind.outputs) {
final outputsList = graph.outputs.keys.toList();
final idx = outputsList.indexOf(edge.toField);
if (idx >= 0) {
to = _inputPortPosition(AutoLayout.outputsNodeId, idx, layout);
toSide = EdgeSide.left;
}
}
if (from == null || to == null || fromSide == null || toSide == null) {
continue;
}
final edgeKey = '${edge.toId}:${edge.toField}';
final highlight =
_hoveredEdge == edgeKey ||
edge.fromId == widget.controller.selectedStepId ||
edge.toId == widget.controller.selectedStepId;
// Type-aware wire colours at both endpoints. Inputs-
// endpoint side carries the declared input type's
// accent; step-output side reads the ModuleSpec when
// resolved so the wire's target colour matches the
// declared output type. With both ends typed, the
// painter draws a source→target gradient — operators
// read flow direction from colour alone, no arrow
// heads needed.
final theme2 = Theme.of(context);
Color? wireColorStart;
if (edge.fromKind == EdgeEndpointKind.inputs) {
final input = graph.inputs[edge.fromField];
if (input != null) {
wireColorStart = _typeAccent(input.type, theme2);
}
} else if (edge.fromKind == EdgeEndpointKind.step) {
final fromStep = graph.steps.firstWhere(
(s) => s.id == edge.fromId,
orElse: () => const FlowStep(id: '__missing__', use: ''),
);
final fromSpec = _specForStep(fromStep);
if (fromSpec != null) {
final f = fromSpec.outputs.firstWhere(
(f) => f.name == edge.fromField,
orElse: () => const ModuleField(name: '', type: ''),
);
if (f.type.isNotEmpty) {
wireColorStart = _typeAccent(f.type, theme2);
}
}
}
Color? wireColorEnd;
// outputs endpoint has no declared type of its own —
// it's a pass-through. The wire reads as the source
// type, so we leave wireColorEnd null and the painter
// collapses to a solid wire.
if (edge.toKind == EdgeEndpointKind.step) {
final toStep = graph.steps.firstWhere(
(s) => s.id == edge.toId,
orElse: () => const FlowStep(id: '__missing__', use: ''),
);
final toSpec = _specForStep(toStep);
if (toSpec != null) {
final f = toSpec.inputs.firstWhere(
(f) => f.name == edge.toField,
orElse: () => const ModuleField(name: '', type: ''),
);
if (f.type.isNotEmpty) {
wireColorEnd = _typeAccent(f.type, theme2);
}
}
}
// Edge is "live" when its target step is currently
// executing AND the active style allows flow animation
// (operators on reduce-motion preferences get a static
// edge during runs).
final targetStatus = edge.toKind == EdgeEndpointKind.step
? widget.controller.stepStatuses[edge.toId]
: null;
final animated =
_style.flowAnimation && targetStatus == StepRunStatus.running;
// Hover / selection always wins the colour treatment
// — operators need a clear "I'm looking at this one"
// signal that overrides the type colour.
final color = highlight ? null : (wireColorStart ?? wireColorEnd);
final colorEnd = highlight ? null : (wireColorEnd ?? wireColorStart);
// Only attach the gradient end-colour when both sides
// resolved to a real type AND they differ. Same colour
// both ends → solid wire (the painter's existing path).
final gradientEnd =
(color != null && colorEnd != null && color != colorEnd)
? colorEnd
: null;
out.add(
EdgeSegment(
from: from,
to: to,
fromSide: fromSide,
toSide: toSide,
accent: highlight ? EdgeAccent.highlight : EdgeAccent.normal,
color: color,
colorEnd: gradientEnd,
animated: animated,
),
);
}
return out;
}
// --- Edge hit-testing ---
/// Hit-test the cursor's canvas position against every
/// edge in the graph. Returns the target-key
/// (`<toId>:<toField>`) of the closest edge within
/// [hitThreshold] canvas pixels, or null if no edge is
/// near enough.
///
/// Each edge's curve is sampled at 24 points and we test
/// distance to every sample. For the flow sizes operators
/// work with (dozens of edges max), this is fast enough to
/// run on every mouse-move frame.
String? _hitTestEdge(
Offset cursorCanvas,
FlowGraph graph,
FlowLayout layout,
) {
const hitThreshold = 8.0;
String? bestKey;
double bestDist = double.infinity;
final inputsList = graph.inputs.keys.toList();
for (final edge in graph.edges) {
Offset? from;
Offset? to;
if (edge.fromKind == EdgeEndpointKind.inputs) {
final idx = inputsList.indexOf(edge.fromField);
if (idx >= 0) from = _inputsEndpointPortPosition(idx, layout);
} else if (edge.fromKind == EdgeEndpointKind.step) {
from = _outputPortPosition(
edge.fromId,
layout,
fieldName: edge.fromField,
);
}
if (edge.toKind == EdgeEndpointKind.step) {
final step = graph.steps.firstWhere(
(s) => s.id == edge.toId,
orElse: () => const FlowStep(id: '__missing__', use: ''),
);
final idx = _inputLabelsForStep(step).indexOf(edge.toField);
if (idx >= 0) to = _inputPortPosition(edge.toId, idx, layout);
} else if (edge.toKind == EdgeEndpointKind.outputs) {
final outs = graph.outputs.keys.toList();
final idx = outs.indexOf(edge.toField);
if (idx >= 0) {
to = _inputPortPosition(AutoLayout.outputsNodeId, idx, layout);
}
}
if (from == null || to == null) continue;
final samples = _sampleEdgePath(from, to);
for (final sample in samples) {
final d = (cursorCanvas - sample).distance;
if (d < bestDist && d <= hitThreshold) {
bestDist = d;
bestKey = '${edge.toId}:${edge.toField}';
}
}
}
return bestKey;
}
/// Sample 24 points along the cubic bezier path used by
/// EdgePainter. The same formula is replicated here so the
/// hit-test geometry matches what's drawn pixel-for-pixel
/// — when the renderer changes, this needs to follow.
List<Offset> _sampleEdgePath(Offset from, Offset to) {
final dx = (to.dx - from.dx).abs();
final handleLen = (dx / 2).clamp(60.0, 260.0);
final cp1 = Offset(from.dx + handleLen, from.dy);
final cp2 = Offset(to.dx - handleLen, to.dy);
const samples = 24;
final pts = <Offset>[];
for (var i = 0; i <= samples; i++) {
final t = i / samples;
final mt = 1 - t;
final x =
mt * mt * mt * from.dx +
3 * mt * mt * t * cp1.dx +
3 * mt * t * t * cp2.dx +
t * t * t * to.dx;
final y =
mt * mt * mt * from.dy +
3 * mt * mt * t * cp1.dy +
3 * mt * t * t * cp2.dy +
t * t * t * to.dy;
pts.add(Offset(x, y));
}
return pts;
}
/// Open the Disconnect popup for an edge. The edge is
/// identified by its target-key (`<toId>:<toField>`); we
/// split it back into the parts the action needs.
Future<void> _showEdgeContextMenu(String edgeKey, Offset globalPos) async {
final overlay =
Overlay.of(context).context.findRenderObject() as RenderBox?;
if (overlay == null) return;
final theme = Theme.of(context);
final result = await showMenu<_EdgeAction>(
context: context,
position: RelativeRect.fromRect(
Rect.fromPoints(globalPos, globalPos),
Offset.zero & overlay.size,
),
items: [
PopupMenuItem(
value: _EdgeAction.disconnect,
child: Row(
children: [
Icon(Icons.link_off, size: 16, color: theme.colorScheme.error),
const SizedBox(width: 8),
Text(
'Disconnect',
style: TextStyle(color: theme.colorScheme.error),
),
],
),
),
],
);
if (!mounted || result != _EdgeAction.disconnect) return;
final colon = edgeKey.indexOf(':');
if (colon < 0) return;
final toId = edgeKey.substring(0, colon);
final toField = edgeKey.substring(colon + 1);
_disconnectEdgeTarget(toId, toField);
}
void _disconnectEdgeTarget(String toId, String toField) {
final graph = widget.controller.graph;
if (toId == AutoLayout.outputsNodeId) {
final next = {
for (final e in graph.outputs.entries)
e.key: e.key == toField ? '' : e.value,
};
widget.controller.applyGraphEdit(
FlowGraph(
name: graph.name,
inputs: graph.inputs,
steps: graph.steps,
outputs: next,
leadingComment: graph.leadingComment,
),
);
} else {
final step = graph.steps.firstWhere(
(s) => s.id == toId,
orElse: () => const FlowStep(id: '', use: ''),
);
if (step.id.isEmpty) return;
final newWith = {...step.with_, toField: ''};
widget.controller.applyGraphEdit(
graph.withStepUpdated(toId, step.copyWith(with_: newWith)),
);
}
}
// --- Canvas chrome controls ---
IconData _patternIcon() {
return switch (_pattern) {
_CanvasPattern.dots => Icons.grain,
_CanvasPattern.grid => Icons.grid_on,
_CanvasPattern.blank => Icons.layers_clear,
_CanvasPattern.modern => Icons.window_outlined,
_CanvasPattern.classic => Icons.grid_3x3,
_CanvasPattern.blueprint => Icons.architecture,
_CanvasPattern.minimal => Icons.density_small,
};
}
PopupMenuItem<_CanvasPattern> _patternMenuItem(
_CanvasPattern value,
IconData icon,
String label,
) {
final selected = _pattern == value;
return PopupMenuItem(
value: value,
child: Row(
children: [
Icon(icon, size: 16),
const SizedBox(width: 8),
Text(label),
if (selected) ...[const Spacer(), const Icon(Icons.check, size: 14)],
],
),
);
}
PopupMenuItem<double> _zoomItem(double level) {
final selected = (_zoom - level).abs() < 0.02;
return PopupMenuItem(
value: level,
child: Row(
children: [
SizedBox(
width: 48,
child: Text(
'${(level * 100).round()}%',
style: const TextStyle(fontFamily: 'monospace'),
),
),
if (selected) const Icon(Icons.check, size: 14),
],
),
);
}
/// Apply a discrete zoom level from the dropdown.
/// -1.0 is the sentinel meaning "fit to screen". Anything
/// positive = exact scale; preserve the current translation
/// so the operator doesn't lose their pan when they pick
/// a zoom value.
void _setZoom(double level) {
if (level < 0) {
_fitToContent();
return;
}
final tx = _transform.value.getTranslation();
_transform.value = Matrix4.identity()
..translateByDouble(tx.x, tx.y, 0, 1)
..scaleByDouble(level, level, 1, 1);
}
/// Modal bottom sheet listing the four style toggles
/// (glass, gradient backdrop, flow animation, node
/// shadows). Edits are kept in `_styleOverride` for the
/// lifetime of the canvas; "Reset to host default" drops
/// the override and the host-supplied style wins again.
Future<void> _openStyleSheet() async {
// Seed from the current effective source (override or
// host) so toggles open in the right state.
final base = _styleOverride ?? widget.style;
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (ctx) {
var draft = base;
return StatefulBuilder(
builder: (ctx, setLocal) {
Widget tile({
required String title,
required String subtitle,
required bool value,
required ValueChanged<bool> onChanged,
}) {
return SwitchListTile(
title: Text(title),
subtitle: Text(
subtitle,
style: Theme.of(ctx).textTheme.bodySmall,
),
value: value,
onChanged: (v) {
setLocal(() => onChanged(v));
},
);
}
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: const Text('Editor style'),
subtitle: const Text(
'Toggles apply to this canvas for the rest of the session.',
),
trailing: TextButton(
onPressed: () {
setState(() => _styleOverride = null);
Navigator.of(ctx).pop();
},
child: const Text('Reset to default'),
),
),
tile(
title: 'Frosted glass panels',
subtitle: 'BackdropFilter blur on the properties panel.',
value: draft.panelStyle == EditorPanelStyle.glass,
onChanged: (v) {
draft = draft.copyWith(
panelStyle: v
? EditorPanelStyle.glass
: EditorPanelStyle.solid,
);
setState(() => _styleOverride = draft);
},
),
tile(
title: 'Gradient canvas backdrop',
subtitle: 'Subtle diagonal gradient on the canvas.',
value:
draft.canvasBackdrop == EditorCanvasBackdrop.gradient,
onChanged: (v) {
draft = draft.copyWith(
canvasBackdrop: v
? EditorCanvasBackdrop.gradient
: EditorCanvasBackdrop.flat,
);
setState(() => _styleOverride = draft);
},
),
tile(
title: 'Flow animation',
subtitle:
'Marching dashes + breathing pulse during runs.',
value: draft.flowAnimation,
onChanged: (v) {
draft = draft.copyWith(flowAnimation: v);
setState(() => _styleOverride = draft);
},
),
tile(
title: 'Node shadows',
subtitle: 'Layered drop shadows under each step card.',
value: draft.nodeShadows,
onChanged: (v) {
draft = draft.copyWith(nodeShadows: v);
setState(() => _styleOverride = draft);
},
),
],
),
),
);
},
);
},
);
}
// --- Port overlays (drag handles for creating edges) ---
Iterable<Widget> _portOverlays(FlowGraph graph, FlowLayout layout) sync* {
// Compute the connected-port set once per build so every
// port dot can render filled or outlined based on real
// wiring state. Keyed by "nodeId:fieldName" both sides.
final connectedPorts = _connectedPorts(graph);
// Output ports — step nodes' right edges. One dot per
// declared (or YAML-implied) output field so the operator
// can grab any specific output to drag a wire. Falls back
// to a single legacy anchor when the step has no resolved
// outputs at all (e.g. brand-new step with no edges yet).
for (final step in graph.steps) {
final labels = _outputLabelsForStep(step);
if (labels.isEmpty) {
final p = _outputPortPosition(step.id, layout);
final key = '${step.id}:__out__';
yield _portDot(
portKey: key,
center: p,
isSource: true,
connected: connectedPorts.contains(key),
accent: Theme.of(context).colorScheme.primary,
onDragStart: () => _draft = _ConnectionDraft(
fromKind: _DraftSourceKind.step,
fromId: step.id,
from: p,
cursor: p,
),
);
continue;
}
for (final field in labels) {
final p = _outputPortPosition(step.id, layout, fieldName: field);
final key = '${step.id}:$field';
// Output port colour matches the declared field's
// type so operators read the wire's payload from the
// dot alone (LabVIEW convention). Falls back to the
// theme's primary when the field's type can't be
// resolved (legacy v1/v2 manifest, hub not loaded).
final type = _stepOutputType(step, field);
final accent = type != null
? _typeAccent(type, Theme.of(context))
: Theme.of(context).colorScheme.primary;
yield _portDot(
portKey: key,
center: p,
isSource: true,
connected: connectedPorts.contains(key),
accent: accent,
onDragStart: () => _draft = _ConnectionDraft(
fromKind: _DraftSourceKind.step,
fromId: step.id,
fromField: field,
from: p,
cursor: p,
),
);
}
}
// Inputs endpoint output ports — one per declared input.
final inputsList = graph.inputs.keys.toList();
for (var i = 0; i < inputsList.length; i++) {
final p = _inputsEndpointPortPosition(i, layout);
final fieldName = inputsList[i];
final input = graph.inputs[fieldName]!;
final key = 'inputs:$fieldName';
yield _portDot(
portKey: key,
center: p,
isSource: true,
connected: connectedPorts.contains(key),
accent: _typeAccent(input.type, Theme.of(context)),
onDragStart: () => _draft = _ConnectionDraft(
fromKind: _DraftSourceKind.inputsField,
fromId: fieldName,
from: p,
cursor: p,
),
);
}
// Step input port targets — left edges. Right-click on a
// wired port opens the disconnect menu. We render every
// label the merged-labels helper emits (declared by the
// manifest + implicit from with_) so the dot positions
// match what FlowNode.build draws on the card body.
for (final step in graph.steps) {
final labels = _inputLabelsForStep(step);
for (var i = 0; i < labels.length; i++) {
final p = _inputPortPosition(step.id, i, layout);
final field = labels[i];
final value = step.with_[field]?.toString() ?? '';
final wired = _isWiredExpression(value);
// Step-input dot wears the declared field's type
// colour so a `bytes` slot reads differently from a
// `text` slot at a glance.
final type = _stepInputType(step, field);
final accent = type != null
? _typeAccent(type, Theme.of(context))
: Theme.of(context).colorScheme.primary;
yield _portDot(
portKey: '${step.id}:$field',
center: p,
isSource: false,
connected: wired,
accent: accent,
onContextMenu: !wired
? null
: (pos) => _disconnectInputPort(step.id, field, pos),
);
}
}
// Outputs endpoint input ports — colour by the upstream
// type when the YAML's $step.field expression resolves
// to a step output whose type we know. Otherwise stays
// the theme's primary so the dot is still visible.
final outs = graph.outputs.keys.toList();
for (var i = 0; i < outs.length; i++) {
final p = _inputPortPosition(AutoLayout.outputsNodeId, i, layout);
final field = outs[i];
final expr = graph.outputs[field] ?? '';
final wired = _isWiredExpression(expr);
final upstreamType = _outputsEndpointType(field, graph);
final accent = upstreamType != null
? _typeAccent(upstreamType, Theme.of(context))
: Theme.of(context).colorScheme.primary;
yield _portDot(
portKey: 'outputs:$field',
center: p,
isSource: false,
connected: wired,
accent: accent,
onContextMenu: !wired
? null
: (pos) => _disconnectOutputPort(field, pos),
);
}
}
/// Resolve the upstream type for an outputs-endpoint slot.
/// Walks the edges into the outputs endpoint; when the
/// source is a step whose ModuleSpec exposes the field's
/// type, returns it. When source is an inputs endpoint
/// (pass-through), returns the FlowInput's declared type.
String? _outputsEndpointType(String field, FlowGraph graph) {
for (final edge in graph.edges) {
if (edge.toKind != EdgeEndpointKind.outputs) continue;
if (edge.toField != field) continue;
if (edge.fromKind == EdgeEndpointKind.inputs) {
return graph.inputs[edge.fromField]?.type;
}
if (edge.fromKind == EdgeEndpointKind.step) {
final step = graph.steps.firstWhere(
(s) => s.id == edge.fromId,
orElse: () => const FlowStep(id: '__missing__', use: ''),
);
if (step.id == '__missing__') return null;
return _stepOutputType(step, edge.fromField);
}
}
return null;
}
/// True when [expression] looks like a `$src.field` ref
/// (the canonical wired-up form). Literal text or empty
/// values count as unwired.
bool _isWiredExpression(String expression) {
return RegExp(
r'\$[A-Za-z_][A-Za-z0-9_-]*\.[A-Za-z_][A-Za-z0-9_-]*',
).hasMatch(expression);
}
/// All port keys that participate in an edge. Used to
/// decide whether each port dot renders filled (connected)
/// or outlined (dangling).
Set<String> _connectedPorts(FlowGraph graph) {
final set = <String>{};
for (final edge in graph.edges) {
// FROM side
if (edge.fromKind == EdgeEndpointKind.inputs) {
set.add('inputs:${edge.fromField}');
} else if (edge.fromKind == EdgeEndpointKind.step) {
// Specific output is wired iff at least one edge
// leaves THIS field. The legacy `__out__` key is
// also seeded so the fallback single-anchor case
// (step with no declared outputs yet) still renders
// its dot as connected.
if (edge.fromField.isNotEmpty) {
set.add('${edge.fromId}:${edge.fromField}');
}
set.add('${edge.fromId}:__out__');
}
// TO side
if (edge.toKind == EdgeEndpointKind.step) {
set.add('${edge.toId}:${edge.toField}');
} else if (edge.toKind == EdgeEndpointKind.outputs) {
set.add('outputs:${edge.toField}');
}
}
return set;
}
/// Datatype → port + wire accent. Inspired by LabVIEW's
/// long-running convention (string → pink/magenta, cluster
/// → brown, numeric → amber/orange) where each type carries
/// a sticky colour you learn after one flow. Tuned so the
/// five F∆I types stay distinguishable both at glance
/// (saturation differences) and for colour-blind operators
/// (different luminance, not only different hue).
Color _typeAccent(String type, ThemeData theme) {
final isDark = theme.brightness == Brightness.dark;
switch (type) {
case 'text':
// Magenta / pink — LabVIEW's string colour. Pops on
// both light + dark surfaces; high saturation reads
// as "text flows here".
return isDark ? const Color(0xFFFF6FB5) : const Color(0xFFD81B60);
case 'json':
// Amber / orange — structured-data colour, evokes
// LabVIEW's cluster brown. Reads as "compound payload".
return isDark ? const Color(0xFFFFB74D) : const Color(0xFFEF6C00);
case 'bytes':
// Cyan / teal — raw binary. Distinct from string-pink
// and from the green of "file reference" so the
// operator never confuses "I'm sending raw bytes" with
// "I'm sending a file handle".
return isDark ? const Color(0xFF4DD0E1) : const Color(0xFF00838F);
case 'file':
// Green — file reference / resource handle. LabVIEW
// uses green for refnums; reads as "this is a pointer
// to something on disk".
return isDark ? const Color(0xFF81C784) : const Color(0xFF2E7D32);
case 'number':
case 'integer':
return isDark ? const Color(0xFFFFD54F) : const Color(0xFFF9A825);
default:
// Unknown type — neutral, deliberately desaturated
// so the operator notices "I haven't typed this".
return theme.colorScheme.onSurfaceVariant;
}
}
Future<void> _disconnectInputPort(
String stepId,
String field,
Offset globalPos,
) async {
final action = await _showDisconnectMenu(globalPos);
if (!mounted || action != _PortAction.disconnect) return;
final graph = widget.controller.graph;
final step = graph.steps.firstWhere(
(s) => s.id == stepId,
orElse: () => const FlowStep(id: '', use: ''),
);
if (step.id.isEmpty) return;
final newWith = {...step.with_, field: ''};
widget.controller.applyGraphEdit(
graph.withStepUpdated(stepId, step.copyWith(with_: newWith)),
);
}
Future<void> _disconnectOutputPort(String field, Offset globalPos) async {
final action = await _showDisconnectMenu(globalPos);
if (!mounted || action != _PortAction.disconnect) return;
final graph = widget.controller.graph;
final next = {
for (final e in graph.outputs.entries)
e.key: e.key == field ? '' : e.value,
};
widget.controller.applyGraphEdit(
FlowGraph(
name: graph.name,
inputs: graph.inputs,
steps: graph.steps,
outputs: next,
leadingComment: graph.leadingComment,
),
);
}
Future<_PortAction?> _showDisconnectMenu(Offset globalPos) async {
final overlay =
Overlay.of(context).context.findRenderObject() as RenderBox?;
if (overlay == null) return null;
final theme = Theme.of(context);
return showMenu<_PortAction>(
context: context,
position: RelativeRect.fromRect(
Rect.fromPoints(globalPos, globalPos),
Offset.zero & overlay.size,
),
items: [
PopupMenuItem(
value: _PortAction.disconnect,
child: Row(
children: [
Icon(Icons.link_off, size: 16, color: theme.colorScheme.error),
const SizedBox(width: 8),
Text(
'Disconnect',
style: TextStyle(color: theme.colorScheme.error),
),
],
),
),
],
);
}
Widget _portDot({
required String portKey,
required Offset center,
required bool isSource,
required bool connected,
required Color accent,
VoidCallback? onDragStart,
void Function(Offset globalPos)? onContextMenu,
}) {
final theme = Theme.of(context);
final dragging = _draft != null;
final isInputDuringDrag = dragging && !isSource;
final isClosest = isInputDuringDrag && _isClosestDropTarget(center);
final isHovered = _hoveredPort == portKey;
// Size scales with focus level:
// - drop-target halo (drag is over this port) → 18 px
// - hover (mouse-over without dragging) → 15 px
// - resting → 12 px
// The hover bump is the new "this port is interactive"
// affordance Stefan asked for.
final size = isClosest
? 18.0
: (isHovered ? 15.0 : NodeGeometry.portDotSize);
// Fill rule: filled when this port participates in an
// edge, OR it's the closest drop target mid-drag. Plain
// outlined circle when neither — the operator sees at
// a glance which ports are wired.
final filled = connected || isClosest;
return Positioned(
left: center.dx - size / 2,
top: center.dy - size / 2,
width: size,
height: size,
child: MouseRegion(
cursor: isSource ? SystemMouseCursors.grab : SystemMouseCursors.cell,
onEnter: (_) => setState(() => _hoveredPort = portKey),
onExit: (_) {
if (_hoveredPort == portKey) {
setState(() => _hoveredPort = null);
}
},
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: !isSource
? null
: (details) {
onDragStart?.call();
setState(() {});
},
onPanUpdate: !isSource || _draft == null
? null
: (details) {
final scale = _transform.value.getMaxScaleOnAxis();
setState(() {
_draft = _draft!.withCursor(
_draft!.cursor + details.delta / scale,
);
});
},
onPanEnd: !isSource || _draft == null
? null
: (_) {
_finalizeDraft();
},
onSecondaryTapDown: onContextMenu == null
? null
: (details) => onContextMenu(details.globalPosition),
// Long-press fallback for trackpad-only users.
onLongPressStart: onContextMenu == null
? null
: (details) => onContextMenu(details.globalPosition),
child: Container(
// Connected = solid filled circle in accent.
// Dangling = clear ring with surface fill so the
// empty socket is obvious. Drop-target halo
// glows in addition. No inner pin — Stefan
// explicitly wants the connected variant to read
// as one continuous filled disc, not a ring with
// a bullseye in it.
decoration: BoxDecoration(
shape: BoxShape.circle,
color: filled ? accent : theme.colorScheme.surface,
border: Border.all(
color: accent,
width: isClosest ? 2.5 : (isHovered ? 2.2 : 1.8),
),
boxShadow: (isClosest || isHovered)
? [
BoxShadow(
color: accent.withValues(
alpha: isClosest ? 0.55 : 0.35,
),
blurRadius: isClosest ? 10 : 6,
),
]
: null,
),
),
),
),
);
}
/// True if this input port is the nearest type-compatible
/// drop target to the current draft cursor (within snap
/// distance). Used to paint the highlight halo so the
/// operator sees which port will accept the connection —
/// AND only sees compatible ones (a text source dragged
/// at a json input won't light up).
bool _isClosestDropTarget(Offset portCenter) {
final draft = _draft;
if (draft == null) return false;
final graph = widget.controller.graph;
final layout = widget.controller.layout;
final sourceType = _typeAtDraftSource();
const maxDist = 32.0;
double bestDist = double.infinity;
Offset? best;
for (final step in graph.steps) {
final labels = _inputLabelsForStep(step);
for (var i = 0; i < labels.length; i++) {
if (!_typesCompatible(sourceType, _stepInputType(step, labels[i]))) {
continue;
}
final p = _inputPortPosition(step.id, i, layout);
final d = (draft.cursor - p).distance;
if (d < bestDist && d <= maxDist) {
bestDist = d;
best = p;
}
}
}
// The outputs endpoint carries no declared type of its
// own — it inherits whatever the source feeds it. Always
// a valid drop target as long as proximity matches.
final outs = graph.outputs.keys.toList();
for (var i = 0; i < outs.length; i++) {
final p = _inputPortPosition(AutoLayout.outputsNodeId, i, layout);
final d = (draft.cursor - p).distance;
if (d < bestDist && d <= maxDist) {
bestDist = d;
best = p;
}
}
if (best == null) return false;
return (best - portCenter).distance < 0.5;
}
void _finalizeDraft() {
final draft = _draft;
setState(() => _draft = null);
if (draft == null) return;
// Find the closest type-compatible input port within
// tolerance. Incompatible candidates are filtered out so
// a snapped drop never creates a mismatched wire.
final graph = widget.controller.graph;
final layout = widget.controller.layout;
final sourceType = (() {
switch (draft.fromKind) {
case _DraftSourceKind.inputsField:
return graph.inputs[draft.fromId]?.type;
case _DraftSourceKind.step:
if (draft.fromField.isEmpty) return null;
final step = graph.steps.firstWhere(
(s) => s.id == draft.fromId,
orElse: () => const FlowStep(id: '__missing__', use: ''),
);
if (step.id == '__missing__') return null;
return _stepOutputType(step, draft.fromField);
}
})();
_DropTarget? best;
double bestDist = double.infinity;
const maxDist = 32.0;
for (final step in graph.steps) {
final labels = _inputLabelsForStep(step);
for (var i = 0; i < labels.length; i++) {
if (!_typesCompatible(sourceType, _stepInputType(step, labels[i]))) {
continue;
}
final p = _inputPortPosition(step.id, i, layout);
final d = (draft.cursor - p).distance;
if (d < bestDist && d <= maxDist) {
bestDist = d;
best = _DropTarget(
kind: _DraftTargetKind.step,
id: step.id,
field: labels[i],
);
}
}
}
final outs = graph.outputs.keys.toList();
for (var i = 0; i < outs.length; i++) {
final p = _inputPortPosition(AutoLayout.outputsNodeId, i, layout);
final d = (draft.cursor - p).distance;
if (d < bestDist && d <= maxDist) {
bestDist = d;
best = _DropTarget(
kind: _DraftTargetKind.outputsField,
id: AutoLayout.outputsNodeId,
field: outs[i],
);
}
}
if (best == null) return;
_applyConnection(draft, best);
}
void _applyConnection(_ConnectionDraft draft, _DropTarget target) {
final graph = widget.controller.graph;
// Compose the $source.field expression. Per-field output
// ports stamp the precise field name into the draft; if
// the operator dragged from the legacy single anchor
// (no field), we fall back to "result" as the placeholder
// and let them rename in the properties panel.
final String expression;
switch (draft.fromKind) {
case _DraftSourceKind.step:
final field = draft.fromField.isNotEmpty ? draft.fromField : 'result';
expression = '\$${draft.fromId}.$field';
case _DraftSourceKind.inputsField:
expression = '\$inputs.${draft.fromId}';
}
switch (target.kind) {
case _DraftTargetKind.step:
final step = graph.steps.firstWhere((s) => s.id == target.id);
final newWith = {...step.with_, target.field: expression};
widget.controller.applyGraphEdit(
graph.withStepUpdated(target.id, step.copyWith(with_: newWith)),
);
case _DraftTargetKind.outputsField:
widget.controller.applyGraphEdit(
FlowGraph(
name: graph.name,
inputs: graph.inputs,
steps: graph.steps,
outputs: {...graph.outputs, target.field: expression},
leadingComment: graph.leadingComment,
),
);
}
}
// --- Context menu actions ---
/// Right-click menu on a step node. Duplicate creates a
/// sibling with a fresh id, same use + with-fields, placed
/// slightly offset so the operator sees both. Delete drops
/// the step entirely; any dangling refs in downstream
/// steps become run-time errors with clear messages, which
/// is by design (silently rewriting downstream YAML would
/// be more surprising than the error).
Future<void> _showStepContextMenu(FlowStep step, Offset globalPos) async {
final theme = Theme.of(context);
final overlay =
Overlay.of(context).context.findRenderObject() as RenderBox?;
if (overlay == null) return;
final result = await showMenu<_StepAction>(
context: context,
position: RelativeRect.fromRect(
Rect.fromPoints(globalPos, globalPos),
Offset.zero & overlay.size,
),
items: [
const PopupMenuItem(
value: _StepAction.duplicate,
child: Row(
children: [
Icon(Icons.copy_outlined, size: 16),
SizedBox(width: 8),
Text('Duplicate'),
],
),
),
const PopupMenuItem(
value: _StepAction.disconnectAll,
child: Row(
children: [
Icon(Icons.link_off, size: 16),
SizedBox(width: 8),
Text('Disconnect all inputs'),
],
),
),
PopupMenuItem(
value: _StepAction.delete,
child: Row(
children: [
Icon(
Icons.delete_outline,
size: 16,
color: theme.colorScheme.error,
),
const SizedBox(width: 8),
Text('Delete', style: TextStyle(color: theme.colorScheme.error)),
],
),
),
],
);
if (!mounted || result == null) return;
switch (result) {
case _StepAction.duplicate:
_duplicateStep(step);
case _StepAction.disconnectAll:
_disconnectAll(step);
case _StepAction.delete:
_deleteStep(step);
}
}
void _duplicateStep(FlowStep step) {
final graph = widget.controller.graph;
final layout = widget.controller.layout;
// Generate a unique id: <base>, <base>_2, <base>_3, ...
final existingIds = graph.steps.map((s) => s.id).toSet();
var i = 2;
var newId = '${step.id}_$i';
while (existingIds.contains(newId)) {
i++;
newId = '${step.id}_$i';
}
widget.controller.applyGraphEdit(
graph.withStepAdded(
FlowStep(
id: newId,
use: step.use,
with_: Map<String, dynamic>.from(step.with_),
),
),
);
// Offset the new node's position so it's visible next to
// the original instead of stacking on top.
final originalPos = layout.positions[step.id];
if (originalPos != null) {
widget.controller.moveStep(
newId,
NodePosition(originalPos.x + 40, originalPos.y + 40),
);
}
}
void _disconnectAll(FlowStep step) {
if (step.with_.isEmpty) return;
// Keep the with-field keys; just clear their values so
// the parameter list survives but no longer wires to any
// upstream step.
final cleared = {for (final k in step.with_.keys) k: ''};
widget.controller.applyGraphEdit(
widget.controller.graph.withStepUpdated(
step.id,
step.copyWith(with_: cleared),
),
);
}
void _deleteStep(FlowStep step) {
widget.controller.applyGraphEdit(
widget.controller.graph.withStepRemoved(step.id),
);
if (widget.controller.selectedStepId == step.id) {
widget.controller.selectStep(null);
}
}
void _resetLayout() {
widget.controller.resetLayout();
// Re-fit once layout settles so the operator sees the
// cleaned-up positions immediately.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _fitToContent();
});
}
// --- Background ---
Widget _grid(ThemeData theme) {
if (_pattern == _CanvasPattern.blank) {
return const SizedBox.shrink();
}
return Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _PatternPainter(
color: theme.dividerColor.withValues(alpha: 0.55),
pattern: _pattern,
),
),
),
);
}
}
enum _StepAction { duplicate, disconnectAll, delete }
enum _PortAction { disconnect }
enum _EdgeAction { disconnect }
enum _DraftSourceKind { step, inputsField }
enum _DraftTargetKind { step, outputsField }
class _ConnectionDraft {
final _DraftSourceKind fromKind;
final String fromId;
/// Field name on the source step's output side. Empty for
/// the legacy single-anchor case or when the source is the
/// inputs endpoint (whose field is encoded in `fromId`).
/// When set, the new edge persists its `fromField` so the
/// per-field-output port stays addressable.
final String fromField;
final Offset from;
final Offset cursor;
const _ConnectionDraft({
required this.fromKind,
required this.fromId,
this.fromField = '',
required this.from,
required this.cursor,
});
_ConnectionDraft withCursor(Offset c) => _ConnectionDraft(
fromKind: fromKind,
fromId: fromId,
fromField: fromField,
from: from,
cursor: c,
);
}
class _DropTarget {
final _DraftTargetKind kind;
final String id;
final String field;
const _DropTarget({
required this.kind,
required this.id,
required this.field,
});
}
FlowNodeStatus _toNodeStatus(StepRunStatus s) {
return switch (s) {
StepRunStatus.idle => FlowNodeStatus.idle,
StepRunStatus.running => FlowNodeStatus.running,
StepRunStatus.done => FlowNodeStatus.done,
StepRunStatus.failed => FlowNodeStatus.failed,
StepRunStatus.awaiting => FlowNodeStatus.awaiting,
};
}
/// Background patterns the canvas can render. Ported from
/// jai_client's CanvasPatternPainter so operators that lived
/// in that editor find their preferred raster back here.
enum _CanvasPattern { dots, grid, blank, modern, classic, blueprint, minimal }
class _PatternPainter extends CustomPainter {
final Color color;
final _CanvasPattern pattern;
_PatternPainter({required this.color, required this.pattern});
static const double _spacing = 24.0;
static const int _lineSkip = 3;
@override
void paint(Canvas canvas, Size size) {
final dotPaint = Paint()..color = color;
final primaryLine = Paint()
..color = color.withValues(alpha: 0.6)
..strokeWidth = 0.5;
final secondaryLine = Paint()
..color = color.withValues(alpha: 0.25)
..strokeWidth = 0.3;
switch (pattern) {
case _CanvasPattern.dots:
for (double x = 0; x < size.width; x += _spacing) {
for (double y = 0; y < size.height; y += _spacing) {
canvas.drawCircle(Offset(x, y), 0.8, dotPaint);
}
}
case _CanvasPattern.grid:
final linePaint = Paint()
..color = color.withValues(alpha: 0.5)
..strokeWidth = 0.6;
for (double x = 0; x < size.width; x += _spacing) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), linePaint);
}
for (double y = 0; y < size.height; y += _spacing) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), linePaint);
}
case _CanvasPattern.blank:
// No painting — the surface colour shows through.
break;
case _CanvasPattern.modern:
// Sparse grid: only every Nth line, plus emphasised
// every 5N. Dots at major intersections.
final lineCount = (size.height / _spacing).ceil() + 1;
final colCount = (size.width / _spacing).ceil() + 1;
for (int i = 0; i < lineCount; i++) {
if (i % _lineSkip != 0 && i % (_lineSkip * 5) != 0) continue;
final y = i * _spacing;
final p = i % (_lineSkip * 5) == 0 ? primaryLine : secondaryLine;
canvas.drawLine(Offset(0, y), Offset(size.width, y), p);
}
for (int i = 0; i < colCount; i++) {
if (i % _lineSkip != 0 && i % (_lineSkip * 5) != 0) continue;
final x = i * _spacing;
final p = i % (_lineSkip * 5) == 0 ? primaryLine : secondaryLine;
canvas.drawLine(Offset(x, 0), Offset(x, size.height), p);
}
for (int i = 0; i < lineCount; i += _lineSkip * 5) {
for (int j = 0; j < colCount; j += _lineSkip * 5) {
canvas.drawCircle(
Offset(j * _spacing, i * _spacing),
0.7,
dotPaint,
);
}
}
case _CanvasPattern.classic:
// Dense grid: every line drawn; every 2Nth emphasised.
final lineCount = (size.height / _spacing).ceil() + 1;
final colCount = (size.width / _spacing).ceil() + 1;
for (int i = 0; i < lineCount; i++) {
final y = i * _spacing;
final p = i % (_lineSkip * 2) == 0 ? primaryLine : secondaryLine;
canvas.drawLine(Offset(0, y), Offset(size.width, y), p);
}
for (int i = 0; i < colCount; i++) {
final x = i * _spacing;
final p = i % (_lineSkip * 2) == 0 ? primaryLine : secondaryLine;
canvas.drawLine(Offset(x, 0), Offset(x, size.height), p);
}
for (int i = 0; i < lineCount; i += _lineSkip * 2) {
for (int j = 0; j < colCount; j += _lineSkip * 2) {
canvas.drawCircle(
Offset(j * _spacing, i * _spacing),
0.9,
dotPaint,
);
}
}
case _CanvasPattern.blueprint:
// Engineering-paper look: every line drawn, every Nth
// emphasised, dots at major intersections.
final lineCount = (size.height / _spacing).ceil() + 1;
final colCount = (size.width / _spacing).ceil() + 1;
for (int i = 0; i < lineCount; i++) {
final y = i * _spacing;
final p = i % _lineSkip == 0 ? primaryLine : secondaryLine;
canvas.drawLine(Offset(0, y), Offset(size.width, y), p);
}
for (int i = 0; i < colCount; i++) {
final x = i * _spacing;
final p = i % _lineSkip == 0 ? primaryLine : secondaryLine;
canvas.drawLine(Offset(x, 0), Offset(x, size.height), p);
}
for (int i = 0; i < lineCount; i += _lineSkip) {
for (int j = 0; j < colCount; j += _lineSkip) {
canvas.drawCircle(
Offset(j * _spacing, i * _spacing),
1.0,
dotPaint,
);
}
}
case _CanvasPattern.minimal:
// Almost nothing: every 2Nth line only, tiny dots.
final lineCount = (size.height / _spacing).ceil() + 1;
final colCount = (size.width / _spacing).ceil() + 1;
for (int i = 0; i < lineCount; i++) {
if (i % (_lineSkip * 2) != 0) continue;
final y = i * _spacing;
canvas.drawLine(Offset(0, y), Offset(size.width, y), primaryLine);
}
for (int i = 0; i < colCount; i++) {
if (i % (_lineSkip * 2) != 0) continue;
final x = i * _spacing;
canvas.drawLine(Offset(x, 0), Offset(x, size.height), primaryLine);
}
for (int i = 0; i < lineCount; i += _lineSkip * 2) {
for (int j = 0; j < colCount; j += _lineSkip * 2) {
canvas.drawCircle(
Offset(j * _spacing, i * _spacing),
0.5,
dotPaint,
);
}
}
}
}
@override
bool shouldRepaint(_PatternPainter old) =>
old.color != color || old.pattern != pattern;
}