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>
2403 lines
89 KiB
Dart
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;
|
|
}
|