feat(editor): edge hover + edge menu + pattern picker + zoom indicator

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 <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-01 17:36:24 +02:00
parent 5c59f3a554
commit 5ff7a1c118
3 changed files with 347 additions and 15 deletions

View file

@ -80,20 +80,46 @@ class _FlowCanvasState extends State<FlowCanvas> {
// String key = same shape used by _connectedPorts so we // String key = same shape used by _connectedPorts so we
// can pass a single hovered-key into the port widget. // can pass a single hovered-key into the port widget.
String? _hoveredPort; 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 @override
void initState() { void initState() {
super.initState(); super.initState();
widget.controller.addListener(_onControllerChanged); 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 @override
void dispose() { void dispose() {
widget.controller.removeListener(_onControllerChanged); widget.controller.removeListener(_onControllerChanged);
_transform.removeListener(_onTransformChanged);
_transform.dispose(); _transform.dispose();
super.dispose(); super.dispose();
} }
void _onTransformChanged() {
final z = _transform.value.getMaxScaleOnAxis();
if ((z - _zoom).abs() > 0.005) {
setState(() => _zoom = z);
}
}
void _onControllerChanged() { void _onControllerChanged() {
if (mounted) setState(() {}); if (mounted) setState(() {});
} }
@ -159,6 +185,57 @@ class _FlowCanvasState extends State<FlowCanvas> {
), ),
), ),
), ),
// 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 // Inputs endpoint its body labels
// represent OUTPUTS of the node (data flows // represent OUTPUTS of the node (data flows
// OUT to downstream steps), so the port // OUT to downstream steps), so the port
@ -229,12 +306,11 @@ class _FlowCanvasState extends State<FlowCanvas> {
), ),
), ),
// Floating canvas controls pinned to the viewport // Floating canvas controls pinned to the viewport
// bottom-right so they don't drift with pan. Fit re- // bottom-right so they don't drift with pan.
// centres on every node; Reset layout wipes the // - Pattern: cycles dots grid blank.
// sidecar so AutoLayout repositions from scratch // - Reset layout: AutoLayout regenerates positions.
// (useful when a flow's manual layout has drifted // - Fit to screen: recentres on every node.
// into spaghetti and the operator wants a clean // - Zoom indicator: current scale as a %.
// starting point).
Positioned( Positioned(
right: FaiSpace.md, right: FaiSpace.md,
bottom: FaiSpace.md, bottom: FaiSpace.md,
@ -245,6 +321,12 @@ class _FlowCanvasState extends State<FlowCanvas> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton(
onPressed: _cyclePattern,
icon: Icon(_patternIcon(), size: 18),
tooltip: 'Background: ${_patternLabel()}',
visualDensity: VisualDensity.compact,
),
IconButton( IconButton(
onPressed: _resetLayout, onPressed: _resetLayout,
icon: const Icon(Icons.dashboard_outlined, size: 18), icon: const Icon(Icons.dashboard_outlined, size: 18),
@ -257,6 +339,29 @@ class _FlowCanvasState extends State<FlowCanvas> {
tooltip: 'Fit to screen', tooltip: 'Fit to screen',
visualDensity: VisualDensity.compact, 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<FlowCanvas> {
if (from == null || to == null || fromSide == null || toSide == null) { if (from == null || to == null || fromSide == null || toSide == null) {
continue; continue;
} }
final edgeKey = '${edge.toId}:${edge.toField}';
final highlight = final highlight =
_hoveredEdge == edgeKey ||
edge.fromId == widget.controller.selectedStepId || edge.fromId == widget.controller.selectedStepId ||
edge.toId == widget.controller.selectedStepId; edge.toId == widget.controller.selectedStepId;
out.add( out.add(
@ -522,6 +629,195 @@ class _FlowCanvasState extends State<FlowCanvas> {
return out; 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);
}
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<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 ---
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) --- // --- Port overlays (drag handles for creating edges) ---
Iterable<Widget> _portOverlays(FlowGraph graph, FlowLayout layout) sync* { Iterable<Widget> _portOverlays(FlowGraph graph, FlowLayout layout) sync* {
@ -800,6 +1096,10 @@ class _FlowCanvasState extends State<FlowCanvas> {
onSecondaryTapDown: onContextMenu == null onSecondaryTapDown: onContextMenu == null
? null ? null
: (details) => onContextMenu(details.globalPosition), : (details) => onContextMenu(details.globalPosition),
// Long-press fallback for trackpad-only users.
onLongPressStart: onContextMenu == null
? null
: (details) => onContextMenu(details.globalPosition),
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
@ -1096,11 +1396,15 @@ class _FlowCanvasState extends State<FlowCanvas> {
// --- Background --- // --- Background ---
Widget _grid(ThemeData theme) { Widget _grid(ThemeData theme) {
if (_pattern == _CanvasPattern.blank) {
return const SizedBox.shrink();
}
return Positioned.fill( return Positioned.fill(
child: IgnorePointer( child: IgnorePointer(
child: CustomPaint( child: CustomPaint(
painter: _DotGridPainter( painter: _PatternPainter(
color: theme.dividerColor.withValues(alpha: 0.55), color: theme.dividerColor.withValues(alpha: 0.55),
pattern: _pattern,
), ),
), ),
), ),
@ -1112,6 +1416,8 @@ enum _StepAction { duplicate, disconnectAll, delete }
enum _PortAction { disconnect } enum _PortAction { disconnect }
enum _EdgeAction { disconnect }
enum _DraftSourceKind { step, inputsField } enum _DraftSourceKind { step, inputsField }
enum _DraftTargetKind { step, outputsField } 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; final Color color;
_DotGridPainter({required this.color}); final _CanvasPattern pattern;
_PatternPainter({required this.color, required this.pattern});
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
const spacing = 24.0; const spacing = 24.0;
final paint = Paint()..color = color; final paint = Paint()..color = color;
for (double x = 0; x < size.width; x += spacing) { switch (pattern) {
for (double y = 0; y < size.height; y += spacing) { case _CanvasPattern.dots:
canvas.drawCircle(Offset(x, y), 0.8, paint); 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 @override
bool shouldRepaint(_DotGridPainter old) => old.color != color; bool shouldRepaint(_PatternPainter old) =>
old.color != color || old.pattern != pattern;
} }

View file

@ -169,6 +169,12 @@ class FlowNode extends StatelessWidget {
onSecondaryTapDown: onContextMenu == null onSecondaryTapDown: onContextMenu == null
? null ? null
: (details) => onContextMenu!(details.globalPosition), : (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( child: Material(
color: theme.colorScheme.surfaceContainerHigh, color: theme.colorScheme.surfaceContainerHigh,
elevation: selected ? 6 : 2, elevation: selected ? 6 : 2,

View file

@ -1,6 +1,6 @@
name: fai_studio_flow_editor name: fai_studio_flow_editor
description: Swappable inline YAML editor for F∆I Studio flows. description: Swappable inline YAML editor for F∆I Studio flows.
version: 0.5.3 version: 0.6.0
publish_to: 'none' publish_to: 'none'
repository: https://git.flemming.ai/fai/studio-flow-editor repository: https://git.flemming.ai/fai/studio-flow-editor