diff --git a/lib/src/editor_controller.dart b/lib/src/editor_controller.dart index afc74ec..de180a0 100644 --- a/lib/src/editor_controller.dart +++ b/lib/src/editor_controller.dart @@ -167,6 +167,18 @@ class FlowEditorController extends ChangeNotifier { notifyListeners(); } + /// Wipe every position in the sidecar and re-run auto- + /// layout from scratch. Useful when manual drags have + /// drifted into spaghetti and the operator wants a clean + /// topological column layout again. The YAML is untouched. + void resetLayout() { + _layout = AutoLayout.layout(_graph, FlowLayout.empty()); + if (_activeName != null) { + LayoutStore.saveSync(_activeName!, _layout); + } + notifyListeners(); + } + /// Apply a graph-side edit (rename step, change `use`, /// adjust `with`, add/remove step, add edge). Emits fresh /// YAML into the text controller, then bumps the layout to diff --git a/lib/src/widgets/flow_canvas.dart b/lib/src/widgets/flow_canvas.dart index 0237184..8a5321a 100644 --- a/lib/src/widgets/flow_canvas.dart +++ b/lib/src/widgets/flow_canvas.dart @@ -202,10 +202,13 @@ class _FlowCanvasState extends State { ), ), ), - // Floating "Fit to screen" button — recenters the - // canvas so every node is in view at once. Pinned - // to the bottom-right of the viewport rather than - // the canvas surface so it doesn't drift with pan. + // Floating canvas controls — pinned to the viewport + // bottom-right so they don't drift with pan. Fit re- + // centres on every node; Reset layout wipes the + // sidecar so AutoLayout repositions from scratch + // (useful when a flow's manual layout has drifted + // into spaghetti and the operator wants a clean + // starting point). Positioned( right: FaiSpace.md, bottom: FaiSpace.md, @@ -213,11 +216,22 @@ class _FlowCanvasState extends State { color: theme.colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(FaiRadius.sm), elevation: 2, - child: IconButton( - onPressed: _fitToContent, - icon: const Icon(Icons.fit_screen_outlined, size: 18), - tooltip: 'Fit to screen', - visualDensity: VisualDensity.compact, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + 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, + ), + ], ), ), ), @@ -312,6 +326,7 @@ class _FlowCanvasState extends State { delta, NodeGeometry.heightFor(step.with_.length), ), + onContextMenu: (globalPos) => _showStepContextMenu(step, globalPos), ), ); } @@ -707,6 +722,137 @@ class _FlowCanvasState extends State { } } + // --- 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 _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: , _2, _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.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) { @@ -722,6 +868,8 @@ class _FlowCanvasState extends State { } } +enum _StepAction { duplicate, disconnectAll, delete } + enum _DraftSourceKind { step, inputsField } enum _DraftTargetKind { step, outputsField } diff --git a/lib/src/widgets/flow_node.dart b/lib/src/widgets/flow_node.dart index e7c9c7a..111dace 100644 --- a/lib/src/widgets/flow_node.dart +++ b/lib/src/widgets/flow_node.dart @@ -67,6 +67,11 @@ class FlowNode extends StatelessWidget { final void Function(Offset delta)? onDrag; final VoidCallback? onDragEnd; + /// Right-click handler. Canvas wires this to a popup menu + /// at the cursor position (Duplicate / Delete / Disconnect + /// inputs etc.). `null` = no context menu offered. + final void Function(Offset globalPos)? onContextMenu; + /// Live status from the most recent run, when this node is /// a step. Coloured dot in the header so the operator can /// glance at the canvas and see what's running. @@ -84,6 +89,7 @@ class FlowNode extends StatelessWidget { this.onTap, this.onDrag, this.onDragEnd, + this.onContextMenu, }); @override @@ -99,6 +105,9 @@ class FlowNode extends StatelessWidget { onTap: onTap, onPanUpdate: onDrag == null ? null : (d) => onDrag!(d.delta), onPanEnd: onDragEnd == null ? null : (_) => onDragEnd!(), + onSecondaryTapDown: onContextMenu == null + ? null + : (details) => onContextMenu!(details.globalPosition), child: Material( color: theme.colorScheme.surfaceContainerHigh, elevation: selected ? 6 : 2, diff --git a/pubspec.yaml b/pubspec.yaml index 290c3a5..1420da1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: fai_studio_flow_editor description: Swappable inline YAML editor for F∆I Studio flows. -version: 0.2.2 +version: 0.3.0 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor