From 5ff7a1c11829cfb8e6ed38766dba5611d6a9128e Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 1 Jun 2026 17:36:24 +0200 Subject: [PATCH] feat(editor): edge hover + edge menu + pattern picker + zoom indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes four operator complaints from the 0.5.3 review. 1. Edges now react to hover. Added an interaction layer between the edge painter and the endpoint nodes. A MouseRegion tracks the cursor in canvas coords; every move runs a spatial hit-test against each edge's bezier sampled at 24 points. The closest edge within 8 px highlights in the primary accent so the operator sees what they're aiming at. Cursor leaves canvas → highlight clears. 2. Right-click / long-press on edges opens a Disconnect menu. Same interaction layer carries `onSecondaryTapDown` and `onLongPressStart` handlers. Both run the same hit-test and pop the menu at the cursor. Disconnect clears the target's expression (step.with[field] = '' or outputs[name] = ''), edge disappears, target port flips outlined. 3. Long-press is now available alongside right-click EVERYWHERE the context menu lives — step nodes, port dots, edges. Trackpad-only users whose "two-finger click" isn't bound to secondary get the same affordances as mouse users. 4. Background pattern is now operator-selectable: cycles through dots → grid → blank via a small icon button on the bottom-right canvas controls. The button's icon and tooltip reflect the next state. Picked dots as the default because they're least visually noisy at scale. 5. Zoom indicator: a mono "100%" readout next to Fit-to- screen shows the current InteractiveViewer scale, updating live as the operator pinches / scrolls. Tap to snap back to 100 % without losing pan position. The bottom-right control cluster is now: [pattern] [reset layout] [fit to screen] [zoom%] Version 0.5.3 -> 0.6.0 (minor bump — new interaction surfaces + visible toolbar additions). Signed-off-by: flemming-it --- lib/src/widgets/flow_canvas.dart | 354 +++++++++++++++++++++++++++++-- lib/src/widgets/flow_node.dart | 6 + pubspec.yaml | 2 +- 3 files changed, 347 insertions(+), 15 deletions(-) diff --git a/lib/src/widgets/flow_canvas.dart b/lib/src/widgets/flow_canvas.dart index 719c288..e749025 100644 --- a/lib/src/widgets/flow_canvas.dart +++ b/lib/src/widgets/flow_canvas.dart @@ -80,20 +80,46 @@ class _FlowCanvasState extends State { // 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; + // 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); } @override void dispose() { widget.controller.removeListener(_onControllerChanged); + _transform.removeListener(_onTransformChanged); _transform.dispose(); super.dispose(); } + void _onTransformChanged() { + final z = _transform.value.getMaxScaleOnAxis(); + if ((z - _zoom).abs() > 0.005) { + setState(() => _zoom = z); + } + } + void _onControllerChanged() { if (mounted) setState(() {}); } @@ -159,6 +185,57 @@ class _FlowCanvasState extends State { ), ), ), + // 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, + 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 @@ -229,12 +306,11 @@ class _FlowCanvasState extends State { ), ), // 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). + // 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, @@ -245,6 +321,12 @@ class _FlowCanvasState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ + IconButton( + onPressed: _cyclePattern, + icon: Icon(_patternIcon(), size: 18), + tooltip: 'Background: ${_patternLabel()}', + visualDensity: VisualDensity.compact, + ), IconButton( onPressed: _resetLayout, icon: const Icon(Icons.dashboard_outlined, size: 18), @@ -257,6 +339,29 @@ class _FlowCanvasState extends State { tooltip: 'Fit to screen', visualDensity: VisualDensity.compact, ), + // Zoom % readout. Tappable to reset zoom + // back to 100 % without losing pan. + Padding( + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.xs, + ), + child: GestureDetector( + onTap: _resetZoom, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.sm, + vertical: 4, + ), + child: Text( + '${(_zoom * 100).round()}%', + style: theme.textTheme.labelSmall?.copyWith( + fontFamily: 'monospace', + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), ], ), ), @@ -506,7 +611,9 @@ class _FlowCanvasState extends State { 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; out.add( @@ -522,6 +629,195 @@ class _FlowCanvasState extends State { return out; } + // --- Edge hit-testing --- + + /// Hit-test the cursor's canvas position against every + /// edge in the graph. Returns the target-key + /// (`:`) 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); + } + 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); + } 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 _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 = []; + 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 (`:`); we + /// split it back into the parts the action needs. + Future _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 --- + + void _cyclePattern() { + setState(() { + _pattern = switch (_pattern) { + _CanvasPattern.dots => _CanvasPattern.grid, + _CanvasPattern.grid => _CanvasPattern.blank, + _CanvasPattern.blank => _CanvasPattern.dots, + }; + }); + } + + IconData _patternIcon() { + return switch (_pattern) { + _CanvasPattern.dots => Icons.grain, + _CanvasPattern.grid => Icons.grid_on, + _CanvasPattern.blank => Icons.layers_clear, + }; + } + + String _patternLabel() { + return switch (_pattern) { + _CanvasPattern.dots => 'dots', + _CanvasPattern.grid => 'grid', + _CanvasPattern.blank => 'blank', + }; + } + + void _resetZoom() { + // Preserve the current translation, drop the scale back + // to 1×. Operators who panned away keep their position; + // just the zoom resets. + final tx = _transform.value.getTranslation(); + _transform.value = Matrix4.identity()..translateByDouble(tx.x, tx.y, 0, 1); + } + // --- Port overlays (drag handles for creating edges) --- Iterable _portOverlays(FlowGraph graph, FlowLayout layout) sync* { @@ -800,6 +1096,10 @@ class _FlowCanvasState extends State { onSecondaryTapDown: onContextMenu == null ? null : (details) => onContextMenu(details.globalPosition), + // Long-press fallback for trackpad-only users. + onLongPressStart: onContextMenu == null + ? null + : (details) => onContextMenu(details.globalPosition), child: Stack( alignment: Alignment.center, children: [ @@ -1096,11 +1396,15 @@ class _FlowCanvasState extends State { // --- Background --- Widget _grid(ThemeData theme) { + if (_pattern == _CanvasPattern.blank) { + return const SizedBox.shrink(); + } return Positioned.fill( child: IgnorePointer( child: CustomPaint( - painter: _DotGridPainter( + painter: _PatternPainter( color: theme.dividerColor.withValues(alpha: 0.55), + pattern: _pattern, ), ), ), @@ -1112,6 +1416,8 @@ enum _StepAction { duplicate, disconnectAll, delete } enum _PortAction { disconnect } +enum _EdgeAction { disconnect } + enum _DraftSourceKind { step, inputsField } enum _DraftTargetKind { step, outputsField } @@ -1156,21 +1462,41 @@ FlowNodeStatus _toNodeStatus(StepRunStatus s) { }; } -class _DotGridPainter extends CustomPainter { +enum _CanvasPattern { dots, grid, blank } + +class _PatternPainter extends CustomPainter { final Color color; - _DotGridPainter({required this.color}); + final _CanvasPattern pattern; + _PatternPainter({required this.color, required this.pattern}); @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); - } + 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, paint); + } + } + 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; } } @override - bool shouldRepaint(_DotGridPainter old) => old.color != color; + bool shouldRepaint(_PatternPainter old) => + old.color != color || old.pattern != pattern; } diff --git a/lib/src/widgets/flow_node.dart b/lib/src/widgets/flow_node.dart index c437591..ce0cf13 100644 --- a/lib/src/widgets/flow_node.dart +++ b/lib/src/widgets/flow_node.dart @@ -169,6 +169,12 @@ class FlowNode extends StatelessWidget { onSecondaryTapDown: onContextMenu == null ? null : (details) => onContextMenu!(details.globalPosition), + // Long-press is the trackpad fallback for the context + // menu — macOS trackpad users whose "two-finger click" + // isn't mapped to secondary still get the same menu. + onLongPressStart: onContextMenu == null + ? null + : (details) => onContextMenu!(details.globalPosition), child: Material( color: theme.colorScheme.surfaceContainerHigh, elevation: selected ? 6 : 2, diff --git a/pubspec.yaml b/pubspec.yaml index 5a5a5e7..27f409a 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.5.3 +version: 0.6.0 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor