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
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue