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:
parent
5c59f3a554
commit
5ff7a1c118
3 changed files with 347 additions and 15 deletions
|
|
@ -80,20 +80,46 @@ class _FlowCanvasState extends State<FlowCanvas> {
|
|||
// 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<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
|
||||
// represent OUTPUTS of the node (data flows
|
||||
// OUT to downstream steps), so the port
|
||||
|
|
@ -229,12 +306,11 @@ class _FlowCanvasState extends State<FlowCanvas> {
|
|||
),
|
||||
),
|
||||
// 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<FlowCanvas> {
|
|||
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<FlowCanvas> {
|
|||
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<FlowCanvas> {
|
|||
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<FlowCanvas> {
|
|||
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) ---
|
||||
|
||||
Iterable<Widget> _portOverlays(FlowGraph graph, FlowLayout layout) sync* {
|
||||
|
|
@ -800,6 +1096,10 @@ class _FlowCanvasState extends State<FlowCanvas> {
|
|||
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<FlowCanvas> {
|
|||
// --- 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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue