This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 5633017df1 Hop creation hardening and unit tests, fixes #7768 (#7808)
5633017df1 is described below

commit 5633017df133baaea50c1d1c774c7e9087f9560a
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Thu Aug 6 20:38:50 2026 +0200

    Hop creation hardening and unit tests, fixes #7768 (#7808)
---
 .../hop/ui/hopgui/file/GraphCanvasTestBase.java    | 351 +++++++++++
 .../HopGuiPipelineGraphHopCreationTest.java        | 628 ++++++++++++++++++++
 .../HopGuiWorkflowGraphHopCreationTest.java        | 642 +++++++++++++++++++++
 .../hopgui/file/pipeline/HopGuiPipelineGraph.java  |  38 +-
 .../hopgui/file/workflow/HopGuiWorkflowGraph.java  |  44 +-
 5 files changed, 1691 insertions(+), 12 deletions(-)

diff --git 
a/rcp/src/test/java/org/apache/hop/ui/hopgui/file/GraphCanvasTestBase.java 
b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/GraphCanvasTestBase.java
new file mode 100644
index 0000000000..de2c783b4d
--- /dev/null
+++ b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/GraphCanvasTestBase.java
@@ -0,0 +1,351 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.file;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+import org.apache.hop.core.gui.AreaOwner;
+import org.apache.hop.core.gui.IUndo;
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.gui.GuiMenuWidgets;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.HopGuiKeyHandler;
+import org.apache.hop.ui.hopgui.perspective.execution.DragViewZoomBase;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.widgets.Canvas;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+
+/**
+ * Drives a pipeline or workflow canvas with real mouse events, so the whole
+ * mouseDown/mouseMove/mouseUp state machine of the graph runs unchanged.
+ *
+ * <p>The canvas is painted by hand rather than addressed through SWTBot's 
widget matchers: the
+ * graph is a single custom-painted {@link Canvas}, so the only handle on a 
transform or action is
+ * the clickable area the painter registers for it.
+ */
+public abstract class GraphCanvasTestBase extends SwtBotTestBase {
+
+  /** Screen distance used to probe the canvas-to-graph coordinate scale. */
+  private static final int SCALE_PROBE_PIXELS = 1000;
+
+  /** How long the helpers wait for a paint, a dialog or a dialog closing. */
+  private static final int POLL_ATTEMPTS = 40;
+
+  private static final int POLL_MILLIS = 50;
+
+  /**
+   * The Hop GUI event loop logs and swallows whatever a mouse handler throws, 
which is why a broken
+   * canvas shows no error dialog. Mirror that, and assert on the collected 
failures instead.
+   */
+  protected final List<Throwable> swallowed = new CopyOnWriteArrayList<>();
+
+  /** Looks up the clickable area a graph registered at a graph coordinate. */
+  @FunctionalInterface
+  protected interface AreaLookup {
+    AreaOwner at(int x, int y);
+  }
+
+  // ---------------------------------------------------------------- mouse 
events
+
+  /**
+   * Dispatches one mouse event on the canvas, converting the graph coordinate 
to a canvas pixel,
+   * and waits for the handler to finish.
+   */
+  protected void fire(
+      Canvas canvas, int type, double scale, Point graphPoint, int button, int 
stateMask) {
+    Event event = mouseEvent(scale, graphPoint, button, stateMask);
+    display.syncExec(() -> dispatch(canvas, type, event));
+  }
+
+  /**
+   * Same as {@link #fire} but without waiting. Use it for an event that may 
open a dialog: a dialog
+   * runs its own event loop and would otherwise block this worker inside the 
event dispatch.
+   */
+  protected void fireAsync(
+      Canvas canvas, int type, double scale, Point graphPoint, int button, int 
stateMask) {
+    Event event = mouseEvent(scale, graphPoint, button, stateMask);
+    display.asyncExec(() -> dispatch(canvas, type, event));
+  }
+
+  private void dispatch(Canvas canvas, int type, Event event) {
+    try {
+      canvas.notifyListeners(type, event);
+    } catch (Throwable t) {
+      swallowed.add(t);
+    }
+  }
+
+  private static Event mouseEvent(double scale, Point graphPoint, int button, 
int stateMask) {
+    Event event = new Event();
+    event.button = button;
+    event.stateMask = stateMask;
+    event.count = 1;
+    event.x = (int) Math.round(graphPoint.x * scale);
+    event.y = (int) Math.round(graphPoint.y * scale);
+    return event;
+  }
+
+  // ---------------------------------------------------------------- the Hop 
GUI
+
+  private static HopGui hopGui;
+
+  /**
+   * The Hop GUI the graphs under test hang off. Menus and toolbars are the 
only thing missing: they
+   * are built when the real application shell opens, which a test never does. 
Call it on the UI
+   * thread.
+   */
+  protected static synchronized HopGui hopGui() {
+    if (hopGui == null) {
+      hopGui = new MenulessHopGui();
+      hopGui.setProps(PropsUi.getInstance());
+    }
+    return hopGui;
+  }
+
+  /**
+   * A Hop GUI whose main menu was never built - it is created when the 
application shell opens,
+   * which a test never does. Without this, refreshing the Edit menu after an 
undo entry throws,
+   * which aborts the very canvas handler under test half way and would be 
read as a defect. The
+   * graphs themselves are the real ones.
+   */
+  private static class MenulessHopGui extends HopGui {
+    private final GuiMenuWidgets emptyMenu = new GuiMenuWidgets();
+
+    @Override
+    public GuiMenuWidgets getMainMenuWidgets() {
+      return emptyMenu;
+    }
+
+    @Override
+    public void setUndoMenu(IUndo undoInterface) {
+      // reads the menu widgets field directly, so it needs its own no-op
+    }
+
+    @Override
+    public void handleFileCapabilities(
+        IHopFileType fileType,
+        IHopFileTypeHandler handler,
+        boolean changed,
+        boolean running,
+        boolean paused) {
+      // reads the menu widgets field directly, so it needs its own no-op
+    }
+  }
+
+  // ---------------------------------------------------------------- keyboard
+
+  /**
+   * Hooks the real Hop GUI key handler up to the test shell. The graph 
registered itself as a
+   * shortcut target in its constructor, but the handler was attached to the 
application shell,
+   * which is not the one holding the canvas here.
+   */
+  protected static void attachKeyboardShortcuts(Shell shell) {
+    hopGui().replaceKeyboardShortcutListeners(shell, 
HopGuiKeyHandler.getInstance());
+  }
+
+  /** Presses a key on the canvas, e.g. {@code SWT.ESC}. */
+  protected void fireKey(Canvas canvas, int keyCode) {
+    Event event = new Event();
+    event.keyCode = keyCode;
+    event.character = (char) keyCode;
+    event.doit = true;
+    display.syncExec(() -> dispatch(canvas, SWT.KeyDown, event));
+  }
+
+  // ---------------------------------------------------------------- 
coordinates
+
+  /**
+   * Canvas pixels per graph unit. Derived through the public {@code 
screen2real} so the tests do
+   * not have to know about zoom factors or high-DPI scaling.
+   */
+  protected double canvasToGraphScale(DragViewZoomBase graph) {
+    Point origin = onUi(() -> graph.screen2real(0, 0));
+    Point far = onUi(() -> graph.screen2real(SCALE_PROBE_PIXELS, 0));
+    return (double) SCALE_PROBE_PIXELS / (far.x - origin.x);
+  }
+
+  protected static Point midpoint(Point from, Point to) {
+    return new Point((from.x + to.x) / 2, (from.y + to.y) / 2);
+  }
+
+  /**
+   * Waits for the first paint - the painter is what registers the clickable 
areas - and returns the
+   * centre of the icon of {@code owner} in graph coordinates.
+   */
+  protected Point awaitIcon(
+      SWTBot bot, AreaLookup lookup, AreaOwner.AreaType iconType, Object 
owner, Point location) {
+    int iconSize = onUi(() -> PropsUi.getInstance().getIconSize());
+    Point centre = new Point(location.x + iconSize / 2, location.y + iconSize 
/ 2);
+    for (int attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
+      AreaOwner areaOwner = onUi(() -> lookup.at(centre.x, centre.y));
+      if (areaOwner != null) {
+        assertEquals(iconType, areaOwner.getAreaType(), "expected to aim at 
the icon");
+        assertSame(owner, areaOwner.getOwner(), "expected to aim at " + owner);
+        return centre;
+      }
+      bot.sleep(POLL_MILLIS);
+    }
+    throw new AssertionError("the canvas never painted the icon of " + owner);
+  }
+
+  /** Guards a test's own assumption that a spot really is empty canvas. */
+  protected void assertEmptyCanvas(AreaLookup lookup, Point... points) {
+    for (Point point : points) {
+      assertTrue(
+          onUi(() -> lookup.at(point.x, point.y)) == null, "expected empty 
canvas at " + point);
+    }
+  }
+
+  // ---------------------------------------------------------------- dialogs
+
+  protected Set<Shell> openShells() {
+    return onUi(() -> new HashSet<>(Arrays.asList(display.getShells())));
+  }
+
+  /** Polls for a shell that was not open before, e.g. a context dialog. */
+  protected Shell awaitNewShell(SWTBot bot, Set<Shell> before) {
+    for (int attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
+      Shell found =
+          onUi(
+              () -> {
+                for (Shell shell : display.getShells()) {
+                  if (!shell.isDisposed() && !before.contains(shell)) {
+                    return shell;
+                  }
+                }
+                return null;
+              });
+      if (found != null) {
+        return found;
+      }
+      bot.sleep(POLL_MILLIS);
+    }
+    return null;
+  }
+
+  /** Closes a dialog that opened, so its event loop hands the UI thread back. 
*/
+  protected void closeShell(SWTBot bot, Shell shell) {
+    if (shell == null) {
+      return;
+    }
+    display.asyncExec(
+        () -> {
+          if (!shell.isDisposed()) {
+            shell.close();
+          }
+        });
+    for (int attempt = 0; attempt < POLL_ATTEMPTS && !onUi(shell::isDisposed); 
attempt++) {
+      bot.sleep(POLL_MILLIS);
+    }
+    display.asyncExec(
+        () -> {
+          if (!shell.isDisposed()) {
+            shell.dispose();
+          }
+        });
+  }
+
+  /** The title of a dialog that opened, or null when nothing opened. */
+  protected String titleOf(Shell shell) {
+    return shell == null ? null : onUi(shell::getText);
+  }
+
+  // ---------------------------------------------------------------- graph 
state
+
+  /**
+   * Asserts that every named field holds its expected idle value, reporting 
all offenders at once.
+   * The graph keeps the state of a gesture in private fields; once the 
gesture is over they have to
+   * be back to their initial value, or the next gesture starts from a 
half-finished one.
+   */
+  protected static void assertGraphState(Object graph, Map<String, Object> 
expected) {
+    List<String> stale = new ArrayList<>();
+    expected.forEach(
+        (name, want) -> {
+          Object actual = privateField(graph, name);
+          if (want == null ? actual != null : !want.equals(actual)) {
+            stale.add(name + " = " + describe(actual) + " (expected " + 
describe(want) + ")");
+          }
+        });
+    assertTrue(stale.isEmpty(), "the canvas kept state from the finished 
gesture: " + stale);
+  }
+
+  private static String describe(Object value) {
+    return value == null ? "null" : value.toString();
+  }
+
+  /** Reads a private field of the graph so a test can assert on the state of 
a gesture. */
+  protected static Object privateField(Object target, String name) {
+    for (Class<?> type = target.getClass(); type != null; type = 
type.getSuperclass()) {
+      try {
+        Field field = type.getDeclaredField(name);
+        field.setAccessible(true);
+        return field.get(target);
+      } catch (NoSuchFieldException e) {
+        // keep walking up the hierarchy
+      } catch (IllegalAccessException e) {
+        throw new AssertionError("Could not read " + type.getSimpleName() + 
"." + name, e);
+      }
+    }
+    throw new AssertionError("No field '" + name + "' on " + 
target.getClass());
+  }
+
+  // ---------------------------------------------------------------- UI thread
+
+  /** Runs {@code supplier} on the UI thread and hands its result back to the 
SWTBot worker. */
+  protected static <T> T onUi(Supplier<T> supplier) {
+    AtomicReference<T> result = new AtomicReference<>();
+    AtomicReference<RuntimeException> failure = new AtomicReference<>();
+    display.syncExec(
+        () -> {
+          try {
+            result.set(supplier.get());
+          } catch (RuntimeException e) {
+            failure.set(e);
+          }
+        });
+    if (failure.get() != null) {
+      throw failure.get();
+    }
+    return result.get();
+  }
+
+  /** Runs {@code runnable} on the UI thread and waits for it. */
+  protected static void onUi(Runnable runnable) {
+    onUi(
+        () -> {
+          runnable.run();
+          return null;
+        });
+  }
+}
diff --git 
a/rcp/src/test/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraphHopCreationTest.java
 
b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraphHopCreationTest.java
new file mode 100644
index 0000000000..ee21a99d9b
--- /dev/null
+++ 
b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraphHopCreationTest.java
@@ -0,0 +1,628 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.file.pipeline;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.gui.AreaOwner;
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.BaseTransformMeta;
+import org.apache.hop.pipeline.transform.ITransform;
+import org.apache.hop.pipeline.transform.ITransformData;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.hopgui.HopGuiEnvironment;
+import org.apache.hop.ui.hopgui.file.GraphCanvasTestBase;
+import 
org.apache.hop.ui.hopgui.file.pipeline.context.HopGuiPipelineTransformContext;
+import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Canvas;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Every way a user can draw a hop on the pipeline canvas, and the ways to 
abandon one. Each test
+ * replays the gesture as real mouse events and then checks three things: the 
hop is there (or is
+ * not), no dialog appeared that should not have, and the canvas is back in 
its idle state so the
+ * next gesture does not start half-finished.
+ *
+ * <p>Covers <a href="https://github.com/apache/hop/issues/7768";>issue 
#7768</a>.
+ */
+@Tag("uitest")
+class HopGuiPipelineGraphHopCreationTest extends GraphCanvasTestBase {
+
+  private static final String SOURCE_TRANSFORM = "Query Delete";
+  private static final String TARGET_TRANSFORM = "Write to log";
+  private static final Point SOURCE_LOCATION = new Point(60, 60);
+  private static final Point TARGET_LOCATION = new Point(280, 60);
+
+  @BeforeAll
+  static void registerGuiPlugins() throws HopException {
+    // A context dialog bails out when the GUI registry holds no actions for 
the context, so
+    // register the GUI plugins to get the same dialogs the user sees.
+    HopGuiEnvironment.init();
+  }
+
+  // ------------------------------------------------------------------ create 
hop from the menu
+
+  @Test
+  void createHopActionThenClickingTheTargetCreatesTheHop() {
+    onCanvas(
+        (bot, graph, pipelineMeta, spots) -> {
+          TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+          TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+
+          clickTransformAndPickCreateHop(graph, spots, source);
+
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.target, 1, 
SWT.NONE);
+          String popup = releaseAndCatchDialog(bot, spots, spots.target, 
SWT.BUTTON1);
+
+          assertAll(
+              () -> assertHopExists(pipelineMeta, source, target),
+              () -> assertNoDialog(popup),
+              () -> assertNoFailures(),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  @Test
+  void createHopActionThenClickingEmptyCanvasCancelsTheHop() {
+    onCanvas(
+        (bot, graph, pipelineMeta, spots) -> {
+          TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+          TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+          Point sourceWas = new Point(source.getLocation());
+
+          clickTransformAndPickCreateHop(graph, spots, source);
+
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.emptyA, 1, 
SWT.NONE);
+          String popup = releaseAndCatchDialog(bot, spots, spots.emptyA, 
SWT.BUTTON1);
+
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0, 
SWT.NONE);
+
+          assertAll(
+              () -> assertNoHop(pipelineMeta, source, target),
+              () -> assertNoDialog(popup),
+              () -> assertNoFailures(),
+              () -> assertDidNotMove(sourceWas, source),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  // ------------------------------------------------------------------ shift 
click, shift click
+
+  @Test
+  void shiftClickingSourceAndTargetCreatesTheHop() {
+    onCanvas(
+        (bot, graph, pipelineMeta, spots) -> {
+          TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+          TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+
+          // SHIFT stays down for the whole gesture.
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1, 
SWT.SHIFT);
+          fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1, 
SWT.SHIFT | SWT.BUTTON1);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0, 
SWT.SHIFT);
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.target, 1, 
SWT.SHIFT);
+          String popup = releaseAndCatchDialog(bot, spots, spots.target, 
SWT.SHIFT | SWT.BUTTON1);
+
+          assertAll(
+              () -> assertHopExists(pipelineMeta, source, target),
+              () -> assertNoDialog(popup),
+              () -> assertNoFailures(),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  // ------------------------------------------------------------------ shift 
click, plain click
+
+  @Test
+  void shiftClickingSourceThenPlainClickingTargetCreatesTheHop() {
+    onCanvas(
+        (bot, graph, pipelineMeta, spots) -> {
+          TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+          TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+
+          // SHIFT is released after starting the hop.
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1, 
SWT.SHIFT);
+          fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1, 
SWT.SHIFT | SWT.BUTTON1);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.target, 1, 
SWT.NONE);
+          String popup = releaseAndCatchDialog(bot, spots, spots.target, 
SWT.BUTTON1);
+
+          assertAll(
+              () -> assertHopExists(pipelineMeta, source, target),
+              () -> assertNoDialog(popup),
+              () -> assertNoFailures(),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  @Test
+  void shiftClickingSourceThenClickingEmptyCanvasCancelsTheHop() {
+    onCanvas(
+        (bot, graph, pipelineMeta, spots) -> {
+          TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+          TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+          Point sourceWas = new Point(source.getLocation());
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1, 
SWT.SHIFT);
+          fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1, 
SWT.SHIFT | SWT.BUTTON1);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.emptyA, 1, 
SWT.NONE);
+          String popup = releaseAndCatchDialog(bot, spots, spots.emptyA, 
SWT.BUTTON1);
+
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0, 
SWT.NONE);
+
+          assertAll(
+              () -> assertNoHop(pipelineMeta, source, target),
+              () -> assertNoDialog(popup),
+              () -> assertNoFailures(),
+              () -> assertDidNotMove(sourceWas, source),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  // ------------------------------------------------------------------ shift 
drag
+
+  @Test
+  void shiftDraggingOntoTheTargetCreatesTheHop() {
+    onCanvas(
+        (bot, graph, pipelineMeta, spots) -> {
+          TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+          TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1, 
SWT.SHIFT);
+          fire(
+              spots.canvas,
+              SWT.MouseMove,
+              spots.scale,
+              midpoint(spots.source, spots.target),
+              0,
+              SWT.SHIFT | SWT.BUTTON1);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0, 
SWT.SHIFT | SWT.BUTTON1);
+          String popup = releaseAndCatchDialog(bot, spots, spots.target, 
SWT.SHIFT | SWT.BUTTON1);
+
+          assertAll(
+              () -> assertHopExists(pipelineMeta, source, target),
+              () -> assertNoDialog(popup),
+              () -> assertNoFailures(),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  /** Issue #7768: the drag is abandoned over empty canvas. */
+  @Test
+  void shiftDraggingOntoEmptyCanvasCancelsTheHop() {
+    onCanvas(
+        (bot, graph, pipelineMeta, spots) -> {
+          TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+          TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+          Point sourceWas = new Point(source.getLocation());
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1, 
SWT.SHIFT);
+          fire(
+              spots.canvas,
+              SWT.MouseMove,
+              spots.scale,
+              midpoint(spots.source, spots.emptyA),
+              0,
+              SWT.SHIFT | SWT.BUTTON1);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0, 
SWT.SHIFT | SWT.BUTTON1);
+          fire(spots.canvas, SWT.MouseUp, spots.scale, spots.emptyA, 1, 
SWT.SHIFT | SWT.BUTTON1);
+
+          Object pendingHop = privateField(graph, "startHopTransform");
+
+          // Click somewhere else and move on, as in the report. Once the 
release above has
+          // cancelled the hop this is an ordinary click on empty canvas, so 
the dialog offering to
+          // create something here is expected - it is dismissed again right 
away.
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.emptyB, 1, 
SWT.NONE);
+          releaseAndCatchDialog(bot, spots, spots.emptyB, SWT.BUTTON1);
+          fire(
+              spots.canvas, SWT.MouseMove, spots.scale, midpoint(spots.emptyB, 
spots.emptyC), 0, 0);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0, 
SWT.NONE);
+
+          assertAll(
+              () ->
+                  assertNull(
+                      pendingHop,
+                      "releasing over empty canvas must cancel the pending hop 
right away"),
+              () -> assertNoHop(pipelineMeta, source, target),
+              () -> assertNoFailures(),
+              () -> assertDidNotMove(sourceWas, source),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  // ------------------------------------------------------------------ middle 
button
+
+  @Test
+  void middleDraggingOntoTheTargetCreatesTheHop() {
+    onCanvas(
+        (bot, graph, pipelineMeta, spots) -> {
+          TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+          TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 2, 
SWT.NONE);
+          fire(
+              spots.canvas,
+              SWT.MouseMove,
+              spots.scale,
+              midpoint(spots.source, spots.target),
+              0,
+              SWT.BUTTON2);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0, 
SWT.BUTTON2);
+          String popup = releaseAndCatchDialog(bot, spots, spots.target, 2, 
SWT.BUTTON2);
+
+          assertAll(
+              () -> assertHopExists(pipelineMeta, source, target),
+              () -> assertNoDialog(popup),
+              () -> assertNoFailures(),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  @Test
+  void middleDraggingOntoEmptyCanvasCancelsTheHop() {
+    onCanvas(
+        (bot, graph, pipelineMeta, spots) -> {
+          TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+          TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+          Point sourceWas = new Point(source.getLocation());
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 2, 
SWT.NONE);
+          fire(
+              spots.canvas,
+              SWT.MouseMove,
+              spots.scale,
+              midpoint(spots.source, spots.emptyA),
+              0,
+              SWT.BUTTON2);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0, 
SWT.BUTTON2);
+          fire(spots.canvas, SWT.MouseUp, spots.scale, spots.emptyA, 2, 
SWT.BUTTON2);
+          Object pendingHop = privateField(graph, "startHopTransform");
+
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0, 
SWT.NONE);
+
+          assertAll(
+              () ->
+                  assertNull(
+                      pendingHop,
+                      "releasing over empty canvas must cancel the pending hop 
right away"),
+              () -> assertNoHop(pipelineMeta, source, target),
+              () -> assertNoFailures(),
+              () -> assertDidNotMove(sourceWas, source),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  @Test
+  void middleClickingSourceThenClickingTargetCreatesTheHop() {
+    onCanvas(
+        (bot, graph, pipelineMeta, spots) -> {
+          TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+          TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 2, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 2, 
SWT.BUTTON2);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.target, 1, 
SWT.NONE);
+          String popup = releaseAndCatchDialog(bot, spots, spots.target, 
SWT.BUTTON1);
+
+          assertAll(
+              () -> assertHopExists(pipelineMeta, source, target),
+              () -> assertNoDialog(popup),
+              () -> assertNoFailures(),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  @Test
+  void middleClickingSourceThenClickingEmptyCanvasCancelsTheHop() {
+    onCanvas(
+        (bot, graph, pipelineMeta, spots) -> {
+          TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+          TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+          Point sourceWas = new Point(source.getLocation());
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 2, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 2, 
SWT.BUTTON2);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.emptyA, 1, 
SWT.NONE);
+          String popup = releaseAndCatchDialog(bot, spots, spots.emptyA, 
SWT.BUTTON1);
+
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0, 
SWT.NONE);
+
+          assertAll(
+              () -> assertNoHop(pipelineMeta, source, target),
+              () -> assertNoDialog(popup),
+              () -> assertNoFailures(),
+              () -> assertDidNotMove(sourceWas, source),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  // ------------------------------------------------------------------ escape 
and right click
+
+  @Test
+  void escapeCancelsThePendingHop() {
+    onCanvas(
+        (bot, graph, pipelineMeta, spots) -> {
+          TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+          TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+          Point sourceWas = new Point(source.getLocation());
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1, 
SWT.SHIFT);
+          fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1, 
SWT.SHIFT | SWT.BUTTON1);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0, 
SWT.NONE);
+          assertNotNull(
+              privateField(graph, "startHopTransform"), "the hop should be 
pending by now");
+
+          fireKey(spots.canvas, SWT.ESC);
+          Object pendingHop = privateField(graph, "startHopTransform");
+
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0, 
SWT.NONE);
+
+          assertAll(
+              () -> assertNull(pendingHop, "escape must cancel the pending 
hop"),
+              () -> assertNoHop(pipelineMeta, source, target),
+              () -> assertNoFailures(),
+              () -> assertDidNotMove(sourceWas, source),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  @Test
+  void rightClickingEmptyCanvasCancelsThePendingHop() {
+    onCanvas(
+        (bot, graph, pipelineMeta, spots) -> {
+          TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+          TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+          Point sourceWas = new Point(source.getLocation());
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1, 
SWT.SHIFT);
+          fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1, 
SWT.SHIFT | SWT.BUTTON1);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0, 
SWT.NONE);
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.emptyA, 3, 
SWT.NONE);
+          String popup = releaseAndCatchDialog(bot, spots, spots.emptyA, 3, 
SWT.BUTTON3);
+
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0, 
SWT.NONE);
+
+          assertAll(
+              () -> assertNoHop(pipelineMeta, source, target),
+              () -> assertNoDialog(popup),
+              () -> assertNoFailures(),
+              () -> assertDidNotMove(sourceWas, source),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  // ------------------------------------------------------------------ 
gesture building blocks
+
+  /**
+   * Clicks the transform and runs the "Create hop" item of the context dialog 
that pops up. Only
+   * the picking of the item is short-circuited - the click that opens the 
dialog is a real one, so
+   * the state it leaves behind is real too.
+   */
+  private void clickTransformAndPickCreateHop(
+      HopGuiPipelineGraph graph, Spots spots, TransformMeta transform) {
+    // Defer the context dialog and cancel it, the way a following click does, 
so the test does
+    // not have to drive the dialog itself.
+    onUi(() -> PropsUi.getInstance().setUseDoubleClickOnCanvas(true));
+    fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1, SWT.NONE);
+    fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1, SWT.BUTTON1);
+    cancelPendingContextDialog(graph);
+    onUi(() -> PropsUi.getInstance().setUseDoubleClickOnCanvas(false));
+
+    onUi(
+        () ->
+            graph.newHopCandidate(
+                new HopGuiPipelineTransformContext(
+                    graph.getPipelineMeta(), transform, graph, new 
Point(spots.source))));
+  }
+
+  private void cancelPendingContextDialog(HopGuiPipelineGraph graph) {
+    Object pending = privateField(graph, "pendingShowActionDialogRunnable");
+    if (pending instanceof Runnable runnable) {
+      onUi(() -> display.timerExec(-1, runnable));
+    }
+  }
+
+  /**
+   * Releases the mouse button and reports the title of the dialog that 
opened, or null when none
+   * did. Any dialog is closed again so the canvas event loop is handed back.
+   */
+  private String releaseAndCatchDialog(SWTBot bot, Spots spots, Point at, int 
stateMask) {
+    return releaseAndCatchDialog(bot, spots, at, 1, stateMask);
+  }
+
+  private String releaseAndCatchDialog(
+      SWTBot bot, Spots spots, Point at, int button, int stateMask) {
+    Set<Shell> before = openShells();
+    fireAsync(spots.canvas, SWT.MouseUp, spots.scale, at, button, stateMask);
+    Shell popup = awaitNewShell(bot, before);
+    String title = titleOf(popup);
+    closeShell(bot, popup);
+    return title;
+  }
+
+  // ------------------------------------------------------------------ 
assertions
+
+  private void assertHopExists(PipelineMeta meta, TransformMeta from, 
TransformMeta to) {
+    assertNotNull(
+        meta.findPipelineHop(from, to),
+        "the gesture should have created the hop " + from + " → " + to);
+  }
+
+  private void assertNoHop(PipelineMeta meta, TransformMeta from, 
TransformMeta to) {
+    assertNull(meta.findPipelineHop(from, to), "the abandoned gesture must not 
create a hop");
+  }
+
+  private void assertNoDialog(String dialogTitle) {
+    assertNull(dialogTitle, "no dialog may open here, but one did");
+  }
+
+  private void assertNoFailures() {
+    assertTrue(swallowed.isEmpty(), "the canvas must not throw, but got " + 
swallowed);
+  }
+
+  private static void assertDidNotMove(Point was, TransformMeta transform) {
+    assertEquals(
+        was.toString(),
+        transform.getLocation().toString(),
+        "the transform followed the mouse pointer");
+  }
+
+  /** Everything the graph remembers about a gesture has to be back to its 
initial value. */
+  private static void assertCanvasIsIdle(HopGuiPipelineGraph graph) {
+    Map<String, Object> idle = new LinkedHashMap<>();
+    idle.put("startHopTransform", null);
+    idle.put("endHopTransform", null);
+    idle.put("endHopLocation", null);
+    idle.put("candidate", null);
+    idle.put("currentTransform", null);
+    idle.put("selectedTransform", null);
+    idle.put("selectedTransforms", null);
+    idle.put("selectionRegion", null);
+    idle.put("splitHop", false);
+    idle.put("dragSelection", false);
+    idle.put("avoidContextDialog", false);
+    idle.put("lastButton", 0);
+    assertGraphState(graph, idle);
+  }
+
+  // ------------------------------------------------------------------ scene
+
+  /** What a test needs to aim at: the canvas, its scale and the interesting 
graph coordinates. */
+  private record Spots(
+      Canvas canvas,
+      double scale,
+      Point source,
+      Point target,
+      Point emptyA,
+      Point emptyB,
+      Point emptyC) {}
+
+  @FunctionalInterface
+  private interface CanvasTest {
+    void run(SWTBot bot, HopGuiPipelineGraph graph, PipelineMeta pipelineMeta, 
Spots spots);
+  }
+
+  private void onCanvas(CanvasTest test) {
+    AtomicReference<HopGuiPipelineGraph> graphRef = new AtomicReference<>();
+    AtomicReference<PipelineMeta> metaRef = new AtomicReference<>();
+
+    withScene(
+        shell -> {
+          shell.setSize(1000, 700);
+          shell.setLayout(new FormLayout());
+          // Keep the single click on the canvas synchronous and deterministic.
+          PropsUi.getInstance().setUseDoubleClickOnCanvas(false);
+
+          PipelineMeta pipelineMeta = buildPipeline();
+          metaRef.set(pipelineMeta);
+          graphRef.set(
+              new HopGuiPipelineGraph(
+                  shell,
+                  hopGui(),
+                  new ExplorerPerspective(),
+                  pipelineMeta,
+                  new HopPipelineFileType<>()));
+          attachKeyboardShortcuts(shell);
+        },
+        bot -> {
+          HopGuiPipelineGraph graph = graphRef.get();
+          PipelineMeta pipelineMeta = metaRef.get();
+          test.run(bot, graph, pipelineMeta, aim(bot, graph, pipelineMeta));
+        });
+  }
+
+  /** Locates both icons and three spots of empty canvas, clear of the icons 
and of the minimap. */
+  private Spots aim(SWTBot bot, HopGuiPipelineGraph graph, PipelineMeta 
pipelineMeta) {
+    Canvas canvas = onUi(graph::getCanvas);
+    double scale = canvasToGraphScale(graph);
+    AreaLookup lookup = graph::getVisibleAreaOwner;
+
+    Point source =
+        awaitIcon(
+            bot,
+            lookup,
+            AreaOwner.AreaType.TRANSFORM_ICON,
+            pipelineMeta.findTransform(SOURCE_TRANSFORM),
+            SOURCE_LOCATION);
+    Point target =
+        awaitIcon(
+            bot,
+            lookup,
+            AreaOwner.AreaType.TRANSFORM_ICON,
+            pipelineMeta.findTransform(TARGET_TRANSFORM),
+            TARGET_LOCATION);
+
+    Rectangle bounds = onUi(canvas::getBounds);
+    Point visible = onUi(() -> graph.screen2real(bounds.width, bounds.height));
+    Point emptyA = new Point((int) (visible.x * 0.50), (int) (visible.y * 
0.45));
+    Point emptyB = new Point((int) (visible.x * 0.30), (int) (visible.y * 
0.60));
+    Point emptyC = new Point((int) (visible.x * 0.20), (int) (visible.y * 
0.72));
+    assertEmptyCanvas(lookup, emptyA, emptyB, emptyC);
+
+    return new Spots(canvas, scale, source, target, emptyA, emptyB, emptyC);
+  }
+
+  /** Two transforms, far enough apart to aim at either icon, with no hop 
between them yet. */
+  private static PipelineMeta buildPipeline() {
+    PipelineMeta pipelineMeta = new PipelineMeta();
+    pipelineMeta.setName("hop-creation");
+
+    TransformMeta source = transform(SOURCE_TRANSFORM, SOURCE_LOCATION);
+    TransformMeta target = transform(TARGET_TRANSFORM, TARGET_LOCATION);
+    pipelineMeta.addTransform(source);
+    pipelineMeta.addTransform(target);
+    return pipelineMeta;
+  }
+
+  private static TransformMeta transform(String name, Point location) {
+    TransformMeta transformMeta =
+        new TransformMeta("Dummy", name, new BaseTransformMeta<ITransform, 
ITransformData>());
+    transformMeta.setLocation(location.x, location.y);
+    return transformMeta;
+  }
+}
diff --git 
a/rcp/src/test/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraphHopCreationTest.java
 
b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraphHopCreationTest.java
new file mode 100644
index 0000000000..ef32ce21f7
--- /dev/null
+++ 
b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraphHopCreationTest.java
@@ -0,0 +1,642 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.file.workflow;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hop.core.Result;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.gui.AreaOwner;
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.hopgui.HopGuiEnvironment;
+import org.apache.hop.ui.hopgui.file.GraphCanvasTestBase;
+import 
org.apache.hop.ui.hopgui.file.workflow.context.HopGuiWorkflowActionContext;
+import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.workflow.action.ActionBase;
+import org.apache.hop.workflow.action.ActionMeta;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.layout.GridLayout;
+import org.eclipse.swt.widgets.Canvas;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Every way a user can draw a hop on the workflow canvas, and the ways to 
abandon one - the
+ * workflow counterpart of the pipeline suite, gesture for gesture.
+ *
+ * <p>Covers <a href="https://github.com/apache/hop/issues/7768";>issue 
#7768</a>.
+ */
+@Tag("uitest")
+class HopGuiWorkflowGraphHopCreationTest extends GraphCanvasTestBase {
+
+  private static final String SOURCE_ACTION = "Query Delete";
+  private static final String TARGET_ACTION = "Write to log";
+  private static final Point SOURCE_LOCATION = new Point(60, 60);
+  private static final Point TARGET_LOCATION = new Point(280, 60);
+
+  @BeforeAll
+  static void registerGuiPlugins() throws HopException {
+    // A context dialog bails out when the GUI registry holds no actions for 
the context, so
+    // register the GUI plugins to get the same dialogs the user sees.
+    HopGuiEnvironment.init();
+  }
+
+  // ------------------------------------------------------------------ create 
hop from the menu
+
+  @Test
+  void createHopActionThenClickingTheTargetCreatesTheHop() {
+    onCanvas(
+        (bot, graph, workflowMeta, spots) -> {
+          ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+          ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+
+          clickActionAndPickCreateHop(graph, spots, source);
+
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.target, 1, 
SWT.NONE);
+          String popup = releaseAndCatchDialog(bot, spots, spots.target, 
SWT.BUTTON1);
+
+          assertAll(
+              () -> assertHopExists(workflowMeta, source, target),
+              () -> assertNoDialog(popup),
+              () -> assertNoFailures(),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  @Test
+  void createHopActionThenClickingEmptyCanvasCancelsTheHop() {
+    onCanvas(
+        (bot, graph, workflowMeta, spots) -> {
+          ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+          ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+          Point sourceWas = new Point(source.getLocation());
+
+          clickActionAndPickCreateHop(graph, spots, source);
+
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.emptyA, 1, 
SWT.NONE);
+          String popup = releaseAndCatchDialog(bot, spots, spots.emptyA, 
SWT.BUTTON1);
+
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0, 
SWT.NONE);
+
+          assertAll(
+              () -> assertNoHop(workflowMeta, source, target),
+              () -> assertNoDialog(popup),
+              () -> assertNoFailures(),
+              () -> assertDidNotMove(sourceWas, source),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  // ------------------------------------------------------------------ shift 
click, shift click
+
+  @Test
+  void shiftClickingSourceAndTargetCreatesTheHop() {
+    onCanvas(
+        (bot, graph, workflowMeta, spots) -> {
+          ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+          ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+
+          // SHIFT stays down for the whole gesture.
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1, 
SWT.SHIFT);
+          fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1, 
SWT.SHIFT | SWT.BUTTON1);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0, 
SWT.SHIFT);
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.target, 1, 
SWT.SHIFT);
+          String popup = releaseAndCatchDialog(bot, spots, spots.target, 
SWT.SHIFT | SWT.BUTTON1);
+
+          assertAll(
+              () -> assertHopExists(workflowMeta, source, target),
+              () -> assertNoDialog(popup),
+              () -> assertNoFailures(),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  // ------------------------------------------------------------------ shift 
click, plain click
+
+  @Test
+  void shiftClickingSourceThenPlainClickingTargetCreatesTheHop() {
+    onCanvas(
+        (bot, graph, workflowMeta, spots) -> {
+          ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+          ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+
+          // SHIFT is released after starting the hop.
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1, 
SWT.SHIFT);
+          fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1, 
SWT.SHIFT | SWT.BUTTON1);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.target, 1, 
SWT.NONE);
+          String popup = releaseAndCatchDialog(bot, spots, spots.target, 
SWT.BUTTON1);
+
+          assertAll(
+              () -> assertHopExists(workflowMeta, source, target),
+              () -> assertNoDialog(popup),
+              () -> assertNoFailures(),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  @Test
+  void shiftClickingSourceThenClickingEmptyCanvasCancelsTheHop() {
+    onCanvas(
+        (bot, graph, workflowMeta, spots) -> {
+          ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+          ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+          Point sourceWas = new Point(source.getLocation());
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1, 
SWT.SHIFT);
+          fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1, 
SWT.SHIFT | SWT.BUTTON1);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.emptyA, 1, 
SWT.NONE);
+          String popup = releaseAndCatchDialog(bot, spots, spots.emptyA, 
SWT.BUTTON1);
+
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0, 
SWT.NONE);
+
+          assertAll(
+              () -> assertNoHop(workflowMeta, source, target),
+              () -> assertNoDialog(popup),
+              () -> assertNoFailures(),
+              () -> assertDidNotMove(sourceWas, source),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  // ------------------------------------------------------------------ shift 
drag
+
+  @Test
+  void shiftDraggingOntoTheTargetCreatesTheHop() {
+    onCanvas(
+        (bot, graph, workflowMeta, spots) -> {
+          ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+          ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1, 
SWT.SHIFT);
+          fire(
+              spots.canvas,
+              SWT.MouseMove,
+              spots.scale,
+              midpoint(spots.source, spots.target),
+              0,
+              SWT.SHIFT | SWT.BUTTON1);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0, 
SWT.SHIFT | SWT.BUTTON1);
+          String popup = releaseAndCatchDialog(bot, spots, spots.target, 
SWT.SHIFT | SWT.BUTTON1);
+
+          assertAll(
+              () -> assertHopExists(workflowMeta, source, target),
+              () -> assertNoDialog(popup),
+              () -> assertNoFailures(),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  /** Issue #7768: the drag is abandoned over empty canvas. */
+  @Test
+  void shiftDraggingOntoEmptyCanvasCancelsTheHop() {
+    onCanvas(
+        (bot, graph, workflowMeta, spots) -> {
+          ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+          ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+          Point sourceWas = new Point(source.getLocation());
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1, 
SWT.SHIFT);
+          fire(
+              spots.canvas,
+              SWT.MouseMove,
+              spots.scale,
+              midpoint(spots.source, spots.emptyA),
+              0,
+              SWT.SHIFT | SWT.BUTTON1);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0, 
SWT.SHIFT | SWT.BUTTON1);
+          fire(spots.canvas, SWT.MouseUp, spots.scale, spots.emptyA, 1, 
SWT.SHIFT | SWT.BUTTON1);
+
+          Object pendingHop = privateField(graph, "startHopAction");
+
+          // Click somewhere else and move on, as in the report. Once the 
release above has
+          // cancelled the hop this is an ordinary click on empty canvas, so 
the dialog offering to
+          // create something here is expected - it is dismissed again right 
away.
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.emptyB, 1, 
SWT.NONE);
+          releaseAndCatchDialog(bot, spots, spots.emptyB, SWT.BUTTON1);
+          fire(
+              spots.canvas,
+              SWT.MouseMove,
+              spots.scale,
+              midpoint(spots.emptyB, spots.emptyC),
+              0,
+              SWT.NONE);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0, 
SWT.NONE);
+
+          assertAll(
+              () ->
+                  assertNull(
+                      pendingHop,
+                      "releasing over empty canvas must cancel the pending hop 
right away"),
+              () -> assertNoHop(workflowMeta, source, target),
+              () -> assertNoFailures(),
+              () -> assertDidNotMove(sourceWas, source),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  // ------------------------------------------------------------------ middle 
button
+
+  @Test
+  void middleDraggingOntoTheTargetCreatesTheHop() {
+    onCanvas(
+        (bot, graph, workflowMeta, spots) -> {
+          ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+          ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 2, 
SWT.NONE);
+          fire(
+              spots.canvas,
+              SWT.MouseMove,
+              spots.scale,
+              midpoint(spots.source, spots.target),
+              0,
+              SWT.BUTTON2);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0, 
SWT.BUTTON2);
+          String popup = releaseAndCatchDialog(bot, spots, spots.target, 2, 
SWT.BUTTON2);
+
+          assertAll(
+              () -> assertHopExists(workflowMeta, source, target),
+              () -> assertNoDialog(popup),
+              () -> assertNoFailures(),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  @Test
+  void middleDraggingOntoEmptyCanvasCancelsTheHop() {
+    onCanvas(
+        (bot, graph, workflowMeta, spots) -> {
+          ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+          ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+          Point sourceWas = new Point(source.getLocation());
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 2, 
SWT.NONE);
+          fire(
+              spots.canvas,
+              SWT.MouseMove,
+              spots.scale,
+              midpoint(spots.source, spots.emptyA),
+              0,
+              SWT.BUTTON2);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0, 
SWT.BUTTON2);
+          fire(spots.canvas, SWT.MouseUp, spots.scale, spots.emptyA, 2, 
SWT.BUTTON2);
+          Object pendingHop = privateField(graph, "startHopAction");
+
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0, 
SWT.NONE);
+
+          assertAll(
+              () ->
+                  assertNull(
+                      pendingHop,
+                      "releasing over empty canvas must cancel the pending hop 
right away"),
+              () -> assertNoHop(workflowMeta, source, target),
+              () -> assertNoFailures(),
+              () -> assertDidNotMove(sourceWas, source),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  @Test
+  void middleClickingSourceThenClickingTargetCreatesTheHop() {
+    onCanvas(
+        (bot, graph, workflowMeta, spots) -> {
+          ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+          ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 2, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 2, 
SWT.BUTTON2);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.target, 1, 
SWT.NONE);
+          String popup = releaseAndCatchDialog(bot, spots, spots.target, 
SWT.BUTTON1);
+
+          assertAll(
+              () -> assertHopExists(workflowMeta, source, target),
+              () -> assertNoDialog(popup),
+              () -> assertNoFailures(),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  @Test
+  void middleClickingSourceThenClickingEmptyCanvasCancelsTheHop() {
+    onCanvas(
+        (bot, graph, workflowMeta, spots) -> {
+          ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+          ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+          Point sourceWas = new Point(source.getLocation());
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 2, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 2, 
SWT.BUTTON2);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.emptyA, 1, 
SWT.NONE);
+          String popup = releaseAndCatchDialog(bot, spots, spots.emptyA, 
SWT.BUTTON1);
+
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0, 
SWT.NONE);
+
+          assertAll(
+              () -> assertNoHop(workflowMeta, source, target),
+              () -> assertNoDialog(popup),
+              () -> assertNoFailures(),
+              () -> assertDidNotMove(sourceWas, source),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  // ------------------------------------------------------------------ escape 
and right click
+
+  @Test
+  void escapeCancelsThePendingHop() {
+    onCanvas(
+        (bot, graph, workflowMeta, spots) -> {
+          ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+          ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+          Point sourceWas = new Point(source.getLocation());
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1, 
SWT.SHIFT);
+          fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1, 
SWT.SHIFT | SWT.BUTTON1);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0, 
SWT.NONE);
+          assertNotNull(privateField(graph, "startHopAction"), "the hop should 
be pending by now");
+
+          fireKey(spots.canvas, SWT.ESC);
+          Object pendingHop = privateField(graph, "startHopAction");
+
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0, 
SWT.NONE);
+
+          assertAll(
+              () -> assertNull(pendingHop, "escape must cancel the pending 
hop"),
+              () -> assertNoHop(workflowMeta, source, target),
+              () -> assertNoFailures(),
+              () -> assertDidNotMove(sourceWas, source),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  @Test
+  void rightClickingEmptyCanvasCancelsThePendingHop() {
+    onCanvas(
+        (bot, graph, workflowMeta, spots) -> {
+          ActionMeta source = workflowMeta.findAction(SOURCE_ACTION);
+          ActionMeta target = workflowMeta.findAction(TARGET_ACTION);
+          Point sourceWas = new Point(source.getLocation());
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1, 
SWT.SHIFT);
+          fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1, 
SWT.SHIFT | SWT.BUTTON1);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyA, 0, 
SWT.NONE);
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.emptyA, 3, 
SWT.NONE);
+          String popup = releaseAndCatchDialog(bot, spots, spots.emptyA, 3, 
SWT.BUTTON3);
+
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyB, 0, 
SWT.NONE);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.emptyC, 0, 
SWT.NONE);
+
+          assertAll(
+              () -> assertNoHop(workflowMeta, source, target),
+              () -> assertNoDialog(popup),
+              () -> assertNoFailures(),
+              () -> assertDidNotMove(sourceWas, source),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  // ------------------------------------------------------------------ 
gesture building blocks
+
+  /**
+   * Clicks the action and runs the "Create hop" item of the context dialog 
that pops up. Only the
+   * picking of the item is short-circuited - the click that opens the dialog 
is a real one, so the
+   * state it leaves behind is real too.
+   */
+  private void clickActionAndPickCreateHop(
+      HopGuiWorkflowGraph graph, Spots spots, ActionMeta action) {
+    // Defer the context dialog and cancel it, the way a following click does, 
so the test does
+    // not have to drive the dialog itself.
+    onUi(() -> PropsUi.getInstance().setUseDoubleClickOnCanvas(true));
+    fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1, SWT.NONE);
+    fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1, SWT.BUTTON1);
+    cancelPendingContextDialog(graph);
+    onUi(() -> PropsUi.getInstance().setUseDoubleClickOnCanvas(false));
+
+    onUi(
+        () ->
+            graph.newHopCandidate(
+                new HopGuiWorkflowActionContext(
+                    graph.getWorkflowMeta(), action, graph, new 
Point(spots.source))));
+  }
+
+  private void cancelPendingContextDialog(HopGuiWorkflowGraph graph) {
+    Object pending = privateField(graph, "pendingShowContextDialogRunnable");
+    if (pending instanceof Runnable runnable) {
+      onUi(() -> display.timerExec(-1, runnable));
+    }
+  }
+
+  /**
+   * Releases the mouse button and reports the title of the dialog that 
opened, or null when none
+   * did. Any dialog is closed again so the canvas event loop is handed back.
+   */
+  private String releaseAndCatchDialog(SWTBot bot, Spots spots, Point at, int 
stateMask) {
+    return releaseAndCatchDialog(bot, spots, at, 1, stateMask);
+  }
+
+  private String releaseAndCatchDialog(
+      SWTBot bot, Spots spots, Point at, int button, int stateMask) {
+    Set<Shell> before = openShells();
+    fireAsync(spots.canvas, SWT.MouseUp, spots.scale, at, button, stateMask);
+    Shell popup = awaitNewShell(bot, before);
+    String title = titleOf(popup);
+    closeShell(bot, popup);
+    return title;
+  }
+
+  // ------------------------------------------------------------------ 
assertions
+
+  private void assertHopExists(WorkflowMeta meta, ActionMeta from, ActionMeta 
to) {
+    assertNotNull(
+        meta.findWorkflowHop(from, to),
+        "the gesture should have created the hop " + from + " → " + to);
+  }
+
+  private void assertNoHop(WorkflowMeta meta, ActionMeta from, ActionMeta to) {
+    assertNull(meta.findWorkflowHop(from, to), "the abandoned gesture must not 
create a hop");
+  }
+
+  private void assertNoDialog(String dialogTitle) {
+    assertNull(dialogTitle, "no dialog may open here, but one did");
+  }
+
+  private void assertNoFailures() {
+    assertTrue(swallowed.isEmpty(), "the canvas must not throw, but got " + 
swallowed);
+  }
+
+  private static void assertDidNotMove(Point was, ActionMeta action) {
+    assertEquals(
+        was.toString(), action.getLocation().toString(), "the action followed 
the mouse pointer");
+  }
+
+  /** Everything the graph remembers about a gesture has to be back to its 
initial value. */
+  private static void assertCanvasIsIdle(HopGuiWorkflowGraph graph) {
+    Map<String, Object> idle = new LinkedHashMap<>();
+    idle.put("startHopAction", null);
+    idle.put("endHopAction", null);
+    idle.put("endHopLocation", null);
+    idle.put("hopCandidate", null);
+    idle.put("currentAction", null);
+    idle.put("selectedAction", null);
+    idle.put("selectedActions", null);
+    idle.put("selectionRegion", null);
+    idle.put("splitHop", false);
+    idle.put("dragSelection", false);
+    idle.put("avoidContextDialog", false);
+    idle.put("ignoreNextClick", false);
+    idle.put("lastButton", 0);
+    assertGraphState(graph, idle);
+  }
+
+  // ------------------------------------------------------------------ scene
+
+  /** What a test needs to aim at: the canvas, its scale and the interesting 
graph coordinates. */
+  private record Spots(
+      Canvas canvas,
+      double scale,
+      Point source,
+      Point target,
+      Point emptyA,
+      Point emptyB,
+      Point emptyC) {}
+
+  @FunctionalInterface
+  private interface CanvasTest {
+    void run(SWTBot bot, HopGuiWorkflowGraph graph, WorkflowMeta workflowMeta, 
Spots spots);
+  }
+
+  private void onCanvas(CanvasTest test) {
+    AtomicReference<HopGuiWorkflowGraph> graphRef = new AtomicReference<>();
+    AtomicReference<WorkflowMeta> metaRef = new AtomicReference<>();
+
+    withScene(
+        shell -> {
+          shell.setSize(1000, 700);
+          // The workflow graph gives itself GridData, so its parent lays out 
with a GridLayout.
+          shell.setLayout(new GridLayout(1, false));
+          // Keep the single click on the canvas synchronous and deterministic.
+          PropsUi.getInstance().setUseDoubleClickOnCanvas(false);
+
+          WorkflowMeta workflowMeta = buildWorkflow();
+          metaRef.set(workflowMeta);
+          graphRef.set(
+              new HopGuiWorkflowGraph(
+                  shell,
+                  hopGui(),
+                  new ExplorerPerspective(),
+                  workflowMeta,
+                  new HopWorkflowFileType<>()));
+          attachKeyboardShortcuts(shell);
+        },
+        bot -> {
+          HopGuiWorkflowGraph graph = graphRef.get();
+          WorkflowMeta workflowMeta = metaRef.get();
+          test.run(bot, graph, workflowMeta, aim(bot, graph, workflowMeta));
+        });
+  }
+
+  /** Locates both icons and three spots of empty canvas, clear of the icons 
and of the minimap. */
+  private Spots aim(SWTBot bot, HopGuiWorkflowGraph graph, WorkflowMeta 
workflowMeta) {
+    Canvas canvas = onUi(graph::getCanvas);
+    double scale = canvasToGraphScale(graph);
+    AreaLookup lookup = graph::getVisibleAreaOwner;
+
+    Point source =
+        awaitIcon(
+            bot,
+            lookup,
+            AreaOwner.AreaType.ACTION_ICON,
+            workflowMeta.findAction(SOURCE_ACTION),
+            SOURCE_LOCATION);
+    Point target =
+        awaitIcon(
+            bot,
+            lookup,
+            AreaOwner.AreaType.ACTION_ICON,
+            workflowMeta.findAction(TARGET_ACTION),
+            TARGET_LOCATION);
+
+    Rectangle bounds = onUi(canvas::getBounds);
+    Point visible = onUi(() -> graph.screen2real(bounds.width, bounds.height));
+    Point emptyA = new Point((int) (visible.x * 0.50), (int) (visible.y * 
0.45));
+    Point emptyB = new Point((int) (visible.x * 0.30), (int) (visible.y * 
0.60));
+    Point emptyC = new Point((int) (visible.x * 0.20), (int) (visible.y * 
0.72));
+    assertEmptyCanvas(lookup, emptyA, emptyB, emptyC);
+
+    return new Spots(canvas, scale, source, target, emptyA, emptyB, emptyC);
+  }
+
+  /** Two actions, far enough apart to aim at either icon, with no hop between 
them yet. */
+  private static WorkflowMeta buildWorkflow() {
+    WorkflowMeta workflowMeta = new WorkflowMeta();
+    workflowMeta.setName("hop-creation");
+    workflowMeta.addAction(action(SOURCE_ACTION, SOURCE_LOCATION));
+    workflowMeta.addAction(action(TARGET_ACTION, TARGET_LOCATION));
+    return workflowMeta;
+  }
+
+  private static ActionMeta action(String name, Point location) {
+    ActionMeta actionMeta = new ActionMeta(new TestAction(name));
+    actionMeta.setLocation(location.x, location.y);
+    return actionMeta;
+  }
+
+  /** The smallest possible action: the canvas only needs a name, a location 
and an icon slot. */
+  private static class TestAction extends ActionBase {
+    TestAction(String name) {
+      super(name, "");
+    }
+
+    @Override
+    public Result execute(Result prevResult, int nr) {
+      return prevResult;
+    }
+
+    @Override
+    public Object clone() {
+      return new TestAction(getName());
+    }
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
index f957c93e87..8eee4e91ed 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
@@ -1020,12 +1020,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
       // go away.
       //
       if (startHopTransform != null) {
-        canvas.setData("mode", "null");
-        canvas.setData(START_HOP_NODE, null);
-        startHopTransform = null;
-        endHopLocation = null;
-        candidate = null;
-        lastClick = null;
+        cancelHopCandidate();
         avoidContextDialog = true;
         redraw();
         return;
@@ -1171,6 +1166,17 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
       }
     }
 
+    // A hop candidate released anywhere but on a transform is abandoned. 
Cancel it here: nothing
+    // below completes it, so without this the canvas keeps drawing a hop 
nobody is drawing any
+    // more and the next click still works on a half-finished gesture.
+    //
+    if (startHopTransform != null && pipelineMeta.getTransform(real.x, real.y, 
iconSize) == null) {
+      cancelHopCandidate();
+      lastButton = 0;
+      redraw();
+      return;
+    }
+
     // Special cases...
     //
     if (areaOwner != null && areaOwner.getAreaType() != null) {
@@ -1399,6 +1405,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     if (avoidContextDialog) {
       avoidContextDialog = false;
       selectionRegion = null;
+      lastButton = 0;
       return;
     }
 
@@ -2600,8 +2607,27 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     return canvas != null && !canvas.isDisposed() && canvas.setFocus();
   }
 
+  /**
+   * Drop the hop that was being drawn. Every field the gesture touched goes 
back to its initial
+   * value, including the transform the mouse went down on: a stale one makes 
the next mouse-up look
+   * like a click on that transform.
+   */
+  private void cancelHopCandidate() {
+    canvas.setData("mode", "null");
+    canvas.setData(START_HOP_NODE, null);
+    startHopTransform = null;
+    endHopTransform = null;
+    endHopLocation = null;
+    candidate = null;
+    candidateHopType = null;
+    startErrorHopTransform = false;
+    forbiddenTransform = null;
+    currentTransform = null;
+  }
+
   public void clearSettings() {
     selectedTransform = null;
+    currentTransform = null;
     forbiddenTransform = null;
     selectedNote = null;
     selectedTransforms = null;
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
index bf15ca081b..3b922f0455 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
@@ -723,6 +723,11 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
             // candidate hop, but we need to ignore this click to not start a 
drag operation
             if (hopCandidate != null) {
               addCandidateAsHop();
+              // The hop completes on mouseDown and clears startHopAction; 
without this, the
+              // following mouseUp would look like a plain action click and 
open the context dialog.
+              if (startHopAction == null) {
+                avoidContextDialog = true;
+              }
             }
           } else if (event.button == 2 || (event.button == 1 && shift)) {
             // SHIFT CLICK is start of drag to create a new hop
@@ -890,12 +895,8 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
       // go away.
       //
       if (startHopAction != null) {
-        canvas.setData("mode", "null");
-        canvas.setData(START_HOP_NODE, null);
-        startHopAction = null;
-        hopCandidate = null;
-        endHopLocation = null;
-        lastClick = null;
+        cancelHopCandidate();
+        avoidContextDialog = true;
         redraw();
         return;
       }
@@ -1033,6 +1034,17 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
       }
     }
 
+    // A hop candidate released anywhere but on an action is abandoned. Cancel 
it here: nothing
+    // below completes it, so without this the canvas keeps drawing a hop 
nobody is drawing any
+    // more and the next click still works on a half-finished gesture.
+    //
+    if (startHopAction != null && workflowMeta.getAction(real.x, real.y, 
iconSize) == null) {
+      cancelHopCandidate();
+      lastButton = 0;
+      redraw();
+      return;
+    }
+
     // Quick new hop option? (drag from one action to another)
     //
     if (areaOwner != null && areaOwner.getAreaType() != null) {
@@ -1042,6 +1054,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
             // Mouse up while drawing a hop candidate
             addCandidateAsHop();
             redraw();
+            return;
           }
           break;
         case ACTION_NAME:
@@ -1257,6 +1270,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     if (avoidContextDialog) {
       avoidContextDialog = false;
       selectionRegion = null;
+      lastButton = 0;
       return;
     }
 
@@ -2176,8 +2190,26 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     }
   }
 
+  /**
+   * Drop the hop that was being drawn. Every field the gesture touched goes 
back to its initial
+   * value, including the action the mouse went down on: a stale one makes the 
next mouse-up look
+   * like a click on that action.
+   */
+  private void cancelHopCandidate() {
+    canvas.setData("mode", "null");
+    canvas.setData(START_HOP_NODE, null);
+    startHopAction = null;
+    endHopAction = null;
+    endHopLocation = null;
+    hopCandidate = null;
+    forbiddenAction = null;
+    currentAction = null;
+  }
+
   public void clearSettings() {
     selectedAction = null;
+    currentAction = null;
+    endHopLocation = null;
     selectedNote = null;
     selectedActions = null;
     selectedNotes = null;

Reply via email to