feat(editor): three-tab WYSIWYG editor — graph / text / run
Full rewrite of the editor surface, layered on top of the
FlowGraph foundation. One in-memory flow drives three tabs
that the operator can flip between freely:
- Graph: a drag-and-drop canvas. Nodes are step cards with
port dots on their left (one per `with:` field) and a
combined output port on the right. Pinned inputs and
outputs pseudo-nodes sit at the left and right edges so
every flow has a visually obvious source and sink. Pan +
zoom via InteractiveViewer; drag a node by its body to
reposition it (positions persisted to a sidecar JSON file
under ~/.fai/data/flows/.layout/<name>.json — kept OUT of
the YAML so `fai run` stays byte-stable).
- Text: the existing YAML CodeField with expands:true so
line 1 anchors at the top edge. YAML-aware syntax
highlighting picks up the theme's primary / secondary /
tertiary palette for keys / strings / numbers.
- Run: an inputs form (text fields + file-pick), a Start
button that calls the host's FlowRunDriver, a live step
list driven by the driver's event stream (matches the
`fai run` CLI rendering — ◻ pending, · running, ✔ done +
duration, ✗ failed, ⏸ awaiting approval), and the typed
outputs once the run resolves.
Source of truth = the YAML text. Graph edits emit fresh YAML
into the shared CodeController; text edits re-parse the
graph on a 350 ms debounce. Layout sidecar persists drag
positions only.
New public API (lib/fai_studio_flow_editor.dart):
FlowEditorPage(
initialFlowName: ...,
locale: ...,
runDriver: FlowRunDriver?, // NEW — host bridge
availableCapabilities: List<String>, // NEW — for the
// capability picker
// dialog when adding
// a step
)
The host (Studio) implements FlowRunDriver to bridge the
hub's gRPC SDK into the editor's event vocabulary. The
StepStarted/Completed/Failed/AwaitingApproval events are
shared verbatim with the CLI's run_progress renderer so
both surfaces speak the same visual language.
Files in this commit:
- lib/src/editor_controller.dart — shared state +
debounced reparse loop
- lib/src/run_driver.dart — host bridge
interface + event types
- lib/src/widgets/flow_canvas.dart — pan / zoom / drag /
port-to-port connection drawing
- lib/src/widgets/flow_node.dart — node card primitive
(module / approval / inputs / outputs variants)
- lib/src/widgets/edge_painter.dart — single CustomPainter
for every edge + draft drag line, cubic bezier with
arrow-head caps
- lib/src/widgets/properties_panel.dart — right-side editor
when a step is selected (rename id, change capability, add
/ remove / rename with-fields, delete step)
- lib/src/widgets/capability_picker.dart — searchable list
dialog used by Add-step
- lib/src/widgets/run_tab.dart — inputs form +
live step progress + outputs renderer
- lib/src/flow_editor_page.dart — host scaffolding,
toolbar, file list, three-tab body, keyboard shortcuts
- lib/src/l10n.dart — EN + DE strings for
every new label
- lib/fai_studio_flow_editor.dart — exports the new
public types (FlowRunDriver, FlowRunEvent variants,
FlowOutputValue variants)
flutter analyze: 0 issues. flutter test: 7/7 green.
Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
dbd9a0004f
commit
870cbc29f7
12 changed files with 2769 additions and 408 deletions
611
lib/src/widgets/flow_canvas.dart
Normal file
611
lib/src/widgets/flow_canvas.dart
Normal file
|
|
@ -0,0 +1,611 @@
|
|||
// 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 '../model/flow_graph.dart';
|
||||
import '../model/layout_store.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;
|
||||
// Fixed canvas-coords for the inputs/outputs pseudo-nodes.
|
||||
// Steps auto-layout starts at AutoLayout.originX (=320), so
|
||||
// inputs at x=40 leaves a comfortable gap; outputs slides to
|
||||
// the right of the right-most step on render.
|
||||
const double _inputsX = 40;
|
||||
const double _inputsY = 80;
|
||||
|
||||
class FlowCanvas extends StatefulWidget {
|
||||
final FlowEditorController controller;
|
||||
final Map<String, FlowNodeStatus> stepStatuses;
|
||||
const FlowCanvas({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.stepStatuses = const {},
|
||||
});
|
||||
|
||||
@override
|
||||
State<FlowCanvas> createState() => _FlowCanvasState();
|
||||
}
|
||||
|
||||
class _FlowCanvasState extends State<FlowCanvas> {
|
||||
final TransformationController _transform = TransformationController();
|
||||
|
||||
// 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;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onControllerChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onControllerChanged);
|
||||
_transform.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onControllerChanged() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final graph = widget.controller.graph;
|
||||
final layout = widget.controller.layout;
|
||||
final outputsX = _outputsX(graph, layout);
|
||||
return Container(
|
||||
color: theme.colorScheme.surface,
|
||||
child: 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: CustomPaint(
|
||||
painter: EdgePainter(
|
||||
segments: _buildSegments(graph, layout, outputsX),
|
||||
baseColor: theme.colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.55,
|
||||
),
|
||||
highlightColor: theme.colorScheme.primary,
|
||||
draftColor: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Inputs pseudo-node.
|
||||
Positioned(
|
||||
left: _inputsX,
|
||||
top: _inputsY,
|
||||
child: FlowNode(
|
||||
id: '__inputs__',
|
||||
title: 'inputs',
|
||||
kind: NodeVisualKind.inputs,
|
||||
inputPortLabels: graph.inputs.keys
|
||||
.map((k) => '$k: ${graph.inputs[k]!.type}')
|
||||
.toList(),
|
||||
selected: false,
|
||||
),
|
||||
),
|
||||
// Outputs pseudo-node.
|
||||
Positioned(
|
||||
left: outputsX,
|
||||
top: _inputsY,
|
||||
child: FlowNode(
|
||||
id: '__outputs__',
|
||||
title: 'outputs',
|
||||
kind: NodeVisualKind.outputs,
|
||||
inputPortLabels: graph.outputs.keys.toList(),
|
||||
selected: false,
|
||||
),
|
||||
),
|
||||
// Step nodes — positioned absolutely, drag to
|
||||
// move, click to select.
|
||||
for (final step in graph.steps)
|
||||
_stepPositioned(step, layout, outputsX),
|
||||
// Port hit-targets for connection drawing. A
|
||||
// transparent overlay positioned over each port
|
||||
// — easier to manage than per-port GestureDetectors
|
||||
// inside the node widget because connection drags
|
||||
// need to cross node boundaries (start in one node,
|
||||
// end in another).
|
||||
..._portOverlays(graph, layout, outputsX),
|
||||
if (_draft != null)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: CustomPaint(
|
||||
painter: EdgePainter(
|
||||
segments: [
|
||||
EdgeSegment(
|
||||
from: _draft!.from,
|
||||
to: _draft!.cursor,
|
||||
accent: EdgeAccent.draftDrag,
|
||||
),
|
||||
],
|
||||
baseColor: theme.colorScheme.primary,
|
||||
highlightColor: theme.colorScheme.primary,
|
||||
draftColor: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Step positioning + drag ---
|
||||
|
||||
Widget _stepPositioned(FlowStep step, FlowLayout layout, double _) {
|
||||
final pos = layout.positions[step.id];
|
||||
if (pos == null) return const SizedBox.shrink();
|
||||
final selected = widget.controller.selectedStepId == step.id;
|
||||
final status = widget.stepStatuses[step.id] ?? FlowNodeStatus.idle;
|
||||
return Positioned(
|
||||
left: pos.x,
|
||||
top: pos.y,
|
||||
child: FlowNode(
|
||||
id: step.id,
|
||||
title: step.id,
|
||||
subtitle: step.use,
|
||||
inputPortLabels: step.with_.keys.toList(),
|
||||
kind: kindForStep(step),
|
||||
selected: selected,
|
||||
status: status,
|
||||
onTap: () => widget.controller.selectStep(step.id),
|
||||
onDrag: (delta) {
|
||||
final scale = _transform.value.getMaxScaleOnAxis();
|
||||
final scaledDelta = delta / scale;
|
||||
final newPos = NodePosition(
|
||||
(pos.x + scaledDelta.dx).clamp(
|
||||
0.0,
|
||||
_canvasWidth - NodeGeometry.width,
|
||||
),
|
||||
(pos.y + scaledDelta.dy).clamp(
|
||||
0.0,
|
||||
_canvasHeight - NodeGeometry.heightFor(step.with_.length),
|
||||
),
|
||||
);
|
||||
widget.controller.moveStep(step.id, newPos);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Port positions in canvas coordinates ---
|
||||
|
||||
Offset _outputPortPosition(
|
||||
String nodeId,
|
||||
FlowLayout layout,
|
||||
double outputsX,
|
||||
) {
|
||||
if (nodeId == '__inputs__') {
|
||||
// Doesn't have an "output" in the canvas sense — the
|
||||
// inputs pseudo-node exposes its declared inputs as
|
||||
// ports on its right edge, one per input. The
|
||||
// _outputPortPosition is called only for step nodes.
|
||||
// This branch is unreachable; return safely.
|
||||
return const Offset(0, 0);
|
||||
}
|
||||
final pos = layout.positions[nodeId];
|
||||
if (pos == null) return const Offset(0, 0);
|
||||
return Offset(
|
||||
pos.x + NodeGeometry.width,
|
||||
pos.y + NodeGeometry.outputPortY(),
|
||||
);
|
||||
}
|
||||
|
||||
Offset _inputPortPosition(
|
||||
String nodeId,
|
||||
int portIndex,
|
||||
FlowLayout layout,
|
||||
double outputsX,
|
||||
) {
|
||||
if (nodeId == '__outputs__') {
|
||||
return Offset(outputsX, _inputsY + NodeGeometry.inputPortY(portIndex));
|
||||
}
|
||||
final pos = layout.positions[nodeId];
|
||||
if (pos == null) return const Offset(0, 0);
|
||||
return Offset(pos.x, pos.y + NodeGeometry.inputPortY(portIndex));
|
||||
}
|
||||
|
||||
// Inputs node exposes one port per declared input on its
|
||||
// RIGHT edge — every input is a "source" of data.
|
||||
Offset _inputsPseudoPortPosition(int portIndex) {
|
||||
return Offset(
|
||||
_inputsX + NodeGeometry.width,
|
||||
_inputsY + NodeGeometry.inputPortY(portIndex),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Edge build (graph -> render segments) ---
|
||||
|
||||
List<EdgeSegment> _buildSegments(
|
||||
FlowGraph graph,
|
||||
FlowLayout layout,
|
||||
double outputsX,
|
||||
) {
|
||||
final out = <EdgeSegment>[];
|
||||
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 = _inputsPseudoPortPosition(idx);
|
||||
} else if (edge.fromKind == EdgeEndpointKind.step) {
|
||||
from = _outputPortPosition(edge.fromId, layout, outputsX);
|
||||
}
|
||||
if (edge.toKind == EdgeEndpointKind.step) {
|
||||
final step = graph.steps.firstWhere(
|
||||
(s) => s.id == edge.toId,
|
||||
orElse: () => const FlowStep(id: '__missing__', use: ''),
|
||||
);
|
||||
final idx = step.with_.keys.toList().indexOf(edge.toField);
|
||||
if (idx >= 0) {
|
||||
to = _inputPortPosition(edge.toId, idx, layout, outputsX);
|
||||
}
|
||||
} else if (edge.toKind == EdgeEndpointKind.outputs) {
|
||||
final outputsList = graph.outputs.keys.toList();
|
||||
final idx = outputsList.indexOf(edge.toField);
|
||||
if (idx >= 0) {
|
||||
to = _inputPortPosition('__outputs__', idx, layout, outputsX);
|
||||
}
|
||||
}
|
||||
if (from == null || to == null) continue;
|
||||
final highlight =
|
||||
edge.fromId == widget.controller.selectedStepId ||
|
||||
edge.toId == widget.controller.selectedStepId;
|
||||
out.add(
|
||||
EdgeSegment(
|
||||
from: from,
|
||||
to: to,
|
||||
accent: highlight ? EdgeAccent.highlight : EdgeAccent.normal,
|
||||
),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Port overlays (drag handles for creating edges) ---
|
||||
|
||||
Iterable<Widget> _portOverlays(
|
||||
FlowGraph graph,
|
||||
FlowLayout layout,
|
||||
double outputsX,
|
||||
) sync* {
|
||||
// Output ports — only on step nodes. Inputs node has its
|
||||
// own input-list port handles below.
|
||||
for (final step in graph.steps) {
|
||||
final p = _outputPortPosition(step.id, layout, outputsX);
|
||||
yield _portDot(
|
||||
center: p,
|
||||
isSource: true,
|
||||
onDragStart: () => _draft = _ConnectionDraft(
|
||||
fromKind: _DraftSourceKind.step,
|
||||
fromId: step.id,
|
||||
from: p,
|
||||
cursor: p,
|
||||
),
|
||||
);
|
||||
}
|
||||
// Inputs pseudo-node output ports (one per input).
|
||||
final inputs = graph.inputs.keys.toList();
|
||||
for (var i = 0; i < inputs.length; i++) {
|
||||
final p = _inputsPseudoPortPosition(i);
|
||||
final fieldName = inputs[i];
|
||||
yield _portDot(
|
||||
center: p,
|
||||
isSource: true,
|
||||
onDragStart: () => _draft = _ConnectionDraft(
|
||||
fromKind: _DraftSourceKind.inputsField,
|
||||
fromId: fieldName,
|
||||
from: p,
|
||||
cursor: p,
|
||||
),
|
||||
);
|
||||
}
|
||||
// Step input port targets.
|
||||
for (final step in graph.steps) {
|
||||
final keys = step.with_.keys.toList();
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
final p = _inputPortPosition(step.id, i, layout, outputsX);
|
||||
yield _portDot(
|
||||
center: p,
|
||||
isSource: false,
|
||||
onDropTarget: _dropTargetFor(step.id, keys[i], _DraftTargetKind.step),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Outputs pseudo-node input ports.
|
||||
final outs = graph.outputs.keys.toList();
|
||||
for (var i = 0; i < outs.length; i++) {
|
||||
final p = _inputPortPosition('__outputs__', i, layout, outputsX);
|
||||
yield _portDot(
|
||||
center: p,
|
||||
isSource: false,
|
||||
onDropTarget: _dropTargetFor(
|
||||
'__outputs__',
|
||||
outs[i],
|
||||
_DraftTargetKind.outputsField,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _portDot({
|
||||
required Offset center,
|
||||
required bool isSource,
|
||||
VoidCallback? onDragStart,
|
||||
void Function(Offset cursor)? onDropTarget,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
const size = 16.0;
|
||||
return Positioned(
|
||||
left: center.dx - size / 2,
|
||||
top: center.dy - size / 2,
|
||||
width: size,
|
||||
height: size,
|
||||
child: MouseRegion(
|
||||
cursor: isSource ? SystemMouseCursors.grab : SystemMouseCursors.cell,
|
||||
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();
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isSource
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
border: Border.all(color: theme.colorScheme.primary, width: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void Function(Offset) _dropTargetFor(
|
||||
String targetId,
|
||||
String targetField,
|
||||
_DraftTargetKind kind,
|
||||
) {
|
||||
return (cursor) {
|
||||
// Snap detection happens in _finalizeDraft via spatial
|
||||
// search; this callback is for future port-level hit
|
||||
// tracking if we add fine-grained drop highlighting.
|
||||
};
|
||||
}
|
||||
|
||||
void _finalizeDraft() {
|
||||
final draft = _draft;
|
||||
setState(() => _draft = null);
|
||||
if (draft == null) return;
|
||||
// Find the closest input port within tolerance.
|
||||
final graph = widget.controller.graph;
|
||||
final layout = widget.controller.layout;
|
||||
final outputsX = _outputsX(graph, layout);
|
||||
_DropTarget? best;
|
||||
double bestDist = double.infinity;
|
||||
const maxDist = 32.0;
|
||||
for (final step in graph.steps) {
|
||||
final keys = step.with_.keys.toList();
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
final p = _inputPortPosition(step.id, i, layout, outputsX);
|
||||
final d = (draft.cursor - p).distance;
|
||||
if (d < bestDist && d <= maxDist) {
|
||||
bestDist = d;
|
||||
best = _DropTarget(
|
||||
kind: _DraftTargetKind.step,
|
||||
id: step.id,
|
||||
field: keys[i],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
final outs = graph.outputs.keys.toList();
|
||||
for (var i = 0; i < outs.length; i++) {
|
||||
final p = _inputPortPosition('__outputs__', i, layout, outputsX);
|
||||
final d = (draft.cursor - p).distance;
|
||||
if (d < bestDist && d <= maxDist) {
|
||||
bestDist = d;
|
||||
best = _DropTarget(
|
||||
kind: _DraftTargetKind.outputsField,
|
||||
id: '__outputs__',
|
||||
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. For step
|
||||
// sources, we don't know the precise output field name
|
||||
// (modules have varied output names); use "result" as a
|
||||
// placeholder so the YAML is syntactically valid, and
|
||||
// let the operator refine it in the properties panel.
|
||||
final String expression;
|
||||
switch (draft.fromKind) {
|
||||
case _DraftSourceKind.step:
|
||||
expression = '\$${draft.fromId}.result';
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Outputs node placement ---
|
||||
|
||||
double _outputsX(FlowGraph graph, FlowLayout layout) {
|
||||
double maxX = _inputsX + NodeGeometry.width + 200;
|
||||
for (final step in graph.steps) {
|
||||
final pos = layout.positions[step.id];
|
||||
if (pos == null) continue;
|
||||
final right = pos.x + NodeGeometry.width;
|
||||
if (right > maxX) maxX = right;
|
||||
}
|
||||
return maxX + 120;
|
||||
}
|
||||
|
||||
// --- Background ---
|
||||
|
||||
Widget _grid(ThemeData theme) {
|
||||
return Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: CustomPaint(
|
||||
painter: _DotGridPainter(
|
||||
color: theme.dividerColor.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _DraftSourceKind { step, inputsField }
|
||||
|
||||
enum _DraftTargetKind { step, outputsField }
|
||||
|
||||
class _ConnectionDraft {
|
||||
final _DraftSourceKind fromKind;
|
||||
final String fromId;
|
||||
final Offset from;
|
||||
final Offset cursor;
|
||||
const _ConnectionDraft({
|
||||
required this.fromKind,
|
||||
required this.fromId,
|
||||
required this.from,
|
||||
required this.cursor,
|
||||
});
|
||||
_ConnectionDraft withCursor(Offset c) => _ConnectionDraft(
|
||||
fromKind: fromKind,
|
||||
fromId: fromId,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
class _DotGridPainter extends CustomPainter {
|
||||
final Color color;
|
||||
_DotGridPainter({required this.color});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
const spacing = 24.0;
|
||||
final paint = Paint()..color = color;
|
||||
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, paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_DotGridPainter old) => old.color != color;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue